fix(frontend): improve observability chart continuity

This commit is contained in:
Zyy
2026-08-31 13:09:24 +08:00
parent c46f98cd2b
commit 93f1a82d3f
8 changed files with 377 additions and 102 deletions
@@ -0,0 +1,121 @@
import type { LineSeriesOption } from 'echarts'
type TemporalValue = number | null | undefined
interface GapRun {
startIndex: number
endIndex: number
}
interface GapBridgeOptions {
name: string
color: string
xAxisIndex?: number
yAxisIndex?: number
width?: number
z?: number
maxGapBuckets?: number
}
const defaultShortGapBuckets = 2
export function sampleGapBridgeSeries(
values: TemporalValue[],
options: GapBridgeOptions,
): LineSeriesOption[] {
const data = values.map(normalizeValue)
const maxGapBuckets = options.maxGapBuckets ?? defaultShortGapBuckets
const series: LineSeriesOption[] = []
for (const [runIndex, run] of boundedGapRuns(data)
.filter(run => gapLength(run) <= maxGapBuckets)
.entries()) {
const beforeIndex = run.startIndex - 1
const afterIndex = run.endIndex + 1
const beforeValue = data[beforeIndex]
const afterValue = data[afterIndex]
const bridge = Array.from<number | null>({ length: data.length }).fill(null)
if (beforeValue == null || afterValue == null)
continue
const span = afterIndex - beforeIndex
for (let index = beforeIndex; index <= afterIndex; index += 1) {
const progress = (index - beforeIndex) / span
bridge[index] = beforeValue + (afterValue - beforeValue) * progress
}
series.push({
name: `${options.name}-gap-${runIndex}`,
type: 'line',
data: bridge,
connectNulls: false,
smooth: false,
showSymbol: false,
silent: true,
xAxisIndex: options.xAxisIndex ?? 0,
yAxisIndex: options.yAxisIndex ?? 0,
z: Math.max(0, (options.z ?? 2) - 1),
lineStyle: {
color: options.color,
type: 'dashed',
width: Math.max(1, (options.width ?? 2.2) - 0.7),
opacity: 0.32,
},
itemStyle: { color: options.color, opacity: 0 },
emphasis: { disabled: true },
tooltip: { show: false },
})
}
return series
}
export function requestActivityByBucket(
buckets: string[],
activity: Array<{ bucket: string, totalRequests: number }>,
) {
const requestsByBucket = new Map(
activity.map(point => [point.bucket, Math.max(0, point.totalRequests)]),
)
return buckets.map(bucket => (requestsByBucket.get(bucket) ?? 0) > 0)
}
export function zeroInactiveValues(
values: TemporalValue[],
active: boolean[],
) {
return values.map((value, index) => normalizeValue(value) ?? (active[index] === false ? 0 : null))
}
function normalizeValue(value: TemporalValue) {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function boundedGapRuns(values: Array<number | null>) {
return gapRuns(values.map(value => value == null)).filter(
run => run.startIndex > 0 && run.endIndex < values.length - 1,
)
}
function gapRuns(isGap: boolean[]) {
const runs: GapRun[] = []
let startIndex = -1
for (let index = 0; index <= isGap.length; index += 1) {
if (isGap[index] && startIndex < 0) {
startIndex = index
continue
}
if (isGap[index] || startIndex < 0)
continue
runs.push({ startIndex, endIndex: index - 1 })
startIndex = -1
}
return runs
}
function gapLength(run: GapRun) {
return run.endIndex - run.startIndex + 1
}
@@ -373,10 +373,10 @@ function aggregateUsageTrend(points: DashboardTrendPoint[]) {
)
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : 0
const successRateValue = requestsValue
? ((requestsValue - errorsValue) / requestsValue) * 100
: null
: 0
return {
...first,
@@ -392,12 +392,12 @@ function aggregateUsageTrend(points: DashboardTrendPoint[]) {
uncachedInputTokensValue,
effectiveTokens: formatCompactNumber(effectiveTokensValue),
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
cacheHitRate: formatDashboardRate(cacheHitRateValue),
cacheHitRateValue,
tokensValue: inputTokensValue + outputTokensValue,
errors: formatCompactNumber(errorsValue),
errorsValue,
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
successRate: `${successRateValue.toFixed(1)}%`,
successRateValue,
}
})
@@ -4,6 +4,7 @@ import type { dashboardTrendView, normalizeDashboardTrendKind } from './useDashb
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap'
import { chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
@@ -197,7 +198,7 @@ export function useRequestTrendChart(options: {
),
tooltipItem(
'缓存命中',
trendPointText(point, 'cacheHitRate'),
zeroAwareRateText(point, 'cacheHitRate', 'inputTokensValue'),
trendColor('缓存', '--cp-color-text-tertiary', '#94A3B8'),
),
tooltipItem('请求', point?.requests, palette.value.textSecondary),
@@ -235,19 +236,19 @@ export function useRequestTrendChart(options: {
if (name === '错误数')
return point?.errors ?? value
if (name === '成功率')
return point?.successRate ?? value
return zeroAwareRateText(point, 'successRate', 'requestsValue') ?? value
if (name === '总请求')
return point?.requests ?? value
return value
}
function getSeries() {
function getSeries(): Array<BarSeriesOption | LineSeriesOption> {
if (activeKind.value === 'usage') {
const cacheColor = trendColor('缓存', '--cp-color-text-tertiary', '#94A3B8')
const inputColor = trendColor('输入', '--cp-color-blue-solid', '#5983F4')
const outputColor = trendColor('输出', '--cp-color-green-solid', '#12B981')
return [
lineSeries('缓存', activeSeriesValues('cachedTokensValue'), cacheColor, {
lineSeries('缓存', seriesValues('cachedTokensValue'), cacheColor, {
area: true,
areaStartAlpha: '30',
areaEndAlpha: '08',
@@ -257,7 +258,7 @@ export function useRequestTrendChart(options: {
width: 1.25,
z: 1,
}),
lineSeries('输入', activeSeriesValues('uncachedInputTokensValue'), inputColor, {
lineSeries('输入', seriesValues('uncachedInputTokensValue'), inputColor, {
area: true,
areaStartAlpha: '2A',
areaEndAlpha: '05',
@@ -266,7 +267,7 @@ export function useRequestTrendChart(options: {
width: 2.3,
z: 3,
}),
lineSeries('输出', activeSeriesValues('outputTokensValue'), outputColor, {
lineSeries('输出', seriesValues('outputTokensValue'), outputColor, {
smooth: 0.24,
width: 2.1,
yAxisIndex: 1,
@@ -277,19 +278,19 @@ export function useRequestTrendChart(options: {
if (activeKind.value === 'latency') {
return [
lineSeries(
...gapLineSeries(
'总耗时 P95',
activeSeriesValues('latencyP95Ms'),
trendColor('总耗时', '--cp-color-orange-solid', '#F59E0B'),
{ lineType: 'dashed', smooth: 0.2, width: 1.8, z: 2 },
),
lineSeries(
...gapLineSeries(
'首字 P95',
activeSeriesValues('firstTokenP95Ms'),
trendColor('首字', '--cp-color-cyan-solid', '#13C2C2'),
{ area: true, smooth: 0.24, width: 2.5, z: 4 },
),
lineSeries(
...gapLineSeries(
'吞吐 P50',
activeSeriesValues('outputThroughputP50'),
trendColor('吞吐', '--cp-color-green-solid', '#12B981'),
@@ -304,9 +305,9 @@ export function useRequestTrendChart(options: {
seriesValues('errorsValue'),
trendColor('错误', '--cp-color-red-solid', '#EF4444'),
),
lineSeries(
...gapLineSeries(
'成功率',
seriesValues('successRateValue'),
activeSeriesValues('successRateValue'),
trendColor('成功', '--cp-color-green-solid', '#12B981'),
{ width: 2.4, yAxisIndex: 1 },
),
@@ -325,7 +326,7 @@ export function useRequestTrendChart(options: {
function activeSeriesValues(key: string) {
return points.value.map(point =>
Number(point.requestsValue) > 0 ? trendPointNumber(point, key) : null,
Number(point.requestsValue) > 0 ? trendPointNumber(point, key) : 0,
)
}
@@ -377,6 +378,26 @@ export function useRequestTrendChart(options: {
}
}
function gapLineSeries(
name: string,
data: (number | null)[],
color: string,
lineOptions: LineSeriesOptions = {},
) {
const primary = lineSeries(name, data, color, lineOptions)
return [
primary,
...sampleGapBridgeSeries(data, {
name,
color,
width: lineOptions.width,
xAxisIndex: lineOptions.xAxisIndex,
yAxisIndex: lineOptions.yAxisIndex,
z: lineOptions.z,
}),
]
}
function barSeries(
name: string,
data: (number | null)[],
@@ -490,7 +511,7 @@ function tooltipValue(source: unknown, key: string) {
function trendPointProperty(point: TrendPoint | undefined, key: string) {
if (!point || !(key in point))
return undefined
return (point as Record<string, unknown>)[key]
return (point as unknown as Record<string, unknown>)[key]
}
function trendPointText(point: TrendPoint | undefined, key: string) {
@@ -498,6 +519,17 @@ function trendPointText(point: TrendPoint | undefined, key: string) {
return typeof value === 'string' ? value : undefined
}
function zeroAwareRateText(
point: TrendPoint | undefined,
valueKey: string,
activityKey: string,
) {
const activity = trendPointProperty(point, activityKey)
if (typeof activity === 'number' && activity <= 0)
return '0.0%'
return trendPointText(point, valueKey)
}
function trendPointNumber(point: TrendPoint, key: string) {
const value = trendPointProperty(point, key)
return typeof value === 'number' && Number.isFinite(value) ? value : null
@@ -7,6 +7,10 @@ import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import {
requestActivityByBucket,
zeroInactiveValues,
} from '@/components/charts/timeSeriesGap'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
@@ -14,10 +18,11 @@ import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageGapAwareLineSeries,
usageLegend,
usageLineSeries,
usageTooltip,
usageTooltipContent,
usageTooltipItem,
usageValueAxis,
} from '../utils/chart'
import {
@@ -28,11 +33,13 @@ import {
} from '../utils/format'
type Cost = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['cost']
type Activity = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']['points']
type UsageChartPalette = ReturnType<typeof useChartPalette>['palette']['value']
const props = withDefaults(
defineProps<{
cost: Cost
activity: Activity
loading?: boolean
}>(),
{
@@ -43,6 +50,10 @@ const props = withDefaults(
const activeView = shallowRef('cost')
const { palette } = useChartPalette()
const points = computed(() => props.cost.points)
const requestActivity = computed(() => requestActivityByBucket(
points.value.map(point => point.bucket),
props.activity,
))
const hasNoCacheCost = computed(() =>
points.value.some(point => point.noCacheCost != null),
)
@@ -74,6 +85,7 @@ const hasData = computed(() => {
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const legend = legendNames()
const series = chartSeries(theme)
return {
animationDuration: 240,
@@ -95,7 +107,7 @@ const chartOption = computed<EChartsOption>(() => {
min: activeView.value === 'cache' ? 0 : undefined,
max: activeView.value === 'cache' ? 1 : undefined,
}),
series: chartSeries(theme),
series,
}
})
@@ -119,9 +131,12 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
const chartPoints = points.value
if (activeView.value === 'savings') {
return [
usageLineSeries(
...usageGapAwareLineSeries(
'缓存节省',
chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)),
zeroInactiveValues(
chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)),
requestActivity.value,
),
theme.success,
{ area: 'strong' },
),
@@ -130,12 +145,12 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
if (activeView.value === 'cache') {
return [
usageLineSeries(
...usageGapAwareLineSeries(
'缓存 Token 占比',
chartPoints.map(point => point.cachedTokenRate),
theme.normal,
),
usageLineSeries(
...usageGapAwareLineSeries(
'命中请求率',
chartPoints.map(point => point.cacheHitRequestRate),
theme.success,
@@ -144,18 +159,24 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
}
const series = [
usageLineSeries(
...usageGapAwareLineSeries(
'实际费用',
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
zeroInactiveValues(
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
requestActivity.value,
),
theme.success,
{ area: 'strong' },
),
]
if (hasNoCacheCost.value) {
series.push(
usageLineSeries(
...usageGapAwareLineSeries(
'无缓存费用',
chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)),
zeroInactiveValues(
chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)),
requestActivity.value,
),
theme.textMuted,
),
)
@@ -165,29 +186,48 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const point = points.value[tooltipIndex(rows[0])]
const pointIndex = tooltipIndex(rows[0])
const point = points.value[pointIndex]
if (!point)
return ''
const theme = palette.value
if (activeView.value === 'savings') {
return usageTooltipContent(palette.value, point.label, [
`缓存节省: ${formatUsd(point.cacheSavings)}`,
return usageTooltipContent(theme, point.label, [
usageTooltipItem(
'缓存节省',
formatUsd(costAmount(point.cacheSavings, pointIndex)),
theme.success,
),
])
}
if (activeView.value === 'cache') {
return usageTooltipContent(palette.value, point.label, [
`缓存 Token 占比: ${formatPercent(point.cachedTokenRate)}`,
`命中请求率: ${formatPercent(point.cacheHitRequestRate)}`,
return usageTooltipContent(theme, point.label, [
usageTooltipItem('缓存 Token 占比', formatPercent(point.cachedTokenRate), theme.normal),
usageTooltipItem('命中请求率', formatPercent(point.cacheHitRequestRate), theme.success),
])
}
const lines = [
`实际费用: ${formatUsd(point.estimatedCost)}`,
usageTooltipItem(
'实际费用',
formatUsd(costAmount(point.estimatedCost, pointIndex)),
theme.success,
),
]
if (point.noCacheCost != null)
lines.push(`无缓存费用: ${formatUsd(point.noCacheCost)}`)
return usageTooltipContent(palette.value, point.label, lines)
if (point.noCacheCost != null) {
lines.push(usageTooltipItem(
'无缓存费用',
formatUsd(costAmount(point.noCacheCost, pointIndex)),
theme.textMuted,
))
}
return usageTooltipContent(theme, point.label, lines)
}
function costAmount(value: string | null, pointIndex: number) {
return value ?? (requestActivity.value[pointIndex] === false ? '0' : null)
}
</script>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
import type { getUsageRecordInsightsOverview } from '@/api'
import { BarChart } from 'echarts/charts'
import { use } from 'echarts/core'
@@ -15,9 +15,11 @@ import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageGapAwareLineSeries,
usageLegend,
usageTooltip,
usageTooltipContent,
usageTooltipItem,
usageValueAxis,
} from '../utils/chart'
import { formatPercent } from '../utils/format'
@@ -60,6 +62,26 @@ const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const chartPoints = points.value
const activePointCount = chartPoints.filter(point => requestCount(point) > 0).length
const successRateSeries = usageGapAwareLineSeries(
'服务成功率',
chartPoints.map(serviceSuccessRate),
theme.success,
{ yAxisIndex: 1 },
)
const series: Array<BarSeriesOption | LineSeriesOption> = [
outcomeSeries('成功', chartPoints.map(point => point.successRequests), theme.success),
outcomeSeries('服务失败', chartPoints.map(point => point.failedRequests), theme.danger),
outcomeSeries('取消', chartPoints.map(point => point.cancelledRequests), theme.info),
outcomeSeries('未完成', chartPoints.map(point => point.incompleteRequests), theme.warning),
outcomeSeries('调用方错误', chartPoints.map(point => point.callerErrorRequests), theme.normal),
...successRateSeries.map((item, index) => index === 0
? {
...item,
symbol: activePointCount <= 16 ? 'circle' : 'none',
symbolSize: 4,
}
: item),
]
return {
animationDuration: 240,
@@ -85,25 +107,7 @@ const chartOption = computed<EChartsOption>(() => {
splitLine: false,
}),
],
series: [
outcomeSeries('成功', chartPoints.map(point => point.successRequests), theme.success),
outcomeSeries('服务失败', chartPoints.map(point => point.failedRequests), theme.danger),
outcomeSeries('取消', chartPoints.map(point => point.cancelledRequests), theme.info),
outcomeSeries('未完成', chartPoints.map(point => point.incompleteRequests), theme.warning),
outcomeSeries('调用方错误', chartPoints.map(point => point.callerErrorRequests), theme.normal),
{
name: '服务成功率',
type: 'line',
yAxisIndex: 1,
data: chartPoints.map(serviceSuccessRate),
connectNulls: true,
smooth: 0.25,
symbol: activePointCount <= 16 ? 'circle' : 'none',
symbolSize: 4,
lineStyle: { color: theme.success, width: 2.2 },
itemStyle: { color: theme.success },
},
],
series,
}
})
@@ -113,7 +117,9 @@ function requestCount(point: HealthPoint) {
function serviceSuccessRate(point: HealthPoint) {
const total = Math.max(0, point.successRequests) + Math.max(0, point.failedRequests)
return total > 0 ? Math.max(0, point.successRequests ?? 0) / total : null
if (total > 0)
return Math.max(0, point.successRequests ?? 0) / total
return requestCount(point) === 0 ? 0 : null
}
function outcomeSeries(name: string, data: number[], color: string) {
@@ -134,15 +140,20 @@ function formatTooltip(params: unknown) {
return ''
const successRate = serviceSuccessRate(point)
const theme = palette.value
return usageTooltipContent(palette.value, point.label, [
`请求量: ${formatCompactNumber(requestCount(point))}`,
`成功: ${formatCompactNumber(point.successRequests)}`,
`服务失败: ${formatCompactNumber(point.failedRequests)}`,
`取消: ${formatCompactNumber(point.cancelledRequests)}`,
`未完成: ${formatCompactNumber(point.incompleteRequests)}`,
`调用方错误: ${formatCompactNumber(point.callerErrorRequests)}`,
`服务成功率: ${successRate == null ? '无服务结果' : formatPercent(successRate)}`,
return usageTooltipContent(theme, point.label, [
usageTooltipItem('请求量', formatCompactNumber(requestCount(point)), theme.textSecondary),
usageTooltipItem('成功', formatCompactNumber(point.successRequests), theme.success),
usageTooltipItem('服务失败', formatCompactNumber(point.failedRequests), theme.danger),
usageTooltipItem('取消', formatCompactNumber(point.cancelledRequests), theme.info),
usageTooltipItem('未完成', formatCompactNumber(point.incompleteRequests), theme.warning),
usageTooltipItem('调用方错误', formatCompactNumber(point.callerErrorRequests), theme.normal),
usageTooltipItem(
'服务成功率',
successRate == null ? '无服务结果' : formatPercent(successRate),
theme.success,
),
])
}
</script>
@@ -28,7 +28,7 @@ const diagnosticDimension = defineModel('diagnosticDimension', {
<template>
<section
class="mt-5 grid grid-cols-1 gap-3 xl:auto-rows-[24.25rem] xl:grid-cols-2"
class="mt-5 grid grid-cols-1 gap-3 xl:auto-rows-97 xl:grid-cols-2"
aria-label="使用统计观测"
>
<UsageHealthCard
@@ -43,8 +43,16 @@ const diagnosticDimension = defineModel('diagnosticDimension', {
:loading="loading"
/>
<UsagePerformanceCard :performance="overview.performance" :loading="loading" />
<UsagePerformanceCard
:performance="overview.performance"
:activity="overview.health.points"
:loading="loading"
/>
<UsageCostCard :cost="overview.cost" :loading="loading" />
<UsageCostCard
:cost="overview.cost"
:activity="overview.health.points"
:loading="loading"
/>
</section>
</template>
@@ -7,6 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { requestActivityByBucket, zeroInactiveValues } from '@/components/charts/timeSeriesGap'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
@@ -14,20 +15,23 @@ import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageGapAwareLineSeries,
usageLegend,
usageLineSeries,
usageTooltip,
usageTooltipContent,
usageTooltipItem,
usageValueAxis,
} from '../utils/chart'
import { formatDuration, formatDurationAxis, formatPercent } from '../utils/format'
type Performance = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['performance']
type PerformancePoint = Performance['points'][number]
type Activity = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']['points']
const props = withDefaults(
defineProps<{
performance: Performance
activity: Activity
loading?: boolean
}>(),
{
@@ -38,6 +42,10 @@ const props = withDefaults(
const activeView = shallowRef('total')
const { palette } = useChartPalette()
const performancePoints = computed<PerformancePoint[]>(() => props.performance.points)
const requestActivity = computed(() => requestActivityByBucket(
performancePoints.value.map(point => point.bucket),
props.activity,
))
const viewOptions = [
{ label: '总耗时', value: 'total' },
@@ -54,14 +62,28 @@ const seriesLabels = computed(() => {
return ['P50', 'P95', 'P99'] as const
})
const selectedPoints = computed(() =>
performancePoints.value.map(point => ({
const selectedPoints = computed(() => {
const points = performancePoints.value
const active = requestActivity.value
const firstValues = zeroInactiveValues(
points.map(point => seriesValue(point, 'first')),
active,
)
const secondValues = zeroInactiveValues(
points.map(point => seriesValue(point, 'second')),
active,
)
const thirdValues = zeroInactiveValues(
points.map(point => seriesValue(point, 'third')),
active,
)
return points.map((point, index) => ({
point,
first: seriesValue(point, 'first'),
second: seriesValue(point, 'second'),
third: seriesValue(point, 'third'),
})),
)
first: firstValues[index] ?? null,
second: secondValues[index] ?? null,
third: thirdValues[index] ?? null,
}))
})
const summaryMetrics = computed(() => {
const performance = props.performance
@@ -98,6 +120,25 @@ const hasData = computed(
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const points = selectedPoints.value
const series = [
...usageGapAwareLineSeries(
seriesLabels.value[0],
points.map(point => point.first),
theme.info,
{ area: 'subtle' },
),
...usageGapAwareLineSeries(
seriesLabels.value[1],
points.map(point => point.second),
theme.warning,
),
...usageGapAwareLineSeries(
seriesLabels.value[2],
points.map(point => point.third),
activeView.value === 'scheduling' ? theme.success : theme.danger,
{ yAxisIndex: activeView.value === 'scheduling' ? 1 : 0 },
),
]
return {
animationDuration: 240,
@@ -130,27 +171,7 @@ const chartOption = computed<EChartsOption>(() => {
? value => `${formatCompactNumber(value)}/s`
: formatDurationAxis,
),
series: [
usageLineSeries(
seriesLabels.value[0],
points.map(point => point.first),
theme.info,
{ area: 'subtle' },
),
usageLineSeries(
seriesLabels.value[1],
points.map(point => point.second),
theme.warning,
),
{
...usageLineSeries(
seriesLabels.value[2],
points.map(point => point.third),
activeView.value === 'scheduling' ? theme.success : theme.danger,
),
yAxisIndex: activeView.value === 'scheduling' ? 1 : 0,
},
],
series,
}
})
@@ -180,10 +201,23 @@ function formatTooltip(params: unknown) {
if (!selected)
return ''
return usageTooltipContent(palette.value, selected.point.label, [
`${seriesLabels.value[0]}: ${formatSeriesValue(selected.first, 'first')}`,
`${seriesLabels.value[1]}: ${formatSeriesValue(selected.second, 'second')}`,
`${seriesLabels.value[2]}: ${formatSeriesValue(selected.third, 'third')}`,
const theme = palette.value
return usageTooltipContent(theme, selected.point.label, [
usageTooltipItem(
seriesLabels.value[0],
formatSeriesValue(selected.first, 'first'),
theme.info,
),
usageTooltipItem(
seriesLabels.value[1],
formatSeriesValue(selected.second, 'second'),
theme.warning,
),
usageTooltipItem(
seriesLabels.value[2],
formatSeriesValue(selected.third, 'third'),
activeView.value === 'scheduling' ? theme.success : theme.danger,
),
])
}
+29
View File
@@ -1,5 +1,6 @@
import type { LineSeriesOption } from 'echarts'
import type { useChartPalette } from '@/composables/useChartPalette'
import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap'
import { chartTooltipStyle } from '@/components/charts/tooltip'
import { escapeTooltip } from './format'
@@ -9,6 +10,8 @@ type UsageAreaStrength = 'strong' | 'subtle'
interface UsageLineSeriesOptions {
stack?: string
area?: UsageAreaStrength | false
xAxisIndex?: number
yAxisIndex?: number
}
const areaAlpha: Record<UsageAreaStrength, readonly [string, string]> = {
@@ -36,6 +39,8 @@ export function usageLineSeries(
data: data.map(value => value ?? null),
connectNulls: false,
stack: options.stack,
xAxisIndex: options.xAxisIndex ?? 0,
yAxisIndex: options.yAxisIndex ?? 0,
smooth: true,
showSymbol: showSymbols,
showAllSymbol: showSymbols,
@@ -61,6 +66,26 @@ export function usageLineSeries(
}
}
export function usageGapAwareLineSeries(
name: string,
data: Array<number | null | undefined>,
color: string,
options: UsageLineSeriesOptions = {},
) {
const primary = usageLineSeries(name, data, color, options)
return [
primary,
...sampleGapBridgeSeries(data, {
name,
color,
xAxisIndex: options.xAxisIndex,
yAxisIndex: options.yAxisIndex,
width: 2.2,
z: 2,
}),
]
}
export function usageTooltip(
theme: UsageChartPalette,
formatter: (params: unknown) => string,
@@ -81,6 +106,10 @@ export function usageTooltipContent(
return `<div style="margin:0 0 7px;padding:0 0 7px;border-bottom:1px solid ${theme.divider};color:${theme.textPrimary};font-family:'JetBrains Mono Variable','JetBrains Mono',monospace;font-size:11px;font-weight:750;line-height:1.2">${title}</div><div style="line-height:1.55">${lines.join('<br/>')}</div>`
}
export function usageTooltipItem(label: string, value: string, color: string) {
return `<span style="display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:999px;background:${escapeTooltip(color)}"></span>${escapeTooltip(label)}: ${escapeTooltip(value)}`
}
export function usageCategoryAxis(labels: string[], theme: UsageChartPalette) {
return {
type: 'category' as const,