Skip to content

Commit 24f5200

Browse files
committed
feat(charts/i18n): zero-dependency SVG BarChart + proxyStats i18n keys
New BarChart.jsx (pure SVG/CSS, ResizeObserver-responsive, no chart library per CLAUDE.md) supports vertical/horizontal/grouped bars, folds long-tail data into a single '...' bar. i18n.js gains proxyStats retry-burden / dominantFail / viewRetryConfig keys across all 18 locales.
1 parent 637192b commit 24f5200

3 files changed

Lines changed: 212 additions & 0 deletions

File tree

src/components/charts/BarChart.jsx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import styles from './BarChart.module.css';
3+
4+
// Default colors cycle for grouped bars. Maps to the app's REAL semantic
5+
// tokens (global.css :root) so bars theme correctly in light AND dark mode;
6+
// hex fallbacks only apply if the tokens are somehow absent.
7+
const DEFAULT_COLORS = [
8+
'var(--color-primary, #1668dc)',
9+
'var(--color-success, #22c55e)',
10+
'var(--color-warning, #f59e0b)',
11+
];
12+
13+
// Pure-SVG bar chart. Supports vertical/horizontal and single/grouped bars.
14+
// No external chart lib — keeps cc-viewer dependency-free (CLAUDE.md).
15+
export default function BarChart({
16+
data = [],
17+
height = 160,
18+
horizontal = false,
19+
grouped = false,
20+
valueFormatter = (n) => String(n),
21+
maxBars,
22+
legend, // optional series labels for grouped bars: ['Upstream', 'Downstream']
23+
ariaLabel, // screen-reader summary for the chart (role="img" on the svg)
24+
}) {
25+
const wrapRef = useRef(null);
26+
const [width, setWidth] = useState(0);
27+
28+
useEffect(() => {
29+
if (!wrapRef.current) return;
30+
const el = wrapRef.current;
31+
const update = () => setWidth(el.clientWidth || 0);
32+
update();
33+
if (typeof ResizeObserver === 'undefined') return;
34+
const ro = new ResizeObserver(update);
35+
ro.observe(el);
36+
return () => { try { ro.disconnect(); } catch { /* benign */ } };
37+
}, []);
38+
39+
// Fold long-tail data into a single "other" (…) bar when maxBars is set.
40+
let rows = data;
41+
if (maxBars && Array.isArray(data) && data.length > maxBars) {
42+
const head = data.slice(0, maxBars);
43+
const tail = data.slice(maxBars);
44+
const otherCount = tail.reduce((s, d) => s + (grouped
45+
? (d.values || []).reduce((a, b) => a + b, 0)
46+
: (d.value || 0)), 0);
47+
rows = [...head, { label: '…', value: otherCount, values: [otherCount] }];
48+
}
49+
50+
if (!rows || rows.length === 0 || width === 0) {
51+
return <div ref={wrapRef} className={styles.wrap} style={{ height }} />;
52+
}
53+
54+
// Series legend for grouped bars — color alone must not carry the meaning
55+
// (WCAG color-not-only); swatch colors follow the same per-series resolution
56+
// as the bars themselves.
57+
const legendEl = (grouped && Array.isArray(legend) && legend.length > 0) ? (
58+
<div className={styles.legend} aria-hidden="true">
59+
{legend.map((label, i) => (
60+
<span key={i} className={styles.legendItem}>
61+
<span className={styles.legendSwatch}
62+
style={{ background: rows[0]?.colors?.[i] || DEFAULT_COLORS[i % DEFAULT_COLORS.length] }} />
63+
{label}
64+
</span>
65+
))}
66+
</div>
67+
) : null;
68+
69+
const pad = 28; // left/bottom padding for labels
70+
const labelPad = horizontal ? 0 : 16; // extra bottom for x labels when vertical
71+
const innerW = Math.max(0, width - pad);
72+
const innerH = Math.max(0, height - pad - labelPad);
73+
74+
// Max value across all bars (handle grouped multi-value)
75+
const maxVal = Math.max(1, ...rows.flatMap((d) =>
76+
grouped ? (d.values || [0]) : [d.value || 0]));
77+
78+
// Vertical layout (bars grow upward)
79+
if (!horizontal) {
80+
const groupW = innerW / rows.length;
81+
const barW = grouped
82+
? Math.max(2, (groupW * 0.6) / Math.max(1, rows[0]?.values?.length || 1))
83+
: Math.max(2, groupW * 0.5);
84+
const baseline = pad + innerH; // y of baseline
85+
return (
86+
<div ref={wrapRef} className={styles.wrap} style={{ height }}>
87+
{legendEl}
88+
<svg className={styles.svg} height={height} width={width} role="img" aria-label={ariaLabel}>
89+
{/* 3 gridlines */}
90+
{[0.25, 0.5, 0.75].map((f) => (
91+
<line key={f} className={styles.gridLine}
92+
x1={pad} x2={width} y1={pad + innerH * (1 - f)} y2={pad + innerH * (1 - f)} />
93+
))}
94+
<line className={styles.axisLine} x1={pad} x2={width} y1={baseline} y2={baseline} />
95+
{rows.map((d, gi) => {
96+
const gx = pad + gi * groupW + (groupW - (grouped ? (rows[0]?.values?.length || 1) * barW : barW)) / 2;
97+
const vals = grouped ? (d.values || [0]) : [d.value || 0];
98+
return (
99+
<g key={gi}>
100+
{vals.map((v, bi) => {
101+
const h = Math.max(0, (v / maxVal) * innerH);
102+
const x = gx + bi * barW;
103+
const y = baseline - h;
104+
const color = grouped
105+
? (d.colors?.[bi] || DEFAULT_COLORS[bi % DEFAULT_COLORS.length])
106+
: (d.color || DEFAULT_COLORS[0]);
107+
return (
108+
<g key={bi}>
109+
<rect className={styles.bar} x={x} y={y} width={Math.max(1, barW - 1)} height={h}
110+
fill={color} rx={1}>
111+
<title>{`${d.label}: ${valueFormatter(v)}`}</title>
112+
</rect>
113+
{/* Direct value label on single-series bars wide enough to
114+
hold it — saves a hover for the common reading path. */}
115+
{!grouped && barW >= 18 && (
116+
<text className={styles.vValue} x={x + barW / 2} y={Math.max(8, y - 4)}
117+
textAnchor="middle">{valueFormatter(v)}</text>
118+
)}
119+
</g>
120+
);
121+
})}
122+
<text className={styles.vLabel} x={pad + gi * groupW + groupW / 2} y={height - 4}
123+
textAnchor="middle">{String(d.label).slice(0, 8)}</text>
124+
</g>
125+
);
126+
})}
127+
</svg>
128+
</div>
129+
);
130+
}
131+
132+
// Horizontal layout (bars grow rightward — good for long labels like status codes)
133+
const rowH = Math.max(14, innerH / rows.length);
134+
const labelW = pad; // left label column
135+
return (
136+
<div ref={wrapRef} className={styles.wrap} style={{ height }}>
137+
{legendEl}
138+
<svg className={styles.svg} height={height} width={width} role="img" aria-label={ariaLabel}>
139+
{[0.25, 0.5, 0.75].map((f) => (
140+
<line key={f} className={styles.gridLine}
141+
x1={labelW + (width - labelW) * f} x2={labelW + (width - labelW) * f}
142+
y1={0} y2={rows.length * rowH} />
143+
))}
144+
{rows.map((d, i) => {
145+
const v = d.value || 0;
146+
const w = Math.max(0, (v / maxVal) * (width - labelW - pad / 2));
147+
const y = i * rowH + 2;
148+
return (
149+
<g key={i}>
150+
<text className={styles.hLabel} x={labelW - 4} y={y + rowH / 2 + 3} textAnchor="end">{String(d.label)}</text>
151+
<rect className={styles.bar} x={labelW} y={y} width={Math.max(1, w)} height={Math.max(2, rowH - 4)}
152+
fill={d.color || DEFAULT_COLORS[0]} rx={1}>
153+
<title>{`${d.label}: ${valueFormatter(v)}`}</title>
154+
</rect>
155+
<text className={styles.vValue} x={labelW + w + 4} y={y + rowH / 2 + 3}>{valueFormatter(v)}</text>
156+
</g>
157+
);
158+
})}
159+
</svg>
160+
</div>
161+
);
162+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/* BarChart — pure SVG bar chart. CSS vars only, no !important (CLAUDE.md). */
2+
.wrap {
3+
width: 100%;
4+
position: relative;
5+
}
6+
.svg {
7+
width: 100%;
8+
display: block;
9+
overflow: visible;
10+
}
11+
.bar {
12+
transition: opacity 0.15s ease;
13+
}
14+
.bar:hover {
15+
opacity: 0.75;
16+
}
17+
@media (prefers-reduced-motion: reduce) {
18+
.bar { transition: none; }
19+
}
20+
.axisLine {
21+
stroke: var(--border-primary, #d9d9d9);
22+
stroke-width: 1;
23+
}
24+
.gridLine {
25+
stroke: var(--border-secondary, #f0f0f0);
26+
stroke-width: 1;
27+
stroke-dasharray: 2 3;
28+
}
29+
.vLabel {
30+
fill: var(--text-secondary, #8c8c8c);
31+
font-size: 11px;
32+
}
33+
.hLabel {
34+
fill: var(--text-secondary, #8c8c8c);
35+
font-size: 11px;
36+
}
37+
.vValue {
38+
fill: var(--text-primary, #262626);
39+
font-size: 10px;
40+
}

src/i18n.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11545,6 +11545,16 @@ const i18nData = {
1154511545
"ui.proxyStats.byProfile": { "zh": "按代理", "en": "By Profile", "zh-TW": "依代理", "ko": "프로필별", "ja": "プロフィール別", "de": "Nach Profil", "es": "Por perfil", "fr": "Par profil", "it": "Per profilo", "da": "Pr. profil", "pl": "Według profilu", "ru": "По профилям", "ar": "حسب الملف الشخصي", "no": "Etter profil", "pt-BR": "Por perfil", "th": "ตามโปรไฟล์", "tr": "Profile göre", "uk": "За профілями" },
1154611546
"ui.proxyStats.retryDistribution": { "zh": "重试次数分布", "en": "Retry Distribution", "zh-TW": "重試次數分佈", "ko": "재시도 분포", "ja": "リトライ分布", "de": "Wiederholungsverteilung", "es": "Distribución de reintentos", "fr": "Distribution des relances", "it": "Distribuzione retry", "da": "Genforsøgsfordeling", "pl": "Rozkład ponownych prób", "ru": "Распределение повторов", "ar": "توزيع إعادة المحاولات", "no": "Fordeling av retries", "pt-BR": "Distribuição de retries", "th": "การกระจายการลองใหม่", "tr": "Yeniden deneme dağılımı", "uk": "Розподіл повторів" },
1154711547
"ui.proxyStats.retryCodes": { "zh": "上游错误码分布", "en": "Upstream Error Codes", "zh-TW": "上游錯誤碼分佈", "ko": "상위 오류 코드 분포", "ja": "上流エラーコード分布", "de": "Upstream-Fehlercodes", "es": "Códigos de error upstream", "fr": "Codes d'erreur amont", "it": "Codici di errore upstream", "da": "Upstream-fejlkoder", "pl": "Kody błędów upstream", "ru": "Коды ошибок upstream", "ar": "رموز أخطاء المنبع", "no": "Oppstrøms feilkoder", "pt-BR": "Códigos de erro upstream", "th": "รหัสข้อผิดพลาดต้นน้ำ", "tr": "Yukarı akış hata kodları", "uk": "Коди помилок upstream" },
11548+
"ui.proxyStats.viewRetryConfig": { "zh": "重试配置", "en": "Retry Config", "zh-TW": "重試設定", "ko": "재시도 설정", "ja": "リトライ設定", "de": "Retry-Konfig", "es": "Config de reintentos", "fr": "Config de relance", "it": "Config retry", "da": "Retry-konfig", "pl": "Konfig. ponawiania", "ru": "Конфиг. повторов", "ar": "إعدادات إعادة المحاولة", "no": "Retry-konfig", "pt-BR": "Config. de retry", "th": "ตั้งค่าลองใหม่", "tr": "Yeniden deneme ayarı", "uk": "Конфіг. повторів" },
11549+
"ui.proxyStats.retryBurden": { "zh": "重试负担", "en": "Retry Burden", "zh-TW": "重試負擔", "ko": "재시도 부담", "ja": "リトライ負担", "de": "Wiederholungs-Last", "es": "Carga de reintentos", "fr": "Charge de relance", "it": "Carico retry", "da": "Genforsøgsbyrde", "pl": "Obciążenie ponówień", "ru": "Нагрузка повторов", "ar": "عبء إعادة المحاولات", "no": "Retry-belastning", "pt-BR": "Carga de retry", "th": "ภาระการลองใหม่", "tr": "Yeniden deneme yükü", "uk": "Навантаження повторів" },
11550+
"ui.proxyStats.retryBurdenBuckets.0": { "zh": "0 次", "en": "0", "zh-TW": "0 次", "ko": "0회", "ja": "0回", "de": "0", "es": "0", "fr": "0", "it": "0", "da": "0", "pl": "0", "ru": "0", "ar": "0", "no": "0", "pt-BR": "0", "th": "0", "tr": "0", "uk": "0" },
11551+
"ui.proxyStats.retryBurdenBuckets.1_5": { "zh": "1-5 次", "en": "1-5", "zh-TW": "1-5 次", "ko": "1-5회", "ja": "1-5回", "de": "1-5", "es": "1-5", "fr": "1-5", "it": "1-5", "da": "1-5", "pl": "1-5", "ru": "1-5", "ar": "1-5", "no": "1-5", "pt-BR": "1-5", "th": "1-5", "tr": "1-5", "uk": "1-5" },
11552+
"ui.proxyStats.retryBurdenBuckets.6_20": { "zh": "6-20 次", "en": "6-20", "zh-TW": "6-20 次", "ko": "6-20회", "ja": "6-20回", "de": "6-20", "es": "6-20", "fr": "6-20", "it": "6-20", "da": "6-20", "pl": "6-20", "ru": "6-20", "ar": "6-20", "no": "6-20", "pt-BR": "6-20", "th": "6-20", "tr": "6-20", "uk": "6-20" },
11553+
"ui.proxyStats.retryBurdenBuckets.21_50": { "zh": "21-50 次", "en": "21-50", "zh-TW": "21-50 次", "ko": "21-50회", "ja": "21-50回", "de": "21-50", "es": "21-50", "fr": "21-50", "it": "21-50", "da": "21-50", "pl": "21-50", "ru": "21-50", "ar": "21-50", "no": "21-50", "pt-BR": "21-50", "th": "21-50", "tr": "21-50", "uk": "21-50" },
11554+
"ui.proxyStats.retryBurdenBuckets.over50": { "zh": ">50 次", "en": ">50", "zh-TW": ">50 次", "ko": ">50회", "ja": ">50回", "de": ">50", "es": ">50", "fr": ">50", "it": ">50", "da": ">50", "pl": ">50", "ru": ">50", "ar": ">50", "no": ">50", "pt-BR": ">50", "th": ">50", "tr": ">50", "uk": ">50" },
11555+
"ui.proxyStats.dominantFail": { "zh": "主要失败码", "en": "Dominant Fail", "zh-TW": "主要失敗碼", "ko": "주요 실패 코드", "ja": "主要失敗コード", "de": "Hauptfehlercode", "es": "Fallo dominante", "fr": "Échec dominant", "it": "Errore dominante", "da": "Dominant fejl", "pl": "Dominujący błąd", "ru": "Осн. код ошибки", "ar": "الفشل الرئيسي", "no": "Dominerende feil", "pt-BR": "Falha dominante", "th": "รหัสล้มเหลวหลัก", "tr": "Baskın hata", "uk": "Домін. код помилки" },
11556+
"ui.proxyStats.rescuedByRetry": { "zh": "重试挽救 {count} 请求", "en": "Retry rescued {count} requests", "zh-TW": "重試挽救 {count} 請求", "ko": "재시도가 {count}건 구함", "ja": "リトライが {count}件救援", "de": "Retry rettete {count} Anfragen", "es": "Reintento rescató {count} solicitudes", "fr": "Relance a sauvé {count} requêtes", "it": "Retry ha salvato {count} richieste", "da": "Genforsøg reddede {count} anmodninger", "pl": "Ponowienie uratowało {count} żądań", "ru": "Повтор спас {count} запросов", "ar": "أنقذت إعادة المحاولة {count} طلب", "no": "Retry reddet {count} forespørsler", "pt-BR": "Retry resgatou {count} solicitações", "th": "ลองใหม่ช่วย {count} คำขอ", "tr": "Yeniden deneme {count} isteği kurtardı", "uk": "Повтор врятував {count} запитів" },
11557+
"ui.proxyStats.upstreamVsDownstream": { "zh": "上游 vs 下游可用率", "en": "Upstream vs Downstream", "zh-TW": "上游 vs 下游可用率", "ko": "상위 vs 하위 가용률", "ja": "上流 vs 下流可用率", "de": "Upstream vs Downstream", "es": "Upstream vs Downstream", "fr": "Amont vs Aval", "it": "Upstream vs Downstream", "da": "Upstream vs Downstream", "pl": "Upstream vs Downstream", "ru": "Upstream vs Downstream", "ar": "المنبع مقابل المصب", "no": "Oppstrøms vs Nedstrøms", "pt-BR": "Upstream vs Downstream", "th": "ต้นน้ำ vs ปลายน้ำ", "tr": "Yukarı vs Aşağı akış", "uk": "Upstream vs Downstream" },
1154811558
"ui.proxyStats.recentRecords": { "zh": "最近请求", "en": "Recent Requests", "zh-TW": "最近請求", "ko": "최근 요청", "ja": "最近のリクエスト", "de": "Letzte Anfragen", "es": "Solicitudes recientes", "fr": "Requêtes récentes", "it": "Richieste recenti", "da": "Seneste anmodninger", "pl": "Ostatnie żądania", "ru": "Недавние запросы", "ar": "الطلبات الأخيرة", "no": "Siste forespørsler", "pt-BR": "Solicitações recentes", "th": "คำขอล่าสุด", "tr": "Son istekler", "uk": "Недавні запити" },
1154911559
"ui.proxyStats.recentErrors": { "zh": "最近错误", "en": "Recent Errors", "zh-TW": "最近錯誤", "ko": "최근 오류", "ja": "最近のエラー", "de": "Letzte Fehler", "es": "Errores recientes", "fr": "Erreurs récentes", "it": "Errori recenti", "da": "Seneste fejl", "pl": "Ostatnie błędy", "ru": "Недавние ошибки", "ar": "الأخطاء الأخيرة", "no": "Siste feil", "pt-BR": "Erros recentes", "th": "ข้อผิดพลาดล่าสุด", "tr": "Son hatalar", "uk": "Недавні помилки" },
1155011560
"ui.proxyStats.attempts": { "zh": "尝试次数", "en": "Attempts", "zh-TW": "嘗試次數", "ko": "시도 횟수", "ja": "試行回数", "de": "Versuche", "es": "Intentos", "fr": "Tentatives", "it": "Tentativi", "da": "Forsøg", "pl": "Próby", "ru": "Попытки", "ar": "المحاولات", "no": "Forsøk", "pt-BR": "Tentativas", "th": "จำนวนครั้งที่ลอง", "tr": "Deneme sayısı", "uk": "Спроби" },

0 commit comments

Comments
 (0)