use topcoat::{
Result,
context::Cx,
icon::{IconData, icon as icon_view},
view::{Attributes, StaticClass, View, class, component, view},
};
const STAT: StaticClass = class!(
"flex flex-col gap-3 rounded-xl border border-border bg-card p-5 text-card-foreground",
"shadow-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
);
const STAT_LINK: StaticClass = class!("hover:border-foreground/20 hover:bg-muted/40");
const STAT_LABEL: StaticClass = class!(
"flex items-center gap-2 text-sm font-medium text-muted-foreground",
"[&>svg]:size-4 [&>svg]:shrink-0",
);
const STAT_VALUE: StaticClass =
class!("text-3xl leading-none font-semibold tracking-tight text-foreground tabular-nums");
const STAT_DETAIL: StaticClass =
class!("flex items-center gap-1 text-sm [&>svg]:size-4 [&>svg]:shrink-0");
const STAT_CHART: StaticClass = class!("-mx-1 h-8 w-[calc(100%+0.5rem)] overflow-visible");
const CHART_WIDTH: f64 = 100.0;
const CHART_HEIGHT: f64 = 24.0;
const CHART_PAD: f64 = 2.0;
#[component]
pub async fn stat(
cx: &Cx,
#[into]
label: String,
#[into]
value: String,
#[into]
#[default]
detail: String,
#[into]
#[default]
detail_icon: Option<IconData>,
#[into]
#[default]
detail_icon_label: String,
#[into]
#[default]
accent: String,
#[default]
chart: Vec<f64>,
#[into]
#[default]
icon: Option<IconData>,
#[into]
#[default]
href: Option<String>,
#[default]
mut attrs: Attributes,
) -> Result<impl View> {
let accent = if accent.is_empty() {
"text-muted-foreground".to_string()
} else {
accent
};
let line = sparkline_points(&chart);
let area = line
.as_ref()
.map(|line| format!("0,{CHART_HEIGHT} {line} {CHART_WIDTH},{CHART_HEIGHT}"));
let view_box = format!("0 0 {CHART_WIDTH} {CHART_HEIGHT}");
let body = view! {
<p class=(STAT_LABEL)>
if let Some(glyph) = icon {
icon_view(
data: glyph,
attrs: topcoat::view::attributes! { aria-hidden="true" }
)
}
(label)
</p>
<p class=(STAT_VALUE)>(value)</p>
if !detail.is_empty() {
<p class=(class!(STAT_DETAIL, accent.clone()))>
if let Some(glyph) = detail_icon {
icon_view(
data: glyph,
attrs: topcoat::view::attributes! { aria-hidden="true" }
)
}
if !detail_icon_label.is_empty() {
<span class="sr-only">
(detail_icon_label)
" "
</span>
}
(detail)
</p>
}
if let (Some(line), Some(area)) = (line, area) {
<svg
class=(class!(STAT_CHART, accent))
viewBox=(view_box)
preserveAspectRatio="none"
aria-hidden="true"
>
<polygon points=(area) fill="currentColor" fill-opacity="0.1" />
<polyline
points=(line)
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
}
};
Ok(view! {
if let Some(href) = href {
<a
href=(href)
class=(class!(STAT, STAT_LINK, attrs.remove("class")))
(attrs)
>
(body)
</a>
} else {
<div class=(class!(STAT, attrs.remove("class"))) (attrs)>(body)</div>
}
})
}
fn sparkline_points(values: &[f64]) -> Option<String> {
let values: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
if values.len() < 2 {
return None;
}
let (min, max) = values
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &v| {
(min.min(v), max.max(v))
});
let span = max - min;
let last = (values.len() - 1) as f64;
let points: Vec<String> = values
.iter()
.enumerate()
.map(|(index, &value)| {
let x = index as f64 * CHART_WIDTH / last;
let share = if span.is_finite() && span > 0.0 {
(max - value) / span
} else {
0.5
};
let y = CHART_PAD + share * (CHART_HEIGHT - 2.0 * CHART_PAD);
format!("{x:.2},{y:.2}")
})
.collect();
Some(points.join(" "))
}
#[cfg(test)]
mod tests;