Skip to content

Instantly share code, notes, and snippets.

@richdougherty
Created July 4, 2025 22:09
Show Gist options
  • Save richdougherty/fbcafb3cb0ebe0dd0f415b42bee17072 to your computer and use it in GitHub Desktop.
Save richdougherty/fbcafb3cb0ebe0dd0f415b42bee17072 to your computer and use it in GitHub Desktop.
Multiplication squares - visualisation of multiplication tables
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiplication Squares</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@700&family=Nunito:wght@400;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Nunito', sans-serif;
margin: 0;
padding: 2em;
background-color: #f9f9f9;
color: #333;
}
.container {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
#svg-container {
width: 100%;
height: 100%;
}
#times-table-svg {
width: 100%;
height: auto;
display: block;
}
/* A subtle style for linked text in the SVG */
#times-table-svg a text {
fill: #555;
text-decoration: none;
}
#times-table-svg a:hover text {
text-decoration: underline;
}
/* Print-specific styles for A4 Portrait */
@media print {
body {
margin: 0;
padding: 0;
background-color: #fff;
}
.container {
width: 210mm;
height: 297mm;
padding: 10mm;
box-sizing: border-box;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
#svg-container {
width: 190mm;
height: 190mm;
}
}
</style>
</head>
<body>
<div class="container">
<div id="svg-container">
<!-- SVG will be generated here -->
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const SVG_NS = "http://www.w3.org/2000/svg";
const container = document.getElementById('svg-container');
// --- Configuration ---
const GRID_MAX = 9;
const CELL_SIZE = 100;
const UNIT_SCALE = 6;
const STROKE_WIDTH = 1;
const TICK_SIZE = 3;
const AXIS_LABEL_FONT_SIZE = 16;
const CELL_LABEL_FONT_SIZE = 12;
const LABEL_OFFSET = 8;
// --- SVG Setup ---
const svg = document.createElementNS(SVG_NS, 'svg');
const totalGridSize = (GRID_MAX + 1) * CELL_SIZE;
const viewBoxSize = Math.ceil(totalGridSize * Math.sqrt(2)) + CELL_SIZE;
svg.setAttribute('id', 'times-table-svg');
svg.setAttribute('viewBox', `0 0 ${viewBoxSize} ${viewBoxSize}`);
const mainGroup = document.createElementNS(SVG_NS, 'g');
const mainTransform = `
translate(${viewBoxSize / 2}, ${viewBoxSize / 2})
rotate(45)
translate(${-totalGridSize / 2}, ${-totalGridSize / 2})
`;
mainGroup.setAttribute('transform', mainTransform);
svg.appendChild(mainGroup);
// --- Helper Function ---
function createElement(type, attributes) {
const el = document.createElementNS(SVG_NS, type);
for (const key in attributes) {
el.setAttribute(key, attributes[key]);
}
return el;
}
// --- Drawing Logic ---
// 1. Create the Title as an SVG element for robust positioning
const titleSize = CELL_SIZE * 0.4;
const diagonalHalfLength = (totalGridSize * Math.sqrt(2)) / 2;
const titleOffset = CELL_SIZE * 0.3; // Pushes title further up-left
const titleX = viewBoxSize / 2 - diagonalHalfLength / 2 - titleSize / 2 - titleOffset;
const titleY = viewBoxSize / 2 - diagonalHalfLength / 2 - titleSize / 2 - titleOffset;
const title = createElement('text', {
x: titleX,
y: titleY,
transform: `rotate(-45, ${titleX}, ${titleY})`,
'font-family': 'Archivo, sans-serif',
'font-size': titleSize,
'font-weight': '700',
fill: '#888',
'text-anchor': 'middle',
dy: '.3em'
});
title.textContent = "multiplication squares";
svg.appendChild(title);
// 2. Draw Axis Labels and Main Grid Lines
const axisLabelGroup = createElement('g');
mainGroup.appendChild(axisLabelGroup);
const gridLinesGroup = createElement('g', { stroke: '#ccc', 'stroke-width': STROKE_WIDTH });
mainGroup.appendChild(gridLinesGroup);
for (let i = 0; i <= GRID_MAX; i++) {
const pos = (i + 1) * CELL_SIZE;
if (i < GRID_MAX) {
gridLinesGroup.appendChild(createElement('line', { x1: 0, y1: pos, x2: totalGridSize, y2: pos }));
gridLinesGroup.appendChild(createElement('line', { x1: pos, y1: 0, x2: pos, y2: totalGridSize }));
}
const axisTextDefaults = { x: 0, y: 0, 'text-anchor': 'middle', dy: '.3em', 'font-size': AXIS_LABEL_FONT_SIZE, 'font-weight': 'bold' };
const colLabelX = (i + 0.5) * CELL_SIZE;
const colLabelY = -25;
const colLabelTextGroup = createElement('g', { transform: `translate(${colLabelX}, ${colLabelY}) rotate(-45)` });
colLabelTextGroup.appendChild(createElement('text', axisTextDefaults)).textContent = i;
axisLabelGroup.appendChild(colLabelTextGroup);
const rowLabelX = -25;
const rowLabelY = (i + 0.5) * CELL_SIZE;
const rowLabelTextGroup = createElement('g', { transform: `translate(${rowLabelX}, ${rowLabelY}) rotate(-45)` });
rowLabelTextGroup.appendChild(createElement('text', axisTextDefaults)).textContent = i;
axisLabelGroup.appendChild(rowLabelTextGroup);
}
// 3. Draw each cell's contents
for (let row = 0; row <= GRID_MAX; row++) {
for (let col = 0; col <= GRID_MAX; col++) {
const cellGroup = createElement('g', { transform: `translate(${col * CELL_SIZE}, ${row * CELL_SIZE})` });
mainGroup.appendChild(cellGroup);
const rectWidth = col * UNIT_SCALE;
const rectHeight = row * UNIT_SCALE;
const rectX = (CELL_SIZE - rectWidth) / 2;
const rectY = (CELL_SIZE - rectHeight) / 2;
// Draw the shape (rectangle, line, or point)
if (row === 0 && col === 0) {
cellGroup.appendChild(createElement('circle', {
cx: rectX, cy: rectY, r: STROKE_WIDTH * 1.5, fill: 'black'
}));
} else {
// All other shapes are built from a base rectangle
const shapeGroup = createElement('g');
cellGroup.appendChild(shapeGroup);
shapeGroup.appendChild(createElement('rect', {
x: rectX, y: rectY, width: rectWidth, height: rectHeight,
fill: 'none', stroke: 'black', 'stroke-width': STROKE_WIDTH
}));
const innerGridGroup = createElement('g', { stroke: '#aaa', 'stroke-width': STROKE_WIDTH / 2 });
shapeGroup.appendChild(innerGridGroup);
for (let k = 1; k < col; k++) {
const x = rectX + k * UNIT_SCALE;
innerGridGroup.appendChild(createElement('line', { x1: x, y1: rectY, x2: x, y2: rectY + rectHeight }));
}
for (let k = 1; k < row; k++) {
const y = rectY + k * UNIT_SCALE;
innerGridGroup.appendChild(createElement('line', { x1: rectX, y1: y, x2: rectX + rectWidth, y2: y }));
}
const tickGroup = createElement('g', { stroke: 'black', 'stroke-width': STROKE_WIDTH });
shapeGroup.appendChild(tickGroup);
for (let k = 0; k <= col; k++) {
const x = rectX + k * UNIT_SCALE;
tickGroup.appendChild(createElement('line', { x1: x, y1: rectY, x2: x, y2: rectY - TICK_SIZE }));
}
for (let k = 0; k <= row; k++) {
const y = rectY + k * UNIT_SCALE;
tickGroup.appendChild(createElement('line', { x1: rectX, y1: y, x2: rectX - TICK_SIZE, y2: y }));
}
}
// Draw the un-rotated text labels for EVERY cell
const textDefaults = { 'font-size': CELL_LABEL_FONT_SIZE, fill: '#333', 'text-anchor': 'middle', dy: '.3em' };
function createTextLabel(value, x, y, isBold = false) {
const textGroup = createElement('g', { transform: `translate(${x}, ${y}) rotate(-45)`});
const text = createElement('text', { x:0, y:0, ...textDefaults });
if (isBold) text.setAttribute('font-weight', 'bold');
text.textContent = value;
textGroup.appendChild(text);
cellGroup.appendChild(textGroup);
}
createTextLabel(row, rectX - TICK_SIZE - LABEL_OFFSET, rectY + rectHeight / 2);
createTextLabel(col, rectX + rectWidth / 2, rectY - TICK_SIZE - LABEL_OFFSET);
createTextLabel(row * col, rectX + rectWidth + LABEL_OFFSET + 4, rectY + rectHeight + LABEL_OFFSET + 4, true);
}
}
// 4. Create Attribution block
const attributionGroup = createElement('g');
const ATTRIBUTION_FONT_SIZE = 14;
const ATTRIBUTION_MARGIN = 25;
const attrBaseX = viewBoxSize / 2 + diagonalHalfLength - ATTRIBUTION_MARGIN;
const attrBaseY = viewBoxSize / 2 + diagonalHalfLength - ATTRIBUTION_MARGIN;
const lineHeight = ATTRIBUTION_FONT_SIZE * 1.4;
const textAttr = {
x: attrBaseX,
'font-family': 'Nunito, sans-serif',
'font-size': ATTRIBUTION_FONT_SIZE,
'text-anchor': 'end',
fill: '#888'
};
// Line 1: Dedication
const line1 = createElement('text', { ...textAttr, y: attrBaseY - lineHeight * 2 });
line1.textContent = ""; // Anonymised
attributionGroup.appendChild(line1);
// Line 2: Copyright & Link
const line2Link = createElement('a', { href: 'https://rd.nz', target: '_blank', rel: 'noopener' });
const line2 = createElement('text', { ...textAttr, y: attrBaseY - lineHeight });
line2.textContent = "© 2025 Rich Dougherty";
line2Link.appendChild(line2);
attributionGroup.appendChild(line2Link);
// Line 3: Creative Commons License & Link
const line3Link = createElement('a', { href: 'https://creativecommons.org/licenses/by/4.0/', target: '_blank', rel: 'noopener' });
const line3 = createElement('text', { ...textAttr, y: attrBaseY });
line3.textContent = "Creative Commons (CC BY 4.0)";
line3Link.appendChild(line3);
attributionGroup.appendChild(line3Link);
svg.appendChild(attributionGroup);
container.appendChild(svg);
});
</script>
</body>
</html>

Comments are disabled for this gist.