LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
'use strict';
const db = require('../db/db');
const { awardXP } = require('./gamificationController');
/* ── Crossword generator (improved) ──────────────────────────────────── */
// Algorithm based on MichaelWehar/crossword-layout-generator scoring with additions:
// 1. Enumerate candidates from already-placed words (only perpendicular directions)
// 2. Weighted score: connections 70% + center 15% + orientation balance 10% + length 5%
// 3. Two-pass: retry skipped words after full first pass
// 4. Random pick from top-3 candidates per attempt for variety
// 5. 120 attempts, keeping best result by placed-word count
const CW_GRID = 20;
const CW_MAX_WORDS = 10;
const CW_ATTEMPTS = 120;
function _shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function _cwPlace(grid, word, row, col, dir) {
for (let i = 0; i < word.length; i++) {
if (dir === 'across') grid[row][col + i] = word[i];
else grid[row + i][col] = word[i];
}
}
// Returns number of valid intersections (≥0) if placement is valid, -1 if invalid
function _cwCheck(grid, word, row, col, dir, N) {
if (row < 0 || col < 0) return -1;
const endR = dir === 'down' ? row + word.length - 1 : row;
const endC = dir === 'across' ? col + word.length - 1 : col;
if (endR >= N || endC >= N) return -1;
// No letter immediately before/after word in its own direction
if (dir === 'across') {
if (col > 0 && grid[row][col - 1]) return -1;
if (endC < N - 1 && grid[row][endC + 1]) return -1;
} else {
if (row > 0 && grid[row - 1][col]) return -1;
if (endR < N - 1 && grid[endR + 1][col]) return -1;
}
let intersections = 0;
for (let i = 0; i < word.length; i++) {
const r = dir === 'across' ? row : row + i;
const c = dir === 'across' ? col + i : col;
const ex = grid[r][c];
if (ex !== null) {
if (ex !== word[i]) return -1; // letter conflict
intersections++;
} else {
// Empty cell — perpendicular neighbours must be empty
if (dir === 'across') {
if (r > 0 && grid[r - 1][c]) return -1;
if (r < N - 1 && grid[r + 1][c]) return -1;
} else {
if (c > 0 && grid[r][c - 1]) return -1;
if (c < N - 1 && grid[r][c + 1]) return -1;
}
}
}
return intersections;
}
// Find all valid placements by iterating over already-placed words.
// Only tries the direction PERPENDICULAR to each placed word — guaranteed crossing.
function _cwFindPlacements(grid, word, placed, N, center) {
const seen = new Set();
const candidates = [];
const acrossCount = placed.filter(p => p.dir === 'across').length;
const downCount = placed.length - acrossCount;
for (const pw of placed) {
const newDir = pw.dir === 'across' ? 'down' : 'across';
for (let wi = 0; wi < word.length; wi++) {
for (let pi = 0; pi < pw.word.length; pi++) {
if (word[wi] !== pw.word[pi]) continue;
// Intersection cell on the grid
const ir = pw.dir === 'across' ? pw.row : pw.row + pi;
const ic = pw.dir === 'across' ? pw.col + pi : pw.col;
// Start of new word so that letter wi lands at (ir, ic)
const r = newDir === 'across' ? ir : ir - wi;
const c = newDir === 'across' ? ic - wi : ic;
const key = `${r},${c},${newDir}`;
if (seen.has(key)) continue;
seen.add(key);
const intersections = _cwCheck(grid, word, r, c, newDir, N);
if (intersections < 1) continue;
// Weighted score (MichaelWehar 70/15/10/5 split)
const maxDist = center * Math.SQRT2;
const dist = Math.hypot(r - center, c - center);
const conn = intersections / (word.length / 2);
const cen = 1 - dist / maxDist;
const oBal = newDir === 'down'
? (acrossCount >= downCount ? 0.1 : 0)
: (downCount >= acrossCount ? 0.1 : 0);
const len = word.length / N;
const score = conn * 0.7 + cen * 0.15 + oBal * 0.1 + len * 0.05;
candidates.push({ row: r, col: c, dir: newDir, intersections, score });
}
}
}
return candidates;
}
function _buildAttempt(words, N) {
const grid = Array.from({ length: N }, () => Array(N).fill(null));
const placed = [];
const center = Math.floor(N / 2);
// First word: placed across at center
const first = words[0];
const r0 = center;
const c0 = Math.max(1, center - Math.floor(first.word.length / 2));
if (c0 + first.word.length > N - 1) return { placed, grid };
_cwPlace(grid, first.word, r0, c0, 'across');
placed.push({ ...first, row: r0, col: c0, dir: 'across' });
const skipped = [];
for (const pass of [words.slice(1), skipped]) {
for (const w of pass) {
if (placed.length >= CW_MAX_WORDS) break;
const cands = _cwFindPlacements(grid, w.word, placed, N, center);
if (!cands.length) {
if (pass !== skipped) skipped.push(w);
continue;
}
cands.sort((a, b) => b.score - a.score);
// Pick randomly from top 3 for variety across attempts
const pick = cands[Math.floor(Math.random() * Math.min(3, cands.length))];
_cwPlace(grid, w.word, pick.row, pick.col, pick.dir);
placed.push({ ...w, row: pick.row, col: pick.col, dir: pick.dir });
}
}
return { placed, grid };
}
function buildCrossword(wordList) {
const N = CW_GRID;
let best = null;
// Baseline: longest words first
const sorted = [...wordList].sort((a, b) => b.word.length - a.word.length);
best = _buildAttempt(sorted, N);
for (let a = 1; a < CW_ATTEMPTS; a++) {
if (best.placed.length >= CW_MAX_WORDS) break;
const shuffled = _shuffle(wordList);
// Ensure a long word is near the front (better first placement)
shuffled.sort((x, y) => (y.word.length >= 6 ? 1 : 0) - (x.word.length >= 6 ? 1 : 0));
const attempt = _buildAttempt(shuffled, N);
if (attempt.placed.length > best.placed.length) best = attempt;
}
if (!best || best.placed.length < 3) return null;
// Compact: trim empty rows/cols
const { placed, grid } = best;
let minR = N, maxR = 0, minC = N, maxC = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (grid[r][c]) {
minR = Math.min(minR, r); maxR = Math.max(maxR, r);
minC = Math.min(minC, c); maxC = Math.max(maxC, c);
}
const trimmed = [];
for (let r = minR; r <= maxR; r++) trimmed.push(grid[r].slice(minC, maxC + 1));
const words = placed.map(p => ({
word: p.word, clue: p.clue,
subjectName: p.subjectName,
row: p.row - minR, col: p.col - minC, dir: p.dir,
}));
// Number words in reading order (top→bottom, left→right)
words.sort((a, b) => a.row !== b.row ? a.row - b.row : a.col - b.col);
const starts = new Map();
let num = 1;
for (const w of words) {
const key = `${w.row},${w.col}`;
if (!starts.has(key)) starts.set(key, num++);
w.num = starts.get(key);
}
return { grid: trimmed, words, across: words.filter(w => w.dir === 'across'), down: words.filter(w => w.dir === 'down') };
}
/* ── GET /api/games/hangman/word?subject_slug=bio ─────────────────────── */
function hangmanWord(req, res) {
const { subject_slug } = req.query;
let row;
if (subject_slug) {
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
row = db.prepare(`
SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE t.subject_id = ? AND length(t.name) >= 4
ORDER BY RANDOM() LIMIT 1
`).get(subj.id);
} else {
row = db.prepare(`
SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE length(t.name) >= 4
ORDER BY RANDOM() LIMIT 1
`).get();
}
if (!row) return res.status(404).json({ error: 'No topics found' });
res.json({
topicId: row.id,
word: row.name.toUpperCase(),
hint: row.subject_name,
subjectSlug: row.subject_slug,
});
}
/* ── POST /api/games/hangman/complete ─────────────────────────────────── */
function hangmanComplete(req, res) {
const { won, errors } = req.body;
if (typeof won !== 'boolean') return res.status(400).json({ error: 'won required' });
let xpGain = 0;
if (won) {
// 15 XP perfect, -2 per error, min 5
xpGain = Math.max(5, 15 - (Number(errors) || 0) * 2);
}
if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'hangman_win'); } catch (e) { console.error('[games] hangman XP:', e.message); }
}
res.json({ ok: true, xp: xpGain });
}
/* ── GET /api/games/crossword/generate?subject_slug= ──────────────────── */
function crosswordGenerate(req, res) {
const { subject_slug } = req.query;
let rows;
const base = `
SELECT t.id, t.name, s.name AS subject_name,
(SELECT q.text FROM questions q WHERE q.topic_id = t.id ORDER BY RANDOM() LIMIT 1) AS clue
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE length(t.name) BETWEEN 4 AND 12
AND t.name NOT LIKE '% %'
AND t.name NOT GLOB '*[0-9]*'
`;
if (subject_slug) {
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
rows = db.prepare(base + ' AND t.subject_id = ? ORDER BY RANDOM() LIMIT 50').all(subj.id);
} else {
rows = db.prepare(base + ' ORDER BY RANDOM() LIMIT 50').all();
}
if (rows.length < 3) return res.status(404).json({ error: 'Not enough topics for a crossword' });
const wordList = rows
.filter(r => /^[А-яЁёA-Za-z]+$/.test(r.name)) // only letters, no numbers/symbols
.map(r => ({
word: r.name.toUpperCase(),
clue: r.clue || r.subject_name,
subjectName: r.subject_name,
}));
const crossword = buildCrossword(wordList);
if (!crossword) return res.status(404).json({ error: 'Could not build crossword' });
res.json(crossword);
}
/* ── POST /api/games/crossword/complete ───────────────────────────────── */
function crosswordComplete(req, res) {
const { completed, hintsUsed } = req.body;
if (typeof completed !== 'boolean') return res.status(400).json({ error: 'completed required' });
let xpGain = 0;
if (completed) {
xpGain = Math.max(5, 20 - (Number(hintsUsed) || 0) * 3);
}
if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'crossword_win'); } catch (e) { console.error('[games] crossword XP:', e.message); }
}
res.json({ ok: true, xp: xpGain });
}
module.exports = { hangmanWord, hangmanComplete, crosswordGenerate, crosswordComplete };