Created
July 4, 2025 22:09
-
-
Save richdougherty/fbcafb3cb0ebe0dd0f415b42bee17072 to your computer and use it in GitHub Desktop.
Multiplication squares - visualisation of multiplication tables
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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.