feat: optimize HeartbeatBar performance by replacing divs with Canvas based rendering (#6407)

This commit is contained in:
Dorian Grasset
2025-11-27 07:21:16 +01:00
committed by GitHub
parent 23c4916c74
commit 3f944ded98

View File

@@ -1,26 +1,21 @@
<template>
<div ref="wrap" class="wrap" :style="wrapStyle">
<div class="hp-bar-big" :style="barStyle">
<div
v-for="(beat, index) in shortBeatList"
:key="index"
class="beat-hover-area"
:class="{ 'empty': (beat === 0) }"
:style="beatHoverAreaStyle"
:aria-label="getBeatAriaLabel(beat)"
role="status"
<canvas
ref="canvas"
class="heartbeat-canvas"
:width="canvasWidth"
:height="canvasHeight"
:aria-label="canvasAriaLabel"
role="img"
tabindex="0"
@mouseenter="showTooltip(beat, $event)"
@mousemove="handleMouseMove"
@mouseleave="hideTooltip"
@focus="showTooltip(beat, $event)"
@blur="hideTooltip"
>
<div
class="beat"
:class="getBeatClasses(beat)"
:style="beatStyle"
/>
</div>
@click="handleClick"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
/>
</div>
<div
v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'"
@@ -88,6 +83,9 @@ export default {
tooltipY: 0,
tooltipPosition: "below",
tooltipTimeoutId: null,
// Canvas
hoveredBeatIndex: -1,
beatBorderRadius: 2.5,
};
},
computed: {
@@ -263,11 +261,45 @@ export default {
} else {
return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ] );
}
}
},
/**
* Canvas width based on number of beats
* @returns {number} Canvas width in pixels
*/
canvasWidth() {
const beatFullWidth = this.beatWidth + (this.beatHoverAreaPadding * 2);
return this.shortBeatList.length * beatFullWidth;
},
/**
* Canvas height based on beat height and hover scale
* @returns {number} Canvas height in pixels
*/
canvasHeight() {
return this.beatHeight * this.hoverScale;
},
/**
* Aria label for canvas accessibility
* @returns {string} Description of heartbeat status
*/
canvasAriaLabel() {
if (!this.shortBeatList || this.shortBeatList.length === 0) {
return "Heartbeat history: No data";
}
const validBeats = this.shortBeatList.filter(b => b !== 0 && b !== null);
const upCount = validBeats.filter(b => Number(b.status) === UP).length;
const downCount = validBeats.filter(b => Number(b.status) === DOWN).length;
return `Heartbeat history: ${validBeats.length} checks, ${upCount} up, ${downCount} down`;
},
},
watch: {
beatList: {
handler() {
// Only handle the slide animation, drawCanvas is triggered by shortBeatList watcher
this.move = true;
setTimeout(() => {
@@ -276,6 +308,17 @@ export default {
},
deep: true,
},
shortBeatList() {
// Triggers on beatList, maxBeat, or move changes
this.$nextTick(() => {
this.drawCanvas();
});
},
hoveredBeatIndex() {
this.drawCanvas();
},
},
unmounted() {
window.removeEventListener("resize", this.resize);
@@ -314,6 +357,11 @@ export default {
window.addEventListener("resize", this.resize);
this.resize();
// Initial canvas draw
this.$nextTick(() => {
this.drawCanvas();
});
},
methods: {
/**
@@ -399,10 +447,11 @@ export default {
/**
* Show custom tooltip
* @param {object} beat Beat data
* @param {Event} event Mouse event
* @param {number} beatIndex Index of the beat
* @param {object} canvasRect Canvas bounding rectangle
* @returns {void}
*/
showTooltip(beat, event) {
showTooltip(beat, beatIndex, canvasRect) {
if (beat === 0 || !beat) {
this.hideTooltip();
return;
@@ -417,18 +466,19 @@ export default {
this.tooltipTimeoutId = setTimeout(() => {
this.tooltipContent = beat;
// Calculate position relative to viewport
const rect = event.target.getBoundingClientRect();
// Calculate the beat's position within the canvas
const beatFullWidth = this.beatWidth + (this.beatHoverAreaPadding * 2);
const beatCenterX = beatIndex * beatFullWidth + beatFullWidth / 2;
// Position relative to viewport
const x = rect.left + (rect.width / 2);
const y = rect.top;
// Convert to viewport coordinates
const x = canvasRect.left + beatCenterX;
const y = canvasRect.top;
// Check if tooltip would go off-screen and adjust position
const tooltipHeight = 80; // Approximate tooltip height
const viewportHeight = window.innerHeight;
const spaceAbove = y;
const spaceBelow = viewportHeight - y - rect.height;
const spaceBelow = viewportHeight - y - canvasRect.height;
if (spaceAbove > tooltipHeight && spaceBelow < tooltipHeight) {
// Show above - arrow points down
@@ -437,7 +487,7 @@ export default {
} else {
// Show below - arrow points up
this.tooltipPosition = "below";
this.tooltipY = y + rect.height + 10;
this.tooltipY = y + canvasRect.height + 10;
}
// Ensure tooltip doesn't go off the left or right edge
@@ -457,9 +507,10 @@ export default {
/**
* Hide custom tooltip
* @param {boolean} resetHoverIndex Whether to reset the hovered beat index
* @returns {void}
*/
hideTooltip() {
hideTooltip(resetHoverIndex = true) {
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
this.tooltipTimeoutId = null;
@@ -467,6 +518,285 @@ export default {
this.tooltipVisible = false;
this.tooltipContent = null;
if (resetHoverIndex) {
this.hoveredBeatIndex = -1;
}
},
/**
* Draw all beats on the canvas
* @returns {void}
*/
drawCanvas() {
const canvas = this.$refs.canvas;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
// Set canvas size accounting for device pixel ratio for crisp rendering
canvas.width = this.canvasWidth * dpr;
canvas.height = this.canvasHeight * dpr;
canvas.style.width = this.canvasWidth + "px";
canvas.style.height = this.canvasHeight + "px";
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
const beatFullWidth = this.beatWidth + (this.beatHoverAreaPadding * 2);
const centerY = this.canvasHeight / 2;
// Cache CSS colors once per redraw
const styles = getComputedStyle(document.documentElement);
const colors = {
empty: styles.getPropertyValue("--bs-body-bg") || "#f0f8ff",
down: styles.getPropertyValue("--bs-danger") || "#dc3545",
pending: styles.getPropertyValue("--bs-warning") || "#ffc107",
maintenance: styles.getPropertyValue("--maintenance") || "#1d4ed8",
up: styles.getPropertyValue("--bs-primary") || "#5cdd8b",
};
// Draw each beat
this.shortBeatList.forEach((beat, index) => {
const x = index * beatFullWidth + this.beatHoverAreaPadding;
const isHovered = index === this.hoveredBeatIndex;
let width = this.beatWidth;
let height = this.beatHeight;
let offsetX = x;
let offsetY = centerY - height / 2;
// Apply hover scale
if (isHovered && beat !== 0) {
width *= this.hoverScale;
height *= this.hoverScale;
offsetX = x - (width - this.beatWidth) / 2;
offsetY = centerY - height / 2;
}
// Get color based on beat status
let color = this.getBeatColor(beat, colors);
// Draw beat rectangle
ctx.fillStyle = color;
this.roundRect(ctx, offsetX, offsetY, width, height, this.beatBorderRadius);
ctx.fill();
// Apply hover opacity
if (isHovered && beat !== 0) {
ctx.globalAlpha = 0.8;
ctx.fillStyle = color;
this.roundRect(ctx, offsetX, offsetY, width, height, this.beatBorderRadius);
ctx.fill();
ctx.globalAlpha = 1;
}
});
},
/**
* Draw a rounded rectangle
* @param {CanvasRenderingContext2D} ctx Canvas context
* @param {number} x X position
* @param {number} y Y position
* @param {number} width Width
* @param {number} height Height
* @param {number} radius Border radius
* @returns {void}
*/
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
},
/**
* Get color for a beat based on its status
* @param {object} beat Beat object
* @param {object} colors Cached CSS colors
* @returns {string} CSS color
*/
getBeatColor(beat, colors) {
if (beat === 0 || beat === null || beat?.status === null) {
return colors.empty;
}
const status = Number(beat.status);
if (status === DOWN) {
return colors.down;
} else if (status === PENDING) {
return colors.pending;
} else if (status === MAINTENANCE) {
return colors.maintenance;
} else {
return colors.up;
}
},
/**
* Update tooltip when hovering a new beat
* @param {object} beat Beat data
* @param {number} beatIndex Index of the beat
* @param {DOMRect} rect Canvas bounding rectangle
* @returns {void}
*/
updateTooltipOnHover(beat, beatIndex, rect) {
const previousIndex = this.hoveredBeatIndex;
this.hoveredBeatIndex = beatIndex;
if (previousIndex !== -1) {
// Hide previous tooltip and show new one after brief delay
this.hideTooltip(false);
setTimeout(() => {
if (this.hoveredBeatIndex === beatIndex) {
this.showTooltip(beat, beatIndex, rect);
}
}, 50);
} else {
this.showTooltip(beat, beatIndex, rect);
}
},
/**
* Handle mouse move on canvas for hover detection
* @param {MouseEvent} event Mouse event
* @returns {void}
*/
handleMouseMove(event) {
const canvas = this.$refs.canvas;
if (!canvas) {
return;
}
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const beatFullWidth = this.beatWidth + (this.beatHoverAreaPadding * 2);
const beatIndex = Math.floor(x / beatFullWidth);
if (beatIndex >= 0 && beatIndex < this.shortBeatList.length) {
const beat = this.shortBeatList[beatIndex];
if (beat !== 0 && beat !== null) {
if (this.hoveredBeatIndex !== beatIndex) {
this.updateTooltipOnHover(beat, beatIndex, rect);
}
} else {
this.hoveredBeatIndex = -1;
this.hideTooltip(true);
}
} else {
this.hoveredBeatIndex = -1;
this.hideTooltip(true);
}
},
/**
* Handle click on canvas (for accessibility)
* @param {MouseEvent} event Mouse event
* @returns {void}
*/
handleClick(event) {
// For future accessibility features if needed
this.handleMouseMove(event);
},
/**
* Handle keyboard navigation on canvas
* @param {KeyboardEvent} event Keyboard event
* @returns {void}
*/
handleKeydown(event) {
const validIndices = this.shortBeatList
.map((beat, index) => (beat !== 0 && beat !== null) ? index : -1)
.filter(index => index !== -1);
if (validIndices.length === 0) {
return;
}
let newIndex = this.hoveredBeatIndex;
if (event.key === "ArrowRight") {
event.preventDefault();
// Find next valid beat
const currentPos = validIndices.indexOf(this.hoveredBeatIndex);
if (currentPos === -1) {
newIndex = validIndices[0];
} else if (currentPos < validIndices.length - 1) {
newIndex = validIndices[currentPos + 1];
}
} else if (event.key === "ArrowLeft") {
event.preventDefault();
// Find previous valid beat
const currentPos = validIndices.indexOf(this.hoveredBeatIndex);
if (currentPos === -1) {
newIndex = validIndices[validIndices.length - 1];
} else if (currentPos > 0) {
newIndex = validIndices[currentPos - 1];
}
} else if (event.key === "Home") {
event.preventDefault();
newIndex = validIndices[0];
} else if (event.key === "End") {
event.preventDefault();
newIndex = validIndices[validIndices.length - 1];
} else if (event.key === "Escape") {
event.preventDefault();
this.hoveredBeatIndex = -1;
this.hideTooltip();
return;
} else {
return;
}
if (newIndex !== this.hoveredBeatIndex && newIndex !== -1) {
const beat = this.shortBeatList[newIndex];
const canvas = this.$refs.canvas;
if (canvas) {
const rect = canvas.getBoundingClientRect();
this.updateTooltipOnHover(beat, newIndex, rect);
}
}
},
/**
* Handle canvas focus
* @returns {void}
*/
handleFocus() {
// Select first valid beat on focus if none selected
if (this.hoveredBeatIndex === -1) {
const firstValidIndex = this.shortBeatList.findIndex(beat => beat !== 0 && beat !== null);
if (firstValidIndex !== -1) {
const beat = this.shortBeatList[firstValidIndex];
const canvas = this.$refs.canvas;
if (canvas) {
const rect = canvas.getBoundingClientRect();
this.updateTooltipOnHover(beat, firstValidIndex, rect);
}
}
}
},
/**
* Handle canvas blur
* @returns {void}
*/
handleBlur() {
this.hoveredBeatIndex = -1;
this.hideTooltip();
},
},
@@ -483,47 +813,9 @@ export default {
}
.hp-bar-big {
.beat-hover-area {
display: inline-block;
&:not(.empty):hover {
transition: all ease-in-out 0.15s;
opacity: 0.8;
transform: scale(var(--hover-scale));
}
.beat {
background-color: $primary;
border-radius: $border-radius;
/*
pointer-events needs to be changed because
tooltip momentarily disappears when crossing between .beat-hover-area and .beat
*/
pointer-events: none;
&.empty {
background-color: aliceblue;
}
&.down {
background-color: $danger;
}
&.pending {
background-color: $warning;
}
&.maintenance {
background-color: $maintenance;
}
}
}
}
.dark {
.hp-bar-big .beat.empty {
background-color: #848484;
.heartbeat-canvas {
display: block;
cursor: pointer;
}
}