vuetify
Version:
Vue Material Component Framework
422 lines (385 loc) • 11 kB
text/typescript
// Mixins
import Colorable from '../../mixins/colorable'
// Utilities
import mixins, { ExtractVue } from '../../util/mixins'
import { genPoints, genBars } from './helpers/core'
import { genPath } from './helpers/path'
// Types
import Vue, { VNode } from 'vue'
import { Prop, PropValidator } from 'vue/types/options'
export type SparklineItem = number | { value: number }
export type SparklineText = {
x: number
value: string
}
export interface Boundary {
minX: number
minY: number
maxX: number
maxY: number
}
export interface Point {
x: number
y: number
value: number
}
export interface Bar {
x: number
y: number
height: number
value: number
}
interface options extends Vue {
$refs: {
path: SVGPathElement
}
}
export default mixins<options &
/* eslint-disable indent */
ExtractVue<[
typeof Colorable
]>
/* eslint-enable indent */
>(
Colorable
).extend({
name: 'VSparkline',
inheritAttrs: false,
props: {
autoDraw: Boolean,
autoDrawDuration: {
type: Number,
default: 2000,
},
autoDrawEasing: {
type: String,
default: 'ease',
},
autoLineWidth: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'primary',
},
fill: {
type: Boolean,
default: false,
},
gradient: {
type: Array,
default: () => ([]),
} as PropValidator<string[]>,
gradientDirection: {
type: String as Prop<'top' | 'bottom' | 'left' | 'right'>,
validator: (val: string) => ['top', 'bottom', 'left', 'right'].includes(val),
default: 'top',
},
height: {
type: [String, Number],
default: 75,
},
labels: {
type: Array,
default: () => ([]),
} as PropValidator<SparklineItem[]>,
labelSize: {
type: [Number, String],
default: 7,
},
lineWidth: {
type: [String, Number],
default: 4,
},
padding: {
type: [String, Number],
default: 8,
},
showLabels: Boolean,
smooth: {
type: [Boolean, Number, String],
default: false,
},
type: {
type: String as Prop<'trend' | 'bar'>,
default: 'trend',
validator: (val: string) => ['trend', 'bar'].includes(val),
},
value: {
type: Array,
default: () => ([]),
} as PropValidator<SparklineItem[]>,
width: {
type: [Number, String],
default: 300,
},
},
data: () => ({
lastLength: 0,
}),
computed: {
parsedPadding (): number {
return Number(this.padding)
},
parsedWidth (): number {
return Number(this.width)
},
parsedHeight (): number {
return parseInt(this.height, 10)
},
parsedLabelSize (): number {
return parseInt(this.labelSize, 10) || 7
},
totalHeight (): number {
let height = this.parsedHeight
if (this.hasLabels) height += parseInt(this.labelSize, 10) * 1.5
return height
},
totalWidth (): number {
let width = this.parsedWidth
if (this.type === 'bar') width = Math.max(this.value.length * this._lineWidth, width)
return width
},
totalValues (): number {
return this.value.length
},
_lineWidth (): number {
if (this.autoLineWidth && this.type !== 'trend') {
const totalPadding = this.parsedPadding * (this.totalValues + 1)
return (this.parsedWidth - totalPadding) / this.totalValues
} else {
return parseFloat(this.lineWidth) || 4
}
},
boundary (): Boundary {
if (this.type === 'bar') return { minX: 0, maxX: this.totalWidth, minY: 0, maxY: this.parsedHeight }
const padding = this.parsedPadding
return {
minX: padding,
maxX: this.totalWidth - padding,
minY: padding,
maxY: this.parsedHeight - padding,
}
},
hasLabels (): boolean {
return Boolean(
this.showLabels ||
this.labels.length > 0 ||
this.$scopedSlots.label
)
},
parsedLabels (): SparklineText[] {
const labels = []
const points = this._values
const len = points.length
for (let i = 0; labels.length < len; i++) {
const item = points[i]
let value = this.labels[i]
if (!value) {
value = typeof item === 'object'
? item.value
: item
}
labels.push({
x: item.x,
value: String(value),
})
}
return labels
},
normalizedValues (): number[] {
return this.value.map(item => (typeof item === 'number' ? item : item.value))
},
_values (): Point[] | Bar[] {
return this.type === 'trend' ? genPoints(this.normalizedValues, this.boundary) : genBars(this.normalizedValues, this.boundary)
},
textY (): number {
let y = this.parsedHeight
if (this.type === 'trend') y -= 4
return y
},
_radius (): number {
return this.smooth === true ? 8 : Number(this.smooth)
},
},
watch: {
value: {
immediate: true,
handler () {
this.$nextTick(() => {
if (
!this.autoDraw ||
this.type === 'bar' ||
!this.$refs.path
) return
const path = this.$refs.path
const length = path.getTotalLength()
if (!this.fill) {
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'
} else {
path.style.transformOrigin = 'bottom center'
path.style.transition = 'none'
path.style.transform = `scaleY(0)`
path.getBoundingClientRect()
path.style.transition = `transform ${this.autoDrawDuration}ms ${this.autoDrawEasing}`
path.style.transform = `scaleY(1)`
}
this.lastLength = length
})
},
},
},
methods: {
genGradient () {
const gradientDirection = this.gradientDirection
const gradient = this.gradient.slice()
// Pushes empty string to force
// a fallback to currentColor
if (!gradient.length) gradient.push('')
const len = Math.max(gradient.length - 1, 1)
const stops = gradient.reverse().map((color, index) =>
this.$createElement('stop', {
attrs: {
offset: index / len,
'stop-color': color || 'currentColor',
},
})
)
return this.$createElement('defs', [
this.$createElement('linearGradient', {
attrs: {
id: this._uid,
gradientUnits: 'userSpaceOnUse',
x1: gradientDirection === 'left' ? '100%' : '0',
y1: gradientDirection === 'top' ? '100%' : '0',
x2: gradientDirection === 'right' ? '100%' : '0',
y2: gradientDirection === 'bottom' ? '100%' : '0',
},
}, stops),
])
},
genG (children: VNode[]) {
return this.$createElement('g', {
style: {
fontSize: '8',
textAnchor: 'middle',
dominantBaseline: 'mathematical',
fill: 'currentColor',
} as object, // TODO: TS 3.5 is too eager with the array type here
}, children)
},
genPath () {
const points = genPoints(this.normalizedValues, this.boundary)
return this.$createElement('path', {
attrs: {
d: genPath(points, this._radius, this.fill, this.parsedHeight),
fill: this.fill ? `url(#${this._uid})` : 'none',
stroke: this.fill ? 'none' : `url(#${this._uid})`,
},
ref: 'path',
})
},
genLabels (offsetX: number) {
const children = this.parsedLabels.map((item, i) => (
this.$createElement('text', {
attrs: {
x: item.x + offsetX + this._lineWidth / 2,
y: this.textY + (this.parsedLabelSize * 0.75),
'font-size': Number(this.labelSize) || 7,
},
}, [this.genLabel(item, i)])
))
return this.genG(children)
},
genLabel (item: SparklineText, index: number) {
return this.$scopedSlots.label
? this.$scopedSlots.label({ index, value: item.value })
: item.value
},
genBars () {
if (!this.value || this.totalValues < 2) return undefined as never
const bars = genBars(this.normalizedValues, this.boundary)
const offsetX = (Math.abs(bars[0].x - bars[1].x) - this._lineWidth) / 2
return this.$createElement('svg', {
attrs: {
display: 'block',
viewBox: `0 0 ${this.totalWidth} ${this.totalHeight}`,
},
}, [
this.genGradient(),
this.genClipPath(bars, offsetX, this._lineWidth, 'sparkline-bar-' + this._uid),
this.hasLabels ? this.genLabels(offsetX) : undefined as never,
this.$createElement('g', {
attrs: {
'clip-path': `url(#sparkline-bar-${this._uid}-clip)`,
fill: `url(#${this._uid})`,
},
}, [
this.$createElement('rect', {
attrs: {
x: 0,
y: 0,
width: this.totalWidth,
height: this.height,
},
}),
]),
])
},
genClipPath (bars: Bar[], offsetX: number, lineWidth: number, id: string) {
const rounding = typeof this.smooth === 'number'
? this.smooth
: this.smooth ? 2 : 0
return this.$createElement('clipPath', {
attrs: {
id: `${id}-clip`,
},
}, bars.map(item => {
return this.$createElement('rect', {
attrs: {
x: item.x + offsetX,
y: item.y,
width: lineWidth,
height: item.height,
rx: rounding,
ry: rounding,
},
}, [
this.autoDraw ? this.$createElement('animate', {
attrs: {
attributeName: 'height',
from: 0,
to: item.height,
dur: `${this.autoDrawDuration}ms`,
fill: 'freeze',
},
}) : undefined as never,
])
}))
},
genTrend () {
return this.$createElement('svg', this.setTextColor(this.color, {
attrs: {
...this.$attrs,
display: 'block',
'stroke-width': this._lineWidth || 1,
viewBox: `0 0 ${this.width} ${this.totalHeight}`,
},
}), [
this.genGradient(),
this.hasLabels && this.genLabels(-(this._lineWidth / 2)),
this.genPath(),
])
},
},
render (h): VNode {
if (this.totalValues < 2) return undefined as never
return this.type === 'trend' ? this.genTrend() : this.genBars()
},
})