Files
core-cooldown/src/lib/components/TimerRing.svelte

190 lines
5.4 KiB
Svelte

<script lang="ts">
interface Props {
progress: number;
size?: number;
strokeWidth?: number;
accentColor?: string;
label?: string;
valueText?: string;
children?: import("svelte").Snippet;
}
let {
progress,
size = 280,
strokeWidth = 8,
accentColor = "#ff4d00",
label = "Timer",
valueText = "",
children,
}: Props = $props();
const padding = 40;
const viewSize = $derived(size + padding * 2);
const radius = $derived((size - strokeWidth) / 2);
const circumference = $derived(2 * Math.PI * radius);
const dashOffset = $derived(circumference * (1 - progress));
const center = $derived(viewSize / 2);
// Unique filter IDs based on color to avoid SVG collisions
const fid = $derived(`ring-${size}-${accentColor.replace('#', '')}`);
// Derive lighter shade for gradient end
function lighten(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.min(255, r + (255 - r) * amount);
const lg = Math.min(255, g + (255 - g) * amount);
const lb = Math.min(255, b + (255 - b) * amount);
return `#${Math.round(lr).toString(16).padStart(2, '0')}${Math.round(lg).toString(16).padStart(2, '0')}${Math.round(lb).toString(16).padStart(2, '0')}`;
}
const gradA = $derived(accentColor);
const gradB = $derived(lighten(accentColor, 0.2));
const gradC = $derived(lighten(accentColor, 0.4));
</script>
<div
class="relative flex items-center justify-center"
style="width: {size}px; height: {size}px;"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
aria-label={label}
aria-valuetext={valueText}
>
<!-- Glow SVG — drawn larger than the container so blur isn't clipped -->
<svg
aria-hidden="true"
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
style="
left: {-padding}px;
top: {-padding}px;
transform: rotate(-90deg);
overflow: visible;
"
>
<defs>
<linearGradient id="ring-grad-{fid}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color={gradA} />
<stop offset="50%" stop-color={gradB} />
<stop offset="100%" stop-color={gradC} />
</linearGradient>
<!-- Wide ambient glow filter -->
<filter id="glow-wide-{fid}" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="28" />
</filter>
<!-- Medium glow filter -->
<filter id="glow-mid-{fid}" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="12" />
</filter>
<!-- Core tight glow filter -->
<filter id="glow-core-{fid}" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{#if progress > 0.002}
<!-- Layer 1: Wide ambient bloom -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth * 4}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-wide-{fid})"
opacity="0.35"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 2: Medium glow -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth * 2}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-mid-{fid})"
opacity="0.5"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 3: Core ring with tight glow -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-core-{fid})"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Non-glow SVG — exact size, draws the track + crisp ring -->
<svg
aria-hidden="true"
width={size}
height={size}
class="absolute"
style="transform: rotate(-90deg);"
>
<!-- Background track -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#161616"
stroke-width={strokeWidth}
/>
{#if progress > 0.002}
<!-- Sharp foreground ring (no filter) -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Content overlay -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
{#if children}
{@render children()}
{/if}
</div>
</div>