105 lines
3.9 KiB
JavaScript
105 lines
3.9 KiB
JavaScript
export function MetricCard({ item, loading = false, onClick }) {
|
|
const Icon = item.icon;
|
|
const UnitIcon = item.unitIcon;
|
|
const clickable = Boolean(onClick);
|
|
if (loading) {
|
|
return (
|
|
<article className={["metric-card", Icon ? "" : "metric-card--no-icon", "is-loading"].filter(Boolean).join(" ")} aria-busy="true">
|
|
{Icon ? <div className="metric-icon metric-icon--skeleton"><span className="skeleton-block skeleton-icon" /></div> : null}
|
|
<div className="metric-content">
|
|
<span className="skeleton-block skeleton-label" />
|
|
<span className="skeleton-block skeleton-value" />
|
|
<span className="skeleton-block skeleton-caption" />
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
return (
|
|
<article
|
|
className={["metric-card", Icon ? "" : "metric-card--no-icon", clickable ? "metric-card--clickable" : ""].filter(Boolean).join(" ")}
|
|
role={clickable ? "button" : undefined}
|
|
tabIndex={clickable ? 0 : undefined}
|
|
onClick={onClick}
|
|
onKeyDown={clickable ? (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
onClick();
|
|
}
|
|
} : undefined}
|
|
>
|
|
<div className="metric-content">
|
|
<div className="metric-label-row">
|
|
{Icon ? <i className="metric-title-icon"><Icon /></i> : null}
|
|
<span>{item.label}</span>
|
|
{UnitIcon ? <i className="metric-unit-icon"><UnitIcon /></i> : null}
|
|
{item.unit ? <em className="metric-unit">{item.unit}</em> : null}
|
|
</div>
|
|
{Array.isArray(item.subMetrics) && item.subMetrics.length ? <MetricSubValues items={item.subMetrics} /> : <MetricMainValue item={item} />}
|
|
<MetricSparkline series={item.trend} />
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function MetricMainValue({ item }) {
|
|
return (
|
|
<>
|
|
<strong>{item.value}</strong>
|
|
<MetricDelta item={item} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MetricSubValues({ items }) {
|
|
return (
|
|
<div className="metric-sub-grid">
|
|
{items.map((subItem) => (
|
|
<div className="metric-sub-item" key={subItem.label}>
|
|
<span>{subItem.label}</span>
|
|
<strong>{subItem.value}</strong>
|
|
<MetricDelta item={subItem} compact />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricDelta({ compact = false, item }) {
|
|
const tone = item.deltaTone || (String(item.delta || "").trim().startsWith("-") ? "down" : "up");
|
|
return (
|
|
<div className={["metric-delta", compact ? "metric-delta--compact" : "", tone === "down" ? "metric-delta--down" : ""].filter(Boolean).join(" ")}>
|
|
<span>{item.caption}</span>
|
|
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${tone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricSparkline({ series }) {
|
|
const lines = Array.isArray(series) ? series.filter((item) => Array.isArray(item.data) && item.data.length > 1) : [];
|
|
if (!lines.length) {
|
|
return <span className="metric-sparkline metric-sparkline--empty" />;
|
|
}
|
|
const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0));
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
return (
|
|
<svg className="metric-sparkline" preserveAspectRatio="none" viewBox="0 0 100 24" aria-hidden="true">
|
|
{lines.slice(0, 2).map((line, index) => (
|
|
<polyline className={`metric-sparkline-line metric-sparkline-line--${index + 1}`} key={line.name || index} points={sparklinePoints(line.data, min, max)} />
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function sparklinePoints(points, min, max) {
|
|
const width = 100;
|
|
const height = 24;
|
|
const count = points.length;
|
|
return points.map((point, index) => {
|
|
const x = count <= 1 ? width : (index / (count - 1)) * width;
|
|
const normalized = max <= min ? 0.5 : ((Number(point.value) || 0) - min) / (max - min);
|
|
const y = height - normalized * 18 - 3;
|
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
}).join(" ");
|
|
}
|