184 lines
4.4 KiB
Svelte
184 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
|
|
interface Props {
|
|
pattern?: string;
|
|
size?: number;
|
|
color?: string;
|
|
showLabel?: boolean;
|
|
phaseLabel?: string;
|
|
countdown?: number;
|
|
breathScale?: number;
|
|
}
|
|
|
|
let {
|
|
pattern = "box",
|
|
size = 200,
|
|
color = "#7c6aef",
|
|
showLabel = true,
|
|
phaseLabel = $bindable("Inhale"),
|
|
countdown = $bindable(4),
|
|
breathScale = $bindable(0.6),
|
|
}: Props = $props();
|
|
|
|
// Breathing patterns: arrays of [phase, durationSeconds]
|
|
const patterns: Record<string, [string, number][]> = {
|
|
box: [["Inhale", 4], ["Hold", 4], ["Exhale", 4], ["Hold", 4]],
|
|
relaxing: [["Inhale", 4], ["Hold", 7], ["Exhale", 8]],
|
|
energizing: [["Inhale", 6], ["Hold", 2], ["Exhale", 6], ["Hold", 2]],
|
|
calm: [["Inhale", 4], ["Hold", 4], ["Exhale", 6]],
|
|
deep: [["Inhale", 5], ["Exhale", 5]],
|
|
};
|
|
|
|
const phases = $derived(patterns[pattern] ?? patterns.box);
|
|
const totalCycleDuration = $derived(phases.reduce((sum, [, d]) => sum + d, 0));
|
|
let scale = $state(0.6);
|
|
let animationId: number | null = null;
|
|
let startTime = 0;
|
|
|
|
function animate(timestamp: number) {
|
|
if (!startTime) startTime = timestamp;
|
|
const elapsed = ((timestamp - startTime) / 1000) % totalCycleDuration;
|
|
|
|
let accumulated = 0;
|
|
for (const [label, duration] of phases) {
|
|
if (elapsed < accumulated + duration) {
|
|
phaseLabel = label;
|
|
const phaseElapsed = elapsed - accumulated;
|
|
countdown = Math.ceil(duration - phaseElapsed);
|
|
|
|
// Calculate scale based on phase
|
|
const t = phaseElapsed / duration;
|
|
if (label === "Inhale") {
|
|
scale = 0.6 + 0.4 * t; // 60% -> 100%
|
|
} else if (label === "Exhale") {
|
|
scale = 1.0 - 0.4 * t; // 100% -> 60%
|
|
}
|
|
// Hold phases keep current scale
|
|
breathScale = scale;
|
|
break;
|
|
}
|
|
accumulated += duration;
|
|
}
|
|
|
|
animationId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
onMount(() => {
|
|
// Check reduced motion preference
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
phaseLabel = "Breathe";
|
|
countdown = 0;
|
|
scale = 0.8;
|
|
return;
|
|
}
|
|
|
|
animationId = requestAnimationFrame(animate);
|
|
return () => {
|
|
if (animationId !== null) cancelAnimationFrame(animationId);
|
|
};
|
|
});
|
|
|
|
const circleR = $derived(size / 2 - 8);
|
|
const cx = $derived(size / 2);
|
|
const cy = $derived(size / 2);
|
|
</script>
|
|
|
|
<div class="breathing-guide" style="width: {size}px; height: {size}px;">
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 {size} {size}"
|
|
class="breathing-svg"
|
|
aria-label="Breathing guide: {phaseLabel}"
|
|
role="img"
|
|
>
|
|
<defs>
|
|
<filter id="breathing-glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="12" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- Outer glow circle -->
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={circleR * scale}
|
|
fill="none"
|
|
stroke={color}
|
|
stroke-width="2"
|
|
opacity="0.15"
|
|
filter="url(#breathing-glow)"
|
|
class="transition-r"
|
|
/>
|
|
|
|
<!-- Main circle -->
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={circleR * scale}
|
|
fill="none"
|
|
stroke={color}
|
|
stroke-width="2"
|
|
opacity="0.4"
|
|
class="transition-r"
|
|
/>
|
|
|
|
<!-- Inner fill -->
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={circleR * scale * 0.85}
|
|
fill={color}
|
|
opacity="0.06"
|
|
class="transition-r"
|
|
/>
|
|
</svg>
|
|
|
|
<!-- Phase label + countdown -->
|
|
{#if showLabel}
|
|
<div class="breathing-label">
|
|
<span class="text-[14px] font-medium text-white tracking-wider uppercase opacity-80">
|
|
{phaseLabel}
|
|
</span>
|
|
{#if countdown > 0}
|
|
<span class="text-[24px] font-semibold text-white tabular-nums mt-0.5">
|
|
{countdown}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.breathing-guide {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.breathing-svg {
|
|
position: absolute;
|
|
inset: 0;
|
|
}
|
|
|
|
.breathing-label {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Smooth radius transitions via CSS */
|
|
.transition-r {
|
|
transition: r 0.3s ease-out, opacity 0.3s ease-out;
|
|
}
|
|
</style>
|