@teachinglab/omd
Version:
omd
350 lines (313 loc) • 17 kB
JavaScript
import { omdColor } from "./omdColor.js";
import { omdNumberTile } from "./omdNumberTile.js";
import { jsvgGroup, jsvgRect, jsvgTextBox, jsvgTextLine } from "@teachinglab/jsvg";
export class omdTileEquation extends jsvgGroup {
constructor() {
super();
this.type = "omdTileEquation";
this.left = []; // generic mode
this.right = [];
this.equation = null; // special mode: { x:number, units:number, total:number, xLabel?:string }
this.tileSize = 28;
this.tileGap = 6;
this.rowGap = 10;
this.equalGap = 24; // gap between left and right
this.tileRadius = 6;
this.showLabels = true;
this.fontFamily = 'Albert Sans';
this.fontSize = 16;
// Styles
this.plusColor = '#79BBFD';
this.equalsColor = '#FF6B6B';
this.xPillColor = omdColor.lightGray;
this.numberTileDefaults = { backgroundColor: omdColor.lightGray, dotColor: 'black' };
this.updateLayout();
}
loadFromJSON(data) {
if (!data || typeof data !== 'object') return;
if (Array.isArray(data.left)) this.left = data.left;
if (Array.isArray(data.right)) this.right = data.right;
if (typeof data.equation === 'string') {
this.equation = this._parseEquationString(data.equation);
} else if (data.equation && typeof data.equation === 'object') {
this.equation = data.equation;
} else if (typeof data.equationString === 'string') {
this.equation = this._parseEquationString(data.equationString);
}
if (typeof data.tileSize === 'number') this.tileSize = data.tileSize;
if (typeof data.tileGap === 'number') this.tileGap = data.tileGap;
if (typeof data.equalGap === 'number') this.equalGap = data.equalGap;
if (typeof data.tileRadius === 'number') this.tileRadius = data.tileRadius;
if (typeof data.showLabels === 'boolean') this.showLabels = data.showLabels;
if (typeof data.fontFamily === 'string') this.fontFamily = data.fontFamily;
if (typeof data.fontSize === 'number') this.fontSize = data.fontSize;
if (typeof data.plusColor === 'string') this.plusColor = data.plusColor;
if (typeof data.equalsColor === 'string') this.equalsColor = data.equalsColor;
if (data.xPill && typeof data.xPill.color === 'string') this.xPillColor = data.xPill.color;
if (typeof data.xPillColor === 'string') this.xPillColor = data.xPillColor;
if (data.numberTileDefaults && typeof data.numberTileDefaults === 'object') {
this.numberTileDefaults = {
backgroundColor: data.numberTileDefaults.backgroundColor ?? this.numberTileDefaults.backgroundColor,
dotColor: data.numberTileDefaults.dotColor ?? this.numberTileDefaults.dotColor
};
}
this.updateLayout();
}
// Parse equations like "5x+4+2=23+12x" or with subtraction like "4x+3=23-x".
// Returns an object with positive counts arranged on each side by moving negatives across '='.
_parseEquationString(str) {
try {
const raw = String(str || '').replace(/\s+/g, '');
const parts = raw.split('=');
if (parts.length !== 2) return null;
const left = parts[0];
const right = parts[1];
const parseSide = (sideStr) => {
const cleaned = String(sideStr || '').replace(/[\s−–]/g, ch => (ch === ' ' ? '' : '-'));
const tokenRe = /([+-]?\d*[a-zA-Z])|([+-]?\d+)/g;
let varLabel = null;
const tokens = [];
const matches = cleaned.match(tokenRe) || [];
matches.forEach(tok => {
if (/[a-zA-Z]/.test(tok)) {
const m = tok.match(/^([+-]?)(\d*)([a-zA-Z])$/);
const sign = (m[1] || '+') === '-' ? '-' : '+';
const coef = m[2] ? parseInt(m[2], 10) : 1;
varLabel = varLabel || m[3];
tokens.push({ kind: 'x', count: Math.max(1, coef), sign });
} else {
const sign = tok[0] === '-' ? '-' : '+';
const val = Math.abs(parseInt(tok, 10));
tokens.push({ kind: 'const', value: Math.max(0, val), sign });
}
});
return { varLabel, tokens };
};
const L = parseSide(left);
const R = parseSide(right);
const useLabel = L.varLabel || R.varLabel || 'X';
return {
leftTokens: L.tokens,
rightTokens: R.tokens,
xLabel: useLabel
};
} catch (_) {
return null;
}
}
updateLayout() {
this.removeAllChildren();
if (this.equation) {
const dims = this._renderEquation(this.equation);
this.setWidthAndHeight(dims.width, dims.height);
this.svgObject.setAttribute('viewBox', `0 0 ${dims.width} ${dims.height}`);
return;
}
const leftGroup = new jsvgGroup();
const rightGroup = new jsvgGroup();
this.addChild(leftGroup);
this.addChild(rightGroup);
const leftDims = this._renderSide(leftGroup, this.left);
const rightDims = this._renderSide(rightGroup, this.right);
rightGroup.setPosition(leftDims.width + this.equalGap, 0);
const totalWidth = leftDims.width + this.equalGap + rightDims.width;
const totalHeight = Math.max(leftDims.height, rightDims.height);
this.setWidthAndHeight(totalWidth, totalHeight);
this.svgObject.setAttribute('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
}
_renderSide(holder, terms) {
let x = 0;
let baseY = 0;
let maxHeight = 0;
terms.forEach(term => {
const kind = term?.kind || 'var';
const color = term?.color || (kind === 'var' ? '#79BBFD' : '#FFA26D');
const count = Math.max(1, Number(term?.count || 1));
if (kind === 'text') {
const t = new jsvgTextBox();
const padX = 8, padY = 6;
t.setWidthAndHeight(this.fontSize * (String(term.value).length * 0.7) + padX * 2, this.fontSize + padY * 2);
t.setFontFamily(this.fontFamily);
t.setFontSize(this.fontSize);
t.setFontColor('black');
t.setAlignment('center');
t.setVerticalCentering();
t.setText(String(term.value));
const r = new jsvgRect();
r.setWidthAndHeight(t.width, t.height);
r.setCornerRadius(Math.min(this.tileRadius, t.height / 2));
r.setFillColor(omdColor.lightGray);
const g = new jsvgGroup();
g.setPosition(x, baseY);
g.addChild(r);
g.addChild(t);
holder.addChild(g);
x += t.width + this.tileGap;
maxHeight = Math.max(maxHeight, t.height);
return;
}
const g = new jsvgGroup();
g.setPosition(x, baseY);
holder.addChild(g);
// Arrange as vertical stack if 'stack' true; otherwise single row
const stack = !!term.stack;
let localWidth = 0;
let localHeight = 0;
for (let i = 0; i < count; i++) {
const box = new jsvgRect();
box.setWidthAndHeight(this.tileSize, this.tileSize);
box.setCornerRadius(Math.min(this.tileRadius, this.tileSize / 2));
box.setFillColor(color);
const bx = stack ? 0 : i * (this.tileSize + this.tileGap);
const by = stack ? i * (this.tileSize + this.rowGap) : 0;
box.setPosition(bx, by);
g.addChild(box);
localWidth = Math.max(localWidth, bx + this.tileSize);
localHeight = Math.max(localHeight, by + this.tileSize);
}
x += localWidth + this.tileGap;
maxHeight = Math.max(maxHeight, localHeight);
});
return { width: x > 0 ? x - this.tileGap : 0, height: maxHeight };
}
_renderEquation(eq) {
// Prefer new token format; fall back to legacy counts if provided
const hasTokens = Array.isArray(eq.leftTokens) && Array.isArray(eq.rightTokens);
const leftTokens = hasTokens ? eq.leftTokens : [];
const rightTokens = hasTokens ? eq.rightTokens : [];
const legacyLeftX = Math.max(0, Number(eq.leftX ?? eq.x ?? 0));
const legacyLeftUnits = Array.isArray(eq.leftUnitList) ? eq.leftUnitList : (Number.isFinite(eq.leftUnits ?? eq.units) ? [Math.max(0, Number(eq.leftUnits ?? eq.units))] : []);
const legacyRightX = Math.max(0, Number(eq.rightX ?? 0));
const legacyRightUnits = Array.isArray(eq.rightUnitList) ? eq.rightUnitList : (Number.isFinite(eq.rightUnits ?? eq.total) ? [Math.max(0, Number(eq.rightUnits ?? eq.total))] : []);
const xLabel = String(eq.xLabel || 'X');
// Pre-compute sizes and local top-center offsets
// Configure X pill appearance
const xPillColor = this.xPillColor || omdColor.lightGray;
// Pill width: ~5x the "X" glyph width (fontSize 0.7 * tileSize; glyph width ~0.62*fontSize)
const xFont = this.tileSize;
const xGlyphWidth = 0.62 * xFont;
const tileW = Math.max(this.tileSize * 1.6, Math.round(5 * xGlyphWidth));
const tileH = this.tileSize;
// Pre-create number tiles for measurement (tokens or legacy)
const countLeftX = hasTokens ? (leftTokens.filter(t => t.kind === 'x').reduce((a, t) => a + t.count, 0)) : legacyLeftX;
const countRightX = hasTokens ? (rightTokens.filter(t => t.kind === 'x').reduce((a, t) => a + t.count, 0)) : legacyRightX;
const stackH = countLeftX > 0 ? (countLeftX * (tileH + this.rowGap) - this.rowGap) : 0;
const unitTilesL = (hasTokens
? leftTokens.filter(t => t.kind === 'const').map(tk => { const t = new omdNumberTile(); t.loadFromJSON({ value: tk.value, size: 'medium', backgroundColor: this.numberTileDefaults.backgroundColor, dotColor: this.numberTileDefaults.dotColor }); return t; })
: legacyLeftUnits.map(v => { const t = new omdNumberTile(); t.loadFromJSON({ value: v, size: 'medium', backgroundColor: this.numberTileDefaults.backgroundColor, dotColor: this.numberTileDefaults.dotColor }); return t; })
);
const unitTilesR = (hasTokens
? rightTokens.filter(t => t.kind === 'const').map(tk => { const t = new omdNumberTile(); t.loadFromJSON({ value: tk.value, size: 'medium', backgroundColor: this.numberTileDefaults.backgroundColor, dotColor: this.numberTileDefaults.dotColor }); return t; })
: legacyRightUnits.map(v => { const t = new omdNumberTile(); t.loadFromJSON({ value: v, size: 'medium', backgroundColor: this.numberTileDefaults.backgroundColor, dotColor: this.numberTileDefaults.dotColor }); return t; })
);
const xTopLocal = countLeftX > 0 ? (tileH / 2) : 0;
const unitTopLocalL = unitTilesL.length ? Math.min(...unitTilesL.map(t => t.getTopDotCenterY())) : 0;
const unitTopLocalR = unitTilesR.length ? Math.min(...unitTilesR.map(t => t.getTopDotCenterY())) : 0;
// Choose a baseline so all components have non-negative Y positions when aligned
const baselineY = Math.max(xTopLocal, unitTopLocalL, unitTopLocalR, this.tileSize * 0.6);
// Compute Y positions such that each component's top anchor equals baselineY
const yX = countLeftX > 0 ? (baselineY - xTopLocal) : 0;
const yUnitL = unitTilesL.length ? (baselineY - unitTopLocalL) : 0;
const yUnitR = unitTilesR.length ? (baselineY - unitTopLocalR) : 0;
// Compute overall height from bottoms
const bottoms = [];
if (countLeftX > 0) bottoms.push(yX + stackH);
unitTilesL.forEach(t => bottoms.push(yUnitL + t.height));
unitTilesR.forEach(t => bottoms.push(yUnitR + t.height));
const maxH = bottoms.length ? Math.max(...bottoms) : this.tileSize * 2;
let cursorX = 0;
// Helper to add an operator centered in the upcoming gap
const addOp = (opChar, x) => {
const op = new jsvgTextLine();
op.setText(opChar);
op.setFontSize(this.tileSize);
op.setFontColor(this.plusColor);
op.setAlignment('center');
op.svgObject.setAttribute('dominant-baseline', 'middle');
op.setPosition(x + this.equalGap / 2, baselineY);
this.addChild(op);
};
// Render left side tokens (or legacy)
const renderXStack = () => {
const g = new jsvgGroup();
for (let i = 0; i < countLeftX; i++) {
const y = i * (tileH + this.rowGap);
const r = new jsvgRect();
r.setWidthAndHeight(tileW, tileH);
r.setCornerRadius(tileH / 2);
r.setFillColor(xPillColor);
r.setPosition(0, y);
g.addChild(r);
const t = new jsvgTextLine();
t.setText(xLabel);
t.setFontWeight('bold');
t.setFontSize(this.tileSize * 0.7);
t.setFontFamily(this.fontFamily);
t.setFontColor('black');
t.setAlignment('center');
t.svgObject.setAttribute('dominant-baseline', 'middle');
const textBias = tileH * 0.06;
t.setPosition(tileW / 2, y + tileH / 2 + textBias);
g.addChild(t);
}
return g;
};
const renderSide = (tokens, startX, yUnitTop, countX, isLeft) => {
let x = startX;
let hasPrev = false;
// If legacy, synthesize token sequence: X then each unit
const seq = hasTokens ? tokens.slice() : [
...(countX > 0 ? [{ kind: 'x', count: countX, sign: '+' }] : []),
...((isLeft ? legacyLeftUnits : legacyRightUnits).map(v => ({ kind: 'const', value: v, sign: '+' })))
];
seq.forEach(tok => {
const sign = tok.sign === '-' ? '-' : '+';
// operator before this token
if (hasPrev || sign === '-') {
addOp(sign === '-' ? '−' : '+', x);
x += this.equalGap;
}
if (tok.kind === 'x') {
const g = renderXStack();
g.setPosition(x, baselineY - xTopLocal);
this.addChild(g);
x += tileW;
hasPrev = true;
} else {
// const tile
const tile = new omdNumberTile();
tile.loadFromJSON({ value: tok.value, size: 'medium', backgroundColor: this.numberTileDefaults.backgroundColor, dotColor: this.numberTileDefaults.dotColor });
tile.setPosition(x, yUnitTop);
this.addChild(tile);
x += tile.width;
hasPrev = true;
}
});
return x;
};
// Render left side
if (hasTokens ? (leftTokens.length > 0) : (countLeftX > 0 || unitTilesL.length)) {
const g = new jsvgGroup();
g.setPosition(0, 0); // placeholder container (not strictly needed)
// Ensure left starts with any minus/plus operators between tokens
cursorX = renderSide(leftTokens, cursorX, yUnitL, countLeftX, true);
}
// Reserve equals gap
cursorX += this.equalGap;
// Equals sign aligned to baselineY
const eqWidth = this.tileSize * 1.2;
const eqTxt = new jsvgTextLine();
eqTxt.setText('=');
eqTxt.setFontSize(this.tileSize * 1.1);
eqTxt.setFontColor(this.equalsColor);
eqTxt.setAlignment('center');
eqTxt.svgObject.setAttribute('dominant-baseline', 'middle');
// Center equals in the equals gap we just reserved
const eqMid = cursorX - this.equalGap / 2;
eqTxt.setPosition(eqMid, baselineY);
this.addChild(eqTxt);
// Render right side tokens
cursorX = renderSide(rightTokens, cursorX, yUnitR, countRightX, false);
return { width: cursorX, height: maxH };
}
}