| 1 |
import React from "react"; |
| 2 |
|
| 3 |
/* Pure-SVG chart primitives ported from the design. */ |
| 4 |
|
| 5 |
export function Sparkline( { points, color = "#00b884", width = 100, height = 28, filled = true, strokeWidth = 1.5 } ) { |
| 6 |
if ( ! points || points.length < 2 ) return null; |
| 7 |
const max = Math.max( ...points ), min = Math.min( ...points ), range = max - min || 1; |
| 8 |
const stepX = width / ( points.length - 1 ); |
| 9 |
const yOf = ( v ) => height - 2 - ( ( v - min ) / range ) * ( height - 4 ); |
| 10 |
const d = points.map( ( v, i ) => `${ i === 0 ? "M" : "L" }${ i * stepX },${ yOf( v ) }` ).join( " " ); |
| 11 |
const id = React.useId(); |
| 12 |
return ( |
| 13 |
<svg width={ width } height={ height } viewBox={ `0 0 ${ width } ${ height }` } preserveAspectRatio="none"> |
| 14 |
{ filled && ( |
| 15 |
<> |
| 16 |
<defs> |
| 17 |
<linearGradient id={ id } x1="0" y1="0" x2="0" y2="1"> |
| 18 |
<stop offset="0%" stopColor={ color } stopOpacity="0.25" /> |
| 19 |
<stop offset="100%" stopColor={ color } stopOpacity="0" /> |
| 20 |
</linearGradient> |
| 21 |
</defs> |
| 22 |
<path d={ `${ d } L${ width },${ height } L0,${ height } Z` } fill={ `url(#${ id })` } /> |
| 23 |
</> |
| 24 |
) } |
| 25 |
<path d={ d } fill="none" stroke={ color } strokeWidth={ strokeWidth } strokeLinecap="round" strokeLinejoin="round" /> |
| 26 |
</svg> |
| 27 |
); |
| 28 |
} |
| 29 |
|
| 30 |
export function AreaChart( { series, labels, height = 240, colors, formatY = ( v ) => v } ) { |
| 31 |
const ref = React.useRef( null ); |
| 32 |
const [ w, setW ] = React.useState( 800 ); |
| 33 |
const baseId = React.useId().replace( /:/g, "" ); |
| 34 |
React.useEffect( () => { |
| 35 |
if ( ! ref.current ) return; |
| 36 |
const ro = new ResizeObserver( ( entries ) => setW( entries[ 0 ].contentRect.width ) ); |
| 37 |
ro.observe( ref.current ); |
| 38 |
return () => ro.disconnect(); |
| 39 |
}, [] ); |
| 40 |
|
| 41 |
const padL = 44, padR = 16, padT = 12, padB = 28; |
| 42 |
const innerW = Math.max( 100, w - padL - padR ); |
| 43 |
const innerH = height - padT - padB; |
| 44 |
const allValues = series.flatMap( ( s ) => s.data ); |
| 45 |
const max = Math.max( ...allValues, 1 ); |
| 46 |
const pow = Math.pow( 10, Math.floor( Math.log10( max ) ) ); |
| 47 |
const niceMax = Math.ceil( max / pow ) * pow; |
| 48 |
const yMax = Math.max( niceMax, max * 1.1 ); |
| 49 |
const xOf = ( i ) => padL + ( i / Math.max( 1, labels.length - 1 ) ) * innerW; |
| 50 |
const yOf = ( v ) => padT + innerH - ( v / yMax ) * innerH; |
| 51 |
const yTicks = 4; |
| 52 |
const ticks = Array.from( { length: yTicks + 1 }, ( _, i ) => ( yMax / yTicks ) * i ); |
| 53 |
const xTickStep = Math.max( 1, Math.ceil( labels.length / 8 ) ); |
| 54 |
const [ hover, setHover ] = React.useState( null ); |
| 55 |
|
| 56 |
return ( |
| 57 |
<div ref={ ref } style={ { width: "100%", position: "relative" } }> |
| 58 |
<svg width={ w } height={ height } style={ { display: "block" } } |
| 59 |
onMouseLeave={ () => setHover( null ) } |
| 60 |
onMouseMove={ ( e ) => { |
| 61 |
const rect = e.currentTarget.getBoundingClientRect(); |
| 62 |
const i = Math.round( ( ( e.clientX - rect.left - padL ) / innerW ) * ( labels.length - 1 ) ); |
| 63 |
setHover( i >= 0 && i < labels.length ? i : null ); |
| 64 |
} }> |
| 65 |
{ ticks.map( ( t, i ) => ( |
| 66 |
<g key={ i }> |
| 67 |
<line x1={ padL } x2={ padL + innerW } y1={ yOf( t ) } y2={ yOf( t ) } stroke="#EDF0F3" strokeWidth="1" /> |
| 68 |
<text x={ padL - 8 } y={ yOf( t ) + 4 } textAnchor="end" fontSize="11" fill="#94A3B8">{ formatY( Math.round( t ) ) }</text> |
| 69 |
</g> |
| 70 |
) ) } |
| 71 |
{ labels.map( ( l, i ) => ( i % xTickStep === 0 || i === labels.length - 1 ) ? ( |
| 72 |
<text key={ i } x={ xOf( i ) } y={ height - 8 } textAnchor="middle" fontSize="11" fill="#94A3B8">{ l }</text> |
| 73 |
) : null ) } |
| 74 |
{ series.map( ( s, si ) => { |
| 75 |
const color = s.color || colors?.[ si ] || "#00b884"; |
| 76 |
const d = s.data.map( ( v, i ) => `${ i === 0 ? "M" : "L" }${ xOf( i ) },${ yOf( v ) }` ).join( " " ); |
| 77 |
const area = `${ d } L${ xOf( s.data.length - 1 ) },${ padT + innerH } L${ xOf( 0 ) },${ padT + innerH } Z`; |
| 78 |
const gid = `g${ baseId }${ si }`; |
| 79 |
return ( |
| 80 |
<g key={ si }> |
| 81 |
<defs> |
| 82 |
<linearGradient id={ gid } x1="0" y1="0" x2="0" y2="1"> |
| 83 |
<stop offset="0%" stopColor={ color } stopOpacity="0.20" /> |
| 84 |
<stop offset="100%" stopColor={ color } stopOpacity="0" /> |
| 85 |
</linearGradient> |
| 86 |
</defs> |
| 87 |
<path d={ area } fill={ `url(#${ gid })` } /> |
| 88 |
<path d={ d } fill="none" stroke={ color } strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> |
| 89 |
</g> |
| 90 |
); |
| 91 |
} ) } |
| 92 |
{ hover !== null && ( |
| 93 |
<g> |
| 94 |
<line x1={ xOf( hover ) } x2={ xOf( hover ) } y1={ padT } y2={ padT + innerH } stroke="#CBD5E1" strokeWidth="1" strokeDasharray="3 3" /> |
| 95 |
{ series.map( ( s, si ) => ( |
| 96 |
<circle key={ si } cx={ xOf( hover ) } cy={ yOf( s.data[ hover ] ) } r="4" fill="#fff" stroke={ s.color || colors?.[ si ] || "#00b884" } strokeWidth="2" /> |
| 97 |
) ) } |
| 98 |
</g> |
| 99 |
) } |
| 100 |
</svg> |
| 101 |
{ hover !== null && ( |
| 102 |
<div style={ { |
| 103 |
position: "absolute", pointerEvents: "none", background: "#0f172a", color: "#fff", |
| 104 |
fontSize: "12px", borderRadius: "8px", padding: "8px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.2)", |
| 105 |
left: `${ Math.max( 8, Math.min( w - 200, xOf( hover ) - 90 ) ) }px`, top: `${ padT }px`, minWidth: "160px" |
| 106 |
} }> |
| 107 |
<div style={ { color: "#cbd5e1", marginBottom: "6px" } }>{ labels[ hover ] }</div> |
| 108 |
{ series.map( ( s, si ) => ( |
| 109 |
<div key={ si } style={ { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px" } }> |
| 110 |
<span style={ { display: "flex", alignItems: "center", gap: "6px" } }> |
| 111 |
<span style={ { width: 8, height: 8, borderRadius: 9999, background: s.color || colors?.[ si ] || "#00b884" } } /> |
| 112 |
<span style={ { color: "#e2e8f0" } }>{ s.name }</span> |
| 113 |
</span> |
| 114 |
<span style={ { fontWeight: 600, fontVariantNumeric: "tabular-nums" } }>{ formatY( s.data[ hover ] ) }</span> |
| 115 |
</div> |
| 116 |
) ) } |
| 117 |
</div> |
| 118 |
) } |
| 119 |
</div> |
| 120 |
); |
| 121 |
} |
| 122 |
|
| 123 |
export function Donut( { segments, size = 180, thickness = 26, centerLabel, centerValue } ) { |
| 124 |
const cx = size / 2, cy = size / 2, r = ( size - thickness ) / 2; |
| 125 |
const total = segments.reduce( ( s, x ) => s + x.value, 0 ) || 1; |
| 126 |
let acc = 0; |
| 127 |
const arcs = segments.map( ( seg ) => { |
| 128 |
const start = ( acc / total ) * Math.PI * 2 - Math.PI / 2; |
| 129 |
acc += seg.value; |
| 130 |
const end = ( acc / total ) * Math.PI * 2 - Math.PI / 2; |
| 131 |
const large = end - start > Math.PI ? 1 : 0; |
| 132 |
return { |
| 133 |
d: `M${ cx + Math.cos( start ) * r },${ cy + Math.sin( start ) * r } A${ r },${ r } 0 ${ large } 1 ${ cx + Math.cos( end ) * r },${ cy + Math.sin( end ) * r }`, |
| 134 |
color: seg.color |
| 135 |
}; |
| 136 |
} ); |
| 137 |
return ( |
| 138 |
<svg width={ size } height={ size } style={ { flexShrink: 0 } }> |
| 139 |
<circle cx={ cx } cy={ cy } r={ r } fill="none" stroke="#F1F5F9" strokeWidth={ thickness } /> |
| 140 |
{ arcs.map( ( a, i ) => <path key={ i } d={ a.d } fill="none" stroke={ a.color } strokeWidth={ thickness } /> ) } |
| 141 |
{ centerValue != null && ( |
| 142 |
<> |
| 143 |
<text x={ cx } y={ cy - 4 } textAnchor="middle" fontSize="22" fontWeight="700" fill="#0F172A">{ centerValue }</text> |
| 144 |
<text x={ cx } y={ cy + 16 } textAnchor="middle" fontSize="11" fill="#64748B">{ centerLabel }</text> |
| 145 |
</> |
| 146 |
) } |
| 147 |
</svg> |
| 148 |
); |
| 149 |
} |
| 150 |
|
| 151 |
export function HBar( { items, max, color = "#00b884", format = ( v ) => v } ) { |
| 152 |
const m = max || Math.max( ...items.map( ( i ) => i.value ), 1 ); |
| 153 |
return ( |
| 154 |
<div className="bda-hbar"> |
| 155 |
{ items.map( ( it, i ) => ( |
| 156 |
<div key={ i } className="bda-hbar-row"> |
| 157 |
<div className="bda-hbar-name">{ it.name }</div> |
| 158 |
<div className="bda-hbar-track"><div className="bda-hbar-fill" style={ { width: `${ ( it.value / m ) * 100 }%`, background: it.color || color } } /></div> |
| 159 |
<div className="bda-hbar-val">{ format( it.value ) }</div> |
| 160 |
</div> |
| 161 |
) ) } |
| 162 |
</div> |
| 163 |
); |
| 164 |
} |
| 165 |
|
| 166 |
|