feat(analytics): enhance histogram and box plot charts with log scaling and unit formatting
This commit is contained in:
parent
bed925ef4c
commit
eacf024057
2 changed files with 84 additions and 22 deletions
|
|
@ -9,7 +9,6 @@ import {
|
|||
CommandBar,
|
||||
CommandBarGroup,
|
||||
HistogramChart,
|
||||
MetaCluster,
|
||||
PageHeader,
|
||||
Panel,
|
||||
Select,
|
||||
|
|
@ -284,23 +283,15 @@ export const Analytics: Component = () => {
|
|||
</div>
|
||||
|
||||
<div class="ui-section-grid analytics__grid--spread-wide">
|
||||
<Panel title="Response Length Distribution" description="Histogram of completion token lengths across the selected window.">
|
||||
<MetaCluster
|
||||
items={[
|
||||
{ key: 'Metric', value: 'completion_tokens' },
|
||||
{ key: 'Scale', value: 'Log' },
|
||||
]}
|
||||
<Panel title="Response Length Distribution" description="Log-scaled completion_tokens histogram across the selected window.">
|
||||
<HistogramChart
|
||||
data={histogram() ?? []}
|
||||
xTickUnit="tok"
|
||||
yTickUnit="req"
|
||||
/>
|
||||
<HistogramChart data={histogram() ?? []} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Daily Response Length Spread" description="Completion token box plot by day using min / q1 / median / q3 / max summary.">
|
||||
<MetaCluster
|
||||
items={[
|
||||
{ key: 'Scale', value: 'Log' },
|
||||
{ key: 'Outliers', value: 'Hidden in this view' },
|
||||
]}
|
||||
/>
|
||||
<Panel title="Daily Response Length Spread" description="Log-scaled daily completion_tokens spread; outliers are hidden.">
|
||||
<BoxPlotChart data={boxPlot() ?? []} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ function formatCompactNumber(value: number): string {
|
|||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
function formatNumberWithUnit(value: number, unit?: string): string {
|
||||
const formatted = formatCompactNumber(value);
|
||||
return unit ? `${formatted} ${unit}` : formatted;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
|
@ -107,6 +112,58 @@ function getSymlogMax(value: number): number {
|
|||
return value <= 0 ? 1 : value * 1.1;
|
||||
}
|
||||
|
||||
function roundSymlogTick(value: number, max: number): number {
|
||||
if (value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value < 10) {
|
||||
return Math.min(max, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
const power = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / power;
|
||||
const niceNormalized = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return Math.min(max, niceNormalized * power);
|
||||
}
|
||||
|
||||
function getSymlogTicks(maxValue: number, intervals: number = 4): number[] {
|
||||
const max = Math.max(1, Math.round(maxValue));
|
||||
const transformedMax = Math.log1p(max);
|
||||
const safeIntervals = Math.max(2, intervals);
|
||||
const ticks = new Set<number>();
|
||||
|
||||
for (let index = 0; index <= safeIntervals; index += 1) {
|
||||
const rawTick = index === safeIntervals ? max : Math.expm1((transformedMax / safeIntervals) * index);
|
||||
ticks.add(roundSymlogTick(rawTick, max));
|
||||
}
|
||||
|
||||
return Array.from(ticks).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function getSymlogTicksInRange(minValue: number, maxValue: number, intervals: number = 4): number[] {
|
||||
const min = Math.max(0, minValue);
|
||||
const max = Math.max(min + 1, Math.round(maxValue));
|
||||
const transformedMin = Math.log1p(min);
|
||||
const transformedMax = Math.log1p(max);
|
||||
const safeIntervals = Math.max(2, intervals);
|
||||
const ticks = new Set<number>();
|
||||
|
||||
for (let index = 0; index <= safeIntervals; index += 1) {
|
||||
const rawTick = index === 0
|
||||
? min
|
||||
: index === safeIntervals
|
||||
? max
|
||||
: Math.expm1(transformedMin + ((transformedMax - transformedMin) / safeIntervals) * index);
|
||||
const tick = roundSymlogTick(rawTick, max);
|
||||
if (tick >= min && tick <= max) {
|
||||
ticks.add(tick);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ticks).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function getDateTicks(values: Date[], width: number): Date[] {
|
||||
if (values.length <= 7) {
|
||||
return values;
|
||||
|
|
@ -637,6 +694,8 @@ export function ComboChart(props: ComboChartProps) {
|
|||
interface HistogramChartProps {
|
||||
data: Array<{ bin_start: number; bin_end: number; count: number }>;
|
||||
height?: number;
|
||||
xTickUnit?: string;
|
||||
yTickUnit?: string;
|
||||
}
|
||||
|
||||
export function HistogramChart(props: HistogramChartProps) {
|
||||
|
|
@ -646,23 +705,34 @@ export function HistogramChart(props: HistogramChartProps) {
|
|||
return readChartTheme();
|
||||
});
|
||||
const dimensions = createMemo(() => buildChartDimensions(env.width(), props.height ?? 200, 20));
|
||||
const xScale = createMemo(() => {
|
||||
const xDomain = createMemo(() => {
|
||||
const min = d3.min(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.bin_start) ?? 0;
|
||||
const max = d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.bin_end) ?? 1;
|
||||
return d3.scaleSymlog().domain([Math.max(0, min), getSymlogMax(max)]).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
|
||||
return {
|
||||
min: Math.max(0, min),
|
||||
max,
|
||||
};
|
||||
});
|
||||
const xScale = createMemo(() => {
|
||||
const domain = xDomain();
|
||||
return d3.scaleSymlog().domain([domain.min, getSymlogMax(domain.max)]).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
|
||||
});
|
||||
const yScale = createMemo(() => {
|
||||
const max = d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.count) ?? 0;
|
||||
return d3.scaleSymlog().domain([0, getSymlogMax(max)]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
});
|
||||
const xTicks = createMemo(() => xScale().ticks(Math.max(2, Math.floor(getInnerWidth(dimensions()) / 100))));
|
||||
const yTicks = createMemo(() => getSymlogTicks(d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.count) ?? 0));
|
||||
const xTicks = createMemo(() => {
|
||||
const domain = xDomain();
|
||||
return getSymlogTicksInRange(domain.min, domain.max);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="ui-chart">
|
||||
<div class="ui-chart__frame" ref={env.rootRef}>
|
||||
<Show when={env.width() > 0 && props.data.length > 0} fallback={<div class="ui-chart__empty">No histogram data available.</div>}>
|
||||
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Histogram">
|
||||
<For each={yScale().ticks(4)}>
|
||||
<For each={yTicks()}>
|
||||
{(tick) => (
|
||||
<>
|
||||
<line
|
||||
|
|
@ -674,7 +744,7 @@ export function HistogramChart(props: HistogramChartProps) {
|
|||
stroke-dasharray="3 4"
|
||||
/>
|
||||
<text x={dimensions().marginLeft - 8} y={yScale()(tick)} fill={theme().textMuted} text-anchor="end" dominant-baseline="middle" class="ui-chart__tick">
|
||||
{formatCompactNumber(tick)}
|
||||
{formatNumberWithUnit(tick, props.yTickUnit)}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -703,7 +773,7 @@ export function HistogramChart(props: HistogramChartProps) {
|
|||
text-anchor="middle"
|
||||
class="ui-chart__tick"
|
||||
>
|
||||
{formatCompactNumber(tick)}
|
||||
{formatNumberWithUnit(tick, props.xTickUnit)}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -740,13 +810,14 @@ export function BoxPlotChart(props: BoxPlotChartProps) {
|
|||
const max = d3.max(points(), (point: ParsedBoxPlotDatum) => point.max) ?? 0;
|
||||
return d3.scaleSymlog().domain([0, getSymlogMax(max)]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
});
|
||||
const yTicks = createMemo(() => getSymlogTicks(d3.max(points(), (point: ParsedBoxPlotDatum) => point.max) ?? 0));
|
||||
|
||||
return (
|
||||
<div class="ui-chart">
|
||||
<div class="ui-chart__frame" ref={env.rootRef}>
|
||||
<Show when={env.width() > 0 && points().length > 0} fallback={<div class="ui-chart__empty">No box plot data available.</div>}>
|
||||
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Box plot chart">
|
||||
<For each={yScale().ticks(4)}>
|
||||
<For each={yTicks()}>
|
||||
{(tick) => (
|
||||
<>
|
||||
<line
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue