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>
179 lines
5.3 KiB
TypeScript
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;
|