macbetter

Charts

General

The everyday charts, drawn quietly enough to sit in an Apple app.

Most of these are EvilCharts components themed with useEvilChart. Scatter, sparklines and candlesticks use the adapter's EChart directly. See Charts for how to install both.

Donut

A ring of parts with the total in the middle, like Screen Time's categories. The total is HTML over the canvas, so it uses the system font's tabular figures. The legend gives each part's time in words.

  • Social2h 10m
  • Productivity1h 45m
  • Entertainment1h 5m
  • Other0h 40m
Screen Time today, by category 5h 40m in all. Social took the most, 2h 10m.
Screen Time today, by category
CategoryTime
Social2h 10m
Productivity1h 45m
Entertainment1h 5m
Other0h 40m
"use client"

import { ChartFrame, ChartLegend, useEvilChart } from "@/components/charts"
import { EChartsPieChart } from "@/components/charts/evil/charts/echarts-pie-chart"

/** Screen Time today, in minutes, by category. */
const data = [
  { category: "social", minutes: 130 },
  { category: "productivity", minutes: 105 },
  { category: "entertainment", minutes: 65 },
  { category: "other", minutes: 40 },
]

const series = {
  social: { label: "Social", color: "series3" },
  productivity: { label: "Productivity", color: "series2" },
  entertainment: { label: "Entertainment", color: "series1" },
  other: { label: "Other", color: "fill" },
} as const

const total = data.reduce((sum, d) => sum + d.minutes, 0)
const time = (m: number) => `${Math.floor(m / 60)}h ${m % 60}m`
const swatches = ["var(--mb-chart-3)", "var(--mb-chart-2)", "var(--mb-chart-1)", "var(--mb-fill)"]

export default function ScreenTimeDonut() {
  const { ref, config, chartOptions } = useEvilChart(series)
  return (
    <ChartFrame
      ref={ref}
      title="Screen Time today, by category"
      summary={`${time(total)} in all. Social took the most, ${time(130)}.`}
      table={{ columns: ["Category", "Time"], rows: data.map((d) => [series[d.category as keyof typeof series].label, time(d.minutes)]) }}
      style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "center", gap: 24 }}
    >
      <div style={{ position: "relative", width: 200, height: 200 }}>
        {config && chartOptions ? (
          <EChartsPieChart data={data} dataKey="minutes" nameKey="category" config={config} chartOptions={chartOptions} className="h-full w-full">
            <EChartsPieChart.Tooltip roundness="lg" />
            <EChartsPieChart.Pie innerRadius="70%" outerRadius="96%" cornerRadius={5} paddingAngle={2} />
          </EChartsPieChart>
        ) : null}
        <div aria-hidden="true" style={{ position: "absolute", inset: 0, display: "grid", placeContent: "center", textAlign: "center", pointerEvents: "none" }}>
          <span style={{ font: "var(--mb-text-title-2)", fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>{time(total)}</span>
          <span style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)" }}>Today</span>
        </div>
      </div>
      <ChartLegend
        style={{ display: "grid", gap: 8 }}
        items={data.map((d, i) => ({ label: series[d.category as keyof typeof series].label, color: swatches[i]!, value: time(d.minutes) }))}
      />
    </ChartFrame>
  )
}

Stacked bars

Parts of a whole over time. Put the series people compare most at the bottom, where they share a baseline.

Daily average
5h 15m
  • Productivity
  • Social
  • Entertainment
Screen Time this week, by category A daily average of 5.3 hours. Weekdays lean on productivity apps; the weekend on social and entertainment.
Screen Time this week, by category
DaySocial (h)Productivity (h)Entertainment (h)
Sun2.40.61.9
Mon1.62.80.7
Tue1.83.10.5
Wed1.42.60.9
Thu22.90.6
Fri2.61.91.4
Sat2.21.81.1
"use client"

import { ChartFrame, ChartLegend, useEvilChart } from "@/components/charts"
import { EChartsBarChart } from "@/components/charts/evil/charts/echarts-bar-chart"

/** Screen Time this week, in hours a day, by category. */
const data = [
  { day: "Sun", social: 2.4, productivity: 0.6, entertainment: 1.9 },
  { day: "Mon", social: 1.6, productivity: 2.8, entertainment: 0.7 },
  { day: "Tue", social: 1.8, productivity: 3.1, entertainment: 0.5 },
  { day: "Wed", social: 1.4, productivity: 2.6, entertainment: 0.9 },
  { day: "Thu", social: 2.0, productivity: 2.9, entertainment: 0.6 },
  { day: "Fri", social: 2.6, productivity: 1.9, entertainment: 1.4 },
  { day: "Sat", social: 2.2, productivity: 1.8, entertainment: 1.1 },
]

const series = {
  social: { label: "Social", color: "series3" },
  productivity: { label: "Productivity", color: "series2" },
  entertainment: { label: "Entertainment", color: "series1" },
} as const

const totals = data.map((d) => d.social + d.productivity + d.entertainment)
const average = totals.reduce((a, b) => a + b, 0) / totals.length

export default function ScreenTimeStacked() {
  const { ref, config, chartOptions } = useEvilChart(series)
  return (
    <ChartFrame
      ref={ref}
      title="Screen Time this week, by category"
      summary={`A daily average of ${average.toFixed(1)} hours. Weekdays lean on productivity apps; the weekend on social and entertainment.`}
      table={{ columns: ["Day", "Social (h)", "Productivity (h)", "Entertainment (h)"], rows: data.map((d) => [d.day, d.social, d.productivity, d.entertainment]) }}
      style={{ width: "100%", maxWidth: 560 }}
    >
      <div>
        <div style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", fontWeight: 600, textTransform: "uppercase" }}>Daily average</div>
        <div style={{ font: "var(--mb-text-title-1)", fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
          {Math.floor(average)}h {Math.round((average % 1) * 60)}m
        </div>
      </div>
      <div style={{ height: 220 }}>
        {config && chartOptions ? (
          <EChartsBarChart data={data} config={config} chartOptions={chartOptions} xDataKey="day" stackType="stacked" barRadius={4} className="h-full w-full">
            <EChartsBarChart.Grid />
            <EChartsBarChart.XAxis dataKey="day" hideDots />
            <EChartsBarChart.YAxis hideDots tickFormatter={(v) => `${v}h`} />
            <EChartsBarChart.Tooltip roundness="lg" position="fixed" />
            <EChartsBarChart.Bar dataKey="productivity" />
            <EChartsBarChart.Bar dataKey="social" />
            <EChartsBarChart.Bar dataKey="entertainment" />
          </EChartsBarChart>
        ) : null}
      </div>
      <ChartLegend
        items={[
          { label: "Productivity", color: "var(--mb-chart-2)" },
          { label: "Social", color: "var(--mb-chart-3)" },
          { label: "Entertainment", color: "var(--mb-chart-1)" },
        ]}
      />
    </ChartFrame>
  )
}

Ranked bars

Horizontal bars, longest first, for categories with names. Labels stay readable and the eye runs down the list.

Most used apps this week Messages leads with 6.2 hours, then Safari with 4.7 and Mail with 3.1.
Most used apps this week
AppHours
Messages6.2
Safari4.7
Mail3.1
Music2.3
Maps1.3
Notes0.8
"use client"

import { ChartFrame, useEvilChart } from "@/components/charts"
import { EChartsBarChart } from "@/components/charts/evil/charts/echarts-bar-chart"

/** Most used apps this week, in hours, most first. */
const data = [
  { app: "Messages", hours: 6.2 },
  { app: "Safari", hours: 4.7 },
  { app: "Mail", hours: 3.1 },
  { app: "Music", hours: 2.3 },
  { app: "Maps", hours: 1.3 },
  { app: "Notes", hours: 0.8 },
]

const series = { hours: { label: "Hours", color: "series3" } } as const

export default function MostUsedApps() {
  const { ref, config, chartOptions } = useEvilChart(series)
  return (
    <ChartFrame
      ref={ref}
      title="Most used apps this week"
      summary="Messages leads with 6.2 hours, then Safari with 4.7 and Mail with 3.1."
      table={{ columns: ["App", "Hours"], rows: data.map((d) => [d.app, d.hours]) }}
      style={{ width: "100%", maxWidth: 480 }}
    >
      <div style={{ height: 230 }}>
        {config && chartOptions ? (
          <EChartsBarChart data={data} config={config} chartOptions={chartOptions} layout="horizontal" barRadius={5} className="h-full w-full">
            <EChartsBarChart.YAxis dataKey="app" hideDots />
            <EChartsBarChart.XAxis hideDots tickFormatter={(v) => `${v}h`} />
            <EChartsBarChart.Grid />
            <EChartsBarChart.Tooltip roundness="lg" />
            <EChartsBarChart.Bar dataKey="hours" enableHoverHighlight />
          </EChartsBarChart>
        ) : null}
      </div>
    </ChartFrame>
  )
}

Lines

Several series over the same months. Give each line its own categorical color, at most three or four, and a legend that names them.

  • Cupertino
  • Seattle
  • Austin
Average daily high by month Austin is warmest all year, peaking at 97°F in August. Seattle is coolest, from 46°F in December to 77°F in summer.
Average daily high by month
MonthCupertino (°F)Seattle (°F)Austin (°F)
Jan594862
Feb625166
Mar655573
Apr696080
May746687
Jun797193
Jul837796
Aug837797
Sep817191
Oct756082
Nov665171
Dec594663
"use client"

import { ChartFrame, ChartLegend, useEvilChart } from "@/components/charts"
import { EChartsLineChart } from "@/components/charts/evil/charts/echarts-line-chart"

/** Average daily high, °F, by month. */
const data = [
  { month: "Jan", cupertino: 59, seattle: 48, austin: 62 },
  { month: "Feb", cupertino: 62, seattle: 51, austin: 66 },
  { month: "Mar", cupertino: 65, seattle: 55, austin: 73 },
  { month: "Apr", cupertino: 69, seattle: 60, austin: 80 },
  { month: "May", cupertino: 74, seattle: 66, austin: 87 },
  { month: "Jun", cupertino: 79, seattle: 71, austin: 93 },
  { month: "Jul", cupertino: 83, seattle: 77, austin: 96 },
  { month: "Aug", cupertino: 83, seattle: 77, austin: 97 },
  { month: "Sep", cupertino: 81, seattle: 71, austin: 91 },
  { month: "Oct", cupertino: 75, seattle: 60, austin: 82 },
  { month: "Nov", cupertino: 66, seattle: 51, austin: 71 },
  { month: "Dec", cupertino: 59, seattle: 46, austin: 63 },
]

const series = {
  cupertino: { label: "Cupertino", color: "series1" },
  seattle: { label: "Seattle", color: "series3" },
  austin: { label: "Austin", color: "series4" },
} as const

export default function AverageHighs() {
  const { ref, config, chartOptions } = useEvilChart(series)
  return (
    <ChartFrame
      ref={ref}
      title="Average daily high by month"
      summary="Austin is warmest all year, peaking at 97°F in August. Seattle is coolest, from 46°F in December to 77°F in summer."
      table={{ columns: ["Month", "Cupertino (°F)", "Seattle (°F)", "Austin (°F)"], rows: data.map((d) => [d.month, d.cupertino, d.seattle, d.austin]) }}
      style={{ width: "100%", maxWidth: 600 }}
    >
      <ChartLegend
        items={[
          { label: "Cupertino", color: "var(--mb-chart-1)", shape: "line" },
          { label: "Seattle", color: "var(--mb-chart-3)", shape: "line" },
          { label: "Austin", color: "var(--mb-chart-4)", shape: "line" },
        ]}
      />
      <div style={{ height: 240 }}>
        {config && chartOptions ? (
          <EChartsLineChart data={data} config={config} chartOptions={chartOptions} xDataKey="month" curveType="monotone" className="h-full w-full">
            <EChartsLineChart.Grid />
            <EChartsLineChart.XAxis dataKey="month" hideDots />
            <EChartsLineChart.YAxis hideDots tickFormatter={(v) => `${v}°`} />
            <EChartsLineChart.Tooltip roundness="lg" position="fixed" />
            <EChartsLineChart.Line dataKey="cupertino" strokeWidth={2.5}>
              <EChartsLineChart.ActiveDot variant="border" />
            </EChartsLineChart.Line>
            <EChartsLineChart.Line dataKey="seattle" strokeWidth={2.5}>
              <EChartsLineChart.ActiveDot variant="border" />
            </EChartsLineChart.Line>
            <EChartsLineChart.Line dataKey="austin" strokeWidth={2.5}>
              <EChartsLineChart.ActiveDot variant="border" />
            </EChartsLineChart.Line>
          </EChartsLineChart>
        ) : null}
      </div>
    </ChartFrame>
  )
}

Calendar heatmap

A year at a glance, one cell per day. Four steps of one color, set with thresholds that mean something: here, 30 minutes closes the Exercise ring.

222 days with your Exercise ring closedNov 2025–Nov 2026
Exercise minutes, last 12 months You exercised on 281 days and closed your Exercise ring on 222. Summer weekends were the most active.
Exercise minutes, last 12 months
MonthDays with exerciseMinutes
December 2025241001
January 2026251003
February 202623860
March 2026241020
April 202621722
May 2026231196
June 2026211087
July 2026241113
August 2026271335
September 2026221172
October 202625987
November 202612502
"use client"

import { ChartFrame, useEvilChart } from "@/components/charts"
import { EChartsHeatmapChart, type HeatmapDatum } from "@/components/charts/evil/charts/echarts-heatmap-chart"

/** A year of Exercise minutes, one per day, seeded so it draws the same every time. */
function exerciseYear(): HeatmapDatum[] {
  let seed = 11
  const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)
  const end = Date.UTC(2026, 10, 14)
  return Array.from({ length: 364 }, (_, i) => {
    const day = new Date(end - (363 - i) * 86_400_000)
    const weekend = day.getUTCDay() === 0 || day.getUTCDay() === 6
    const summer = day.getUTCMonth() >= 4 && day.getUTCMonth() <= 8
    const rest = random() < (weekend ? 0.15 : 0.22)
    const minutes = rest ? 0 : Math.round(12 + random() * (weekend ? 70 : 40) + (summer ? 12 : 0))
    return { date: day.toISOString().slice(0, 10), value: minutes }
  }).filter((d) => d.value > 0)
}

const data = exerciseYear()
const series = { exercise: { label: "Exercise", color: ["exercise/25", "exercise/50", "exercise/75", "exercise"] } } as const
const closed = data.filter((d) => d.value >= 30).length

export default function ExerciseYear() {
  const { ref, config, chartOptions } = useEvilChart(series)
  return (
    <ChartFrame
      ref={ref}
      title="Exercise minutes, last 12 months"
      summary={`You exercised on ${data.length} days and closed your Exercise ring on ${closed}. Summer weekends were the most active.`}
      table={{
        columns: ["Month", "Days with exercise", "Minutes"],
        rows: Array.from({ length: 12 }, (_, m) => {
          const month = new Date(Date.UTC(2025, 11 + m, 1))
          const days = data.filter((d) => d.date.startsWith(month.toISOString().slice(0, 7)))
          return [month.toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC" }), days.length, days.reduce((s, d) => s + d.value, 0)]
        }),
      }}
      style={{ width: "100%", maxWidth: 760 }}
    >
      <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }}>
        <span style={{ font: "var(--mb-text-headline)" }}>{closed} days with your Exercise ring closed</span>
        <span style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)" }}>Nov 2025–Nov 2026</span>
      </div>
      <div style={{ height: 150 }}>
        {config && chartOptions ? (
          <EChartsHeatmapChart data={data} config={config} chartOptions={chartOptions} range={["2025-11-16", "2026-11-14"]} thresholds={[20, 30, 45]} className="h-full w-full">
            <EChartsHeatmapChart.Cell dataKey="exercise" radius={3} gap={3} />
            <EChartsHeatmapChart.MonthLabel />
            <EChartsHeatmapChart.DayLabel labels={["", "Mon", "", "Wed", "", "Fri", ""]} />
            <EChartsHeatmapChart.Tooltip roundness="lg" valueFormatter={(v) => `${v} min`} />
            <EChartsHeatmapChart.Legend />
          </EChartsHeatmapChart>
        ) : null}
      </div>
    </ChartFrame>
  )
}

Radial progress

Concentric bars toward a goal, with round caps and a dim track. For a single reading in a range, use macbetter's gauge instead.

  • Water92%
  • Mindful minutes64%
  • Sleep86%
Goals met this month Water 92%, sleep 86% and mindful minutes 64% of this month's goals.
Goals met this month
GoalMet
Sleep86%
Mindful minutes64%
Water92%
"use client"

import { ChartFrame, ChartLegend, useEvilChart } from "@/components/charts"
import { EChartsRadialChart } from "@/components/charts/evil/charts/echarts-radial-chart"

/** How much of this month's goals you've met so far, in percent. */
const data = [
  { goal: "sleep", percent: 86 },
  { goal: "mindful", percent: 64 },
  { goal: "water", percent: 92 },
]

const series = {
  sleep: { label: "Sleep", color: "core" },
  mindful: { label: "Mindful minutes", color: "mint" },
  water: { label: "Water", color: "cyan" },
} as const

export default function MonthlyGoals() {
  const { ref, config, chartOptions } = useEvilChart(series)
  return (
    <ChartFrame
      ref={ref}
      title="Goals met this month"
      summary="Water 92%, sleep 86% and mindful minutes 64% of this month's goals."
      table={{ columns: ["Goal", "Met"], rows: data.map((d) => [series[d.goal as keyof typeof series].label, `${d.percent}%`]) }}
      style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "center", gap: 24 }}
    >
      <div style={{ width: 190, height: 190 }}>
        {config && chartOptions ? (
          <EChartsRadialChart data={data} nameKey="goal" config={config} chartOptions={chartOptions} max={100} innerRadius="34%" outerRadius="100%" className="h-full w-full">
            <EChartsRadialChart.Tooltip roundness="lg" />
            <EChartsRadialChart.RadialBar dataKey="percent" showBackground cornerRadius={99} barSize={14} />
          </EChartsRadialChart>
        ) : null}
      </div>
      <ChartLegend
        style={{ display: "grid", gap: 8 }}
        items={[
          { label: "Water", color: "var(--mb-cyan)", value: "92%" },
          { label: "Mindful minutes", color: "var(--mb-mint)", value: "64%" },
          { label: "Sleep", color: "var(--mb-chart-core)", value: "86%" },
        ]}
      />
    </ChartFrame>
  )
}

Scatter

Two measurements of the same thing, here distance and pace for each run. Turn the pace axis over so faster is higher, and say so.

Faster pace is higher
Distance and pace, this year's runs Longer runs are slower: short runs average around 8′30″ a mile, and the half marathon was 10′18″.
Distance and pace, this year's runs
RunDistance (mi)Pace (/mi)
Run 13.19′24″
Run 259′06″
Run 32.48′36″
Run 46.29′36″
Run 53.58′54″
Run 68.19′54″
Run 748′48″
Run 83.28′30″
Run 910.210′06″
Run 105.49′12″
Run 1128′12″
Run 126.89′30″
Run 134.48′54″
Run 143.18′42″
Run 1513.110′18″
Run 165.18′54″
Run 1738′18″
Run 187.29′24″
Run 194.68′36″
Run 203.48′24″
Run 2199′42″
Run 225.68′48″
Run 232.68′00″
Run 2469′00″
"use client"

import * as React from "react"
import { alpha, baseOption, ChartFrame, EChart, valueAxis, type ChartTheme } from "@/components/charts"

/** This year's runs: distance in miles and average pace in minutes per mile. */
const runs = [
  [3.1, 9.4], [5.0, 9.1], [2.4, 8.6], [6.2, 9.6], [3.5, 8.9], [8.1, 9.9], [4.0, 8.8], [3.2, 8.5],
  [10.2, 10.1], [5.4, 9.2], [2.0, 8.2], [6.8, 9.5], [4.4, 8.9], [3.1, 8.7], [13.1, 10.3], [5.1, 8.9],
  [3.0, 8.3], [7.2, 9.4], [4.6, 8.6], [3.4, 8.4], [9.0, 9.7], [5.6, 8.8], [2.6, 8.0], [6.0, 9.0],
] as [number, number][]

const pace = (m: number) => `${Math.floor(m)}′${String(Math.round((m % 1) * 60)).padStart(2, "0")}″`

export default function RunsScatter() {
  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 40, top: 10, bottom: 24 },
      xAxis: {
        type: "value",
        min: 0,
        max: 14,
        interval: 2,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, formatter: (v: number) => `${v} mi` },
        splitLine: { lineStyle: { color: theme.colors.grid, type: [2, 3] } },
      },
      yAxis: valueAxis(theme, {
        min: 7.5,
        max: 10.5,
        interval: 1,
        inverse: true,
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, formatter: (v: number) => pace(v) },
      }),
      tooltip: {
        trigger: "item",
        backgroundColor: theme.colors.bg,
        borderWidth: 0,
        textStyle: { color: theme.colors.label, fontFamily: theme.font, fontSize: theme.fontSize + 1 },
        formatter: ({ value }: { value: [number, number] }) => `${value[0]} mi · ${pace(value[1])} /mi`,
      },
      series: [
        {
          id: "runs",
          type: "scatter",
          data: runs,
          symbolSize: 11,
          itemStyle: { color: alpha(theme.colors.green, 0.85), borderColor: theme.colors.bg, borderWidth: 1.5 },
          emphasis: { scale: 1.3 },
        },
      ],
    }),
    []
  )
  return (
    <ChartFrame
      title="Distance and pace, this year's runs"
      summary="Longer runs are slower: short runs average around 8′30″ a mile, and the half marathon was 10′18″."
      table={{ columns: ["Run", "Distance (mi)", "Pace (/mi)"], rows: runs.map(([d, p], i) => [`Run ${i + 1}`, d, pace(p)]) }}
      style={{ width: "100%", maxWidth: 520 }}
    >
      <div style={{ font: "var(--mb-text-headline)" }}>Faster pace is higher</div>
      <EChart option={option} height={230} />
    </ChartFrame>
  )
}

Sparklines

A trend with no axes, beside the number it belongs to. A sparkline is decorative; the tile's text says the value and the trend.

Steps
8,214 avg
Steps, last 14 days 8,214 avg. Up 6% from the 14 days before.
Resting Heart Rate
55 BPM
Resting Heart Rate, last 14 days 55 BPM. Down 3 BPM.
Sleep
7h 4m avg
Sleep, last 14 days 7h 4m avg. About the same.
Weight
168.4 lb
Weight, last 14 days 168.4 lb. Down 2.6 lb.
import { Card } from "macbetter/card"
import { ChartFrame, Sparkline, type ChartColor } from "@/components/charts"

/** The last 14 days of four measurements, for summary tiles. */
const tiles: { label: string; value: string; unit: string; color: ChartColor; values: number[]; trend: string }[] = [
  { label: "Steps", value: "8,214", unit: "avg", color: "orange", values: [6.4, 9.8, 7.1, 11.2, 8.5, 8.0, 6.4, 7.9, 9.1, 8.8, 7.7, 10.2, 8.4, 8.2], trend: "Up 6% from the 14 days before" },
  { label: "Resting Heart Rate", value: "55", unit: "BPM", color: "pink", values: [58, 57, 58, 56, 57, 56, 56, 55, 56, 55, 55, 54, 55, 55], trend: "Down 3 BPM" },
  { label: "Sleep", value: "7h 4m", unit: "avg", color: "core", values: [6.8, 7.2, 6.5, 7.4, 7.1, 6.9, 7.6, 7.0, 7.3, 6.6, 7.2, 7.5, 6.9, 7.1], trend: "About the same" },
  { label: "Weight", value: "168.4", unit: "lb", color: "purple", values: [171, 170.6, 170.8, 170.1, 169.9, 170.2, 169.4, 169.1, 169.3, 168.8, 168.9, 168.5, 168.6, 168.4], trend: "Down 2.6 lb" },
]

export default function SummaryTiles() {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 12, width: "100%", maxWidth: 720 }}>
      {tiles.map((tile) => (
        <Card key={tile.label} style={{ display: "grid", gap: 6, padding: "12px 14px" }}>
          <ChartFrame title={`${tile.label}, last 14 days`} summary={`${tile.value} ${tile.unit}. ${tile.trend}.`} style={{ gap: 6 }}>
            <div style={{ color: `var(--mb-${tile.color === "core" ? "chart-core" : tile.color})`, font: "var(--mb-text-subheadline)", fontWeight: 600 }}>{tile.label}</div>
            <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 8 }}>
              <div style={{ fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
                <span style={{ font: "var(--mb-text-title-2)", fontFamily: "var(--mb-font-rounded)" }}>{tile.value}</span>
                <span style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", fontWeight: 600 }}> {tile.unit}</span>
              </div>
              <Sparkline values={tile.values} color={tile.color} area width={64} height={30} />
            </div>
          </ChartFrame>
        </Card>
      ))}
    </div>
  )
}

Candlestick

Each day's open, close, high and low. Green when a day closed above its open, red when below. Scrub to read a day in the row above the chart.

Last
Nov 13
Open
228.90
High
232.10
Low
227.90
Close
231.40
MCBT daily prices, last 22 trading days MCBT fell from 238.60 to a low of 218.90 on October 29, then recovered to close at 231.40.
MCBT daily prices, last 22 trading days
DayOpenCloseLowHigh
Oct 15238.60236.90235.20239.80
Oct 16236.90234.10233.00237.50
Oct 19234.00235.80232.60236.40
Oct 20235.90233.20232.10236.80
Oct 21233.00229.40228.70233.60
Oct 22229.60230.80227.90231.50
Oct 23230.90227.60226.80231.40
Oct 26227.20224.90223.80228.00
Oct 27225.10226.70224.20227.90
Oct 28226.80222.40221.60227.30
Oct 29222.00220.30218.90223.10
Oct 30220.60223.90220.10224.60
Nov 2224.00225.60222.80226.90
Nov 3225.80224.10223.00227.20
Nov 4224.00227.80223.50228.60
Nov 5228.00226.20225.40229.10
Nov 6226.00229.50225.70230.20
Nov 9229.80228.40227.10231.00
Nov 10228.20230.90227.60231.80
Nov 11231.00229.70228.80232.40
Nov 12229.50228.30227.40230.60
Nov 13228.90231.40227.90232.10
"use client"

import * as React from "react"
import { baseOption, ChartFrame, EChart, formatters, valueAxis, type ChartTheme, type Scrub } from "@/components/charts"

/** MCBT daily open, close, low and high for the last 22 trading days. */
const days = [
  ["Oct 15", 238.6, 236.9, 235.2, 239.8], ["Oct 16", 236.9, 234.1, 233.0, 237.5], ["Oct 19", 234.0, 235.8, 232.6, 236.4],
  ["Oct 20", 235.9, 233.2, 232.1, 236.8], ["Oct 21", 233.0, 229.4, 228.7, 233.6], ["Oct 22", 229.6, 230.8, 227.9, 231.5],
  ["Oct 23", 230.9, 227.6, 226.8, 231.4], ["Oct 26", 227.2, 224.9, 223.8, 228.0], ["Oct 27", 225.1, 226.7, 224.2, 227.9],
  ["Oct 28", 226.8, 222.4, 221.6, 227.3], ["Oct 29", 222.0, 220.3, 218.9, 223.1], ["Oct 30", 220.6, 223.9, 220.1, 224.6],
  ["Nov 2", 224.0, 225.6, 222.8, 226.9], ["Nov 3", 225.8, 224.1, 223.0, 227.2], ["Nov 4", 224.0, 227.8, 223.5, 228.6],
  ["Nov 5", 228.0, 226.2, 225.4, 229.1], ["Nov 6", 226.0, 229.5, 225.7, 230.2], ["Nov 9", 229.8, 228.4, 227.1, 231.0],
  ["Nov 10", 228.2, 230.9, 227.6, 231.8], ["Nov 11", 231.0, 229.7, 228.8, 232.4], ["Nov 12", 229.5, 228.3, 227.4, 230.6],
  ["Nov 13", 228.9, 231.4, 227.9, 232.1],
] as [string, number, number, number, number][]

export default function Candlesticks() {
  const [scrub, setScrub] = React.useState<Scrub | null>(null)
  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 44, top: 8, bottom: 24 },
      xAxis: {
        type: "category",
        data: days.map(([d]) => d),
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, interval: 4 },
      },
      yAxis: valueAxis(theme, { scale: true, splitNumber: 4, axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize } }),
      series: [
        {
          id: "ohlc",
          type: "candlestick",
          barWidth: "56%",
          // ECharts wants [open, close, low, high].
          data: days.map(([, open, close, low, high]) => [open, close, low, high]),
          itemStyle: {
            color: theme.colors.up,
            borderColor: theme.colors.up,
            color0: theme.colors.down,
            borderColor0: theme.colors.down,
            borderWidth: 1.25,
          },
          emphasis: { disabled: true },
        },
      ],
    }),
    []
  )
  const day = scrub ? days[scrub.index]! : days.at(-1)!
  const [label, open, close, low, high] = day
  const up = close >= open
  return (
    <ChartFrame
      title="MCBT daily prices, last 22 trading days"
      summary="MCBT fell from 238.60 to a low of 218.90 on October 29, then recovered to close at 231.40."
      table={{ columns: ["Day", "Open", "Close", "Low", "High"], rows: days.map((d) => [d[0], ...d.slice(1).map((v) => formatters.price.format(v as number))]) }}
      style={{ width: "100%", maxWidth: 600 }}
    >
      <dl style={{ display: "flex", flexWrap: "wrap", gap: "4px 18px", margin: 0, font: "var(--mb-text-footnote)", fontVariantNumeric: "tabular-nums" }}>
        <div style={{ display: "flex", gap: 6 }}>
          <dt style={{ color: "var(--mb-label-secondary)" }}>{scrub ? "Day" : "Last"}</dt>
          <dd style={{ margin: 0, fontWeight: 600 }}>{label}</dd>
        </div>
        {(
          [
            ["Open", open],
            ["High", high],
            ["Low", low],
            ["Close", close],
          ] as const
        ).map(([name, value]) => (
          <div key={name} style={{ display: "flex", gap: 6 }}>
            <dt style={{ color: "var(--mb-label-secondary)" }}>{name}</dt>
            <dd style={{ margin: 0, fontWeight: 600, color: name === "Close" ? (up ? "var(--mb-green)" : "var(--mb-red)") : undefined }}>
              {formatters.price.format(value)}
            </dd>
          </div>
        ))}
      </dl>
      <EChart
        option={option}
        height={240}
        onScrub={setScrub}
        scrub={{
          label: "Day",
          count: days.length,
          valueText: (i) => {
            const [d, o, c, l, h] = days[i]!
            return `${d}: open ${o}, high ${h}, low ${l}, close ${c}`
          },
        }}
      />
    </ChartFrame>
  )
}

Accessibility

Every chart here is a ChartFrame: a named figure with a one-sentence summary and a hidden table of its data. EvilCharts tooltips are for pointers; the table is what a screen reader gets.