190 lines
5.4 KiB
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>
|