Skip to content

Commit 3ad19ca

Browse files
author
psriraj3
committed
PDF clock generation problem fixed - 2
1 parent 0aec099 commit 3ad19ca

8 files changed

Lines changed: 488 additions & 367 deletions

File tree

node_modules/.tmp/tsconfig.app.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,13 @@ export default function App() {
321321
{!generated ? (
322322
<div className="card">No set yet.</div>
323323
) : mode === "focus" ? (
324-
<FocusMode key={generated.seed} subject={subject} ukYear={ukYear} difficulty={difficulty} seed={seed} />
324+
<FocusMode
325+
subject={subject}
326+
ukYear={ukYear}
327+
difficulty={difficulty}
328+
seed={seed}
329+
topic={topic} // ✅ whatever your maths topic state variable is
330+
/>
325331
) : mode === "practice" ? (
326332
<PracticeRunner key={generated.seed} set={generated} />
327333
) : (

src/core/timeClock.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// src/core/timeClock.ts
2+
3+
export type ClockTime = { h: number; m: number };
4+
5+
function getOptions(q: any): string[] {
6+
const opts = q?.options ?? q?.choices ?? q?.mcqOptions ?? q?.answers;
7+
return Array.isArray(opts) ? opts.map(String) : [];
8+
}
9+
10+
function clamp(n: number, min: number, max: number) {
11+
return Math.max(min, Math.min(max, n));
12+
}
13+
14+
export function parseClockTimeFromString(s: string): ClockTime | null {
15+
const m = String(s ?? "").match(/\b(\d{1,2})\s*:\s*(\d{2})\b/);
16+
if (!m) return null;
17+
const h = Number(m[1]);
18+
const mm = Number(m[2]);
19+
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
20+
if (mm < 0 || mm > 59) return null;
21+
if (h < 0 || h > 23) return null;
22+
return { h, m: mm };
23+
}
24+
25+
function analogHour(h24: number) {
26+
const h = ((h24 % 12) + 12) % 12;
27+
return h === 0 ? 12 : h;
28+
}
29+
30+
function matchOptionIndexByTime(opts: string[], t: ClockTime): number {
31+
const th = analogHour(t.h);
32+
for (let i = 0; i < opts.length; i++) {
33+
const ot = parseClockTimeFromString(opts[i]);
34+
if (!ot) continue;
35+
if (analogHour(ot.h) === th && ot.m === t.m) return i;
36+
}
37+
return -1;
38+
}
39+
40+
function letterToIndex(v: string): number | null {
41+
const s = String(v ?? "").trim().toUpperCase();
42+
const m = s.match(/\b([A-Z])\b/);
43+
if (!m?.[1]) return null;
44+
return m[1].charCodeAt(0) - 65;
45+
}
46+
47+
function numberToIndex(v: any, len: number): number | null {
48+
const n = typeof v === "number" ? v : Number(String(v ?? "").trim());
49+
if (!Number.isFinite(n)) return null;
50+
if (n >= 0 && n < len) return n;
51+
if (n >= 1 && n <= len) return n - 1;
52+
return null;
53+
}
54+
55+
function getQuestionId(q: any): string | null {
56+
const id = q?.id ?? q?.qid ?? q?.uid ?? q?.key ?? q?.slug ?? q?.questionId ?? q?.uuid ?? q?.meta?.id;
57+
if (typeof id === "string" && id.trim()) return id.trim();
58+
if (typeof id === "number" && Number.isFinite(id)) return String(id);
59+
return null;
60+
}
61+
62+
export function looksLikeTimeMcq(q: any): boolean {
63+
const opts = getOptions(q);
64+
if (opts.length < 3) return false;
65+
66+
const timeLike = opts.filter((o) => /\b\d{1,2}\s*:\s*\d{2}\b/.test(String(o))).length;
67+
if (timeLike >= 3) return true;
68+
69+
const text = `${q?.prompt ?? q?.question ?? q?.stem ?? q?.text ?? ""}`.toLowerCase();
70+
return text.includes("time") && (text.includes("clock") || timeLike >= 2);
71+
}
72+
73+
/**
74+
* IMPORTANT FIX:
75+
* If options exist, we FIRST map answer => option index (letter/index/text).
76+
* Only then do we parse a time string from the answer, and even then ONLY if it matches an option.
77+
*/
78+
export function resolveClockTimeFromSet(set: any, q: any, index: number): ClockTime | null {
79+
const opts = getOptions(q);
80+
81+
// explicit fields (rare; if present, still require matching an option when options exist)
82+
const explicit =
83+
q?.clockTime ?? q?.time ?? q?.meta?.clockTime ?? q?.meta?.time ?? q?.data?.clockTime ?? q?.data?.time;
84+
if (explicit != null) {
85+
const t = parseClockTimeFromString(String(explicit));
86+
if (t) {
87+
if (opts.length === 0) return t;
88+
const mi = matchOptionIndexByTime(opts, t);
89+
if (mi >= 0) return parseClockTimeFromString(opts[mi]) ?? t;
90+
}
91+
}
92+
93+
// answer key by id or parallel
94+
const qid = getQuestionId(q);
95+
const ak = Array.isArray(set?.answerKey) ? set.answerKey : [];
96+
97+
let answer: any = undefined;
98+
if (qid) {
99+
const hit = ak.find((k: any) => String(k?.questionId ?? k?.id ?? k?.qid ?? "") === qid);
100+
answer = hit?.answer ?? hit?.value ?? hit?.correct ?? hit;
101+
}
102+
if (answer == null && ak.length === (Array.isArray(set?.questions) ? set.questions.length : -1)) {
103+
const k = ak[index];
104+
answer = k?.answer ?? k?.value ?? k?.correct ?? k;
105+
}
106+
107+
const ansStr = answer == null ? "" : String(answer);
108+
109+
// ✅ If MCQ options exist: prefer letter/index/text mapping to OPTIONS (prevents “random time” bug)
110+
if (opts.length > 0 && ansStr.trim()) {
111+
const li = letterToIndex(ansStr);
112+
if (li != null && li >= 0 && li < opts.length) {
113+
const t = parseClockTimeFromString(opts[li]);
114+
if (t) return t;
115+
}
116+
117+
const ni = numberToIndex(answer, opts.length);
118+
if (ni != null) {
119+
const t = parseClockTimeFromString(opts[ni]);
120+
if (t) return t;
121+
}
122+
123+
const exactIdx = opts.findIndex((o) => String(o).trim() === ansStr.trim());
124+
if (exactIdx >= 0) {
125+
const t = parseClockTimeFromString(opts[exactIdx]);
126+
if (t) return t;
127+
}
128+
}
129+
130+
// Parse time from answer text ONLY if it matches an option (answers can contain multiple times)
131+
if (answer != null) {
132+
const t = parseClockTimeFromString(ansStr);
133+
if (t) {
134+
if (opts.length === 0) return t;
135+
const mi = matchOptionIndexByTime(opts, t);
136+
if (mi >= 0) return parseClockTimeFromString(opts[mi]) ?? t;
137+
}
138+
}
139+
140+
// Last resort: pick the first time-like option (keeps worksheet complete if key is odd)
141+
for (const o of opts) {
142+
const t = parseClockTimeFromString(String(o));
143+
if (t) return t;
144+
}
145+
146+
return null;
147+
}
148+
149+
export function clockSvgDataUri(time: ClockTime, size = 160): string {
150+
const h = ((time.h % 12) + 12) % 12;
151+
const m = clamp(time.m, 0, 59);
152+
153+
const minuteDeg = (m / 60) * 360;
154+
const hourDeg = ((h + m / 60) / 12) * 360;
155+
156+
const ticks = Array.from({ length: 12 })
157+
.map((_, i) => {
158+
const thick = i % 3 === 0 ? 2.2 : 1.4;
159+
const y1 = 6;
160+
const y2 = i % 3 === 0 ? 16 : 13;
161+
const deg = i * 30;
162+
return `<line x1="50" y1="${y1}" x2="50" y2="${y2}" stroke="black" stroke-width="${thick}" stroke-linecap="round" transform="rotate(${deg} 50 50)" />`;
163+
})
164+
.join("");
165+
166+
const svg = `<?xml version="1.0" encoding="UTF-8"?>
167+
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100" role="img" aria-label="Clock">
168+
<circle cx="50" cy="50" r="48" fill="white" stroke="black" stroke-width="2" />
169+
${ticks}
170+
<line x1="50" y1="50" x2="50" y2="22" stroke="black" stroke-width="4" stroke-linecap="round" transform="rotate(${hourDeg} 50 50)" />
171+
<line x1="50" y1="50" x2="50" y2="12" stroke="black" stroke-width="3" stroke-linecap="round" transform="rotate(${minuteDeg} 50 50)" />
172+
<circle cx="50" cy="50" r="2.5" fill="black" />
173+
</svg>`;
174+
175+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
176+
}

0 commit comments

Comments
 (0)