add tooltip

This commit is contained in:
Doruk 2025-06-15 18:09:23 +02:00
parent 82cb26b465
commit b1bf26289e
2 changed files with 415 additions and 10 deletions

View file

@ -7,11 +7,17 @@
class="beat-hover-area"
:class="{ 'empty': (beat === 0) }"
:style="beatHoverAreaStyle"
:title="getBeatTitle(beat)"
:aria-label="getBeatAriaLabel(beat)"
role="status"
tabindex="0"
@mouseenter="showTooltip(beat, $event)"
@mouseleave="hideTooltip"
@focus="showTooltip(beat, $event)"
@blur="hideTooltip"
>
<div
class="beat"
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
:class="{ 'empty': (beat === 0 || beat === null || beat.status === null), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
:style="beatStyle"
/>
</div>
@ -24,13 +30,27 @@
<div v-if="$root.styleElapsedTime === 'with-line'" class="connecting-line"></div>
<div>{{ timeSinceLastBeat }}</div>
</div>
<!-- Custom Tooltip -->
<Tooltip
:visible="tooltipVisible"
:content="tooltipContent"
:x="tooltipX"
:y="tooltipY"
:position="tooltipPosition"
/>
</div>
</template>
<script>
import dayjs from "dayjs";
import Tooltip from "./Tooltip.vue";
export default {
components: {
Tooltip,
},
props: {
/** Size of the heartbeat bar */
size: {
@ -46,6 +66,11 @@ export default {
heartbeatList: {
type: Array,
default: null,
},
/** Heartbeat bar days */
heartbeatBarDays: {
type: Number,
default: 0
}
},
data() {
@ -56,10 +81,25 @@ export default {
beatHoverAreaPadding: 4,
move: false,
maxBeat: -1,
// Tooltip data
tooltipVisible: false,
tooltipContent: null,
tooltipX: 0,
tooltipY: 0,
tooltipPosition: 'below',
tooltipTimeoutId: null,
};
},
computed: {
/**
* Normalized heartbeatBarDays as a number
* @returns {number} Number of days for heartbeat bar
*/
normalizedHeartbeatBarDays() {
return Math.max(0, Math.min(365, Math.floor(this.heartbeatBarDays || 0)));
},
/**
* If heartbeatList is null, get it from $root.heartbeatList
* @returns {object} Heartbeat list
@ -80,6 +120,12 @@ export default {
if (!this.beatList) {
return 0;
}
// For configured ranges, no padding needed since we show all beats
if (this.normalizedHeartbeatBarDays > 0) {
return 0;
}
let num = this.beatList.length - this.maxBeat;
if (this.move) {
@ -98,8 +144,20 @@ export default {
return [];
}
// If heartbeat days is configured (not auto), data is already aggregated from server
if (this.normalizedHeartbeatBarDays > 0 && this.beatList.length > 0) {
// Show all beats from server - they are already properly aggregated
return this.beatList;
}
// Original logic for auto mode (heartbeatBarDays = 0)
let placeholders = [];
// Handle case where maxBeat is -1 (no limit)
if (this.maxBeat <= 0) {
return this.beatList;
}
let start = this.beatList.length - this.maxBeat;
if (this.move) {
@ -172,13 +230,17 @@ export default {
* @returns {string} The time elapsed in minutes or hours.
*/
timeSinceFirstBeat() {
// For configured days mode, show the configured range
if (this.normalizedHeartbeatBarDays > 0) {
return this.normalizedHeartbeatBarDays < 2 ?
(this.normalizedHeartbeatBarDays * 24) + "h" :
this.normalizedHeartbeatBarDays + "d";
}
// For auto mode, calculate from actual data
const firstValidBeat = this.shortBeatList.at(this.numPadding);
const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes");
if (minutes > 60) {
return (minutes / 60).toFixed(0) + "h";
} else {
return minutes + "m";
}
return minutes > 60 ? Math.floor(minutes / 60) + "h" : minutes + "m";
},
/**
@ -205,7 +267,7 @@ export default {
},
watch: {
beatList: {
handler(val, oldVal) {
handler() {
this.move = true;
setTimeout(() => {
@ -217,6 +279,10 @@ export default {
},
unmounted() {
window.removeEventListener("resize", this.resize);
// Clean up tooltip timeout
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
}
},
beforeMount() {
if (this.heartbeatList === null) {
@ -256,7 +322,23 @@ export default {
*/
resize() {
if (this.$refs.wrap) {
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
const newMaxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
// If maxBeat changed and we're in configured days mode, notify parent to reload data
if (newMaxBeat !== this.maxBeat && this.normalizedHeartbeatBarDays > 0) {
this.maxBeat = newMaxBeat;
// Find the closest parent with reloadHeartbeatData method (StatusPage)
let parent = this.$parent;
while (parent && !parent.reloadHeartbeatData) {
parent = parent.$parent;
}
if (parent && parent.reloadHeartbeatData) {
parent.reloadHeartbeatData(newMaxBeat);
}
} else {
this.maxBeat = newMaxBeat;
}
}
},
@ -267,7 +349,103 @@ export default {
* @returns {string} Beat title
*/
getBeatTitle(beat) {
return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
if (beat === 0 || !beat) {
return "";
}
// Show timestamp for all beats (both individual and aggregated)
return `${this.$root.datetime(beat.time)}${beat.msg ? ` - ${beat.msg}` : ""}`;
},
/**
* Get the aria-label for accessibility
* @param {object} beat Beat to get aria-label from
* @returns {string} Aria label
*/
getBeatAriaLabel(beat) {
if (beat === 0 || !beat) {
return "No data";
}
const statusText = {
0: "Down",
1: "Up",
2: "Pending",
3: "Maintenance"
}[beat.status] || "Unknown";
return `${statusText} at ${this.$root.datetime(beat.time)}`;
},
/**
* Show custom tooltip
* @param {object} beat Beat data
* @param {Event} event Mouse event
*/
showTooltip(beat, event) {
if (beat === 0 || !beat) {
this.hideTooltip();
return;
}
// Clear any existing timeout
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
}
// Small delay for better UX
this.tooltipTimeoutId = setTimeout(() => {
this.tooltipContent = beat;
// Calculate position relative to viewport
const rect = event.target.getBoundingClientRect();
// Position relative to viewport
const x = rect.left + (rect.width / 2);
const y = rect.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;
if (spaceAbove > tooltipHeight && spaceBelow < tooltipHeight) {
// Show above - arrow points down
this.tooltipPosition = 'above';
this.tooltipY = y - 10;
} else {
// Show below - arrow points up
this.tooltipPosition = 'below';
this.tooltipY = y + rect.height + 10;
}
// Ensure tooltip doesn't go off the left or right edge
const tooltipWidth = 120; // Approximate tooltip width
let adjustedX = x;
if (x - tooltipWidth/2 < 10) {
adjustedX = tooltipWidth/2 + 10;
} else if (x + tooltipWidth/2 > window.innerWidth - 10) {
adjustedX = window.innerWidth - tooltipWidth/2 - 10;
}
this.tooltipX = adjustedX;
this.tooltipVisible = true;
}, 150);
},
/**
* Hide custom tooltip
*/
hideTooltip() {
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
this.tooltipTimeoutId = null;
}
this.tooltipVisible = false;
this.tooltipContent = null;
},
},
@ -345,4 +523,5 @@ export default {
background-color: #333;
}
}
</style>

226
src/components/Tooltip.vue Normal file
View file

@ -0,0 +1,226 @@
<template>
<teleport to="body">
<div
v-if="visible && content"
ref="tooltip"
class="tooltip-wrapper"
:style="tooltipStyle"
:class="{ 'tooltip-above': position === 'above' }"
>
<div class="tooltip-content">
<slot :content="content">
<!-- Default content if no slot provided -->
<div class="tooltip-status" :class="statusClass">
{{ statusText }}
</div>
<div class="tooltip-time">{{ timeText }}</div>
<div v-if="message" class="tooltip-message">{{ message }}</div>
</slot>
</div>
<div class="tooltip-arrow" :class="{ 'arrow-above': position === 'above' }"></div>
</div>
</teleport>
</template>
<script>
export default {
name: "Tooltip",
props: {
/** Whether tooltip is visible */
visible: {
type: Boolean,
default: false
},
/** Content object to display */
content: {
type: Object,
default: null
},
/** X position (viewport coordinates) */
x: {
type: Number,
default: 0
},
/** Y position (viewport coordinates) */
y: {
type: Number,
default: 0
},
/** Position relative to target element */
position: {
type: String,
default: 'below',
validator: (value) => ['above', 'below'].includes(value)
}
},
computed: {
tooltipStyle() {
return {
left: this.x + 'px',
top: this.y + 'px',
};
},
statusText() {
if (!this.content || this.content === 0) return this.$t('Unknown');
const statusMap = {
0: this.$t('Down'),
1: this.$t('Up'),
2: this.$t('Pending'),
3: this.$t('Maintenance')
};
return statusMap[this.content.status] || this.$t('Unknown');
},
statusClass() {
if (!this.content || this.content === 0) return 'status-empty';
const classMap = {
0: 'status-down',
1: 'status-up',
2: 'status-pending',
3: 'status-maintenance'
};
return classMap[this.content.status] || 'status-unknown';
},
timeText() {
if (!this.content || this.content === 0) return '';
return this.$root.datetime(this.content.time);
},
message() {
if (!this.content || this.content === 0) return '';
return this.content.msg || '';
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.tooltip-wrapper {
position: fixed;
z-index: 9999;
pointer-events: none;
transform: translateX(-50%);
.tooltip-content {
background: rgba(17, 24, 39, 0.95);
backdrop-filter: blur(8px);
border: 1px solid rgba(75, 85, 99, 0.3);
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25);
min-width: 120px;
text-align: center;
.tooltip-status {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
&.status-up {
color: $primary;
}
&.status-down {
color: $danger;
}
&.status-pending {
color: $warning;
}
&.status-maintenance {
color: $maintenance;
}
&.status-empty {
color: $secondary-text;
}
}
.tooltip-time {
color: #d1d5db;
font-size: 11px;
margin-bottom: 2px;
}
.tooltip-message {
color: #f3f4f6;
font-size: 10px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid rgba(75, 85, 99, 0.3);
}
}
.tooltip-arrow {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
// Default: tooltip below element, arrow points up
border-bottom: 6px solid rgba(17, 24, 39, 0.95);
top: -6px;
&.arrow-above {
// Tooltip above element, arrow points down
top: auto;
bottom: -6px;
border-bottom: none;
border-top: 6px solid rgba(17, 24, 39, 0.95);
}
}
// Smooth entrance animation
animation: tooltip-fade-in 0.2s $easing-out;
&.tooltip-above {
transform: translateX(-50%) translateY(-8px);
}
}
// Dark theme adjustments
.dark .tooltip-wrapper {
.tooltip-content {
background: rgba(31, 41, 55, 0.95);
border-color: rgba(107, 114, 128, 0.3);
}
.tooltip-arrow {
border-bottom-color: rgba(31, 41, 55, 0.95);
&.arrow-above {
border-top-color: rgba(31, 41, 55, 0.95);
}
}
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
// Accessibility improvements
@media (prefers-reduced-motion: reduce) {
.tooltip-wrapper {
animation: none !important;
}
}
</style>