vuetify
Version:
Vue Material Component Framework
334 lines (327 loc) • 13.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 { makeLineProps } 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 makeVBarlineProps = propsFactory({
autoLineWidth: Boolean,
...makeLineProps()
}, 'VBarline');
export const VBarline = genericComponent()({
name: 'VBarline',
inheritAttrs: false,
props: makeVBarlineProps(),
emits: {
'update:currentIndex': _index => true
},
setup(props, {
slots,
attrs,
emit
}) {
const uid = useId();
const id = computed(() => props.id || `barline-${uid}`);
const autoDrawDuration = computed(() => Number(props.autoDrawDuration) || 500);
const hasDrawn = ref(false);
const clipRects = shallowRef([]);
const animationDuration = computed(() => typeof props.animation === 'object' ? props.animation.duration ?? 300 : 300);
const animationEasing = computed(() => typeof props.animation === 'object' ? props.animation.easing ?? 'ease' : 'ease');
const hasLabels = computed(() => {
return Boolean(props.showLabels || props.labels.length > 0 || !!slots?.label);
});
const lineWidth = computed(() => parseFloat(props.lineWidth) || 4);
const items = computed(() => props.modelValue.map(item => getPropertyFromItem(item, props.itemValue, item)));
const totalWidth = computed(() => Math.max(items.value.length * lineWidth.value, Number(props.width)));
const boundary = computed(() => {
return {
minX: 0,
maxX: totalWidth.value,
minY: 0,
maxY: parseInt(props.height, 10)
};
});
function genBars(values, boundary) {
const {
minX,
maxX,
minY,
maxY
} = boundary;
const totalValues = values.length;
let maxValue = props.max != null ? Number(props.max) : Math.max(...values);
let minValue = props.min != null ? Number(props.min) : Math.min(...values);
if (minValue > 0 && props.min == null) minValue = 0;
if (maxValue < 0 && props.max == null) maxValue = 0;
const gridX = maxX / (totalValues === 1 ? 2 : totalValues);
const gridY = (maxY - minY) / (maxValue - minValue || 1);
const horizonY = maxY - Math.abs(minValue * gridY);
return values.map((value, index) => {
const height = Math.abs(gridY * value);
return {
x: minX + index * gridX,
y: horizonY - height + Number(value < 0) * height,
height,
value
};
});
}
const bars = computed(() => genBars(items.value, boundary.value));
const prevBarCount = ref(0);
function applyTransition(el, duration, easing) {
el.style.transition = `y ${duration}ms ${easing}, height ${duration}ms ${easing}`;
}
function collapseNewBars(bars, fromIndex, duration, easing) {
for (let i = fromIndex; i < bars.length; i++) {
const el = clipRects.value[i];
if (!el) continue;
// Snap to collapsed at bar's baseline
el.style.transition = 'none';
el.setAttribute('y', String(bars[i].y + bars[i].height));
el.setAttribute('height', '0');
el.getBoundingClientRect();
// Animate to final state
applyTransition(el, duration, easing);
el.setAttribute('y', String(bars[i].y));
el.setAttribute('height', String(bars[i].height));
}
}
function applyBarsTransition(oldCount) {
if (!props.animation) return;
clipRects.value.forEach(el => {
if (el) applyTransition(el, animationDuration.value, animationEasing.value);
});
if (bars.value.length > oldCount && oldCount > 0) {
collapseNewBars(bars.value, oldCount, animationDuration.value, animationEasing.value);
}
}
watch(() => props.modelValue, async () => {
await nextTick();
if (PREFERS_REDUCED_MOTION() || !clipRects.value.length) return;
const oldCount = prevBarCount.value;
prevBarCount.value = bars.value.length;
// Animation-only mode (no auto-draw), or draw-once after first draw: just ensure transition is set
if (!props.autoDraw || props.autoDraw === 'once' && hasDrawn.value) {
applyBarsTransition(oldCount);
return;
}
hasDrawn.value = true;
const shouldDrawOnce = props.autoDraw === 'once';
clipRects.value.forEach((el, i) => {
const bar = bars.value[i];
if (!el || !bar) return;
// Snap to collapsed state
el.style.transition = 'none';
el.setAttribute('y', String(bar.y + bar.height));
el.setAttribute('height', '0');
el.getBoundingClientRect();
// Animate to final state
applyTransition(el, autoDrawDuration.value, props.autoDrawEasing);
el.setAttribute('y', String(bar.y));
el.setAttribute('height', String(bar.height));
// After initial draw, switch to animation timing for subsequent changes
if (shouldDrawOnce && props.animation) {
el.addEventListener('transitionend', () => {
applyTransition(el, animationDuration.value, animationEasing.value);
}, {
once: true
});
}
});
}, {
immediate: true
});
const parsedLabels = computed(() => {
return bars.value.map((bar, i) => ({
x: bar.x,
value: String(props.labels[i] ?? bar.value)
}));
});
const offsetX = computed(() => bars.value.length === 1 ? (boundary.value.maxX - lineWidth.value) / 2 : (Math.abs(bars.value[0].x - bars.value[1].x) - lineWidth.value) / 2);
const smooth = computed(() => typeof props.smooth === 'boolean' ? props.smooth ? 2 : 0 : Number(props.smooth));
const columnWidth = computed(() => {
const len = bars.value.length;
return totalWidth.value / (len === 1 ? 2 : len);
});
// Hover / tooltip state
const svgRef = shallowRef(null);
const currentIndex = shallowRef(null);
const tooltipVisible = shallowRef(false);
const targetX = shallowRef(0);
const targetY = shallowRef(0);
const targetHeight = shallowRef(0);
watch(currentIndex, index => {
if (index === null) return;
const bar = bars.value[index];
if (!bar) return;
targetX.value = bar.x + offsetX.value;
targetY.value = bar.y;
targetHeight.value = bar.height;
});
const transitionOptions = {
duration: 150,
transition: easingPatterns.easeOutQuad
};
const animatedX = useTransition(targetX, transitionOptions);
const animatedY = useTransition(targetY, transitionOptions);
const tooltipTarget = computed(() => {
if (currentIndex.value === null || !svgRef.value) return undefined;
const ctm = svgRef.value.getScreenCTM();
if (!ctm) return undefined;
const svgPoint = svgRef.value.createSVGPoint();
svgPoint.x = animatedX.value + lineWidth.value / 2;
svgPoint.y = animatedY.value;
const {
x,
y
} = svgPoint.matrixTransform(ctm);
return [x, y];
});
const tooltipConfig = computed(() => ({
showCrosshair: false,
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 * totalWidth.value;
let nearest = 0;
let minDist = Infinity;
bars.value.forEach((bar, i) => {
const barCenter = bar.x + offsetX.value + lineWidth.value / 2;
const dist = Math.abs(barCenter - svgX);
if (dist < minDist) {
minDist = dist;
nearest = i;
}
});
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 (!bars.value.length) return;
setIndex(bars.value.length - 1);
}
function onSvgBlur() {
tooltipVisible.value = false;
if (!props.tooltip) {
setIndex(null);
}
}
function onSvgKeydown(e) {
if (!bars.value.length) return;
const len = bars.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 : len);
const next = Math.max(0, Math.min(len - 1, current + direction));
setIndex(next);
}
}
useRender(() => {
const gradientData = !props.gradient.slice().length ? [''] : props.gradient.slice().reverse();
return _createElementVNode(_Fragment, null, [_createElementVNode("svg", _mergeProps({
"ref": svgRef,
"display": "block",
"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))])]), _createElementVNode("clipPath", {
"id": `${id.value}-clip`
}, [bars.value.map((item, i) => _createElementVNode("rect", {
"ref": el => {
if (el) clipRects.value[i] = el;
},
"x": item.x + offsetX.value,
"y": item.y,
"width": lineWidth.value,
"height": item.height,
"rx": smooth.value,
"ry": smooth.value
}, null))]), hasLabels.value && _createElementVNode("g", {
"key": "labels",
"style": {
textAnchor: 'middle',
dominantBaseline: 'mathematical',
fill: 'currentColor'
}
}, [parsedLabels.value.map((item, i) => _createElementVNode("text", {
"x": item.x + offsetX.value + lineWidth.value / 2,
"y": parseInt(props.height, 10) - 2 + (parseInt(props.labelSize, 10) || 7 * 0.75),
"font-size": Number(props.labelSize) || 7
}, [slots.label?.({
index: i,
value: item.value
}) ?? item.value]))]), props.interactive && currentIndex.value !== null && _createElementVNode("rect", {
"key": "highlight",
"x": animatedX.value - offsetX.value,
"y": 0,
"width": columnWidth.value,
"height": props.height,
"fill": "currentColor",
"opacity": 0.1,
"pointer-events": "none"
}, null), _createElementVNode("g", {
"clip-path": `url(#${id.value}-clip)`,
"fill": `url(#${id.value})`
}, [_createElementVNode("rect", {
"x": 0,
"y": 0,
"width": totalWidth.value,
"height": props.height
}, null)])]), !!props.tooltip && _createVNode(VSparklineTooltip, {
"key": "tooltip",
"modelValue": tooltipVisible.value,
"target": tooltipTarget.value,
"index": currentIndex.value,
"value": currentIndex.value !== null ? bars.value[currentIndex.value].value : 0,
"offset": tooltipConfig.value.offset,
"contentClass": tooltipConfig.value.class,
"titleFormat": tooltipConfig.value.titleFormat,
"location": "top center",
"onAfterLeave": onTooltipAfterLeave
}, {
default: slots.tooltip
})]);
});
}
});
//# sourceMappingURL=VBarline.js.map