forked from Tadrisino/MathApp_frontend
play whole islands, make home the path itself, draw tutor diagrams, add tedi
- Home is now the island path of every unit with one clear Start button on the
current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
392
src/components/Diagram.tsx
Normal file
392
src/components/Diagram.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { memo, useMemo, type ReactNode } from 'react';
|
||||
import type { Diagram as DiagramSpec, DiagramItem, DiagramPointDef, DiagramRef } from '@/api';
|
||||
import { parseExpr } from '@/lib/mathExpr';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* شکلِ پاسخِ دستیار، از روی توصیفِ JSONی که بکاند اعتبارسنجی کرده
|
||||
* (rag/services/diagram.py). مختصات همینجا حساب میشوند — مدل فقط میگوید
|
||||
* «نقطه روی دایره با زاویهی ۳۰°» — و همهچیز عنصرِ SVGِ React است، بدونِ
|
||||
* innerHTML؛ پس متنِ مدل هیچ راهی به HTMLِ صفحه ندارد.
|
||||
*
|
||||
* هر ایرادی (نقطهی ناشناخته، عبارتِ نامعتبر) فقط همان تکه را حذف میکند؛ اگر
|
||||
* چیزی برای کشیدن نماند، هیچ شکلی نشان داده نمیشود.
|
||||
*/
|
||||
const INK = '#2b2b45';
|
||||
const MAIN = '#4C5FD5';
|
||||
const ACCENT = '#ED6956';
|
||||
const SOFT = '#9e9ec0';
|
||||
const W = 300;
|
||||
const PAD = 22;
|
||||
|
||||
type XY = [number, number];
|
||||
|
||||
function DiagramBase({ spec }: { spec: DiagramSpec }) {
|
||||
const body = useMemo(() => {
|
||||
try {
|
||||
if (spec.kind === 'numberline') return <NumberLine spec={spec} />;
|
||||
if (spec.kind === 'fraction') return <Fraction spec={spec} />;
|
||||
if (spec.kind === 'plane') return <Plane spec={spec} />;
|
||||
} catch {
|
||||
/* شکلِ خراب نباید پاسخ را خراب کند */
|
||||
}
|
||||
return null;
|
||||
}, [spec]);
|
||||
if (!body) return null;
|
||||
return (
|
||||
// dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
||||
// برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند.
|
||||
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line bg-[#fbfbff] p-2">
|
||||
{body}
|
||||
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted">{spec.title}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export const Diagram = memo(DiagramBase);
|
||||
|
||||
/* ─── صفحهی مختصات / هندسه ───────────────────────────────────────────────── */
|
||||
|
||||
function Plane({ spec }: { spec: Extract<DiagramSpec, { kind: 'plane' }> }) {
|
||||
// نقطهها: polar نسبت به مبدأ یا نقطهی from، با حلِ ارجاعِ زنجیرهای.
|
||||
const named = new Map<string, XY>();
|
||||
const resolving = new Set<string>();
|
||||
const resolveName = (name: string): XY | null => {
|
||||
if (named.has(name)) return named.get(name)!;
|
||||
const def: DiagramPointDef | undefined = spec.points[name];
|
||||
if (!def || resolving.has(name)) return null;
|
||||
resolving.add(name);
|
||||
let xy: XY | null = null;
|
||||
if (Array.isArray(def)) xy = [def[0], def[1]];
|
||||
else {
|
||||
const origin = def.from !== undefined ? ref(def.from) : [0, 0];
|
||||
if (origin) {
|
||||
const [r, deg] = def.polar;
|
||||
const t = (deg * Math.PI) / 180;
|
||||
xy = [origin[0] + r * Math.cos(t), origin[1] + r * Math.sin(t)];
|
||||
}
|
||||
}
|
||||
resolving.delete(name);
|
||||
if (xy) named.set(name, xy);
|
||||
return xy;
|
||||
};
|
||||
const ref = (r: DiagramRef): XY | null => (typeof r === 'string' ? resolveName(r) : [r[0], r[1]]);
|
||||
Object.keys(spec.points).forEach(resolveName);
|
||||
|
||||
// نمودارِ تابعها پیش از کادر، چون کادر را تعیین میکنند.
|
||||
const curves: { item: Extract<DiagramItem, { type: 'function' }>; pts: XY[] }[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'function') continue;
|
||||
try {
|
||||
const f = parseExpr(item.expr);
|
||||
const pts: XY[] = [];
|
||||
for (let i = 0; i <= 160; i++) {
|
||||
const x = item.from + ((item.to - item.from) * i) / 160;
|
||||
const y = f(x);
|
||||
if (Number.isFinite(y)) pts.push([x, y]);
|
||||
}
|
||||
if (pts.length > 1) curves.push({ item, pts });
|
||||
} catch {
|
||||
/* عبارتِ نامعتبر: همین نمودار کشیده نمیشود */
|
||||
}
|
||||
}
|
||||
|
||||
// کادر
|
||||
const xs: number[] = [], ys: number[] = [];
|
||||
const add = ([x, y]: XY) => { xs.push(x); ys.push(y); };
|
||||
named.forEach(add);
|
||||
for (const item of spec.items) {
|
||||
if (item.type === 'circle') {
|
||||
const c = ref(item.center);
|
||||
if (c) { add([c[0] - item.r, c[1] - item.r]); add([c[0] + item.r, c[1] + item.r]); }
|
||||
} else if (item.type === 'text') add(item.at);
|
||||
else if (item.type === 'segment' || item.type === 'arrow') {
|
||||
const a = ref(item.from), b = ref(item.to);
|
||||
if (a) add(a);
|
||||
if (b) add(b);
|
||||
} else if (item.type === 'polygon') item.points.forEach((p) => { const xy = ref(p); if (xy) add(xy); });
|
||||
else if (item.type === 'point') { const xy = ref(item.point); if (xy) add(xy); }
|
||||
}
|
||||
for (const { item, pts } of curves) {
|
||||
xs.push(item.from, item.to);
|
||||
const cy = pts.map((p) => p[1]).sort((a, b) => a - b);
|
||||
// دُمِ نمودار (مثلاً tan) نباید کلِ کادر را بکشد: ۵ تا ۹۵ درصد.
|
||||
ys.push(cy[Math.floor(cy.length * 0.05)], cy[Math.ceil(cy.length * 0.95) - 1]);
|
||||
}
|
||||
if (!xs.length) return null;
|
||||
if (spec.axes) { xs.push(0); ys.push(0); }
|
||||
let [x0, x1] = [Math.min(...xs), Math.max(...xs)];
|
||||
let [y0, y1] = [Math.min(...ys), Math.max(...ys)];
|
||||
const span = Math.max(x1 - x0, y1 - y0, 1e-6);
|
||||
const margin = span * 0.14 + 0.2;
|
||||
x0 -= margin; x1 += margin; y0 -= margin; y1 += margin;
|
||||
const scale = (W - 2 * PAD) / (x1 - x0);
|
||||
const height = Math.min(Math.max((y1 - y0) * scale + 2 * PAD, 150), 340);
|
||||
// اگر ارتفاع سقف خورد، مقیاس را از روی ارتفاع بگیر تا دایره بیضی نشود.
|
||||
const s = Math.min(scale, (height - 2 * PAD) / (y1 - y0));
|
||||
const ox = (W - (x1 - x0) * s) / 2, oy = (height - (y1 - y0) * s) / 2;
|
||||
const X = (x: number) => ox + (x - x0) * s;
|
||||
const Y = (y: number) => oy + (y1 - y) * s;
|
||||
const P = ([x, y]: XY): XY => [X(x), Y(y)];
|
||||
|
||||
const els: ReactNode[] = [];
|
||||
let key = 0;
|
||||
const k = () => key++;
|
||||
|
||||
if (spec.axes) {
|
||||
const ticks = niceTicks(x0, x1), yticks = niceTicks(y0, y1);
|
||||
els.push(
|
||||
<g key={k()} stroke="#d9d7ea" strokeWidth={1}>
|
||||
{ticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} strokeDasharray="2 4" />)}
|
||||
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} strokeDasharray="2 4" />)}
|
||||
</g>,
|
||||
<g key={k()} stroke={SOFT} strokeWidth={1.4} markerEnd="url(#dg-arrow-soft)">
|
||||
<line x1={X(x0)} y1={Y(0)} x2={X(x1)} y2={Y(0)} />
|
||||
<line x1={X(0)} y1={Y(y0)} x2={X(0)} y2={Y(y1)} />
|
||||
</g>,
|
||||
<g key={k()} fill={SOFT} fontSize={9} textAnchor="middle">
|
||||
{ticks.filter((t) => t !== 0).map((t) => <text key={`tx${t}`} x={X(t)} y={Y(0) + 11}>{fa(t)}</text>)}
|
||||
{yticks.filter((t) => t !== 0).map((t) => <text key={`ty${t}`} x={X(0) - 8} y={Y(t) + 3}>{fa(t)}</text>)}
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
|
||||
const centroid: XY = (() => {
|
||||
const all = [...named.values()];
|
||||
if (!all.length) return [0, 0];
|
||||
return [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length];
|
||||
})();
|
||||
|
||||
for (const item of spec.items) {
|
||||
if (item.type === 'circle') {
|
||||
const c = ref(item.center);
|
||||
if (!c) continue;
|
||||
const [cx, cy] = P(c);
|
||||
els.push(<circle key={k()} cx={cx} cy={cy} r={item.r * s} fill="none" stroke={MAIN} strokeWidth={2.2} />);
|
||||
} else if (item.type === 'segment' || item.type === 'arrow') {
|
||||
const a = ref(item.from), b = ref(item.to);
|
||||
if (!a || !b) continue;
|
||||
const [ax, ay] = P(a), [bx, by] = P(b);
|
||||
els.push(
|
||||
<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={item.type === 'arrow' ? ACCENT : INK} strokeWidth={2.2}
|
||||
strokeLinecap="round" markerEnd={item.type === 'arrow' ? 'url(#dg-arrow)' : undefined} />,
|
||||
);
|
||||
if (item.label) els.push(sideLabel(k(), [ax, ay], [bx, by], P(centroid), item.label));
|
||||
} else if (item.type === 'line') {
|
||||
const a = ref(item.through[0]), b = ref(item.through[1]);
|
||||
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) continue;
|
||||
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||
const t = (x1 - x0 + y1 - y0) * 2 / Math.hypot(dx, dy);
|
||||
const [px, py] = P([a[0] - dx * t, a[1] - dy * t]), [qx, qy] = P([a[0] + dx * t, a[1] + dy * t]);
|
||||
els.push(<line key={k()} x1={px} y1={py} x2={qx} y2={qy} stroke={MAIN} strokeWidth={1.8} />);
|
||||
} else if (item.type === 'polygon') {
|
||||
const pts = item.points.map(ref).filter((p): p is XY => p !== null).map(P);
|
||||
if (pts.length < 3) continue;
|
||||
els.push(<polygon key={k()} points={pts.map((p) => p.join(',')).join(' ')} fill="#4C5FD514" stroke={INK} strokeWidth={2.2} strokeLinejoin="round" />);
|
||||
} else if (item.type === 'angle' || item.type === 'rightAngle') {
|
||||
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
|
||||
if (!v || !a || !b) continue;
|
||||
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
|
||||
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
|
||||
let d = tb - ta;
|
||||
while (d <= -Math.PI) d += 2 * Math.PI;
|
||||
while (d > Math.PI) d -= 2 * Math.PI;
|
||||
const r = Math.min(24, Math.hypot(ax - vx, ay - vy) * 0.4, Math.hypot(bx - vx, by - vy) * 0.4);
|
||||
if (item.type === 'rightAngle') {
|
||||
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
|
||||
const q = Math.min(r, 12);
|
||||
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
|
||||
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
|
||||
els.push(<polyline key={k()} points={[p1, p2, p3].map((p) => p.join(',')).join(' ')} fill="none" stroke={ACCENT} strokeWidth={1.8} />);
|
||||
} else {
|
||||
const end: XY = [vx + r * Math.cos(ta + d), vy + r * Math.sin(ta + d)];
|
||||
const start: XY = [vx + r * Math.cos(ta), vy + r * Math.sin(ta)];
|
||||
els.push(
|
||||
<path key={k()} d={`M${vx},${vy} L${start[0]},${start[1]} A${r},${r} 0 0 ${d > 0 ? 1 : 0} ${end[0]},${end[1]} Z`}
|
||||
fill="#ED695626" stroke={ACCENT} strokeWidth={1.8} />,
|
||||
);
|
||||
}
|
||||
if (item.label) {
|
||||
const mid = ta + d / 2, lr = r + 13;
|
||||
els.push(<Label key={k()} x={vx + lr * Math.cos(mid)} y={vy + lr * Math.sin(mid) + 4} color={ACCENT}>{item.label}</Label>);
|
||||
}
|
||||
} else if (item.type === 'text') {
|
||||
const [tx, ty] = P(item.at);
|
||||
els.push(<Label key={k()} x={tx} y={ty} color={INK}>{item.text}</Label>);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { item, pts } of curves) {
|
||||
const clipped = pts.filter(([, y]) => y >= y0 - (y1 - y0) && y <= y1 + (y1 - y0));
|
||||
const d = clipped.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(' ');
|
||||
els.push(<path key={k()} d={d} fill="none" stroke={MAIN} strokeWidth={2.4} strokeLinejoin="round" />);
|
||||
if (item.label) {
|
||||
const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1];
|
||||
els.push(<Label key={k()} x={X(last[0])} y={Y(last[1]) - 8} color={MAIN}>{item.label}</Label>);
|
||||
}
|
||||
}
|
||||
|
||||
// نقطهها آخر از همه، روی بقیه
|
||||
const pointLabels = new Map<string, string>();
|
||||
const extraPoints: { xy: XY; label: string }[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'point') continue;
|
||||
if (typeof item.point === 'string') pointLabels.set(item.point, item.label ?? item.point);
|
||||
else extraPoints.push({ xy: item.point, label: item.label ?? '' });
|
||||
}
|
||||
const drawPoint = (xy: XY, label: string) => {
|
||||
const [px, py] = P(xy);
|
||||
const [cx, cy] = P(centroid);
|
||||
let [dx, dy] = [px - cx, py - cy];
|
||||
const n = Math.hypot(dx, dy) || 1;
|
||||
[dx, dy] = [(dx / n) * 13, (dy / n) * 13 || -13];
|
||||
els.push(<circle key={k()} cx={px} cy={py} r={3.6} fill={INK} />);
|
||||
if (label) els.push(<Label key={k()} x={px + dx} y={py + dy + 4} color={INK} bold>{label}</Label>);
|
||||
};
|
||||
named.forEach((xy, name) => {
|
||||
if (name.startsWith('_')) return;
|
||||
const isOriginOnAxes = spec.axes && xy[0] === 0 && xy[1] === 0 && !pointLabels.has(name);
|
||||
drawPoint(xy, isOriginOnAxes ? '' : pointLabels.get(name) ?? name);
|
||||
});
|
||||
extraPoints.forEach((p) => drawPoint(p.xy, p.label));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'شکل'}>
|
||||
<Markers />
|
||||
{els}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function NumberLine({ spec }: { spec: Extract<DiagramSpec, { kind: 'numberline' }> }) {
|
||||
const { min, max } = spec;
|
||||
const height = spec.jumps.length ? 120 : 80;
|
||||
const base = height - 38;
|
||||
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
|
||||
const step = niceStep(max - min, 12);
|
||||
const ticks: number[] = [];
|
||||
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) ticks.push(Math.round(t * 1e6) / 1e6);
|
||||
const labelled = new Set(spec.points.map((p) => p.at));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
|
||||
<Markers />
|
||||
<line x1={PAD - 12} y1={base} x2={W - PAD + 12} y2={base} stroke={INK} strokeWidth={2} markerEnd="url(#dg-arrow-ink)" markerStart="url(#dg-arrow-ink-start)" />
|
||||
{ticks.map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={X(t)} x2={X(t)} y1={base - 5} y2={base + 5} stroke={INK} strokeWidth={t === 0 ? 2 : 1.3} />
|
||||
<text x={X(t)} y={base + 19} fontSize={labelled.has(t) ? 11 : 10} fontWeight={labelled.has(t) ? 800 : 400} fill={labelled.has(t) ? INK : SOFT} textAnchor="middle">
|
||||
{fa(t)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{spec.jumps.map((j, i) => {
|
||||
const a = X(j.from), b = X(j.to);
|
||||
const lift = Math.min(46, Math.abs(b - a) * 0.5 + 14);
|
||||
return (
|
||||
<g key={`j${i}`}>
|
||||
<path d={`M${a},${base - 6} Q${(a + b) / 2},${base - 6 - lift * 1.4} ${b},${base - 6}`} fill="none" stroke={ACCENT} strokeWidth={2.2} markerEnd="url(#dg-arrow)" />
|
||||
{j.label && <Label x={(a + b) / 2} y={base - 10 - lift * 0.75} color={ACCENT}>{j.label}</Label>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{spec.points.map((p, i) => (
|
||||
<g key={`p${i}`}>
|
||||
<circle cx={X(p.at)} cy={base} r={5.5} fill={i === spec.points.length - 1 && spec.points.length > 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} />
|
||||
{p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && <Label x={X(p.at)} y={base + 33} color={INK}>{p.label}</Label>}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── کسر ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Fraction({ spec }: { spec: Extract<DiagramSpec, { kind: 'fraction' }> }) {
|
||||
const { parts, shaded } = spec;
|
||||
const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff');
|
||||
if (spec.shape === 'rect') {
|
||||
const w = (W - 2 * PAD) / parts;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 90`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'کسر'}>
|
||||
{Array.from({ length: parts }, (_, i) => (
|
||||
<rect key={i} x={W - PAD - (i + 1) * w} y={18} width={w} height={46} fill={fill(i)} stroke={INK} strokeWidth={2} />
|
||||
))}
|
||||
<text x={W / 2} y={84} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const cx = W / 2, cy = 82, r = 64;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 170`} className="mx-auto block w-full max-w-[260px]" role="img" aria-label={spec.title || 'کسر'}>
|
||||
{parts === 1 ? (
|
||||
<circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} />
|
||||
) : (
|
||||
Array.from({ length: parts }, (_, i) => {
|
||||
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
return (
|
||||
<path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`}
|
||||
fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />
|
||||
);
|
||||
})
|
||||
)}
|
||||
<text x={cx} y={164} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)} تکه</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── کمکیها ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Markers() {
|
||||
const head = (id: string, color: string, start = false) => (
|
||||
<marker id={id} viewBox="0 0 10 10" refX={start ? 2 : 8} refY="5" markerWidth="7" markerHeight="7" orient={start ? 'auto-start-reverse' : 'auto'}>
|
||||
<path d="M0,0 L10,5 L0,10 z" fill={color} />
|
||||
</marker>
|
||||
);
|
||||
return (
|
||||
<defs>
|
||||
{head('dg-arrow', ACCENT)}
|
||||
{head('dg-arrow-soft', SOFT)}
|
||||
{head('dg-arrow-ink', INK)}
|
||||
{head('dg-arrow-ink-start', INK, true)}
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<text x={x} y={y} fontSize={11} fontWeight={bold ? 800 : 700} fill={color} textAnchor="middle"
|
||||
stroke="#fbfbff" strokeWidth={3} paintOrder="stroke" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** برچسبِ ضلع، وسطِ پارهخط و به سمتِ بیرونِ شکل. */
|
||||
function sideLabel(key: number, a: XY, b: XY, center: XY, text: string) {
|
||||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||||
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
|
||||
const n = Math.hypot(nx, ny) || 1;
|
||||
nx /= n; ny /= n;
|
||||
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
|
||||
return <Label key={key} x={mx + nx * 13} y={my + ny * 13 + 4} color={MAIN}>{text}</Label>;
|
||||
}
|
||||
|
||||
function niceStep(span: number, maxTicks: number): number {
|
||||
const raw = span / maxTicks;
|
||||
const pow = 10 ** Math.floor(Math.log10(raw || 1));
|
||||
const m = raw / pow;
|
||||
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * pow;
|
||||
}
|
||||
|
||||
function niceTicks(lo: number, hi: number): number[] {
|
||||
const step = niceStep(hi - lo, 8);
|
||||
const out: number[] = [];
|
||||
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) out.push(Math.round(t * 1e6) / 1e6);
|
||||
return out;
|
||||
}
|
||||
|
||||
const fa = (n: number) => toFa(String(n).replace('-', '−'));
|
||||
107
src/components/IslandPath.tsx
Normal file
107
src/components/IslandPath.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, Lock, Play, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { shade } from '@/lib/color';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
const SPACING = 128;
|
||||
const TOP = 70;
|
||||
|
||||
/**
|
||||
* مسیرِ جزیرههای یک فصل، مثلِ دولینگو. جزیرهی فعلی یک دکمهی «شروع» بالای
|
||||
* سرش دارد که مستقیم آزمون را باز میکند؛ زدنِ خودِ جزیره برگهی جزئیات را.
|
||||
*
|
||||
* `currentRef` به جزیرهی فعلی وصل میشود تا خانه بتواند تا آنجا اسکرول کند.
|
||||
*/
|
||||
export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
nodes: HomeNode[];
|
||||
color: string;
|
||||
/** جزیرهای که باید «شروع» بخورد؛ null یعنی در این فصل نیست. */
|
||||
currentId: string | null;
|
||||
onOpen: (node: HomeNode) => void;
|
||||
onStart: (node: HomeNode) => void;
|
||||
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
|
||||
const points = zigzagPositions(nodes.length, SPACING, TOP);
|
||||
const height = points.length ? points[points.length - 1].y + 80 : 120;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
|
||||
{nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
const current = node.id === currentId;
|
||||
const Icon = iconFor(node.icon, i);
|
||||
return (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
ref={current ? currentRef : undefined}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => onStart(node)}
|
||||
animate={{ y: [0, -5, 0] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute -top-[62px] left-1/2 z-20 flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-black text-white"
|
||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
|
||||
>
|
||||
<Play size={14} fill="white" /> {node.status === 'in_progress' ? 'ادامه' : 'شروع'}
|
||||
<span className="absolute -bottom-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45" style={{ backgroundColor: color }} />
|
||||
</motion.button>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.06, 0.3] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity }}
|
||||
className="absolute inset-[-12px] rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمامشده)' : ''}`}
|
||||
onClick={() => onOpen(node)}
|
||||
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
|
||||
style={{
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
backgroundColor: locked ? '#e7e6ef' : done ? color : '#fff',
|
||||
border: current ? `5px solid ${color}` : 'none',
|
||||
boxShadow: locked ? '0 6px 0 #cfcedb' : done ? `0 7px 0 ${shade(color, -0.25)}` : `0 7px 0 ${shade(color, -0.25)}`,
|
||||
}}
|
||||
>
|
||||
{locked ? (
|
||||
<Lock size={24} strokeWidth={2.5} className="text-[#a9a8b8]" />
|
||||
) : done ? (
|
||||
<Check size={32} color="white" strokeWidth={3.5} />
|
||||
) : (
|
||||
<Icon size={30} style={{ color }} strokeWidth={2.5} />
|
||||
)}
|
||||
{done && (
|
||||
<Star size={16} className="absolute -right-1 -top-1 text-[#FFC53D]" fill="#FFC53D" />
|
||||
)}
|
||||
</button>
|
||||
{/* اسم کنارِ جزیره، نه زیرش: فضای بینِ دو جزیره مالِ دکمهی «شروع» است
|
||||
و اسمِ زیرِ جزیرهی قبلی زیرِ آن دکمه پنهان میشد. سمتی که جای بیشتری دارد. */}
|
||||
<div
|
||||
className={`absolute top-1/2 w-28 -translate-y-1/2 text-[11px] font-bold leading-4 ${
|
||||
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left'
|
||||
} ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
|
||||
>
|
||||
<span className="line-clamp-2">{node.title}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
108
src/components/IslandSheet.tsx
Normal file
108
src/components/IslandSheet.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { BookOpen, CheckCircle2, ChevronDown, Circle, Lock, Play, RotateCw } from 'lucide-react';
|
||||
import { api, type HomeNode } from '@/api';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { ErrorState, PrimaryButton } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* برگهی جزیره. کارِ اصلی یک دکمه است: «شروعِ آزمونِ جزیره». فهرستِ درسها
|
||||
* (معرفیِ هر نوع مسئله) زیرِ آن و بسته است، برای بچهای که اول میخواهد بخواند.
|
||||
*/
|
||||
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||
const nav = useNav();
|
||||
const [showLessons, setShowLessons] = useState(false);
|
||||
const lessons = useAsync(
|
||||
() => (node && showLessons ? api.nodeLessons(node.id) : Promise.resolve(null)),
|
||||
[node?.id, showLessons],
|
||||
);
|
||||
const locked = node?.status === 'locked';
|
||||
const done = node?.status === 'completed';
|
||||
|
||||
const close = () => {
|
||||
setShowLessons(false);
|
||||
onClose();
|
||||
};
|
||||
const start = () => {
|
||||
if (!node) return;
|
||||
close();
|
||||
nav.push({ name: 'quiz', nodeId: node.id });
|
||||
};
|
||||
const openLesson = (lessonId: number) => {
|
||||
if (!node) return;
|
||||
close();
|
||||
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||
};
|
||||
|
||||
// فقط درسهایی که سوال دارند در lessonIds هستند.
|
||||
const playable = new Set(node?.lessonIds ?? []);
|
||||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onClose={close} title={node?.title}>
|
||||
{locked ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm leading-7 text-body">
|
||||
{done
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
||||
: 'چند سوالِ کوتاه از همین جزیره. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={color} onClick={start}>
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
||||
</PrimaryButton>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLessons((s) => !s)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 py-2 text-sm font-bold text-muted"
|
||||
aria-expanded={showLessons}
|
||||
>
|
||||
<BookOpen size={15} /> اول درسهاش رو بخونم
|
||||
<ChevronDown size={15} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showLessons && (
|
||||
lessons.loading ? (
|
||||
<div className="py-4 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||
) : lessons.error ? (
|
||||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 mt-1 text-xs text-muted">
|
||||
{toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده
|
||||
</p>
|
||||
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto">
|
||||
{list.map((lesson) => (
|
||||
<li key={lesson.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson(Number(lesson.id))}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-line bg-white px-3 py-2.5 text-right text-sm"
|
||||
>
|
||||
{lesson.done ? (
|
||||
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||
) : (
|
||||
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { shade } from '@/lib/color';
|
||||
|
||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -28,7 +29,7 @@ export function ScreenHeader({
|
||||
return (
|
||||
<header
|
||||
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
||||
@@ -85,8 +86,8 @@ export function PrimaryButton({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`}
|
||||
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-[transform,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
|
||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Spinner /> : children}
|
||||
@@ -98,7 +99,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 disabled:opacity-50 ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user