v0.6.2: 仪表盘布局重构 + 每日趋势折线图 + OpenCode Go/Zen 预设
仪表盘: - 新增每日用量趋势折线图(Token/缓存命中率/请求数三线) - 用量趋势 Tab 组(每日/模型/供应商模型)与模型表格同行等高 - 热力图独立整行,格子放大 16px - 折线图坐标轴文字改 HTML 覆盖层(修复 SVG 拉伸变形) - 表格 border-collapse + 行线与边框同色 - 模型表格估算标签行内显示(行高统一) - 最近请求容器 max-h 限制 - 页脚归属放内容流末尾 模型唯一性: - 获取模型列表时自动勾选已存在模型 + 已添加徽章 - 批量添加跳过同供应商已存在 model id - onBulkAdd 同供应商 model id 去重 新增预设: - OpenCode Go(套餐 subscription,带推荐链接) - OpenCode Zen(按量 pay_as_you_go) 折线图优化: - 缓存命中率 0-1 映射修复 - 请求数默认显示 - 直线连接 + 渐变填充
This commit is contained in:
1 parent
2614f80d0f
commit
bee981aefa
13 files changed
+739
-95
No files matched your search
@@ -5,6 +5,7 @@ import { useTranslation } from "../../i18n";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { TrendLineChart } from "./TrendLineChart";
|
||||
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
|
||||
@@ -12,18 +13,20 @@ function Card({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
<div className={`flex flex-col rounded-xl border border-border bg-panel ${className ?? ""}`}>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
||||
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
||||
</div>
|
||||
<div className="flex-1 p-4">{children}</div>
|
||||
<div className="flex flex-1 flex-col p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export function DashboardPage() {
|
||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||
|
||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||
today: t("rangeToday"),
|
||||
@@ -187,18 +190,29 @@ export function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
||||
{/* Heatmap — full row */}
|
||||
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
|
||||
<Heatmap heatmap={heatmap} />
|
||||
</Card>
|
||||
|
||||
{/* Charts + Model side table */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
{/* Usage-trend tabs + Model table side by side */}
|
||||
<div className="grid items-stretch gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
{/* Usage-trend tab container */}
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
{/* Tab bar header */}
|
||||
<div className="flex items-end justify-between border-b border-border px-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("daily")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "daily"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabDailyTrend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("model")}
|
||||
@@ -223,21 +237,29 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-content-muted">
|
||||
{daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
{trendTab === "daily"
|
||||
? t("trendLineSub", { n: trendDaily.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Active tab content */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
{trendTab === "daily" ? (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<TrendLineChart daily={trendDaily} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +268,7 @@ export function DashboardPage() {
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
|
||||
@@ -260,7 +282,7 @@ export function DashboardPage() {
|
||||
{visibleModels.map((m) => (
|
||||
<tr
|
||||
key={m.model}
|
||||
className="border-b border-border/50 hover:bg-hover"
|
||||
className="border-b border-border hover:bg-hover"
|
||||
>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -273,10 +295,10 @@ export function DashboardPage() {
|
||||
: "#6b7280",
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
|
||||
{m.costEstimated && (
|
||||
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
|
||||
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,8 +338,8 @@ export function DashboardPage() {
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-auto max-h-[860px]">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
|
||||
@@ -330,7 +352,7 @@ export function DashboardPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentPageRows.map((r, i) => (
|
||||
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
|
||||
<tr key={recentStart + i} className="border-b border-border hover:bg-hover">
|
||||
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
|
||||
{fmtTime(r.time)}
|
||||
</td>
|
||||
@@ -398,20 +420,23 @@ export function DashboardPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Attribution footer */}
|
||||
{/* Footer — meta + attribution at the end of the content flow */}
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
<br />
|
||||
{t("dashboardAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
{t("attributionSuffix")}
|
||||
<div>
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
{t("dashboardAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
{t("attributionSuffix")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ const LEVEL_BG = [
|
||||
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const CELL = 12; // px
|
||||
const CELL = 16; // px
|
||||
const GAP = 3; // px
|
||||
const LABEL_W = 28; // dow label column width
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useMemo, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtTokens } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
|
||||
interface TrendLineChartProps {
|
||||
/** Per-day totals, already capped to the desired window by the caller. */
|
||||
daily: DailyRow[];
|
||||
}
|
||||
|
||||
const W = 800;
|
||||
const H = 130;
|
||||
const PAD_L = 56;
|
||||
const PAD_R = 56;
|
||||
const PAD_T = 10;
|
||||
const PAD_B = 22;
|
||||
const PLOT_W = W - PAD_L - PAD_R;
|
||||
const PLOT_H = H - PAD_T - PAD_B;
|
||||
|
||||
const COLOR_TOKENS = "#3b82f6"; // blue
|
||||
const COLOR_CACHE = "#22c55e"; // green
|
||||
const COLOR_REQUESTS = "#f97316"; // orange
|
||||
|
||||
/** Human-friendly token count for axis labels (K / M / B). */
|
||||
function fmtTokenAxis(value: number): string {
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
function niceMax(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const base = Math.pow(10, exp);
|
||||
const norm = value / base;
|
||||
let nice: number;
|
||||
if (norm <= 1) nice = 1;
|
||||
else if (norm <= 2) nice = 2;
|
||||
else if (norm <= 5) nice = 5;
|
||||
else nice = 10;
|
||||
return nice * base;
|
||||
}
|
||||
|
||||
export function TrendLineChart({ daily }: TrendLineChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showRequests, setShowRequests] = useState(true);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const maxTokens = useMemo(() => {
|
||||
if (!daily?.length) return 1;
|
||||
return niceMax(Math.max(...daily.map((d) => d.totalTokens || 0), 1));
|
||||
}, [daily]);
|
||||
|
||||
const maxRequests = useMemo(() => {
|
||||
if (!daily?.length) return 1;
|
||||
return niceMax(Math.max(...daily.map((d) => d.requests || 0), 1));
|
||||
}, [daily]);
|
||||
|
||||
const n = daily?.length ?? 0;
|
||||
const stepX = n > 1 ? PLOT_W / (n - 1) : PLOT_W;
|
||||
|
||||
const pointX = (i: number) => (n > 1 ? PAD_L + i * stepX : PAD_L + PLOT_W / 2);
|
||||
const pointYTokens = (v: number) => PAD_T + PLOT_H - (v / maxTokens) * PLOT_H;
|
||||
// Cache hit rate is a fraction 0-1 (see fmtPct); map onto the right 0-100% axis.
|
||||
const pointYCache = (v: number) => PAD_T + PLOT_H - Math.max(0, Math.min(1, v)) * PLOT_H;
|
||||
const pointYReq = (v: number) => PAD_T + PLOT_H - (v / maxRequests) * PLOT_H;
|
||||
|
||||
const pathTokens = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYTokens(d.totalTokens || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily, maxTokens]);
|
||||
|
||||
const pathCache = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYCache(d.cacheHitRate || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily]);
|
||||
|
||||
const pathRequests = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYReq(d.requests || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily, maxRequests]);
|
||||
|
||||
const areaTokens = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
const lastX = pointX(daily.length - 1);
|
||||
return (
|
||||
pathTokens +
|
||||
` L ${lastX} ${PAD_T + PLOT_H} L ${PAD_L} ${PAD_T + PLOT_H} Z`
|
||||
);
|
||||
}, [pathTokens, daily]);
|
||||
|
||||
// X-axis ticks: show every ~5 days (plus the first and last) to avoid overlap.
|
||||
const xTicks = useMemo(() => {
|
||||
if (!daily?.length) return [];
|
||||
if (n <= 6) return daily.map((_, i) => i);
|
||||
const step = Math.max(1, Math.round(n / 6));
|
||||
const set = new Set<number>([0, n - 1]);
|
||||
for (let i = step; i < n - 1; i += step) set.add(i);
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}, [daily, n]);
|
||||
|
||||
// Hover handling
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
if (!daily?.length) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const scale = W / rect.width;
|
||||
const x = (e.clientX - rect.left) * scale - PAD_L;
|
||||
if (x < 0 || x > PLOT_W) {
|
||||
setHoverIndex(null);
|
||||
return;
|
||||
}
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round(x / stepX)));
|
||||
setHoverIndex(idx);
|
||||
};
|
||||
|
||||
const onLeave = () => setHoverIndex(null);
|
||||
|
||||
if (!daily?.length) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-content-muted">
|
||||
{t("trendLineNoData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = hoverIndex != null ? daily[hoverIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
{/* Legend + toggle */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-content-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_TOKENS }} />
|
||||
{t("trendLineLegendTokens")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_CACHE }} />
|
||||
{t("trendLineLegendCacheHit")}
|
||||
</span>
|
||||
{showRequests && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_REQUESTS }} />
|
||||
{t("trendLineLegendRequests")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-content-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRequests}
|
||||
onChange={(e) => setShowRequests(e.target.checked)}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
{t("trendLineShowRequests")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
{/* Gradient definitions */}
|
||||
<defs>
|
||||
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLOR_TOKENS} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={COLOR_TOKENS} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Y-axis grid lines only (labels rendered as HTML overlay below) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||
const y = PAD_T + PLOT_H * (1 - p);
|
||||
return (
|
||||
<line
|
||||
key={p}
|
||||
x1={PAD_L}
|
||||
y1={y}
|
||||
x2={W - PAD_R}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={p === 0 ? "" : "2 3"}
|
||||
opacity={p === 0 ? 0.9 : 0.4}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area fill for tokens */}
|
||||
<path d={areaTokens} fill="url(#tokenAreaGrad)" />
|
||||
|
||||
{/* Tokens line (area + line) */}
|
||||
<path
|
||||
d={pathTokens}
|
||||
fill="none"
|
||||
stroke={COLOR_TOKENS}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Cache hit line */}
|
||||
<path
|
||||
d={pathCache}
|
||||
fill="none"
|
||||
stroke={COLOR_CACHE}
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Requests line (optional) */}
|
||||
{showRequests && (
|
||||
<path
|
||||
d={pathRequests}
|
||||
fill="none"
|
||||
stroke={COLOR_REQUESTS}
|
||||
strokeWidth={1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover crosshair + dot */}
|
||||
{hover && (
|
||||
<g pointerEvents="none">
|
||||
<line
|
||||
x1={pointX(hoverIndex!)}
|
||||
y1={PAD_T}
|
||||
x2={pointX(hoverIndex!)}
|
||||
y2={PAD_T + PLOT_H}
|
||||
stroke="currentColor"
|
||||
className="text-content-muted"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 2"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYTokens(hover.totalTokens || 0)}
|
||||
r={3.5}
|
||||
fill={COLOR_TOKENS}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYCache(hover.cacheHitRate || 0)}
|
||||
r={3}
|
||||
fill={COLOR_CACHE}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{showRequests && (
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYReq(hover.requests || 0)}
|
||||
r={2.5}
|
||||
fill={COLOR_REQUESTS}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Axis labels as HTML overlay (avoids SVG preserveAspectRatio="none" font distortion) */}
|
||||
{/* Left Y-axis (tokens) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||
return (
|
||||
<span
|
||||
key={`yl-${p}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
left: 0,
|
||||
top: `${top}%`,
|
||||
width: `${((PAD_L - 4) / W) * 100}%`,
|
||||
textAlign: "right",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{fmtTokenAxis(maxTokens * p)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* Right Y-axis (cache hit %) */}
|
||||
{[0, 0.5, 1].map((p) => {
|
||||
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||
return (
|
||||
<span
|
||||
key={`yr-${p}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
right: 0,
|
||||
top: `${top}%`,
|
||||
width: `${((PAD_R - 4) / W) * 100}%`,
|
||||
textAlign: "left",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{Math.round(p * 100)}%
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* X-axis labels */}
|
||||
{xTicks.map((i) => (
|
||||
<span
|
||||
key={`x-${i}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
left: `${(pointX(i) / W) * 100}%`,
|
||||
bottom: 0,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{daily[i].date.slice(5)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Tooltip (HTML overlay for readability) */}
|
||||
{hover && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-md border border-border bg-panel px-2.5 py-1.5 text-[11px] text-content-primary shadow-xl"
|
||||
style={{
|
||||
left: `${(pointX(hoverIndex!) / W) * 100}%`,
|
||||
top: 4,
|
||||
transform: hoverIndex! > n / 2 ? "translateX(-100%)" : "translateX(8px)",
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{hover.date}</div>
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendTokens")}: <span className="tabular-nums">{fmtTokens(hover.totalTokens)}</span>
|
||||
</div>
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendCacheHit")}: <span className="tabular-nums">{(hover.cacheHitRate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
{showRequests && (
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendRequests")}: <span className="tabular-nums">{fmtInt(hover.requests)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user