"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(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 (
{ready ? ( {/* Back-facing leaves */} {backLeaves.map((lf) => ( ))} {/* Vine far half — behind the pole */} {topPhase > 0 ? : null} {/* Pole */} {/* 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 ( ); }, )} {/* Finial + saffron flag */} {filledToTop ? ( ) : null} {/* Vine near half — over the pole, with a tube sheen */} {runs.map(([a, b], i) => ( ))} {runs.map(([a, b], i) => ( ))} {/* Soil */} {/* Tendrils */} {tendrils.map((td) => ( ))} {/* Petioles + front leaves */} {petioles.map((p) => ( ))} {frontLeaves.map((lf) => ( ))} ) : null} {/* Day chips (HTML overlay, aligned to the SVG nodes) */} {ready ? nodes.map((n) => ( )) : null} {/* Hidden-folds hint at the base */} {ready && hidden > 0 ? (
+{hidden} earlier {hidden === 1 ? "day" : "days"}
) : null}
); } function Leaf({ lf, pal, fill, }: { lf: { x: number; y: number; rot: number; L: number; delay: number }; pal: Palette; fill: string; }) { return ( ); } 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 (
{isTip ? "Today" : fmtDate(day.date)} {blank ? ( Begin ) : ( {day.rounds} {day.posts} )}
); } 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", }); }