/** * Dashboard charts — bar (تعداد بیماران) + line/area (میزان درآمد). * * The source (clinic-pro-tauri) draws these with @mui/x-charts. MUI is not used * in clinicpro, so they are reproduced with plain DOM + inline SVG, matching the * source visuals: bars #5559CE, dashed horizontal grid, y-axis ticks #858D9D, * x-axis labels #7E7E7E. The income line follows the ApexCharts «gradient line» * look instead — gradient stroke, glow, fading area and hover markers — drawn * from the theme tokens so it tracks light/dark mode. */ import React from 'react'; export interface ChartPoint { label: string; value: number; } /** ~5 rounded gridline ticks covering [0, max], top → bottom. Deduped so tiny * integer ranges (e.g. max=1) never render repeated labels. */ function niceTicks(max: number, count = 4): number[] { const safeMax = max > 0 ? max : 1; const rawStep = safeMax / count; const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const norm = rawStep / mag; const niceNorm = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10; const step = niceNorm * mag; const ticks: number[] = []; for (let i = count; i >= 0; i--) ticks.push(Math.round(step * i)); // collapse duplicate rounded labels (keeps gridlines proportional via ticks[0]) const deduped = ticks.filter((t, i) => i === 0 || t !== ticks[i - 1]); return deduped.length >= 2 ? deduped : [ticks[0], 0]; } const faNum = new Intl.NumberFormat('fa-IR'); /** منحنی نرم از میان نقاط (کنترل‌پوینت وسط هر بازه — همان شکل قبلی خط). */ function smoothPath(pts: [number, number][]): string { return pts .map((p, i) => { if (i === 0) return `M${p[0]},${p[1]}`; const prev = pts[i - 1]; const cx = (prev[0] + p[0]) / 2; return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`; }) .join(' '); } /** * ادامهٔ فرضیِ سری برای ماه‌هایی که هنوز نرسیده‌اند: برازش خطی روی حداکثر ۶ نقطهٔ * آخر (روند اخیر، نه کل سال) و ادامه دادن همان شیب. خروجی هرگز منفی نمی‌شود. * * این یک عدد واقعی نیست — نمودار آن را نقطه‌چین و با برچسب «پیش‌بینی» نشان می‌دهد. */ function projectTrend(actual: number[], count: number): number[] { const window = actual.slice(-6); const n = window.length; const meanX = (n - 1) / 2; const meanY = window.reduce((s, v) => s + v, 0) / n; let num = 0; let den = 0; window.forEach((v, i) => { num += (i - meanX) * (v - meanY); den += (i - meanX) ** 2; }); const slope = den === 0 ? 0 : num / den; const last = actual[actual.length - 1]; return Array.from({ length: count }, (_, k) => Math.max(0, Math.round(last + slope * (k + 1)))); } /** * Shared plot frame: left y-axis tick column + dashed gridlines + bottom x-axis * labels. `render(top, bottom)` receives the plot-area vertical bounds (px kept * implicit via fl/percentages) and returns the plot content. */ function ChartFrame({ ticks, labels, yWidth, children, }: { ticks: number[]; labels: string[]; yWidth: number; children: React.ReactNode; }) { // NOTE: styles.css ships an unlayered legacy `.flex { align-items: center }` // which beats Tailwind's layered `items-*` utilities, collapsing the plot // column to zero height. Inline `alignItems` is the only reliable override. return (
{/* y-axis ticks, aligned to gridlines */}
{ticks.map((t, i) => ( {faNum.format(t)} ))}
{/* plot area */} {/* overflow-hidden keeps a dense series (a 31-day month) inside the card */}
{ticks.map((_, i) => (
))} {children}
{/* x-axis labels */}
{labels.map((l, i) => ( {l} ))}
); } /** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */ export function TauriBarChart({ data }: { data: ChartPoint[] }) { if (!data.length || !data.some((d) => d.value > 0)) { return ; } const max = Math.max(...data.map((d) => d.value), 1); const ticks = niceTicks(max); const top = ticks[0] || 1; return ( d.label)} yWidth={42}> {/* `min-w-0` on every column is required: without it each column's intrinsic min-width is the bar's own width, so a 31-day month sums to more than the plot width and the whole row overflows out of the card. The bar is therefore a fraction of its column (which leaves the gap between bars) and only capped at the source's 24px. */}
{data.map((d, i) => (
0 ? 4 : 0, animation: `tdgrowcol .9s ${i * 0.06}s cubic-bezier(.22,.61,.36,1) both`, }} />
))}
); } /** * Line + area chart in the ApexCharts «gradient line» style: a smooth curve whose * stroke runs through a horizontal gradient (--primary → --accent), a soft glow * beneath it, a fading area fill, and markers + tooltip that appear on hover. * * Still hand-drawn SVG — no charting dependency. The plot is scaled non-uniformly * (`preserveAspectRatio="none"`), so anything that must stay round (markers) or * readable (tooltip) lives in an HTML overlay positioned in percentages instead. */ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actualCount?: number }) { if (data.length < 2 || !data.some((d) => d.value > 0)) { return ; } // ماه‌های نیامده صفر برمی‌گردند؛ رسم‌کردنشان یک خط صاف زشت تا انتهای سال می‌سازد. // به‌جای آن، از آخرین دادهٔ واقعی به بعد روند را ادامه می‌دهیم و نقطه‌چین می‌کشیم. const nActual = Math.min(actualCount ?? data.length, data.length); const hasForecast = nActual >= 2 && nActual < data.length; const values = hasForecast ? [...data.slice(0, nActual).map((d) => d.value), ...projectTrend(data.slice(0, nActual).map((d) => d.value), data.length - nActual)] : data.map((d) => d.value); const max = Math.max(...values, 1); const ticks = niceTicks(max); const top = ticks[0] || 1; // SVG plot: 0..100 in both axes, non-uniform scaling (path has no text). const W = 100; const H = 100; const stepX = data.length > 1 ? W / (data.length - 1) : W; const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]); // خط پیش‌بینی از آخرین نقطهٔ واقعی شروع می‌شود تا وصله‌ی دو بخش دیده نشود. const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts); const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : ''; const areaPts = hasForecast ? pts.slice(0, nActual) : pts; const area = `${smoothPath(areaPts)} L${areaPts[areaPts.length - 1][0]},${H} L${areaPts[0][0]},${H} Z`; return ( x.label)} yWidth={64}> {/* رنگِ خط در طول محور افقی از برند به اکسنت می‌رود (سبک دموی apex) */} {hasForecast && ( )} {hasForecast && ( نقطه‌چین: پیش‌بینی )} {/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS) */}
{data.map((p, i) => { const isForecast = hasForecast && i >= nActual; return (
{isForecast ? `پیش‌بینی: ${faNum.format(values[i])}` : faNum.format(p.value)}
); })}
); } function EmptyChart() { return (
داده‌ای برای نمایش نیست
); }