tablo_ui/components/composites/
stat.rs1use topcoat::{
2 Result,
3 context::Cx,
4 icon::{IconData, icon as icon_view},
6 view::{Attributes, StaticClass, View, class, component, view},
7};
8
9const STAT: StaticClass = class!(
10 "flex flex-col gap-3 rounded-xl border border-border bg-card p-5 text-card-foreground",
11 "shadow-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
12);
13const STAT_LINK: StaticClass = class!("hover:border-foreground/20 hover:bg-muted/40");
15const STAT_LABEL: StaticClass = class!(
16 "flex items-center gap-2 text-sm font-medium text-muted-foreground",
17 "[&>svg]:size-4 [&>svg]:shrink-0",
18);
19const STAT_VALUE: StaticClass =
20 class!("text-3xl leading-none font-semibold tracking-tight text-foreground tabular-nums");
21const STAT_DETAIL: StaticClass =
22 class!("flex items-center gap-1 text-sm [&>svg]:size-4 [&>svg]:shrink-0");
23const STAT_CHART: StaticClass = class!("-mx-1 h-8 w-[calc(100%+0.5rem)] overflow-visible");
24
25const CHART_WIDTH: f64 = 100.0;
27const CHART_HEIGHT: f64 = 24.0;
28const CHART_PAD: f64 = 2.0;
30
31#[component]
63pub async fn stat(
64 cx: &Cx,
65 #[into]
67 label: String,
68 #[into]
70 value: String,
71 #[into]
73 #[default]
74 detail: String,
75 #[into]
77 #[default]
78 detail_icon: Option<IconData>,
79 #[into]
82 #[default]
83 detail_icon_label: String,
84 #[into]
87 #[default]
88 accent: String,
89 #[default]
92 chart: Vec<f64>,
93 #[into]
95 #[default]
96 icon: Option<IconData>,
97 #[into]
99 #[default]
100 href: Option<String>,
101 #[default]
103 mut attrs: Attributes,
104) -> Result<impl View> {
105 let accent = if accent.is_empty() {
106 "text-muted-foreground".to_string()
107 } else {
108 accent
109 };
110 let line = sparkline_points(&chart);
111 let area = line
112 .as_ref()
113 .map(|line| format!("0,{CHART_HEIGHT} {line} {CHART_WIDTH},{CHART_HEIGHT}"));
114 let view_box = format!("0 0 {CHART_WIDTH} {CHART_HEIGHT}");
115 let body = view! {
116 <p class=(STAT_LABEL)>
117 if let Some(glyph) = icon {
118 icon_view(
119 data: glyph,
120 attrs: topcoat::view::attributes! { aria-hidden="true" }
121 )
122 }
123 (label)
124 </p>
125 <p class=(STAT_VALUE)>(value)</p>
126 if !detail.is_empty() {
127 <p class=(class!(STAT_DETAIL, accent.clone()))>
128 if let Some(glyph) = detail_icon {
129 icon_view(
130 data: glyph,
131 attrs: topcoat::view::attributes! { aria-hidden="true" }
132 )
133 }
134 if !detail_icon_label.is_empty() {
135 <span class="sr-only">
136 (detail_icon_label)
137 " "
138 </span>
139 }
140 (detail)
141 </p>
142 }
143 if let (Some(line), Some(area)) = (line, area) {
144 <svg
145 class=(class!(STAT_CHART, accent))
146 viewBox=(view_box)
147 preserveAspectRatio="none"
148 aria-hidden="true"
149 >
150 <polygon points=(area) fill="currentColor" fill-opacity="0.1" />
151 <polyline
152 points=(line)
153 fill="none"
154 stroke="currentColor"
155 stroke-width="2"
156 stroke-linejoin="round"
157 stroke-linecap="round"
158 vector-effect="non-scaling-stroke"
159 />
160 </svg>
161 }
162 };
163 Ok(view! {
164 if let Some(href) = href {
165 <a
166 href=(href)
167 class=(class!(STAT, STAT_LINK, attrs.remove("class")))
168 (attrs)
169 >
170 (body)
171 </a>
172 } else {
173 <div class=(class!(STAT, attrs.remove("class"))) (attrs)>(body)</div>
174 }
175 })
176}
177
178fn sparkline_points(values: &[f64]) -> Option<String> {
182 let values: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
183 if values.len() < 2 {
184 return None;
185 }
186 let (min, max) = values
187 .iter()
188 .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &v| {
189 (min.min(v), max.max(v))
190 });
191 let span = max - min;
192 let last = (values.len() - 1) as f64;
193 let points: Vec<String> = values
194 .iter()
195 .enumerate()
196 .map(|(index, &value)| {
197 let x = index as f64 * CHART_WIDTH / last;
198 let share = if span.is_finite() && span > 0.0 {
200 (max - value) / span
201 } else {
202 0.5
203 };
204 let y = CHART_PAD + share * (CHART_HEIGHT - 2.0 * CHART_PAD);
205 format!("{x:.2},{y:.2}")
206 })
207 .collect();
208 Some(points.join(" "))
209}
210
211#[cfg(test)]
212mod tests;