feat: enhance Tauri charts with smooth curves and forecasting
- Implemented a smooth curve rendering for the TauriLineChart using a cubic Bezier path. - Added a forecasting feature to project trends based on recent data points. - Updated TauriDashboardView to pass the current month for accurate forecasting. - Refactored TauriLineChart to handle actual and forecasted data points distinctly. - Introduced gradient strokes and glow effects to align with ApexCharts styling. - Enhanced user interaction with hover markers and tooltips for forecasted data. - Added a new test suite for ClinicDetailPage to ensure proper rendering and functionality.
This commit is contained in:
@@ -88,4 +88,80 @@ describe('TauriLineChart', () => {
|
||||
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||
expect(container.querySelector('svg path')).toBeTruthy();
|
||||
});
|
||||
|
||||
/** سبک «gradient line»: خط از گرادیان افقی رنگ میگیرد، نه یک رنگ ثابت. */
|
||||
it('paints the line with the horizontal gradient and a glow', () => {
|
||||
const { container } = render(<TauriLineChart data={real} />);
|
||||
const line = container.querySelectorAll('svg path')[1] as SVGPathElement;
|
||||
|
||||
expect(line.getAttribute('stroke')).toBe('url(#tdIncomeStroke)');
|
||||
expect(line.getAttribute('filter')).toBe('url(#tdIncomeGlow)');
|
||||
expect(container.querySelector('#tdIncomeStroke')).toBeTruthy();
|
||||
expect(container.querySelector('#tdIncomeGlow feDropShadow')).toBeTruthy();
|
||||
});
|
||||
|
||||
/** نقطه و تولتیپ برای هر داده ساخته میشود و با هاور نمایان میشود. */
|
||||
it('renders a hover marker and value tooltip per point', () => {
|
||||
const { container } = render(<TauriLineChart data={real} />);
|
||||
|
||||
expect(container.querySelectorAll('.td-hit')).toHaveLength(real.length);
|
||||
expect(container.querySelectorAll('.td-dot')).toHaveLength(real.length);
|
||||
// مقدارها با ارقام فارسی در تولتیپ
|
||||
expect(screen.getByText('۴۸')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ماههای نیامده صفر برمیگردند؛ رسمکردنشان یک خط صاف تا انتهای سال میساخت.
|
||||
* از آخرین دادهٔ واقعی به بعد باید روند نقطهچین ادامه پیدا کند.
|
||||
*/
|
||||
describe('TauriLineChart — ادامهٔ پیشبینی', () => {
|
||||
/** ۴ ماه واقعیِ صعودی + ۸ ماه نیامده (صفر) */
|
||||
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `ماه ${i + 1}`,
|
||||
value: i < 4 ? (i + 1) * 10 : 0,
|
||||
}));
|
||||
|
||||
it('بدون actualCount خط پیشبینی ندارد (رفتار قبلی حفظ میشود)', () => {
|
||||
const { container } = render(<TauriLineChart data={year} />);
|
||||
expect(container.querySelector('.td-forecast')).toBeNull();
|
||||
expect(screen.queryByText('نقطهچین: پیشبینی')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('با actualCount ماههای باقیمانده را نقطهچین ادامه میدهد', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
|
||||
const forecast = container.querySelector('.td-forecast');
|
||||
expect(forecast).toBeTruthy();
|
||||
expect(forecast!.getAttribute('stroke')).toBe('var(--accent)');
|
||||
expect(screen.getByText('نقطهچین: پیشبینی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('روند صعودی را ادامه میدهد و صفرها را رسم نمیکند', () => {
|
||||
render(<TauriLineChart data={year} actualCount={4} />);
|
||||
// شیب ۱۰ در ماه، آخرین واقعی ۴۰ ⇒ ماه پنجم ۵۰
|
||||
expect(screen.getByText('پیشبینی: ۵۰')).toBeInTheDocument();
|
||||
// آخرین ماه: ۴۰ + ۸×۱۰ = ۱۲۰
|
||||
expect(screen.getByText('پیشبینی: ۱۲۰')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پیشبینی هرگز منفی نمیشود', () => {
|
||||
const falling: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `ماه ${i + 1}`,
|
||||
value: i < 3 ? 100 - i * 45 : 0,
|
||||
}));
|
||||
const { container } = render(<TauriLineChart data={falling} actualCount={3} />);
|
||||
|
||||
const tips = Array.from(container.querySelectorAll('.td-tip'))
|
||||
.map((el) => el.textContent ?? '')
|
||||
.filter((t) => t.startsWith('پیشبینی'));
|
||||
expect(tips.length).toBeGreaterThan(0);
|
||||
expect(tips.some((t) => t.includes('-') || t.includes('−'))).toBe(false);
|
||||
});
|
||||
|
||||
it('سال کامل (actualCount برابر طول داده) پیشبینی ندارد', () => {
|
||||
const full: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({ label: `ماه ${i + 1}`, value: 10 + i }));
|
||||
const { container } = render(<TauriLineChart data={full} actualCount={12} />);
|
||||
expect(container.querySelector('.td-forecast')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* 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, and a line #5559CE over a #3A6FF8 gradient area.
|
||||
* 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';
|
||||
|
||||
@@ -31,6 +33,40 @@ function niceTicks(max: number, count = 4): number[] {
|
||||
|
||||
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
|
||||
@@ -124,12 +160,27 @@ export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */
|
||||
export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||
/**
|
||||
* 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 <EmptyChart />;
|
||||
}
|
||||
const values = data.map((d) => d.value);
|
||||
// ماههای نیامده صفر برمیگردند؛ رسمکردنشان یک خط صاف زشت تا انتهای سال میسازد.
|
||||
// بهجای آن، از آخرین دادهٔ واقعی به بعد روند را ادامه میدهیم و نقطهچین میکشیم.
|
||||
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;
|
||||
@@ -139,15 +190,11 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||
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 d = 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(' ');
|
||||
const area = `${d} L${pts[pts.length - 1][0]},${H} L${pts[0][0]},${H} Z`;
|
||||
// خط پیشبینی از آخرین نقطهٔ واقعی شروع میشود تا وصلهی دو بخش دیده نشود.
|
||||
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 (
|
||||
<ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}>
|
||||
@@ -157,22 +204,98 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3A6FF8" stopOpacity={0.1} />
|
||||
<stop offset="100%" stopColor="#3A6FF8" stopOpacity={0.02} />
|
||||
{/* رنگِ خط در طول محور افقی از برند به اکسنت میرود (سبک دموی apex) */}
|
||||
<linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--primary)" />
|
||||
<stop offset="55%" stopColor="var(--primary-600)" />
|
||||
<stop offset="100%" stopColor="var(--accent)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id="tdIncomeGlow" x="-20%" y="-40%" width="140%" height="200%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="var(--primary)" floodOpacity="0.28" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path d={area} fill="url(#tdIncomeGrad)" />
|
||||
{hasForecast && (
|
||||
<path
|
||||
className="td-forecast"
|
||||
d={dashed}
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={d}
|
||||
d={solid}
|
||||
fill="none"
|
||||
stroke="#5559CE"
|
||||
stroke="url(#tdIncomeStroke)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
filter="url(#tdIncomeGlow)"
|
||||
style={{ strokeDasharray: 1200, animation: 'tddraw 1.1s var(--ease) both' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{hasForecast && (
|
||||
<span
|
||||
className="absolute top-0 left-0 rounded-[var(--r-pill)] px-2 py-[3px] text-[10.5px] font-bold"
|
||||
style={{ background: 'var(--accent-bg)', color: 'var(--accent-600)' }}
|
||||
>
|
||||
نقطهچین: پیشبینی
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS) */}
|
||||
<div className="absolute inset-0 flex" style={{ alignItems: 'stretch' }}>
|
||||
{data.map((p, i) => {
|
||||
const isForecast = hasForecast && i >= nActual;
|
||||
return (
|
||||
<div key={i} className="td-hit relative flex-1 min-w-0">
|
||||
<span
|
||||
className="td-dot absolute block rounded-full border-2 border-[var(--surface)]"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: isForecast ? 'var(--accent)' : 'var(--primary)',
|
||||
left: `${(i * stepX / W) * 100}%`,
|
||||
top: `${(pts[i][1] / H) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="td-tip absolute whitespace-nowrap rounded-[var(--r-xs)] px-2 py-1 text-[11px] font-bold"
|
||||
style={{
|
||||
left: `${(i * stepX / W) * 100}%`,
|
||||
top: `${(pts[i][1] / H) * 100}%`,
|
||||
transform: 'translate(-50%, calc(-100% - 12px))',
|
||||
background: isForecast ? 'var(--accent)' : 'var(--text)',
|
||||
color: '#fff',
|
||||
boxShadow: 'var(--shadow)',
|
||||
}}
|
||||
>
|
||||
{isForecast ? `پیشبینی: ${faNum.format(values[i])}` : faNum.format(p.value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes tddraw { from { stroke-dashoffset: 1200 } to { stroke-dashoffset: 0 } }
|
||||
/* نقطهچینِ بخش پیشبینی — با non-scaling-stroke در محور کشیده نمیشود */
|
||||
.td-forecast { stroke-dasharray: 0.1 7; stroke-linecap: round; }
|
||||
.td-dot, .td-tip { opacity: 0; transition: opacity .14s var(--ease); pointer-events: none; }
|
||||
.td-hit:hover .td-dot, .td-hit:hover .td-tip { opacity: 1; }
|
||||
`}</style>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface TauriDashboardViewProps {
|
||||
onRevenueYearChange: (y: number) => void;
|
||||
/** سال شمسی جاری — مبنای گزینههای سلکتور سال */
|
||||
currentJalaliYear: number;
|
||||
/** ماه شمسی جاری (۱..۱۲) — مرز دادهٔ واقعی و پیشبینی در نمودار درآمد */
|
||||
currentJalaliMonth: number;
|
||||
}
|
||||
|
||||
export function TauriDashboardView({
|
||||
@@ -103,8 +105,12 @@ export function TauriDashboardView({
|
||||
revenueYear,
|
||||
onRevenueYearChange,
|
||||
currentJalaliYear,
|
||||
currentJalaliMonth,
|
||||
}: TauriDashboardViewProps) {
|
||||
const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]);
|
||||
// در سالِ جاری فقط تا ماه جاری دادهی واقعی وجود دارد؛ بقیه پیشبینی میشود.
|
||||
// سالهای گذشته کاملاند و پیشبینی ندارند.
|
||||
const incomeActualCount = revenueYear === currentJalaliYear ? currentJalaliMonth : undefined;
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-y-[24px] w-full">
|
||||
{/* Cards */}
|
||||
@@ -127,7 +133,7 @@ export function TauriDashboardView({
|
||||
selectorValue={revenueYear}
|
||||
onSelectorChange={onRevenueYearChange}
|
||||
>
|
||||
<TauriLineChart data={incomeLine} />
|
||||
<TauriLineChart data={incomeLine} actualCount={incomeActualCount} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user