Diagrams: hide answer-giving counts in quizzes (HideCounts); bar/pie slices take the colour named by their label
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
|
||||
@@ -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 slot = plotW / labels.length;
|
||||
const X = (i: number) => left + slot * (i + 0.5);
|
||||
const barColors = seriesColors(labels, values.length);
|
||||
const barW = Math.min(38, slot * 0.62);
|
||||
const long = labels.some((l) => l.length > 6);
|
||||
|
||||
@@ -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>}
|
||||
{kind === 'bar'
|
||||
? values.map((v, i) => {
|
||||
const c = COLORS[SERIES[i % SERIES.length]];
|
||||
const c = barColors[i];
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={X(i) - barW / 2} y={Math.min(Y(v), Y(0))} width={barW} height={Math.abs(Y(v) - Y(0))} rx={4} fill={c} />
|
||||
@@ -64,12 +65,13 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
||||
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
|
||||
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
|
||||
if (total <= 0) return null;
|
||||
const sliceColors = seriesColors(labels, values.length);
|
||||
const cx = 105, cy = 105, r = 88;
|
||||
let a = -Math.PI / 2;
|
||||
const slices = values.map((v, i) => {
|
||||
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
|
||||
a = a1;
|
||||
return { i, frac, a0, a1, c: COLORS[SERIES[i % SERIES.length]] };
|
||||
return { i, frac, a0, a1, c: sliceColors[i] };
|
||||
});
|
||||
const H = Math.max(215, 24 + labels.length * 22);
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Space } from './space';
|
||||
import { Chart } from './charts';
|
||||
import { Mapping, Tree, Venn } from './sets';
|
||||
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
|
||||
import { PAPER } from './svg';
|
||||
import { HideCounts, PAPER } from './svg';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
export type { DiagramSpec } from './types';
|
||||
@@ -45,13 +45,13 @@ class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
|
||||
render() { return this.state.broken ? null : this.props.children; }
|
||||
}
|
||||
|
||||
function DiagramBase({ spec }: { spec: DiagramSpec }) {
|
||||
function DiagramBase({ spec, hideCounts = false }: { spec: DiagramSpec; hideCounts?: boolean }) {
|
||||
return (
|
||||
<Guard>
|
||||
{/* dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
||||
برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند. */}
|
||||
<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>}
|
||||
</figure>
|
||||
</Guard>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
const W = 320;
|
||||
@@ -8,11 +9,12 @@ const SHADE = COLORS.yellow;
|
||||
/* ─── کسر: دایره یا نوارِ تکهتکه ─────────────────────────────────────────── */
|
||||
|
||||
export function Fraction({ spec }: { spec: FractionSpec }) {
|
||||
if (spec.compare?.length) return <FractionCompare items={spec.compare} />;
|
||||
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} />;
|
||||
const hide = useContext(HideCounts);
|
||||
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 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') {
|
||||
const w = (W - 40) / parts;
|
||||
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 H = items.length * (h + gap) + 8;
|
||||
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} />
|
||||
))}
|
||||
<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>
|
||||
</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 cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
|
||||
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} />
|
||||
: <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>
|
||||
<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)}`}
|
||||
</text>
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +85,7 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
|
||||
/* ─── شبکهی خانهها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
|
||||
|
||||
export function Grid({ spec }: { spec: GridSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const rows = spec.rows, cols = spec.cols;
|
||||
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
|
||||
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 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)}٪` : ''}`}
|
||||
</text>
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +113,7 @@ export function Grid({ spec }: { spec: GridSpec }) {
|
||||
/* ─── گروهها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
|
||||
|
||||
export function Groups({ spec }: { spec: GroupsSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const g = spec.groups, each = spec.each;
|
||||
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
|
||||
const rowsG = Math.ceil(g / perRow);
|
||||
@@ -141,9 +145,9 @@ export function Groups({ spec }: { spec: GroupsSpec }) {
|
||||
);
|
||||
})}
|
||||
</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}`)}
|
||||
</text>
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -233,6 +237,7 @@ function patternCells(layout: PatternSpec['layout'], n: number): [number, number
|
||||
}
|
||||
|
||||
export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const stages = spec.stages;
|
||||
const sticks = spec.unit === 'stick';
|
||||
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} />;
|
||||
})}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
import type { ColorName } from './types';
|
||||
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'];
|
||||
|
||||
/** رنگِ هر ستون یا برشِ نمودار: اگر برچسب نامِ یک رنگ باشد («قرمز»، «مهرهی آبی (۳)») همان رنگ، وگرنه رنگهای
|
||||
* پیشفرض به ترتیب، بدون رنگهایی که برچسبی به نامشان هست؛ تا «قرمز» در چرخنده یا جدولِ فراوانی آبی کشیده نشود. */
|
||||
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 SOFT = '#9EA3B8';
|
||||
export const GRID = '#E4E6F0';
|
||||
@@ -141,3 +156,10 @@ export function piLabel(t: number): string {
|
||||
const head = n === 1 ? 'π' : `${toFa(n)}π`;
|
||||
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
|
||||
* جوابِ سوال را لو میداد؛ آزمون این را روشن میکند و شکل فقط خودِ داده را نشان میدهد.
|
||||
* در درس و جوابِ دستیار شمارش میماند چون آنجا توضیح است، نه سوال.
|
||||
*/
|
||||
export const HideCounts = createContext(false);
|
||||
|
||||
@@ -346,7 +346,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
|
||||
</div>
|
||||
<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 className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||
|
||||
Reference in New Issue
Block a user