diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index e0abc19..84b6d4b 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -1173,55 +1173,107 @@ function TrafficBars({ compactLabels = false, data, maxLabels = 6, rotateLabels } function TrafficTrendChart({ data, title }: TrafficTrendChartProps) { - const requestSegments = toPolylineSegments(data.map((item) => ({ missing: item.missing, value: item.primary })), { - fillMissingWithZero: true, - }) - const costSegments = toPolylineSegments(data.map((item) => ({ missing: item.missing, value: item.secondary })), { - fillMissingWithZero: true, - }) - const cacheSegments = toPolylineSegments(data.map((item) => ({ missing: item.missing, value: item.tertiary })), { - fillMissingWithZero: true, - }) + const requestSeries = buildLineChartSeries( + data.map((item) => ({ missing: item.missing, value: item.primary })), + { fillMissingWithZero: true }, + ) + const costSeries = buildLineChartSeries( + data.map((item) => ({ missing: item.missing, value: item.secondary })), + { fillMissingWithZero: true }, + ) + const cacheSeries = buildLineChartSeries( + data.map((item) => ({ missing: item.missing, value: item.tertiary })), + { fillMissingWithZero: true }, + ) + const ticks = buildLineChartTicks(data.map((item) => item.day)) + const hoverTargets = buildLineChartHoverTargets(data.length) return ( - + {title} - - - - {requestSegments.map((points, index) => ( + + + + {requestSeries.segments.map((points, index) => ( ))} - {costSegments.map((points, index) => ( + {costSeries.segments.map((points, index) => ( ))} - {cacheSegments.map((points, index) => ( + {cacheSeries.segments.map((points, index) => ( ))} + {requestSeries.points.map((point) => ( + + ))} + {costSeries.points.map((point) => ( + + ))} + {cacheSeries.points.map((point) => ( + + ))} + {hoverTargets.map((target, index) => ( + + {formatTrafficTooltip(data[index])} + + + ))} + {ticks.map((tick) => ( + + {formatBucketLabel(tick.day)} + + {formatLineAxisLabel(tick.day)} + + + ))} ) } function LineChart({ data, title }: LineChartProps) { - const primarySegments = toPolylineSegments(data.map((item) => ({ missing: item.missing, value: item.primary })), { - fillMissingWithZero: true, - }) - const secondarySegments = toPolylineSegments(data.map((item) => ({ missing: item.missing, value: item.secondary })), { - fillMissingWithZero: true, - }) + const primarySeries = buildLineChartSeries( + data.map((item) => ({ missing: item.missing, value: item.primary })), + { fillMissingWithZero: true }, + ) + const secondarySeries = buildLineChartSeries( + data.map((item) => ({ missing: item.missing, value: item.secondary })), + { fillMissingWithZero: true }, + ) + const ticks = buildLineChartTicks(data.map((item) => item.day)) + const hoverTargets = buildLineChartHoverTargets(data.length) return ( - + {title} - - - - {primarySegments.map((points, index) => ( + + + + {primarySeries.segments.map((points, index) => ( ))} - {secondarySegments.map((points, index) => ( + {secondarySeries.segments.map((points, index) => ( ))} + {primarySeries.points.map((point) => ( + + ))} + {secondarySeries.points.map((point) => ( + + ))} + {hoverTargets.map((target, index) => ( + + {formatInputOutputTooltip(data[index])} + + + ))} + {ticks.map((tick) => ( + + {formatBucketLabel(tick.day)} + + {formatLineAxisLabel(tick.day)} + + + ))} ) } @@ -1626,6 +1678,29 @@ function formatDayShort(value: string, compact = false) { }).format(date) } +function formatLineAxisLabel(value: string) { + const isTimestamp = value.includes('T') + const date = isTimestamp ? new Date(value) : new Date(`${value}T00:00:00Z`) + + if (isTimestamp) { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + timeZone: DASHBOARD_TIME_ZONE, + }) + .format(date) + .replace(/\s/g, '') + .toLowerCase() + } + + return new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + }) + .format(date) + .toLowerCase() +} + function shouldRenderTick(index: number, total: number, maxLabels = 6) { if (total <= maxLabels) return true if (index === 0 || index === total - 1) return true @@ -1676,8 +1751,7 @@ function toPolylineSegments( points: Array<{ missing?: boolean; value: number }>, options?: { fillMissingWithZero?: boolean }, ) { - const presentValues = points.filter((point) => !point.missing).map((point) => point.value) - const maxValue = Math.max(...presentValues, 1) + const maxValue = getLineChartMaxValue(points) const segments: string[] = [] let currentSegment: string[] = [] @@ -1690,10 +1764,8 @@ function toPolylineSegments( return } - const x = 16 + index * (288 / Math.max(points.length - 1, 1)) const value = point.missing && options?.fillMissingWithZero ? 0 : point.value - const y = 130 - (value / maxValue) * 112 - currentSegment.push(`${x},${y}`) + currentSegment.push(`${getLineChartX(index, points.length)},${getLineChartY(value, maxValue)}`) }) if (currentSegment.length >= 2) { @@ -1703,6 +1775,99 @@ function toPolylineSegments( return segments } +function buildLineChartSeries( + points: Array<{ missing?: boolean; value: number }>, + options?: { fillMissingWithZero?: boolean }, +) { + const maxValue = getLineChartMaxValue(points) + + return { + points: points.flatMap((point, index) => { + if (point.missing) { + return [] + } + + return [{ + index, + value: point.value, + x: getLineChartX(index, points.length), + y: getLineChartY(point.value, maxValue), + }] + }), + segments: toPolylineSegments(points, options), + } +} + +function buildLineChartTicks(days: string[], maxLabels = 6) { + return days.flatMap((day, index) => { + if (!shouldRenderTick(index, days.length, maxLabels)) { + return [] + } + + return [{ + day, + x: getLineChartX(index, days.length), + }] + }) +} + +function buildLineChartHoverTargets(total: number) { + if (total === 0) { + return [] + } + + const slotWidth = total > 1 ? LINE_CHART_WIDTH / (total - 1) : LINE_CHART_WIDTH + + return Array.from({ length: total }, (_, index) => ({ + width: slotWidth, + x: Math.max(0, Math.min(320 - slotWidth, getLineChartX(index, total) - slotWidth / 2)), + })) +} + +function getLineChartMaxValue(points: Array<{ missing?: boolean; value: number }>) { + const presentValues = points.filter((point) => !point.missing).map((point) => point.value) + return Math.max(...presentValues, 1) +} + +function getLineChartX(index: number, total: number) { + return LINE_CHART_LEFT + index * (LINE_CHART_WIDTH / Math.max(total - 1, 1)) +} + +function getLineChartY(value: number, maxValueOrPoints: number | Array<{ missing?: boolean; value: number }>) { + const maxValue = + typeof maxValueOrPoints === 'number' + ? maxValueOrPoints + : getLineChartMaxValue(maxValueOrPoints) + + return LINE_CHART_BOTTOM - (value / maxValue) * LINE_CHART_HEIGHT +} + +function formatTrafficTooltip(point: TrafficTrendChartProps['data'][number]) { + return [ + formatBucketLabel(point.day), + `Requests: ${point.primary.toLocaleString('en-US')}`, + `Allocated cost: ${formatCurrency(point.secondary / 10)}`, + `Cached share: ${point.tertiary.toFixed(1)}%`, + ].join('\n') +} + +function formatInputOutputTooltip(point: LineChartProps['data'][number]) { + return [ + formatBucketLabel(point.day), + `Input tokens: ${point.primary.toLocaleString('en-US')}`, + `Output tokens: ${point.secondary.toLocaleString('en-US')}`, + ].join('\n') +} + +const LINE_CHART_LEFT = 16 +const LINE_CHART_RIGHT = 304 +const LINE_CHART_TOP = 16 +const LINE_CHART_BOTTOM = 126 +const LINE_CHART_LABEL_Y = 140 +const LINE_CHART_VIEWBOX_HEIGHT = 156 +const LINE_CHART_WIDTH = LINE_CHART_RIGHT - LINE_CHART_LEFT +const LINE_CHART_HEIGHT = LINE_CHART_BOTTOM - LINE_CHART_TOP + const toneClassNameMap = { negative: 'text-rose-600', neutral: 'text-slate-500', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 8028015..ceb607a 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -601,8 +601,8 @@ width: 100%; max-width: 100%; height: auto; - min-height: 228px; - aspect-ratio: 320 / 150; + min-height: 236px; + aspect-ratio: 320 / 156; } @media (max-width: 768px) { @@ -644,7 +644,7 @@ } .line-chart { - min-height: 188px; + min-height: 196px; } } @@ -693,6 +693,34 @@ stroke: var(--chart-red); } +.chart-point { + fill: var(--chart-ink); + stroke: #fff; + stroke-width: 1.25; +} + +.chart-point-muted { + fill: var(--chart-violet); +} + +.chart-point-grey { + fill: var(--chart-grey); +} + +.chart-point-red { + fill: var(--chart-red); +} + +.chart-hover-target { + fill: transparent; +} + +.chart-axis-label { + fill: #334155; + font-size: 9px; + font-weight: 600; +} + .panel-card-signals { min-height: 0; }