diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 560dc4bee7..1f74a406b9 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -138,6 +138,48 @@ root.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(localStorage.themeColor)); document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff'); }; + // Shows the tooltip at the same time on every chart of a group while one of them is hovered. + // ApexCharts' own chart.group needs equal widths and equal x ranges, so it does not + // fit side-by-side cards. Instead, replay the hover as a mouse event at the matching x. + // Charts can join a group from separate scripts; charts removed from the page drop out. + window.chartSyncGroups ??= {}; + window.syncChartTooltips = (group, charts) => { + const grid = chart => chart.el.querySelector('.apexcharts-grid')?.getBoundingClientRect(); + const hoverArea = chart => chart.el.querySelector('.apexcharts-svg'); + const hide = chart => hoverArea(chart)?.dispatchEvent(new MouseEvent('mouseout', { clientX: 0, clientY: -1 })); + const peers = source => window.chartSyncGroups[group].filter(chart => chart !== source && chart.el.isConnected); + + window.chartSyncGroups[group] = [ + ...(window.chartSyncGroups[group] ?? []).filter(chart => chart.el.isConnected), + ...charts, + ]; + + charts.forEach(source => { + source.el.addEventListener('mousemove', event => { + // Replayed events are untrusted; skip them so charts do not echo each other. + const rect = grid(source); + const { minX, maxX } = source.w.globals; + if (!event.isTrusted || !rect?.width || !(maxX > minX)) { + return; + } + const timestamp = minX + (event.clientX - rect.left) / rect.width * (maxX - minX); + + peers(source).forEach(chart => { + const target = grid(chart); + const { minX: min, maxX: max } = chart.w.globals; + if (!target?.width || !(max > min) || timestamp < min || timestamp > max) { + hide(chart); + return; + } + hoverArea(chart)?.dispatchEvent(new MouseEvent('mousemove', { + clientX: target.left + (timestamp - min) / (max - min) * target.width, + clientY: target.top + target.height / 2, + })); + }); + }); + source.el.addEventListener('mouseleave', () => peers(source).forEach(hide)); + }); + }; // Alpine state for the theme controls on the Appearance page (x-data="themeControls()"). window.themeControls = () => ({ theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 11a529c75b..9a9b8eb95d 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -433,6 +433,7 @@ $serverListboxOptions = array_merge( chart.render(); charts[key] = chart; } + window.syncChartTooltips('{!! $chartId !!}', Object.values(charts)); const renderLegends = specs => { for (const [key, spec] of Object.entries(specs)) { diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index bcca527914..4b78b12a6e 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -170,6 +170,7 @@ }); cpuChart.render(); + window.syncChartTooltips('{!! $chartId !!}', [cpuChart]); Livewire.on('refreshChartData-{!! $chartId !!}-cpu', chartData => { checkTheme(); @@ -326,6 +327,7 @@ }); memoryChart.render(); + window.syncChartTooltips('{!! $chartId !!}', [memoryChart]); Livewire.on('refreshChartData-{!! $chartId !!}-memory', chartData => { checkTheme(); diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 1867766e38..822a6aa2c2 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -220,11 +220,12 @@ dataPointIndex, w }) => { - const value = series[seriesIndex][dataPointIndex]; const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const values = series.map((values, index) => values[dataPointIndex] === undefined ? '' : + `
${w.globals.seriesNames?.[index] ?? name}: ${formatter(values[dataPointIndex])}
`); return `
-
${w.globals.seriesNames?.[seriesIndex] ?? name}: ${formatter(value)}
+ ${values.join('')}
Your time: ${formatLocalTimestamp(timestamp)}
UTC: ${formatUtcTimestamp(timestamp)}
`; @@ -268,6 +269,7 @@ diskChart.render(); networkChart.render(); loadChart.render(); + window.syncChartTooltips('{!! $chartId !!}-metrics', [cpuChart, memoryChart, networkChart, loadChart, diskChart]); }); Livewire.on('refreshChartData-{!! $chartId !!}-metrics', chartData => {