feat(analytics): add logarithmic scale option for daily volume chart

This commit is contained in:
Kyush 2026-05-12 18:20:25 +09:00
commit 43664819d4
2 changed files with 96 additions and 24 deletions

View file

@ -37,6 +37,7 @@ export const Analytics: Component = () => {
const [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
const [refreshInterval, setRefreshInterval] = createSignal('10');
const [refreshKey, setRefreshKey] = createSignal(0);
const [dailyVolumeScale, setDailyVolumeScale] = createSignal<'linear' | 'log'>('linear');
const filters = createMemo(() => ({
days: Number(days()),
@ -234,14 +235,25 @@ export const Analytics: Component = () => {
title="Daily Volume"
description="Daily request and token totals on shared time axis."
actions={
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
<div style="display: flex; align-items: center; gap: 16px;">
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
<Select
label="Scale"
value={dailyVolumeScale()}
options={[
{ value: 'linear', label: 'Linear' },
{ value: 'log', label: 'Log' },
]}
onChange={setDailyVolumeScale}
/>
</div>
}
>
<TimeSeriesChart
@ -258,6 +270,7 @@ export const Analytics: Component = () => {
formatLeftValue={(value) => new Intl.NumberFormat('en-US').format(Math.round(value))}
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
tooltipTitle="Daily request and token totals"
yScaleType={dailyVolumeScale()}
/>
</Panel>

View file

@ -214,6 +214,7 @@ interface TimeSeriesChartProps {
formatLeftValue?: (value: number) => string;
formatRightValue?: (value: number) => string;
tooltipTitle?: string;
yScaleType?: 'linear' | 'log';
}
interface ChartLegendProps {
@ -287,32 +288,82 @@ export function TimeSeriesChart(props: TimeSeriesChartProps) {
const leftSeries = createMemo(() => visibleSeries().filter((series) => series.axis !== 'right'));
const rightSeries = createMemo(() => visibleSeries().filter((series) => series.axis === 'right'));
const isLogScale = () => props.yScaleType === 'log';
const leftScale = createMemo(() => {
const maxValue =
d3.max(points(), (point: ParsedTimeSeriesDatum) =>
d3.max(leftSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
) ?? 0;
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
const values = points().flatMap((point: ParsedTimeSeriesDatum) =>
leftSeries().map((series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
);
const maxValue = d3.max(values) ?? 0;
const range = [dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop];
if (isLogScale()) {
const positiveValues = values.filter((v) => v > 0);
const minLog = positiveValues.length > 0 ? d3.min(positiveValues) ?? 1 : 1;
const maxLog = maxValue === 0 ? 10 : maxValue * 1.1;
return d3.scaleLog().domain([minLog, maxLog]).range(range);
}
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range(range);
});
const rightScale = createMemo(() => {
const maxValue =
d3.max(points(), (point: ParsedTimeSeriesDatum) =>
d3.max(rightSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
) ?? 0;
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
const values = points().flatMap((point: ParsedTimeSeriesDatum) =>
rightSeries().map((series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
);
const maxValue = d3.max(values) ?? 0;
const range = [dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop];
if (isLogScale()) {
const positiveValues = values.filter((v) => v > 0);
const minLog = positiveValues.length > 0 ? d3.min(positiveValues) ?? 1 : 1;
const maxLog = maxValue === 0 ? 10 : maxValue * 1.1;
return d3.scaleLog().domain([minLog, maxLog]).range(range);
}
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range(range);
});
const leftTicks = createMemo(() => leftScale().ticks(4));
const rightTicks = createMemo(() => rightSeries().length > 0 ? rightScale().ticks(4) : []);
const leftTicks = createMemo(() => {
if (isLogScale()) {
const maxValue = d3.max(points(), (point: ParsedTimeSeriesDatum) =>
d3.max(leftSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
) ?? 0;
return getSymlogTicks(maxValue);
}
return leftScale().ticks(4);
});
const rightTicks = createMemo(() => {
if (rightSeries().length === 0) {
return [];
}
if (isLogScale()) {
const maxValue = d3.max(points(), (point: ParsedTimeSeriesDatum) =>
d3.max(rightSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
) ?? 0;
return getSymlogTicks(maxValue);
}
return rightScale().ticks(4);
});
const xTicks = createMemo(() => xScale().ticks(Math.max(2, Math.floor(getInnerWidth(dimensions()) / 120))));
const dateTicks = createMemo(() => getDateTicks(points().map((point) => point.parsedDate), getInnerWidth(dimensions())));
const linePath = (series: TimeSeriesChartSeries) =>
d3.line()
.defined((point: ParsedTimeSeriesDatum) => typeof point[series.key] === 'number')
const linePath = (series: TimeSeriesChartSeries) => {
const scale = series.axis === 'right' ? rightScale() : leftScale();
return d3.line()
.defined((point: ParsedTimeSeriesDatum) => {
const value = Number(point[series.key] ?? 0);
if (!Number.isFinite(value)) {
return false;
}
if (isLogScale() && value <= 0) {
return false;
}
return true;
})
.x((point: ParsedTimeSeriesDatum) => xScale()(point.parsedDate))
.y((point: ParsedTimeSeriesDatum) => (series.axis === 'right' ? rightScale() : leftScale())(Number(point[series.key] ?? 0)))(points()) ?? '';
.y((point: ParsedTimeSeriesDatum) => scale(Number(point[series.key] ?? 0)))(points()) ?? '';
};
const hoveredPoint = createMemo(() => {
const index = hoverIndex();
@ -326,7 +377,15 @@ export function TimeSeriesChart(props: TimeSeriesChartProps) {
...series,
value: Number(hoveredPoint()?.[series.key] ?? 0),
}))
.filter((series) => Number.isFinite(series.value))
.filter((series) => {
if (!Number.isFinite(series.value)) {
return false;
}
if (isLogScale() && series.value <= 0) {
return false;
}
return true;
})
: []
);