1- import type { CSSProperties } from "react" ;
1+ import { useId , type CSSProperties } from "react" ;
22
33/** One activity ring: progress towards a daily goal. */
44export interface Ring {
@@ -14,8 +14,9 @@ export interface Ring {
1414 hint ?: string ;
1515}
1616
17- const STROKE = 14 ;
18- const GAP = 4 ;
17+ /** Room around the rings for the stroke's round caps and the fill's glow, so
18+ * nothing is ever clipped at the edge of the drawing. */
19+ const PAD = 10 ;
1920
2021/** Share of the goal reached, 0 to 1 (a ring is full at its goal). */
2122export function progress ( value : number , goal : number ) : number {
@@ -28,21 +29,55 @@ function legendValue(r: Ring, n: number): string {
2829 return `${ Math . round ( n ) . toLocaleString ( "en-GB" ) } ${ r . unit ?? "" } ` ;
2930}
3031
31- /** Concentric activity rings in the style of Apple Health. The legend
32- * carries every value as text, so the drawing itself is decorative. */
33- export function Rings ( { rings, size = 168 } : { rings : Ring [ ] ; size ?: number } ) {
32+ /** The plain number of a value, without its unit, for the large legend. */
33+ function bareValue ( r : Ring , n : number ) : string {
34+ if ( r . format ) return r . format ( n ) ;
35+ return Math . round ( n ) . toLocaleString ( "en-GB" ) ;
36+ }
37+
38+ /** Concentric activity rings in the style of Apple Health. Each ring sits in
39+ * a recessed track. The legend carries every value as text, so the drawing
40+ * itself is decorative. `hero` draws thicker rings with a large legend. */
41+ export function Rings ( {
42+ rings,
43+ size = 168 ,
44+ variant = "compact" ,
45+ } : {
46+ rings : Ring [ ] ;
47+ size ?: number ;
48+ variant ?: "compact" | "hero" ;
49+ } ) {
50+ const stroke = variant === "hero" ? 18 : 14 ;
51+ const gap = variant === "hero" ? 5 : 4 ;
3452 const centre = size / 2 ;
53+ const inset = `rings-inset-${ useId ( ) . replace ( / : / g, "" ) } ` ;
54+ const box = size + PAD * 2 ;
3555 return (
36- < figure className = " rings" >
56+ < figure className = { ` rings rings-- ${ variant } ` } >
3757 < svg
3858 width = { size }
3959 height = { size }
40- viewBox = { `0 0 ${ String ( size ) } ${ String ( size ) } ` }
60+ viewBox = { `${ String ( - PAD ) } ${ String ( - PAD ) } ${ String ( box ) } ${ String ( box ) } ` }
4161 role = "presentation"
4262 focusable = "false"
4363 >
64+ < defs >
65+ { /* A dark edge along the top of each track: the light comes from
66+ above, so the track reads as pressed into the card. */ }
67+ < filter id = { inset } x = "-10%" y = "-10%" width = "120%" height = "120%" >
68+ < feOffset dy = "1.5" in = "SourceAlpha" result = "offset" />
69+ < feComposite in = "SourceAlpha" in2 = "offset" operator = "out" result = "edge" />
70+ < feGaussianBlur in = "edge" stdDeviation = "1" result = "soft" />
71+ < feFlood floodColor = "#000" floodOpacity = "0.75" />
72+ < feComposite in2 = "soft" operator = "in" result = "shadow" />
73+ < feMerge >
74+ < feMergeNode in = "SourceGraphic" />
75+ < feMergeNode in = "shadow" />
76+ </ feMerge >
77+ </ filter >
78+ </ defs >
4479 { rings . map ( ( r , i ) => {
45- const radius = centre - STROKE / 2 - i * ( STROKE + GAP ) ;
80+ const radius = centre - stroke / 2 - i * ( stroke + gap ) ;
4681 if ( radius <= 0 ) return null ;
4782 const p = progress ( r . value , r . goal ) ;
4883 return (
@@ -53,13 +88,23 @@ export function Rings({ rings, size = 168 }: { rings: Ring[]; size?: number }) {
5388 cy = { centre }
5489 r = { radius }
5590 stroke = { r . color }
91+ strokeWidth = { stroke }
92+ />
93+ < circle
94+ className = "rings__well"
95+ cx = { centre }
96+ cy = { centre }
97+ r = { radius }
98+ strokeWidth = { stroke }
99+ filter = { `url(#${ inset } )` }
56100 />
57101 < circle
58102 className = "rings__fill"
59103 cx = { centre }
60104 cy = { centre }
61105 r = { radius }
62106 stroke = { r . color }
107+ strokeWidth = { stroke }
63108 pathLength = { 1 }
64109 style = {
65110 {
@@ -77,16 +122,31 @@ export function Rings({ rings, size = 168 }: { rings: Ring[]; size?: number }) {
77122 </ svg >
78123 < figcaption >
79124 < ul className = "rings__legend" >
80- { rings . map ( ( r ) => (
81- < li key = { r . key } >
82- < span className = "bar-chart__swatch" style = { { background : r . color } } />
83- < span className = "rings__name" > { r . name } </ span >
84- < span className = "rings__value" >
85- { legendValue ( r , r . value ) } / { legendValue ( r , r . goal ) }
86- </ span >
87- { r . hint && < span className = "rings__hint" > { r . hint } </ span > }
88- </ li >
89- ) ) }
125+ { rings . map ( ( r ) =>
126+ variant === "hero" ? (
127+ < li key = { r . key } >
128+ < span className = "rings__line" >
129+ < span className = "rings__name" style = { { color : r . color } } >
130+ { r . name }
131+ </ span >
132+ { r . hint && < span className = "rings__hint" > { r . hint } </ span > }
133+ </ span >
134+ < span className = "rings__big" >
135+ < span className = "rings__number" > { bareValue ( r , r . value ) } </ span >
136+ < span className = "rings__of" > of { legendValue ( r , r . goal ) } </ span >
137+ </ span >
138+ </ li >
139+ ) : (
140+ < li key = { r . key } >
141+ < span className = "bar-chart__swatch" style = { { background : r . color } } />
142+ < span className = "rings__name" > { r . name } </ span >
143+ < span className = "rings__value" >
144+ { legendValue ( r , r . value ) } / { legendValue ( r , r . goal ) }
145+ </ span >
146+ { r . hint && < span className = "rings__hint" > { r . hint } </ span > }
147+ </ li >
148+ ) ,
149+ ) }
90150 </ ul >
91151 </ figcaption >
92152 </ figure >
0 commit comments