diff --git a/src/client/components/chart-cards/common/chart/cartesian-grid.tsx b/src/client/components/chart-cards/common/chart/cartesian-grid.tsx new file mode 100644 index 0000000..fdc5dca --- /dev/null +++ b/src/client/components/chart-cards/common/chart/cartesian-grid.tsx @@ -0,0 +1,30 @@ +const width = 640; +const height = 480; + +export const CartesianGrid = () => ( + + {'Cartesian grid'} + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + +); diff --git a/src/client/components/chart-cards/common/chart.css b/src/client/components/chart-cards/common/chart/index.css similarity index 100% rename from src/client/components/chart-cards/common/chart.css rename to src/client/components/chart-cards/common/chart/index.css diff --git a/src/client/components/chart-cards/common/chart.tsx b/src/client/components/chart-cards/common/chart/index.tsx similarity index 62% rename from src/client/components/chart-cards/common/chart.tsx rename to src/client/components/chart-cards/common/chart/index.tsx index aa11fc1..8a80eca 100644 --- a/src/client/components/chart-cards/common/chart.tsx +++ b/src/client/components/chart-cards/common/chart/index.tsx @@ -1,16 +1,15 @@ import { useAnimationFrame } from '@/hooks/use-animation-frame'; import { getFillColor, getStrokeColor } from '@/utils/colors'; -import { type FormatOptions, formatValue } from '@/utils/format'; +import type { FormatOptions } from '@/utils/format'; import { useEffect, useMemo, useRef, useState } from 'react'; -import './chart.css'; +import { CartesianGrid } from './cartesian-grid'; +import './index.css'; +import { YAxis } from './y-axis'; const stepWindow = Number(import.meta.env.CLIENT_GRAPH_STEPS); const stepPeriod = Number(import.meta.env.CLIENT_REFETCH_INTERVAL); -const width = 640; -const height = 480; const xMargin = 4; const fps = 30; -const framePeriod = 1000 / fps; type Props = { total: number; @@ -21,49 +20,11 @@ type Props = { hueOffset?: number; }; -const xFromTimestamp = (timestamp: number) => +const xFromTimestamp = (timestamp: number, width: number) => ((timestamp - Date.now()) / stepPeriod) * (width / stepWindow) + width + (width / stepWindow) * 2; -const YAxis = ({ max, formatOptions }: Pick & { max: number }) => ( -
- {Array.from({ length: 5 }).map((_, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: supress react console error -
{formatValue((max * (4 - index)) / 4, formatOptions)}
- ))} -
-); - -const CartesianGrid = () => ( - - {'Cartesian grid'} - - {Array.from({ length: 5 }).map((_, index) => ( - - ))} - -); - export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, formatOptions }: Props) => { const canvasRef = useRef(null!); - const ignored = useRef(0); const now = useMemo(() => Date.now(), []); const [history, setHistory] = useState<[number, number[]][]>([[now, []]]); const targetMax = useMemo(() => { @@ -81,36 +42,39 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo }, [history, domain, hardDomain]); const max = useRef(targetMax); + const [width, setWidth] = useState(640); + const [height, setHeight] = useState(480); + + // Record data changes useEffect(() => { if (data) { setHistory(history => { - const firstValidIndex = history.findIndex(([timestamp]) => xFromTimestamp(timestamp) >= -xMargin); + const firstValidIndex = history.findIndex(([timestamp]) => xFromTimestamp(timestamp, width) >= -xMargin); const newHistory = history.slice(firstValidIndex); newHistory.push([Date.now(), data]); return newHistory; }); } - }, [data]); + }, [data, width]); + // Sync canvas size to actual element dimensions useEffect(() => { - const ctx = canvasRef.current.getContext('2d'); - if (!ctx) { - return; - } + const onResize = () => { + const dimensions = canvasRef.current.getBoundingClientRect(); + const newWidth = dimensions.width * window.devicePixelRatio; + setWidth(newWidth); + setHeight(dimensions.height * window.devicePixelRatio); + }; - ctx.lineWidth = 2; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; + onResize(); + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); }, []); - useAnimationFrame(dt => { - ignored.current += dt; - if (ignored.current < framePeriod) { - return; - } - ignored.current = 0; - + // Redraw chart + useAnimationFrame(() => { const ctx = canvasRef.current.getContext('2d'); if (!ctx) { return; @@ -121,6 +85,9 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo } ctx.clearRect(0, 0, width, height); + ctx.lineWidth = 2 * window.devicePixelRatio; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; for (let i = 0; i < total; i++) { ctx.fillStyle = getFillColor(hueOffset + (360 * Number(i)) / total); @@ -129,7 +96,7 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo ctx.moveTo(-xMargin, height); ctx.lineTo(-xMargin, height - (height * (history[0][1][i] ?? 0)) / max.current); for (const [timestamp, values] of history) { - const x = xFromTimestamp(timestamp); + const x = xFromTimestamp(timestamp, width); const y = height - (height * (values[i] ?? 0)) / max.current; ctx.lineTo(x, y); @@ -140,7 +107,7 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo ctx.fill(); ctx.closePath(); } - }); + }, fps); return (
diff --git a/src/client/components/chart-cards/common/chart/y-axis.tsx b/src/client/components/chart-cards/common/chart/y-axis.tsx new file mode 100644 index 0000000..fabd8a3 --- /dev/null +++ b/src/client/components/chart-cards/common/chart/y-axis.tsx @@ -0,0 +1,15 @@ +import { type FormatOptions, formatValue } from '@/utils/format'; + +type Props = { + formatOptions?: FormatOptions; + max: number; +}; + +export const YAxis = ({ max, formatOptions }: Props) => ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: supress react console error +
{formatValue((max * (4 - index)) / 4, formatOptions)}
+ ))} +
+); diff --git a/src/client/components/chart-cards/static.tsx b/src/client/components/chart-cards/static.tsx index a7f7875..530952a 100644 --- a/src/client/components/chart-cards/static.tsx +++ b/src/client/components/chart-cards/static.tsx @@ -1,6 +1,6 @@ import { useAnimationFrame } from '@/hooks/use-animation-frame'; import { useQuery } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; const formatUptime = (value: number) => { const seconds = String(Math.floor(value % 60)).padStart(2, '0'); @@ -24,14 +24,8 @@ const formatUptime = (value: number) => { const Uptime = ({ boot_time }: Pick) => { const [uptime, setUptime] = useState(formatUptime(Date.now() / 1000 - boot_time)); - const lastUpdate = useRef(0); - useAnimationFrame(dt => { - lastUpdate.current += dt; - if (lastUpdate.current > 1000) { - setUptime(formatUptime(Date.now() / 1000 - boot_time)); - } - }); + useAnimationFrame(() => setUptime(formatUptime(Date.now() / 1000 - boot_time)), 1); return ( <> diff --git a/src/client/hooks/use-animation-frame.ts b/src/client/hooks/use-animation-frame.ts index 96f2211..97957a6 100644 --- a/src/client/hooks/use-animation-frame.ts +++ b/src/client/hooks/use-animation-frame.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; -export const useAnimationFrame = (callback: (dt: number) => void) => { +export const useAnimationFrame = (callback: (dt: number) => void, fps = 60) => { + const ignored = useRef(0); const requestRef = useRef(); const previousTimeRef = useRef(); @@ -8,7 +9,12 @@ export const useAnimationFrame = (callback: (dt: number) => void) => { const animate: FrameRequestCallback = time => { if (previousTimeRef.current !== undefined) { const deltaTime = time - previousTimeRef.current; - callback(deltaTime); + ignored.current += deltaTime; + + if (ignored.current > 1000 / fps) { + ignored.current = 0; + callback(deltaTime); + } } previousTimeRef.current = time; requestRef.current = requestAnimationFrame(animate); @@ -20,5 +26,5 @@ export const useAnimationFrame = (callback: (dt: number) => void) => { cancelAnimationFrame(requestRef.current); } }; - }, [callback]); + }, [callback, fps]); };