From 6006efb00a074a788a2055bb1f266a328c0e0f55 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 28 Mar 2026 01:08:00 +0800 Subject: [PATCH] feat(ServiceHealthCard): implement tooltip positioning and rendering logic --- src/components/usage/ServiceHealthCard.tsx | 210 +++++++++++++++------ src/pages/UsagePage.module.scss | 17 +- src/styles/themes.scss | 9 + 3 files changed, 169 insertions(+), 67 deletions(-) diff --git a/src/components/usage/ServiceHealthCard.tsx b/src/components/usage/ServiceHealthCard.tsx index 876c5bb..c4e82c9 100644 --- a/src/components/usage/ServiceHealthCard.tsx +++ b/src/components/usage/ServiceHealthCard.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { collectUsageDetails, @@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData'; import styles from '@/pages/UsagePage.module.scss'; const COLOR_STOPS = [ - { r: 239, g: 68, b: 68 }, // #ef4444 - { r: 250, g: 204, b: 21 }, // #facc15 - { r: 34, g: 197, b: 94 }, // #22c55e + { r: 239, g: 68, b: 68 }, // #ef4444 + { r: 250, g: 204, b: 21 }, // #facc15 + { r: 34, g: 197, b: 94 }, // #22c55e ] as const; +const TOOLTIP_OFFSET = 8; +const TOOLTIP_SAFE_WIDTH = 180; +const TOOLTIP_SAFE_HEIGHT = 72; + +type TooltipHorizontalPosition = 'center' | 'left' | 'right'; +type TooltipVerticalPosition = 'above' | 'below'; + +interface ActiveTooltipState { + idx: number; + anchorEl: HTMLDivElement; + horizontal: TooltipHorizontalPosition; + vertical: TooltipVerticalPosition; + left: number; + top: number; + transform: string; +} + function rateToColor(rate: number): string { const t = Math.max(0, Math.min(1, rate)); const segment = t < 0.5 ? 0 : 1; @@ -43,7 +61,7 @@ export interface ServiceHealthCardProps { export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) { const { t } = useTranslation(); - const [activeTooltip, setActiveTooltip] = useState(null); + const [activeTooltip, setActiveTooltip] = useState(null); const gridRef = useRef(null); const healthData: ServiceHealthData = useMemo(() => { @@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) { return () => document.removeEventListener('pointerdown', handler); }, [activeTooltip]); - const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => { - if (e.pointerType === 'mouse') { - setActiveTooltip(idx); - } - }, []); + const buildTooltipState = useCallback( + (idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => { + const rect = anchorEl.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + + let horizontal: TooltipHorizontalPosition = 'center'; + let left = centerX; + + if (centerX <= TOOLTIP_SAFE_WIDTH / 2) { + horizontal = 'left'; + left = rect.left; + } else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) { + horizontal = 'right'; + left = rect.right; + } + + const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above'; + const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET; + const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0'; + const translateY = vertical === 'below' ? '0' : '-100%'; + + return { + idx, + anchorEl, + horizontal, + vertical, + left: Math.round(left), + top: Math.round(top), + transform: `translate(${translateX}, ${translateY})`, + }; + }, + [] + ); + + useEffect(() => { + if (!activeTooltip) return; + + const updateTooltipPosition = () => { + if (!document.body.contains(activeTooltip.anchorEl)) { + setActiveTooltip(null); + return; + } + setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl)); + }; + + window.addEventListener('resize', updateTooltipPosition); + window.addEventListener('scroll', updateTooltipPosition, true); + return () => { + window.removeEventListener('resize', updateTooltipPosition); + window.removeEventListener('scroll', updateTooltipPosition, true); + }; + }, [activeTooltip, buildTooltipState]); + + const openTooltip = useCallback( + (idx: number, anchorEl: HTMLDivElement) => { + setActiveTooltip(buildTooltipState(idx, anchorEl)); + }, + [buildTooltipState] + ); + + const handlePointerEnter = useCallback( + (e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'mouse') { + openTooltip(idx, e.currentTarget); + } + }, + [openTooltip] + ); const handlePointerLeave = useCallback((e: React.PointerEvent) => { if (e.pointerType === 'mouse') { @@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) { } }, []); - const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => { - if (e.pointerType === 'touch') { - e.preventDefault(); - setActiveTooltip((prev) => (prev === idx ? null : idx)); - } - }, []); + const handlePointerDown = useCallback( + (e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'touch') { + e.preventDefault(); + setActiveTooltip((prev) => + prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget) + ); + } + }, + [buildTooltipState] + ); - const getTooltipPositionClass = (idx: number): string => { - const col = Math.floor(idx / healthData.rows); - if (col <= 2) return styles.healthTooltipLeft; - if (col >= healthData.cols - 3) return styles.healthTooltipRight; - return ''; - }; - - const getTooltipVerticalClass = (idx: number): string => { - const row = idx % healthData.rows; - if (row <= 1) return styles.healthTooltipBelow; - return ''; - }; - - const renderTooltip = (detail: StatusBlockDetail, idx: number) => { + const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => { const total = detail.success + detail.failure; - const posClass = getTooltipPositionClass(idx); - const vertClass = getTooltipVerticalClass(idx); + const posClass = + tooltipState.horizontal === 'left' + ? styles.healthTooltipLeft + : tooltipState.horizontal === 'right' + ? styles.healthTooltipRight + : ''; + const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : ''; const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`; - - return ( -
+ const tooltip = ( +
{timeRange} {total > 0 ? ( - {t('status_bar.success_short')} {detail.success} - {t('status_bar.failure_short')} {detail.failure} + + {t('status_bar.success_short')} {detail.success} + + + {t('status_bar.failure_short')} {detail.failure} + ({(detail.rate * 100).toFixed(1)}%) ) : ( @@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) { )}
); + + return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body); }; const rateClass = !hasData @@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
-
- {healthData.blockDetails.map((detail, idx) => { - const isIdle = detail.rate === -1; - const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) }; - const isActive = activeTooltip === idx; +
+ {healthData.blockDetails.map((detail, idx) => { + const isIdle = detail.rate === -1; + const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) }; + const isActive = activeTooltip?.idx === idx; - return ( -
handlePointerEnter(e, idx)} - onPointerLeave={handlePointerLeave} - onPointerDown={(e) => handlePointerDown(e, idx)} - > + return (
- {isActive && renderTooltip(detail, idx)} -
- ); - })} -
+ key={idx} + className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`} + onPointerEnter={(e) => handlePointerEnter(e, idx)} + onPointerLeave={handlePointerLeave} + onPointerDown={(e) => handlePointerDown(e, idx)} + > +
+ {isActive && activeTooltip && renderTooltip(detail, activeTooltip)} +
+ ); + })} +
{t('service_health.oldest')} diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 9d0ef49..73c15fa 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -1112,17 +1112,20 @@ bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); - background: var(--bg-primary, #fff); - border: 1px solid var(--border-secondary, #e5e7eb); + background-color: var(--floating-surface, #ffffff); + background-image: none; + border: 1px solid var(--floating-border, #e5e7eb); border-radius: 6px; padding: 6px 10px; font-size: 11px; line-height: 1.5; white-space: nowrap; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)); z-index: $z-dropdown; pointer-events: none; color: var(--text-primary); + opacity: 1; + background-clip: padding-box; &::after { content: ''; @@ -1131,7 +1134,7 @@ left: 50%; transform: translateX(-50%); border: 5px solid transparent; - border-top-color: var(--bg-primary, #fff); + border-top-color: var(--floating-surface, #ffffff); } &::before { @@ -1141,7 +1144,7 @@ left: 50%; transform: translateX(-50%); border: 6px solid transparent; - border-top-color: var(--border-secondary, #e5e7eb); + border-top-color: var(--floating-border, #e5e7eb); } } @@ -1154,14 +1157,14 @@ top: auto; bottom: 100%; border-top-color: transparent; - border-bottom-color: var(--bg-primary, #fff); + border-bottom-color: var(--floating-surface, #ffffff); } &::before { top: auto; bottom: 100%; border-top-color: transparent; - border-bottom-color: var(--border-secondary, #e5e7eb); + border-bottom-color: var(--floating-border, #e5e7eb); } } diff --git a/src/styles/themes.scss b/src/styles/themes.scss index ab37a28..774c84e 100644 --- a/src/styles/themes.scss +++ b/src/styles/themes.scss @@ -11,6 +11,9 @@ --bg-hover: var(--bg-tertiary); --bg-quinary: #f6f4ee; --bg-error-light: rgba(198, 87, 70, 0.1); + --floating-surface: #fffdf9; + --floating-border: #d8d3ca; + --floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14); --text-primary: #2d2a26; --text-secondary: #6d6760; @@ -64,6 +67,9 @@ --bg-hover: var(--bg-tertiary); --bg-quinary: #ffffff; --bg-error-light: rgba(198, 87, 70, 0.08); + --floating-surface: #ffffff; + --floating-border: #d9d9d9; + --floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12); --text-primary: #2d2a26; --text-secondary: #6d6760; @@ -118,6 +124,9 @@ --bg-hover: #2e2a26; --bg-quinary: #191714; --bg-error-light: rgba(198, 87, 70, 0.18); + --floating-surface: #2a2723; + --floating-border: #4a443d; + --floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4); --text-primary: #f6f4f1; --text-secondary: #c9c3bb;