Files
spsays-training/components/sadhna/sadhna-vine.tsx
T
2026-06-07 22:32:34 +05:30

683 lines
22 KiB
TypeScript

"use client";
/**
* SadhnaVine — the sādhana creeper.
*
* A realistic crawler vine twines up a pole, gaining one full fold (a 360°
* wrap) for every day of the current streak — oldest at the base, climbing
* toward the top. An unfinished today is the soft growing tip above the last
* finished fold, tipped with a tendril and reaching a little higher as the
* day's two disciplines are met. Each finished fold carries a chip showing
* how many rounds were chanted and how many quotes were posted that day.
*
* The pole stands its full height from the start — the practice is to climb
* it. When the vine fills the pole a saffron flag crowns it. Lose the streak
* and the living vine you built is gone: you begin the climb again, and a
* faltering practice yellows, then browns, the foliage (the `growth` health
* score drives the palette).
*
* Rendering: the stem is a tapered, tube-shaded ribbon following a helix; its
* far half is drawn behind the pole and near half in front, so it wraps the
* pole convincingly. Heart-shaped leaves (veined, gradient-shaded, on
* petioles) and coiled tendrils dress the stem. Geometry is in real pixels,
* sized to the container via a ResizeObserver, so the day chips (HTML) line
* up with the SVG. A gentle CSS breeze sways leaves, tendrils and the tip;
* the stem stays put so the chips track it.
*
* The drawing math mirrors the standalone SVG study iterated with the
* render-svg tool, kept parametric so it scales with the container and the
* streak length.
*/
import { useEffect, useRef, useState } from "react";
import { CircleDot, MessageSquareQuote, Sparkles } from "lucide-react";
import type { VineDay } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
const TAU = Math.PI * 2;
type Palette = {
stemFrontTop: string;
stemFrontMid: string;
stemFrontBot: string;
stemBack: string;
sheen: string;
leafFront: [string, string, string];
leafBack: [string, string, string];
vein: string;
tendril: string;
droop: number; // extra rotation toward the ground when wilting
dim: boolean;
};
function palette(growth: number, alive: boolean): Palette {
if (!alive || growth <= 0) {
return {
stemFrontTop: "#a98f63",
stemFrontMid: "#8c7149",
stemFrontBot: "#6f5836",
stemBack: "#5f4a2e",
sheen: "#cdb085",
leafFront: ["#bfa46f", "#9c8451", "#6f5a35"],
leafBack: ["#9c8451", "#7d683f", "#54462a"],
vein: "#5f4a2e",
tendril: "#8c7149",
droop: 22,
dim: true,
};
}
if (growth < 30) {
return {
stemFrontTop: "#9fb04a",
stemFrontMid: "#84983a",
stemFrontBot: "#5f7029",
stemBack: "#566b27",
sheen: "#cfe07f",
leafFront: ["#c2d85e", "#9bbf45", "#6f8a30"],
leafBack: ["#9bbf45", "#7ea136", "#5b7327"],
vein: "#566b27",
tendril: "#84983a",
droop: 8,
dim: false,
};
}
if (growth < 60) {
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#26602a",
sheen: "#7bbf5e",
leafFront: ["#74c044", "#45a23b", "#27702c"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#235f27",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#235a27",
sheen: "#8fce6a",
leafFront: ["#7cc848", "#46a23c", "#27732f"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#1f5a23",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
// Heart-shaped crawler leaf outline, tip up at -L, petiole at origin.
function leafOutline(L: number): string {
const w = L * 0.66;
return `M 0 ${-L * 0.2}
C ${w * 0.16} ${-L * 0.1} ${w * 0.36} 0 ${w * 0.52} ${-L * 0.02}
C ${w * 0.8} ${-L * 0.05} ${w} ${-L * 0.22} ${w} ${-L * 0.41}
C ${w} ${-L * 0.61} ${w * 0.64} ${-L * 0.81} ${w * 0.3} ${-L * 0.92}
C ${w * 0.18} ${-L * 0.96} ${w * 0.08} ${-L * 0.98} 0 ${-L}
C ${-w * 0.08} ${-L * 0.98} ${-w * 0.18} ${-L * 0.96} ${-w * 0.3} ${-L * 0.92}
C ${-w * 0.64} ${-L * 0.81} ${-w} ${-L * 0.61} ${-w} ${-L * 0.41}
C ${-w} ${-L * 0.22} ${-w * 0.8} ${-L * 0.05} ${-w * 0.52} ${-L * 0.02}
C ${-w * 0.36} 0 ${-w * 0.16} ${-L * 0.1} 0 ${-L * 0.2} Z`;
}
function leafVeins(L: number): string {
const w = L * 0.66;
const lat = (yf: number, xf: number, s: number) =>
`M0 ${-L * yf} Q ${s * w * xf * 0.55} ${-L * (yf + 0.1)} ${s * w * xf} ${-L * (yf + 0.2)}`;
return [
`M0 ${-L * 0.2} L0 ${-L * 0.95}`,
lat(0.3, 0.74, 1),
lat(0.3, 0.74, -1),
lat(0.48, 0.6, 1),
lat(0.48, 0.6, -1),
lat(0.64, 0.44, 1),
lat(0.64, 0.44, -1),
lat(0.78, 0.28, 1),
lat(0.78, 0.28, -1),
].join(" ");
}
export function SadhnaVine({
days,
targetRounds,
growth,
alive,
totalStreak,
className,
}: {
days: VineDay[];
targetRounds: number;
growth: number;
alive: boolean;
totalStreak: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const cr = entries[0].contentRect;
setSize({ w: cr.width, h: cr.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const W = size.w;
const H = size.h;
const ready = W > 40 && H > 140;
const pal = palette(growth, alive);
// ── Geometry ──
const centerX = W / 2;
const baseY = H - 34;
const topPad = 24;
const availH = Math.max(120, baseY - topPad);
const amp = Math.min(46, Math.max(22, W * 0.17));
const pitch = Math.min(86, Math.max(54, availH / 12));
const poleW = Math.min(14, Math.max(9, amp * 0.3));
const wBase = Math.min(15, Math.max(8, amp * 0.3));
const wTip = 3;
const maxWraps = Math.max(1, Math.floor(availH / pitch));
// Growing tip — today, still unfinished — extends with the day's progress.
const tip =
days.length > 0 && days[days.length - 1].in_progress
? days[days.length - 1]
: null;
const chantPortion =
targetRounds > 0
? Math.min(1, (tip?.rounds ?? 0) / targetRounds)
: (tip?.rounds ?? 0) > 0
? 1
: 0;
const quotePortion = (tip?.posts ?? 0) > 0 ? 1 : 0;
const partialFrac = tip ? 0.18 + 0.78 * ((chantPortion + quotePortion) / 2) : 0;
// Trim oldest folds beyond the pole's capacity.
const completedAll = days.filter((d) => d.completed).length;
const unitsNeeded = completedAll + (tip ? 1 : 0);
const hidden = Math.max(0, unitsNeeded - maxWraps);
const render = hidden > 0 ? days.slice(hidden) : days;
const renderCompleted = render.filter((d) => d.completed).length;
const renderTip =
render.length > 0 && render[render.length - 1].in_progress
? render[render.length - 1]
: null;
const topPhase =
renderCompleted * TAU + (renderTip ? partialFrac * TAU : 0);
// ── Helix helpers (closures over the measured geometry) ──
const ptOf = (ph: number) => ({
x: centerX + amp * Math.sin(ph),
y: baseY - (ph / TAU) * pitch,
depth: Math.cos(ph),
});
const tanOf = (ph: number) => {
const dx = amp * Math.cos(ph);
const dy = -(pitch / TAU);
const l = Math.hypot(dx, dy) || 1;
return { tx: dx / l, ty: dy / l };
};
const widthOf = (t: number) => wBase + (wTip - wBase) * t;
const ribbon = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const left: string[] = [];
const right: string[] = [];
for (let i = 0; i <= steps; i++) {
const ph = p0 + ((p1 - p0) * i) / steps;
const c = ptOf(ph);
const { tx, ty } = tanOf(ph);
const nx = -ty;
const ny = tx;
const w = widthOf(topPhase > 0 ? ph / topPhase : 0) / 2;
left.push(`${(c.x + nx * w).toFixed(1)} ${(c.y + ny * w).toFixed(1)}`);
right.push(`${(c.x - nx * w).toFixed(1)} ${(c.y - ny * w).toFixed(1)}`);
}
return `M${left.join(" L")} L${right.reverse().join(" L")} Z`;
};
const centerPath = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const pts: string[] = [];
for (let i = 0; i <= steps; i++) {
const c = ptOf(p0 + ((p1 - p0) * i) / steps);
pts.push(`${c.x.toFixed(1)} ${c.y.toFixed(1)}`);
}
return `M${pts.join(" L")}`;
};
const frontRuns = () => {
const runs: [number, number][] = [];
let start: number | null = null;
const step = TAU / 80;
for (let ph = 0; ph <= topPhase + 1e-6; ph += step) {
const d = Math.cos(ph);
if (d >= 0 && start === null) start = ph;
if (d < 0 && start !== null) {
runs.push([start, ph]);
start = null;
}
}
if (start !== null) runs.push([start, topPhase]);
return runs;
};
const tendrilPath = (x: number, y: number, dir: number, scale: number) => {
const loops = 2.3;
const R = 11 * scale;
const a0 = dir > 0 ? 0 : Math.PI;
const ex = x + dir * 15 * scale;
const ey = y - 5 * scale;
const steps = 56;
const pts: string[] = [`${x.toFixed(1)} ${y.toFixed(1)}`];
for (let i = 0; i <= steps; i++) {
const a = a0 + (i / steps) * loops * TAU;
const r = R * (1 - 0.62 * (i / steps));
pts.push(
`${(ex + dir * r * Math.cos(a)).toFixed(1)} ${(ey + r * Math.sin(a)).toFixed(1)}`,
);
}
return `M${pts.join(" L")}`;
};
// ── Build foliage ──
type LeafSpec = {
key: string;
x: number;
y: number;
rot: number;
L: number;
back: boolean;
delay: number;
};
const leaves: LeafSpec[] = [];
const petioles: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
let k = 0;
for (let ph = Math.PI / 2; ph < topPhase - 0.25; ph += Math.PI, k++) {
const c = ptOf(ph);
const right = Math.sin(ph) > 0;
const t = ph / topPhase;
const L = (54 - 22 * t) * (k % 3 === 0 ? 0.82 : 1);
const petLen = 11 * (1 - 0.4 * t);
const rot = (right ? 56 : -56) + (k % 2 ? 8 : -6) + (right ? pal.droop : -pal.droop);
const aa = ((rot - 90) * Math.PI) / 180;
const lx = c.x + Math.cos(aa) * petLen;
const ly = c.y + Math.sin(aa) * petLen;
petioles.push({
key: `p${k}`,
d: `M${c.x.toFixed(1)} ${c.y.toFixed(1)} q ${(Math.cos(aa) * petLen * 0.4).toFixed(1)} ${(Math.sin(aa) * petLen * 0.4).toFixed(1)} ${(Math.cos(aa) * petLen).toFixed(1)} ${(Math.sin(aa) * petLen).toFixed(1)}`,
});
leaves.push({
key: `l${k}`,
x: lx,
y: ly,
rot,
L,
back: c.depth < -0.1,
delay: (k % 5) * 0.45,
});
}
}
const backLeaves = leaves.filter((l) => l.back);
const frontLeaves = leaves.filter((l) => !l.back);
// Tendrils: always one at the growing tip, plus a couple along the way.
const tendrils: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
const t = ptOf(topPhase);
tendrils.push({
key: "t-tip",
d: tendrilPath(t.x, t.y, Math.sin(topPhase) >= 0 ? 1 : -1, 1.1),
});
for (let ph = Math.PI * 1.5; ph < topPhase - 0.4; ph += TAU) {
const c = ptOf(ph);
tendrils.push({
key: `t${ph.toFixed(0)}`,
d: tendrilPath(c.x, c.y, -1, 0.85),
});
}
}
// ── Day nodes (chips anchor here, at each fold's front-centre top) ──
type Node = {
key: string;
x: number;
y: number;
day: VineDay;
kind: "fold" | "tip";
sideRight: boolean;
};
const nodes: Node[] = [];
if (ready) {
for (let i = 0; i < renderCompleted; i++) {
const phase = (i + 1) * TAU;
const c = ptOf(phase);
nodes.push({
key: render[i].date,
x: c.x,
y: c.y,
day: render[i],
kind: "fold",
sideRight: i % 2 === 0,
});
}
if (renderTip) {
const c = ptOf(topPhase);
nodes.push({
key: `${renderTip.date}-tip`,
x: c.x,
y: c.y,
day: renderTip,
kind: "tip",
sideRight: renderCompleted % 2 === 0,
});
}
}
const filledToTop = ready && unitsNeeded >= maxWraps && renderCompleted >= 1;
const runs = ready ? frontRuns() : [];
return (
<div ref={ref} className={cn("relative w-full h-full overflow-hidden", className)}>
<style>{`
@keyframes sv-leaf { 0%,100%{transform:rotate(-5deg)} 50%{transform:rotate(5deg)} }
@keyframes sv-flag { 0%,100%{transform:scaleX(1)} 50%{transform:scaleX(0.82)} }
@keyframes sv-tip { 0%,100%{transform:rotate(-6deg)} 50%{transform:rotate(7deg)} }
.sv-leaf { transform-box:fill-box; transform-origin:50% 100%; animation:sv-leaf 4.5s ease-in-out infinite; }
.sv-flag { transform-box:fill-box; transform-origin:0% 50%; animation:sv-flag 2.6s ease-in-out infinite; }
.sv-tip { transform-box:fill-box; transform-origin:50% 100%; animation:sv-tip 3.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce){ .sv-leaf,.sv-flag,.sv-tip{ animation:none } }
`}</style>
{ready ? (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
className="absolute inset-0"
role="img"
aria-label={`Your sādhana vine — ${totalStreak} day streak`}
>
<defs>
<linearGradient id="sv-pole" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#caa066" />
<stop offset="0.5" stopColor="#a87a40" />
<stop offset="1" stopColor="#724d24" />
</linearGradient>
<linearGradient
id="sv-stem"
gradientUnits="userSpaceOnUse"
x1={centerX - amp}
y1="0"
x2={centerX + amp}
y2="0"
>
<stop offset="0" stopColor={pal.stemFrontTop} />
<stop offset="0.5" stopColor={pal.stemFrontMid} />
<stop offset="1" stopColor={pal.stemFrontBot} />
</linearGradient>
<linearGradient id="sv-leaf-front" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafFront[0]} />
<stop offset="0.5" stopColor={pal.leafFront[1]} />
<stop offset="1" stopColor={pal.leafFront[2]} />
</linearGradient>
<linearGradient id="sv-leaf-back" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafBack[0]} />
<stop offset="0.5" stopColor={pal.leafBack[1]} />
<stop offset="1" stopColor={pal.leafBack[2]} />
</linearGradient>
<linearGradient id="sv-soil" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#9c7350" />
<stop offset="1" stopColor="#6b4a31" />
</linearGradient>
</defs>
{/* Back-facing leaves */}
{backLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-back)" />
))}
{/* Vine far half — behind the pole */}
{topPhase > 0 ? <path d={ribbon(0, topPhase)} fill={pal.stemBack} /> : null}
{/* Pole */}
<rect
x={centerX - poleW / 2}
y={topPad}
width={poleW}
height={baseY - topPad}
rx={poleW / 2}
fill="url(#sv-pole)"
/>
<rect
x={centerX - poleW / 2 + 1.5}
y={topPad}
width={1.8}
height={baseY - topPad}
rx={1}
fill="#ecd6ad"
opacity={0.5}
/>
{/* bamboo node rings */}
{Array.from({ length: Math.max(2, Math.floor((baseY - topPad) / 90)) }).map(
(_, i) => {
const y = baseY - (i + 1) * 90;
if (y < topPad + 8) return null;
return (
<rect
key={`ring${i}`}
x={centerX - poleW / 2 - 1}
y={y}
width={poleW + 2}
height={3}
rx={1.5}
fill="#6b4a26"
opacity={0.45}
/>
);
},
)}
{/* Finial + saffron flag */}
<circle cx={centerX} cy={topPad} r={poleW * 0.55} fill="#b8842f" />
<circle cx={centerX - 1} cy={topPad - 1} r={poleW * 0.22} fill="#f0d9a8" opacity={0.6} />
{filledToTop ? (
<g className="sv-flag">
<path
d={`M${centerX + poleW * 0.4} ${topPad - 2}
L${centerX + poleW * 0.4 + 32} ${topPad + 5}
L${centerX + poleW * 0.4} ${topPad + 12} Z`}
fill="#ef8a35"
/>
</g>
) : null}
{/* Vine near half — over the pole, with a tube sheen */}
{runs.map(([a, b], i) => (
<path key={`fr${i}`} d={ribbon(a, b)} fill="url(#sv-stem)" />
))}
{runs.map(([a, b], i) => (
<path
key={`sh${i}`}
d={centerPath(a, b)}
fill="none"
stroke={pal.sheen}
strokeWidth={Math.max(2, wBase * 0.22)}
strokeLinecap="round"
opacity={pal.dim ? 0.25 : 0.45}
/>
))}
{/* Soil */}
<ellipse cx={centerX} cy={baseY + 8} rx={Math.min(70, W * 0.3)} ry={12} fill="url(#sv-soil)" />
<ellipse cx={centerX} cy={baseY + 3} rx={Math.min(58, W * 0.25)} ry={6} fill="#9c7350" />
{/* Tendrils */}
{tendrils.map((td) => (
<path
key={td.key}
className="sv-tip"
d={td.d}
fill="none"
stroke={pal.tendril}
strokeWidth={2.2}
strokeLinecap="round"
/>
))}
{/* Petioles + front leaves */}
{petioles.map((p) => (
<path
key={p.key}
d={p.d}
fill="none"
stroke={pal.stemFrontMid}
strokeWidth={2.4}
strokeLinecap="round"
/>
))}
{frontLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-front)" />
))}
</svg>
) : null}
{/* Day chips (HTML overlay, aligned to the SVG nodes) */}
{ready
? nodes.map((n) => (
<DayChip
key={`c-${n.key}`}
x={n.x}
y={n.y}
W={W}
sideRight={n.sideRight}
day={n.day}
isTip={n.kind === "tip"}
dim={pal.dim}
/>
))
: null}
{/* Hidden-folds hint at the base */}
{ready && hidden > 0 ? (
<div
className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border bg-card/90 px-2.5 py-0.5 text-[10px] text-muted-foreground shadow-sm"
style={{ bottom: 6 }}
>
+{hidden} earlier {hidden === 1 ? "day" : "days"}
</div>
) : null}
</div>
);
}
function Leaf({
lf,
pal,
fill,
}: {
lf: { x: number; y: number; rot: number; L: number; delay: number };
pal: Palette;
fill: string;
}) {
return (
<g
className="sv-leaf"
style={{ animationDelay: `${lf.delay}s` }}
transform={`translate(${lf.x.toFixed(1)} ${lf.y.toFixed(1)}) rotate(${lf.rot.toFixed(1)})`}
>
<path d={leafOutline(lf.L)} fill={fill} stroke={pal.vein} strokeWidth={0.7} />
<path
d={leafVeins(lf.L)}
fill="none"
stroke={pal.vein}
strokeWidth={Math.max(0.5, lf.L * 0.013)}
strokeLinecap="round"
opacity={0.45}
/>
<path
d={`M ${-lf.L * 0.1} ${-lf.L * 0.3} Q ${-lf.L * 0.28} ${-lf.L * 0.55} ${-lf.L * 0.12} ${-lf.L * 0.8}`}
fill="none"
stroke="#d8f2b0"
strokeWidth={lf.L * 0.045}
strokeLinecap="round"
opacity={pal.dim ? 0.12 : 0.25}
/>
</g>
);
}
function DayChip({
x,
y,
W,
sideRight,
day,
isTip,
dim,
}: {
x: number;
y: number;
W: number;
sideRight: boolean;
day: VineDay;
isTip: boolean;
dim: boolean;
}) {
const gap = 18;
const style: React.CSSProperties = sideRight
? { left: x + gap, top: y, transform: "translateY(-50%)" }
: { right: W - x + gap, top: y, transform: "translateY(-50%)" };
const blank = isTip && day.rounds === 0 && day.posts === 0;
return (
<div
className={cn(
"pointer-events-none absolute flex max-w-[42%] flex-col gap-0.5 rounded-xl border bg-card/90 px-2 py-1 shadow-sm backdrop-blur-sm",
isTip ? "border-primary/50 ring-1 ring-primary/20" : "border-border",
dim && !isTip && "opacity-70",
)}
style={style}
>
<span className="text-[10px] font-medium leading-none text-muted-foreground">
{isTip ? "Today" : fmtDate(day.date)}
</span>
{blank ? (
<span className="flex items-center gap-1 text-[11px] font-medium leading-none text-primary">
<Sparkles className="size-3" /> Begin
</span>
) : (
<span className="flex items-center gap-2 text-[11px] font-semibold leading-none text-foreground tabular-nums">
<span className="flex items-center gap-0.5" title="rounds chanted">
<CircleDot className="size-3 text-primary" />
{day.rounds}
</span>
<span className="flex items-center gap-0.5" title="quotes posted">
<MessageSquareQuote className="size-3 text-primary" />
{day.posts}
</span>
</span>
)}
</div>
);
}
function fmtDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}