Expand chart examples and derived surfaces

This commit is contained in:
Ronan Berder
2026-06-19 16:42:15 +08:00
parent 1d33827162
commit 0ae97ae627
4 changed files with 429 additions and 69 deletions
+21 -17
View File
@@ -1,7 +1,12 @@
import ChartExample from "../site/ChartExample.astro"
import ChartExamples from "../site/ChartExamples.astro"
# Chart
<Callout type="warning" title="Alpha API" icon="triangle-alert">
Chart support is alpha. The JavaScript API, generated markup, and option names may change before the stable 1.0 release.
</Callout>
Use Chart.js with Basecoat defaults for themed colors, external tooltips, and optional generated legends.
<ChartExample />
@@ -9,7 +14,7 @@ Use Chart.js with Basecoat defaults for themed colors, external tooltips, and op
Chart is an optional JavaScript helper. Load Chart.js first, then `basecoat.js`, then `chart.js`.
```html
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.0-beta.2/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.0-beta.2/dist/js/chart.min.js" defer></script>
```
@@ -45,22 +50,6 @@ Chart is an optional JavaScript helper. Load Chart.js first, then `basecoat.js`,
Charts default to `aspect-video`. Use a height, `min-h-*`, or `aspect-*` utility on a parent element when you need a different measuring box.
## Card Composition
Chart does not own titles, descriptions, or framing. Compose those with Card or your own markup.
```html
<div class="card">
<header>
<h2>Bar Chart - Multiple</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="visitors-chart" aria-label="Monthly visitors"></canvas>
</section>
</div>
```
## Legend
Set `legend: true` to generate a Basecoat-styled legend after the canvas.
@@ -90,7 +79,9 @@ window.basecoat.chart("#revenue-chart", {
recurring: {
label: "Recurring",
color: "var(--chart-1)",
surface: "gradient",
dataset: {
fill: true,
tension: 0.35,
},
},
@@ -155,6 +146,10 @@ window.basecoat.chart("#custom-chart", {
`basecoat.chart()` returns the Chart.js instance for single targets and an array of instances for multi-target selectors.
## Examples
<ChartExamples />
## API
| Option | Default | Description |
@@ -168,3 +163,12 @@ window.basecoat.chart("#custom-chart", {
| `options` | `{}` | Raw Chart.js options. |
| `plugins` | `[]` | Raw Chart.js plugins. |
| `chartData` | `undefined` | Complete Chart.js data object. Overrides the data mapper. |
### Series options
| Option | Description |
| --- | --- |
| `label` | Display label for legends and tooltips. |
| `color` | Base color for the series stroke, fill, legend marker, and tooltip marker. |
| `surface` | Derives a fill from `color`. Use `true` for a translucent fill, `"gradient"` for an upstream-style vertical fade, or `{ from, to }` for custom opacity stops. |
| `dataset` | Raw Chart.js dataset options. |
+223 -33
View File
@@ -1,65 +1,255 @@
---
import { Preview } from "reallysimpledocs/components";
const code = `<div class="card">
<header>
<h2>Bar Chart - Multiple</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="chart-example-visitors" aria-label="Monthly visitors by device"></canvas>
</section>
const code = `<div>
<h2>Area Chart - Interactive</h2>
<p>Showing total visitors for the last 3 months</p>
<div class="h-[250px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="visitors-chart" aria-label="Visitors by device"></canvas>
</div>
</div>`;
---
<Preview class="w-full" code={code}>
<div class="card w-full">
<header>
<h2>Bar Chart - Multiple</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="chart-example-visitors" aria-label="Monthly visitors by device"></canvas>
</section>
<div class="w-full">
<div class="mb-4">
<h2 class="text-base font-medium">Area Chart - Interactive</h2>
<p class="text-muted-foreground text-sm">Showing total visitors for the last 3 months</p>
</div>
<div class="h-[250px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-overview" aria-label="Visitors by device"></canvas>
</div>
</div>
</Preview>
<script is:inline src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script is:inline src="https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js"></script>
<script>
import "../../../src/js/basecoat.js";
import "../../../src/js/chart.js";
const initChartExample = () => {
const canvas = document.getElementById("chart-example-visitors");
if (!canvas || !window.Chart || !window.basecoat?.chart || canvas.dataset.chartInitialized) return;
const visitorsByDay = [
{ date: "Apr 1", desktop: 222, mobile: 150 },
{ date: "Apr 5", desktop: 373, mobile: 290 },
{ date: "Apr 9", desktop: 59, mobile: 110 },
{ date: "Apr 13", desktop: 342, mobile: 380 },
{ date: "Apr 17", desktop: 446, mobile: 360 },
{ date: "Apr 21", desktop: 137, mobile: 200 },
{ date: "Apr 25", desktop: 215, mobile: 250 },
{ date: "Apr 29", desktop: 315, mobile: 240 },
{ date: "May 3", desktop: 247, mobile: 190 },
{ date: "May 7", desktop: 388, mobile: 300 },
{ date: "May 11", desktop: 335, mobile: 270 },
{ date: "May 15", desktop: 473, mobile: 380 },
{ date: "May 19", desktop: 235, mobile: 180 },
{ date: "May 23", desktop: 252, mobile: 290 },
{ date: "May 27", desktop: 420, mobile: 460 },
{ date: "May 31", desktop: 178, mobile: 230 },
{ date: "Jun 4", desktop: 439, mobile: 380 },
{ date: "Jun 8", desktop: 385, mobile: 320 },
{ date: "Jun 12", desktop: 492, mobile: 420 },
{ date: "Jun 16", desktop: 371, mobile: 310 },
{ date: "Jun 20", desktop: 408, mobile: 450 },
{ date: "Jun 24", desktop: 132, mobile: 180 },
{ date: "Jun 28", desktop: 149, mobile: 200 },
];
window.basecoat.chart(canvas, {
const monthlyVisitors = [
{ month: "Jan", desktop: 186, mobile: 80 },
{ month: "Feb", desktop: 305, mobile: 200 },
{ month: "Mar", desktop: 237, mobile: 120 },
{ month: "Apr", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "Jun", desktop: 214, mobile: 140 },
];
const defaultScales = {
y: { beginAtZero: true },
x: { grid: { display: false } },
};
const initChart = (id, config) => {
const canvas = document.getElementById(id);
if (!canvas || canvas.dataset.chartInitialized) return;
window.basecoat.chart(canvas, config);
};
const initChartExamples = () => {
if (!window.Chart || !window.basecoat?.chart) return;
initChart("chart-example-overview", {
type: "line",
labelKey: "date",
legend: true,
data: visitorsByDay,
series: {
desktop: {
label: "Desktop",
color: "var(--chart-2)",
surface: "gradient",
dataset: {
borderWidth: 1.5,
fill: true,
tension: 0.35,
pointRadius: 0,
},
},
mobile: {
label: "Mobile",
color: "var(--chart-1)",
surface: "gradient",
dataset: {
borderWidth: 1.5,
fill: true,
tension: 0.35,
pointRadius: 0,
},
},
},
options: {
interaction: { mode: "index", intersect: false },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 6 } },
y: { beginAtZero: true, stacked: true, ticks: { display: false } },
},
},
});
initChart("chart-example-visitors", {
type: "bar",
labelKey: "month",
legend: true,
data: [
{ month: "Jan", desktop: 186, mobile: 80 },
{ month: "Feb", desktop: 305, mobile: 200 },
{ month: "Mar", desktop: 237, mobile: 120 },
{ month: "Apr", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "Jun", desktop: 214, mobile: 140 },
],
data: monthlyVisitors,
series: {
desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" },
},
options: { scales: defaultScales },
});
initChart("chart-example-line", {
type: "line",
labelKey: "month",
data: [
{ month: "Jan", revenue: 42 },
{ month: "Feb", revenue: 68 },
{ month: "Mar", revenue: 61 },
{ month: "Apr", revenue: 90 },
{ month: "May", revenue: 112 },
{ month: "Jun", revenue: 128 },
],
series: {
revenue: {
label: "Revenue",
color: "var(--chart-1)",
dataset: { tension: 0, pointRadius: 3, pointHoverRadius: 5 },
},
},
options: { scales: defaultScales },
});
initChart("chart-example-step", {
type: "line",
labelKey: "day",
data: [
{ day: "Mon", queued: 12 },
{ day: "Tue", queued: 18 },
{ day: "Wed", queued: 9 },
{ day: "Thu", queued: 24 },
{ day: "Fri", queued: 16 },
{ day: "Sat", queued: 14 },
],
series: {
queued: {
label: "Queued",
color: "var(--chart-3)",
dataset: { stepped: true, pointRadius: 3 },
},
},
options: { scales: defaultScales },
});
initChart("chart-example-stacked", {
type: "bar",
labelKey: "quarter",
legend: true,
data: [
{ quarter: "Q1", won: 32, open: 44, lost: 12 },
{ quarter: "Q2", won: 48, open: 38, lost: 18 },
{ quarter: "Q3", won: 52, open: 46, lost: 16 },
{ quarter: "Q4", won: 61, open: 40, lost: 14 },
],
series: {
won: { label: "Won", color: "var(--chart-1)" },
open: { label: "Open", color: "var(--chart-2)" },
lost: { label: "Lost", color: "var(--chart-5)" },
},
options: {
scales: {
y: { beginAtZero: true },
x: { grid: { display: false } },
x: { stacked: true, grid: { display: false } },
y: { stacked: true, beginAtZero: true, ticks: { display: false } },
},
},
});
initChart("chart-example-donut", {
type: "doughnut",
labelKey: "source",
legend: true,
data: [
{ source: "Search", visitors: 275, color: "var(--chart-1)" },
{ source: "Direct", visitors: 200, color: "var(--chart-2)" },
{ source: "Social", visitors: 187, color: "var(--chart-3)" },
{ source: "Referral", visitors: 173, color: "var(--chart-4)" },
{ source: "Email", visitors: 90, color: "var(--chart-5)" },
],
series: {
visitors: { label: "Visitors" },
},
options: {
cutout: "62%",
},
});
initChart("chart-example-radar", {
type: "radar",
labelKey: "channel",
legend: true,
data: [
{ channel: "Acquisition", current: 82, previous: 64 },
{ channel: "Activation", current: 72, previous: 70 },
{ channel: "Retention", current: 66, previous: 58 },
{ channel: "Revenue", current: 88, previous: 75 },
{ channel: "Referral", current: 61, previous: 52 },
],
series: {
current: {
label: "Current",
color: "var(--chart-1)",
surface: { from: 0.22 },
},
previous: {
label: "Previous",
color: "var(--chart-2)",
surface: { from: 0.18 },
},
},
options: {
scales: {
r: {
beginAtZero: true,
grid: { color: "color-mix(in oklab, var(--border) 60%, transparent)" },
angleLines: { color: "color-mix(in oklab, var(--border) 60%, transparent)" },
pointLabels: { color: "var(--muted-foreground)" },
ticks: { display: false },
},
},
},
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initChartExample, { once: true });
document.addEventListener("DOMContentLoaded", initChartExamples, { once: true });
} else {
initChartExample();
initChartExamples();
}
</script>
+83
View File
@@ -0,0 +1,83 @@
---
import { Preview } from "reallysimpledocs/components";
const barCode = `<div class="h-[220px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="visitors-chart" aria-label="Monthly visitors by device"></canvas>
</div>`;
const lineCode = `<div class="h-[220px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="revenue-chart" aria-label="Revenue line chart"></canvas>
</div>`;
const stepCode = `<div class="h-[220px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="queue-chart" aria-label="Support queue step chart"></canvas>
</div>`;
const stackedCode = `<div class="h-[220px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="pipeline-chart" aria-label="Stacked pipeline bar chart"></canvas>
</div>`;
const donutCode = `<div class="h-[220px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="traffic-chart" aria-label="Traffic source donut chart"></canvas>
</div>`;
const radarCode = `<div class="h-[260px] [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="channel-chart" aria-label="Channel performance radar chart"></canvas>
</div>`;
---
<div class="grid gap-10">
<section>
<h3>Bar Chart - Multiple</h3>
<Preview class="w-full" code={barCode}>
<div class="h-[220px] w-full [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-visitors" aria-label="Monthly visitors by device"></canvas>
</div>
</Preview>
</section>
<section>
<h3>Line Chart - Linear</h3>
<Preview class="w-full" code={lineCode}>
<div class="h-[220px] w-full [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-line" aria-label="Revenue line chart"></canvas>
</div>
</Preview>
</section>
<section>
<h3>Line Chart - Step</h3>
<Preview class="w-full" code={stepCode}>
<div class="h-[220px] w-full [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-step" aria-label="Support queue step chart"></canvas>
</div>
</Preview>
</section>
<section>
<h3>Bar Chart - Stacked</h3>
<Preview class="w-full" code={stackedCode}>
<div class="h-[220px] w-full [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-stacked" aria-label="Stacked pipeline bar chart"></canvas>
</div>
</Preview>
</section>
<section>
<h3>Pie Chart - Donut</h3>
<Preview class="w-full" code={donutCode}>
<div class="h-[220px] w-full [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-donut" aria-label="Traffic source donut chart"></canvas>
</div>
</Preview>
</section>
<section>
<h3>Radar Chart</h3>
<Preview class="w-full" code={radarCode}>
<div class="h-[260px] w-full [&_.chart]:h-full [&_.chart]:aspect-auto">
<canvas id="chart-example-radar" aria-label="Channel performance radar chart"></canvas>
</div>
</Preview>
</section>
</div>
+102 -19
View File
@@ -68,6 +68,35 @@
return resolved || color;
};
const colorWithAlpha = (color, alpha, element) => {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return color;
context.clearRect(0, 0, 1, 1);
context.fillStyle = '#000';
context.fillStyle = resolveColor(color, element);
context.fillRect(0, 0, 1, 1);
const [red, green, blue, sourceAlpha] = context.getImageData(0, 0, 1, 1).data;
return `rgba(${red}, ${green}, ${blue}, ${(sourceAlpha / 255) * alpha})`;
};
const surfaceColor = (color, element, { from = 0.4, to } = {}) => {
if (to === undefined) return colorWithAlpha(color, from, element);
return ({ chart }) => {
if (!chart.chartArea) return colorWithAlpha(color, from, element);
const gradient = chart.ctx.createLinearGradient(0, chart.chartArea.top, 0, chart.chartArea.bottom);
gradient.addColorStop(0, colorWithAlpha(color, from, element));
gradient.addColorStop(1, colorWithAlpha(color, to, element));
return gradient;
};
};
const resolveDatasetColors = (chartData, element) => {
chartData.datasets?.forEach((dataset) => {
dataset.borderColor = resolveColor(dataset.borderColor, element);
@@ -78,10 +107,47 @@
return chartData;
};
const resolveScaleColors = (scales, element) => {
Object.values(scales || {}).forEach((scale) => {
['border', 'grid', 'ticks', 'angleLines', 'pointLabels'].forEach((key) => {
if (scale[key]?.color !== undefined) {
scale[key].color = resolveColor(scale[key].color, element);
}
});
});
return scales;
};
const chartColor = (element, token) => resolveColor(`var(${token})`, element);
const colorMix = (color, opacity) => `color-mix(in oklab, ${color} ${opacity * 100}%, transparent)`;
const readCssPixels = (element, property, fallback = 0) => {
const value = getComputedStyle(element).getPropertyValue(property).trim();
if (!value) return fallback;
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : fallback;
};
const applyDatasetDefaults = (chartData, type, { barRadius, element }) => {
chartData.datasets?.forEach((dataset) => {
if (type === 'bar' || dataset.type === 'bar') {
if (dataset.borderRadius === undefined) dataset.borderRadius = barRadius;
}
if (dataset._basecoatSurface) {
dataset.backgroundColor = surfaceColor(dataset.borderColor || dataset.backgroundColor, element, dataset._basecoatSurface);
delete dataset._basecoatSurface;
}
});
return chartData;
};
const usesCartesianScales = (type) => !['pie', 'doughnut', 'polarArea', 'radar'].includes(type);
const ensureContainer = (canvas) => {
if (canvas.parentElement?.classList.contains('chart')) {
canvas.parentElement.dataset.basecoatChartContainer = 'true';
@@ -128,6 +194,13 @@
labels,
datasets: entries.map(([key, item], index) => {
const color = item.color || colorForIndex(index);
const dataset = item.dataset || {};
const surface = item.surface === true
? { from: 0.4 }
: item.surface === 'gradient'
? { from: 0.8, to: 0.1 }
: item.surface;
return {
label: item.label || key,
data: rows.map((row) => valueFor(row, key)),
@@ -137,7 +210,10 @@
type: item.type,
yAxisID: item.axis,
hidden: item.hidden,
...(item.dataset || {}),
...dataset,
...(surface && item.backgroundColor === undefined && dataset.backgroundColor === undefined
? { _basecoatSurface: surface }
: {}),
};
}),
};
@@ -276,23 +352,9 @@
const borderColor = chartColor(canvas, '--border');
const gridColor = resolveColor(colorMix('var(--border)', 0.5), canvas);
const mutedColor = chartColor(canvas, '--muted-foreground');
canvas.classList.remove('chart');
canvas._basecoatChartContainer = container;
canvas.dataset.chartLegend = String(legend);
const chart = new Chart(canvas, {
type,
data: resolveDatasetColors(chartData, canvas),
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
...userOptions,
scales: {
const barRadius = readCssPixels(canvas, '--chart-bar-radius');
const defaultScales = usesCartesianScales(type)
? {
x: {
border: { display: false, color: borderColor },
grid: { display: false, color: gridColor },
@@ -305,8 +367,29 @@
ticks: { display: false, color: mutedColor },
...(userOptions.scales?.y || {}),
},
...(Object.fromEntries(Object.entries(userOptions.scales || {}).filter(([key]) => key !== 'x' && key !== 'y'))),
}
: {};
const scales = {
...defaultScales,
...(Object.fromEntries(Object.entries(userOptions.scales || {}).filter(([key]) => key !== 'x' && key !== 'y'))),
};
canvas.classList.remove('chart');
canvas._basecoatChartContainer = container;
canvas.dataset.chartLegend = String(legend);
const chart = new Chart(canvas, {
type,
data: resolveDatasetColors(applyDatasetDefaults(chartData, type, { barRadius, element: canvas }), canvas),
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
...userOptions,
scales: resolveScaleColors(scales, canvas),
plugins: {
...userOptions.plugins,
legend: {