Line Chart
Line Chart gives one ordered trend a visible caption, a readable scale, calm responsive geometry, and points that disclose the same exact values on hover, touch, or keyboard focus. It is deliberately small enough for real application dashboards without bringing a charting system into the product.
Installation
One command detects Vue, React, or Svelte and copies the matching one-file component into the conventional UI directory:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add line-chart- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
When to use
Use Line Chart for one small ordered series such as signups over seven days, deployment duration over recent releases, or CPU usage over the last hour.
Use Sparkline when an adjacent visible number is primary and space is tight. Use Table when people need to compare or retrieve many exact values. A dense analytical workspace with axes, zooming, brushing, stacked series, or statistical transforms deserves a purpose-built application chart—not more props on Line Chart.
Usage
Vue
<script setup>
import LineChart from '@/components/ui/line-chart/LineChart.vue'
const signups = [
{ label: 'Fri', value: 4, detail: 'Friday, 4 signups' },
{ label: 'Sat', value: 4, detail: 'Saturday, 4 signups' },
{ label: 'Sun', value: 7, detail: 'Sunday, 7 signups' },
{ label: 'Mon', value: 7, detail: 'Monday, 7 signups' },
{ label: 'Tue', value: 4, detail: 'Tuesday, 4 signups' },
{ label: 'Wed', value: 4, detail: 'Wednesday, 4 signups' },
{ label: 'Thu', value: 5, detail: 'Thursday, 5 signups' }
]
</script>
<template>
<LineChart
:data="signups"
caption="Signups — last 7 days"
class="h-56 text-gray-950"
/>
</template>
React
import LineChart from '@/components/ui/line-chart/LineChart.jsx'
const signups = [
{ label: 'Fri', value: 4, detail: 'Friday, 4 signups' },
{ label: 'Sat', value: 4, detail: 'Saturday, 4 signups' },
{ label: 'Sun', value: 7, detail: 'Sunday, 7 signups' },
{ label: 'Mon', value: 7, detail: 'Monday, 7 signups' },
{ label: 'Tue', value: 4, detail: 'Tuesday, 4 signups' },
{ label: 'Wed', value: 4, detail: 'Wednesday, 4 signups' },
{ label: 'Thu', value: 5, detail: 'Thursday, 5 signups' }
]
export default function SignupChart() {
return (
<LineChart
data={signups}
caption="Signups — last 7 days"
className="h-56 text-gray-950"
/>
)
}
Svelte
<script>
import LineChart from '@/components/ui/line-chart/LineChart.svelte'
const signups = [
{ label: 'Fri', value: 4, detail: 'Friday, 4 signups' },
{ label: 'Sat', value: 4, detail: 'Saturday, 4 signups' },
{ label: 'Sun', value: 7, detail: 'Sunday, 7 signups' },
{ label: 'Mon', value: 7, detail: 'Monday, 7 signups' },
{ label: 'Tue', value: 4, detail: 'Tuesday, 4 signups' },
{ label: 'Wed', value: 4, detail: 'Wednesday, 4 signups' },
{ label: 'Thu', value: 5, detail: 'Thursday, 5 signups' }
]
</script>
<LineChart
data={signups}
caption="Signups — last 7 days"
class="h-56 text-gray-950"
/>
API
| Input | Default | Purpose |
|---|---|---|
data | [] | Ordered { label, value, detail? } points. Non-finite values create honest gaps. |
caption | required | Visible name for the figure. |
emptyLabel | No data | Visible and announced wording when no finite values exist. |
formatValue | String | Formats the visible scale and exact accessible values when a point does not supply detail. |
class / className | — | Ordinary Tailwind merged after the neutral responsive frame. Color follows currentColor. |
| native/global attributes | — | IDs, data hooks, event hooks, and framework-native figure references when genuinely needed. |
The stable datum contract is intentionally boring:
{
label: 'Mon',
value: 7,
detail: 'Monday, 7 signups'
}detail is optional. Use it when the exact accessible sentence needs more context than formatValue(value) can provide.
Inspecting exact values
The visible caption names a native figure. Hover, tap, or focus any finite sample to inspect its exact label and value. Edge points place their readout inward and high points place it below, so the callout stays with the chart in narrow containers.
The visual line, guides, and SVG markers remain decorative to assistive technology. The inspectable points form an exact labelled list generated from the same data, including unavailable samples. There is no second dataset to drift out of sync and hover is never the only route to the data.
The default exact value is String(value). Format units or locale-sensitive numbers at the call site:
<KleanLineChart
:data="cpu"
caption="CPU usage — last hour"
:format-value="formatPercent"
/>Use Intl.NumberFormat, Intl.DateTimeFormat, or application-owned formatting before data reaches the component when locale changes the meaning. The chart does not guess locale, timezone, or units.
Render a visible Table from the same array when people need persistent comparison or retrieval rather than one-at-a-time inspection.
Empty, missing, and live data
- Empty or entirely invalid data shows
emptyLabel. - One finite value renders a point rather than inventing a trend.
- Flat, zero, negative, and very large values remain finite and stable.
- The finite minimum and maximum stay visible, so the tighter vertical range remains honest.
- Missing values break the line instead of implying continuity.
- Replacing
dataupdates the figure; polling, realtime transport, and loading policy remain application state. - Time range, metric selection, and filters belong in the URL only when they should survive refresh, history, or sharing.
Styling with Tailwind
Height, width, color, caption treatment, labels, empty state, line treatment, and point readouts stay in caller Tailwind. Stable data-slot hooks make targeted styling explicit:
<LineChart
:data="memory"
caption="Memory — last hour"
class="h-80 text-sky-600 **:data-[slot=line-chart-caption]:text-gray-950 **:data-[slot=line-chart-line]:stroke-[3]"
/>There is no variant, palette, theme object, legend system, animation setting, or global chart provider. Repeated application treatment belongs in a small local wrapper around the copied component.
Slipway and Hagfish
Slipway can replace its Lookout line geometry with Line Chart while retaining its exact current readings, dark operational styling, polling, and metric controls in application markup. Its compact CPU and memory rows use Sparkline.
Hagfish can use the same primitive for small invoice or payment trends without inheriting Slipway colors or dashboard assumptions. Tailwind preserves each product's visual language; the data and accessibility contract stays the same.
Related components
- Sparkline — a compact trend beside an exact visible number.
- Table — visible exact values and comparison.
- Card — a semantic surface for one chart and its supporting content.
- Tooltip — supplementary explanation, never the only source of chart data.
Complete framework source
Vue
<script setup>
import { computed, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
data: { type: Array, default: () => [] },
caption: { type: String, required: true },
emptyLabel: { type: String, default: 'No data' },
formatValue: { type: Function, default: (value) => String(value) }
})
const attrs = useAttrs()
const width = 640
const height = 200
const inset = 8
const cornerRadius = 20
const guides = [inset, height / 2, height - inset]
function finiteValue(point) {
return Number.isFinite(point?.value) ? point.value : undefined
}
function geometry(data) {
const values = data.map(finiteValue).filter((value) => value !== undefined)
if (!values.length) {
return { segments: [], points: [], minimum: undefined, maximum: undefined }
}
const minimum = Math.min(...values)
const maximum = Math.max(...values)
let domainMinimum = minimum
let domainMaximum = maximum
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1)
domainMinimum -= padding
domainMaximum += padding
}
const x = (index) =>
data.length === 1
? width / 2
: inset + (index / (data.length - 1)) * (width - inset * 2)
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(height - inset * 2)
const segments = []
const points = []
let segment = []
data.forEach((point, index) => {
const value = finiteValue(point)
if (value === undefined) {
if (segment.length) segments.push(segment)
segment = []
return
}
const coordinate = { x: x(index), y: y(value), index }
points.push(coordinate)
segment.push(coordinate)
})
if (segment.length) segments.push(segment)
return { segments, points, minimum, maximum }
}
const chart = computed(() => geometry(props.data))
const hasValues = computed(() => chart.value.points.length > 0)
const firstLabel = computed(() => props.data[0]?.label ?? '')
const middleLabel = computed(() =>
props.data.length > 2
? (props.data[Math.floor((props.data.length - 1) / 2)]?.label ?? '')
: ''
)
const lastLabel = computed(() =>
props.data.length > 1 ? (props.data.at(-1)?.label ?? '') : ''
)
const currentPoint = computed(() => chart.value.points.at(-1))
const forwardedAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
function compact(value) {
return Number(value.toFixed(2))
}
function roundedPath(segment) {
if (segment.length < 2) return ''
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1]
const point = segment[index]
const next = segment[index + 1]
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y
)
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y)
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3
)
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius
}
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius
}
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`
}
const last = segment.at(-1)
return `${path} L ${compact(last.x)} ${compact(last.y)}`
}
function exactValue(point) {
if (point?.detail) return point.detail
const value = finiteValue(point)
return value === undefined ? props.emptyLabel : props.formatValue(value)
}
function pointLabel(point) {
const value = exactValue(point)
return point?.detail || !point?.label ? value : `${point.label}: ${value}`
}
function pointStyle(point) {
return {
left: `${(point.x / width) * 100}%`,
top: `${(point.y / height) * 100}%`
}
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? 'left-1/2'
: point.x >= width * 0.8
? 'right-1/2'
: 'left-1/2 -translate-x-1/2'
const vertical =
point.y <= height * 0.32 ? 'top-full mt-1.5' : 'bottom-full mb-1.5'
return [
'pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950',
horizontal,
vertical
]
}
</script>
<template>
<figure
v-bind="forwardedAttrs"
data-slot="line-chart"
:class="
twMerge(
'grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white',
attrs.class
)
"
>
<figcaption
data-slot="line-chart-caption"
class="col-span-2 text-sm font-semibold"
>
{{ caption }}
</figcaption>
<div
v-if="hasValues"
data-slot="line-chart-scale"
:class="[
'row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400',
chart.minimum === chart.maximum
? 'place-items-center'
: 'content-between'
]"
>
<span>{{ formatValue(chart.maximum) }}</span>
<span v-if="chart.minimum !== chart.maximum">
{{ formatValue(chart.minimum) }}
</span>
</div>
<div
v-if="hasValues"
data-slot="line-chart-plot"
class="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
class="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
class="text-gray-200 dark:text-gray-800"
>
<line
v-for="guide in guides"
:key="guide"
data-slot="line-chart-guide"
:x1="inset"
:x2="width - inset"
:y1="guide"
:y2="guide"
stroke="currentColor"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</g>
<template v-for="(segment, index) in chart.segments" :key="index">
<path
v-if="segment.length > 1"
data-slot="line-chart-line"
:d="roundedPath(segment)"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</template>
<circle
v-for="point in chart.points"
:key="point.index"
data-slot="line-chart-point"
:cx="point.x"
:cy="point.y"
r="2.25"
fill="currentColor"
opacity="0.38"
vector-effect="non-scaling-stroke"
/>
<template v-if="currentPoint">
<circle
data-slot="line-chart-current-halo"
:cx="currentPoint.x"
:cy="currentPoint.y"
r="7"
fill="currentColor"
opacity="0.14"
vector-effect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
:cx="currentPoint.x"
:cy="currentPoint.y"
r="3.5"
fill="currentColor"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
data-slot="line-chart-values"
role="list"
:aria-label="`${caption} values`"
class="pointer-events-none absolute inset-0"
>
<span v-for="point in chart.points" :key="point.index" role="listitem">
<button
type="button"
data-slot="line-chart-hit"
:aria-label="`Inspect ${pointLabel(data[point.index])}`"
:style="pointStyle(point)"
class="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
class="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
/>
<span
data-slot="line-chart-tip"
aria-hidden="true"
:class="tipPosition(point)"
>
{{ pointLabel(data[point.index]) }}
</span>
</button>
</span>
<template v-for="(point, index) in data" :key="`missing-${index}`">
<span
v-if="finiteValue(point) === undefined"
role="listitem"
class="sr-only"
>
{{ pointLabel(point) }}
</span>
</template>
</div>
</div>
<p
v-else
data-slot="line-chart-empty"
class="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{{ emptyLabel }}
</p>
<div
v-if="hasValues"
data-slot="line-chart-labels"
:class="[
'col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400',
lastLabel ? 'justify-between' : 'justify-center'
]"
>
<span>{{ firstLabel }}</span>
<span v-if="middleLabel">{{ middleLabel }}</span>
<span v-if="lastLabel">{{ lastLabel }}</span>
</div>
</figure>
</template>
React
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
const width = 640
const height = 200
const inset = 8
const cornerRadius = 20
const guides = [inset, height / 2, height - inset]
function finiteValue(point) {
return Number.isFinite(point?.value) ? point.value : undefined
}
function geometry(data) {
const values = data.map(finiteValue).filter((value) => value !== undefined)
if (!values.length) {
return { segments: [], points: [], minimum: undefined, maximum: undefined }
}
const minimum = Math.min(...values)
const maximum = Math.max(...values)
let domainMinimum = minimum
let domainMaximum = maximum
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1)
domainMinimum -= padding
domainMaximum += padding
}
const x = (index) =>
data.length === 1
? width / 2
: inset + (index / (data.length - 1)) * (width - inset * 2)
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(height - inset * 2)
const segments = []
const points = []
let segment = []
data.forEach((point, index) => {
const value = finiteValue(point)
if (value === undefined) {
if (segment.length) segments.push(segment)
segment = []
return
}
const coordinate = { x: x(index), y: y(value), index }
points.push(coordinate)
segment.push(coordinate)
})
if (segment.length) segments.push(segment)
return { segments, points, minimum, maximum }
}
function compact(value) {
return Number(value.toFixed(2))
}
function roundedPath(segment) {
if (segment.length < 2) return ''
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1]
const point = segment[index]
const next = segment[index + 1]
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y
)
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y)
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3
)
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius
}
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius
}
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`
}
const last = segment.at(-1)
return `${path} L ${compact(last.x)} ${compact(last.y)}`
}
function pointStyle(point) {
return {
left: `${(point.x / width) * 100}%`,
top: `${(point.y / height) * 100}%`
}
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? 'left-1/2'
: point.x >= width * 0.8
? 'right-1/2'
: 'left-1/2 -translate-x-1/2'
const vertical =
point.y <= height * 0.32 ? 'top-full mt-1.5' : 'bottom-full mb-1.5'
return twMerge(
'pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950',
horizontal,
vertical
)
}
const LineChart = forwardRef(function LineChart(
{
data = [],
caption,
emptyLabel = 'No data',
formatValue = String,
className,
'data-slot': _dataSlot,
...props
},
ref
) {
const chart = geometry(data)
const hasValues = chart.points.length > 0
const firstLabel = data[0]?.label ?? ''
const middleLabel =
data.length > 2
? (data[Math.floor((data.length - 1) / 2)]?.label ?? '')
: ''
const lastLabel = data.length > 1 ? (data.at(-1)?.label ?? '') : ''
const currentPoint = chart.points.at(-1)
const exactValue = (point) => {
if (point?.detail) return point.detail
const value = finiteValue(point)
return value === undefined ? emptyLabel : formatValue(value)
}
const pointLabel = (point) => {
const value = exactValue(point)
return point?.detail || !point?.label ? value : `${point.label}: ${value}`
}
return (
<figure
{...props}
ref={ref}
data-slot="line-chart"
className={twMerge(
'grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white',
className
)}
>
<figcaption
data-slot="line-chart-caption"
className="col-span-2 text-sm font-semibold"
>
{caption}
</figcaption>
{hasValues ? (
<div
data-slot="line-chart-scale"
className={twMerge(
'row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400',
chart.minimum === chart.maximum
? 'place-items-center'
: 'content-between'
)}
>
<span>{formatValue(chart.maximum)}</span>
{chart.minimum !== chart.maximum ? (
<span>{formatValue(chart.minimum)}</span>
) : null}
</div>
) : null}
{hasValues ? (
<div
data-slot="line-chart-plot"
className="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
className="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
className="text-gray-200 dark:text-gray-800"
>
{guides.map((guide) => (
<line
key={guide}
data-slot="line-chart-guide"
x1={inset}
x2={width - inset}
y1={guide}
y2={guide}
stroke="currentColor"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
))}
</g>
{chart.segments.map((segment, index) =>
segment.length > 1 ? (
<path
key={index}
data-slot="line-chart-line"
d={roundedPath(segment)}
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
) : null
)}
{chart.points.map((point) => (
<circle
key={point.index}
data-slot="line-chart-point"
cx={point.x}
cy={point.y}
r="2.25"
fill="currentColor"
opacity="0.38"
vectorEffect="non-scaling-stroke"
/>
))}
{currentPoint ? (
<>
<circle
data-slot="line-chart-current-halo"
cx={currentPoint.x}
cy={currentPoint.y}
r="7"
fill="currentColor"
opacity="0.14"
vectorEffect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
cx={currentPoint.x}
cy={currentPoint.y}
r="3.5"
fill="currentColor"
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</svg>
<div
data-slot="line-chart-values"
role="list"
aria-label={`${caption} values`}
className="pointer-events-none absolute inset-0"
>
{chart.points.map((point) => (
<span key={point.index} role="listitem">
<button
type="button"
data-slot="line-chart-hit"
aria-label={`Inspect ${pointLabel(data[point.index])}`}
style={pointStyle(point)}
className="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
/>
<span
data-slot="line-chart-tip"
aria-hidden="true"
className={tipPosition(point)}
>
{pointLabel(data[point.index])}
</span>
</button>
</span>
))}
{data.map((point, index) =>
finiteValue(point) === undefined ? (
<span
key={`missing-${index}`}
role="listitem"
className="sr-only"
>
{pointLabel(point)}
</span>
) : null
)}
</div>
</div>
) : (
<p
data-slot="line-chart-empty"
className="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{emptyLabel}
</p>
)}
{hasValues ? (
<div
data-slot="line-chart-labels"
className={twMerge(
'col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400',
lastLabel ? 'justify-between' : 'justify-center'
)}
>
<span>{firstLabel}</span>
{middleLabel ? <span>{middleLabel}</span> : null}
{lastLabel ? <span>{lastLabel}</span> : null}
</div>
) : null}
</figure>
)
})
export default LineChart
Svelte
<script>
import { twMerge } from "tailwind-merge";
let {
data = [],
caption,
emptyLabel = "No data",
formatValue = String,
class: className,
"data-slot": _dataSlot,
...props
} = $props();
const width = 640;
const height = 200;
const inset = 8;
const cornerRadius = 20;
const guides = [inset, height / 2, height - inset];
function finiteValue(point) {
return Number.isFinite(point?.value) ? point.value : undefined;
}
function geometry(points) {
const values = points
.map(finiteValue)
.filter((value) => value !== undefined);
if (!values.length) {
return {
segments: [],
points: [],
minimum: undefined,
maximum: undefined,
};
}
const minimum = Math.min(...values);
const maximum = Math.max(...values);
let domainMinimum = minimum;
let domainMaximum = maximum;
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1);
domainMinimum -= padding;
domainMaximum += padding;
}
const x = (index) =>
points.length === 1
? width / 2
: inset + (index / (points.length - 1)) * (width - inset * 2);
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(height - inset * 2);
const segments = [];
const chartPoints = [];
let segment = [];
points.forEach((point, index) => {
const value = finiteValue(point);
if (value === undefined) {
if (segment.length) segments.push(segment);
segment = [];
return;
}
const coordinate = { x: x(index), y: y(value), index };
chartPoints.push(coordinate);
segment.push(coordinate);
});
if (segment.length) segments.push(segment);
return { segments, points: chartPoints, minimum, maximum };
}
function compact(value) {
return Number(value.toFixed(2));
}
function roundedPath(segment) {
if (segment.length < 2) return "";
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`;
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1];
const point = segment[index];
const next = segment[index + 1];
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y,
);
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y);
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3,
);
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius,
};
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius,
};
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`;
}
const last = segment.at(-1);
return `${path} L ${compact(last.x)} ${compact(last.y)}`;
}
function exactValue(point) {
if (point?.detail) return point.detail;
const value = finiteValue(point);
return value === undefined ? emptyLabel : formatValue(value);
}
function pointLabel(point) {
const value = exactValue(point);
return point?.detail || !point?.label ? value : `${point.label}: ${value}`;
}
function pointStyle(point) {
return `left: ${(point.x / width) * 100}%; top: ${(point.y / height) * 100}%`;
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? "left-1/2"
: point.x >= width * 0.8
? "right-1/2"
: "left-1/2 -translate-x-1/2";
const vertical =
point.y <= height * 0.32 ? "top-full mt-1.5" : "bottom-full mb-1.5";
return twMerge(
"pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950",
horizontal,
vertical,
);
}
let chart = $derived(geometry(data));
let hasValues = $derived(chart.points.length > 0);
let firstLabel = $derived(data[0]?.label ?? "");
let middleLabel = $derived(
data.length > 2
? (data[Math.floor((data.length - 1) / 2)]?.label ?? "")
: "",
);
let lastLabel = $derived(data.length > 1 ? (data.at(-1)?.label ?? "") : "");
let currentPoint = $derived(chart.points.at(-1));
</script>
<figure
{...props}
data-slot="line-chart"
class={twMerge(
"grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white",
className,
)}
>
<figcaption
data-slot="line-chart-caption"
class="col-span-2 text-sm font-semibold"
>
{caption}
</figcaption>
{#if hasValues}
<div
data-slot="line-chart-scale"
class={twMerge(
"row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400",
chart.minimum === chart.maximum
? "place-items-center"
: "content-between",
)}
>
<span>{formatValue(chart.maximum)}</span>
{#if chart.minimum !== chart.maximum}
<span>{formatValue(chart.minimum)}</span>
{/if}
</div>
{/if}
{#if hasValues}
<div
data-slot="line-chart-plot"
class="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
class="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
class="text-gray-200 dark:text-gray-800"
>
{#each guides as guide (guide)}
<line
data-slot="line-chart-guide"
x1={inset}
x2={width - inset}
y1={guide}
y2={guide}
stroke="currentColor"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
{/each}
</g>
{#each chart.segments as segment, index (index)}
{#if segment.length > 1}
<path
data-slot="line-chart-line"
d={roundedPath(segment)}
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
{/if}
{/each}
{#each chart.points as point (point.index)}
<circle
data-slot="line-chart-point"
cx={point.x}
cy={point.y}
r="2.25"
fill="currentColor"
opacity="0.38"
vector-effect="non-scaling-stroke"
/>
{/each}
{#if currentPoint}
<circle
data-slot="line-chart-current-halo"
cx={currentPoint.x}
cy={currentPoint.y}
r="7"
fill="currentColor"
opacity="0.14"
vector-effect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
cx={currentPoint.x}
cy={currentPoint.y}
r="3.5"
fill="currentColor"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
<div
data-slot="line-chart-values"
role="list"
aria-label={`${caption} values`}
class="pointer-events-none absolute inset-0"
>
{#each chart.points as point (point.index)}
<span role="listitem">
<button
type="button"
data-slot="line-chart-hit"
aria-label={`Inspect ${pointLabel(data[point.index])}`}
style={pointStyle(point)}
class="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
class="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
></span>
<span
data-slot="line-chart-tip"
aria-hidden="true"
class={tipPosition(point)}
>
{pointLabel(data[point.index])}
</span>
</button>
</span>
{/each}
{#each data as point, index (index)}
{#if finiteValue(point) === undefined}
<span role="listitem" class="sr-only">{pointLabel(point)}</span>
{/if}
{/each}
</div>
</div>
{:else}
<p
data-slot="line-chart-empty"
class="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{emptyLabel}
</p>
{/if}
{#if hasValues}
<div
data-slot="line-chart-labels"
class={twMerge(
"col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400",
lastLabel ? "justify-between" : "justify-center",
)}
>
<span>{firstLabel}</span>
{#if middleLabel}<span>{middleLabel}</span>{/if}
{#if lastLabel}<span>{lastLabel}</span>{/if}
</div>
{/if}
</figure>