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

View file

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