Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions frontend/components/network-activity-tracker/tps-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
'use client'

import Image from 'next/image'
import { useMemo } from 'react'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useSmoothedTpsHistory } from '@/hooks/use-smoothed-tps-history'
import { useTotalTransactions } from '@/hooks/use-total-transactions'
import { useTps } from '@/hooks/use-tps'
import { TPS_HISTORY_DURATION_MS, useTps } from '@/hooks/use-tps'
import { formatRelativeTime, formatTimeHMS } from '@/lib/timestamp'
import { formatIntNumber } from '@/lib/ui'
import { NetworkActivityStats } from './network-activity-stats'
Expand All @@ -21,10 +23,46 @@ const chartConfig = {
},
} satisfies ChartConfig

/**
* Pin tick labels to absolute 30-second boundaries within the visible domain.
* Because each tick has a fixed timestamp, its pixel position is purely a
* function of the smoothly-advancing domain — so ticks slide left continuously
* with the chart instead of being re-picked by recharts when the data set
* grows. Once a minute the leftmost tick slides off-screen and a new one
* appears at the right edge.
*/
const TICK_INTERVAL_MS = 30_000

function buildTicks(minTimestamp: number, maxTimestamp: number): number[] {
const ticks: number[] = []
let t = Math.floor(maxTimestamp / TICK_INTERVAL_MS) * TICK_INTERVAL_MS
while (t >= minTimestamp) {
ticks.push(t)
t -= TICK_INTERVAL_MS
}
return ticks.reverse()
}

export function TpsChart() {
const { currentTps, peakTps, history } = useTps()
const smoothedHistory = useSmoothedTpsHistory(history)
const totalTransactions = useTotalTransactions()
const hasData = history.length > 0
const hasData = smoothedHistory.length > 0
Comment thread
Camillebzd marked this conversation as resolved.

// Anchor both edges of the domain to the smoothly-advancing tip so the
// axis scrolls continuously. Anchoring the left edge to `latest - window`
// (instead of the oldest history point) prevents a jump at the left every
// time `useTps` drops an expired point.
const xDomain = useMemo<[number, number] | undefined>(() => {
if (smoothedHistory.length === 0) return undefined
const latest = smoothedHistory[smoothedHistory.length - 1].timestamp
return [latest - TPS_HISTORY_DURATION_MS, latest]
}, [smoothedHistory])

const xTicks = useMemo(() => {
if (xDomain === undefined) return undefined
return buildTicks(xDomain[0], xDomain[1])
}, [xDomain])

return (
<div className="flex flex-col h-full">
Expand Down Expand Up @@ -59,7 +97,7 @@ export function TpsChart() {
className="h-full min-w-2xl w-full p-0"
>
<AreaChart
data={history}
data={smoothedHistory}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<defs>
Expand All @@ -77,12 +115,16 @@ export function TpsChart() {
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
<XAxis
dataKey="timestamp"
type="number"
domain={xDomain ?? ['dataMin', 'dataMax']}
ticks={xTicks}
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={80}
tick={{ fill: 'var(--chart-axis)', fontSize: 12 }}
tickFormatter={formatRelativeTime}
allowDataOverflow
/>
<YAxis
domain={[0, 'auto']}
Expand Down
107 changes: 107 additions & 0 deletions frontend/hooks/use-smoothed-tps-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import type { TpsDataPoint } from '@/hooks/use-tps'

/**
* Matches the backend's TPS cadence (~one update per block at ~400ms).
* Picking the inter-arrival time lets the tween finish right as the next
* datapoint arrives, producing continuous motion instead of a snap.
*/
const ANIMATION_DURATION_MS = 400

function easeOutCubic(t: number): number {
return 1 - (1 - t) ** 3
}

interface AnimationState {
startTime: number
startValue: number
startTimestamp: number
targetValue: number
targetTimestamp: number
}

function pointAt(anim: AnimationState, now: number): TpsDataPoint {
const t = Math.min(1, (now - anim.startTime) / ANIMATION_DURATION_MS)
const eased = easeOutCubic(t)
return {
tps: anim.startValue + (anim.targetValue - anim.startValue) * eased,
timestamp:
anim.startTimestamp +
(anim.targetTimestamp - anim.startTimestamp) * eased,
}
}

/**
* Tweens the trailing tip of a TPS history so the chart extends smoothly to
* each new datapoint rather than snapping. When a new point arrives mid-tween,
* the tween restarts from the current interpolated position so the line never
* jumps.
*/
export function useSmoothedTpsHistory(history: TpsDataPoint[]): TpsDataPoint[] {
const [rendered, setRendered] = useState<TpsDataPoint[]>(history)
const animRef = useRef<AnimationState | null>(null)
const rafRef = useRef<number | null>(null)
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Both refs use explicit union types (| null) which violates the team convention of letting React infer the nullability. The rule calls for useRef<AnimationState>(null) and useRef<number>(null) — React's overload resolution already makes .current nullable when null is passed as the initial value.

Suggested change
const animRef = useRef<AnimationState | null>(null)
const rafRef = useRef<number | null>(null)
const animRef = useRef<AnimationState>(null)
const rafRef = useRef<number>(null)

Rule Used: When using useRef with a default value in TypeScri... (source)

Learned From
monad-developers/monapp#178

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/hooks/use-smoothed-tps-history.ts
Line: 44-45

Comment:
Both refs use explicit union types (`| null`) which violates the team convention of letting React infer the nullability. The rule calls for `useRef<AnimationState>(null)` and `useRef<number>(null)` — React's overload resolution already makes `.current` nullable when `null` is passed as the initial value.

```suggestion
  const animRef = useRef<AnimationState>(null)
  const rafRef = useRef<number>(null)
```

**Rule Used:** When using useRef with a default value in TypeScri... ([source](https://app.greptile.com/monad-foudnation/-/custom-context?memory=e5b39c00-8ef7-4612-a56a-d956ea833db7))

**Learned From**
[monad-developers/monapp#178](https://github.com/monad-developers/monapp/pull/178)

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


useEffect(() => {
if (history.length === 0) {
animRef.current = null
setRendered([])
return
}

const latest = history[history.length - 1]

if (
animRef.current !== null &&
animRef.current.targetTimestamp === latest.timestamp
) {
return
}

const prev = history[history.length - 2]
const now = performance.now()
const tip: TpsDataPoint =
animRef.current !== null
? pointAt(animRef.current, now)
: {
tps: prev?.tps ?? latest.tps,
timestamp: prev?.timestamp ?? latest.timestamp,
}

animRef.current = {
startTime: now,
startValue: tip.tps,
startTimestamp: tip.timestamp,
targetValue: latest.tps,
targetTimestamp: latest.timestamp,
}

const head = history.slice(0, -1)

const tick = () => {
const anim = animRef.current
if (anim === null) return
const current = pointAt(anim, performance.now())
setRendered([...head, current])
if (performance.now() - anim.startTime < ANIMATION_DURATION_MS) {
rafRef.current = requestAnimationFrame(tick)
} else {
rafRef.current = null
}
}
Comment thread
Camillebzd marked this conversation as resolved.

if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(tick)
}, [history])

useEffect(
() => () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
},
[],
)

return rendered
}
2 changes: 1 addition & 1 deletion frontend/hooks/use-tps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { useEventsContext } from '@/contexts/events-context'

/** Duration of TPS history to keep */
const TPS_HISTORY_DURATION_MS = 5 * 60 * 1000
export const TPS_HISTORY_DURATION_MS = 5 * 60 * 1000

export interface TpsDataPoint {
timestamp: number
Expand Down
Loading