forked from Tadrisino/MathApp_frontend
Compare commits
11 Commits
3a0192518c
...
f0b7a5e28f
| Author | SHA1 | Date | |
|---|---|---|---|
| f0b7a5e28f | |||
| 24a6f7c570 | |||
| 2e7e5823cc | |||
| 09f6b78198 | |||
| 22bdb4d39f | |||
| f214abff3c | |||
| 9a6ef69480 | |||
| 12c3721d92 | |||
| 782998e325 | |||
| 2ac783d222 | |||
| 941e8245f1 |
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#ED6956" />
|
<meta name="theme-color" content="#ED6956" />
|
||||||
<meta name="description" content="تدریسینو — یادگیری ریاضی پایه سوم تا دوازدهم، قدمبهقدم و با دستیار هوشمند." />
|
<meta name="description" content="تدریسینو ریاضی — یادگیری ریاضی پایه سوم تا دوازدهم، قدمبهقدم و با دستیار هوشمند." />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>تدریسینو</title>
|
<title>تدریسینو ریاضی</title>
|
||||||
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار میشود. -->
|
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار میشود. -->
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<!-- اسپلشِ ثابت: همینجا در HTML است تا از اولین فریم دیده شود (نه صفحهی سفید تا بار شدنِ JS)؛
|
<!-- اسپلشِ ثابت: همینجا در HTML است تا از اولین فریم دیده شود (نه صفحهی سفید تا بار شدنِ JS)؛
|
||||||
@@ -30,12 +30,12 @@
|
|||||||
.in-app .boot-splash > * { visibility: hidden; }
|
.in-app .boot-splash > * { visibility: hidden; }
|
||||||
.in-old-app .boot-splash span { display: none; }
|
.in-old-app .boot-splash span { display: none; }
|
||||||
.in-old-app .boot-splash img { width: 160px; height: 160px; }
|
.in-old-app .boot-splash img { width: 160px; height: 160px; }
|
||||||
.boot-splash span { font: 900 34px/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; }
|
.boot-splash span { font: 900 min(34px, 8.5vw)/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; white-space: nowrap; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">
|
<div id="root">
|
||||||
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو</span></div>
|
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو ریاضی</span></div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
2
pay.html
2
pay.html
@@ -6,7 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<!-- Referer باید از دامنهی خودمان برود؛ زرینپال بیآن صفحه را باز نمیکند (src/pay.ts). -->
|
<!-- Referer باید از دامنهی خودمان برود؛ زرینپال بیآن صفحه را باز نمیکند (src/pay.ts). -->
|
||||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||||
<title>رفتن به درگاه — تدریسینو</title>
|
<title>رفتن به درگاه — تدریسینو ریاضی</title>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f6f5fb; color: #1a1a2e; font-family: Vazirmatn, system-ui, sans-serif; }
|
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f6f5fb; color: #1a1a2e; font-family: Vazirmatn, system-ui, sans-serif; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function BrandFooter() {
|
|||||||
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
||||||
<BrandMark size={24} tone="dark" />
|
<BrandMark size={24} tone="dark" />
|
||||||
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتاب درسی</span>
|
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتاب درسی</span>
|
||||||
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همه حقوق محفوظ است</span>
|
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو ریاضی · همه حقوق محفوظ است</span>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function BrandMark({ size = 28, tone = 'light', withName = true }: { size
|
|||||||
const c = useCompanion();
|
const c = useCompanion();
|
||||||
const gid = useId();
|
const gid = useId();
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
|
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو ریاضی">
|
||||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
|
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
|
||||||
@@ -25,7 +25,7 @@ export function BrandMark({ size = 28, tone = 'light', withName = true }: { size
|
|||||||
</text>
|
</text>
|
||||||
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
|
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
|
||||||
</svg>
|
</svg>
|
||||||
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}
|
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو ریاضی</span>}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ChartSpec } from './types';
|
import type { ChartSpec } from './types';
|
||||||
import { COLORS, GRID, INK, Label, SERIES, SOFT, bidi, fa, mathText, niceStep, ticksBetween } from './svg';
|
import { COLORS, GRID, INK, Label, SOFT, bidi, fa, mathText, niceStep, seriesColors, ticksBetween } from './svg';
|
||||||
|
|
||||||
const W = 330;
|
const W = 330;
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
|||||||
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
|
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
|
||||||
const slot = plotW / labels.length;
|
const slot = plotW / labels.length;
|
||||||
const X = (i: number) => left + slot * (i + 0.5);
|
const X = (i: number) => left + slot * (i + 0.5);
|
||||||
|
const barColors = seriesColors(labels, values.length);
|
||||||
const barW = Math.min(38, slot * 0.62);
|
const barW = Math.min(38, slot * 0.62);
|
||||||
const long = labels.some((l) => l.length > 6);
|
const long = labels.some((l) => l.length > 6);
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
|||||||
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
|
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
|
||||||
{kind === 'bar'
|
{kind === 'bar'
|
||||||
? values.map((v, i) => {
|
? values.map((v, i) => {
|
||||||
const c = COLORS[SERIES[i % SERIES.length]];
|
const c = barColors[i];
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<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} />
|
<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} />
|
||||||
@@ -64,12 +65,13 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
|||||||
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
|
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
|
||||||
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
|
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
|
||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
|
const sliceColors = seriesColors(labels, values.length);
|
||||||
const cx = 105, cy = 105, r = 88;
|
const cx = 105, cy = 105, r = 88;
|
||||||
let a = -Math.PI / 2;
|
let a = -Math.PI / 2;
|
||||||
const slices = values.map((v, i) => {
|
const slices = values.map((v, i) => {
|
||||||
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
|
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
|
||||||
a = a1;
|
a = a1;
|
||||||
return { i, frac, a0, a1, c: COLORS[SERIES[i % SERIES.length]] };
|
return { i, frac, a0, a1, c: sliceColors[i] };
|
||||||
});
|
});
|
||||||
const H = Math.max(215, 24 + labels.length * 22);
|
const H = Math.max(215, 24 + labels.length * 22);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import type { DiagramSpec } from './types';
|
|||||||
import { Plane } from './plane';
|
import { Plane } from './plane';
|
||||||
import { NumberLine } from './numberline';
|
import { NumberLine } from './numberline';
|
||||||
import { Solid } from './solid';
|
import { Solid } from './solid';
|
||||||
|
import { Space } from './space';
|
||||||
import { Chart } from './charts';
|
import { Chart } from './charts';
|
||||||
import { Mapping, Tree, Venn } from './sets';
|
import { Mapping, Tree, Venn } from './sets';
|
||||||
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
|
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
|
||||||
import { PAPER } from './svg';
|
import { HideCounts, PAPER } from './svg';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
export type { DiagramSpec } from './types';
|
export type { DiagramSpec } from './types';
|
||||||
@@ -23,6 +24,7 @@ function Body({ spec }: { spec: DiagramSpec }): ReactNode {
|
|||||||
case 'plane': return <Plane spec={spec} />;
|
case 'plane': return <Plane spec={spec} />;
|
||||||
case 'numberline': return <NumberLine spec={spec} />;
|
case 'numberline': return <NumberLine spec={spec} />;
|
||||||
case 'solid': return <Solid spec={spec} />;
|
case 'solid': return <Solid spec={spec} />;
|
||||||
|
case 'space': return <Space spec={spec} />;
|
||||||
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
|
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
|
||||||
case 'venn': return <Venn spec={spec} />;
|
case 'venn': return <Venn spec={spec} />;
|
||||||
case 'mapping': return <Mapping spec={spec} />;
|
case 'mapping': return <Mapping spec={spec} />;
|
||||||
@@ -43,13 +45,13 @@ class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
|
|||||||
render() { return this.state.broken ? null : this.props.children; }
|
render() { return this.state.broken ? null : this.props.children; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiagramBase({ spec }: { spec: DiagramSpec }) {
|
function DiagramBase({ spec, hideCounts = false }: { spec: DiagramSpec; hideCounts?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Guard>
|
<Guard>
|
||||||
{/* dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
{/* dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
||||||
برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند. */}
|
برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند. */}
|
||||||
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line p-2" style={{ backgroundColor: PAPER }}>
|
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line p-2" style={{ backgroundColor: PAPER }}>
|
||||||
<Body spec={spec} />
|
<HideCounts.Provider value={hideCounts}><Body spec={spec} /></HideCounts.Provider>
|
||||||
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
|
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
|
||||||
</figure>
|
</figure>
|
||||||
</Guard>
|
</Guard>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
|
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
|
||||||
import { COLORS, INK, bidi, color, mathText } from './svg';
|
import { useContext } from 'react';
|
||||||
|
import { COLORS, HideCounts, INK, bidi, color, mathText } from './svg';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
|
||||||
const W = 320;
|
const W = 320;
|
||||||
@@ -8,11 +9,12 @@ const SHADE = COLORS.yellow;
|
|||||||
/* ─── کسر: دایره یا نوارِ تکهتکه ─────────────────────────────────────────── */
|
/* ─── کسر: دایره یا نوارِ تکهتکه ─────────────────────────────────────────── */
|
||||||
|
|
||||||
export function Fraction({ spec }: { spec: FractionSpec }) {
|
export function Fraction({ spec }: { spec: FractionSpec }) {
|
||||||
if (spec.compare?.length) return <FractionCompare items={spec.compare} />;
|
const hide = useContext(HideCounts);
|
||||||
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} />;
|
if (spec.compare?.length) return <FractionCompare items={spec.compare} hide={hide} />;
|
||||||
|
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} hide={hide} />;
|
||||||
const { parts, shaded } = spec;
|
const { parts, shaded } = spec;
|
||||||
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
|
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
|
||||||
const caption = <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
|
const caption = hide ? null : <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
|
||||||
if (spec.shape === 'rect') {
|
if (spec.shape === 'rect') {
|
||||||
const w = (W - 40) / parts;
|
const w = (W - 40) / parts;
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +38,7 @@ export function Fraction({ spec }: { spec: FractionSpec }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** چند کسر روی نوارهای هماندازه، برای مقایسه (یکپنجم و نُهدهم). */
|
/** چند کسر روی نوارهای هماندازه، برای مقایسه (یکپنجم و نُهدهم). */
|
||||||
function FractionCompare({ items }: { items: { parts: number; shaded: number; label?: string }[] }) {
|
function FractionCompare({ items, hide }: { items: { parts: number; shaded: number; label?: string }[]; hide: boolean }) {
|
||||||
const barW = W - 110, h = 30, gap = 16;
|
const barW = W - 110, h = 30, gap = 16;
|
||||||
const H = items.length * (h + gap) + 8;
|
const H = items.length * (h + gap) + 8;
|
||||||
const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple];
|
const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple];
|
||||||
@@ -50,7 +52,7 @@ function FractionCompare({ items }: { items: { parts: number; shaded: number; la
|
|||||||
<rect key={i} x={W - 12 - (i + 1) * w} y={y} width={w} height={h} fill={i < f.shaded ? cols[r % cols.length] : '#fff'} stroke={INK} strokeWidth={1.6} />
|
<rect key={i} x={W - 12 - (i + 1) * w} y={y} width={w} height={h} fill={i < f.shaded ? cols[r % cols.length] : '#fff'} stroke={INK} strokeWidth={1.6} />
|
||||||
))}
|
))}
|
||||||
<text x={W - 20 - barW} y={y + h / 2 + 4} fontSize={11.5} fontWeight={800} fill={INK} textAnchor="end" style={{ unicodeBidi: 'plaintext' }}>
|
<text x={W - 20 - barW} y={y + h / 2 + 4} fontSize={11.5} fontWeight={800} fill={INK} textAnchor="end" style={{ unicodeBidi: 'plaintext' }}>
|
||||||
{f.label ?? `${toFa(f.shaded)} از ${toFa(f.parts)}`}
|
{f.label ?? (hide ? '' : `${toFa(f.shaded)} از ${toFa(f.parts)}`)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
@@ -60,7 +62,7 @@ function FractionCompare({ items }: { items: { parts: number; shaded: number; la
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفتدهم). */
|
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفتدهم). */
|
||||||
function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect' }) {
|
function MixedNumber({ wholes, parts, shaded, shape, hide }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect'; hide: boolean }) {
|
||||||
const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52;
|
const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52;
|
||||||
const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
|
const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
|
||||||
const slice = (x: number, i: number, full: boolean) => {
|
const slice = (x: number, i: number, full: boolean) => {
|
||||||
@@ -73,9 +75,9 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
|
|||||||
? <rect key={w} x={cx(w) - r} y={cy - r} width={2 * r} height={2 * r} fill={SHADE} stroke={INK} strokeWidth={2} />
|
? <rect key={w} x={cx(w) - r} y={cy - r} width={2 * r} height={2 * r} fill={SHADE} stroke={INK} strokeWidth={2} />
|
||||||
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
|
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
|
||||||
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
|
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
|
||||||
<text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
{!hide && <text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||||
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
||||||
</text>
|
</text>}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,7 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
|
|||||||
/* ─── شبکهی خانهها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
|
/* ─── شبکهی خانهها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
|
||||||
|
|
||||||
export function Grid({ spec }: { spec: GridSpec }) {
|
export function Grid({ spec }: { spec: GridSpec }) {
|
||||||
|
const hide = useContext(HideCounts);
|
||||||
const rows = spec.rows, cols = spec.cols;
|
const rows = spec.rows, cols = spec.cols;
|
||||||
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
|
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
|
||||||
const gw = cols * cell, gh = rows * cell;
|
const gw = cols * cell, gh = rows * cell;
|
||||||
@@ -100,9 +103,9 @@ export function Grid({ spec }: { spec: GridSpec }) {
|
|||||||
<rect key={`${r}-${c}`} x={x0 + c * cell} y={y0 + r * cell} width={cell} height={cell} fill={on.has(`${r},${c}`) ? col : '#fff'} stroke="#B8BCCD" strokeWidth={1} />
|
<rect key={`${r}-${c}`} x={x0 + c * cell} y={y0 + r * cell} width={cell} height={cell} fill={on.has(`${r},${c}`) ? col : '#fff'} stroke="#B8BCCD" strokeWidth={1} />
|
||||||
)))}
|
)))}
|
||||||
<rect x={x0} y={y0} width={gw} height={gh} fill="none" stroke={INK} strokeWidth={2} />
|
<rect x={x0} y={y0} width={gw} height={gh} fill="none" stroke={INK} strokeWidth={2} />
|
||||||
<text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
{!hide && <text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||||
{`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`}
|
{`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`}
|
||||||
</text>
|
</text>}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,7 @@ export function Grid({ spec }: { spec: GridSpec }) {
|
|||||||
/* ─── گروهها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
|
/* ─── گروهها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
|
||||||
|
|
||||||
export function Groups({ spec }: { spec: GroupsSpec }) {
|
export function Groups({ spec }: { spec: GroupsSpec }) {
|
||||||
|
const hide = useContext(HideCounts);
|
||||||
const g = spec.groups, each = spec.each;
|
const g = spec.groups, each = spec.each;
|
||||||
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
|
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
|
||||||
const rowsG = Math.ceil(g / perRow);
|
const rowsG = Math.ceil(g / perRow);
|
||||||
@@ -141,9 +145,9 @@ export function Groups({ spec }: { spec: GroupsSpec }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
<text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
|
{(spec.label != null || !hide) && <text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
|
||||||
{mathText(spec.label ?? `${g} × ${each} = ${g * each}`)}
|
{mathText(spec.label ?? `${g} × ${each} = ${g * each}`)}
|
||||||
</text>
|
</text>}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,6 +237,7 @@ function patternCells(layout: PatternSpec['layout'], n: number): [number, number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Pattern({ spec }: { spec: PatternSpec }) {
|
export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||||
|
const hide = useContext(HideCounts);
|
||||||
const stages = spec.stages;
|
const stages = spec.stages;
|
||||||
const sticks = spec.unit === 'stick';
|
const sticks = spec.unit === 'stick';
|
||||||
const sizes = stages.map((n) => {
|
const sizes = stages.map((n) => {
|
||||||
@@ -269,7 +274,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
|
|||||||
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
|
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
|
||||||
})}
|
})}
|
||||||
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
|
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
|
||||||
{spec.counts !== false && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
|
{spec.counts !== false && !hide && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
125
src/diagram/space.tsx
Normal file
125
src/diagram/space.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SpaceSpec } from './types';
|
||||||
|
import { COLORS, INK, Label, SOFT, color, mathText } from './svg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فضای سهبعدی با تصویرِ مایلِ ثابت: x رو به بیننده (پایینچپ)، y راست، z بالا. چرخش و تعامل ندارد.
|
||||||
|
* هر نقطه (x, y, z) به صفحه میرود با sx = y − 0.46·x ، sy = z − 0.36·x و کلِ شکل برای جا شدن در
|
||||||
|
* کادر مقیاس میشود. جهتِ x کوتاهشده است (نسبتِ ۰٫۵۸) تا طولِ ظاهری بزرگتر از واقعی دیده نشود.
|
||||||
|
*/
|
||||||
|
type XY = [number, number];
|
||||||
|
type P3 = [number, number, number];
|
||||||
|
const W = 320;
|
||||||
|
const H = 270;
|
||||||
|
const PAD = 34;
|
||||||
|
const proj = ([x, y, z]: P3): XY => [y - 0.46 * x, z - 0.36 * x];
|
||||||
|
|
||||||
|
export function Space({ spec }: { spec: SpaceSpec }) {
|
||||||
|
const pts: Record<string, P3> = { O: [0, 0, 0], ...spec.points };
|
||||||
|
const items = spec.items ?? [];
|
||||||
|
const get = (n: string): P3 | undefined => pts[n];
|
||||||
|
|
||||||
|
// کرانِ شکل: همهی نقطهها، جعبهها و محورها (محور تا کمی بیشتر از بزرگترین مختصات)
|
||||||
|
const all: P3[] = Object.values(pts);
|
||||||
|
for (const it of items) if (it.type === 'box') all.push(it.to);
|
||||||
|
const ext = (k: 0 | 1 | 2) => Math.max(1, ...all.map((p) => Math.abs(p[k])));
|
||||||
|
const ax: P3 = [ext(0) * 1.15, ext(1) * 1.15, ext(2) * 1.15];
|
||||||
|
const corners: P3[] = [...all];
|
||||||
|
if (spec.axes !== false) corners.push([ax[0], 0, 0], [0, ax[1], 0], [0, 0, ax[2]]);
|
||||||
|
const xs = corners.map((c) => proj(c)[0]), ys = corners.map((c) => proj(c)[1]);
|
||||||
|
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
||||||
|
const s = Math.min((W - 2 * PAD) / Math.max(x1 - x0, 1e-6), (H - 2 * PAD) / Math.max(y1 - y0, 1e-6));
|
||||||
|
const ox = (W - (x1 - x0) * s) / 2 - x0 * s;
|
||||||
|
const oy = (H - (y1 - y0) * s) / 2 + y1 * s;
|
||||||
|
const T = (p: P3): XY => {
|
||||||
|
const [u, v] = proj(p);
|
||||||
|
return [ox + u * s, oy - v * s];
|
||||||
|
};
|
||||||
|
|
||||||
|
const els: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
const k = () => key++;
|
||||||
|
const line = (a: P3, b: P3, col: string, w = 1.8, dash?: string, arrow = false) => {
|
||||||
|
const A = T(a), B = T(b);
|
||||||
|
els.push(<line key={k()} x1={A[0]} y1={A[1]} x2={B[0]} y2={B[1]} stroke={col} strokeWidth={w} strokeDasharray={dash} strokeLinecap="round" />);
|
||||||
|
if (arrow) {
|
||||||
|
const ang = Math.atan2(B[1] - A[1], B[0] - A[0]);
|
||||||
|
const h = 9, a1 = ang + Math.PI - 0.38, a2 = ang + Math.PI + 0.38;
|
||||||
|
els.push(<path key={k()} d={`M${B[0]},${B[1]} L${B[0] + h * Math.cos(a1)},${B[1] + h * Math.sin(a1)} L${B[0] + h * Math.cos(a2)},${B[1] + h * Math.sin(a2)} Z`} fill={col} />);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const text = (p: XY, t: string | undefined, col = INK, dx = 8, dy = -7) => {
|
||||||
|
if (t) els.push(<Label key={k()} x={p[0] + dx} y={p[1] + dy} color={col} size={12}>{mathText(t)}</Label>);
|
||||||
|
};
|
||||||
|
|
||||||
|
// صفحههای مختصات بهصورت شبکهی کمرنگ پشت همه چیز
|
||||||
|
if (spec.axes !== false) {
|
||||||
|
const mk = (a: P3, b: P3, c: P3, d: P3) => {
|
||||||
|
const q = [a, b, c, d].map(T);
|
||||||
|
els.push(<path key={k()} d={`M${q.map((p) => p.join(',')).join(' L')} Z`} fill={`${COLORS.blue}0c`} stroke="none" />);
|
||||||
|
};
|
||||||
|
mk([0, 0, 0], [0, ax[1], 0], [ax[0], ax[1], 0], [ax[0], 0, 0]);
|
||||||
|
line([0, 0, 0], [ax[0], 0, 0], SOFT, 1.6, undefined, true);
|
||||||
|
line([0, 0, 0], [0, ax[1], 0], SOFT, 1.6, undefined, true);
|
||||||
|
line([0, 0, 0], [0, 0, ax[2]], SOFT, 1.6, undefined, true);
|
||||||
|
text(T([ax[0], 0, 0]), 'x', SOFT, -10, 12);
|
||||||
|
text(T([0, ax[1], 0]), 'y', SOFT, 8, 4);
|
||||||
|
text(T([0, 0, ax[2]]), 'z', SOFT, 8, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// چندضلعیها اول (نیمهشفاف)، بعد جعبهها، پارهخطها، نقطهها
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type !== 'polygon') continue;
|
||||||
|
const ps = it.points.map(get).filter((p): p is P3 => !!p);
|
||||||
|
if (ps.length < 3) continue;
|
||||||
|
const q = ps.map(T);
|
||||||
|
const c = color(it.color, COLORS.blue);
|
||||||
|
els.push(<path key={k()} d={`M${q.map((p) => p.join(',')).join(' L')} Z`} fill={`${c}26`} stroke={c} strokeWidth={1.8} strokeLinejoin="round" />);
|
||||||
|
text([q.reduce((a, p) => a + p[0], 0) / q.length, q.reduce((a, p) => a + p[1], 0) / q.length], it.label, c, 0, 4);
|
||||||
|
}
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type !== 'box') continue;
|
||||||
|
const [a, b, c] = it.to;
|
||||||
|
const col = color(it.color, COLORS.teal);
|
||||||
|
const v = (i: number, j: number, l: number): P3 => [i * a, j * b, l * c];
|
||||||
|
const hidden: [P3, P3][] = [[v(0, 0, 0), v(1, 0, 0)], [v(0, 0, 0), v(0, 1, 0)], [v(0, 0, 0), v(0, 0, 1)]];
|
||||||
|
const shown: [P3, P3][] = [
|
||||||
|
[v(1, 0, 0), v(1, 1, 0)], [v(0, 1, 0), v(1, 1, 0)],
|
||||||
|
[v(1, 0, 0), v(1, 0, 1)], [v(0, 1, 0), v(0, 1, 1)], [v(1, 1, 0), v(1, 1, 1)],
|
||||||
|
[v(0, 0, 1), v(1, 0, 1)], [v(0, 0, 1), v(0, 1, 1)], [v(1, 0, 1), v(1, 1, 1)], [v(0, 1, 1), v(1, 1, 1)],
|
||||||
|
];
|
||||||
|
hidden.forEach(([p, q]) => line(p, q, col, 1.3, '4 3'));
|
||||||
|
shown.forEach(([p, q]) => line(p, q, col, 1.8));
|
||||||
|
}
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type === 'segment' || it.type === 'vector') {
|
||||||
|
const a = get(it.from), b = get(it.to);
|
||||||
|
if (!a || !b) continue;
|
||||||
|
const col = color(it.color, it.type === 'vector' ? COLORS.blue : INK);
|
||||||
|
line(a, b, col, 2.2, it.dashed ? '5 4' : undefined, it.type === 'vector');
|
||||||
|
const A = T(a), B = T(b);
|
||||||
|
text([(A[0] + B[0]) / 2, (A[1] + B[1]) / 2], it.label, col, 8, -6);
|
||||||
|
} else if (it.type === 'drop') {
|
||||||
|
const p = get(it.point);
|
||||||
|
if (!p) continue;
|
||||||
|
const col = color(it.color, SOFT);
|
||||||
|
line(p, [p[0], p[1], 0], col, 1.2, '3 3');
|
||||||
|
line([p[0], p[1], 0], [p[0], 0, 0], col, 1.2, '3 3');
|
||||||
|
line([p[0], p[1], 0], [0, p[1], 0], col, 1.2, '3 3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type !== 'point') continue;
|
||||||
|
const p = get(it.point);
|
||||||
|
if (!p) continue;
|
||||||
|
const q = T(p), col = color(it.color, INK);
|
||||||
|
els.push(<circle key={k()} cx={q[0]} cy={q[1]} r={3.6} fill={col} />);
|
||||||
|
text(q, it.label ?? it.point, col, 8, -6);
|
||||||
|
}
|
||||||
|
// مبدأ
|
||||||
|
const o = T([0, 0, 0]);
|
||||||
|
els.push(<circle key={k()} cx={o[0]} cy={o[1]} r={2.6} fill={SOFT} />);
|
||||||
|
if (!items.some((i) => i.type === 'point' && i.point === 'O')) text(o, 'O', SOFT, -9, 13);
|
||||||
|
|
||||||
|
return <svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="شکلِ سهبعدی">{els}</svg>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { createContext, type ReactNode } from 'react';
|
||||||
import type { ColorName } from './types';
|
import type { ColorName } from './types';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
|
||||||
@@ -25,6 +25,21 @@ export const COLORS: Record<ColorName, string> = {
|
|||||||
/** ترتیبِ رنگِ سریها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
|
/** ترتیبِ رنگِ سریها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
|
||||||
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
|
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
|
||||||
|
|
||||||
|
/** رنگِ هر ستون یا برشِ نمودار: اگر برچسب نامِ یک رنگ باشد («قرمز»، «مهرهی آبی (۳)») همان رنگ، وگرنه رنگهای
|
||||||
|
* پیشفرض به ترتیب، بدون رنگهایی که برچسبی به نامشان هست؛ تا «قرمز» در چرخنده یا جدولِ فراوانی آبی کشیده نشود. */
|
||||||
|
const COLOR_WORDS: [string, ColorName][] = [
|
||||||
|
['قرمز', 'red'], ['سرخ', 'red'], ['آبی', 'blue'], ['سبز', 'green'], ['نارنجی', 'orange'], ['بنفش', 'purple'],
|
||||||
|
['صورتی', 'pink'], ['زرد', 'yellow'], ['خاکستری', 'gray'], ['مشکی', 'black'], ['سیاه', 'black'],
|
||||||
|
];
|
||||||
|
const namedColor = (label: string): ColorName | undefined =>
|
||||||
|
COLOR_WORDS.find(([w]) => new RegExp(`(^|[\\s(«])${w}($|[\\s)»])`).test(label))?.[1];
|
||||||
|
export function seriesColors(labels: string[], n: number): string[] {
|
||||||
|
const named = Array.from({ length: n }, (_, i) => namedColor(labels[i] ?? ''));
|
||||||
|
const free = SERIES.filter((c) => !named.includes(c));
|
||||||
|
let k = 0;
|
||||||
|
return named.map((c) => COLORS[c ?? free[k++ % free.length]]);
|
||||||
|
}
|
||||||
|
|
||||||
export const INK = COLORS.black;
|
export const INK = COLORS.black;
|
||||||
export const SOFT = '#9EA3B8';
|
export const SOFT = '#9EA3B8';
|
||||||
export const GRID = '#E4E6F0';
|
export const GRID = '#E4E6F0';
|
||||||
@@ -141,3 +156,10 @@ export function piLabel(t: number): string {
|
|||||||
const head = n === 1 ? 'π' : `${toFa(n)}π`;
|
const head = n === 1 ? 'π' : `${toFa(n)}π`;
|
||||||
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
|
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
|
||||||
|
* جوابِ سوال را لو میداد؛ آزمون این را روشن میکند و شکل فقط خودِ داده را نشان میدهد.
|
||||||
|
* در درس و جوابِ دستیار شمارش میماند چون آنجا توضیح است، نه سوال.
|
||||||
|
*/
|
||||||
|
export const HideCounts = createContext(false);
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ export interface SolidSpec {
|
|||||||
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string };
|
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SpaceItem =
|
||||||
|
| { type: 'point' | 'drop'; point: string; label?: string; color?: ColorName }
|
||||||
|
| { type: 'vector' | 'segment'; from: string; to: string; label?: string; dashed?: boolean; color?: ColorName }
|
||||||
|
| { type: 'polygon'; points: string[]; label?: string; color?: ColorName }
|
||||||
|
| { type: 'box'; to: [number, number, number]; label?: string; color?: ColorName };
|
||||||
|
/** فضای سهبعدی با تصویرِ مایلِ ثابت (x رو به بیننده، y راست، z بالا). */
|
||||||
|
export interface SpaceSpec { kind: 'space'; title: string; axes?: boolean; points: Record<string, [number, number, number]>; items: SpaceItem[] }
|
||||||
|
|
||||||
export type DiagramSpec =
|
export type DiagramSpec =
|
||||||
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
|
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
|
||||||
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec;
|
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec | SpaceSpec;
|
||||||
|
|||||||
@@ -33,8 +33,126 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
|||||||
// سمتِ خودشان میکشیدند، پس فهرست باز برعکس میشد.
|
// سمتِ خودشان میکشیدند، پس فهرست باز برعکس میشد.
|
||||||
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
|
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
|
||||||
|
|
||||||
export function MathText({ text: raw }: { text: string }) {
|
// فرمولِ «واقعی» (نه عددِ تنها): نمادِ رابطه یا عمل دارد و دستِکم چهار نویسه است.
|
||||||
|
const FORMULA = /[=<>≤≥≠∈∉⊂⊆∪∩+×÷^√∑∫]|[0-9۰-۹A-Za-z)][\s]*[−\-/][\s]*[0-9۰-۹A-Za-z(]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `display`: فرمولِ وسطِ جملهی فارسی در خطِ جدا و وسطچین مینشیند (سوالها و درسها)، تا
|
||||||
|
* چشم وسطِ فرمول به خطِ بعد نیفتد. عددِ تنها و عبارتِ کوتاهِ بدونِ نمادِ عمل داخلِ جمله میماند.
|
||||||
|
* آزموده نشده: صفحهی واقعیِ اپ در Chrome و Safari؛ فقط منطقِ تشخیص با نمونههای بانک سنجیده شد.
|
||||||
|
*/
|
||||||
|
// ماتریس «[[۲، ۷]، [۴، ۱]]»: ردیفها داخلِ کروشهی تو در تو. اگر مثلِ متنِ عادی بگذاریم، در خطِ باریک
|
||||||
|
// وسطِ ردیف شکسته میشود و راستبهچپ بودنِ جمله عددها را جابهجا نشان میدهد (گزارشِ مالک ۲۰۲۶-۱۰-۱۴).
|
||||||
|
const MATRIX = /\[\s*\[[^\[\]]*\](?:\s*[،,]\s*\[[^\[\]]*\])*\s*\]/g;
|
||||||
|
|
||||||
|
function Matrix({ src }: { src: string }) {
|
||||||
|
const rows = [...src.matchAll(/\[([^\[\]]*)\]/g)].map((m) => m[1].split(/[،,]/).map((c) => plainPersian(plainMath(c.trim()))));
|
||||||
|
const border = '2px solid currentColor';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: 'middle', margin: '4px 6px', padding: '2px 8px', borderLeft: border, borderRight: border, borderRadius: 6, unicodeBidi: 'isolate' }}
|
||||||
|
>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<span key={i} style={{ display: 'flex', justifyContent: 'center', gap: 14, lineHeight: 1.7, whiteSpace: 'nowrap' }}>
|
||||||
|
{row.map((c, j) => <span key={j} style={{ minWidth: 18, textAlign: 'center' }}>{c}</span>)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سطرهای ماتریس که خطِبهخط نوشته شدهاند («b₁₁ b₁₂ ⋯ b(1n)» زیرِ «b₂₁ b₂₂ ⋯ b(2n)» و «⋮ ⋮ ⋱ ⋮»):
|
||||||
|
* بدونِ ستونبندی، نقطههای عمودی زیرِ درایهها نمیافتند و فاصلهی دوتایی هم در HTML فرو میریزد.
|
||||||
|
* دو خطِ پشتِ هم یا بیشتر که بدونِ حرفِ فارسیاند و تعدادِ توکنشان برابر (≥۳) است، یک جدول میشوند.
|
||||||
|
* سطرِ اول میتواند برچسب داشته باشد («B(m×n) =»).
|
||||||
|
*/
|
||||||
|
const PERSIAN = /[-ۿ]/;
|
||||||
|
function parseRow(line: string): { label: string; cells: string[] } | null {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t || PERSIAN.test(t)) return null;
|
||||||
|
const m = /^(.*?\s=\s+)(\S.*)$/.exec(t);
|
||||||
|
const label = m ? m[1].trim() : '';
|
||||||
|
const cells = (m ? m[2] : t).split(/\s+/);
|
||||||
|
// معادله («f(1) = 1² + 1 = 2») سطرِ ماتریس نیست: توکنِ عملگر یعنی ماتریس نیست.
|
||||||
|
// نمادِ تکی (+ = ⊆ → …) جز نقطهچینها و شمارهی فهرست («۲)») هم یعنی ماتریس نیست.
|
||||||
|
if (cells.some((c) => (/^[^\p{L}\p{N}]$/u.test(c) && !'⋯⋮⋱…'.includes(c)) || /^[0-9۰-۹]+[.)]$/.test(c))) return null;
|
||||||
|
return cells.length >= 3 ? { label, cells } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitGrids(raw: string): (string | { rows: { label: string; cells: string[] }[] })[] | null {
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
const parts: (string | { rows: { label: string; cells: string[] }[] })[] = [];
|
||||||
|
let buf: string[] = [], found = false, i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const first = parseRow(lines[i]);
|
||||||
|
let j = i;
|
||||||
|
const rows: { label: string; cells: string[] }[] = [];
|
||||||
|
while (first && j < lines.length) {
|
||||||
|
const r = parseRow(lines[j]);
|
||||||
|
if (!r || r.cells.length !== first.cells.length) break;
|
||||||
|
rows.push(r);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (rows.length >= 2) {
|
||||||
|
if (buf.length) parts.push(buf.join('\n'));
|
||||||
|
buf = [];
|
||||||
|
parts.push({ rows });
|
||||||
|
found = true;
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
buf.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buf.length) parts.push(buf.join('\n'));
|
||||||
|
return found ? parts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Grid({ rows }: { rows: { label: string; cells: string[] }[] }) {
|
||||||
|
const border = '2px solid currentColor';
|
||||||
|
const label = rows.find((r) => r.label)?.label ?? '';
|
||||||
|
const clean = (c: string) => plainPersian(plainMath(c));
|
||||||
|
return (
|
||||||
|
<span dir="ltr" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', gap: '2px 8px', margin: '6px 0', maxWidth: '100%', unicodeBidi: 'isolate', fontWeight: 700 }}>
|
||||||
|
{label && <span style={{ whiteSpace: 'nowrap' }}>{clean(label)}</span>}
|
||||||
|
<span style={{ display: 'inline-grid', gridTemplateColumns: `repeat(${rows[0].cells.length}, auto)`, columnGap: 10, rowGap: 4, padding: '2px 8px', maxWidth: '100%', overflowX: 'auto', borderLeft: border, borderRight: border, borderRadius: 6 }}>
|
||||||
|
{rows.flatMap((r, i) => r.cells.map((c, j) => <span key={`${i}-${j}`} style={{ textAlign: 'center', whiteSpace: 'nowrap', lineHeight: 1.7 }}>{clean(c)}</span>))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MathText({ text: raw, display = false }: { text: string; display?: boolean }) {
|
||||||
|
const grids = splitGrids(raw);
|
||||||
|
if (grids) {
|
||||||
|
return <>{grids.map((p, k) => (typeof p === 'string' ? <MathText key={k} text={p} display={display} /> : <Grid key={k} rows={p.rows} />))}</>;
|
||||||
|
}
|
||||||
|
if (!MATRIX.test(raw)) { MATRIX.lastIndex = 0; return <Inline raw={raw} display={display} />; }
|
||||||
|
MATRIX.lastIndex = 0;
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let last = 0, k = 0;
|
||||||
|
for (const m of raw.matchAll(MATRIX)) {
|
||||||
|
const at = m.index ?? 0;
|
||||||
|
if (at > last) out.push(<Inline key={`t${k++}`} raw={raw.slice(last, at)} display={display} />);
|
||||||
|
out.push(<Matrix key={`m${k++}`} src={m[0]} />);
|
||||||
|
last = at + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < raw.length) out.push(<Inline key={`t${k++}`} raw={raw.slice(last)} display={display} />);
|
||||||
|
return <>{out}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inline({ raw, display }: { raw: string; display: boolean }) {
|
||||||
|
const first = build(raw, display);
|
||||||
|
// دو فرمولِ بلوکی در یک جمله («a₁ = … و a₂ = …») هر کدام خطِ جدا میگرفتند و «و» و «.» تنها میماندند.
|
||||||
|
const { nodes } = first.blocks > 1 ? build(raw, false) : first;
|
||||||
|
return <>{nodes}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(raw: string, display: boolean): { nodes: React.ReactNode[]; blocks: number } {
|
||||||
const text = plainPersian(plainMath(raw));
|
const text = plainPersian(plainMath(raw));
|
||||||
|
let blocks = 0;
|
||||||
const out: React.ReactNode[] = [];
|
const out: React.ReactNode[] = [];
|
||||||
const depth = bracketDepth(text);
|
const depth = bracketDepth(text);
|
||||||
let last = 0, i = 0;
|
let last = 0, i = 0;
|
||||||
@@ -56,12 +174,19 @@ export function MathText({ text: raw }: { text: string }) {
|
|||||||
run = edge.run;
|
run = edge.run;
|
||||||
if (!run || !HAS_MATH.test(run)) continue;
|
if (!run || !HAS_MATH.test(run)) continue;
|
||||||
out.push(text.slice(last, start + head.length));
|
out.push(text.slice(last, start + head.length));
|
||||||
out.push(<span key={i++} style={LTR}>{run}</span>);
|
const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length);
|
||||||
|
const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after);
|
||||||
|
if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) {
|
||||||
|
blocks++;
|
||||||
|
out.push(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
|
||||||
|
} else {
|
||||||
|
out.push(<span key={i++} style={LTR}>{run}</span>);
|
||||||
|
}
|
||||||
last = start + head.length + run.length;
|
last = start + head.length + run.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push(text.slice(last));
|
out.push(text.slice(last));
|
||||||
return <>{out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n))}</>;
|
return { nodes: out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n)), blocks };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسهی متن. */
|
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسهی متن. */
|
||||||
@@ -108,5 +233,16 @@ function trimUnpaired(run: string): { head: string; run: string } {
|
|||||||
head += m;
|
head += m;
|
||||||
run = run.slice(m.length);
|
run = run.slice(m.length);
|
||||||
}
|
}
|
||||||
|
// «…اگر i<j).»: پرانتزِ بستهی بیجفتِ تهِ تکه مالِ جملهی فارسی است. اگر داخلِ قفلِ چپبهراست بماند،
|
||||||
|
// سمتِ راستِ j با شکلِ «)» دیده میشود، نه سمتِ چپ و آینهشده (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
|
||||||
|
while (/[)\]}]\s*$/.test(run) && closes(run) > opens(run)) {
|
||||||
|
run = run.slice(0, run.length - /\s*[)\]}]\s*$/.exec(run)![0].length);
|
||||||
|
}
|
||||||
|
// و پرانتزِ بازِ بیجفتِ سرِ تکه («(مثلاً a=7 …» که بستهاش جای دیگری است) هم مالِ جمله است.
|
||||||
|
while (/^\s*[(\[{]/.test(run) && opens(run) > closes(run)) {
|
||||||
|
const m = /^\s*[(\[{]\s*/.exec(run)![0];
|
||||||
|
head += m;
|
||||||
|
run = run.slice(m.length);
|
||||||
|
}
|
||||||
return { head, run };
|
return { head, run };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ export function parseNumbered(text: string): { lead: string; items: string[] } |
|
|||||||
|
|
||||||
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
|
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
|
||||||
const list = parseNumbered(text);
|
const list = parseNumbered(text);
|
||||||
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} /></p>;
|
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} display /></p>;
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} /></p>}
|
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} display /></p>}
|
||||||
<ol className="flex flex-col gap-2.5">
|
<ol className="flex flex-col gap-2.5">
|
||||||
{list.items.map((item, i) => (
|
{list.items.map((item, i) => (
|
||||||
<li key={i} className="flex gap-2.5">
|
<li key={i} className="flex gap-2.5">
|
||||||
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||||
{toFa(i + 1)}
|
{toFa(i + 1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 whitespace-pre-line"><MathText text={item} /></span>
|
<span className="flex-1 whitespace-pre-line"><MathText text={item} display /></span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
128
src/lib/lineFit.ts
Normal file
128
src/lib/lineFit.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* شکستنِ بهترِ خط برای متنِ دوطرفچین.
|
||||||
|
*
|
||||||
|
* مرورگر خط را حریصانه میشکند: کلمهای که یک ذره جا نشود به خطِ بعد میرود و خطِ بالا برای
|
||||||
|
* پر شدن فاصلهی بینِ کلمهها را بیشازحد باز میکند. چاپِ حرفهای (الگوریتمِ Knuth–Plass) برای
|
||||||
|
* اینکه کلمهای از خطِ پایین به بالا بیاید، فاصلهها را کمی فشرده میکند. اینجا همان را با
|
||||||
|
* `word-spacing`ِ منفیِ کوچک میکنیم: برای هر پاراگراف چند مقدار امتحان میشود، هر بار خطها
|
||||||
|
* اندازهگیری میشوند و مقداری میماند که فاصلهی نهاییِ خطها به فاصلهی معمولی نزدیکتر است.
|
||||||
|
*
|
||||||
|
* فقط `style.wordSpacing` عوض میشود، نه DOM؛ پس با React و قفلهای جهتِ `MathText` درگیر نمیشود.
|
||||||
|
* آزموده: Chrome (سرِ بیسر، فونتِ واقعیِ Vazirmatn)؛ روی Safari و WebViewِ اندروید دیده نشده.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// برحسبِ em. فاصلهی معمولیِ Vazirmatn حدودِ ۰٫۲۶em است؛ تا ۰٫۰۸em فشردن (~۳۰٪) حدِ چاپِ حرفهای است.
|
||||||
|
const CANDIDATES = [0, -0.02, -0.04, -0.06, -0.08];
|
||||||
|
const SPACE_EM = 0.26;
|
||||||
|
const SELECTOR = 'p, li, .whitespace-pre-line';
|
||||||
|
const MIN_CHARS = 14;
|
||||||
|
// بیشینهی فاصلهی مجازِ بینِ کلمهها نسبت به فاصلهی معمول، پیش از آنکه دوطرفچین کنار گذاشته شود
|
||||||
|
const MAX_GAP = 2.2;
|
||||||
|
|
||||||
|
const observed = new WeakSet<HTMLElement>();
|
||||||
|
|
||||||
|
/** پهنای طبیعیِ هر خط (با ترازِ start) از مستطیلهای Range. */
|
||||||
|
function lineWidths(el: HTMLElement, fontSize: number): number[] {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
const rects = [...range.getClientRects()].filter((r) => r.width > 0.5).sort((a, b) => a.top + a.height / 2 - (b.top + b.height / 2));
|
||||||
|
const lines: { y: number; left: number; right: number }[] = [];
|
||||||
|
for (const r of rects) {
|
||||||
|
const y = r.top + r.height / 2;
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
if (last && Math.abs(y - last.y) < fontSize * 0.7) {
|
||||||
|
last.left = Math.min(last.left, r.left);
|
||||||
|
last.right = Math.max(last.right, r.right);
|
||||||
|
} else {
|
||||||
|
lines.push({ y, left: r.left, right: r.right });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.map((l) => l.right - l.left);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fit(el: HTMLElement) {
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
el.style.wordSpacing = '';
|
||||||
|
el.style.textAlign = '';
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
if (cs.textAlign !== 'justify' || cs.display === 'flex' || cs.display === 'inline-flex' || cs.display === 'none') return;
|
||||||
|
const text = el.textContent ?? '';
|
||||||
|
if (text.length < MIN_CHARS) return;
|
||||||
|
const fontSize = parseFloat(cs.fontSize);
|
||||||
|
const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||||
|
if (!(width > 0)) return;
|
||||||
|
const spaces = (text.match(/\s+/g) ?? []).length;
|
||||||
|
if (spaces < 2) return;
|
||||||
|
const s0 = SPACE_EM * fontSize;
|
||||||
|
|
||||||
|
el.style.textAlign = 'start';
|
||||||
|
let best = 0, bestScore = Infinity, bestWorst = 0;
|
||||||
|
for (const c of CANDIDATES) {
|
||||||
|
el.style.wordSpacing = `${c}em`;
|
||||||
|
const w = lineWidths(el, fontSize);
|
||||||
|
if (w.length < 2) { best = 0; break; }
|
||||||
|
const total = w.reduce((a, b) => a + b, 0);
|
||||||
|
let score = 0, worst = 0;
|
||||||
|
for (let i = 0; i < w.length - 1; i++) {
|
||||||
|
const k = Math.max(1, (spaces * w[i]) / total); // فاصلههای این خط (تخمین)
|
||||||
|
const gap = s0 + c * fontSize + Math.max(0, width - w[i]) / k;
|
||||||
|
// فشردن گرانتر از کشیدن است (چاپِ حرفهای هم همینطور): فشردهشدنِ فاصله زودتر زشت دیده میشود
|
||||||
|
const dev = (gap - s0) / s0;
|
||||||
|
worst = Math.max(worst, gap / s0);
|
||||||
|
score += (dev < 0 ? 2.2 * dev : dev) ** 2;
|
||||||
|
}
|
||||||
|
if (score < bestScore - 1e-6) { bestScore = score; best = c; bestWorst = worst; }
|
||||||
|
}
|
||||||
|
// حتی بهترین شکستن فاصلهای بیش از ۲٫۲ برابرِ معمول میسازد (کلمهی بلند که جا نمیشود): چاپِ
|
||||||
|
// حرفهای هم در این حالت دوطرفچین نمیکند، چون لبهی نامرتب از فاصلهی گشاد بهتر است.
|
||||||
|
if (bestWorst > MAX_GAP) {
|
||||||
|
el.style.wordSpacing = '';
|
||||||
|
el.style.textAlign = 'start';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.style.textAlign = '';
|
||||||
|
el.style.wordSpacing = best ? `${best}em` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// همهی اجراها همزمان و پیش از اولین نقاشیِ صفحهاند (MutationObserver/ResizeObserver پیش از paint
|
||||||
|
// صدا زده میشوند)، پس متنِ خام و اصلاحنشده هرگز دیده نمیشود؛ تأخیرِ قبلی (۱۵۰ms) یک چشمک میساخت.
|
||||||
|
const lastWidth = new WeakMap<HTMLElement, number>();
|
||||||
|
|
||||||
|
function watch(el: HTMLElement) {
|
||||||
|
if (observed.has(el)) return;
|
||||||
|
observed.add(el);
|
||||||
|
resizer.observe(el);
|
||||||
|
fit(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizer = new ResizeObserver((entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
const width = e.contentRect.width;
|
||||||
|
// تغییرِ ارتفاعِ خودِ fit نباید دوباره fit را راه بیندازد
|
||||||
|
if (lastWidth.get(el) === width) continue;
|
||||||
|
lastWidth.set(el, width);
|
||||||
|
fit(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function scan(node: ParentNode) {
|
||||||
|
if (node instanceof HTMLElement && node.matches(SELECTOR)) watch(node);
|
||||||
|
node.querySelectorAll<HTMLElement>(SELECTOR).forEach(watch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLineFit() {
|
||||||
|
const root = document.getElementById('root')!;
|
||||||
|
scan(root);
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
const changed = new Set<HTMLElement>();
|
||||||
|
for (const r of records) {
|
||||||
|
r.addedNodes.forEach((n) => { if (n instanceof HTMLElement) scan(n); });
|
||||||
|
// متنِ داخلِ پاراگراف عوض شده (سوالِ بعدی، پاسخِ در حالِ پخش)
|
||||||
|
const host = (r.target instanceof HTMLElement ? r.target : r.target.parentElement)?.closest<HTMLElement>(SELECTOR);
|
||||||
|
if (host) changed.add(host);
|
||||||
|
}
|
||||||
|
changed.forEach(fit);
|
||||||
|
}).observe(root, { childList: true, subtree: true, characterData: true });
|
||||||
|
void document.fonts.ready.then(() => document.querySelectorAll<HTMLElement>(SELECTOR).forEach(fit));
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ const SYMBOLS: Record<string, string> = {
|
|||||||
Delta: 'Δ', lambda: 'λ', mu: 'μ', sigma: 'σ', Sigma: 'Σ', phi: 'φ', varphi: 'φ', omega: 'ω', epsilon: 'ε', rho: 'ρ', tau: 'τ',
|
Delta: 'Δ', lambda: 'λ', mu: 'μ', sigma: 'σ', Sigma: 'Σ', phi: 'φ', varphi: 'φ', omega: 'ω', epsilon: 'ε', rho: 'ρ', tau: 'τ',
|
||||||
infty: '∞', neq: '≠', ne: '≠', leq: '≤', le: '≤', geq: '≥', ge: '≥', to: '→', rightarrow: '→', leftarrow: '←',
|
infty: '∞', neq: '≠', ne: '≠', leq: '≤', le: '≤', geq: '≥', ge: '≥', to: '→', rightarrow: '→', leftarrow: '←',
|
||||||
Rightarrow: '⇒', implies: '⇒', Leftrightarrow: '⇔', iff: '⇔', perp: '⊥', parallel: '∥', circ: '°', degree: '°', ldots: '…',
|
Rightarrow: '⇒', implies: '⇒', Leftrightarrow: '⇔', iff: '⇔', perp: '⊥', parallel: '∥', circ: '°', degree: '°', ldots: '…',
|
||||||
dots: '…', cdots: '⋯', int: '∫', sum: 'Σ', prod: '∏', partial: '∂', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆',
|
dots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', int: '∫', sum: 'Σ', prod: '∏', partial: '∂', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆',
|
||||||
supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', angle: '∠', triangle: '△',
|
supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', angle: '∠', triangle: '△',
|
||||||
approx: '≈', equiv: '≡', sim: '∼', cong: '≅', mid: '|', vert: '|', neg: '¬', therefore: '∴', setminus: '−',
|
approx: '≈', equiv: '≡', sim: '∼', cong: '≅', mid: '|', vert: '|', neg: '¬', therefore: '∴', setminus: '−',
|
||||||
quad: ' ', qquad: ' ', prime: '′',
|
quad: ' ', qquad: ' ', prime: '′',
|
||||||
@@ -61,7 +61,10 @@ export function plainMath(text: string): string {
|
|||||||
}
|
}
|
||||||
s = s.replace(new RegExp(`\\\\lim\\s*_${G}`, 'g'), 'lim($1)');
|
s = s.replace(new RegExp(`\\\\lim\\s*_${G}`, 'g'), 'lim($1)');
|
||||||
s = s.replace(/\\(left|right)\s*/g, '').replace(/\\\|/g, '|').replace(/\\\{/g, '{').replace(/\\\}/g, '}').replace(/\\[, ]/g, ' ');
|
s = s.replace(/\\(left|right)\s*/g, '').replace(/\\\|/g, '|').replace(/\\\{/g, '{').replace(/\\\}/g, '}').replace(/\\[, ]/g, ' ');
|
||||||
|
s = s.replace(/(?<![A-Za-z\\])sqrt\(/g, '√(');
|
||||||
s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]);
|
s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]);
|
||||||
|
// «vdots/ddots» بدونِ بکاسلش (در درسِ ماتریسها همینطور ذخیره شده بود)؛ سطرِ ماتریس نقطهچینِ عمودی/قطری است.
|
||||||
|
s = s.replace(/(?<![A-Za-z\\])(vdots|ddots)(?![A-Za-z])/g, (_m, n: string) => SYMBOLS[n]);
|
||||||
s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^'));
|
s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^'));
|
||||||
s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_'));
|
s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_'));
|
||||||
s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان (${a.trim()}) `);
|
s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان (${a.trim()}) `);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import { unlockAudioOnFirstTouch } from './lib/sound';
|
import { unlockAudioOnFirstTouch } from './lib/sound';
|
||||||
|
import { startLineFit } from './lib/lineFit';
|
||||||
|
|
||||||
unlockAudioOnFirstTouch();
|
unlockAudioOnFirstTouch();
|
||||||
|
startLineFit();
|
||||||
|
|
||||||
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
||||||
const DiagramGallery = import.meta.env.DEV
|
const DiagramGallery = import.meta.env.DEV
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role:
|
|||||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمی تمرینهای سریع" },
|
delta: { name: "دلتا", symbol: "Δ", role: "همتیمی تمرینهای سریع" },
|
||||||
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
|
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
|
||||||
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو" },
|
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ interface Nav {
|
|||||||
/** صفحهی فعلی را عوض میکند بدونِ اضافه کردن به تاریخچه. */
|
/** صفحهی فعلی را عوض میکند بدونِ اضافه کردن به تاریخچه. */
|
||||||
replace: (route: Route) => void;
|
replace: (route: Route) => void;
|
||||||
back: () => void;
|
back: () => void;
|
||||||
|
/** از صفحهی فعلی بیرون میرود حتی اگر برگهای باز باشد (برگه هم بسته میشود)؛ `back` فقط خودِ برگه را میبندد. */
|
||||||
|
leave: () => void;
|
||||||
switchTab: (tab: TabName) => void;
|
switchTab: (tab: TabName) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +124,13 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
|||||||
else stepBack();
|
else stepBack();
|
||||||
}, [stepBack]);
|
}, [stepBack]);
|
||||||
|
|
||||||
|
const leave = useCallback(() => {
|
||||||
|
setSteps([]);
|
||||||
|
const s = stackRef.current;
|
||||||
|
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
|
||||||
|
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const switchTab = useCallback((tab: TabName) => {
|
const switchTab = useCallback((tab: TabName) => {
|
||||||
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمیسازد (قبلاً دوباره بار میشد)؛ مثلِ اپهای بومی به
|
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمیسازد (قبلاً دوباره بار میشد)؛ مثلِ اپهای بومی به
|
||||||
// بالای همان صفحه برمیگردد.
|
// بالای همان صفحه برمیگردد.
|
||||||
@@ -141,9 +150,10 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
|||||||
push,
|
push,
|
||||||
replace,
|
replace,
|
||||||
back,
|
back,
|
||||||
|
leave,
|
||||||
switchTab,
|
switchTab,
|
||||||
};
|
};
|
||||||
}, [stack, direction, reselect, push, replace, back, switchTab]);
|
}, [stack, direction, reselect, push, replace, back, leave, switchTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavContext.Provider value={value}>
|
<NavContext.Provider value={value}>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function InviteScreen() {
|
|||||||
const text = 'با تدریسینو ریاضی رو قدمبهقدم یاد بگیر:';
|
const text = 'با تدریسینو ریاضی رو قدمبهقدم یاد بگیر:';
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({ title: 'تدریسینو', text, url });
|
await navigator.share({ title: 'تدریسینو ریاضی', text, url });
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
/* کاربر لغو کرد؛ میرویم سراغِ کپی */
|
/* کاربر لغو کرد؛ میرویم سراغِ کپی */
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
|||||||
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||||
{toFa(i + 1)}
|
{toFa(i + 1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span>
|
<span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-black">ورود به تدریسینو</h1>
|
<h1 className="text-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
|
||||||
<p className="mt-1 text-sm text-white/75">
|
<p className="mt-1 text-sm text-white/75">
|
||||||
{step === 'phone'
|
{step === 'phone'
|
||||||
? 'شماره موبایلت رو وارد کن'
|
? 'شماره موبایلت رو وارد کن'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
|
|||||||
{
|
{
|
||||||
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
|
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
|
||||||
sub: 'از پایه سوم تا دوازدهم',
|
sub: 'از پایه سوم تا دوازدهم',
|
||||||
title: 'به تدریسینو خوش اومدی!',
|
title: 'به تدریسینو ریاضی خوش اومدی!',
|
||||||
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو میبریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
|
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو میبریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,7 +64,7 @@ export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
|
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
|
||||||
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
|
<h1 className="mb-3 text-balance text-2xl font-black leading-snug">{slide.title}</h1>
|
||||||
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
|
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو</span>
|
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو ریاضی</span>
|
||||||
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
|
|||||||
@@ -345,8 +345,8 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
|
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
|
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
|
||||||
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
|
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} hideCounts /></div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||||
@@ -405,7 +405,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
<SecondaryButton onClick={() => nav.leave()}>خروج</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function SplashScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="boot-splash" dir="rtl">
|
<div className="boot-splash" dir="rtl">
|
||||||
<img src="/app-icon.png" alt="" width={144} height={144} />
|
<img src="/app-icon.png" alt="" width={144} height={144} />
|
||||||
<span>تدریسینو</span>
|
<span>تدریسینو ریاضی</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function SubscriptionScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو'}
|
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو ریاضی'}
|
||||||
subtitle="همه فصلها، قلب نامحدود"
|
subtitle="همه فصلها، قلب نامحدود"
|
||||||
color="#ED6956"
|
color="#ED6956"
|
||||||
exact
|
exact
|
||||||
|
|||||||
@@ -79,6 +79,24 @@
|
|||||||
input, textarea {
|
input, textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
/* متنِ چندخطی (پاراگراف، گامهای درس، متنِ سوال، حبابِ پی) دوطرفچین است تا لبهی چپ
|
||||||
|
مرتب باشد. اینها در لایهی baseاند، پس هر جا کلاسِ text-center / text-left داده شده
|
||||||
|
برنده است و تیترها و برچسبهای کوتاه سرِ جایشان میمانند. خطِ آخر طبقِ معمول راستچین
|
||||||
|
میماند. text-justify فقط فاصلهی بینِ کلمهها را باز میکند (کشیدهنویسی در CSS نیست). */
|
||||||
|
p, li, .whitespace-pre-line {
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
/* از تککلمهی یتیم در خطِ آخر جلوگیری میکند */
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
/* پاراگرافِ داخلِ بخشِ وسطچین (حالتِ خالی، آنبوردینگ، اسپلش) وسطچین میماند؛
|
||||||
|
بدونِ این، قاعدهی بالا ارثِ text-center را میشکست. */
|
||||||
|
.text-center :is(p, li, .whitespace-pre-line) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.text-left :is(p, li, .whitespace-pre-line) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility no-scrollbar {
|
@utility no-scrollbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user