SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
8.1 KB · 158 lines tsx
Raw Blame History
1/**2 * Small server-renderable SVG charts for account pages: one system (hairlines, tabular numbers,3 * neutral ink, accents only for gain/loss/index). No client JS required.4 */5import { cn } from '@/lib/format';67// Categorical identity: the four validated series tokens in fixed order; beyond four, fold into muted steps ('Other').8const PALETTE = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)', 'var(--ri-fg-muted)', 'var(--ri-fg-subtle)', 'var(--ri-border-strong)', 'var(--ri-border)'];910export function Donut({ data, size = 120, thickness = 14, className, centerLabel, centerValue }: { data: Array<{ label: string; value: number }>; size?: number; thickness?: number; className?: string; centerLabel?: string; centerValue?: string }) {11  const total = data.reduce((a, d) => a + d.value, 0);12  const r = (size - thickness) / 2;13  const c = 2 * Math.PI * r;14  let offset = 0;15  return (16    <div className={cn('flex items-center gap-4', className)}>17      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Allocation">18        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--ri-bg-inset)" strokeWidth={thickness} />19        {total > 020          ? data.map((d, i) => {21              const len = (d.value / total) * c;22              const gap = data.length > 1 ? Math.min(2, len / 2) : 0; // 2px surface gap between segments23              const el = <circle key={d.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={PALETTE[i % PALETTE.length]} strokeWidth={thickness} strokeDasharray={`${Math.max(0, len - gap)} ${c - len + gap}`} strokeDashoffset={-offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} />;24              offset += len;25              return el;26            })27          : null}28        {centerValue ? (29          <>30            <text x="50%" y="48%" textAnchor="middle" fontSize={size / 9} fontWeight={600} fill="var(--ri-fg)" style={{ fontVariantNumeric: 'tabular-nums' }}>31              {centerValue}32            </text>33            {centerLabel ? (34              <text x="50%" y="62%" textAnchor="middle" fontSize={size / 13} fill="var(--ri-fg-subtle)">35                {centerLabel}36              </text>37            ) : null}38          </>39        ) : null}40      </svg>41      <ul className="min-w-0 flex-1 space-y-1 text-xs">42        {data.slice(0, 8).map((d, i) => (43          <li key={d.label} className="flex items-center gap-2">44            <span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: PALETTE[i % PALETTE.length] }} />45            <span className="min-w-0 flex-1 truncate text-muted">{d.label}</span>46            <span className="num text-fg">{total > 0 ? `${((d.value / total) * 100).toFixed(0)}%` : '—'}</span>47          </li>48        ))}49        {data.length === 0 ? <li className="text-subtle">No valued items yet.</li> : null}50      </ul>51    </div>52  );53}5455export interface SeriesPoint {56  date: string;57  value: number;58}5960export function LineChart({ series, height = 180, className, formatY = (v: number) => v.toFixed(0), showArea = true, ariaLabel = 'Value over time' }: { series: Array<{ name: string; points: SeriesPoint[]; color?: string; dashed?: boolean }>; height?: number; className?: string; formatY?: (v: number) => string; showArea?: boolean; ariaLabel?: string }) {61  const all = series.flatMap((s) => s.points);62  if (all.length < 2) {63    return (64      <div className={cn('flex items-center justify-center rounded-md border border-dashed border-border text-xs text-subtle', className)} style={{ height }}>65        Not enough history yet — values are snapshotted daily.66      </div>67    );68  }69  const W = 640;70  const H = height;71  const padL = 44;72  const padR = 8;73  const padT = 8;74  const padB = 20;75  const dates = [...new Set(all.map((p) => p.date))].sort();76  const t0 = new Date(dates[0]!).getTime();77  const t1 = new Date(dates[dates.length - 1]!).getTime();78  const min = Math.min(...all.map((p) => p.value));79  const max = Math.max(...all.map((p) => p.value));80  const span = max - min || max || 1;81  const yMin = min - span * 0.08;82  const yMax = max + span * 0.08;83  const x = (d: string) => padL + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - padL - padR);84  const y = (v: number) => padT + (1 - (v - yMin) / (yMax - yMin)) * (H - padT - padB);85  const ticks = [yMin + (yMax - yMin) * 0.1, (yMin + yMax) / 2, yMax - (yMax - yMin) * 0.1];86  return (87    <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={ariaLabel} preserveAspectRatio="none">88      {ticks.map((t) => (89        <g key={t}>90          <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--ri-border)" />91          <text x={padL - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ri-fg-subtle)" style={{ fontVariantNumeric: 'tabular-nums' }}>92            {formatY(t)}93          </text>94        </g>95      ))}96      {series.map((s, i) => {97        const pts = [...s.points].sort((a, b) => a.date.localeCompare(b.date));98        const d = pts.map((p, j) => `${j === 0 ? 'M' : 'L'}${x(p.date).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');99        const color = s.color ?? PALETTE[i % PALETTE.length];100        const last = pts[pts.length - 1]!;101        return (102          <g key={s.name}>103            {showArea && i === 0 ? <path d={`${d} L${x(last.date).toFixed(1)},${H - padB} L${x(pts[0]!.date).toFixed(1)},${H - padB} Z`} fill={color} opacity={0.06} /> : null}104            <path d={d} fill="none" stroke={color} strokeWidth={2} strokeDasharray={s.dashed ? '4 3' : undefined} vectorEffect="non-scaling-stroke" />105            <circle cx={x(last.date)} cy={y(last.value)} r={2.5} fill={color} />106          </g>107        );108      })}109      <text x={padL} y={H - 6} fontSize={10} fill="var(--ri-fg-subtle)">110        {dates[0]}111      </text>112      <text x={W - padR} y={H - 6} fontSize={10} textAnchor="end" fill="var(--ri-fg-subtle)">113        {dates[dates.length - 1]}114      </text>115    </svg>116  );117}118119export function Sparkline({ points, width = 96, height = 24, positive }: { points: number[]; width?: number; height?: number; positive?: boolean | null }) {120  if (points.length < 2) return <span className="text-subtle">—</span>;121  const min = Math.min(...points);122  const max = Math.max(...points);123  const span = max - min || 1;124  const d = points.map((v, i) => `${i === 0 ? 'M' : 'L'}${((i / (points.length - 1)) * width).toFixed(1)},${(height - 2 - ((v - min) / span) * (height - 4)).toFixed(1)}`).join(' ');125  const stroke = positive === null || positive === undefined ? 'var(--ri-fg-muted)' : positive ? 'var(--ri-gain)' : 'var(--ri-loss)';126  return (127    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>128      <path d={d} fill="none" stroke={stroke} strokeWidth={1.4} />129    </svg>130  );131}132133export function Bars({ data, className, format = (v: number) => v.toFixed(0) }: { data: Array<{ label: string; value: number; tone?: 'gain' | 'loss' | 'neutral' }>; className?: string; format?: (v: number) => string }) {134  const max = Math.max(1, ...data.map((d) => Math.abs(d.value)));135  return (136    <ul className={cn('space-y-1.5 text-xs', className)}>137      {data.map((d) => (138        <li key={d.label} className="grid grid-cols-[minmax(0,1fr)_120px_64px] items-center gap-2">139          <span className="truncate text-muted">{d.label}</span>140          <span className="h-2 rounded-sm bg-inset">141            <span className={cn('block h-2 rounded-sm', d.tone === 'loss' ? 'bg-loss' : d.tone === 'gain' ? 'bg-gain' : 'bg-index')} style={{ width: `${(Math.abs(d.value) / max) * 100}%` }} />142          </span>143          <span className={cn('num text-right', d.tone === 'loss' ? 'text-loss' : d.tone === 'gain' ? 'text-gain' : 'text-fg')}>{format(d.value)}</span>144        </li>145      ))}146    </ul>147  );148}149150export function Progress({ value, className }: { value: number; className?: string }) {151  const v = Math.max(0, Math.min(1, value));152  return (153    <span className={cn('block h-1.5 w-full rounded-full bg-inset', className)}>154      <span className={cn('block h-1.5 rounded-full', v >= 1 ? 'bg-gain' : 'bg-index')} style={{ width: `${v * 100}%` }} />155    </span>156  );157}158