Chart
Line, area, bar, donut, scatter and heatmap charts, a ranked bar list and a gauge, with hover readouts, keyboard support and table views.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add chartpnpm dlx ermineui add chartyarn dlx ermineui add chartbunx ermineui add chartThat copies 9 files into src/components/ui/chart/. Files you already have are kept.
Use
import { BarList, Chart, DonutChart, Gauge, Heatmap, ScatterChart } from '@/components/ui/chart'<BarList :data="pages" name="path" value="views" label="Top pages" />
<Heatmap :rows="days" :columns="hours" :values="matrix" label="Requests by day and hour" />
<Gauge :value="91" label="Build minutes" :warning="80" :critical="95" />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
BarList.vue | src/components/ui/chart/BarList.vue |
Chart.vue | src/components/ui/chart/Chart.vue |
ChartFrame.vue | src/components/ui/chart/ChartFrame.vue |
DonutChart.vue | src/components/ui/chart/DonutChart.vue |
Gauge.vue | src/components/ui/chart/Gauge.vue |
Heatmap.vue | src/components/ui/chart/Heatmap.vue |
ScatterChart.vue | src/components/ui/chart/ScatterChart.vue |
index.ts | src/components/ui/chart/index.ts |
scale.ts | src/components/ui/chart/scale.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
BarList.vue 100 lines
<script setup lang="ts">
import { computed } from 'vue'
import { toNumber } from './scale'
const props = withDefaults(
defineProps<{
/** One object per bar. */
data: readonly object[]
/** Property of each row that names its bar. */
name: string
/** Property of each row that holds its number. */
value: string
/** What the list shows, for screen readers. */
label: string
/** Put the biggest first. Turn off when the rows already have a meaningful order. */
sort?: boolean
/** Show only this many rows. */
limit?: number
/** How values are written. */
format?: (value: number) => string
}>(),
{ sort: true },
)
// Fixed locale, so server and browser write numbers the same way.
const fullNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 })
const formatValue = (value: number) => (props.format ?? fullNumber.format)(value)
const rows = computed(() => {
let list = props.data.flatMap((row) => {
const record = row as Record<string, unknown>
const value = toNumber(record[props.value])
return value === null ? [] : [{ name: String(record[props.name] ?? ''), value }]
})
if (props.sort) list = [...list].sort((a, b) => b.value - a.value)
if (props.limit !== undefined) list = list.slice(0, props.limit)
const max = Math.max(0, ...list.map((row) => row.value))
return list.map((row) => ({ ...row, share: max > 0 ? Math.max(0, row.value) / max : 0 }))
})
</script>
<template>
<!-- A plain list: every bar has its name and its number written beside it, so nothing needs a tooltip or a legend. -->
<ul class="er-bar-list" :aria-label="label">
<li v-for="row in rows" :key="row.name">
<span class="er-bar-list-name">{{ row.name }}</span>
<span class="er-bar-list-value">{{ formatValue(row.value) }}</span>
<span class="er-bar-list-track" aria-hidden="true"><span :style="{ width: `${row.share * 100}%` }" /></span>
</li>
</ul>
</template>
<style>
@layer components {
.er-bar-list {
display: grid;
gap: 0.625rem;
width: 100%;
min-width: 0;
margin: 0;
padding: 0;
font-size: 0.8125rem;
list-style: none;
}
.er-bar-list li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem 1rem;
min-width: 0;
}
.er-bar-list-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.er-bar-list-value {
font-weight: var(--er-weight-semibold);
font-variant-numeric: tabular-nums;
}
.er-bar-list-track {
grid-column: 1 / -1;
height: 0.375rem;
}
/* One series, so one colour for every bar: length carries the value, not shade. */
.er-bar-list-track span {
display: block;
min-width: 2px;
height: 100%;
border-radius: 0 4px 4px 0;
background: var(--er-chart-1);
}
}
</style>Chart.vue 394 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useId } from 'vue'
import ChartFrame from './ChartFrame.vue'
import { barPath, niceScale, textWidth, toNumber, type ChartSeries } from './scale'
const props = withDefaults(
defineProps<{
/** One object per position along the bottom axis, e.g. one per month. */
data: readonly object[]
/** Property of each row used as its label on the bottom axis. */
x: string
/** Heading for that column in the table view. Defaults to the property name. */
xLabel?: string
/** What to plot. Colours are handed out in this order, so keep the order stable. */
series: readonly ChartSeries[]
/** What the chart shows, for screen readers and as the table's caption. */
label: string
type?: 'line' | 'area' | 'bar'
/** Bars only: stack the series on top of each other instead of side by side. */
stacked?: boolean
/** Height of the plot in px, including the labels along the bottom. */
height?: number
/** Lines only: name each line at its right-hand end when there is room (two to four series). */
endLabels?: boolean
/** How values are written in the readout and the table. */
format?: (value: number) => string
/** How values are written on the axis. Short by default: 12.5K, 3M. */
tickFormat?: (value: number) => string
/**
* Lines and areas only: how much to draw, 0 to 1, from the left. What is still to come shows faintly as a
* dotted line with open points. For a chart that draws itself as the page scrolls, e.g. in a sticky scroll.
*/
reveal?: number
}>(),
{ type: 'line', height: 240, endLabels: true },
)
// Fixed locale, so server and browser write numbers the same way.
const fullNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 })
const shortNumber = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })
const formatValue = (value: number | null) => (value === null ? '–' : (props.format ?? fullNumber.format)(value))
const formatTick = (value: number) => (props.tickFormat ?? shortNumber.format)(value)
const TOP = 10
const BOTTOM = 26
const BAR_MAX = 24
const GAP = 2
/** Keys of the series the reader has switched off in the legend. */
const hidden = defineModel<string[]>('hidden', { default: () => [] })
const plot = ref<HTMLElement | null>(null)
/** Measured after mount; the SVG is only drawn once the width is known. */
const width = ref(0)
/** Index of the row under the pointer or chosen with the arrow keys. */
const active = ref<number | null>(null)
/** Read out by screen readers. Only set from the keyboard, so pointing with a mouse stays quiet. */
const announcement = ref('')
let observer: ResizeObserver | undefined
onMounted(() => {
if (!plot.value) return
width.value = plot.value.clientWidth
observer = new ResizeObserver(([entry]) => {
// Hidden behind the table view it measures 0; keep the last real width for when it comes back.
if (entry && entry.contentRect.width > 0) width.value = entry.contentRect.width
})
observer.observe(plot.value)
})
onBeforeUnmount(() => observer?.disconnect())
const names = computed(() => props.series.map((series) => series.label ?? series.key))
// Eight colours are defined. Past that, series go grey rather than repeating a colour that is already taken.
const colors = computed(() =>
props.series.map((series, index) => series.color ?? (index < 8 ? `var(--er-chart-${index + 1})` : 'var(--er-muted-foreground)')),
)
const rows = computed(() =>
props.data.map((row) => {
const record = row as Record<string, unknown>
return { label: String(record[props.x] ?? ''), values: props.series.map((series) => toNumber(record[series.key])) }
}),
)
/** Indexes of the series being drawn. Colours go by position in `series`, so hiding one never repaints the rest. */
const plotted = computed(() => props.series.flatMap((series, index) => (hidden.value.includes(series.key) ? [] : [index])))
function toggle(index: number) {
const key = props.series[index]!.key
if (hidden.value.includes(key)) hidden.value = hidden.value.filter((item) => item !== key)
// The last one stays: an empty chart with every series struck out helps nobody.
else if (plotted.value.length > 1) hidden.value = [...hidden.value, key]
}
const legend = computed(() =>
props.series.length > 1
? names.value.map((name, index) => ({ name, color: colors.value[index]!, hidden: !plotted.value.includes(index) }))
: undefined,
)
const tableRows = computed(() => rows.value.map((row) => [row.label, ...row.values.map(formatValue)]))
const isBar = computed(() => props.type === 'bar')
const isStacked = computed(() => isBar.value && props.stacked)
/** The value axis always includes zero, so bar lengths and line heights can be compared honestly. */
const scale = computed(() => {
let min = 0
let max = 0
for (const row of rows.value) {
if (isStacked.value) {
max = Math.max(max, plotted.value.reduce((sum, index) => sum + Math.max(0, row.values[index] ?? 0), 0))
continue
}
for (const index of plotted.value) {
const value = row.values[index] ?? null
if (value === null) continue
min = Math.min(min, value)
max = Math.max(max, value)
}
}
return niceScale(min, max)
})
const bottom = computed(() => props.height - BOTTOM)
function y(value: number): number {
const { min, max } = scale.value
return bottom.value - ((value - min) / (max - min)) * (bottom.value - TOP)
}
const baseline = computed(() => y(0))
const ticks = computed(() => scale.value.ticks.map((value) => ({ value, y: y(value), text: formatTick(value) })))
/** Names at the right-hand end of each line — dropped entirely if any two would sit on top of each other. */
const ends = computed(() => {
const count = plotted.value.length
const last = rows.value.length - 1
if (isBar.value || !props.endLabels || count < 2 || count > 4 || last < 0) return []
const values = rows.value[last]!.values
if (plotted.value.some((index) => values[index] === null)) return []
const labels = plotted.value.map((index) => ({ y: y(values[index]!), text: names.value[index]! }))
const sorted = labels.map((label) => label.y).sort((a, b) => a - b)
return sorted.some((position, index) => index > 0 && position - sorted[index - 1]! < 14) ? [] : labels
})
const left = computed(() => Math.max(...ticks.value.map((tick) => textWidth(tick.text))) + 10)
const right = computed(() => (ends.value.length ? Math.max(...ends.value.map((end) => textWidth(end.text))) + 16 : 10))
const band = computed(() => Math.max(0, width.value - left.value - right.value) / Math.max(1, rows.value.length))
const center = (index: number) => left.value + band.value * (index + 0.5)
/** Bottom-axis labels, thinned out so neighbours never touch. */
const xLabels = computed(() => {
const widest = Math.max(0, ...rows.value.map((row) => textWidth(row.label)))
const every = band.value > 0 ? Math.max(1, Math.ceil((widest + 12) / band.value)) : 1
return rows.value.flatMap((row, index) => (index % every === 0 ? [{ x: center(index), text: row.label }] : []))
})
const lines = computed(() => {
if (isBar.value) return []
return plotted.value.map((seriesIndex) => {
let line = ''
let area = ''
let run: number[] = []
let end: { x: number; y: number } | undefined
const points: Array<{ x: number; y: number }> = []
// A gap in the data breaks the line instead of drawing straight across it.
const closeRun = () => {
if (run.length) area += `L${center(run[run.length - 1]!)},${baseline.value}L${center(run[0]!)},${baseline.value}Z`
run = []
}
rows.value.forEach((row, index) => {
const value = row.values[seriesIndex]
if (value === null || value === undefined) return closeRun()
const point = `${center(index)},${y(value)}`
line += `${run.length ? 'L' : 'M'}${point}`
area += `${run.length ? 'L' : 'M'}${point}`
run.push(index)
end = { x: center(index), y: y(value) }
points.push(end)
})
closeRun()
return { line, area, end, points, color: colors.value[seriesIndex]! }
})
})
const bars = computed(() => {
if (!isBar.value) return []
const count = plotted.value.length
const result: Array<{ d: string; color: string; key: string }> = []
rows.value.forEach((row, rowIndex) => {
if (isStacked.value) {
const barWidth = Math.min(BAR_MAX, band.value * 0.7)
const x = center(rowIndex) - barWidth / 2
const top = plotted.value.reduce((last, index) => ((row.values[index] ?? 0) > 0 ? index : last), -1)
let total = 0
plotted.value.forEach((index) => {
const value = row.values[index] ?? null
if (value === null || value <= 0) return
// Each segment stops 2px short of the one below it: the gap separates them, not an outline.
// A very small segment gives up some of its gap instead, so it never shrinks to a hairline.
const start = y(total)
total += value
const end = y(total)
const base = start - (start < baseline.value ? Math.min(GAP, Math.max(0, start - end - 2)) : 0)
result.push({ d: barPath(x, base, end, barWidth, index === top ? 4 : 0), color: colors.value[index]!, key: `${rowIndex}-${index}` })
})
return
}
const barWidth = Math.max(1, Math.min(BAR_MAX, (band.value * 0.7 - GAP * (count - 1)) / count))
const groupWidth = barWidth * count + GAP * (count - 1)
plotted.value.forEach((index, position) => {
const value = row.values[index] ?? null
if (value === null || value === 0) return
const x = center(rowIndex) - groupWidth / 2 + position * (barWidth + GAP)
result.push({ d: barPath(x, baseline.value, y(value), barWidth), color: colors.value[index]!, key: `${rowIndex}-${index}` })
})
})
return result
})
/** With `reveal`: how far across the line is drawn, in px, and the clip that stops it there. */
const revealX = computed(() => {
if (props.reveal === undefined || isBar.value || !rows.value.length) return undefined
const first = center(0)
return first + Math.min(1, Math.max(0, props.reveal)) * (center(rows.value.length - 1) - first)
})
const clipId = `er-chart-reveal-${useId()}`
// A point counts as reached a hair before the line gets to it, so it fills as the line arrives.
const reached = (x: number) => revealX.value !== undefined && x <= revealX.value + 0.5
const activeRow = computed(() => (active.value === null ? undefined : rows.value[active.value]))
/** The readout for the active row: one entry per series being drawn. */
const readout = computed(() =>
activeRow.value ? plotted.value.map((index) => ({ index, value: activeRow.value!.values[index] ?? null })) : [],
)
const tooltipStyle = computed(() => {
if (active.value === null) return undefined
const x = center(active.value)
// Sits beside the hovered position, on whichever side has more room.
return { left: `${x}px`, transform: x > width.value / 2 ? 'translateX(calc(-100% - 12px))' : 'translateX(12px)' }
})
function indexAt(event: PointerEvent): number | null {
if (!plot.value || !rows.value.length || band.value <= 0) return null
const x = event.clientX - plot.value.getBoundingClientRect().left
return Math.max(0, Math.min(rows.value.length - 1, Math.floor((x - left.value) / band.value)))
}
function onPointer(event: PointerEvent) {
active.value = indexAt(event)
}
function onKeydown(event: KeyboardEvent) {
const last = rows.value.length - 1
if (last < 0) return
let next: number
if (event.key === 'ArrowRight') next = active.value === null ? 0 : Math.min(last, active.value + 1)
else if (event.key === 'ArrowLeft') next = active.value === null ? last : Math.max(0, active.value - 1)
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = last
else if (event.key === 'Escape' && active.value !== null) {
active.value = null
announcement.value = ''
return
} else return
event.preventDefault()
active.value = next
const row = rows.value[next]!
announcement.value = `${row.label}: ${plotted.value.map((index) => `${names.value[index]} ${formatValue(row.values[index] ?? null)}`).join(', ')}`
}
function clear() {
active.value = null
}
</script>
<template>
<!-- One series gets no legend: the chart's own heading already says what is plotted. -->
<ChartFrame
:label="label"
:legend="legend"
:legend-shape="type === 'line' ? 'line' : 'box'"
:columns="[xLabel ?? x, ...names]"
:rows="tableRows"
:announcement="announcement"
:data-type="type"
@toggle="toggle"
>
<div
ref="plot"
class="er-chart-plot"
role="img"
aria-roledescription="chart"
tabindex="0"
:aria-label="`${label}. Use the left and right arrow keys to read values.`"
:style="{ height: `${height}px` }"
@pointermove="onPointer"
@pointerdown="onPointer"
@pointerleave="clear"
@blur="clear"
@keydown="onKeydown"
>
<p v-if="!rows.length" class="er-chart-empty"><slot name="empty">No data</slot></p>
<svg v-else-if="width" :width="width" :height="height" aria-hidden="true">
<g class="er-chart-grid">
<line v-for="tick in ticks" :key="tick.value" :x1="left" :x2="width - right" :y1="tick.y" :y2="tick.y" />
</g>
<g class="er-chart-axis">
<text v-for="tick in ticks" :key="tick.value" :x="left - 8" :y="tick.y" text-anchor="end" dominant-baseline="middle">{{ tick.text }}</text>
<text v-for="item in xLabels" :key="item.x" :x="item.x" :y="height - 6" text-anchor="middle">{{ item.text }}</text>
</g>
<!-- Bars: the whole column behind the hovered group lights up, so the target is far bigger than a bar. -->
<rect
v-if="isBar && active !== null"
class="er-chart-band"
:x="left + band * active"
:y="TOP"
:width="band"
:height="bottom - TOP"
/>
<path v-for="bar in bars" :key="bar.key" :d="bar.d" :fill="bar.color" />
<template v-if="!isBar">
<!-- Partly drawn: the part still to come is a faint dotted line, and the drawn part is clipped at revealX. -->
<clipPath v-if="revealX !== undefined" :id="clipId">
<rect x="0" y="0" :width="revealX" :height="height" />
</clipPath>
<template v-if="type === 'area'">
<path
v-for="(item, index) in lines"
:key="`area-${index}`"
class="er-chart-area"
:d="item.area"
:fill="item.color"
:clip-path="revealX !== undefined ? `url(#${clipId})` : undefined"
/>
</template>
<line v-if="active !== null" class="er-chart-crosshair" :x1="center(active)" :x2="center(active)" :y1="TOP" :y2="bottom" />
<template v-if="revealX !== undefined">
<path v-for="(item, index) in lines" :key="`ahead-${index}`" class="er-chart-line" data-part="ahead" :d="item.line" :stroke="item.color" />
</template>
<path
v-for="(item, index) in lines"
:key="`line-${index}`"
class="er-chart-line"
:d="item.line"
:stroke="item.color"
:clip-path="revealX !== undefined ? `url(#${clipId})` : undefined"
/>
<template v-if="revealX !== undefined">
<template v-for="(item, index) in lines" :key="`points-${index}`">
<circle
v-for="point in item.points"
:key="point.x"
class="er-chart-point"
:data-state="reached(point.x) ? 'reached' : 'ahead'"
:cx="point.x"
:cy="point.y"
r="3.5"
:stroke="item.color"
:style="reached(point.x) ? { fill: item.color } : undefined"
/>
</template>
</template>
<template v-else>
<template v-for="(item, index) in lines" :key="`end-${index}`">
<circle v-if="item.end" class="er-chart-dot" :cx="item.end.x" :cy="item.end.y" r="4" :fill="item.color" />
</template>
</template>
<template v-for="item in readout" :key="`dot-${item.index}`">
<circle v-if="item.value !== null" class="er-chart-dot" :cx="center(active!)" :cy="y(item.value)" r="4" :fill="colors[item.index]" />
</template>
<g class="er-chart-ends">
<text v-for="end in ends" :key="end.text" :x="width - right + 10" :y="end.y" dominant-baseline="middle">{{ end.text }}</text>
</g>
</template>
<!-- Drawn last so it sits over the bars: the line every bar grows from. -->
<line class="er-chart-baseline" :x1="left" :x2="width - right" :y1="baseline" :y2="baseline" />
</svg>
<div v-if="activeRow && width" class="er-chart-tooltip" :style="tooltipStyle">
<div class="er-chart-tooltip-title">{{ activeRow.label }}</div>
<div v-for="item in readout" :key="item.index" class="er-chart-tooltip-row">
<span class="er-chart-key" data-shape="line" :style="{ background: colors[item.index] }" />
<span>{{ names[item.index] }}</span>
<strong>{{ formatValue(item.value) }}</strong>
</div>
</div>
</div>
</ChartFrame>
</template>ChartFrame.vue 356 lines
<script setup lang="ts">
import { ref } from 'vue'
import type { ChartLegendItem } from './scale'
/** What every chart has around its drawing: the legend, the table view and the screen-reader readout. */
withDefaults(
defineProps<{
/** What the chart shows. Used as the table's caption. */
label: string
/** Shown above the chart. Leave out when there is nothing to tell apart. */
legend?: readonly ChartLegendItem[]
/** Matches the marks: a short stroke for lines, a square for bars and areas, a dot for points. */
legendShape?: 'line' | 'box' | 'dot'
columns: readonly string[]
rows: ReadonlyArray<readonly string[]>
/** Read out by screen readers when it changes. */
announcement?: string
}>(),
{ legendShape: 'box', announcement: '' },
)
defineEmits<{
/** A legend entry was pressed, to hide or show what it names. */
toggle: [index: number]
}>()
const showTable = ref(false)
</script>
<template>
<figure class="er-chart">
<div class="er-chart-header">
<ul v-if="legend?.length" class="er-chart-legend">
<li v-for="(item, index) in legend" :key="index">
<button
type="button"
class="er-chart-legend-item"
:aria-pressed="!item.hidden"
:title="item.hidden ? `Show ${item.name}` : `Hide ${item.name}`"
@click="$emit('toggle', index)"
>
<span class="er-chart-key" :data-shape="legendShape" :style="{ background: item.color }" />
{{ item.name }}
</button>
</li>
</ul>
<button type="button" class="er-chart-toggle" :aria-pressed="showTable" @click="showTable = !showTable">
{{ showTable ? 'Show chart' : 'Show table' }}
</button>
</div>
<div v-show="!showTable" class="er-chart-body"><slot /></div>
<div role="status" class="er-chart-announcer">{{ announcement }}</div>
<!-- Always in the page: screen readers get the numbers as a table even while the chart is showing. -->
<div class="er-chart-table" :data-visible="showTable ? '' : undefined">
<table>
<caption>{{ label }}</caption>
<thead>
<tr>
<th v-for="(column, index) in columns" :key="index" scope="col">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
<template v-for="(cell, index) in row" :key="index">
<th v-if="index === 0" scope="row">{{ cell }}</th>
<td v-else>{{ cell }}</td>
</template>
</tr>
</tbody>
</table>
</div>
</figure>
</template>
<style>
@layer components {
.er-chart {
/* The colour behind the chart. Gaps between marks and rings around dots are drawn in it. */
--er-chart-surface: var(--er-background);
position: relative;
display: grid;
gap: 0.75rem;
width: 100%;
min-width: 0;
margin: 0;
font-size: 0.75rem;
}
.er-card .er-chart {
--er-chart-surface: var(--er-card);
}
.er-chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.er-chart-body {
min-width: 0;
}
.er-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.er-chart-legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-muted-foreground);
font: inherit;
cursor: pointer;
}
.er-chart-legend-item:hover {
color: var(--er-foreground);
}
.er-chart-legend-item[aria-pressed='false'] {
text-decoration: line-through;
opacity: 0.55;
}
/* The colour sits beside the name; the name itself stays in the text colour so it is always readable. */
.er-chart-key {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
}
.er-chart-key[data-shape='line'] {
width: 0.75rem;
height: 2px;
border-radius: 1px;
}
.er-chart-key[data-shape='dot'] {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.er-chart-legend-item[aria-pressed='false'] .er-chart-key {
background: var(--er-muted-foreground) !important;
}
.er-chart-toggle {
flex-shrink: 0;
margin-left: auto;
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-muted-foreground);
font: inherit;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.er-chart-toggle:hover {
color: var(--er-foreground);
}
.er-chart-toggle:focus-visible,
.er-chart-legend-item:focus-visible,
.er-chart-plot:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-chart-plot {
position: relative;
border-radius: var(--er-radius-sm);
touch-action: pan-y;
}
.er-chart-plot svg {
display: block;
overflow: visible;
}
.er-chart-empty {
display: grid;
place-items: center;
height: 100%;
margin: 0;
color: var(--er-muted-foreground);
}
.er-chart-grid line {
stroke: var(--er-border);
stroke-width: 1;
}
.er-chart-baseline {
stroke: var(--er-input);
stroke-width: 1;
}
.er-chart-axis text,
.er-chart-ends text {
fill: var(--er-muted-foreground);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.er-chart-ends text {
fill: var(--er-foreground);
}
.er-chart-line {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.er-chart-area {
opacity: 0.1;
}
/* A partly drawn line: the way still to come. */
.er-chart-line[data-part='ahead'] {
stroke-width: 1.5;
stroke-dasharray: 0 5;
opacity: 0.55;
}
.er-chart-point {
fill: var(--er-chart-surface);
stroke-width: 1.5;
transition: fill 0.15s ease;
}
.er-chart-dot {
stroke: var(--er-chart-surface);
stroke-width: 2;
}
.er-chart-crosshair {
stroke: var(--er-input);
stroke-width: 1;
}
.er-chart-band {
fill: var(--er-accent);
opacity: 0.6;
}
.er-chart-tooltip {
position: absolute;
top: 0;
z-index: 1;
display: grid;
gap: 0.25rem;
min-width: 8rem;
padding: 0.5rem 0.625rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-md);
background: var(--er-popover);
color: var(--er-popover-foreground);
box-shadow: var(--er-shadow-md);
white-space: nowrap;
pointer-events: none;
}
.er-chart-tooltip-title {
color: var(--er-muted-foreground);
}
.er-chart-tooltip-row {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--er-muted-foreground);
}
/* The number is what the reader came for, so it carries the weight and the name stays quiet. */
.er-chart-tooltip-row strong {
margin-left: auto;
padding-left: 0.75rem;
color: var(--er-popover-foreground);
font-weight: var(--er-weight-semibold);
font-variant-numeric: tabular-nums;
}
.er-chart-announcer,
.er-chart-table:not([data-visible]) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.er-chart-table {
overflow-x: auto;
}
.er-chart-table table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.er-chart-table caption {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.er-chart-table th,
.er-chart-table td {
padding: 0.375rem 0.75rem;
border-bottom: 1px solid var(--er-border);
text-align: right;
white-space: nowrap;
}
.er-chart-table th:first-child {
padding-left: 0;
text-align: left;
}
.er-chart-table th {
color: var(--er-muted-foreground);
font-weight: var(--er-weight-medium);
}
.er-chart-table tbody th {
color: var(--er-foreground);
font-weight: 400;
}
}
</style>DonutChart.vue 250 lines
<script setup lang="ts">
import { computed, ref } from 'vue'
import ChartFrame from './ChartFrame.vue'
import { arcPath, toNumber } from './scale'
const props = withDefaults(
defineProps<{
/** One object per slice. Rows with no value, zero or a negative value are left out. */
data: readonly object[]
/** Property of each row that names its slice. */
name: string
/** Property of each row that holds its number. */
value: string
/** What the chart shows, for screen readers and as the table's caption. */
label: string
/** Headings for the table view. Default to the property names. */
nameLabel?: string
valueLabel?: string
/** Draw a full pie instead of a ring. */
pie?: boolean
/** Diameter in px. */
size?: number
/** Most slices to draw. Past this, the last rows are added together as "Other". */
maxSlices?: number
/** Written under the total in the middle of the ring. */
totalLabel?: string
/** How values are written. */
format?: (value: number) => string
}>(),
{ size: 180, maxSlices: 6, totalLabel: 'Total' },
)
// Fixed locale, so server and browser write numbers the same way.
const fullNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 })
const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 })
const formatValue = (value: number) => (props.format ?? fullNumber.format)(value)
/** Index of the slice under the pointer or chosen with the arrow keys. */
const active = ref<number | null>(null)
/** Read out by screen readers. Only set from the keyboard, so pointing with a mouse stays quiet. */
const announcement = ref('')
const slices = computed(() => {
const rows = props.data.flatMap((row) => {
const record = row as Record<string, unknown>
const value = toNumber(record[props.value])
return value !== null && value > 0 ? [{ name: String(record[props.name] ?? ''), value }] : []
})
// Many thin slices cannot be compared by eye, so the tail is folded into one grey "Other".
const keep = rows.length > props.maxSlices ? props.maxSlices - 1 : rows.length
const shown = rows.slice(0, keep).map((row, index) => ({ ...row, color: `var(--er-chart-${index + 1})` }))
const rest = rows.slice(keep).reduce((sum, row) => sum + row.value, 0)
if (rest > 0) shown.push({ name: 'Other', value: rest, color: 'var(--er-muted-foreground)' })
const total = shown.reduce((sum, slice) => sum + slice.value, 0)
const radius = props.size / 2
const inner = props.pie ? 0 : radius * 0.62
let angle = 0
return shown.map((slice) => {
const from = angle
angle += (slice.value / total) * Math.PI * 2
const middle = (from + angle) / 2
const reach = (radius + inner) / 2
return {
...slice,
share: slice.value / total,
d: arcPath(radius, radius, radius, inner, from, angle),
// Where the readout points: the middle of the slice.
x: radius + reach * Math.sin(middle),
y: radius - reach * Math.cos(middle),
}
})
})
const total = computed(() => slices.value.reduce((sum, slice) => sum + slice.value, 0))
const activeSlice = computed(() => (active.value === null ? undefined : slices.value[active.value]))
const tableRows = computed(() => slices.value.map((slice) => [slice.name, formatValue(slice.value), percent.format(slice.share)]))
function onKeydown(event: KeyboardEvent) {
const count = slices.value.length
if (!count) return
let next: number
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = active.value === null ? 0 : (active.value + 1) % count
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = active.value === null ? count - 1 : (active.value - 1 + count) % count
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = count - 1
else if (event.key === 'Escape' && active.value !== null) {
active.value = null
announcement.value = ''
return
} else return
event.preventDefault()
active.value = next
const slice = slices.value[next]!
announcement.value = `${slice.name}: ${formatValue(slice.value)}, ${percent.format(slice.share)}`
}
</script>
<template>
<ChartFrame
:label="label"
:columns="[nameLabel ?? name, valueLabel ?? value, 'Share']"
:rows="tableRows"
:announcement="announcement"
:data-type="pie ? 'pie' : 'donut'"
>
<div class="er-chart-donut">
<div
class="er-chart-plot"
role="img"
aria-roledescription="chart"
tabindex="0"
:aria-label="`${label}. Use the arrow keys to read values.`"
:style="{ width: `${size}px`, height: `${size}px` }"
@pointerleave="active = null"
@blur="active = null"
@keydown="onKeydown"
>
<p v-if="!slices.length" class="er-chart-empty"><slot name="empty">No data</slot></p>
<svg v-else :width="size" :height="size" aria-hidden="true">
<!-- The outline is drawn in the surface colour: it reads as a gap between slices, not a border. -->
<path
v-for="(slice, index) in slices"
:key="index"
class="er-chart-slice"
:d="slice.d"
:fill="slice.color"
:data-dimmed="active !== null && active !== index ? '' : undefined"
@pointermove="active = index"
@pointerdown="active = index"
/>
</svg>
<div v-if="!pie && slices.length" class="er-chart-donut-center">
<strong>{{ formatValue(total) }}</strong>
<span>{{ totalLabel }}</span>
</div>
<div v-if="activeSlice" class="er-chart-tooltip er-chart-donut-tooltip" :style="{ left: `${activeSlice.x}px`, top: `${activeSlice.y}px` }">
<div class="er-chart-tooltip-row">
<span class="er-chart-key" :style="{ background: activeSlice.color }" />
<span>{{ activeSlice.name }}</span>
<strong>{{ formatValue(activeSlice.value) }}</strong>
</div>
<div class="er-chart-tooltip-title">{{ percent.format(activeSlice.share) }} of {{ formatValue(total) }}</div>
</div>
</div>
<!-- Every slice is named with its value beside the chart, so nothing depends on telling colours apart. -->
<ul class="er-chart-donut-legend">
<li
v-for="(slice, index) in slices"
:key="index"
:data-active="active === index ? '' : undefined"
@pointerenter="active = index"
@pointerleave="active = null"
>
<span class="er-chart-key" :style="{ background: slice.color }" />
<span>{{ slice.name }}</span>
<strong>{{ formatValue(slice.value) }}</strong>
<span>{{ percent.format(slice.share) }}</span>
</li>
</ul>
</div>
</ChartFrame>
</template>
<style>
@layer components {
.er-chart-donut {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 2rem;
}
.er-chart-donut .er-chart-plot {
flex-shrink: 0;
border-radius: 50%;
}
.er-chart-slice {
stroke: var(--er-chart-surface);
stroke-width: 2;
stroke-linejoin: round;
transition: opacity var(--er-duration);
}
.er-chart-slice[data-dimmed] {
opacity: 0.45;
}
.er-chart-donut-center {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
color: var(--er-muted-foreground);
pointer-events: none;
}
.er-chart-donut-center strong {
color: var(--er-foreground);
font-size: 1.5rem;
font-weight: var(--er-weight-semibold);
line-height: 1.2;
}
.er-chart-donut-tooltip {
transform: translate(-50%, calc(-100% - 10px));
}
.er-chart-donut-legend {
display: grid;
flex: 1;
grid-template-columns: auto 1fr auto auto;
gap: 0.375rem 0.625rem;
align-items: center;
min-width: 12rem;
margin: 0;
padding: 0;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
list-style: none;
}
.er-chart-donut-legend li {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.er-chart-donut-legend li > :last-child {
text-align: right;
}
.er-chart-donut-legend strong {
color: var(--er-foreground);
font-weight: var(--er-weight-semibold);
text-align: right;
}
.er-chart-donut-legend li[data-active] > span:nth-child(2) {
color: var(--er-foreground);
}
}
</style>Gauge.vue 140 lines
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
value: number
min?: number
max?: number
/** What is being measured. */
label: string
/** From this value up, the gauge reads as a warning; from `critical` up, as critical. Leave out for a neutral gauge. */
warning?: number
critical?: number
/** Diameter in px. */
size?: number
/** How the value is written. */
format?: (value: number) => string
}>(),
{ min: 0, max: 100, size: 160 },
)
// Fixed locale, so server and browser write numbers the same way.
const fullNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 })
const shown = computed(() => (props.format ?? fullNumber.format)(props.value))
const share = computed(() => {
const span = props.max - props.min
return span > 0 ? Math.max(0, Math.min((props.value - props.min) / span, 1)) : 0
})
const state = computed(() => {
if (props.critical !== undefined && props.value >= props.critical) return 'critical'
if (props.warning !== undefined && props.value >= props.warning) return 'warning'
return 'normal'
})
const stateText = computed(() => (state.value === 'critical' ? 'Critical' : state.value === 'warning' ? 'Warning' : ''))
const STROKE = 10
const radius = computed(() => props.size / 2 - STROKE / 2)
/** A half circle from the left end, over the top, to the right end. */
const arc = computed(() => {
const r = radius.value
const y = props.size / 2
return `M${STROKE / 2},${y}A${r},${r} 0 0 1 ${props.size - STROKE / 2},${y}`
})
const length = computed(() => Math.PI * radius.value)
</script>
<template>
<div
class="er-gauge"
role="meter"
:aria-label="label"
:aria-valuenow="value"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuetext="`${shown}${stateText ? `, ${stateText.toLowerCase()}` : ''}`"
:data-state="state"
:style="{ width: `${size}px` }"
>
<svg :width="size" :height="size / 2 + STROKE / 2" aria-hidden="true">
<!-- The unfilled part is a pale step of the same colour, so the state reads across the whole arc. -->
<path class="er-gauge-track" :d="arc" :stroke-width="STROKE" />
<path class="er-gauge-fill" :d="arc" :stroke-width="STROKE" :stroke-dasharray="`${length * share} ${length}`" />
</svg>
<div class="er-gauge-text">
<strong>{{ shown }}</strong>
<span>{{ label }}</span>
<!-- The state is written out as well as coloured. -->
<span v-if="stateText" class="er-gauge-state">{{ stateText }}</span>
</div>
</div>
</template>
<style>
@layer components {
.er-gauge {
--er-gauge-color: var(--er-chart-1);
position: relative;
display: grid;
justify-items: center;
max-width: 100%;
}
.er-gauge[data-state='warning'] {
--er-gauge-color: var(--er-warning);
}
.er-gauge[data-state='critical'] {
--er-gauge-color: var(--er-destructive);
}
.er-gauge svg {
display: block;
max-width: 100%;
}
.er-gauge path {
fill: none;
stroke-linecap: round;
}
.er-gauge-track {
stroke: color-mix(in oklab, var(--er-gauge-color) 18%, transparent);
}
.er-gauge-fill {
stroke: var(--er-gauge-color);
transition: stroke-dasharray 300ms var(--er-ease);
}
@media (prefers-reduced-motion: reduce) {
.er-gauge-fill {
transition: none;
}
}
.er-gauge-text {
display: grid;
justify-items: center;
margin-top: -1.75rem;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
text-align: center;
}
.er-gauge-text strong {
color: var(--er-foreground);
font-size: 1.5rem;
font-weight: var(--er-weight-semibold);
line-height: 1.2;
}
.er-gauge-state {
color: var(--er-foreground);
font-size: 0.75rem;
font-weight: var(--er-weight-semibold);
}
}
</style>Heatmap.vue 226 lines
<script setup lang="ts">
import { computed, ref } from 'vue'
import ChartFrame from './ChartFrame.vue'
import { toNumber } from './scale'
const props = withDefaults(
defineProps<{
/** Names of the rows, top to bottom. */
rows: readonly string[]
/** Names of the columns, left to right. */
columns: readonly string[]
/** One array per row, one number per column. Leave a cell null where there is no data. */
values: ReadonlyArray<ReadonlyArray<number | null | undefined>>
/** What the heatmap shows, for screen readers and as the table's caption. */
label: string
/** Heading for the corner of the table view, naming what the rows are. */
rowLabel?: string
/** How values are written in the readout and the table. */
format?: (value: number) => string
}>(),
{ rowLabel: '' },
)
// Fixed locale, so server and browser write numbers the same way.
const fullNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 })
const formatValue = (value: number | null) => (value === null ? '–' : (props.format ?? fullNumber.format)(value))
/** Five steps of one colour, light to dark. Few enough that neighbouring steps are easy to tell apart. */
const STEPS = [14, 32, 52, 74, 100]
/** The cell under the pointer or chosen with the arrow keys. */
const active = ref<{ row: number; column: number }>()
/** Read out by screen readers. Only set from the keyboard, so pointing with a mouse stays quiet. */
const announcement = ref('')
const cells = computed(() => props.rows.map((_row, row) => props.columns.map((_column, column) => toNumber(props.values[row]?.[column]))))
const extent = computed(() => {
const all = cells.value.flat().filter((value): value is number => value !== null)
return all.length ? { min: Math.min(...all), max: Math.max(...all) } : { min: 0, max: 0 }
})
function step(value: number | null): number | undefined {
if (value === null) return undefined
const { min, max } = extent.value
return max === min ? STEPS.length - 1 : Math.min(STEPS.length - 1, Math.floor(((value - min) / (max - min)) * STEPS.length))
}
const fill = (index: number) => `color-mix(in oklab, var(--er-chart-1) ${STEPS[index]}%, var(--er-chart-surface))`
const activeCell = computed(() => {
const at = active.value
if (!at) return undefined
return { row: props.rows[at.row] ?? '', column: props.columns[at.column] ?? '', value: cells.value[at.row]?.[at.column] ?? null }
})
/** The readout sits over the active cell, as a share of the grid, so it needs no measuring. */
const tooltipStyle = computed(() => {
const at = active.value
if (!at) return undefined
return {
left: `${((at.column + 0.5) / props.columns.length) * 100}%`,
top: `${(at.row / props.rows.length) * 100}%`,
transform: `translate(${at.column > props.columns.length / 2 ? '-90%' : '-10%'}, calc(-100% - 6px))`,
}
})
const tableRows = computed(() => props.rows.map((name, row) => [name, ...cells.value[row]!.map(formatValue)]))
function onKeydown(event: KeyboardEvent) {
const lastRow = props.rows.length - 1
const lastColumn = props.columns.length - 1
if (lastRow < 0 || lastColumn < 0) return
const at = active.value ?? { row: 0, column: 0 }
let next = at
if (!active.value && event.key.startsWith('Arrow')) next = at
else if (event.key === 'ArrowRight') next = { ...at, column: Math.min(lastColumn, at.column + 1) }
else if (event.key === 'ArrowLeft') next = { ...at, column: Math.max(0, at.column - 1) }
else if (event.key === 'ArrowDown') next = { ...at, row: Math.min(lastRow, at.row + 1) }
else if (event.key === 'ArrowUp') next = { ...at, row: Math.max(0, at.row - 1) }
else if (event.key === 'Home') next = { ...at, column: 0 }
else if (event.key === 'End') next = { ...at, column: lastColumn }
else if (event.key === 'Escape' && active.value) {
active.value = undefined
announcement.value = ''
return
} else return
event.preventDefault()
active.value = next
announcement.value = `${props.rows[next.row]}, ${props.columns[next.column]}: ${formatValue(cells.value[next.row]?.[next.column] ?? null)}`
}
</script>
<template>
<ChartFrame :label="label" :columns="[rowLabel, ...columns]" :rows="tableRows" :announcement="announcement" data-type="heatmap">
<div class="er-heatmap">
<div class="er-heatmap-columns" aria-hidden="true" :style="{ '--er-heatmap-columns': columns.length }">
<span />
<span v-for="column in columns" :key="column">{{ column }}</span>
</div>
<div class="er-heatmap-body">
<div class="er-heatmap-rows" aria-hidden="true">
<span v-for="row in rows" :key="row">{{ row }}</span>
</div>
<div
class="er-chart-plot er-heatmap-grid"
role="img"
aria-roledescription="chart"
tabindex="0"
:aria-label="`${label}. Use the arrow keys to read values.`"
:style="{ '--er-heatmap-columns': columns.length }"
@pointerleave="active = undefined"
@blur="active = undefined"
@keydown="onKeydown"
>
<template v-for="(row, rowIndex) in cells" :key="rowIndex">
<!-- The gap between cells is the page showing through, not a border drawn around them. -->
<span
v-for="(value, columnIndex) in row"
:key="columnIndex"
class="er-heatmap-cell"
:data-empty="value === null ? '' : undefined"
:data-active="active?.row === rowIndex && active.column === columnIndex ? '' : undefined"
:style="{ background: step(value) === undefined ? undefined : fill(step(value)!) }"
@pointermove="active = { row: rowIndex, column: columnIndex }"
@pointerdown="active = { row: rowIndex, column: columnIndex }"
/>
</template>
<div v-if="activeCell" class="er-chart-tooltip" :style="tooltipStyle">
<div class="er-chart-tooltip-title">{{ activeCell.row }} · {{ activeCell.column }}</div>
<div class="er-chart-tooltip-row"><strong style="margin-left: 0; padding-left: 0">{{ formatValue(activeCell.value) }}</strong></div>
</div>
</div>
</div>
<!-- The key to the colours: which end is low and which is high, with the actual numbers. -->
<div class="er-heatmap-scale">
<span>{{ formatValue(extent.min) }}</span>
<span v-for="(_step, index) in STEPS" :key="index" class="er-heatmap-swatch" :style="{ background: fill(index) }" />
<span>{{ formatValue(extent.max) }}</span>
</div>
</div>
</ChartFrame>
</template>
<style>
@layer components {
.er-heatmap {
display: grid;
gap: 0.375rem;
min-width: 0;
color: var(--er-muted-foreground);
font-variant-numeric: tabular-nums;
}
.er-heatmap-columns {
display: grid;
grid-template-columns: var(--er-heatmap-gutter, 2.5rem) repeat(var(--er-heatmap-columns), minmax(0, 1fr));
gap: 2px;
text-align: center;
}
.er-heatmap-columns span {
overflow: hidden;
white-space: nowrap;
}
.er-heatmap-body {
display: grid;
grid-template-columns: var(--er-heatmap-gutter, 2.5rem) minmax(0, 1fr);
gap: 2px;
}
.er-heatmap-rows {
display: grid;
gap: 2px;
align-items: center;
}
.er-heatmap-rows span {
overflow: hidden;
white-space: nowrap;
}
.er-heatmap-grid {
display: grid;
grid-template-columns: repeat(var(--er-heatmap-columns), minmax(0, 1fr));
gap: 2px;
}
.er-heatmap-cell {
min-height: 1.5rem;
border-radius: 3px;
}
/* No data is not the same as a low value, so it gets an outline in place of a fill. */
.er-heatmap-cell[data-empty] {
box-shadow: inset 0 0 0 1px var(--er-border);
}
.er-heatmap-cell[data-active] {
outline: 2px solid var(--er-foreground);
outline-offset: 1px;
}
.er-heatmap-scale {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
margin-top: 0.25rem;
}
.er-heatmap-scale span:first-child {
margin-right: 0.375rem;
}
.er-heatmap-scale span:last-child {
margin-left: 0.375rem;
}
.er-heatmap-swatch {
width: 1rem;
height: 0.625rem;
border-radius: 2px;
}
}
</style>ScatterChart.vue 262 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ChartFrame from './ChartFrame.vue'
import { niceScale, textWidth, toNumber } from './scale'
const props = withDefaults(
defineProps<{
/** One object per point. Rows missing either number are left out. */
data: readonly object[]
/** Properties of each row that hold its two numbers. */
x: string
y: string
/** What the chart shows, for screen readers and as the table's caption. */
label: string
/** Axis titles, also used in the readout and the table. Default to the property names. */
xLabel?: string
yLabel?: string
/** Property that sorts points into coloured groups. The first three groups get a colour; any others share grey. */
group?: string
/** Property that names each point, shown in the readout and the table. */
name?: string
/** Height of the plot in px, including the axis labels. */
height?: number
/** How values are written in the readout and the table. */
format?: (value: number) => string
/** How values are written on the axes. Short by default: 12.5K, 3M. */
tickFormat?: (value: number) => string
}>(),
{ height: 280 },
)
// Fixed locale, so server and browser write numbers the same way.
const fullNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 })
const shortNumber = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })
const formatValue = (value: number) => (props.format ?? fullNumber.format)(value)
const formatTick = (value: number) => (props.tickFormat ?? shortNumber.format)(value)
/** Any two points can end up side by side, and only three of the series colours stay distinct in every pairing. */
const MAX_GROUPS = 3
const OTHER = 'Other'
/** How close the pointer must be to a point to select it, in px. Far more forgiving than the 8px dot. */
const REACH = 32
/** Names of the groups the reader has switched off in the legend. */
const hidden = defineModel<string[]>('hidden', { default: () => [] })
const plot = ref<HTMLElement | null>(null)
/** Measured after mount; the SVG is only drawn once the width is known. */
const width = ref(0)
/** Index (into `points`) of the point nearest the pointer or chosen with the arrow keys. */
const active = ref<number | null>(null)
/** Read out by screen readers. Only set from the keyboard, so pointing with a mouse stays quiet. */
const announcement = ref('')
let observer: ResizeObserver | undefined
onMounted(() => {
if (!plot.value) return
width.value = plot.value.clientWidth
observer = new ResizeObserver(([entry]) => {
// Hidden behind the table view it measures 0; keep the last real width for when it comes back.
if (entry && entry.contentRect.width > 0) width.value = entry.contentRect.width
})
observer.observe(plot.value)
})
onBeforeUnmount(() => observer?.disconnect())
const xTitle = computed(() => props.xLabel ?? props.x)
const yTitle = computed(() => props.yLabel ?? props.y)
const records = computed(() =>
props.data.flatMap((row) => {
const record = row as Record<string, unknown>
const x = toNumber(record[props.x])
const y = toNumber(record[props.y])
if (x === null || y === null) return []
return [{ x, y, name: props.name ? String(record[props.name] ?? '') : '', group: props.group ? String(record[props.group] ?? '') : '' }]
}),
)
/** Groups in the order they first appear, so a group keeps its colour however the rows are sorted later. */
const groups = computed(() => {
if (!props.group) return []
const found = [...new Set(records.value.map((record) => record.group))]
const named = found.slice(0, MAX_GROUPS).map((name, index) => ({ name, color: `var(--er-chart-${index + 1})` }))
return found.length > MAX_GROUPS ? [...named, { name: OTHER, color: 'var(--er-muted-foreground)' }] : named
})
const points = computed(() =>
records.value
.map((record) => {
const group = groups.value.find((item) => item.name === record.group) ?? groups.value.find((item) => item.name === OTHER)
return { ...record, legend: group?.name ?? '', color: group?.color ?? 'var(--er-chart-1)' }
})
.filter((point) => !hidden.value.includes(point.legend))
// Left to right, so the arrow keys walk across the chart.
.sort((a, b) => a.x - b.x || a.y - b.y),
)
function toggle(index: number) {
const name = groups.value[index]!.name
if (hidden.value.includes(name)) hidden.value = hidden.value.filter((item) => item !== name)
// The last one stays: an empty chart with every group struck out helps nobody.
else if (groups.value.filter((group) => !hidden.value.includes(group.name)).length > 1) hidden.value = [...hidden.value, name]
}
const legend = computed(() =>
groups.value.length > 1 ? groups.value.map((group) => ({ ...group, hidden: hidden.value.includes(group.name) })) : undefined,
)
const top = computed(() => (yTitle.value ? 28 : 10))
const bottom = computed(() => props.height - (xTitle.value ? 46 : 26))
const extent = (values: number[]) => (values.length ? [Math.min(...values), Math.max(...values)] : [0, 1])
const yScale = computed(() => {
const [min, max] = extent(points.value.map((point) => point.y))
return niceScale(min!, max!)
})
const yTicks = computed(() => yScale.value.ticks.map((value) => ({ value, y: py(value), text: formatTick(value) })))
const left = computed(() => Math.max(...yTicks.value.map((tick) => textWidth(tick.text))) + 10)
const right = 14
const xScale = computed(() => {
const [min, max] = extent(points.value.map((point) => point.x))
// Roughly one label every 90px, so they never crowd on a narrow screen.
return niceScale(min!, max!, Math.max(2, Math.floor((width.value - left.value - right) / 90)))
})
const xTicks = computed(() => xScale.value.ticks.map((value) => ({ value, x: px(value), text: formatTick(value) })))
function px(value: number): number {
const { min, max } = xScale.value
return left.value + ((value - min) / (max - min)) * (width.value - left.value - right)
}
function py(value: number): number {
const { min, max } = yScale.value
return bottom.value - ((value - min) / (max - min)) * (bottom.value - top.value)
}
const dots = computed(() => points.value.map((point) => ({ ...point, cx: px(point.x), cy: py(point.y) })))
const activeDot = computed(() => (active.value === null ? undefined : dots.value[active.value]))
const tooltipStyle = computed(() => {
const dot = activeDot.value
if (!dot) return undefined
// Sits beside the point, on whichever side has more room.
const side = dot.cx > width.value / 2 ? 'calc(-100% - 12px)' : '12px'
return { left: `${dot.cx}px`, top: `${dot.cy}px`, transform: `translate(${side}, -50%)` }
})
const columns = computed(() => [...(props.name ? ['Name'] : []), ...(props.group ? ['Group'] : []), xTitle.value, yTitle.value])
const tableRows = computed(() =>
records.value.map((record) => [
...(props.name ? [record.name] : []),
...(props.group ? [record.group] : []),
formatValue(record.x),
formatValue(record.y),
]),
)
/** The pointer only has to be nearest a point, not on it. */
function onPointer(event: PointerEvent) {
if (!plot.value) return
const box = plot.value.getBoundingClientRect()
const x = event.clientX - box.left
const y = event.clientY - box.top
let nearest: number | null = null
let best = REACH
dots.value.forEach((dot, index) => {
const distance = Math.hypot(dot.cx - x, dot.cy - y)
if (distance < best) {
best = distance
nearest = index
}
})
active.value = nearest
}
function onKeydown(event: KeyboardEvent) {
const last = dots.value.length - 1
if (last < 0) return
let next: number
if (event.key === 'ArrowRight') next = active.value === null ? 0 : Math.min(last, active.value + 1)
else if (event.key === 'ArrowLeft') next = active.value === null ? last : Math.max(0, active.value - 1)
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = last
else if (event.key === 'Escape' && active.value !== null) {
active.value = null
announcement.value = ''
return
} else return
event.preventDefault()
active.value = next
const dot = dots.value[next]!
const heading = [dot.name, dot.legend].filter(Boolean).join(', ')
announcement.value = `${heading ? `${heading}: ` : ''}${xTitle.value} ${formatValue(dot.x)}, ${yTitle.value} ${formatValue(dot.y)}`
}
function clear() {
active.value = null
}
</script>
<template>
<ChartFrame
:label="label"
:legend="legend"
legend-shape="dot"
:columns="columns"
:rows="tableRows"
:announcement="announcement"
data-type="scatter"
@toggle="toggle"
>
<div
ref="plot"
class="er-chart-plot"
role="img"
aria-roledescription="chart"
tabindex="0"
:aria-label="`${label}. Use the left and right arrow keys to read values.`"
:style="{ height: `${height}px` }"
@pointermove="onPointer"
@pointerdown="onPointer"
@pointerleave="clear"
@blur="clear"
@keydown="onKeydown"
>
<p v-if="!records.length" class="er-chart-empty"><slot name="empty">No data</slot></p>
<svg v-else-if="width" :width="width" :height="height" aria-hidden="true">
<g class="er-chart-grid">
<line v-for="tick in yTicks" :key="`y-${tick.value}`" :x1="left" :x2="width - right" :y1="tick.y" :y2="tick.y" />
<line v-for="tick in xTicks" :key="`x-${tick.value}`" :x1="tick.x" :x2="tick.x" :y1="top" :y2="bottom" />
</g>
<g class="er-chart-axis">
<text v-for="tick in yTicks" :key="`y-${tick.value}`" :x="left - 8" :y="tick.y" text-anchor="end" dominant-baseline="middle">{{ tick.text }}</text>
<text v-for="tick in xTicks" :key="`x-${tick.value}`" :x="tick.x" :y="bottom + 18" text-anchor="middle">{{ tick.text }}</text>
<text v-if="yTitle" :x="0" :y="12">{{ yTitle }}</text>
<text v-if="xTitle" :x="(left + width - right) / 2" :y="height - 4" text-anchor="middle">{{ xTitle }}</text>
</g>
<!-- Each dot carries a ring in the surface colour, so overlapping points stay separate. -->
<circle v-for="(dot, index) in dots" :key="index" class="er-chart-dot" :cx="dot.cx" :cy="dot.cy" r="4" :fill="dot.color" />
<circle v-if="activeDot" class="er-chart-dot" :cx="activeDot.cx" :cy="activeDot.cy" r="6" :fill="activeDot.color" />
</svg>
<div v-if="activeDot && width" class="er-chart-tooltip" :style="tooltipStyle">
<div v-if="activeDot.name || activeDot.legend" class="er-chart-tooltip-row">
<span v-if="activeDot.legend" class="er-chart-key" data-shape="dot" :style="{ background: activeDot.color }" />
<span>{{ [activeDot.name, activeDot.legend].filter(Boolean).join(' · ') }}</span>
</div>
<div class="er-chart-tooltip-row">
<span>{{ xTitle }}</span>
<strong>{{ formatValue(activeDot.x) }}</strong>
</div>
<div class="er-chart-tooltip-row">
<span>{{ yTitle }}</span>
<strong>{{ formatValue(activeDot.y) }}</strong>
</div>
</div>
</div>
</ChartFrame>
</template>index.ts 9 lines
/** Chart — line, area, bar, donut, scatter and heatmap charts, a ranked bar list and a gauge, with hover readouts, keyboard support and table views. */
export { default as BarList } from './BarList.vue'
export { default as Chart } from './Chart.vue'
export { default as DonutChart } from './DonutChart.vue'
export { default as Gauge } from './Gauge.vue'
export { default as Heatmap } from './Heatmap.vue'
export { default as ScatterChart } from './ScatterChart.vue'
export type { ChartSeries } from './scale'scale.ts 70 lines
export interface ChartSeries {
/** Property of each data row that holds this series' number. */
key: string
/** Name shown in the legend, the readout and the table. Defaults to the key. */
label?: string
/** Any CSS colour. Defaults to the next --er-chart-N variable, in order. */
color?: string
}
function niceStep(rough: number): number {
const power = 10 ** Math.floor(Math.log10(rough))
const fraction = rough / power
const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10
return nice * power
}
/** A value axis that covers min..max and lands on round numbers (0, 500, 1,000 ...). */
export function niceScale(min: number, max: number, count = 4): { min: number; max: number; ticks: number[] } {
if (min === max) max = min + 1
const step = niceStep((max - min) / count)
const low = Math.floor(min / step) * step
const high = Math.ceil(max / step) * step
const ticks: number[] = []
// Rounded on the way in, so 0.1 + 0.2 does not become a tick labelled 0.30000000000000004.
for (let index = 0; low + index * step <= high + step / 2; index++) ticks.push(Number((low + index * step).toFixed(10)))
return { min: low, max: high, ticks }
}
/** A number from a data row, or null when the cell is empty or not a number. */
export function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
/** Rough width of a short label at the chart's 12px text size — enough to reserve room for it. */
export function textWidth(text: string): number {
return text.length * 6.6
}
/**
* A bar from `base` to `end` with its data end rounded and its base square.
* Works in both directions, so negative bars round at the bottom.
*/
export function barPath(x: number, base: number, end: number, width: number, radius = 4): string {
const r = Math.min(radius, width / 2, Math.abs(base - end))
const turn = end < base ? r : -r
return `M${x},${base}V${end + turn}Q${x},${end} ${x + r},${end}H${x + width - r}Q${x + width},${end} ${x + width},${end + turn}V${base}Z`
}
/** One entry in a chart's legend. */
export interface ChartLegendItem {
name: string
color: string
/** Switched off by the reader. Still listed, so it can be switched back on. */
hidden?: boolean
}
/** A ring segment (or a pie slice when `inner` is 0) between two angles, in radians from 12 o'clock. */
export function arcPath(cx: number, cy: number, outer: number, inner: number, from: number, to: number): string {
// A complete circle has the same start and end point, which draws nothing; stop a hair short.
const end = Math.min(to, from + Math.PI * 2 - 0.0001)
const point = (radius: number, angle: number) => `${cx + radius * Math.sin(angle)},${cy - radius * Math.cos(angle)}`
const large = end - from > Math.PI ? 1 : 0
const outerArc = `M${point(outer, from)}A${outer},${outer} 0 ${large} 1 ${point(outer, end)}`
return inner > 0
? `${outerArc}L${point(inner, end)}A${inner},${inner} 0 ${large} 0 ${point(inner, from)}Z`
: `${outerArc}L${cx},${cy}Z`
}