/**
* 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. */}
);
}
/**
* 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}>
{hasForecast && (
نقطهچین: پیشبینی
)}
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS) */}