Skip to content

Commit

Permalink
feat: add sparkline component (#4923)
Browse files Browse the repository at this point in the history
* feat: add sparkline

* fix: sparkline porps

* feat(sparkline): add show-label prop

* refactor(VSparkline): rename file to .ts

* refactor(VSparkline): add types

* test: update a-la-carte snapshots

* style: combine imports

* docs: add props as wrapperFor

* docs: add sparklines page and example

* fix: use non-boolean smooth values in rect

* docs: add meta info, "new" tag in drawer

* docs: update sparkline playground and prop descriptions

* docs: reduce width slider max value

* docs: add type for "type" prop

* docs: add labels to playground button groups

* docs: fix "type" prop definition

* enh: reduce default auto-draw duration for type="bar"

* docs: add AppDrawer.sparklines string


Co-authored-by: qingwei.li <cinwell.li@gmail.com>
  • Loading branch information
KaelWD and QingWei-Li committed Nov 30, 2018
1 parent 98917af commit 2729a0e
Show file tree
Hide file tree
Showing 25 changed files with 670 additions and 0 deletions.
9 changes: 9 additions & 0 deletions packages/api-generator/src/map.js
Expand Up @@ -720,6 +720,15 @@ module.exports = {
'v-snackbar': {
slots: ['default']
},
'v-sparkline': {
props: [
{
name: 'type',
type: "'trend' | 'bar'",
default: "'trend'"
}
]
},
'v-select': VSelect,
'v-slider': {
events: [
Expand Down
70 changes: 70 additions & 0 deletions packages/vuetify/src/components/VSparkline/Bar.ts
@@ -0,0 +1,70 @@
import mixins from '../../util/mixins'
import { VNode } from 'vue'

import props from './mixins/props'
import Rect from './components/rect'
import Text from './components/text'
import Gradient from './components/gradient'
import { genPoints } from './helpers/core'

export default mixins(props).extend({
name: 'bar',

props: {
autoDrawDuration: {
type: Number,
default: 500
}
},

render (h): VNode {
if (!this.data || this.data.length < 2) return undefined as never
const { width, height, padding, lineWidth } = this
const viewWidth = width || 300
const viewHeight = height || 75
const boundary = {
minX: padding,
minY: padding,
maxX: viewWidth - padding,
maxY: viewHeight - padding
}
const props = this.$props

props.points = genPoints(this.data, boundary)

const totalWidth = boundary.maxX / (props.points.length - 1)

props.boundary = boundary
props.id = 'sparkline-bar-' + this._uid
props.lineWidth = lineWidth || (totalWidth - (padding || 5))
props.offsetX = (totalWidth - props.lineWidth) / 2

return h('svg', {
attrs: {
width: width || '100%',
height: height || '25%',
viewBox: `0 0 ${viewWidth} ${viewHeight}`
}
}, [
h(Gradient, { props }),
h(Rect, { props }),
this.showLabel ? h(Text, { props }) : undefined as never,
h('g', {
attrs: {
transform: `scale(1,-1) translate(0,-${boundary.maxY})`,
'clip-path': `url(#${props.id}-clip)`,
fill: `url(#${props.id})`
}
}, [
h('rect', {
attrs: {
x: 0,
y: 0,
width: viewWidth,
height: viewHeight
}
})
])
])
}
})
80 changes: 80 additions & 0 deletions packages/vuetify/src/components/VSparkline/Trend.ts
@@ -0,0 +1,80 @@
import { VNode } from 'vue'
import mixins, { ExtractVue } from '../../util/mixins'

import props from './mixins/props'
import Path from './components/path'
import Text from './components/text'
import Gradient from './components/gradient'
import { genPoints } from './helpers/core'

interface options {
$refs: {
path: InstanceType<typeof Path>
}
}

export default mixins<options & ExtractVue<typeof props>>(props).extend({
name: 'trend',

data: () => ({
lastLength: 0
}),

watch: {
data: {
immediate: true,
handler () {
this.$nextTick(() => {
if (!this.autoDraw) {
return
}

const path = this.$refs.path.$el
const length = path.getTotalLength()

path.style.transition = 'none'
path.style.strokeDasharray = length + ' ' + length
path.style.strokeDashoffset = Math.abs(length - (this.lastLength || 0)).toString()
path.getBoundingClientRect()
path.style.transition = `stroke-dashoffset ${this.autoDrawDuration}ms ${this.autoDrawEasing}`
path.style.strokeDashoffset = '0'
this.lastLength = length
})
}
}
},

render (h): VNode {
if (!this.data || this.data.length < 2) return undefined as never
const { width, height, padding } = this
const viewWidth = width || 300
const viewHeight = height || 75
const boundary = {
minX: padding,
minY: padding,
maxX: viewWidth - padding,
maxY: viewHeight - padding
}
const props = this.$props

props.boundary = boundary
props.id = 'sparkline-trend-' + this._uid
props.points = genPoints(this.data, boundary)

return h('svg', {
attrs: {
'stroke-width': this.lineWidth || 1,
width: width || '100%',
height: height || '25%',
viewBox: `0 0 ${viewWidth} ${viewHeight}`
}
}, [
h(Gradient, { props }),
this.showLabel ? h(Text, { props }) : undefined as never,
h(Path, {
props,
ref: 'path'
})
])
}
})
50 changes: 50 additions & 0 deletions packages/vuetify/src/components/VSparkline/VSparkline.ts
@@ -0,0 +1,50 @@
import Vue from 'vue'
import { PropValidator } from 'vue/types/options'

import bar from './Bar'
import trend from './Trend'
import props from './mixins/props'

const COMPONENTS = {
bar,
trend
}

export type SparklineItem = number | { value: number }

export interface Boundary {
minX: number
minY: number
maxX: number
maxY: number
}

export interface Point {
x: number
y: number
value: number
}

export default Vue.extend({
name: 'v-sparkline',

functional: true,

$_wrapperFor: props,

props: {
type: {
type: String,
default: 'trend',
validator: (val: string) => ['trend', 'bar'].includes(val)
} as PropValidator<'trend' | 'bar'>
},

render (h, { props, data }) {
return h('div', {
staticClass: 'sparkline'
}, [
h(COMPONENTS[props.type], data)
])
}
})
35 changes: 35 additions & 0 deletions packages/vuetify/src/components/VSparkline/components/gradient.ts
@@ -0,0 +1,35 @@
/* eslint-disable no-multi-spaces, object-property-newline */

import Vue, { VNode } from 'vue'
import { Prop } from 'vue/types/options'

export default Vue.extend({
props: ['gradient', 'id', 'gradientDirection'] as any as {
gradient: Prop<string[]>
id: Prop<string>
gradientDirection: Prop<string>
},

render (h): VNode {
const { gradient, id, gradientDirection } = this
const len = gradient.length - 1
const stops = gradient.slice().reverse().map((color, index) =>
h('stop', {
attrs: {
offset: index / len,
'stop-color': color
}
})
)

return h('defs', [
h('linearGradient', {
attrs: {
id,
x1: +(gradientDirection === 'left'), y1: +(gradientDirection === 'top'),
x2: +(gradientDirection === 'right'), y2: +(gradientDirection === 'bottom')
}
}, stops)
])
}
})
24 changes: 24 additions & 0 deletions packages/vuetify/src/components/VSparkline/components/path.ts
@@ -0,0 +1,24 @@
import Vue, { VNode } from 'vue'
import { Prop } from 'vue/types/options'

import { Point } from '../VSparkline'
import { genPath } from '../helpers/path'

export default Vue.extend<Vue & { $el: SVGPathElement }>().extend({
props: ['smooth', 'radius', 'id', 'points'] as any as {
smooth: Prop<boolean>
radius: Prop<number>
id: Prop<string>
points: Prop<Point[]>
},

render (h): VNode {
const { smooth, id } = this
const radius = smooth === true ? 8 : Number(smooth)
const d = genPath(this.points, radius)

return h('path', {
attrs: { d, fill: 'none', stroke: `url(#${id})` }
})
}
})
62 changes: 62 additions & 0 deletions packages/vuetify/src/components/VSparkline/components/rect.ts
@@ -0,0 +1,62 @@
import Vue, { VNode } from 'vue'
import { Prop } from 'vue/types/options'

import { Boundary, Point } from '../VSparkline'

export default Vue.extend({
props: [
'id',
'smooth',
'boundary',
'lineWidth',
'gradient',
'autoDrawDuration',
'autoDraw',
'points',
'offsetX'
] as any as {
id: Prop<string>
smooth: Prop<boolean | number>
boundary: Prop<Boundary>
lineWidth: Prop<number>
gradient: Prop<string[]>
autoDrawDuration: Prop<number>
autoDraw: Prop<boolean>
points: Prop<Point[]>
offsetX: Prop<number>
},

render (h): VNode {
const { maxY } = this.boundary
const rounding = typeof this.smooth === 'number'
? this.smooth
: this.smooth ? 2 : 0

return h('clipPath', {
attrs: {
id: `${this.id}-clip`
}
}, this.points.map((item, index) =>
h('rect', {
attrs: {
x: item.x - this.offsetX,
y: 0,
width: this.lineWidth,
height: maxY - item.y,
rx: rounding,
ry: rounding
}
}, [
this.autoDraw ? h('animate', {
attrs: {
attributeName: 'height',
from: 0,
to: maxY - item.y,
dur: `${this.autoDrawDuration}ms`,
fill: 'freeze'
}
}) : undefined as never
])
))
}
})
32 changes: 32 additions & 0 deletions packages/vuetify/src/components/VSparkline/components/text.ts
@@ -0,0 +1,32 @@
import Vue, { VNode } from 'vue'
import { Prop } from 'vue/types/options'

import { Boundary, Point } from '../VSparkline'

export default Vue.extend({
props: ['points', 'boundary', 'offsetX'] as any as {
points: Prop<Point[]>
boundary: Prop<Boundary>
offsetX: Prop<number>
},

render (h): VNode {
const offsetX = (this.offsetX || 0) / 2

return h('g', {
style: {
fontSize: '8',
textAnchor: 'middle',
dominantBaseline: 'mathematical',
fill: this.$vuetify.theme.secondary
}
}, this.points.map(item => (
h('text', {
attrs: {
x: item.x - offsetX,
y: this.boundary.maxY + 2
}
}, item.value.toString())
)))
}
})
21 changes: 21 additions & 0 deletions packages/vuetify/src/components/VSparkline/helpers/core.ts
@@ -0,0 +1,21 @@
import { SparklineItem, Boundary, Point } from '../VSparkline'

export function genPoints (points: SparklineItem[], boundary: Boundary): Point[] {
const { minX, minY, maxX, maxY } = boundary
const normalisedPoints = points.map(item => (typeof item === 'number' ? item : item.value))
const minValue = Math.min(...normalisedPoints) - 0.001
const gridX = (maxX - minX) / (normalisedPoints.length - 1)
const gridY = (maxY - minY) / (Math.max(...normalisedPoints) + 0.001 - minValue)

return normalisedPoints.map((value, index) => {
return {
x: index * gridX + minX,
y:
maxY -
(value - minValue) * gridY +
+(index === normalisedPoints.length - 1) * 0.00001 -
+(index === 0) * 0.00001,
value
}
})
}

0 comments on commit 2729a0e

Please sign in to comment.