| 1 | import React from "react" |
| 2 | import { StreamXYFrame } from "semiotic" |
| 3 | |
| 4 | function formatValue(value, unit) { |
| 5 | if (unit === "$") return `${value.toLocaleString()}` |
| 6 | if (unit === "%") return `${value}%` |
| 7 | return value.toLocaleString() |
| 8 | } |
| 9 | |
| 10 | // Standard sparkline KPI card |
| 11 | function KpiCard({ label, value, previousValue, unit, trend }) { |
| 12 | const change = ((value - previousValue) / previousValue) * 100 |
| 13 | const isPositive = change >= 0 |
| 14 | const sparkColor = isPositive ? "#22c55e" : "#ef4444" |
| 15 | |
| 16 | return ( |
| 17 | <div style={{ |
| 18 | background: "var(--surface-1, #12121a)", |
| 19 | border: "1px solid var(--surface-3, #252530)", |
| 20 | borderRadius: "12px", |
| 21 | padding: "20px", |
| 22 | }}> |
| 23 | <div style={{ |
| 24 | fontSize: "11px", fontWeight: 600, textTransform: "uppercase", |
| 25 | letterSpacing: "0.05em", color: "var(--text-secondary, #8888a0)", |
| 26 | marginBottom: "4px", |
| 27 | }}> |
| 28 | {label} |
| 29 | </div> |
| 30 | <div style={{ fontSize: "28px", fontWeight: 700, lineHeight: 1.2, marginBottom: "8px" }}> |
| 31 | {formatValue(value, unit)} |
| 32 | </div> |
| 33 | <div style={{ display: "flex", alignItems: "center", gap: "12px" }}> |
| 34 | <span style={{ |
| 35 | fontSize: "13px", fontWeight: 600, |
| 36 | color: isPositive ? "#22c55e" : "#ef4444", |
| 37 | }}> |
| 38 | {isPositive ? "+" : ""}{change.toFixed(1)}% |
| 39 | </span> |
| 40 | <StreamXYFrame |
| 41 | chartType="line" |
| 42 | size={[120, 40]} |
| 43 | data={trend} |
| 44 | xAccessor="day" |
| 45 | yAccessor="value" |
| 46 | lineStyle={{ stroke: sparkColor, strokeWidth: 2 }} |
| 47 | showAxes={false} |
| 48 | margin={{ top: 4, bottom: 4, left: 0, right: 0 }} |
| 49 | /> |
| 50 | </div> |
| 51 | </div> |
| 52 | ) |
| 53 | } |
| 54 | |
| 55 | // Forecast sparkline KPI card with confidence interval |
| 56 | function ForecastKpiCard({ label, value, previousValue, unit, actual, forecast }) { |
| 57 | const change = ((value - previousValue) / previousValue) * 100 |
| 58 | const isPositive = change >= 0 |
| 59 | const sparkColor = isPositive ? "#22c55e" : "#ef4444" |
| 60 | |
| 61 | const flatData = [ |
| 62 | ...actual.map(d => ({ ...d, lineGroup: "actual", forecast: false })), |
| 63 | ...forecast.map(d => ({ ...d, lineGroup: "forecast", forecast: true })), |
| 64 | ] |
| 65 | |
| 66 | return ( |
| 67 | <div style={{ |
| 68 | background: "var(--surface-1, #12121a)", |
| 69 | border: "1px solid var(--surface-3, #252530)", |
| 70 | borderRadius: "12px", |
| 71 | padding: "20px", |
| 72 | }}> |
| 73 | <div style={{ |
| 74 | fontSize: "11px", fontWeight: 600, textTransform: "uppercase", |
| 75 | letterSpacing: "0.05em", color: "var(--text-secondary, #8888a0)", |
| 76 | marginBottom: "4px", |
| 77 | }}> |
| 78 | {label} |
| 79 | </div> |
| 80 | <div style={{ fontSize: "28px", fontWeight: 700, lineHeight: 1.2, marginBottom: "8px" }}> |
| 81 | {formatValue(value, unit)} |
| 82 | </div> |
| 83 | <div style={{ display: "flex", alignItems: "center", gap: "12px" }}> |
| 84 | <span style={{ |
| 85 | fontSize: "13px", fontWeight: 600, |
| 86 | color: isPositive ? "#22c55e" : "#ef4444", |
| 87 | }}> |
| 88 | {isPositive ? "+" : ""}{change.toFixed(1)}% |
| 89 | </span> |
| 90 | <StreamXYFrame |
| 91 | chartType="line" |
| 92 | size={[160, 50]} |
| 93 | data={flatData} |
| 94 | groupAccessor="lineGroup" |
| 95 | xAccessor="day" |
| 96 | yAccessor="value" |
| 97 | lineStyle={(d) => d.forecast |
| 98 | ? { stroke: sparkColor, strokeWidth: 2, strokeDasharray: "3 2" } |
| 99 | : { stroke: sparkColor, strokeWidth: 2 } |
| 100 | } |
| 101 | boundsAccessor={d => d.uncertainty || 0} |
| 102 | boundsStyle={{ fill: sparkColor, fillOpacity: 0.15, stroke: "none" }} |
| 103 | showAxes={false} |
| 104 | margin={{ top: 6, bottom: 6, left: 0, right: 0 }} |
| 105 | /> |
| 106 | </div> |
| 107 | <div style={{ |
| 108 | fontSize: "10px", color: "var(--text-secondary, #8888a0)", |
| 109 | marginTop: "4px", fontStyle: "italic", |
| 110 | }}> |
| 111 | forecast with confidence interval |
| 112 | </div> |
| 113 | </div> |
| 114 | ) |
| 115 | } |
| 116 | |
| 117 | // Usage: |
| 118 | // <KpiCard label="Revenue" value={128400} previousValue={112300} |
| 119 | // unit="$" trend={[{ day: 1, value: 112 }, ...]} /> |
| 120 | // |
| 121 | // <ForecastKpiCard label="Projected Revenue" value={142000} |
| 122 | // previousValue={128400} unit="$" |
| 123 | // actual={[{ day: 1, value: 112 }, ...]} |
| 124 | // forecast={[{ day: 9, value: 128, uncertainty: 0 }, |
| 125 | // { day: 10, value: 131, uncertainty: 4 }, ...]} /> |