SPB Git

spb/akos

Public
4.8 KB · 71 lines tsx
1import { useState } from "react";23/** Grouped/stacked daily bars: ok vs bad. Thin marks, rounded ends, 2px gaps, hover tooltip. */4export function DailyBars({ data, width = 640, height = 170 }: { data: { day: string; ok: number; bad: number; n: number }[]; width?: number; height?: number }) {5  const [tip, setTip] = useState<{ x: number; y: number; d: (typeof data)[number] } | null>(null);6  if (!data.length) return <div className="empty">No executions in the last 14 days.</div>;7  const m = { l: 34, r: 8, t: 10, b: 24 };8  const w = width - m.l - m.r, h = height - m.t - m.b;9  const max = Math.max(1, ...data.map((d) => d.ok + d.bad));10  const bw = w / data.length;11  const ticks = [0, 0.5, 1].map((f) => Math.round(max * f));12  return (13    <div>14      <div className="legend"><span><i style={{ background: "var(--s1)" }} />succeeded</span><span><i style={{ background: "var(--s8)" }} />failed / blocked</span></div>15      <svg className="chart" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Executions per day, succeeded versus failed">16        <g className="grid">{ticks.map((t) => <line key={t} x1={m.l} x2={width - m.r} y1={m.t + h - (t / max) * h} y2={m.t + h - (t / max) * h} />)}</g>17        {ticks.map((t) => <text key={t} x={m.l - 4} y={m.t + h - (t / max) * h + 4} textAnchor="end">{t}</text>)}18        {data.map((d, i) => {19          const x = m.l + i * bw + bw * 0.2, bwi = Math.max(3, bw * 0.6);20          const hOk = (d.ok / max) * h, hBad = (d.bad / max) * h;21          const yBad = m.t + h - hBad, yOk = yBad - hOk - (hOk && hBad ? 2 : 0);22          return (23            <g key={d.day} onMouseEnter={(e) => setTip({ x: e.clientX, y: e.clientY, d })} onMouseMove={(e) => setTip({ x: e.clientX, y: e.clientY, d })} onMouseLeave={() => setTip(null)}>24              <rect x={m.l + i * bw} y={m.t} width={bw} height={h} fill="transparent" />25              {hBad > 0 && <rect x={x} y={yBad} width={bwi} height={hBad} rx={3} fill="var(--s8)" />}26              {hOk > 0 && <rect x={x} y={yOk} width={bwi} height={hOk} rx={3} fill="var(--s1)" />}27              {(i % Math.ceil(data.length / 7) === 0 || data.length <= 7) && <text x={x + bwi / 2} y={height - 6} textAnchor="middle">{d.day.slice(5)}</text>}28            </g>29          );30        })}31        <line className="axis" x1={m.l} x2={width - m.r} y1={m.t + h} y2={m.t + h} />32      </svg>33      {tip && <div className="tooltip" style={{ left: tip.x + 12, top: tip.y + 12 }}><b>{tip.d.day}</b><br />succeeded {tip.d.ok} · failed/blocked {tip.d.bad} · total {tip.d.n}</div>}34    </div>35  );36}3738/** Horizontal breakdown bars for categorical counts (one hue, labels in text tokens). */39export function Breakdown({ items, color = "var(--s1)" }: { items: { label: string; n: number; tone?: string }[]; color?: string }) {40  if (!items.length) return <div className="empty">Nothing yet.</div>;41  const max = Math.max(1, ...items.map((i) => i.n));42  return (43    <div className="stack">44      {items.map((it) => (45        <div key={it.label} className="row" style={{ gap: 8, flexWrap: "nowrap" }}>46          <span style={{ width: 150, fontSize: 13, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.label}</span>47          <svg width="100%" height="12" style={{ flex: 1 }} role="img" aria-label={`${it.label}: ${it.n}`}><rect x={0} y={1} width={`${(it.n / max) * 100}%`} height={10} rx={3} fill={it.tone ? `var(--${it.tone})` : color} /></svg>48          <span style={{ width: 44, textAlign: "right", fontVariantNumeric: "tabular-nums", fontSize: 13 }}>{it.n}</span>49        </div>50      ))}51    </div>52  );53}5455export function Sparkline({ points, width = 320, height = 48, label }: { points: { x: string; y: number }[]; width?: number; height?: number; label: string }) {56  const [tip, setTip] = useState<{ x: number; y: number; p: (typeof points)[number] } | null>(null);57  if (points.length < 2) return <div className="hint">not enough data for {label}</div>;58  const max = Math.max(1, ...points.map((p) => p.y));59  const step = width / (points.length - 1);60  const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${i * step},${height - 4 - (p.y / max) * (height - 8)}`).join(" ");61  return (62    <div>63      <svg className="chart" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={label} onMouseLeave={() => setTip(null)}64        onMouseMove={(e) => { const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); const i = Math.round(((e.clientX - r.left) / r.width) * (points.length - 1)); const p = points[Math.max(0, Math.min(points.length - 1, i))]; if (p) setTip({ x: e.clientX, y: e.clientY, p }); }}>65        <path d={path} stroke="var(--s1)" strokeWidth={2} fill="none" />66      </svg>67      {tip && <div className="tooltip" style={{ left: tip.x + 12, top: tip.y + 12 }}>{tip.p.x}: {tip.p.y}</div>}68    </div>69  );70}71
Files · da58f809c01c422e65c786b05d6ffa3079c9feba
Menu

Theme