vuetify
Version:
Vue Material Component Framework
413 lines (409 loc) • 16.1 kB
JavaScript
import { createElementVNode as _createElementVNode, mergeProps as _mergeProps, createVNode as _createVNode, Fragment as _Fragment } from "vue";
// Components
import { VSparklineTooltip } from "./VSparklineTooltip.js"; // Utilities
import { computed, Fragment, nextTick, ref, shallowRef, useId, watch } from 'vue';
import { buildPath, extendPoints, makeLineProps, resample } from "./util/line.js";
import { genericComponent, getPropertyFromItem, PREFERS_REDUCED_MOTION, propsFactory, useRender } from "../../util/index.js";
import { easingPatterns, useTransition } from "../../util/easing.js"; // Types
export const makeVTrendlineProps = propsFactory({
fill: Boolean,
...makeLineProps()
}, 'VTrendline');
export const VTrendline = genericComponent()({
name: 'VTrendline',
inheritAttrs: false,
props: makeVTrendlineProps(),
emits: {
'update:currentIndex': _index => true
},
setup(props, {
slots,
attrs,
emit
}) {
const uid = useId();
const id = computed(() => props.id || `trendline-${uid}`);
const autoDrawDuration = computed(() => Number(props.autoDrawDuration) || (props.fill ? 500 : 2000));
const hasDrawn = ref(false);
const fillPath = ref(null);
const strokePath = ref(null);
const animationDuration = computed(() => typeof props.animation === 'object' ? props.animation.duration ?? 300 : 300);
const animationEasing = computed(() => typeof props.animation === 'object' ? props.animation.easing ?? 'ease' : 'ease');
function genPoints(values, boundary) {
const {
minX,
maxX,
minY,
maxY
} = boundary;
if (values.length === 1) {
values = [values[0], values[0]];
}
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) => ({
x: minX + index * gridX,
y: maxY - (value - minValue) * gridY,
value
}));
}
const hasLabels = computed(() => {
return Boolean(props.showLabels || props.labels.length > 0 || !!slots?.label);
});
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(() => props.modelValue.map(item => getPropertyFromItem(item, props.itemValue, item)));
// When animation is enabled, resample to a consistent point count
// so the SVG path always has the same number of commands for CSS d transitions
const sampleCount = ref(0);
// When sampleCount grows (new dataset is longer than any seen before),
// manually patch the DOM path to old-data-at-new-count before Vue re-renders,
// so the browser sees same-structure paths and can CSS-transition between them.
watch(items, (newVal, oldVal) => {
if (!props.animation) return;
const prevCount = sampleCount.value;
if (newVal.length > prevCount) {
sampleCount.value = newVal.length;
if (prevCount > 0 && oldVal) {
const oldResampled = resample(oldVal, sampleCount.value);
for (const [pathRef, fill] of [[strokePath, false], [fillPath, true]]) {
const path = pathRef.value;
if (!path) continue;
path.setAttribute('d', genPath(oldResampled, fill));
}
}
}
}, {
immediate: true
});
const normalizedItems = computed(() => {
if (!props.animation || !sampleCount.value || items.value.length === sampleCount.value) {
return items.value;
}
return resample(items.value, sampleCount.value);
});
const points = computed(() => genPoints(normalizedItems.value, boundary.value));
const extendedPoints = computed(() => extendPoints(points.value, props.inset, totalWidth.value));
function genPath(input, fill) {
const points = typeof input[0] === 'number' ? extendPoints(genPoints(input, boundary.value), props.inset, totalWidth.value) : input;
return buildPath(points, {
smooth: props.smooth,
smoothMode: props.smoothMode,
height: parseInt(props.height, 10),
fill,
animation: !!props.animation
});
}
const parsedLabels = computed(() => {
const labels = [];
const len = points.value.length;
for (let i = 0; labels.length < len; i++) {
const point = points.value[i];
let value = props.labels[i];
if (!value) {
value = point.value;
}
labels.push({
x: point.x,
value: String(value)
});
}
return labels;
});
function applyDTransition(path, duration, easing) {
path.style.transition = `d ${duration}ms ${easing}`;
}
watch(() => props.modelValue, async () => {
await nextTick();
if (PREFERS_REDUCED_MOTION()) return;
// Animation-only mode (no auto-draw): just ensure d transition is set
if (!props.autoDraw) {
if (props.animation && strokePath.value) {
for (const path of [fillPath.value, strokePath.value]) {
if (path) applyDTransition(path, animationDuration.value, animationEasing.value);
}
}
return;
}
if (!strokePath.value) return;
if (props.autoDraw === 'once' && hasDrawn.value) return;
hasDrawn.value = true;
const shouldDrawOnce = props.autoDraw === 'once';
if (!props.fill) {
const path = strokePath.value;
const length = path.getTotalLength();
path.style.transition = 'none';
path.style.strokeDasharray = `${length}`;
path.style.strokeDashoffset = `${length}`;
path.getBoundingClientRect();
const dTransition = props.animation ? `, d ${animationDuration.value}ms ${animationEasing.value}` : '';
path.style.transition = `stroke-dashoffset ${autoDrawDuration.value}ms ${props.autoDrawEasing}${dTransition}`;
path.style.strokeDashoffset = '0';
if (shouldDrawOnce) {
path.addEventListener('transitionend', e => {
if (e.propertyName !== 'stroke-dashoffset') return;
path.style.strokeDasharray = '';
path.style.strokeDashoffset = '';
if (props.animation) {
applyDTransition(path, animationDuration.value, animationEasing.value);
} else {
path.style.transition = '';
}
}, {
once: true
});
}
} else {
for (const path of [fillPath.value, strokePath.value]) {
if (!path) continue;
path.style.transformOrigin = 'bottom center';
path.style.transition = 'none';
path.style.transform = `scaleY(0)`;
path.getBoundingClientRect();
const dTransition = props.animation ? `, d ${animationDuration.value}ms ${animationEasing.value}` : '';
path.style.transition = `transform ${autoDrawDuration.value}ms ${props.autoDrawEasing}${dTransition}`;
path.style.transform = `scaleY(1)`;
if (shouldDrawOnce) {
path.addEventListener('transitionend', e => {
if (e.propertyName !== 'transform') return;
path.style.transform = '';
path.style.transformOrigin = '';
if (props.animation) {
applyDTransition(path, animationDuration.value, animationEasing.value);
} else {
path.style.transition = '';
}
}, {
once: true
});
}
}
}
}, {
immediate: true
});
// Hover / tooltip state
const svgRef = shallowRef(null);
const currentIndex = shallowRef(null);
const tooltipVisible = shallowRef(false);
const currentPoint = computed(() => currentIndex.value !== null ? points.value[currentIndex.value] : null);
function getPathLengthAtX(svgPath, targetX) {
const total = svgPath.getTotalLength();
let low = 0;
let high = total;
// 32 bisections ≈ sub-pixel accuracy on any reasonable chart width
for (let i = 0; i < 32; i++) {
const mid = (low + high) / 2;
if (svgPath.getPointAtLength(mid).x < targetX) low = mid;else high = mid;
}
return (low + high) / 2;
}
const markerPathLength = shallowRef(0);
watch(currentPoint, point => {
if (!point || !strokePath.value) return;
markerPathLength.value = getPathLengthAtX(strokePath.value, point.x);
});
const animatedLength = useTransition(markerPathLength, {
duration: 150,
transition: easingPatterns.easeOutQuad
});
const markerPoint = computed(() => {
const {
x,
y
} = strokePath.value?.getPointAtLength(animatedLength.value) ?? {
x: 0,
y: 0
};
return {
x,
y
};
});
const tooltipTarget = computed(() => {
if (!currentPoint.value || !svgRef.value) return undefined;
const matrix = svgRef.value.getScreenCTM();
if (!matrix) return undefined;
const svgPoint = svgRef.value.createSVGPoint();
svgPoint.x = markerPoint.value.x;
svgPoint.y = markerPoint.value.y;
const {
x,
y
} = svgPoint.matrixTransform(matrix);
return [x, y];
});
const tooltipConfig = computed(() => ({
showCrosshair: true,
offset: 16,
titleFormat: item => String(item.value),
...(typeof props.tooltip === 'object' ? props.tooltip : {})
}));
let frame = -1;
function onSvgMousemove(e) {
const target = e.currentTarget;
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const rect = target.getBoundingClientRect();
const svgX = (e.clientX - rect.left) / rect.width * Number(props.width);
let nearest = 0;
let minDist = Infinity;
points.value.forEach((point, index) => {
const dist = Math.abs(point.x - svgX);
if (dist < minDist) {
minDist = dist;
nearest = index;
}
});
currentIndex.value = nearest;
emit('update:currentIndex', nearest);
tooltipVisible.value = true;
});
}
function onSvgMouseleave() {
cancelAnimationFrame(frame);
tooltipVisible.value = false;
if (!props.tooltip) {
currentIndex.value = null;
emit('update:currentIndex', null);
}
}
function onTooltipAfterLeave() {
currentIndex.value = null;
emit('update:currentIndex', null);
}
function setIndex(index) {
currentIndex.value = index;
emit('update:currentIndex', index);
tooltipVisible.value = index !== null;
}
function onSvgFocus() {
if (!points.value.length) return;
setIndex(points.value.length - 1);
}
function onSvgBlur() {
tooltipVisible.value = false;
if (!props.tooltip) {
setIndex(null);
}
}
function onSvgKeydown(e) {
if (!points.value.length) return;
const length = points.value.length;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const direction = e.key === 'ArrowLeft' ? -1 : 1;
const current = currentIndex.value ?? (direction === 1 ? -1 : length);
const next = Math.max(0, Math.min(length - 1, current + direction));
setIndex(next);
}
}
useRender(() => {
const gradientData = !props.gradient.slice().length ? [''] : props.gradient.slice().reverse();
const markerRadius = (parseFloat(props.markerSize) || 8) / 2;
return _createElementVNode(_Fragment, null, [_createElementVNode("svg", _mergeProps({
"ref": svgRef,
"display": "block",
"stroke-width": parseFloat(props.lineWidth) ?? 4,
"tabindex": props.interactive ? 0 : undefined,
"onMousemove": props.interactive ? onSvgMousemove : undefined,
"onMouseleave": props.interactive ? onSvgMouseleave : undefined,
"onFocus": props.interactive ? onSvgFocus : undefined,
"onBlur": props.interactive ? onSvgBlur : undefined,
"onKeydown": props.interactive ? onSvgKeydown : undefined
}, attrs), [_createElementVNode("defs", null, [_createElementVNode("linearGradient", {
"id": id.value,
"gradientUnits": "userSpaceOnUse",
"x1": props.gradientDirection === 'left' ? '100%' : '0',
"y1": props.gradientDirection === 'top' ? '100%' : '0',
"x2": props.gradientDirection === 'right' ? '100%' : '0',
"y2": props.gradientDirection === 'bottom' ? '100%' : '0'
}, [gradientData.map((color, index) => _createElementVNode("stop", {
"offset": index / Math.max(gradientData.length - 1, 1),
"stop-color": color || 'currentColor'
}, null))])]), hasLabels.value && _createElementVNode("g", {
"key": "labels",
"style": {
textAnchor: 'middle',
dominantBaseline: 'mathematical',
fill: 'currentColor'
}
}, [parsedLabels.value.map((item, i) => _createElementVNode("text", {
"x": item.x,
"y": parseInt(props.height, 10) - 4 + (parseInt(props.labelSize, 10) || 7 * 0.75),
"font-size": Number(props.labelSize) || 7
}, [slots.label?.({
index: i,
value: item.value
}) ?? item.value]))]), _createElementVNode("path", {
"key": "fill",
"ref": props.fill ? fillPath : strokePath,
"d": genPath(extendedPoints.value, props.fill),
"fill": props.fill ? `url(#${id.value})` : 'none',
"stroke": props.fill ? 'none' : `url(#${id.value})`
}, null), props.fill && _createElementVNode("path", {
"key": "trendline",
"ref": strokePath,
"d": genPath(extendedPoints.value, false),
"fill": "none",
"stroke": "currentColor"
}, null), props.showMarkers && _createElementVNode("g", {
"key": "markers"
}, [points.value.map((point, i) => _createElementVNode("circle", {
"key": i,
"cx": point.x,
"cy": point.y,
"r": markerRadius,
"fill": "currentColor",
"stroke": props.markerStroke,
"stroke-width": 2,
"pointer-events": "none"
}, null))]), props.interactive && currentPoint.value && _createElementVNode("g", {
"key": "hover",
"pointer-events": "none"
}, [tooltipConfig.value.showCrosshair && _createElementVNode("line", {
"key": "crosshair-line",
"x1": markerPoint.value.x,
"y1": props.inset ? 0 : boundary.value.minY,
"x2": markerPoint.value.x,
"y2": props.inset ? parseInt(props.height, 10) : boundary.value.maxY,
"stroke": "currentColor",
"stroke-width": 1,
"stroke-dasharray": "4 2",
"opacity": 0.5
}, null), _createElementVNode("circle", {
"key": "marker",
"cx": markerPoint.value.x,
"cy": markerPoint.value.y,
"r": markerRadius,
"fill": "currentColor",
"stroke": props.markerStroke,
"stroke-width": 2
}, null)])]), !!props.tooltip && _createVNode(VSparklineTooltip, {
"key": "tooltip",
"modelValue": tooltipVisible.value,
"target": tooltipTarget.value,
"index": currentIndex.value,
"value": currentIndex.value !== null ? points.value[currentIndex.value].value : 0,
"offset": tooltipConfig.value.offset,
"contentClass": tooltipConfig.value.class,
"titleFormat": tooltipConfig.value.titleFormat,
"onAfterLeave": onTooltipAfterLeave
}, {
default: slots.tooltip
})]);
});
}
});
//# sourceMappingURL=VTrendline.js.map