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
| Category | Time |
|---|---|
| Social | 2h 10m |
| Productivity | 1h 45m |
| Entertainment | 1h 5m |
| Other | 0h 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.
- Productivity
- Social
- Entertainment
| Day | Social (h) | Productivity (h) | Entertainment (h) |
|---|---|---|---|
| Sun | 2.4 | 0.6 | 1.9 |
| Mon | 1.6 | 2.8 | 0.7 |
| Tue | 1.8 | 3.1 | 0.5 |
| Wed | 1.4 | 2.6 | 0.9 |
| Thu | 2 | 2.9 | 0.6 |
| Fri | 2.6 | 1.9 | 1.4 |
| Sat | 2.2 | 1.8 | 1.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.
| App | Hours |
|---|---|
| Messages | 6.2 |
| Safari | 4.7 |
| 3.1 | |
| Music | 2.3 |
| Maps | 1.3 |
| Notes | 0.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
| Month | Cupertino (°F) | Seattle (°F) | Austin (°F) |
|---|---|---|---|
| Jan | 59 | 48 | 62 |
| Feb | 62 | 51 | 66 |
| Mar | 65 | 55 | 73 |
| Apr | 69 | 60 | 80 |
| May | 74 | 66 | 87 |
| Jun | 79 | 71 | 93 |
| Jul | 83 | 77 | 96 |
| Aug | 83 | 77 | 97 |
| Sep | 81 | 71 | 91 |
| Oct | 75 | 60 | 82 |
| Nov | 66 | 51 | 71 |
| Dec | 59 | 46 | 63 |
"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.
| Month | Days with exercise | Minutes |
|---|---|---|
| December 2025 | 24 | 1001 |
| January 2026 | 25 | 1003 |
| February 2026 | 23 | 860 |
| March 2026 | 24 | 1020 |
| April 2026 | 21 | 722 |
| May 2026 | 23 | 1196 |
| June 2026 | 21 | 1087 |
| July 2026 | 24 | 1113 |
| August 2026 | 27 | 1335 |
| September 2026 | 22 | 1172 |
| October 2026 | 25 | 987 |
| November 2026 | 12 | 502 |
"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%
| Goal | Met |
|---|---|
| Sleep | 86% |
| Mindful minutes | 64% |
| Water | 92% |
"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.
| Run | Distance (mi) | Pace (/mi) |
|---|---|---|
| Run 1 | 3.1 | 9′24″ |
| Run 2 | 5 | 9′06″ |
| Run 3 | 2.4 | 8′36″ |
| Run 4 | 6.2 | 9′36″ |
| Run 5 | 3.5 | 8′54″ |
| Run 6 | 8.1 | 9′54″ |
| Run 7 | 4 | 8′48″ |
| Run 8 | 3.2 | 8′30″ |
| Run 9 | 10.2 | 10′06″ |
| Run 10 | 5.4 | 9′12″ |
| Run 11 | 2 | 8′12″ |
| Run 12 | 6.8 | 9′30″ |
| Run 13 | 4.4 | 8′54″ |
| Run 14 | 3.1 | 8′42″ |
| Run 15 | 13.1 | 10′18″ |
| Run 16 | 5.1 | 8′54″ |
| Run 17 | 3 | 8′18″ |
| Run 18 | 7.2 | 9′24″ |
| Run 19 | 4.6 | 8′36″ |
| Run 20 | 3.4 | 8′24″ |
| Run 21 | 9 | 9′42″ |
| Run 22 | 5.6 | 8′48″ |
| Run 23 | 2.6 | 8′00″ |
| Run 24 | 6 | 9′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.
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
| Day | Open | Close | Low | High |
|---|---|---|---|---|
| Oct 15 | 238.60 | 236.90 | 235.20 | 239.80 |
| Oct 16 | 236.90 | 234.10 | 233.00 | 237.50 |
| Oct 19 | 234.00 | 235.80 | 232.60 | 236.40 |
| Oct 20 | 235.90 | 233.20 | 232.10 | 236.80 |
| Oct 21 | 233.00 | 229.40 | 228.70 | 233.60 |
| Oct 22 | 229.60 | 230.80 | 227.90 | 231.50 |
| Oct 23 | 230.90 | 227.60 | 226.80 | 231.40 |
| Oct 26 | 227.20 | 224.90 | 223.80 | 228.00 |
| Oct 27 | 225.10 | 226.70 | 224.20 | 227.90 |
| Oct 28 | 226.80 | 222.40 | 221.60 | 227.30 |
| Oct 29 | 222.00 | 220.30 | 218.90 | 223.10 |
| Oct 30 | 220.60 | 223.90 | 220.10 | 224.60 |
| Nov 2 | 224.00 | 225.60 | 222.80 | 226.90 |
| Nov 3 | 225.80 | 224.10 | 223.00 | 227.20 |
| Nov 4 | 224.00 | 227.80 | 223.50 | 228.60 |
| Nov 5 | 228.00 | 226.20 | 225.40 | 229.10 |
| Nov 6 | 226.00 | 229.50 | 225.70 | 230.20 |
| Nov 9 | 229.80 | 228.40 | 227.10 | 231.00 |
| Nov 10 | 228.20 | 230.90 | 227.60 | 231.80 |
| Nov 11 | 231.00 | 229.70 | 228.80 | 232.40 |
| Nov 12 | 229.50 | 228.30 | 227.40 | 230.60 |
| Nov 13 | 228.90 | 231.40 | 227.90 | 232.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.