Rework the charts page into sections; add .page-section-title (#3101)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-20 22:08:56 +02:00
1 parent 88811afaff
commit a984752c84
20 files changed
+632 -112

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/preview": minor
---
Updated the charts page with sections, card subtitles, `Legend` lists above charts and a semantic colors chart.
+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": patch
---
Added `.legend-lg`, `.legend-unit` and `.legend-list-divided` for large legend items with a big value and a unit.
+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/core": minor
"@tabler/docs": patch
---
Added `.page-section-title` and `.page-section-description` classes for section headings inside the page body.
+2
View File
@@ -2136,6 +2136,8 @@ $legend-padding-y: 0.375rem !default;
$legend-padding-x: 0.75rem !default; $legend-padding-x: 0.75rem !default;
$legend-list-gap: 0.25rem 1rem !default; $legend-list-gap: 0.25rem 1rem !default;
$legend-list-gap-interactive: 0.25rem !default; $legend-list-gap-interactive: 0.25rem !default;
$legend-lg-min-width: 9rem !default;
$legend-lg-value-font-size: $h2-font-size !default;
// scss-docs-end legend-variables // scss-docs-end legend-variables
// Switch icon // Switch icon
+13
View File
@@ -130,6 +130,19 @@
color: var(--secondary); color: var(--secondary);
} }
// Heading of a section inside the page body, e.g. between groups of cards in a `.row-cards`
.page-section-title {
margin: 1rem 0 0;
font-size: $h2-font-size;
font-weight: $page-title-font-weight;
line-height: $h2-line-height;
}
.page-section-description {
margin: 0.25rem 0 0;
color: var(--secondary);
}
// //
// Page cover // Page cover
// //
+44
View File
@@ -87,6 +87,30 @@ button.legend {
color: var(--secondary); color: var(--secondary);
} }
.legend-unit {
font-size: $h5-font-size;
font-weight: var(--font-weight-normal);
color: var(--secondary);
}
// Large legend: the label on the first line, a big value (with an optional unit) below
.legend-lg {
display: inline-grid;
grid-template-columns: auto 1fr;
row-gap: 0.5rem;
min-width: $legend-lg-min-width;
@include border-radius(var(--border-radius));
font-weight: var(--font-weight-normal);
text-align: start;
.legend-value {
grid-column: 1 / -1;
font-size: $legend-lg-value-font-size;
font-weight: var(--font-weight-medium);
color: var(--body-color);
}
}
@each $name, $color in map.merge($theme-colors, $social-colors) { @each $name, $color in map.merge($theme-colors, $social-colors) {
.legend-#{$name} { .legend-#{$name} {
--legend-color: var(--#{$name}); --legend-color: var(--#{$name});
@@ -113,3 +137,23 @@ button.legend {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
// A line between the items, for a row of large legends
.legend-list-divided {
&,
&:has(> a.legend, > button.legend) {
--legend-list-gap: 0.5rem 1.5rem;
}
> .legend {
position: relative;
}
> .legend + .legend::before {
position: absolute;
inset-block: 0;
inset-inline-start: -0.75rem;
content: '';
border-inline-start: var(--border-width) var(--border-style) var(--border-color);
}
}
+53
View File
@@ -4,6 +4,7 @@ summary: A legend labels the series of a chart or a progress bar with a colored
description: Label chart series with colored markers. Group legend items in a list, add values, and let users toggle series on click. description: Label chart series with colored markers. Group legend items in a list, add values, and let users toggle series on click.
related: [/ui/plugins/chart, /ui/components/status, /ui/components/progress] related: [/ui/plugins/chart, /ui/components/status, /ui/components/progress]
source: core/scss/ui/_legend.scss source: core/scss/ui/_legend.scss
docs-libs: [apexcharts]
classnames: classnames:
component: component:
- class: legend-dot - class: legend-dot
@@ -15,9 +16,16 @@ classnames:
part: part:
- class: legend-value - class: legend-value
desc: Muted value next to the label desc: Muted value next to the label
- class: legend-unit
desc: Small unit after the value, for example ms or GB
size:
- class: legend-lg
desc: Label on the first line and a big value below
style: style:
- class: legend-list-vertical - class: legend-list-vertical
desc: Stacks the items in a column desc: Stacks the items in a column
- class: legend-list-divided
desc: Draws a line between the items, for large legends
state: state:
- class: active - class: active
desc: Keeps the hover background on a link or button desc: Keeps the hover background on a link or button
@@ -29,6 +37,7 @@ classnames:
--- ---
import Example from '@components/Example.astro'; import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro'; import CodeDocs from '@components/CodeDocs.astro';
import Chart from '@shared/components/demo/Chart.astro';
## Legend marker ## Legend marker
@@ -102,6 +111,50 @@ Add `legend-list-vertical` to stack the items, for example next to a donut chart
</div> </div>
</Example> </Example>
## Large legend
Add `legend-lg` to put the label on the first line and a big value below it. Wrap the unit in a `legend-unit`. Add `legend-list-divided` to the list to draw a line between the items. This size works well above a chart, where the legend also shows the key numbers.
<Example centered>
<div class="legend-list legend-list-divided">
<span class="legend legend-lg legend-primary"><span class="legend-dot"></span> P99 <span class="legend-value">124 <span class="legend-unit">ms</span></span></span>
<span class="legend legend-lg legend-azure"><span class="legend-dot"></span> P95 <span class="legend-value">76 <span class="legend-unit">ms</span></span></span>
<span class="legend legend-lg legend-green"><span class="legend-dot"></span> P75 <span class="legend-value">32 <span class="legend-unit">ms</span></span></span>
<span class="legend legend-lg legend-yellow legend-off"><span class="legend-dot"></span> Requests <span class="legend-value">1,234</span></span>
</div>
</Example>
### Above a chart
Put the large items above a chart to show the latest value of each series. Turn off the chart's own legend, because the items already name the series.
<Example>
<div class="card">
<div class="card-body">
<div class="legend-list legend-list-divided mb-3">
<span class="legend legend-lg legend-primary"><span class="legend-dot"></span> P99 <span class="legend-value">124 <span class="legend-unit">ms</span></span></span>
<span class="legend legend-lg legend-azure"><span class="legend-dot"></span> P95 <span class="legend-value">76 <span class="legend-unit">ms</span></span></span>
<span class="legend legend-lg legend-green"><span class="legend-dot"></span> P75 <span class="legend-value">32 <span class="legend-unit">ms</span></span></span>
<span class="legend legend-lg legend-yellow"><span class="legend-dot"></span> P50 <span class="legend-value">10 <span class="legend-unit">ms</span></span></span>
</div>
<Chart chartId="legend-latency" id="legend-latency-docs" label="Read latency by percentile" height={12} />
</div>
</div>
</Example>
### Large links
Large items work as links and buttons too, for example as filters above a list. Add `active` to mark the current item and `legend-off` for a hidden one.
<Example centered>
<div class="legend-list legend-list-divided">
<a href="#" class="legend legend-lg legend-green active"><span class="legend-dot"></span> Passed <span class="legend-value">128</span></a>
<a href="#" class="legend legend-lg legend-red"><span class="legend-dot"></span> Failed <span class="legend-value">4</span></a>
<a href="#" class="legend legend-lg legend-yellow"><span class="legend-dot"></span> Skipped <span class="legend-value">12</span></a>
<a href="#" class="legend legend-lg legend-secondary legend-off"><span class="legend-dot"></span> Pending <span class="legend-value">3</span></a>
</div>
</Example>
## Variables ## Variables
Use these SCSS variables to customize legends. The default values are: Use these SCSS variables to customize legends. The default values are:
+32
View File
@@ -151,9 +151,41 @@ A [breadcrumb](/ui/components/breadcrumb) above the title shows where the page s
</div> </div>
</Example> </Example>
## Section title
Use `.page-section-title` to split a long page body into sections, for example between groups of cards in a `.row-cards` grid. Add a `.page-section-description` below it to say in one sentence what the section holds.
<Example>
<div class="row row-cards">
<div class="col-12">
<h2 class="page-section-title">Line charts</h2>
<p class="page-section-description">Trends over time, where the change between points matters more than single values.</p>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="card-title">Revenue by plan</h3>
<p class="text-secondary mb-0">Monthly revenue of each plan.</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="card-title">CPU load</h3>
<p class="text-secondary mb-0">Load of web servers and workers in one day.</p>
</div>
</div>
</div>
</div>
</Example>
The title sets its own size and top margin, so it looks the same on an `h2`, an `h3` or a `div`. The space below comes from the grid gutter.
## Accessibility ## Accessibility
- The page title belongs in an `h1`, and there should be exactly one per page. `page-title` is a style, not a heading. - The page title belongs in an `h1`, and there should be exactly one per page. `page-title` is a style, not a heading.
- A section title is a real heading. Use an `h2` under the page `h1`, and an `h3` for card titles inside that section.
- The pretitle is context, not a heading. Keep it a `div` so it does not break the heading order. - The pretitle is context, not a heading. Keep it a `div` so it does not break the heading order.
- Buttons in `page-header` come after the title in the markup even when they sit to its right. That is the order they should be read in. - Buttons in `page-header` come after the title in the markup even when they sit to its right. That is the order they should be read in.
- An icon-only action needs an `aria-label`. - An icon-only action needs an `aria-label`.
+131 -40
View File
@@ -6,6 +6,8 @@ import Chart from '@components/demo/Chart.astro'
import Card from '@ui/Card.astro' import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro' import CardHeader from '@ui/CardHeader.astro'
import CardBody from '@ui/CardBody.astro' import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import chartsData from '@data/charts.json' import chartsData from '@data/charts.json'
import { capitalize } from '@shared/lib/string-format' import { capitalize } from '@shared/lib/string-format'
import DocsLink from '@ui/DocsLink.astro' import DocsLink from '@ui/DocsLink.astro'
@@ -13,7 +15,99 @@ import CaptureScript from '@shared/components/CaptureScript.astro'
import LegendList from '@ui/LegendList.astro' import LegendList from '@ui/LegendList.astro'
import Legend from '@ui/Legend.astro' import Legend from '@ui/Legend.astro'
const demoCharts = Object.entries(chartsData as Record<string, { demo?: boolean; name?: string }>).filter(([, chart]) => chart && chart.demo) type DemoChart = { name?: string; extend?: string; type?: string; series?: { name?: string; color?: string }[] }
const charts = chartsData as Record<string, DemoChart>
// Legend rules: one serie needs no legend, two to four series get a <Legend> list above the
// chart, and only circular charts or charts with five and more series keep the legend below.
const circular = ['pie', 'donut', 'radialBar', 'polarArea', 'radar']
function topLegend(chartId: string) {
const own = charts[chartId]
const chart = own?.extend ? { ...charts[own.extend], ...own } : own
const series = chart?.series ?? []
if (circular.includes(chart?.type ?? 'bar') || series.length < 2 || series.length > 4) return undefined
return series.map((serie) => ({ text: serie.name ?? '', color: serie.color ?? 'primary' }))
}
const third = 'col-md-6 col-lg-4'
const half = 'col-lg-6'
const wide = 'col-lg-8'
// Every chart is placed by hand, section by section and row by row (each row adds up to
// 12 columns). Charts with many points, several series or long labels get a wider column.
const sections: { title: string; description: string; charts: [chartId: string, col: string, subtitle: string][] }[] = [
{
title: 'Line charts',
description: 'Trends over time, where the change between points matters more than single values.',
charts: [
['completion-tasks-2', third, 'Tickets solved per day with a smooth curve, the default.'],
['completion-tasks-4', third, 'The same tickets drawn with straight segments.'],
['completion-tasks-5', third, 'A stepped line for values that change in jumps.'],
['completion-tasks-7', third, 'Tickets solved per day, labeled with values.'],
['tasks', wide, 'Issues opened, closed and reopened per day over the last 17 days.'],
['line', third, 'Monthly revenue of each plan, January to June.'],
['line-stroke', third, 'Monthly signups, dashed for paid and referral.'],
['spline', third, 'CPU load of web servers and workers in one day.'],
['temperature', half, 'Monthly average temperature in Tokyo and London.'],
['stepline', half, 'Number of running instances during the day.'],
],
},
{
title: 'Area charts',
description: 'Lines with a filled area, to stress volume or to show how parts add up to a total.',
charts: [
['completion-tasks-3', third, 'Tickets solved per day as a filled area.'],
['completion-tasks-10', third, 'Solved and reopened tickets as two overlapping areas.'],
['completion-tasks-11', third, 'Solved and reopened tickets stacked on top of each other.'],
['area', third, 'Monthly sessions on mobile and desktop.'],
['area-spline', third, 'Monthly egress and ingress traffic.'],
['area-spline-stacked', third, 'Storage used by media and backups, stacked.'],
],
},
{
title: 'Bar charts',
description: 'Comparison of separate categories, side by side or stacked.',
charts: [
['completion-tasks', third, 'Tickets solved per day, last week.'],
['completion-tasks-6', third, 'The same tickets as horizontal bars.'],
['completion-tasks-8', third, 'Solved and reopened tickets side by side.'],
['completion-tasks-9', third, 'Solved and reopened tickets in one stacked bar per day.'],
['cache-status', third, 'Cache status of requests per day, colored by meaning.'],
['funnel', third, 'Visitors left at each step from first visit to customer.'],
],
},
{
title: 'Circular charts',
description: 'Parts of a whole and progress toward a goal, best with a few values.',
charts: [
['demo-pie', third, 'Share of visits by source.'],
['campaigns', third, 'Delivery, open and click rates of one campaign.'],
['polar-area', third, 'Sales volume of each region.'],
],
},
{
title: 'Other charts',
description: 'Mixed series, distributions, schedules and hierarchies.',
charts: [
['combination', wide, 'Monthly spend as columns with leads and trials as lines.'],
['radar', third, 'Pro and Enterprise plans rated in six areas.'],
['scatter', third, 'Page load time samples on desktop and mobile.'],
['timeline', wide, 'Planned duration of each release phase.'],
['bubble', third, 'Search and social campaigns on three metrics.'],
['treemap', third, 'Disk space taken by each folder.'],
['box-plot', third, 'Spread of API response times in each month.'],
],
},
]
--- ---
<DefaultLayout title="Charts" pageMenu="plugins.charts" pageLibs={['apexcharts']}> <DefaultLayout title="Charts" pageMenu="plugins.charts" pageLibs={['apexcharts']}>
@@ -23,16 +117,15 @@ const demoCharts = Object.entries(chartsData as Record<string, { demo?: boolean;
<Activity /> <Activity />
</div> </div>
<div class="col-lg-8"> <div class="col-12">
<Card> <Card>
<CardHeader title="Chart with legend"> <CardHeader title="Chart with legend" />
<LegendList class="ms-auto" id="legend-demo-list">
<Legend button color="yellow" text="Total visits" value="6.7k" data-series="Total visits" />
<Legend button color="green" text="Unique visitors" value="4.7k" data-series="Unique visitors" />
<Legend button color="primary" text="New users" value="1.6k" data-series="New users" />
</LegendList>
</CardHeader>
<CardBody> <CardBody>
<LegendList divided class="mb-3" id="legend-demo-list">
<Legend button size="lg" color="yellow" text="Total visits" value="63.0k" data-series="Total visits" />
<Legend button size="lg" color="green" text="Unique visitors" value="43.0k" data-series="Unique visitors" />
<Legend button size="lg" color="primary" text="New users" value="13.5k" data-series="New users" />
</LegendList>
<Chart chartId="legend-demo" height={15} label="Site traffic" /> <Chart chartId="legend-demo" height={15} label="Site traffic" />
<CaptureScript> <CaptureScript>
<!-- BEGIN SCRIPT OF LEGEND --> <!-- BEGIN SCRIPT OF LEGEND -->
@@ -56,28 +149,6 @@ const demoCharts = Object.entries(chartsData as Record<string, { demo?: boolean;
</Card> </Card>
</div> </div>
<div class="col-lg-4">
<Card>
<CardHeader title="Legend variants" />
<CardBody>
<LegendList class="mb-3">
<Legend color="blue" text="Allowed" value="339.0k" />
<Legend color="red" text="Denied" value="1.2k" />
<Legend color="orange" text="Challenged" value={815} />
</LegendList>
<LegendList class="mb-3">
<Legend href="#" color="purple" text="Logged" value="-" active />
<Legend href="#" color="teal" text="Rate limited" value="-" off />
</LegendList>
<LegendList vertical>
<Legend color="primary" text="This year" />
<Legend color="secondary" text="Last year" />
<Legend text="Free" />
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-12"> <div class="col-12">
<SocialReferrals /> <SocialReferrals />
</div> </div>
@@ -92,15 +163,35 @@ const demoCharts = Object.entries(chartsData as Record<string, { demo?: boolean;
</div> </div>
{ {
demoCharts.map(([chartId, chart]) => ( sections.map((section) => (
<div class="col-lg-6 col-xl-4"> <Fragment>
<Card> <div class="col-12">
{chart.name && <CardHeader title={chart.name} />} <h2 class="page-section-title">{section.title}</h2>
<CardBody> <p class="page-section-description">{section.description}</p>
<Chart chartId={chartId} height={15} label={chart.name ?? capitalize(chartId.replace(/-/g, ' '))} /> </div>
</CardBody> {section.charts.map(([chartId, col, subtitle]) => (
</Card> <div class={col}>
</div> <Card class="h-100">
<CardHeader>
<div>
<CardTitle as="h3">{charts[chartId]?.name ?? capitalize(chartId.replace(/-/g, ' '))}</CardTitle>
<CardSubtitle>{subtitle}</CardSubtitle>
</div>
</CardHeader>
<CardBody>
{topLegend(chartId) && (
<LegendList class="justify-content-end mb-3">
{topLegend(chartId)?.map((item) => (
<Legend color={item.color} text={item.text} />
))}
</LegendList>
)}
<Chart chartId={chartId} height={15} label={charts[chartId]?.name ?? capitalize(chartId.replace(/-/g, ' '))} legend={topLegend(chartId) ? false : undefined} />
</CardBody>
</Card>
</div>
))}
</Fragment>
)) ))
} }
</div> </div>
+125 -17
View File
@@ -32,6 +32,13 @@ const requests = [
{ name: 'Rate limited', color: 'teal', value: '-' }, { name: 'Rate limited', color: 'teal', value: '-' },
] ]
const latency = [
{ name: 'P99', color: 'primary', value: 124 },
{ name: 'P95', color: 'azure', value: 76 },
{ name: 'P75', color: 'green', value: 32 },
{ name: 'P50', color: 'yellow', value: 10 },
]
const storage = [ const storage = [
{ name: 'Documents', color: 'primary', value: '3.5 GB', width: 44 }, { name: 'Documents', color: 'primary', value: '3.5 GB', width: 44 },
{ name: 'Media', color: 'info', value: '1.5 GB', width: 19 }, { name: 'Media', color: 'info', value: '1.5 GB', width: 19 },
@@ -119,13 +126,128 @@ const storage = [
</Card> </Card>
</div> </div>
<div class="col-lg-8">
<Card>
<CardBody>
<CardTitle>Large</CardTitle>
<CardSubtitle>Set <code>size="lg"</code> to put a big value under the label. Add <code>unit</code> for the unit and <code>divided</code> on the list for the lines.</CardSubtitle>
<LegendList divided>
<Legend size="lg" color="primary" text="P99" value={124} unit="ms" />
<Legend size="lg" color="azure" text="P95" value={76} unit="ms" />
<Legend size="lg" color="green" text="P75" value={32} unit="ms" />
<Legend size="lg" color="yellow" text="Requests" value="1,234" off />
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Under a stat</CardTitle>
<CardSubtitle>A legend row explains the split behind a single number.</CardSubtitle>
<Subheader>Requests</Subheader>
<div class="h1 mb-3">341.0k</div>
<LegendList class="border-top pt-3">
{requests.map((item) => <Legend color={item.color} text={item.name} value={item.value} />)}
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-lg-8">
<Card>
<CardBody>
<CardTitle>Above a chart</CardTitle>
<CardSubtitle>Large items show the latest value of each series, so the chart needs no legend of its own.</CardSubtitle>
<LegendList divided class="mb-3">
{latency.map((item) => <Legend size="lg" color={item.color} text={item.name} value={item.value} unit="ms" />)}
</LegendList>
<Chart chartId="legend-latency" height={12} label="Read latency by percentile" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Large vertical</CardTitle>
<CardSubtitle>Combine <code>size="lg"</code> with a <code>vertical</code> list for a side panel.</CardSubtitle>
<LegendList vertical>
{storage.map((item) => <Legend size="lg" color={item.color} text={item.name} value={item.value.replace(' GB', '')} unit="GB" />)}
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-lg-8">
<Card>
<CardBody>
<CardTitle>Large links</CardTitle>
<CardSubtitle>Large items work as links and buttons too. Mark the current one with <code>active</code> and a hidden series with <code>off</code>.</CardSubtitle>
<LegendList divided>
<Legend href="#" size="lg" color="green" text="Passed" value={128} active />
<Legend href="#" size="lg" color="red" text="Failed" value={4} />
<Legend href="#" size="lg" color="yellow" text="Skipped" value={12} />
<Legend href="#" size="lg" color="secondary" text="Pending" value={3} off />
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With units</CardTitle>
<CardSubtitle>Add <code>unit</code> to a regular item to show the unit after the value.</CardSubtitle>
<LegendList>
{latency.slice(0, 3).map((item) => <Legend color={item.color} text={item.name} value={item.value} unit="ms" />)}
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-lg-8">
<Card>
<CardBody>
<CardTitle>Without lines</CardTitle>
<CardSubtitle>Leave out <code>divided</code> when the items sit in a tight space or wrap to more rows.</CardSubtitle>
<LegendList>
{traffic.map((item) => <Legend size="lg" color={item.color} text={item.name} value={item.value} />)}
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Loading</CardTitle>
<CardSubtitle>Use <code>placeholder</code> blocks for the label and the value while the data loads.</CardSubtitle>
<div class="legend-list legend-list-divided placeholder-glow" aria-hidden="true">
{
[1, 2].map(() => (
<span class="legend legend-lg">
<span class="legend-dot" />
<span class="placeholder col-9" />
<span class="legend-value">
<span class="placeholder col-6" />
</span>
</span>
))
}
</div>
</CardBody>
</Card>
</div>
<div class="col-lg-8"> <div class="col-lg-8">
<Card> <Card>
<CardHeader title="Interactive legend"> <CardHeader title="Interactive legend">
<LegendList class="ms-auto" id="legend-page-list"> <LegendList class="ms-auto" id="legend-page-list">
<Legend button color="yellow" text="Total visits" value="6.7k" data-series="Total visits" /> <Legend button color="yellow" text="Total visits" value="63.0k" data-series="Total visits" />
<Legend button color="green" text="Unique visitors" value="4.7k" data-series="Unique visitors" /> <Legend button color="green" text="Unique visitors" value="43.0k" data-series="Unique visitors" />
<Legend button color="primary" text="New users" value="1.6k" data-series="New users" /> <Legend button color="primary" text="New users" value="13.5k" data-series="New users" />
</LegendList> </LegendList>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
@@ -170,20 +292,6 @@ const storage = [
</Card> </Card>
</div> </div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Under a stat</CardTitle>
<CardSubtitle>A legend row explains the split behind a single number.</CardSubtitle>
<Subheader>Requests</Subheader>
<div class="h1 mb-3">341.0k</div>
<LegendList class="border-top pt-3">
{requests.map((item) => <Legend color={item.color} text={item.name} value={item.value} />)}
</LegendList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<Card> <Card>
<CardBody> <CardBody>
+22
View File
@@ -0,0 +1,22 @@
---
// Large legend on its own, no card and no chart: a `divided` LegendList of
// `size="lg"` items — colored dot and name, a big value with a small unit
// below — with a line between them. The last item shows the `off` state of a
// hidden series.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Legend from '@ui/Legend.astro'
import LegendList from '@ui/LegendList.astro'
const items = [
{ name: 'Requests', color: 'primary', value: '1,234', unit: 'req/s' },
{ name: 'Bandwidth', color: 'azure', value: '86.2', unit: 'MB/s' },
{ name: 'Storage', color: 'purple', value: '56', unit: 'GB' },
{ name: 'Warnings', color: 'orange', value: '128', off: true },
]
---
<ScreenshotLayout title="Large legend list" columns={3.2} zoom={1.3}>
<LegendList divided class="justify-content-center">
{items.map((item) => <Legend size="lg" color={item.color} text={item.name} value={item.value} unit={item.unit} off={item.off} />)}
</LegendList>
</ScreenshotLayout>
@@ -0,0 +1,38 @@
---
// Large legend next to a donut: a traffic-sources card with the chart on the
// left and a `vertical` list of `size="lg"` items on the right, each showing a
// source and its share. `legend-donut` is the traffic donut from charts.json
// with the ApexCharts legend left off, since the list replaces it.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardBody from '@ui/CardBody.astro'
import Legend from '@ui/Legend.astro'
import LegendList from '@ui/LegendList.astro'
import Chart from '@components/demo/Chart.astro'
// Order and colors follow the `traffic-sources-donut` entry in charts.json.
const sources = [
{ name: 'Organic search', color: 'azure', value: 38 },
{ name: 'Direct', color: 'primary', value: 27 },
{ name: 'Social media', color: 'purple', value: 18 },
{ name: 'Referral', color: 'orange', value: 12 },
{ name: 'Email', color: 'green', value: 5 },
]
---
<ScreenshotLayout title="Large vertical legend" columns={1.6} pageLibs={['apexcharts']}>
<Card>
<CardHeader title="Traffic sources" />
<CardBody>
<div class="d-flex align-items-center gap-4">
<div class="flex-fill">
<Chart chartId="legend-donut" height={14} label="Traffic sources" />
</div>
<LegendList vertical>
{sources.map((item) => <Legend size="lg" color={item.color} text={item.name} value={`${item.value}%`} />)}
</LegendList>
</div>
</CardBody>
</Card>
</ScreenshotLayout>
+42
View File
@@ -0,0 +1,42 @@
---
// Large legend above a chart: a read-latency card where each percentile is a
// `size="lg"` button item — colored dot and name on the first line, the latest
// value with its unit below — in a `divided` list. The fake cursor rests on the
// active item so the tinted hover background is part of the picture. Every
// series stays on, because the chart draws all four. `legend-latency` is a four-series line chart in
// charts.json with the ApexCharts legend left off, since the items replace it.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardBody from '@ui/CardBody.astro'
import Legend from '@ui/Legend.astro'
import LegendList from '@ui/LegendList.astro'
import Chart from '@components/demo/Chart.astro'
// Series order and colors follow the `legend-latency` entry in charts.json.
const series = [
{ name: 'P99', color: 'primary', value: 124 },
{ name: 'P95', color: 'azure', value: 76, active: true },
{ name: 'P75', color: 'green', value: 32 },
{ name: 'P50', color: 'yellow', value: 10 },
]
---
<ScreenshotLayout title="Large legend" columns={2.4} pageLibs={['apexcharts']}>
<Card>
<CardHeader title="Read latency" />
<CardBody>
<LegendList divided class="mb-3">
{
series.map((item) => (
<Legend button size="lg" color={item.color} value={item.value} unit="ms" active={item.active} class="position-relative">
{item.name}
{item.active && <span class="cursor" />}
</Legend>
))
}
</LegendList>
<Chart chartId="legend-latency" height={14} label="Read latency by percentile" />
</CardBody>
</Card>
</ScreenshotLayout>
+13 -3
View File
@@ -10,9 +10,11 @@ interface Props {
class?: string; class?: string;
height?: number; height?: number;
label: string; label: string;
/** overrides the `legend` flag from charts.json, e.g. `false` when the page renders its own legend */
legend?: boolean | undefined;
} }
const { chartId, id = chartId, size, class: classProp, height: heightProp, label } = Astro.props; const { chartId, id = chartId, size, class: classProp, height: heightProp, label, legend } = Astro.props;
const chartsData = charts as Record<string, ChartData>; const chartsData = charts as Record<string, ChartData>;
let data = chartsData[chartId]; let data = chartsData[chartId];
@@ -23,6 +25,10 @@ if (data?.extend) {
data = { ...chartsData[data.extend], ...chartsData[chartId] }; data = { ...chartsData[data.extend], ...chartsData[chartId] };
} }
if (data && legend !== undefined) {
data = { ...data, legend };
}
let height = heightProp ?? data?.height ?? 10; let height = heightProp ?? data?.height ?? 10;
if (size === 'sm') { if (size === 'sm') {
@@ -36,7 +42,7 @@ if (size === 'sm') {
// The :root custom-property block stays a plain string rendered via set:html — it's // The :root custom-property block stays a plain string rendered via set:html — it's
// inert CSS text (no injection surface), not executable code. // inert CSS text (no injection surface), not executable code.
const style = data ? chartStyle({ id, data }) : ''; const style = data ? chartStyle({ id, data }) : '';
const { config, xFormatterExpr } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatterExpr: undefined }; const { config, xFormatterExpr, yFormatterExpr } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatterExpr: undefined, yFormatterExpr: undefined };
const chartKey = `chart-${id}`; const chartKey = `chart-${id}`;
const elementId = `chart-${id}`; const elementId = `chart-${id}`;
--- ---
@@ -48,7 +54,7 @@ const elementId = `chart-${id}`;
<Fragment set:html={style} /> <Fragment set:html={style} />
<CaptureScript> <CaptureScript>
<!-- BEGIN CHART --> <!-- BEGIN CHART -->
<script is:inline define:vars={{ chartKey, elementId, config, xFormatterExpr }}> <script is:inline define:vars={{ chartKey, elementId, config, xFormatterExpr, yFormatterExpr }}>
window.tabler_chart ??= {}; window.tabler_chart ??= {};
// Heatmap and treemap shade each cell by computing off the literal base color in JS // Heatmap and treemap shade each cell by computing off the literal base color in JS
@@ -83,6 +89,10 @@ const elementId = `chart-${id}`;
config.xaxis.labels.formatter = new Function('val', `return (${xFormatterExpr})`); config.xaxis.labels.formatter = new Function('val', `return (${xFormatterExpr})`);
} }
if (yFormatterExpr && config.yaxis?.labels) {
config.yaxis.labels.formatter = new Function('val', `return (${yFormatterExpr})`);
}
if ((config.chart?.type === 'heatmap' || config.chart?.type === 'treemap') && Array.isArray(config.colors)) { if ((config.chart?.type === 'heatmap' || config.chart?.type === 'treemap') && Array.isArray(config.colors)) {
config.colors = config.colors.map(resolveCssVar); config.colors = config.colors.map(resolveCssVar);
} }
@@ -23,19 +23,19 @@ import DropdownDays from '@shared/components/demo/DropdownDays.astro'
<div class="text-secondary"> <div class="text-secondary">
<span class="status-dot bg-primary"></span> Mobile <span class="status-dot bg-primary"></span> Mobile
</div> </div>
<div class="h2">11,425</div> <div class="h2">10,601</div>
</div> </div>
<div class="px-3"> <div class="px-3">
<div class="text-secondary"> <div class="text-secondary">
<span class="status-dot bg-azure"></span> Desktop <span class="status-dot bg-azure"></span> Desktop
</div> </div>
<div class="h2">6,458</div> <div class="h2">6,109</div>
</div> </div>
<div class="px-3"> <div class="px-3">
<div class="text-secondary"> <div class="text-secondary">
<span class="status-dot bg-green"></span> Tablet <span class="status-dot bg-green"></span> Tablet
</div> </div>
<div class="h2">3,985</div> <div class="h2">2,260</div>
</div> </div>
</div> </div>
</div> </div>
+66 -39
View File
@@ -34,27 +34,28 @@
"type": "line", "type": "line",
"height": 18, "height": 18,
"datetime": true, "datetime": true,
"stroke-curve": "straight",
"y-formatter": "val.toLocaleString(\"en-US\")",
"series": [ "series": [
{ {
"name": "Mobile", "name": "Mobile",
"color": "primary", "color": "primary",
"data": [4164, 4652, 4817, 4841, 4920, 5439, 5486, 5498, 5512, 5538, 5841, 5877, 6086, 6146, 6199, 6431, 6704, 7939, 8127, 8296, 8322, 8389, 8411, 8502, 8868, 8977, 9273, 9325, 9345, 9430] "data": [10113, 9996, 9273, 9891, 9497, 9420, 10154, 10168, 10183, 9879, 10079, 10354, 10583, 10611, 10819, 10774, 10680, 12059, 11914, 10686, 10486, 11667, 10919, 10421, 10592, 11153, 10723, 11524, 11583, 11830]
}, },
{ {
"name": "Desktop", "name": "Desktop",
"color": "azure", "color": "azure",
"data": [2164, 2292, 2386, 2430, 2528, 3045, 3255, 3295, 3481, 3604, 3688, 3840, 3932, 3949, 4003, 4298, 4424, 4869, 4922, 4973, 5155, 5267, 5566, 5689, 5692, 5758, 5773, 5799, 5960, 6000] "data": [3813, 3597, 6934, 6985, 7484, 7383, 6554, 3857, 3570, 6991, 7402, 7379, 7493, 6487, 3767, 3798, 7319, 9043, 8040, 7468, 6584, 3834, 3614, 7252, 7660, 7374, 7268, 6658, 3974, 3675]
}, },
{ {
"name": "Tablet", "name": "Tablet",
"color": "green", "color": "green",
"data": [1069, 1089, 1125, 1141, 1162, 1179, 1185, 1216, 1274, 1322, 1346, 1395, 1439, 1564, 1581, 1590, 1656, 1815, 1868, 2010, 2133, 2179, 2264, 2265, 2278, 2343, 2354, 2456, 2472, 2480] "data": [2696, 2696, 1941, 1834, 1967, 2077, 2003, 2480, 2570, 2031, 1918, 1910, 2090, 2063, 2758, 2742, 1872, 1950, 1900, 1992, 2296, 2644, 2937, 2018, 1908, 2023, 2207, 2321, 2899, 3061]
} }
] ]
}, },
"demo-pie": { "demo-pie": {
"type": "donut", "type": "donut",
"demo": true,
"sparkline": true, "sparkline": true,
"legend": true, "legend": true,
"series": [ "series": [
@@ -118,8 +119,27 @@
] ]
}, },
"legend-demo": { "legend-demo": {
"extend": "demo-line", "type": "line",
"legend": false "datetime": true,
"stroke-curve": "straight",
"y-formatter": "val.toLocaleString(\"en-US\")",
"series": [
{
"name": "Total visits",
"color": "yellow",
"data": [1329, 1228, 1995, 2217, 2276, 2166, 2047, 1373, 1314, 2286, 2401, 2327, 2405, 2171, 1568, 1453, 2399, 3181, 2856, 2539, 2359, 1624, 1574, 2515, 2681, 2654, 2505, 2304, 1631, 1662]
},
{
"name": "Unique visitors",
"color": "green",
"data": [887, 843, 1367, 1471, 1596, 1498, 1392, 965, 875, 1529, 1629, 1543, 1639, 1482, 1090, 997, 1618, 2166, 1955, 1773, 1594, 1119, 1113, 1668, 1903, 1803, 1711, 1527, 1097, 1104]
},
{
"name": "New users",
"color": "primary",
"data": [291, 255, 374, 409, 449, 532, 498, 286, 261, 493, 520, 445, 489, 440, 318, 348, 580, 732, 535, 570, 530, 348, 348, 556, 655, 595, 488, 518, 335, 343]
}
]
}, },
"legend-requests": { "legend-requests": {
"type": "bar", "type": "bar",
@@ -131,6 +151,16 @@
{ "name": "Logged", "color": "green", "data": [20, 24, 22, 28, 31, 27, 34, 37, 33, 41, 44, 48] } { "name": "Logged", "color": "green", "data": [20, 24, 22, 28, 31, 27, 34, 37, 33, 41, 44, 48] }
] ]
}, },
"legend-latency": {
"type": "line",
"categories": ["00:00", "03:00", "06:00", "09:00", "12:00", "15:00", "18:00", "21:00"],
"series": [
{ "name": "P99", "color": "primary", "data": [96, 88, 102, 131, 148, 139, 127, 124] },
{ "name": "P95", "color": "azure", "data": [61, 57, 66, 82, 91, 85, 79, 76] },
{ "name": "P75", "color": "green", "data": [27, 25, 29, 35, 38, 36, 33, 32] },
{ "name": "P50", "color": "yellow", "data": [9, 8, 9, 11, 12, 11, 10, 10] }
]
},
"legend-donut": { "legend-donut": {
"extend": "traffic-sources-donut", "extend": "traffic-sources-donut",
"legend": false, "legend": false,
@@ -160,7 +190,6 @@
] ]
}, },
"social-referrals": { "social-referrals": {
"demo": false,
"type": "line", "type": "line",
"datetime": true, "datetime": true,
"height": 18, "height": 18,
@@ -194,7 +223,6 @@
"stacked": true, "stacked": true,
"datetime": true, "datetime": true,
"show-x": true, "show-x": true,
"demo": false,
"series": [ "series": [
{ {
"name": "Web", "name": "Web",
@@ -216,7 +244,6 @@
] ]
}, },
"completion-tasks": { "completion-tasks": {
"demo": true,
"categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"series": [ "series": [
{ {
@@ -226,38 +253,34 @@
], ],
"name": "Bar chart" "name": "Bar chart"
}, },
"completion-tasks-2": { "demo": true, "extend": "completion-tasks", "type": "line", "name": "Line chart" }, "completion-tasks-2": { "extend": "completion-tasks", "type": "line", "name": "Line chart" },
"completion-tasks-3": { "demo": true, "extend": "completion-tasks", "type": "area", "name": "Area chart" }, "completion-tasks-3": { "extend": "completion-tasks", "type": "area", "name": "Area chart" },
"completion-tasks-4": { "completion-tasks-4": {
"demo": true,
"extend": "completion-tasks", "extend": "completion-tasks",
"stroke-curve": "straight", "stroke-curve": "straight",
"type": "line", "type": "line",
"name": "Straight line" "name": "Straight line"
}, },
"completion-tasks-5": { "completion-tasks-5": {
"demo": true,
"extend": "completion-tasks", "extend": "completion-tasks",
"stroke-curve": "stepline", "stroke-curve": "stepline",
"type": "line", "type": "line",
"name": "Stepped line" "name": "Stepped line"
}, },
"completion-tasks-6": { "completion-tasks-6": {
"demo": true,
"extend": "completion-tasks", "extend": "completion-tasks",
"type": "bar", "type": "bar",
"horizontal": true, "horizontal": true,
"name": "Horizontal bars" "name": "Horizontal bars"
}, },
"completion-tasks-7": { "completion-tasks-7": {
"demo": true,
"extend": "completion-tasks", "extend": "completion-tasks",
"type": "line", "type": "line",
"datalabels": true, "datalabels": true,
"name": "Data labels" "name": "Data labels"
}, },
"completion-tasks-8": { "completion-tasks-8": {
"demo": true, "legend": true,
"type": "bar", "type": "bar",
"categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"series": [ "series": [
@@ -273,10 +296,24 @@
], ],
"name": "Grouped bars" "name": "Grouped bars"
}, },
"completion-tasks-9": { "demo": true, "extend": "completion-tasks-8", "stacked": true, "name": "Stacked bars" }, "completion-tasks-9": { "extend": "completion-tasks-8", "stacked": true, "name": "Stacked bars" },
"completion-tasks-10": { "demo": true, "extend": "completion-tasks-8", "type": "area", "name": "Two areas" }, "cache-status": {
"type": "bar",
"stacked": true,
"legend": true,
"name": "Semantic colors",
"y-formatter": "val.toLocaleString(\"en-US\")",
"categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"series": [
{ "name": "Hit", "color": "success", "data": [18000, 19200, 21000, 20300, 22000, 16600, 15000] },
{ "name": "Error", "color": "danger", "data": [4300, 4700, 5000, 5000, 5300, 4000, 3700] },
{ "name": "Revalidated", "color": "info", "color-opacity": "40%", "data": [2700, 3000, 3200, 3100, 3400, 2500, 2300] },
{ "name": "Expired", "color": "warning", "data": [1300, 1500, 1600, 1600, 1700, 1200, 1100] },
{ "name": "Bypass", "color": "gray-300", "data": [700, 800, 900, 800, 900, 700, 600] }
]
},
"completion-tasks-10": { "extend": "completion-tasks-8", "type": "area", "name": "Two areas" },
"completion-tasks-11": { "completion-tasks-11": {
"demo": true,
"extend": "completion-tasks-8", "extend": "completion-tasks-8",
"type": "area", "type": "area",
"stacked": true, "stacked": true,
@@ -318,7 +355,7 @@
] ]
}, },
"campaigns": { "campaigns": {
"demo": true, "legend": true,
"type": "radialBar", "type": "radialBar",
"sparkline": true, "sparkline": true,
"series": [ "series": [
@@ -329,7 +366,7 @@
"name": "Email campaign" "name": "Email campaign"
}, },
"tasks": { "tasks": {
"demo": true, "legend": true,
"type": "line", "type": "line",
"categories": ["M", "T", "W", "T", "F", "S", "S", "M", "T", "W", "T", "F", "S", "S", "M", "T", "W"], "categories": ["M", "T", "W", "T", "F", "S", "S", "M", "T", "W", "T", "F", "S", "S", "M", "T", "W"],
"hide-points": true, "hide-points": true,
@@ -393,7 +430,7 @@
] ]
}, },
"line": { "line": {
"demo": true, "legend": true,
"type": "line", "type": "line",
"stroke-curve": "straight", "stroke-curve": "straight",
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
@@ -417,7 +454,7 @@
"name": "Revenue by plan" "name": "Revenue by plan"
}, },
"line-stroke": { "line-stroke": {
"demo": true, "legend": true,
"type": "line", "type": "line",
"stroke-curve": "straight", "stroke-curve": "straight",
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"],
@@ -442,7 +479,6 @@
"stroke-dash": [0, 4, 4] "stroke-dash": [0, 4, 4]
}, },
"stepline": { "stepline": {
"demo": true,
"type": "line", "type": "line",
"stroke-curve": "stepline", "stroke-curve": "stepline",
"series": [ "series": [
@@ -456,7 +492,7 @@
"categories": ["00:00", "02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"] "categories": ["00:00", "02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"]
}, },
"temperature": { "temperature": {
"demo": true, "legend": true,
"type": "line", "type": "line",
"show-data-labels": true, "show-data-labels": true,
"show-markers": true, "show-markers": true,
@@ -476,7 +512,7 @@
"name": "Average temperature" "name": "Average temperature"
}, },
"area": { "area": {
"demo": true, "legend": true,
"type": "area", "type": "area",
"stroke-curve": "straight", "stroke-curve": "straight",
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
@@ -495,7 +531,7 @@
"name": "Sessions by device" "name": "Sessions by device"
}, },
"area-spline": { "area-spline": {
"demo": true, "legend": true,
"type": "area", "type": "area",
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
"series": [ "series": [
@@ -513,7 +549,7 @@
"name": "Bandwidth usage" "name": "Bandwidth usage"
}, },
"area-spline-stacked": { "area-spline-stacked": {
"demo": true, "legend": true,
"type": "area", "type": "area",
"stacked": true, "stacked": true,
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
@@ -532,7 +568,7 @@
"name": "Storage by bucket" "name": "Storage by bucket"
}, },
"spline": { "spline": {
"demo": true, "legend": true,
"type": "line", "type": "line",
"categories": ["00:00", "03:00", "06:00", "09:00", "12:00", "15:00", "18:00", "21:00"], "categories": ["00:00", "03:00", "06:00", "09:00", "12:00", "15:00", "18:00", "21:00"],
"series": [ "series": [
@@ -720,7 +756,7 @@
] ]
}, },
"scatter": { "scatter": {
"demo": true, "legend": true,
"type": "scatter", "type": "scatter",
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
"series": [ "series": [
@@ -738,7 +774,6 @@
"name": "Page load time" "name": "Page load time"
}, },
"combination": { "combination": {
"demo": true,
"type": "line", "type": "line",
"legend": true, "legend": true,
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
@@ -772,7 +807,6 @@
"name": "Spend and leads" "name": "Spend and leads"
}, },
"radar": { "radar": {
"demo": true,
"type": "radar", "type": "radar",
"legend": true, "legend": true,
"categories": ["Support", "Security", "Speed", "Storage", "Integrations", "Price"], "categories": ["Support", "Security", "Speed", "Storage", "Integrations", "Price"],
@@ -792,12 +826,10 @@
}, },
"radar-large": { "radar-large": {
"extend": "radar", "extend": "radar",
"demo": false,
"legend": false, "legend": false,
"radar-size": 115 "radar-size": 115
}, },
"polar-area": { "polar-area": {
"demo": true,
"type": "polarArea", "type": "polarArea",
"legend": true, "legend": true,
"series": [ "series": [
@@ -810,7 +842,6 @@
"name": "Sales by region" "name": "Sales by region"
}, },
"treemap": { "treemap": {
"demo": true,
"type": "treemap", "type": "treemap",
"series": [ "series": [
{ {
@@ -830,7 +861,6 @@
"name": "Storage by folder" "name": "Storage by folder"
}, },
"timeline": { "timeline": {
"demo": true,
"type": "rangeBar", "type": "rangeBar",
"horizontal": true, "horizontal": true,
"datetime": true, "datetime": true,
@@ -865,7 +895,6 @@
"name": "Release roadmap" "name": "Release roadmap"
}, },
"box-plot": { "box-plot": {
"demo": true,
"type": "boxPlot", "type": "boxPlot",
"series": [ "series": [
{ {
@@ -902,7 +931,6 @@
"name": "API response times" "name": "API response times"
}, },
"bubble": { "bubble": {
"demo": true,
"type": "bubble", "type": "bubble",
"legend": true, "legend": true,
"series": [ "series": [
@@ -930,7 +958,6 @@
"name": "Campaign performance" "name": "Campaign performance"
}, },
"funnel": { "funnel": {
"demo": true,
"type": "bar", "type": "bar",
"funnel": true, "funnel": true,
"datalabels": true, "datalabels": true,
+6
View File
@@ -137,6 +137,12 @@ describe('chartConfig', () => {
expect(JSON.stringify(config)).not.toContain('val + "K"') expect(JSON.stringify(config)).not.toContain('val + "K"')
}) })
it('returns the y-formatter as a separate expression instead of embedding it', () => {
const { config, yFormatterExpr } = chartConfig({ id: 'demo', data: { 'series': [], 'y-formatter': 'val.toLocaleString("en-US")' }, height: 10 })
expect(yFormatterExpr).toBe('val.toLocaleString("en-US")')
expect(JSON.stringify(config)).not.toContain('toLocaleString')
})
it('uses the ApexCharts 7 legend marker options', () => { it('uses the ApexCharts 7 legend marker options', () => {
const { config } = chartConfig({ id: 'demo', data: { series: [], legend: true }, height: 10 }) const { config } = chartConfig({ id: 'demo', data: { series: [], legend: true }, height: 10 })
expect(config.legend).toMatchObject({ markers: { size: 5, strokeWidth: 0, shape: 'circle' } }) expect(config.legend).toMatchObject({ markers: { size: 5, strokeWidth: 0, shape: 'circle' } })
+6 -5
View File
@@ -70,6 +70,7 @@ export type ChartData = {
'hide-grid'?: boolean 'hide-grid'?: boolean
'show-x'?: boolean 'show-x'?: boolean
'x-formatter'?: string 'x-formatter'?: string
'y-formatter'?: string
'y-max'?: number 'y-max'?: number
'y-title'?: string 'y-title'?: string
'y-tooltip'?: boolean 'y-tooltip'?: boolean
@@ -173,14 +174,14 @@ export function chartStyle(opts: { id: string; data: ChartData }): string {
/** /**
* The ApexCharts config, as a real JSON-serializable object (rendered via * The ApexCharts config, as a real JSON-serializable object (rendered via
* <script define:vars> — no string-built script). The one exception is * <script define:vars> — no string-built script). The one exception is
* `x-formatter`: a handful of charts.json entries carry a raw JS expression for * `x-formatter` / `y-formatter`: a handful of charts.json entries carry a raw JS
* the x-axis label formatter (e.g. `val + "K"`), which can't be represented as * expression for an axis label formatter (e.g. `val + "K"`), which can't be represented
* data. It comes back as `xFormatterExpr` instead of being embedded in `config`, * as data. It comes back as `xFormatterExpr` / `yFormatterExpr` instead of being embedded in `config`,
* for the caller to turn into a real function with `new Function` — see * for the caller to turn into a real function with `new Function` — see
* Chart.astro. This is data-driven (from our own charts.json, not user input), * Chart.astro. This is data-driven (from our own charts.json, not user input),
* same trust level as everything else here. * same trust level as everything else here.
*/ */
export function chartConfig(opts: { id: string; data: ChartData; height: number }): { config: Record<string, unknown>; xFormatterExpr?: string | undefined } { export function chartConfig(opts: { id: string; data: ChartData; height: number }): { config: Record<string, unknown>; xFormatterExpr?: string | undefined; yFormatterExpr?: string | undefined } {
const { id, data, height } = opts const { id, data, height } = opts
const type = data.type ?? 'bar' const type = data.type ?? 'bar'
const series = data.series ?? [] const series = data.series ?? []
@@ -467,5 +468,5 @@ export function chartConfig(opts: { id: string; data: ChartData; height: number
config.markers = { size: 2 } config.markers = { size: 2 }
} }
return { config, xFormatterExpr: data['x-formatter'] } return { config, xFormatterExpr: data['x-formatter'], yFormatterExpr: data['y-formatter'] }
} }
+14 -3
View File
@@ -7,6 +7,10 @@ interface Props {
text?: string text?: string
/** legend-value next to the label */ /** legend-value next to the label */
value?: string | number | undefined value?: string | number | undefined
/** legend-unit after the value, e.g. "ms" or "GB" */
unit?: string | undefined
/** lg: legend-lg, the label on top and a big value below */
size?: 'lg' | undefined
/** legend-{color} on the item, shared by the marker and the hover background */ /** legend-{color} on the item, shared by the marker and the hover background */
color?: string | undefined color?: string | undefined
/** render a <button type="button"> (toggle control) */ /** render a <button type="button"> (toggle control) */
@@ -22,15 +26,22 @@ interface Props {
[key: string]: unknown [key: string]: unknown
} }
const { text, value, color, button, href, active, off, class: className, ...rest } = Astro.props const { text, value, unit, size, color, button, href, active, off, class: className, ...rest } = Astro.props
const Element = href ? 'a' : button ? 'button' : 'span' const Element = href ? 'a' : button ? 'button' : 'span'
const typeAttr = !href && button ? 'button' : undefined const typeAttr = !href && button ? 'button' : undefined
const classes = ['legend', color && `legend-${color}`, active && 'active', off && 'legend-off', className] const classes = ['legend', size && `legend-${size}`, color && `legend-${color}`, active && 'active', off && 'legend-off', className]
--- ---
<Element class:list={classes} href={href} type={typeAttr} {...rest}> <Element class:list={classes} href={href} type={typeAttr} {...rest}>
<span class="legend-dot"></span> <span class="legend-dot"></span>
{text ?? <slot />} {text ?? <slot />}
{value !== undefined && <span class="legend-value">{value}</span>} {
value !== undefined && (
<span class="legend-value">
{value}
{unit && <span class="legend-unit">{unit}</span>}
</span>
)
}
</Element> </Element>
+4 -2
View File
@@ -4,14 +4,16 @@
interface Props { interface Props {
/** legend-list-vertical (stacked items) */ /** legend-list-vertical (stacked items) */
vertical?: boolean vertical?: boolean
/** legend-list-divided (a line between the items, for large legends) */
divided?: boolean
class?: string class?: string
/** remaining attributes (id, data-*, …) are forwarded to the root element */ /** remaining attributes (id, data-*, …) are forwarded to the root element */
[key: string]: unknown [key: string]: unknown
} }
const { vertical, class: className, ...rest } = Astro.props const { vertical, divided, class: className, ...rest } = Astro.props
--- ---
<div class:list={['legend-list', vertical && 'legend-list-vertical', className]} {...rest}> <div class:list={['legend-list', vertical && 'legend-list-vertical', divided && 'legend-list-divided', className]} {...rest}>
<slot /> <slot />
</div> </div>