feat(uptime-chart): enhance tooltip functionality and refactor delta display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m53s

- Added useEffect to set tooltip portal for improved rendering.
- Introduced UptimeDelta component to streamline delta display logic.
- Updated ChartTooltip to utilize ChartTooltipContent for better formatting of tooltip data.
- Refactored chart margin and overflow properties for improved layout consistency.
This commit is contained in:
Denozordec
2026-08-20 00:49:27 +07:00
parent a458465153
commit 78811bc9b1
@@ -1,4 +1,4 @@
import { useId, useMemo, useState } from 'react' import { useEffect, useId, useMemo, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react' import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, AreaChart, XAxis } from 'recharts' import { Area, AreaChart, XAxis } from 'recharts'
@@ -12,6 +12,7 @@ import { Button } from '@cfdm/ui/components/button'
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent,
type ChartConfig, type ChartConfig,
} from '@cfdm/ui/components/chart' } from '@cfdm/ui/components/chart'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
@@ -87,21 +88,27 @@ function deltaPercent(points: ChartPoint[]): number | null {
return next - prev return next - prev
} }
interface UptimeTooltipProps { function UptimeDelta({ delta }: { delta: number }) {
active?: boolean if (Math.abs(delta) < 0.05) {
payload?: Array<{ payload: ChartPoint }> return <span className="text-muted-foreground">без изменений за период</span>
} }
if (delta > 0) {
return (
<>
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
<span className="text-muted-foreground">с начала периода</span>
</>
)
}
function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
if (!active || !payload?.[0]) return null
const point = payload[0].payload
return ( return (
<div className="bg-popover text-popover-foreground rounded-md px-3 py-2 text-sm shadow-md"> <>
<p className="font-medium tabular-nums"> <TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
{point.latency} мс · {point.ok ? 'OK' : 'Down'} <span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
</p> <span className="text-muted-foreground">с начала периода</span>
<p className="text-muted-foreground text-xs">{point.period}</p> </>
</div>
) )
} }
@@ -124,9 +131,14 @@ export function UptimeChart({
}: UptimeChartProps) { }: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '') const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D') const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
const period = periodProp ?? internalPeriod const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5 const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
useEffect(() => {
setTooltipPortal(document.body)
}, [])
function handlePeriodChange(next: UptimePeriodKey) { function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next) onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next) if (periodProp == null) setInternalPeriod(next)
@@ -202,35 +214,21 @@ export function UptimeChart({
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm">
{points.length} проб {points.length} проб
</Badge> </Badge>
) : delta >= 0 ? (
<>
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
<span className="text-success font-medium">
+{delta.toFixed(1)} п.п.
</span>
<span className="text-muted-foreground">к первой половине окна</span>
</>
) : ( ) : (
<> <UptimeDelta delta={delta} />
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
<span className="text-destructive font-medium">
{delta.toFixed(1)} п.п.
</span>
<span className="text-muted-foreground">к первой половине окна</span>
</>
)} )}
</div> </div>
</div> </div>
<div className="h-40 w-full"> <div className="h-40 w-full overflow-visible">
<ChartContainer <ChartContainer
config={chartConfig} config={chartConfig}
className="h-full w-full overflow-hidden rounded-b-xl" className="h-full w-full overflow-visible rounded-b-xl"
initialDimension={{ width: 320, height: 160 }} initialDimension={{ width: 320, height: 160 }}
> >
<AreaChart <AreaChart
data={points} data={points}
margin={{ top: 10, left: 0, right: 0, bottom: 0 }} margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
> >
<defs> <defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
@@ -247,33 +245,56 @@ export function UptimeChart({
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="period" hide /> <XAxis dataKey="period" hide />
<ChartTooltip content={<UptimeTooltip />} /> <ChartTooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
allowEscapeViewBox={{ x: true, y: true }}
portal={tooltipPortal ?? undefined}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const point = item.payload as ChartPoint | undefined
const ping = Number(value)
return (
<div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">
{point?.ok === false ? 'Down' : 'Пинг'}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{Number.isFinite(ping) ? `${ping} мс` : '—'}
</span>
</div>
)
}}
/>
}
/>
<Area <Area
dataKey="latency" dataKey="latency"
name="latency"
type="natural" type="natural"
fill={`url(#${gradientId})`} fill={`url(#${gradientId})`}
stroke="var(--color-latency)" stroke="var(--color-latency)"
strokeWidth={2} strokeWidth={2}
isAnimationActive={false}
dot={(dotProps) => { dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps as { const { cx, cy, payload, index } = dotProps
cx?: number
cy?: number
index?: number
payload?: ChartPoint
}
if (cx == null || cy == null) return <g key={index} /> if (cx == null || cy == null) return <g key={index} />
const fill = payload?.ok const point = payload as ChartPoint | undefined
? 'var(--color-latency)'
: 'var(--destructive)'
return ( return (
<circle <circle
key={index} key={index}
cx={cx} cx={cx}
cy={cy} cy={cy}
r={4} r={4}
fill={fill} fill={
point?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
}
stroke="var(--background)" stroke="var(--background)"
strokeWidth={2} strokeWidth={2}
pointerEvents="none"
/> />
) )
}} }}