website

source code for the ttmaths.uk website
Log | Files | Refs | README

commit 580af4a487bea9d4413edbe88804e2f336335c1e
parent ce4705fdecbc3f878714b1c28c210c9ec41a5206
Author: hrryt <harry@mayesfield.uk>
Date:   Thu,  6 Aug 2026 20:20:11 +0100

Refactor src to add preact

Diffstat:
Aindex.html | 12++++++++++++
Ajsconfig.json | 20++++++++++++++++++++
Asrc/components/Equation.jsx | 12++++++++++++
Asrc/components/Header.jsx | 22++++++++++++++++++++++
Asrc/components/MultipleSelection.jsx | 25+++++++++++++++++++++++++
Asrc/components/Question.jsx | 9+++++++++
Asrc/components/QuestionList.jsx | 9+++++++++
Asrc/components/QuestionWindow.jsx | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/Window.jsx | 17+++++++++++++++++
Asrc/components/questions/AddingFractionsQuestion.jsx | 41+++++++++++++++++++++++++++++++++++++++++
Asrc/components/questions/QuadraticEquationQuestion.jsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/data/questions/gcse/adding-fractions.js | 23+++++++++++++++++++++++
Asrc/data/questions/gcse/quadratic-equations.js | 24++++++++++++++++++++++++
Asrc/index.jsx | 29+++++++++++++++++++++++++++++
Asrc/pages/Home.jsx | 10++++++++++
Asrc/pages/NotFound.jsx | 12++++++++++++
Asrc/pages/QuestionIndex.jsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/auto-maths.js | 8++++++++
Asrc/scripts/utils.js | 30++++++++++++++++++++++++++++++
Asrc/styles/index.css | 3+++
Asrc/styles/main.css | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avite.config.js | 17+++++++++++++++++
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', + }, + }), + ], +});