import { rocket, startPeeking, stopPeeking } from 'datastar'
// The bitmap is H pixels high and as wide as the box at square pixels, so
// the height is a token and the line keeps its pixel look at any `length`.
// At --sb-notch 0 the line is drawn smoothly at the screen's resolution.
const H = 24
// Pushed points survive a move in the DOM (setup reruns on every connect).
const kept = new WeakMap()
const styles = /* css */ `
:host {
--_c: var(--sb-brand-light, #B09AFF);
--_notch: var(--sb-notch, 1);
display: inline-flex;
align-items: center;
gap: 0.75rem;
inline-size: var(--sb-sparkline-width, 12rem);
max-inline-size: 100%;
vertical-align: middle;
}
:host([hidden]) { display: none; }
:host([tone=ok]) { --_c: var(--sb-ok, #6EF59A); }
:host([tone=warn]) { --_c: var(--sb-warn, #F5C451); }
:host([tone=danger]) { --_c: var(--sb-danger, #F2777A); }
:host([tone=accent]) { --_c: var(--sb-accent, #65BFFF); }
/* The line paints in the canvas's colour, and stroke-width (which draws nothing
on a canvas) carries --sb-notch. The transition turns any change of either
(theme switch, class, media query, tone, style) into a transitionend: a repaint. */
canvas { flex: 1; min-inline-size: 0; block-size: var(--sb-sparkline-height, 2.25rem); image-rendering: pixelated; color: var(--_c); stroke-width: var(--_notch); transition: color 1ms, stroke-width 1ms; }
@container style(--_notch: 0) { canvas { image-rendering: auto; } }
.value { color: var(--sb-text-1, #F3F4FA); font-variant-numeric: tabular-nums; font-weight: 700; font-size: 0.875rem; white-space: nowrap; }
`
rocket('sb-sparkline', {
props: ({ array, bool, number, oneOf, string }) => ({
values: array(number).docs({ description: 'Initial data points, as a JSON array.' }),
value: number.docs({ description: 'Push mode: every change appends a point.' }),
length: number.clamp(4, 200).default(40).docs({ description: 'Points kept (older ones scroll off).' }),
scale: oneOf('auto', 'fixed').default('auto').docs({ description: 'auto fits the data; fixed uses min and max.' }),
min: number.docs({ description: 'Bottom of the fixed scale.' }),
max: number.default(100).docs({ description: 'Top of the fixed scale.' }),
tone: oneOf('brand', 'ok', 'warn', 'danger', 'accent').default('brand').docs({ description: 'Line colour (theme token).' }),
showValue: bool.docs({ description: 'Show the latest value next to the line.' }),
unit: string.docs({ description: 'Unit after the shown value.' }),
decimals: number.clamp(0, 4).docs({ description: 'Decimals of the shown value.' }),
label: string.trim.docs({ description: 'What the chart shows, named first in its accessible label.' }),
}),
renderOnPropChange: false,
setup: ({ $$, action, adoptStyles, cleanup, defineHostProp, host, observeProps, props }) => {
adoptStyles(host, styles)
const fmt = (v) => Number(v).toFixed(props.decimals) + props.unit
const read = () => parseFloat(host.getAttribute('value'))
let raf = 0
const paint = () => {
raf = 0
const c = host.shadowRoot.querySelector('canvas'), ctx = c.getContext('2d'), n = data.length
// Colours and the notch are read at paint time.
const cs = getComputedStyle(c)
let lo = props.min, hi = props.max, px = 0, py
if (props.scale == 'auto' && n) {
lo = Math.min(...data)
hi = Math.max(...data)
if (hi - lo < 1e-9) lo--, hi++
}
if (!parseFloat(cs.strokeWidth)) return paintSmooth(c, ctx, cs.color, lo, hi)
c.height = H
const w = (c.width = Math.round((H * c.clientWidth) / c.clientHeight))
ctx.fillStyle = cs.color
data.forEach((v, i) => {
// Fewer than `length` points spread across the width; once full,
// the line scrolls with the newest point at the right.
const x = 1 + Math.round((i * (w - 3)) / Math.max(1, n - 1))
const y = Math.round(H - 3 - ((Math.max(lo, Math.min(hi, v)) - lo) / (hi - lo || 1)) * (H - 4))
py ??= y
ctx.globalAlpha = 0.18
ctx.fillRect(px + 1, py + 1, x - px, H) // area under the step
ctx.globalAlpha = 1
ctx.fillRect(px + 1, py, x - px, 1) // step: flat…
ctx.fillRect(x, Math.min(py, y), 1, Math.abs(py - y) + 1) // …then vertical
px = x
py = y
})
if (n) ctx.fillRect(px - 1, py - 1, 3, 3)
}
// The same line through the middle of those pixels, in their units scaled by s.
const paintSmooth = (c, ctx, color, lo, hi) => {
const w = (c.width = Math.round(c.clientWidth * devicePixelRatio))
const s = (c.height = Math.round(c.clientHeight * devicePixelRatio)) / H
const n = data.length, last = n - 1
if (!n || !s) return
const pts = data.map((v, i) => [1.5 + (i * (w / s - 3)) / Math.max(1, last), H - 2.5 - ((Math.max(lo, Math.min(hi, v)) - lo) / (hi - lo || 1)) * (H - 4)])
ctx.setTransform(s, 0, 0, s, 0, 0)
ctx.fillStyle = ctx.strokeStyle = color
ctx.beginPath()
ctx.moveTo(pts[0][0], H)
for (const [x, y] of pts) ctx.lineTo(x, y)
ctx.lineTo(pts[last][0], H)
ctx.globalAlpha = 0.18
ctx.fill() // area under the line
ctx.globalAlpha = 1
ctx.beginPath()
for (const [x, y] of pts) ctx.lineTo(x, y)
ctx.lineWidth = 1
ctx.lineJoin = ctx.lineCap = 'round'
ctx.stroke()
ctx.beginPath()
ctx.arc(...pts[last], 1.5, 0, 2 * Math.PI)
ctx.fill()
}
const draw = () => {
// Callers can run inside an effect (data-attr sets a prop, data-effect
// calls push): writing $$ there must not subscribe it.
startPeeking()
try {
data.splice(0, data.length - props.length)
const n = data.length, last = n && fmt(data.at(-1))
$$.show = props.showValue
$$.latest = last || ''
$$.label = (props.label && props.label + ': ') + (n ? `Trend of ${n} points: latest ${last}, low ${fmt(Math.min(...data))}, high ${fmt(Math.max(...data))}` : 'No data yet')
raf ||= requestAnimationFrame(paint)
} finally {
stopPeeking()
}
}
const push = (v) => Number.isFinite(v) && (data.push(v), draw())
let served = read()
let data = kept.get(host)
if (!data) {
kept.set(host, (data = [...props.values]))
// The starting value is the latest point, after the history in `values`.
if (served !== data.at(-1)) push(served)
}
// Push mode watches the attribute: a removed or empty one adds nothing,
// and neither does the same value put back (a morph strips and restores it).
const watch = new MutationObserver(() => {
const v = read()
if (v === v && v !== served) push((served = v))
})
watch.observe(host, { attributeFilter: ['value'] })
const ro = new ResizeObserver(draw)
// The canvas renders after setup. Removed before then (in the same task),
// raf is -1 and nothing is observed: the observer would outlive cleanup.
queueMicrotask(() => raf < 0 || ro.observe(host.shadowRoot.querySelector('canvas')))
cleanup(() => (cancelAnimationFrame(raf), (raf = -1), watch.disconnect(), ro.disconnect()))
observeProps((_, changes) => {
if ('values' in changes) kept.set(host, (data = [...props.values]))
draw()
})
action('draw', draw)
defineHostProp('push', { value: (v) => push(Number(v)) })
defineHostProp('data', { get: () => [...data] })
draw()
},
render: ({ html }) => html`
`,
})