Chart Utils
Shared chart helpers: nice ticks, scales, monotone paths, tweened values and a clamped tooltip.
The vuelix charts are plain SVG with a little maths. Everything they have in common lives here, so you can build your own chart with the same feel. shadcn-vue add installs it automatically with any chart that needs it.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/chart-utils.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/chart-utils/ (plus lib/utils.ts if you don't have it):
ts
import type { Ref } from 'vue'
import { animate } from 'motion-v'
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
export type ChartPoint = [x: number, y: number]
/** Maps a numeric domain onto a pixel range (and back with `.invert`). */
export interface LinearScale {
(value: number): number
invert: (pixel: number) => number
domain: [number, number]
range: [number, number]
}
export function linearScale(domain: [number, number], range: [number, number]): LinearScale {
const [d0, d1] = domain
const [r0, r1] = range
const span = d1 - d0 || 1
const scale = ((v: number) => r0 + ((v - d0) / span) * (r1 - r0)) as LinearScale
scale.invert = (p: number) => d0 + ((p - r0) / (r1 - r0 || 1)) * span
scale.domain = domain
scale.range = range
return scale
}
/**
* Round tick values for an axis: picks a step of 1, 2, 5 or 10 × 10ⁿ and
* widens the domain so both ends land on a tick.
*/
export function niceTicks(min: number, max: number, count = 5) {
if (!Number.isFinite(min) || !Number.isFinite(max))
return { ticks: [0, 1], min: 0, max: 1, step: 1 }
if (min === max) {
const pad = Math.abs(min) * 0.1 || 1
min -= min === 0 ? 0 : pad
max += pad
}
const raw = (max - min) / Math.max(1, count)
const magnitude = 10 ** Math.floor(Math.log10(raw))
const norm = raw / magnitude
const step = (norm >= 7.5 ? 10 : norm >= 3.5 ? 5 : norm >= 1.5 ? 2 : 1) * magnitude
const lo = Math.floor(min / step) * step
const hi = Math.ceil(max / step) * step
const decimals = Math.max(0, -Math.floor(Math.log10(step)))
const ticks: number[] = []
for (let v = lo; v <= hi + step / 2; v += step)
ticks.push(Number(v.toFixed(decimals)))
return { ticks, min: lo, max: hi, step }
}
/** Straight segments through every point. */
export function linearPath(points: ChartPoint[]) {
if (!points.length)
return ''
return `M${points.map(([x, y]) => `${x},${y}`).join('L')}`
}
/**
* Monotone cubic curve through the points (x must increase). Unlike a plain
* Catmull-Rom spline it never overshoots, so peaks stay at the real values.
*/
export function monotonePath(points: ChartPoint[]) {
const n = points.length
if (n < 3)
return linearPath(points)
const slopes: number[] = []
for (let i = 0; i < n - 1; i++) {
const dx = points[i + 1]![0] - points[i]![0]
slopes.push(dx === 0 ? 0 : (points[i + 1]![1] - points[i]![1]) / dx)
}
const tangents = [slopes[0]!]
for (let i = 1; i < n - 1; i++) {
const a = slopes[i - 1]!
const b = slopes[i]!
// Flat at turning points, harmonic-style blend elsewhere keeps the curve monotone.
tangents.push(a * b <= 0 ? 0 : (3 * a * b) / (Math.max(a, b) + 2 * Math.min(a, b)) || (a + b) / 2)
}
tangents.push(slopes[n - 2]!)
let d = `M${points[0]![0]},${points[0]![1]}`
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = points[i]!
const [x1, y1] = points[i + 1]!
const h = (x1 - x0) / 3
d += `C${x0 + h},${y0 + tangents[i]! * h} ${x1 - h},${y1 - tangents[i + 1]! * h} ${x1},${y1}`
}
return d
}
/** Closes a line path down to a baseline so it can be filled as an area. */
export function areaPath(line: string, points: ChartPoint[], baseline: number) {
if (!points.length)
return ''
return `${line}L${points.at(-1)![0]},${baseline}L${points[0]![0]},${baseline}Z`
}
/** Theme colour for series `i`, cycling through the five `--chart-*` tokens. */
export function chartColor(i: number) {
return `var(--chart-${(((i % 5) + 5) % 5) + 1})`
}
const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })
/** 1234 → "1.2K". Fixed locale so server and client render the same string. */
export function formatCompact(value: number) {
return compact.format(value)
}
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
/** Stretches or squeezes `values` to `length` entries by linear interpolation. */
export function resample(values: number[], length: number) {
if (values.length === length)
return values
if (!values.length)
return Array.from({ length }, () => 0)
if (values.length === 1 || length === 1)
return Array.from({ length }, () => values[0]!)
return Array.from({ length }, (_, i) => {
const t = (i / (length - 1)) * (values.length - 1)
const lo = Math.floor(t)
const hi = Math.min(values.length - 1, lo + 1)
return values[lo]! + (values[hi]! - values[lo]!) * (t - lo)
})
}
/**
* Width of an element, tracked with a ResizeObserver once mounted.
* Renders `fallback` on the server and until the first measurement.
*/
export function useElementWidth(target: Ref<HTMLElement | null | undefined>, fallback = 640) {
const width = ref(fallback)
let observer: ResizeObserver | undefined
onMounted(() => {
const el = target.value
if (!el)
return
width.value = Math.round(el.clientWidth) || fallback
observer = new ResizeObserver(([entry]) => {
const w = Math.round(entry?.contentRect.width ?? 0)
if (w > 0)
width.value = w
})
observer.observe(el)
})
onBeforeUnmount(() => observer?.disconnect())
return width
}
export interface TweenOptions {
/** Seconds. */
duration?: number
/** Skip the tween and jump straight to new values. */
reduced?: Ref<boolean>
}
/**
* Smoothly interpolates a flat list of numbers whenever `source` changes.
* Charts flatten whatever drives their geometry (values, domain ends) into
* one array; arrays of a different length are resampled first so shapes
* still morph instead of jumping.
*/
export function useTween(source: () => number[], options: TweenOptions = {}) {
const current = shallowRef<number[]>(source().slice())
let controls: { stop: () => void } | undefined
watch(source, (next) => {
const prev = current.value
if (prev.length === next.length && prev.every((v, i) => v === next[i]))
return
controls?.stop()
if (options.reduced?.value) {
current.value = next.slice()
return
}
const from = resample(prev, next.length)
controls = animate(0, 1, {
duration: options.duration ?? 0.7,
ease: [0.22, 1, 0.36, 1],
onUpdate: (t: number) => {
current.value = next.map((v, i) => from[i]! + (v - from[i]!) * t)
},
})
})
onBeforeUnmount(() => controls?.stop())
return current
}vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion } from 'motion-v'
import { computed, reactive, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { clamp } from './utils'
const props = withDefaults(defineProps<{
open: boolean
/** Anchor point in px, relative to the chart's positioned wrapper. */
x: number
y: number
/** Size of the wrapper; the tooltip is clamped inside it. */
bounds: { width: number, height: number }
/**
* `top` / `bottom`: centred above / below the anchor, flipping when there's no room.
* `side`: to the right, flipping left at the edge.
*/
placement?: 'top' | 'bottom' | 'side'
/** Gap between anchor and tooltip in px. */
offset?: number
class?: HTMLAttributes['class']
}>(), {
placement: 'top',
offset: 12,
})
const POP = { type: 'spring', stiffness: 520, damping: 34 } as const
const el = ref<HTMLElement>()
const size = reactive({ w: 0, h: 0 })
// Glide between anchors only after the first placement, never on entry.
const settled = ref(false)
watch(el, (node, _, onCleanup) => {
if (!node) {
settled.value = false
return
}
const measure = () => {
size.w = node.offsetWidth
size.h = node.offsetHeight
}
measure()
const frame = requestAnimationFrame(() => (settled.value = true))
const observer = new ResizeObserver(measure)
observer.observe(node)
onCleanup(() => {
cancelAnimationFrame(frame)
observer.disconnect()
})
}, { flush: 'post' })
const position = computed(() => {
const { width, height } = props.bounds
const { w, h } = size
const o = props.offset
let left: number
let top: number
if (props.placement === 'side') {
left = props.x + o + w > width ? props.x - o - w : props.x + o
top = props.y - h / 2
}
else if (props.placement === 'bottom') {
left = props.x - w / 2
top = props.y + o + h > height ? props.y - o - h : props.y + o
}
else {
left = props.x - w / 2
top = props.y - o - h < 0 ? props.y + o : props.y - o - h
}
return {
left: `${clamp(left, 0, Math.max(0, width - w))}px`,
top: `${clamp(top, 0, Math.max(0, height - h))}px`,
}
})
</script>
<template>
<AnimatePresence>
<motion.div
v-if="props.open"
key="vx-chart-tooltip"
aria-hidden="true"
class="pointer-events-none absolute z-20"
:class="settled ? 'transition-[left,top] duration-200 ease-out' : ''"
:style="position"
:initial="{ opacity: 0, scale: 0.94 }"
:animate="{ opacity: 1, scale: 1 }"
:exit="{ opacity: 0, scale: 0.94, transition: { duration: 0.12 } }"
:transition="POP"
>
<div
ref="el"
:class="cn(
'min-w-28 whitespace-nowrap rounded-lg border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-lg',
props.class,
)"
>
<slot />
</div>
</motion.div>
</AnimatePresence>
</template>ts
export { default as ChartTooltip } from './ChartTooltip.vue'
export {
areaPath,
chartColor,
clamp,
formatCompact,
linearPath,
linearScale,
monotonePath,
niceTicks,
resample,
useElementWidth,
useTween,
} from './utils'
export type { ChartPoint, LinearScale, TweenOptions } from './utils'Usage
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChartTooltip, linearScale, monotonePath, niceTicks, useElementWidth, useTween } from '@/components/ui/chart-utils'
const props = defineProps<{ data: number[] }>()
const root = ref<HTMLElement>()
const width = useElementWidth(root) // 640 on the server, then the real width
const values = useTween(() => props.data) // morphs whenever data changes
const axis = computed(() => niceTicks(0, Math.max(...props.data))) // { ticks, min, max, step }
const path = computed(() => {
const y = linearScale([axis.value.min, axis.value.max], [200, 0])
return monotonePath(values.value.map((v, i) => [(i / (values.value.length - 1)) * width.value, y(v)]))
})
</script>
<template>
<div ref="root" class="relative">
<svg :width="width" height="200">
<path :d="path" fill="none" stroke="var(--chart-1)" />
</svg>
</div>
</template>API
| Export | Signature | Description |
|---|---|---|
linearScale | (domain, range) => scale | Maps numbers to pixels; scale.invert(px) goes back. |
niceTicks | (min, max, count = 5) => { ticks, min, max, step } | Round tick values (steps of 1, 2, 5 × 10ⁿ) and a domain widened to land on them. |
monotonePath | (points) => string | Smooth cubic path through [x, y] points that never overshoots the data. |
linearPath | (points) => string | Straight segments. |
areaPath | (line, points, baseline) => string | Closes a line path down to a baseline. |
chartColor | (i) => string | var(--chart-1…5), cycling. |
formatCompact | (n) => string | 12345 → "12.3K", fixed en-US locale so SSR and client match. |
resample | (values, length) => number[] | Stretches a series to a new length by interpolation. |
clamp | (v, min, max) => number | Clamps a number. |
useElementWidth | (el, fallback = 640) => Ref<number> | Container width via ResizeObserver (SSR-safe). |
useTween | (source, { duration?, reduced? }) => Ref<number[]> | Interpolates a flat number array whenever source() changes; arrays of a new length are resampled first. |
ChartTooltip
An absolutely positioned tooltip for a relative chart wrapper. It measures itself, stays inside bounds, flips when there's no room and glides between anchors.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Show / hide (animated). |
x / y | number | — | Anchor in px, relative to the wrapper. |
bounds | { width, height } | — | Area the tooltip is clamped to. |
placement | 'top' | 'bottom' | 'side' | 'top' | Preferred side of the anchor. |
offset | number | 12 | Gap between anchor and tooltip. |