Skip to content

Commit 22d1d0f

Browse files
committed
feat: monotone smoothing
1 parent cf93f74 commit 22d1d0f

5 files changed

Lines changed: 91 additions & 3 deletions

File tree

packages/api-generator/src/locale/en/VSparkline.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"showLabels": "Show labels below each data point.",
2424
"showMarkers": "Show circle markers at each data point.",
2525
"smooth": "Controls the curve tension. `true` defaults to 8, `false` or `0` gives straight lines. Higher values produce smoother curves.",
26+
"smoothMode": "The interpolation algorithm to use. `default` uses corner-rounding, `monotone` uses interpolation which prevents overshoot at local extrema. In `monotone` mode, **smooth** has value limit of 8 (larger values do not make the line any different).",
2627
"tooltip": "Displays a tooltip on the active data point. Requires **interactive**. Can be `true` or an object with `titleFormat`, `offset`, and `showCrosshair` options.",
2728
"type": "Choose between a trendline or bars.",
2829
"width": "Width of the SVG container."

packages/vuetify/src/components/VSparkline/VTrendline.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { VTooltip } from '@/components/VTooltip/VTooltip'
44
// Utilities
55
import { computed, Fragment, nextTick, ref, shallowRef, useId, watch } from 'vue'
66
import { makeLineProps } from './util/line'
7-
import { genPath as _genPath } from './util/path'
7+
import { genMonotonePath } from './util/monotone'
8+
import { genRoundedPath } from './util/path'
89
import { genericComponent, getPropertyFromItem, PREFERS_REDUCED_MOTION, propsFactory, useRender } from '@/util'
910
import { easingPatterns, useTransition } from '@/util/easing'
1011

@@ -180,8 +181,11 @@ export const VTrendline = genericComponent<VTrendlineSlots>()({
180181

181182
function genPath (fill: boolean) {
182183
const smoothValue = typeof props.smooth === 'boolean' ? (props.smooth ? 8 : 0) : Number(props.smooth ?? 0)
184+
const pathGen = props.smoothMode === 'monotone'
185+
? genMonotonePath
186+
: genRoundedPath
183187

184-
return _genPath(
188+
return pathGen(
185189
extendedPoints.value.slice(),
186190
smoothValue,
187191
fill,

packages/vuetify/src/components/VSparkline/util/line.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export const makeLineProps = propsFactory({
7474
showLabels: Boolean,
7575
showMarkers: Boolean,
7676
smooth: [Boolean, String, Number],
77+
smoothMode: {
78+
type: String as PropType<'default' | 'monotone'>,
79+
default: 'default',
80+
},
7781
interactive: Boolean,
7882
tooltip: {
7983
type: [Boolean, Object] as PropType<boolean | SparklineTooltipConfig>,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Types
2+
import type { Point } from '../VTrendline'
3+
4+
/**
5+
* Monotone cubic Hermite interpolation (Fritsch-Carlson) converted to cubic Bezier.
6+
* Prevents overshoot at local extrema (e.g. consecutive equal min/max values)
7+
* by zeroing tangents at turning points and applying an alpha-beta constraint.
8+
*
9+
* `smooth` controls tension: 0 = straight lines, 8 (default true) = full curve.
10+
*/
11+
export function genMonotonePath (points: Point[], smooth: number, fill = false, height = 75) {
12+
if (points.length === 0) return ''
13+
14+
const start = points[0]
15+
const end = points[points.length - 1]
16+
17+
const prefix = fill
18+
? `M${start.x} ${height - start.x + 2} L${start.x} ${start.y}`
19+
: `M${start.x} ${start.y}`
20+
21+
const suffix = fill ? `L${end.x} ${height - start.x + 2} Z` : ''
22+
23+
if (smooth === 0 || points.length < 3) {
24+
return prefix + points.slice(1).map(point => `L${point.x} ${point.y}`).join('') + suffix
25+
}
26+
27+
const tension = Math.min(smooth / 8, 1)
28+
const n = points.length
29+
30+
const delta: number[] = []
31+
for (let i = 0; i < n - 1; i++) {
32+
const dx = points[i + 1].x - points[i].x
33+
delta[i] = dx === 0 ? 0 : (points[i + 1].y - points[i].y) / dx
34+
}
35+
36+
const tangent: number[] = new Array(n)
37+
tangent[0] = delta[0]
38+
tangent[n - 1] = delta[n - 2]
39+
40+
for (let i = 1; i < n - 1; i++) {
41+
if (delta[i - 1] === 0 || delta[i] === 0 ||
42+
(delta[i - 1] > 0) !== (delta[i] > 0)) {
43+
tangent[i] = 0
44+
} else {
45+
tangent[i] = (delta[i - 1] + delta[i]) / 2
46+
}
47+
}
48+
49+
for (let i = 0; i < n - 1; i++) {
50+
if (delta[i] === 0) {
51+
tangent[i] = 0
52+
tangent[i + 1] = 0
53+
} else {
54+
const alpha = tangent[i] / delta[i]
55+
const beta = tangent[i + 1] / delta[i]
56+
const squaredSum = alpha * alpha + beta * beta
57+
58+
if (squaredSum > 9) {
59+
const tau = 3 / Math.sqrt(squaredSum)
60+
tangent[i] = tau * alpha * delta[i]
61+
tangent[i + 1] = tau * beta * delta[i]
62+
}
63+
}
64+
}
65+
66+
const curves = points.slice(1).map((curr, index) => {
67+
const prev = points[index]
68+
const dx = curr.x - prev.x
69+
70+
const controlPoint1X = prev.x + dx * tension / 3
71+
const controlPoint1Y = prev.y + tangent[index] * dx * tension / 3
72+
const controlPoint2X = curr.x - dx * tension / 3
73+
const controlPoint2Y = curr.y - tangent[index + 1] * dx * tension / 3
74+
75+
return `C${controlPoint1X} ${controlPoint1Y} ${controlPoint2X} ${controlPoint2Y} ${curr.x} ${curr.y}`
76+
})
77+
78+
return prefix + curves.join('') + suffix
79+
}

packages/vuetify/src/components/VSparkline/util/path.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Point } from '../VSparkline'
77
/**
88
* From https://github.com/unsplash/react-trend/blob/master/src/helpers/DOM.helpers.js#L18
99
*/
10-
export function genPath (points: Point[], radius: number, fill = false, height = 75) {
10+
export function genRoundedPath (points: Point[], radius: number, fill = false, height = 75) {
1111
if (points.length === 0) return ''
1212
const start = points.shift()!
1313
const end = points[points.length - 1]

0 commit comments

Comments
 (0)