commit 580af4a487bea9d4413edbe88804e2f336335c1e
parent ce4705fdecbc3f878714b1c28c210c9ec41a5206
Author: hrryt <harry@mayesfield.uk>
Date: Thu, 6 Aug 2026 20:20:11 +0100
Refactor src to add preact
Diffstat:
22 files changed, 620 insertions(+), 0 deletions(-)
diff --git a/index.html b/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>TTMaths</title>
+ </head>
+ <body>
+ <div id="app"></div>
+ <script prerender type="module" src="/src/index.jsx"></script>
+ </body>
+</html>
diff --git a/jsconfig.json b/jsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "allowJs": true,
+ "checkJs": true,
+
+ /* Preact Config */
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "skipLibCheck": true,
+ "paths": {
+ "react": ["./node_modules/preact/compat/"],
+ "react-dom": ["./node_modules/preact/compat/"]
+ }
+ },
+ "include": ["node_modules/vite/client.d.ts", "**/*"]
+}
diff --git a/src/components/Equation.jsx b/src/components/Equation.jsx
@@ -0,0 +1,12 @@
+import { useRef, useEffect } from 'preact/hooks';
+import { render } from 'kern-typ';
+
+export default function Equation({ equation, displayMode = false }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ render(equation, ref.current, { displayMode: displayMode });
+ }, [equation]);
+
+ return <div ref={ref} >{equation}</div>;
+}
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
@@ -0,0 +1,22 @@
+import { useLocation } from 'preact-iso';
+
+export default function Header() {
+ const { url } = useLocation();
+
+ return (
+ <header>
+ <nav class="window">
+ <a href="/">
+ <button class={'nav-element' + (url == '/' ? ' active' : '')}>
+ Home
+ </button>
+ </a>
+ <a href="/questions">
+ <button class={'nav-element' + (url.startsWith('/questions') ? ' active' : '')}>
+ Questions
+ </button>
+ </a>
+ </nav>
+ </header>
+ );
+}
diff --git a/src/components/MultipleSelection.jsx b/src/components/MultipleSelection.jsx
@@ -0,0 +1,25 @@
+
+export default function MultipleSelection({ variable, setVariable, id, legend, values, labels = values }) {
+ const checked = values.map(value => variable.includes(value));
+
+ function updateVariable(e, index) {
+ const newChecked = checked.map((value, i) => i === index ? e.target.checked : value);
+ const validChecked = newChecked.some(Boolean) ? newChecked : checked.map((value) => !value);
+ setVariable(values.filter((_, i) => validChecked[i]));
+ }
+
+ return (
+ <fieldset key={id}>
+ <legend>{legend}</legend>
+ {values.map((value, i) => {
+ const name = `${id}:${value}`;
+ return (
+ <div key={value} class="field-row">
+ <input type="checkbox" id={name} name={name} checked={checked[i]} onChange={e => updateVariable(e, i)} />
+ <label htmlFor={name}>{labels[i]}</label>
+ </div>
+ );
+ })}
+ </fieldset>
+ );
+}
diff --git a/src/components/Question.jsx b/src/components/Question.jsx
@@ -0,0 +1,9 @@
+import Equation from './Equation.jsx';
+
+export default function Question({ question, answer, showAnswer }) {
+ return (
+ <li>
+ <Equation equation={showAnswer ? answer : question} />
+ </li>
+ );
+}
diff --git a/src/components/QuestionList.jsx b/src/components/QuestionList.jsx
@@ -0,0 +1,9 @@
+export default function QuestionList({ children }) {
+ return (
+ <li>
+ <ol class="question-list">
+ {children}
+ </ol>
+ </li>
+ );
+}
diff --git a/src/components/QuestionWindow.jsx b/src/components/QuestionWindow.jsx
@@ -0,0 +1,92 @@
+import { useState } from 'preact/hooks';
+import QuestionList from './QuestionList.jsx';
+import { getRandomInt, getRandomNumber, getRandomChoice } from '../scripts/utils.js';
+import Window from './Window.jsx';
+
+function resolveParameters(question, variables) {
+ const parameters = question.constants ?? { };
+ const variableKeys = question.variables ?? { };
+ Object.keys(variableKeys).forEach((key) => {
+ parameters[key] = variables[variableKeys[key]];
+ })
+ return parameters;
+}
+
+function renderQuestion(seed, showAnswer, variables) {
+ return (question, i) => {
+ const QuestionComponent = question.type;
+ const parameters = resolveParameters(question, variables);
+ return <QuestionComponent key={i} seed={seed+i} showAnswer={showAnswer} {...parameters} />;
+ };
+}
+
+function getQuestionList(id, questions, showAnswers, seed, variables) {
+ return (
+ <QuestionList key={id}>
+ {questions.map(renderQuestion(seed, showAnswers, variables))}
+ </QuestionList>
+ );
+}
+
+function shuffleArray(arr, n, seed) {
+ return Array.from({ length: n }).map(() => getRandomChoice(arr, ++seed));
+}
+
+export default function QuestionWindow({ data, title }) {
+
+ const dataVariables = data.variables ?? [];
+
+ const defaultVariables = Object.fromEntries(
+ dataVariables.map(input => [input.id, input.default ?? input.values])
+ );
+
+ const [showAnswers, setShowAnswers] = useState(false);
+ const [seed , setSeed ] = useState(Math.random());
+ const [variables , setVariables ] = useState(defaultVariables);
+
+ function setVariable(id) {
+ return (value) => {
+ setVariables({
+ ...variables,
+ [id]: value
+ });
+ }
+ }
+
+ function revealAnswers() { setShowAnswers(!showAnswers); }
+ function triggerRefresh() { setSeed(getRandomNumber(seed)); }
+
+ let current_seed = seed;
+ const questions = data.questionLists.map(questionList => {
+ const { id: id, n: n, questions: unique_questions } = questionList;
+ const questions = shuffleArray(unique_questions, n, current_seed);
+ const randomQuestionList = getQuestionList(id, questions, showAnswers, current_seed, variables);
+ current_seed += n;
+ return randomQuestionList;
+ });
+
+ const fields = dataVariables.map(input => {
+ const { type: InputComponent, ...props } = input;
+ return <InputComponent key={input.id} variable={variables[input.id]} setVariable={setVariable(input.id)} {...props} />;
+ });
+
+ return (
+ <Window title={title}>
+ {fields}
+ <fieldset>
+ <legend>Options</legend>
+ <button onClick={revealAnswers}>
+ {showAnswers ? "Hide" : "Show"} Answers
+ </button>
+ <button onClick={triggerRefresh}>
+ Refresh Questions
+ </button>
+ </fieldset>
+ <div class="field-border question-field kern">
+ <ol class="master-question-list">
+ {questions}
+ </ol>
+ </div>
+ </Window>
+ );
+}
diff --git a/src/components/Window.jsx b/src/components/Window.jsx
@@ -0,0 +1,17 @@
+export default function Window({ children, title, maximised }){
+ return (
+ <div class="window">
+ <div class="title-bar">
+ <div class="title-bar-text">{title}</div>
+ <div class="title-bar-controls">
+ <button class="window-button" aria-label="Minimize" />
+ <button class="window-button" aria-label={maximised ? "Restore" : "Maximize"} />
+ <button class="window-button" aria-label="Close" />
+ </div>
+ </div>
+ <div class="window-body">
+ {children}
+ </div>
+ </div>
+ )
+}
diff --git a/src/components/questions/AddingFractionsQuestion.jsx b/src/components/questions/AddingFractionsQuestion.jsx
@@ -0,0 +1,41 @@
+import Question from '../Question.jsx';
+import { getRandomInt, getHCF } from '../../scripts/utils.js';
+
+function formatFraction(num, denom) {
+ if (denom == 1) { return `${num}`; }
+ return num + '/' + denom;
+}
+
+function simplifyFraction(num, denom) {
+ const hcf = getHCF(num, denom);
+ return [num / hcf, denom / hcf];
+}
+
+function getRandomFraction(seed) {
+ const num0 = getRandomInt(1, 5, seed);
+ const denom0 = getRandomInt(2, 10, seed + .05);
+ const [num, denom] = simplifyFraction(num0, denom0);
+ if (denom == 1) { return getRandomFraction(seed + .1); }
+ return [num, denom];
+}
+
+function getRandomFractions(seed) {
+ const [num1, denom1] = getRandomFraction(seed);
+ const [num2, denom2] = getRandomFraction(seed + .5);
+ return [num1, denom1, num2, denom2];
+}
+
+export default function AddingFractionsQuestion({ seed, showAnswer }) {
+ const [num1, denom1, num2, denom2] = getRandomFractions(seed);
+
+ const question = formatFraction(num1, denom1) + ' + ' + formatFraction(num2, denom2);
+
+ const hcf = getHCF(denom1, denom2);
+ const sum = (num1 * denom2 + num2 * denom1) / hcf;
+ const lcm = denom1 * denom2 / hcf;
+ const [num, denom] = simplifyFraction(sum, lcm);
+
+ const answer = formatFraction(num, denom);
+
+ return <Question question={question} answer={answer} showAnswer={showAnswer} />;
+}
diff --git a/src/components/questions/QuadraticEquationQuestion.jsx b/src/components/questions/QuadraticEquationQuestion.jsx
@@ -0,0 +1,68 @@
+import Question from '../Question.jsx';
+import { getRandomInt, getRandomBoolean, getRandomChoice, formatNumber } from '../../scripts/utils.js';
+
+function formatQuadraticExpression(a, b, c) {
+ const fa = formatNumber(a, {showPlus: false, showOne: false, showZero: false, suffix: 'x^2'});
+ const fb = formatNumber(b, {showPlus: true, showOne: false, showZero: false, suffix: 'x'});
+ const fc = formatNumber(c, {showPlus: true, showOne: true, showZero: false});
+ return `${fa} ${fb} ${fc}`;
+}
+
+function formatRoots(root1, root2) {
+ const froot2 = formatNumber(root2, {showPlus: false, showOne: true, showZero: true});
+ if (root1 == root2) { return `x = ${froot2}`; }
+ if (Math.abs(root1) == root2) { return `x = plus.minus ${root2}`; }
+ const froot1 = formatNumber(root1, {showPlus: false, showOne: true, showZero: true});
+ return `x = ${froot1} "or" x = ${froot2}`;
+}
+
+function formatFactor(root) {
+ if (root == 0) { return "x"; }
+ const froot = formatNumber(root, {showPlus: true, showOne: true, showZero: false});
+ return `(x${froot})`;
+}
+
+function sortFactors(root1, root2) {
+ if (root1 > 0 && root2 < 0) { return -1; }
+ if (root1 < 0 && root2 > 0) { return +1; }
+ return Math.abs(root1) - Math.abs(root2);
+}
+
+function formatFactors(root1, root2, a) {
+ const fa = formatNumber(a, {showPlus: false, showOne: false, showZero: false});
+ [root1, root2] = [root1, root2].sort(sortFactors);
+ const froot2 = formatFactor(root2);
+ if (root1 == root2) { return `${fa}${froot2}^2`; }
+ const froot1 = formatFactor(root1);
+ return `${fa}${froot1}${froot2}`;
+}
+
+function getRandomRoots(seed) {
+ return [
+ getRandomInt(-9, 9, seed),
+ getRandomInt(-9, 9, seed + .1),
+ ].sort((a, b) => a - b);
+}
+
+export default function QuadraticEquationQuestion({ seed, showAnswer, aPlurality, framing }) {
+ const aChoices = Array();
+ aPlurality.includes("a=1") && aChoices.push(1);
+ aPlurality.includes("a>1") && aChoices.push(2, 3);
+ const a = getRandomChoice(aChoices, seed);
+
+ const factoriseChoices = Array();
+ framing.includes("Solve") && factoriseChoices.push(false);
+ framing.includes("Factorise") && factoriseChoices.push(true);
+ const factorise = getRandomChoice(factoriseChoices, seed + .1);
+
+ const [root1, root2] = getRandomRoots(seed + .2);
+
+ const b = a * -(root1 + root2);
+ const c = a * root1 * root2;
+
+ const quadratic = formatQuadraticExpression(a, b, c);
+ const question = factorise ? `"Factorise" ${quadratic}` : `${quadratic} = 0`;
+ const answer = factorise ? formatFactors(root1, root2, a) : formatRoots(root1, root2);
+
+ return <Question question={question} answer={answer} showAnswer={showAnswer} />;
+}
diff --git a/src/data/questions/gcse/adding-fractions.js b/src/data/questions/gcse/adding-fractions.js
@@ -0,0 +1,23 @@
+import AddingFractionsQuestion from '/src/components/questions/AddingFractionsQuestion.jsx';
+
+export const data = {
+ questionLists: [{
+ id: 0,
+ n: 10,
+ questions: [{
+ type: AddingFractionsQuestion,
+ }]
+ }, {
+ id: 1,
+ n: 10,
+ questions: [{
+ type: AddingFractionsQuestion,
+ }]
+ }, {
+ id: 2,
+ n: 10,
+ questions: [{
+ type: AddingFractionsQuestion,
+ }]
+ }]
+}
diff --git a/src/data/questions/gcse/quadratic-equations.js b/src/data/questions/gcse/quadratic-equations.js
@@ -0,0 +1,24 @@
+import QuadraticEquationQuestion from '/src/components/questions/QuadraticEquationQuestion.jsx';
+import MultipleSelection from '/src/components/MultipleSelection.jsx';
+
+export const data = {
+ variables: [{
+ id: 'framing',
+ legend: 'Framing',
+ type: MultipleSelection,
+ values: ["Solve", "Factorise"],
+ }, {
+ id: 'aPlurality',
+ legend: 'Coefficient a',
+ type: MultipleSelection,
+ values: ["a=1", "a>1"],
+ }],
+ questionLists: [{
+ id: 0,
+ n: 10,
+ questions: [{
+ type: QuadraticEquationQuestion,
+ variables: { aPlurality: 'aPlurality', framing: 'framing' },
+ }]
+ }]
+}
diff --git a/src/index.jsx b/src/index.jsx
@@ -0,0 +1,29 @@
+import { LocationProvider, Router, hydrate, prerender as ssr } from 'preact-iso';
+
+import Header from './components/Header.jsx';
+import Home from './pages/Home.jsx';
+import QuestionIndex from './pages/QuestionIndex.jsx';
+import NotFound from './pages/NotFound.jsx';
+import './styles/index.css';
+
+export function App() {
+ return (
+ <LocationProvider>
+ <Header />
+ <Router>
+ <Home path="/" />
+ <QuestionIndex path="/questions" />
+ <QuestionIndex path="/questions/*" />
+ <NotFound default />
+ </Router>
+ </LocationProvider>
+ );
+}
+
+if (typeof window !== 'undefined') {
+ hydrate(<App />, document.getElementById('app'));
+}
+
+export async function prerender(data) {
+ return await ssr(<App {...data} />);
+}
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
@@ -0,0 +1,10 @@
+import Window from '../components/Window.jsx';
+
+export default function Home() {
+ return (
+ <main>
+ <aside></aside>
+ <Window title="Home">Hello World!</Window>
+ </main>
+ );
+}
diff --git a/src/pages/NotFound.jsx b/src/pages/NotFound.jsx
@@ -0,0 +1,12 @@
+import Window from '../components/Window.jsx';
+
+export default function NotFound() {
+ return (
+ <main>
+ <aside></aside>
+ <Window title="Error">
+ <img class="http-error" src="https://httpducks.com/404.jpg" />
+ </Window>
+ </main>
+ );
+}
diff --git a/src/pages/QuestionIndex.jsx b/src/pages/QuestionIndex.jsx
@@ -0,0 +1,71 @@
+import { lazy, Router, Route } from 'preact-iso';
+import QuestionWindow from '../components/QuestionWindow.jsx';
+
+const allQuestions = [{
+ id: 'gcse',
+ label: 'GCSE',
+ questions: [{
+ id: 'quadratic-equations',
+ label: 'Quadratic Equations'
+ }, {
+ id: 'adding-fractions',
+ label: 'Adding Fractions'
+ }]
+}, {
+ id: 'a-level',
+ label: 'A-Level',
+ questions: []
+}]
+
+function QuestionItem({ id, question }) {
+ return (
+ <li>
+ <a href={`/questions/${id}/${question.id}`}>
+ {question.label}
+ </a>
+ </li>
+ );
+}
+
+function QuestionsItem({ id, label, questions }) {
+ return (
+ <li>
+ <details open>
+ <summary>{label}</summary>
+ <ul>
+ {questions.map(question =>
+ <QuestionItem id={id} question={question} />
+ )}
+ </ul>
+ </details>
+ </li>
+ );
+}
+
+export default function QuestionIndex() {
+
+ const items = allQuestions.map(({ id, label, questions }) =>
+ <QuestionsItem id={id} label={label} questions={questions} />
+ );
+ const routes = allQuestions.flatMap(({ id, label, questions }) => questions.map(question => {
+ const Component = lazy(() =>
+ import(`../data/questions/${id}/${question.id}.js`).then(
+ ({ data }) => () => <QuestionWindow data={data} title={question.label} />
+ )
+ );
+ return <Route path={`/${id}/${question.id}`} component={Component} />;
+ }));
+
+ return (
+ <main>
+ <aside>
+ <ul class="tree-view">
+ {items}
+ </ul>
+ </aside>
+ <Router>
+ {routes}
+ </Router>
+ </main>
+ );
+}
diff --git a/src/scripts/auto-maths.js b/src/scripts/auto-maths.js
@@ -0,0 +1,8 @@
+import renderMathInElement from 'kern-typ/auto-render';
+
+renderMathInElement(document.body, {
+ delimiters: [
+ { left: '$$', right: '$$', display: true },
+ { left: '$', right: '$', display: false },
+ ],
+});
diff --git a/src/scripts/utils.js b/src/scripts/utils.js
@@ -0,0 +1,30 @@
+export function getRandomInt(min, max, seed) {
+ return min + Math.floor((max - min + 1) * getRandomNumber(seed));
+}
+
+export function getRandomChoice(arr, seed) {
+ return arr[getRandomInt(0, arr.length - 1, seed)];
+}
+
+export function getRandomBoolean(seed) {
+ return getRandomNumber(seed) < 0.5;
+}
+
+export function getRandomNumber(seed) {
+ const x = 1e4 * Math.sin(7 * seed);
+ return x - Math.floor(x);
+}
+
+export function getHCF(a, b) {
+ return b == 0 ? a : getHCF(b, a % b);
+}
+
+export function formatNumber(x, parameters = {showPlus: false, showOne: true, showZero: true, suffix: ''}) {
+ if (!parameters.showZero && x == 0) { return ''; }
+ const plus = parameters.showPlus ? '+' : '';
+ const sign = x < 0 ? '-' : plus;
+ x = Math.abs(x);
+ parameters.showOne || x == 1 && (x = '');
+ parameters.suffix || (parameters.suffix = '') // if suffix undefined
+ return sign + x + parameters.suffix;
+}
diff --git a/src/styles/index.css b/src/styles/index.css
@@ -0,0 +1,3 @@
+@import 'kern-typ/kern.css';
+@import '98.css';
+@import './main.css';
diff --git a/src/styles/main.css b/src/styles/main.css
@@ -0,0 +1,66 @@
+body {
+ background-color: #008080;
+ margin: 0;
+}
+
+nav {
+ max-width: 100vw !important;
+}
+
+button.nav-element.active {
+ font-weight: bold;
+}
+
+.window {
+ min-width: min-content;
+ max-width: max-content;
+}
+
+fieldset {
+ margin-bottom: 7px;
+}
+
+.field-border {
+ font-size: 1.25rem;
+ padding: 20px;
+}
+
+ol.master-question-list {
+ margin: 0;
+ padding-left: 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 40px;
+}
+
+ol.question-list {
+ list-style-type: lower-latin;
+}
+
+main {
+ padding: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+aside {
+ width: 200px;
+}
+
+button {
+ margin-right: 3px;
+}
+
+button.window-button {
+ margin-right: 0;
+}
+
+button.active {
+ box-shadow: inset -1px -1px #ffffff, inset 1px 1px #0a0a0a,
+ inset -2px -2px #dfdfdf, inset 2px 2px #808080;
+}
+
+img.http-error {
+ max-width: 400px;
+}
diff --git a/vite.config.js b/vite.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [
+ preact({
+ prerender: {
+ enabled: true,
+ renderTarget: '#app',
+ additionalPrerenderRoutes: ['/404'],
+ previewMiddlewareEnabled: true,
+ previewMiddlewareFallback: '/404',
+ },
+ }),
+ ],
+});