gigafibre-fsm/src/components/Speedometer.tsx
louispaulb 88dc3714a1 Initial deploy: gigafibre.ca website with self-hosted address search
React/Vite/shadcn-ui site for Gigafibre ISP.
Address qualification via PostgreSQL (5.2M AQ addresses, pg_trgm fuzzy search).
No Supabase dependency for address search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:37:50 -04:00

179 lines
5.3 KiB
TypeScript

import { useEffect, useState } from "react";
interface SpeedometerProps {
speed: string; // e.g., "80 Mbps", "1 Gbps", "1.5 Gbps"
size?: number;
}
const Speedometer = ({ speed, size = 140 }: SpeedometerProps) => {
const [animatedValue, setAnimatedValue] = useState(0);
// Parse speed to get a normalized value (0-1) for indicator position
// Uses logarithmic curve: fast climb for lower speeds, diminishing impact for higher speeds
const getSpeedValue = (speedStr: string): number => {
const lower = speedStr.toLowerCase();
let mbps: number;
if (lower.includes("gbps")) {
mbps = parseFloat(speedStr) * 1000; // Convert Gbps to Mbps
} else {
mbps = parseFloat(speedStr);
}
// Logarithmic curve: log(1 + x * k) / log(1 + k)
// Higher k = faster climb for lower speeds, compressed difference at higher speeds
const maxSpeed = 3500;
const k = 50; // Higher value for more aggressive lower speed progression
const normalized = Math.min(mbps / maxSpeed, 1);
return Math.log(1 + normalized * k) / Math.log(1 + k);
};
const targetValue = getSpeedValue(speed);
// Animate on mount
useEffect(() => {
const duration = 1200;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedValue(eased * targetValue);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [targetValue]);
// Parse speed into number and unit
const speedNumber = parseFloat(speed);
const speedUnit = speed.toLowerCase().includes("gbps") ? "Gbps" : "Mbps";
const strokeWidth = 14;
const radius = (size - strokeWidth) / 2;
const center = size / 2;
// Arc path for the gauge
const polarToCartesian = (cx: number, cy: number, r: number, angleDeg: number) => {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
};
const describeArc = (cx: number, cy: number, r: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
};
// Full arc from -135 to 135 (270 degrees)
const arcPath = describeArc(center, center, radius, -135, 135);
// Filled arc based on animated value
const filledAngle = -135 + animatedValue * 270;
const filledArcPath = describeArc(center, center, radius, -135, filledAngle);
// Position for the indicator circle
const indicatorPos = polarToCartesian(center, center, radius, filledAngle);
const uniqueId = `speedGradient-${speed.replace(/\s/g, '-')}`;
// Gradient with lighter green tones
const getGradientColors = () => {
return { start: "#34d399", mid: "#4ade80", end: "#86efac" };
};
const colors = getGradientColors();
return (
<div className="relative flex flex-col items-center" style={{ width: size, height: size * 0.7 }}>
<svg
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size * 0.7}
className="overflow-visible"
>
<defs>
<linearGradient id={uniqueId} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor={colors.start} />
<stop offset="50%" stopColor={colors.mid} />
<stop offset="100%" stopColor={colors.end} />
</linearGradient>
<filter id={`shadow-${uniqueId}`} x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodOpacity="0.35" />
</filter>
</defs>
{/* Background arc (grey) */}
<path
d={arcPath}
fill="none"
stroke="hsl(var(--muted))"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Colored gradient arc */}
<path
d={filledArcPath}
fill="none"
stroke={`url(#${uniqueId})`}
strokeWidth={strokeWidth / 2}
strokeLinecap="round"
/>
{/* Indicator circle with shadow */}
<g filter={`url(#shadow-${uniqueId})`}>
<circle
cx={indicatorPos.x}
cy={indicatorPos.y}
r={10}
fill="hsl(var(--muted))"
/>
<circle
cx={indicatorPos.x}
cy={indicatorPos.y}
r={8}
fill="white"
/>
</g>
{/* Speed number - large */}
<text
x={center}
y={center}
textAnchor="middle"
dominantBaseline="middle"
className="fill-primary font-bold"
style={{ fontSize: size * 0.35, letterSpacing: "-0.05em" }}
>
{speedNumber}
</text>
{/* Speed unit - small */}
<text
x={center}
y={center + size * 0.20}
textAnchor="middle"
className="fill-muted-foreground font-medium"
style={{ fontSize: size * 0.12 }}
>
{speedUnit}
</text>
</svg>
</div>
);
};
export default Speedometer;