diff --git a/packages/vuetify/src/labs/VSparkline/VMultiTrend.tsx b/packages/vuetify/src/labs/VSparkline/VMultiTrend.tsx new file mode 100644 index 00000000000..9d536a3c6b3 --- /dev/null +++ b/packages/vuetify/src/labs/VSparkline/VMultiTrend.tsx @@ -0,0 +1,259 @@ +// Composables +import { makeThemeProps, provideTheme } from '@/composables/theme' + +// Utilities +import { computed, nextTick, ref, watch } from 'vue' +import { makeLineProps } from './util/line' +import { genPath as _genPath } from './util/path' +import { genericComponent, getPropertyFromItem, getUid, isCssColor, propsFactory, useRender } from '@/util' + +// Types +export type VMultiTrendSlots = { + default: void + label: { index: number, value: string } +} + +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 const makeVMultiTrendProps = propsFactory({ + fill: Boolean, + ...makeLineProps(), + ...makeThemeProps(), +}, 'VMultiTrend') + +export const VMultiTrend = genericComponent()({ + name: 'VMultiTrend', + + props: makeVMultiTrendProps(), + + setup (props, { slots }) { + const theme = provideTheme(props) + const uid = getUid() + const id = computed(() => props.id || `multitrend-${uid}`) + const autoDrawDuration = computed(() => Number(props.autoDrawDuration) || (props.fill ? 500 : 2000)) + + const lastLength = ref(0) + const paths = ref([]) + + function genPoints ( + values: number[], + boundary: Boundary + ): Point[] { + const { minX, maxX, minY, maxY } = boundary + const totalValues = values.length + const maxValue = props.max != null ? Number(props.max) : Math.max(...values) + const minValue = props.min != null ? Number(props.min) : Math.min(...values) + + const gridX = (maxX - minX) / (totalValues - 1) + const gridY = (maxY - minY) / ((maxValue - minValue) || 1) + + return values.map((value, index) => { + return { + x: minX + index * gridX, + y: maxY - (value - minValue) * gridY, + value, + } + }) + } + const hasLabels = computed(() => { + return Boolean( + props.showLabels || + props.labels.length > 0 || + !!slots?.label + ) + }) + const lineWidth = computed(() => { + return parseFloat(props.lineWidth) || 4 + }) + const totalWidth = computed(() => Number(props.width)) + + const boundary = computed(() => { + const padding = Number(props.padding) + + return { + minX: padding, + maxX: totalWidth.value - padding, + minY: padding, + maxY: parseInt(props.height, 10) - padding, + } + }) + const items = computed(() => { + let lines = [] + if (!Array.isArray(props.modelValue?.[0])) { + lines = [props.modelValue.map(item => getPropertyFromItem(item, props.itemValue, item))] + } else { + lines = props.modelValue.map(item => (item as SparklineItem[]).map(nested => getPropertyFromItem(nested, props.itemValue, nested))) + } + // Need to make lines the same length + const longestLine = lines.reduce((longest, line) => line.length > longest ? line.length : longest, 0) + lines = lines.map(line => line.length === longestLine + ? line + : [...line, ...Array.from({ length: longestLine - line.length }, (v, i) => line.at(-1))] + ) + return lines + }) + const parsedLabels = computed(() => { + const labels = [] + const points = items.value.map(item => genPoints(item, boundary.value)) + const len = points.length + + for (let i = 0; labels.length < len; i++) { + const item = points[0][i] + let value = props.labels[i] + + if (!value) { + value = typeof item === 'object' + ? item.value + : item + } + + labels.push({ + x: item.x, + value: String(value), + }) + } + + return labels + }) + + watch(() => props.modelValue, async () => { + await nextTick() + + if (!props.autoDraw || !paths.value || !paths.value.length) return + for (const path of paths.value) { + const pathRef = path + const length = pathRef.getTotalLength() + + if (!props.fill) { + // Initial setup to "hide" the line by using the stroke dash array + pathRef.style.strokeDasharray = `${length}` + pathRef.style.strokeDashoffset = `${length}` + + // Force reflow to ensure the transition starts from this state + pathRef.getBoundingClientRect() + + // Animate the stroke dash offset to "draw" the line + pathRef.style.transition = `stroke-dashoffset ${autoDrawDuration.value}ms ${props.autoDrawEasing}` + pathRef.style.strokeDashoffset = '0' + } else { + pathRef.style.fill = 'black' + // Your existing logic for filled paths remains the same + pathRef.style.transformOrigin = 'bottom center' + pathRef.style.transition = 'none' + pathRef.style.transform = `scaleY(0)` + pathRef.getBoundingClientRect() + pathRef.style.transition = `transform ${autoDrawDuration.value}ms ${props.autoDrawEasing}` + pathRef.style.transform = `scaleY(1)` + } + } + + lastLength.value = length + }, { immediate: true }) + + function genPath (index: number, fill: boolean) { + return _genPath( + genPoints(items.value[index], boundary.value), + props.smooth ? 8 : Number(props.smooth), + fill, + parseInt(props.height, 10) + ) + } + + useRender(() => { + const gradientData = !props.gradient.slice().length ? [''] : props.gradient.slice().reverse() + + return ( + + + + { + gradientData.map((color, index) => ( + + )) + } + + + + { hasLabels.value && ( + + { + parsedLabels.value.map((item, i) => ( + + { slots.label?.({ index: i, value: item.value }) ?? item.value } + + )) + } + + )} + { + items.value.map((item, i) => ( + + )) + } + + { props.fill && ( + + )} + + ) + }) + }, +}) + +export type VMultiTrend = InstanceType diff --git a/packages/vuetify/src/labs/VSparkline/VSparkline.tsx b/packages/vuetify/src/labs/VSparkline/VSparkline.tsx index 73e2ffb9e78..6b222cfe674 100644 --- a/packages/vuetify/src/labs/VSparkline/VSparkline.tsx +++ b/packages/vuetify/src/labs/VSparkline/VSparkline.tsx @@ -1,5 +1,6 @@ // Components import { makeVBarlineProps, VBarline } from './VBarline' +import { makeVMultiTrendProps, VMultiTrend } from './VMultiTrend' import { makeVTrendlineProps, VTrendline } from './VTrendline' // Composables @@ -16,11 +17,12 @@ import type { PropType } from 'vue' export const makeVSparklineProps = propsFactory({ type: { - type: String as PropType<'trend' | 'bar'>, + type: String as PropType<'trend' | 'bar' | 'multi'>, default: 'trend', }, ...makeVBarlineProps(), + ...makeVMultiTrendProps(), ...makeVTrendlineProps(), }, 'VSparkline') @@ -35,7 +37,11 @@ export const VSparkline = genericComponent()({ props: makeVSparklineProps(), setup (props, { slots }) { - const { textColorClasses, textColorStyles } = useTextColor(toRef(props, 'color')) + const { textColorClasses, textColorStyles } = useTextColor( + Array.isArray(props.color) + ? toRef(props, 'color[0]') + : toRef(props, 'color') + ) const hasLabels = computed(() => { return Boolean( props.showLabels || @@ -52,8 +58,12 @@ export const VSparkline = genericComponent()({ }) useRender(() => { - const Tag = props.type === 'trend' ? VTrendline : VBarline - const lineProps = props.type === 'trend' ? VTrendline.filterProps(props) : VBarline.filterProps(props) + const Tag = props.type === 'trend' ? VTrendline : props.type === 'bar' ? VBarline : VMultiTrend + const lineProps = props.type === 'trend' + ? VTrendline.filterProps(props) + : props.type === 'bar' + ? VBarline.filterProps(props) + : VMultiTrend.filterProps(props) return ( ()({ @@ -45,13 +42,12 @@ export const VTrendline = genericComponent()({ props: makeVTrendlineProps(), setup (props, { slots }) { - const theme = provideTheme(props) const uid = getUid() const id = computed(() => props.id || `trendline-${uid}`) const autoDrawDuration = computed(() => Number(props.autoDrawDuration) || (props.fill ? 500 : 2000)) const lastLength = ref(0) - const paths = ref([]) + const path = ref(null) function genPoints ( values: number[], @@ -67,6 +63,7 @@ export const VTrendline = genericComponent()({ if (values.length === 1) { values.push(values[0]) } + return values.map((value, index) => { return { x: minX + index * gridX, @@ -97,18 +94,14 @@ export const VTrendline = genericComponent()({ maxY: parseInt(props.height, 10) - padding, } }) - const items = computed(() => - !Array.isArray(props.modelValue?.[0]) - ? [props.modelValue.map(item => getPropertyFromItem(item, props.itemValue, item))] - : props.modelValue.map(item => (item as SparklineItem[]).map(nested => getPropertyFromItem(nested, props.itemValue, nested))) - ) + const items = computed(() => props.modelValue.map(item => getPropertyFromItem(item, props.itemValue, item))) const parsedLabels = computed(() => { const labels = [] - const points = items.value.map(item => genPoints(item, boundary.value)) + const points = genPoints(items.value, boundary.value) const len = points.length for (let i = 0; labels.length < len; i++) { - const item = points[0][i] + const item = points[i] let value = props.labels[i] if (!value) { @@ -129,42 +122,38 @@ export const VTrendline = genericComponent()({ watch(() => props.modelValue, async () => { await nextTick() - if (!props.autoDraw || !paths.value || !paths.value.length) return - let pathIndex = 0 - for (const path of paths.value) { - const pathRef = path - const length = pathRef.getTotalLength() - - if (!props.fill) { - // Initial setup to "hide" the line by using the stroke dash array - pathRef.style.strokeDasharray = `${length}` - pathRef.style.strokeDashoffset = `${length}` - - // Force reflow to ensure the transition starts from this state - pathRef.getBoundingClientRect() - - // Animate the stroke dash offset to "draw" the line - pathRef.style.transition = `stroke-dashoffset ${autoDrawDuration.value}ms ${props.autoDrawEasing}` - pathRef.style.strokeDashoffset = '0' - } else { - pathRef.style.fill = 'black' - // Your existing logic for filled paths remains the same - pathRef.style.transformOrigin = 'bottom center' - pathRef.style.transition = 'none' - pathRef.style.transform = `scaleY(0)` - pathRef.getBoundingClientRect() - pathRef.style.transition = `transform ${autoDrawDuration.value}ms ${props.autoDrawEasing}` - pathRef.style.transform = `scaleY(1)` - } - pathIndex++ + if (!props.autoDraw || !path.value) return + + const pathRef = path.value + const length = pathRef.getTotalLength() + + if (!props.fill) { + // Initial setup to "hide" the line by using the stroke dash array + pathRef.style.strokeDasharray = `${length}` + pathRef.style.strokeDashoffset = `${length}` + + // Force reflow to ensure the transition starts from this state + pathRef.getBoundingClientRect() + + // Animate the stroke dash offset to "draw" the line + pathRef.style.transition = `stroke-dashoffset ${autoDrawDuration.value}ms ${props.autoDrawEasing}` + pathRef.style.strokeDashoffset = '0' + } else { + // Your existing logic for filled paths remains the same + pathRef.style.transformOrigin = 'bottom center' + pathRef.style.transition = 'none' + pathRef.style.transform = `scaleY(0)` + pathRef.getBoundingClientRect() + pathRef.style.transition = `transform ${autoDrawDuration.value}ms ${props.autoDrawEasing}` + pathRef.style.transform = `scaleY(1)` } lastLength.value = length }, { immediate: true }) - function genPath (index: number, fill: boolean) { + function genPath (fill: boolean) { return _genPath( - genPoints(items.value[index], boundary.value), + genPoints(items.value, boundary.value), props.smooth ? 8 : Number(props.smooth), fill, parseInt(props.height, 10) @@ -218,30 +207,19 @@ export const VTrendline = genericComponent()({ } )} - { - items.value.map((item, i) => ( - - )) - } + + { props.fill && ( )}