forked from Tadrisino/MathApp_frontend
102 lines
5.0 KiB
TypeScript
102 lines
5.0 KiB
TypeScript
import type { ChartSpec } from './types';
|
|
import { COLORS, GRID, INK, Label, SOFT, bidi, fa, mathText, niceStep, seriesColors, ticksBetween } from './svg';
|
|
|
|
const W = 330;
|
|
|
|
/** نمودارِ ستونی، خطشکسته و دایرهای. درصدِ دایرهای را کد از روی مقدارها حساب میکند. */
|
|
export function Chart({ spec }: { spec: ChartSpec }) {
|
|
const n = Math.min(spec.labels.length, spec.values.length);
|
|
if (!n) return null;
|
|
const labels = spec.labels.slice(0, n), values = spec.values.slice(0, n);
|
|
return spec.kind === 'pie' ? <Pie labels={labels} values={values} /> : <Bars kind={spec.kind} labels={labels} values={values} unit={spec.unit} />;
|
|
}
|
|
|
|
function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; labels: string[]; values: number[]; unit?: string }) {
|
|
const H = 230, left = 38, right = 12, top = 22, bottom = 44;
|
|
const lo = Math.min(0, ...values), hi = Math.max(0, ...values);
|
|
const step = niceStep(hi - lo || 1, 5);
|
|
const y0 = Math.floor(lo / step) * step, y1 = Math.ceil(hi / step) * step || step;
|
|
const plotW = W - left - right, plotH = H - top - bottom;
|
|
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
|
|
const slot = plotW / labels.length;
|
|
const X = (i: number) => left + slot * (i + 0.5);
|
|
const barColors = seriesColors(labels, values.length);
|
|
const barW = Math.min(38, slot * 0.62);
|
|
const long = labels.some((l) => l.length > 6);
|
|
|
|
return (
|
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
|
|
{ticksBetween(y0, y1, step).map((t) => (
|
|
<g key={t}>
|
|
<line x1={left} x2={W - right} y1={Y(t)} y2={Y(t)} stroke={Math.abs(t) < 1e-9 ? SOFT : GRID} strokeWidth={Math.abs(t) < 1e-9 ? 1.5 : 1} />
|
|
<text x={left - 6} y={Y(t) + 3.5} fontSize={9.5} fill={SOFT} textAnchor="end">{fa(t)}</text>
|
|
</g>
|
|
))}
|
|
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
|
|
{kind === 'bar'
|
|
? values.map((v, i) => {
|
|
const c = barColors[i];
|
|
return (
|
|
<g key={i}>
|
|
<rect x={X(i) - barW / 2} y={Math.min(Y(v), Y(0))} width={barW} height={Math.abs(Y(v) - Y(0))} rx={4} fill={c} />
|
|
<Label x={X(i)} y={v >= 0 ? Y(v) - 5 : Y(v) + 13} color={INK} size={10.5}>{fa(v)}</Label>
|
|
</g>
|
|
);
|
|
})
|
|
: (
|
|
<>
|
|
<path d={values.map((v, i) => `${i ? 'L' : 'M'}${X(i)},${Y(v)}`).join(' ')} fill="none" stroke={COLORS.blue} strokeWidth={2.6} strokeLinejoin="round" />
|
|
{values.map((v, i) => (
|
|
<g key={i}>
|
|
<circle cx={X(i)} cy={Y(v)} r={4.2} fill={COLORS.blue} stroke="#fff" strokeWidth={1.5} />
|
|
<Label x={X(i)} y={Y(v) - 8} color={INK} size={10}>{fa(v)}</Label>
|
|
</g>
|
|
))}
|
|
</>
|
|
)}
|
|
{labels.map((l, i) => (
|
|
<text key={i} x={X(i)} y={H - bottom + 16} fontSize={long ? 9 : 10.5} fontWeight={700} fill={INK} textAnchor={long ? 'end' : 'middle'}
|
|
transform={long ? `rotate(-30 ${X(i)} ${H - bottom + 16})` : undefined} {...bidi(mathText(l))}>{mathText(l)}</text>
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
|
|
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
|
|
if (total <= 0) return null;
|
|
const sliceColors = seriesColors(labels, values.length);
|
|
const cx = 105, cy = 105, r = 88;
|
|
let a = -Math.PI / 2;
|
|
const slices = values.map((v, i) => {
|
|
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
|
|
a = a1;
|
|
return { i, frac, a0, a1, c: sliceColors[i] };
|
|
});
|
|
const H = Math.max(215, 24 + labels.length * 22);
|
|
return (
|
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
|
|
{slices.map((s) => {
|
|
if (s.frac >= 0.9999) return <circle key={s.i} cx={cx} cy={cy} r={r} fill={s.c} stroke="#fff" strokeWidth={2} />;
|
|
const large = s.a1 - s.a0 > Math.PI ? 1 : 0;
|
|
const p0 = [cx + r * Math.cos(s.a0), cy + r * Math.sin(s.a0)], p1 = [cx + r * Math.cos(s.a1), cy + r * Math.sin(s.a1)];
|
|
return <path key={s.i} d={`M${cx},${cy} L${p0.join(',')} A${r},${r} 0 ${large} 1 ${p1.join(',')} Z`} fill={s.c} stroke="#fff" strokeWidth={2} />;
|
|
})}
|
|
{slices.filter((s) => s.frac >= 0.06).map((s) => {
|
|
const m = (s.a0 + s.a1) / 2;
|
|
return (
|
|
<text key={`t${s.i}`} x={cx + r * 0.62 * Math.cos(m)} y={cy + r * 0.62 * Math.sin(m) + 4} fontSize={11} fontWeight={800} fill="#fff" textAnchor="middle">
|
|
{fa(s.frac * 100, 1)}٪
|
|
</text>
|
|
);
|
|
})}
|
|
{labels.map((l, i) => (
|
|
<g key={`l${i}`}>
|
|
<rect x={214} y={18 + i * 22} width={12} height={12} rx={3} fill={slices[i].c} />
|
|
<text x={232} y={28 + i * 22} fontSize={10.5} fontWeight={700} fill={INK} style={{ unicodeBidi: 'plaintext' }}>{`${l} (${fa(values[i])})`}</text>
|
|
</g>
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|