683 lines
22 KiB
TypeScript
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",
|
|
});
|
|
}
|