Add catalog subject banner feature: Introduce optional header images for subjects in the catalog, update CONTRIBUTING.md with guidelines for adding banners, and modify catalog data structure to support banner URLs. Enhance frontend components and styles to display banners effectively. Update TypeScript types and curriculum structure to accommodate new banner functionality.

This commit is contained in:
Hananoshika Yomaru 2026-05-20 12:40:51 -07:00
parent 8a7f6449a7
commit 7636bb4a86
35 changed files with 4140 additions and 10 deletions

View file

@ -0,0 +1,293 @@
+++
title = "Written calculation"
description = "Add, subtract, multiply, and divide whole numbers using vertical written form: align columns, regroup, and read the result."
template = "chapter.html"
weight = 2
[extra]
subject = "math"
chapter_id = "written-calculation"
strand = "Number & Algebra"
+++
<div class="book-prose">
<p>
You can read number sentences from <strong>basic math notation</strong> and use the
<strong>properties of operations</strong> to see why reordering or splitting sums can still give
the same answer. This chapter teaches
<strong>written calculation</strong> — how to add, subtract, multiply, and divide on paper by
stacking digits in <strong>vertical form</strong>: one number above another, digit under digit.
The ideas work for any size number; the visualizer below works best with about eight digits or
fewer per line.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Vertical form</p>
<p>
Line up the same places in columns, work from right to left, and write the answer under a horizontal
line. Carry and borrow keep each column within a single digit.
</p>
</div>
<h2 id="why-columns" class="book-prose__heading">Why columns?</h2>
<p>
In <strong>counting and numbers</strong> you learned <strong>place value</strong>: the ones column,
tens column, hundreds column. In vertical form, digits that share a place must sit in the same
column. If you shift a digit left or right, you are changing its value.
</p>
<p>
Always line up the <strong>ones</strong> under the ones, then tens under tens, and so on. Draw a
horizontal line under the stack before you write the answer.
</p>
<h2 id="carry-borrow" class="book-prose__heading">Carry and borrow</h2>
<p>
In the four operations, a column sometimes gives a digit from <strong>0 to 9</strong> and sometimes
gives <strong>10 or more</strong> (or too small to subtract). Then you adjust the next column to the
<strong>left</strong>.
</p>
<ul>
<li>
<strong>Carry</strong> — in addition, when a column sums to 10 or more, you write the ones
digit of that sum in the column and pass the extra ten to the next column on the left.
</li>
<li>
<strong>Borrow</strong> — in subtraction, when the top digit is smaller than the bottom
digit, you take one ten from the column on the left, add 10 to the current column, then subtract.
</li>
</ul>
<p>Work from the <strong>right</strong> (ones) toward the <strong>left</strong>, one column at a time.</p>
<h3 id="carry-example" class="book-prose__heading">Carry: 27 + 59 = 86</h3>
<pre class="va-static" aria-label="27 plus 59 equals 86 with carry"><span class="va-static__carry">¹</span>
27
+ 59
────
86</pre>
<p>
In the ones column: \(7 + 9 = 16\). Write <strong>6</strong> in the ones place. Carry
<strong>1</strong> to the tens column (written small above the tens). In the tens column:
\(2 + 5 + 1 = 8\).
</p>
<h3 id="borrow-example" class="book-prose__heading">Borrow: 47 − 19 = 28</h3>
<pre class="va-static" aria-label="47 minus 19 equals 28 with borrow"><span class="va-static__borrow">−1</span>
47
− 19
────
28</pre>
<p>
In the ones column: \(7 - 9\) does not work. Borrow 10 from the tens: the <strong>4</strong> becomes
<strong>3</strong>, and the ones digit becomes <strong>17</strong>. Then \(17 - 9 = 8\). In the tens
column: \(3 - 1 = 2\).
</p>
<h2 id="addition" class="book-prose__heading">Addition</h2>
<p>
Write the numbers one above the other, plus sign to the left of the bottom number, line underneath,
sum below. Add each column from right to left; carry when needed.
</p>
<h2 id="subtraction" class="book-prose__heading">Subtraction</h2>
<p>
Put the <strong>larger</strong> number on top. Subtract column by column. When the top digit is too
small, borrow from the left (decrease that digit by 1, add 10 to the current column). Example with a
zero in the middle: \(503 - 278 = 225\) needs borrowing across the tens column.
</p>
<h2 id="multiplication" class="book-prose__heading">Multiplication</h2>
<p>
Write the larger number on top (often). Multiply the top number by each digit of the bottom number,
one <strong>partial product</strong> per digit, shifted so ones line up with ones. Add the partial
products for the final answer.
</p>
<pre class="va-static" aria-label="23 times 14 equals 322">
23
× 14
────
92
230
────
322</pre>
<p>Here \(23 \times 4 = 92\) and \(23 \times 10 = 230\); \(92 + 230 = 322\).</p>
<h2 id="division" class="book-prose__heading">Division</h2>
<p>
<strong>Long division</strong> writes the divisor outside a bracket, the dividend inside, and the
quotient on top. At each step: how many times does the divisor fit into the current part of the
dividend? Write that digit in the quotient, multiply, subtract, then bring down the next digit. If
the division is not exact, the amount left is the <strong>remainder</strong>.
</p>
<h2 id="terminology" class="book-prose__heading">Names of the parts</h2>
<div class="book-table-wrap">
<table class="book-table written-terms-table">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Parts</th>
<th scope="col">Answer</th>
</tr>
</thead>
<tbody>
<tr>
<td>Addition</td>
<td>addend + addend</td>
<td>sum</td>
</tr>
<tr>
<td>Subtraction</td>
<td>minuend − subtrahend</td>
<td>difference</td>
</tr>
<tr>
<td>Multiplication</td>
<td>factor × factor</td>
<td>product</td>
</tr>
<tr>
<td>Division</td>
<td>dividend ÷ divisor</td>
<td>quotient (and remainder if any)</td>
</tr>
</tbody>
</table>
</div>
<h2 id="try-it-yourself" class="book-prose__heading">Try it yourself</h2>
<p>
Use the visualizer below: enter two or more whole numbers (one per line), choose an operation, and
click <strong>Generate visualization</strong>. Step through with <strong>Play</strong> or the arrow
buttons to see each carry, borrow, and column update.
</p>
<div class="math-widget-mount" data-widget="vertical-arithmetic-workbench" data-pagefind-ignore></div>
</div>
<div class="book-prose">
<h2 id="history" class="book-prose__heading">History</h2>
<p>
People calculated with tokens, rods, and abacuses long before everyone used pencil and paper. In many
places, including China, India, and the Arab world, scribes lined up digits in columns so addition
and subtraction could be done digit by digit.
</p>
<p>
<strong>Mechanical calculators</strong> (1600s–1900s) had to solve the same puzzle: when one wheel
moved from 9 to 0, it had to add 1 to the wheel for the next place. Early machines often did addition
first, then ran a separate <strong>carry cycle</strong> to update higher digits. Later designs passed
the carry instantly along the whole machine. That idea appears again inside modern chips: a
<strong>carry flag</strong> records whether the last step overflowed a column.
</p>
<h2 id="derivation" class="book-prose__heading">Derivation</h2>
<h3 id="place-value-add" class="book-prose__heading">Why column addition works</h3>
<p>
Split each number by place value. For example,
\(27 + 59 = (20 + 7) + (50 + 9) = (20 + 50) + (7 + 9) = 70 + 16 = 86\). The ones column adds
\(7 + 9\); the tens column adds \(2 + 5\) plus any carry. Each column only mixes the same place —
that is why alignment matters.
</p>
<h3 id="inverse" class="book-prose__heading">Subtraction and addition</h3>
<p>
Subtraction is the inverse of addition: \(c = a - b\) means the same as \(c + b = a\). Borrowing
moves one ten from the left so the subtraction in the current column matches that story.
</p>
<h2 id="checkpoints" class="book-prose__heading">Checkpoints</h2>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>When you add 7 and 9 in the ones column, what do you write in the ones place? What do you carry?</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>In \(47 - 19\), why does the 4 in the tens column become 3 before you subtract the ones?</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>In \(23 \times 14\), why are there two rows under the line before the final sum?</p>
</div>
<h2 id="applications" class="book-prose__heading">Real life</h2>
<ul>
<li><strong>Shopping</strong> — adding prices on a receipt column by column.</li>
<li><strong>Scores</strong> — finding the difference between two team totals.</li>
<li><strong>Sharing</strong> — dividing a total equally (e.g. \(157\) stickers among \(12\) children: quotient 13, remainder 1).</li>
</ul>
<h2 id="questions-easy" class="book-prose__heading">Question bank — Easy</h2>
<details class="book-question">
<summary class="book-question__prompt">Work out \(34 + 25\).</summary>
<div class="book-question__solution">
<pre class="va-static"> 34
+ 25
────
59</pre>
<p><strong>Answer:</strong> 59</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(61 - 28\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 33 (borrow from tens in the ones column).</p>
</div>
</details>
<h2 id="questions-intermediate" class="book-prose__heading">Intermediate</h2>
<details class="book-question">
<summary class="book-question__prompt">Work out \(27 + 59\).</summary>
<div class="book-question__solution">
<pre class="va-static"><span class="va-static__carry">¹</span>
27
+ 59
────
86</pre>
<p><strong>Answer:</strong> 86</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(503 - 278\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 225</p>
<p><strong>Steps:</strong> Borrow across the tens (0) from hundreds; ones become 13−8=5; tens 9−7=2; hundreds 4−2=2.</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(23 \times 14\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 322</p>
</div>
</details>
<h2 id="questions-difficult" class="book-prose__heading">Difficult</h2>
<details class="book-question">
<summary class="book-question__prompt">Work out \(156 \div 12\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 13</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(157 \div 12\) and state the remainder.</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> Quotient 13, remainder 1.</p>
</div>
</details>
<h2 id="questions-hardcore" class="book-prose__heading">Hardcore</h2>
<details class="book-question">
<summary class="book-question__prompt">A book has 864 pages. You read 379 pages. How many pages are left?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \(864 - 379 = 485\) pages.</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">A classroom orders 24 packs of 15 pencils. How many pencils is that?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \(24 \times 15 = 360\) pencils.</p>
</div>
</details>
<h2 id="references" class="book-prose__heading">References</h2>
<ul>
<li>Wikipedia — <em>Carry (arithmetic)</em> and <em>Subtraction</em>.</li>
<li>Cajori, Florian — <em>A History of Mathematical Notations</em>.</li>
<li>Other school methods (Austrian/additions, partial differences) exist; this chapter uses column borrow as in most HK primary texts.</li>
</ul>
</div>

View file

@ -0,0 +1,371 @@
// Break out of prose max-width (68ch) to the same width as .container
.book-prose .math-widget-mount:has(.va-workbench) {
max-width: none;
}
.va-workbench {
--va-text: var(--ink);
--va-operator: var(--muted);
--va-carry: #dc2626;
--va-borrow: var(--accent);
--va-result: var(--success);
--va-line: var(--line);
--va-highlight: color-mix(in srgb, var(--accent) 14%, transparent);
--va-highlight-border: color-mix(in srgb, var(--accent) 38%, transparent);
--va-error: #dc2626;
--va-canvas-dot: color-mix(in srgb, var(--line) 90%, transparent);
--va-panel-bg: var(--card-bg);
--va-panel-border: var(--card-border);
--va-stage-bg: var(--paper);
width: min(1100px, 92vw);
max-width: none;
margin-top: 1.25rem;
}
[data-theme="dark"] .va-workbench,
:root:not([data-theme="light"]) .va-workbench {
--va-carry: #f87171;
--va-error: #f87171;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .va-workbench {
--va-carry: #f87171;
--va-error: #f87171;
}
}
.va-workbench__shell {
display: flex;
flex-direction: column;
min-height: 28rem;
border: 1px solid var(--va-panel-border);
border-radius: 14px;
overflow: hidden;
background: var(--va-panel-bg);
box-shadow: 0 10px 32px var(--shadow-soft);
}
@media (min-width: 768px) {
.va-workbench__shell {
flex-direction: row;
min-height: 32rem;
}
}
.va-workbench__sidebar {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--line);
background: var(--va-panel-bg);
overflow-y: auto;
}
@media (min-width: 768px) {
.va-workbench__sidebar {
width: 18rem;
border-bottom: none;
border-right: 1px solid var(--line);
}
}
.va-workbench__brand {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.va-workbench__brand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
background: var(--accent);
color: var(--paper);
font-size: 1.25rem;
font-weight: 700;
box-shadow: 0 4px 12px var(--shadow-soft);
}
.va-workbench__brand-text {
font-size: 1rem;
line-height: 1.3;
color: var(--ink);
}
.va-workbench__brand-text strong {
color: var(--accent);
font-weight: 600;
}
.va-workbench__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.va-workbench__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--soft-ink);
}
.va-workbench__op-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.va-workbench__op-btn {
font: inherit;
font-size: 1.25rem;
font-weight: 700;
padding: 0.5rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: color-mix(in srgb, var(--paper) 60%, var(--card-bg));
color: var(--soft-ink);
cursor: pointer;
transition:
background 0.12s ease,
border-color 0.12s ease,
color 0.12s ease;
}
.va-workbench__op-btn:hover {
border-color: var(--accent);
color: var(--ink);
}
.va-workbench__op-btn--active {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 12%, var(--card-bg));
color: var(--accent);
}
.va-workbench__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-family: var(--mono);
font-size: 1.0625rem;
line-height: 1.4;
color: var(--ink);
background: color-mix(in srgb, var(--paper) 50%, var(--card-bg));
border: 1px solid var(--line);
border-radius: 0.5rem;
resize: vertical;
min-height: 5rem;
}
.va-workbench__textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.va-workbench__hint,
.va-workbench__footer-note {
font-size: 0.75rem;
line-height: 1.45;
color: var(--muted);
margin: 0;
}
.va-workbench__generate {
font: inherit;
font-weight: 600;
width: 100%;
padding: 0.75rem 1rem;
border: none;
border-radius: 0.5rem;
background: var(--accent);
color: var(--paper);
cursor: pointer;
box-shadow: 0 4px 14px var(--shadow-soft);
transition:
filter 0.12s ease,
transform 0.08s ease;
}
.va-workbench__generate:hover {
filter: brightness(1.06);
}
.va-workbench__generate:active {
transform: scale(0.98);
}
.va-workbench__anim {
padding-top: 1rem;
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.va-workbench__transport {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: color-mix(in srgb, var(--paper) 40%, var(--card-bg));
}
.va-workbench__transport-btn {
font: inherit;
font-size: 1.5rem;
line-height: 1;
padding: 0.35rem 0.65rem;
border: none;
border-radius: 0.35rem;
background: transparent;
color: var(--soft-ink);
cursor: pointer;
}
.va-workbench__transport-btn:hover {
color: var(--accent);
background: var(--card-bg);
}
.va-workbench__play {
font: inherit;
font-weight: 600;
flex: 1;
padding: 0.45rem 1rem;
border: none;
border-radius: 0.35rem;
background: var(--accent);
color: var(--paper);
cursor: pointer;
}
.va-workbench__play:hover {
filter: brightness(1.06);
}
.va-workbench__speed {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--muted);
}
.va-workbench__speed-slider {
flex: 1;
accent-color: var(--accent);
}
.va-workbench__footer-note {
margin-top: auto;
text-align: center;
}
.va-workbench__stage {
flex: 1;
display: flex;
flex-direction: column;
min-height: 16rem;
background: var(--va-stage-bg);
position: relative;
}
.va-workbench__message-box {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2;
width: min(92%, 32rem);
padding: 0.85rem 1.1rem;
text-align: center;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
backdrop-filter: blur(8px);
box-shadow: 0 8px 24px var(--shadow-soft);
}
.va-workbench__message-box--error .va-workbench__message {
color: var(--va-error);
}
.va-workbench__message {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
margin: 0 0 0.5rem;
line-height: 1.4;
}
.va-workbench__progress-track {
width: 100%;
height: 0.35rem;
border-radius: 999px;
background: var(--line);
overflow: hidden;
}
.va-workbench__progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.25s ease;
}
.va-workbench__step-counter {
font-size: 0.75rem;
color: var(--muted);
margin: 0.35rem 0 0;
}
.va-workbench__canvas-wrap {
flex: 1;
position: relative;
min-height: 14rem;
background-color: var(--va-stage-bg);
background-image: radial-gradient(var(--va-canvas-dot) 1px, transparent 1px);
background-size: 20px 20px;
}
.va-workbench__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.va-static {
font-family: var(--mono);
font-size: 1.25rem;
line-height: 1.35;
letter-spacing: 0.08em;
margin: 1rem 0;
padding: 1rem 1.25rem;
color: var(--ink);
background: color-mix(in srgb, var(--paper) 50%, var(--card-bg));
border: 1px solid var(--line);
border-radius: 0.5rem;
overflow-x: auto;
}
.va-static__carry,
.va-static__borrow {
font-size: 0.85em;
vertical-align: super;
}
.book-prose .written-terms-table td:first-child {
font-weight: 600;
white-space: nowrap;
}

View file

@ -0,0 +1,152 @@
<div class="book-prose">
<p>
You can read number sentences from <strong>basic math notation</strong> and use the
<strong>properties of operations</strong> to see why reordering or splitting sums can still give
the same answer. This chapter teaches
<strong>written calculation</strong> — how to add, subtract, multiply, and divide on paper by
stacking digits in <strong>vertical form</strong>: one number above another, digit under digit.
The ideas work for any size number; the visualizer below works best with about eight digits or
fewer per line.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Vertical form</p>
<p>
Line up the same places in columns, work from right to left, and write the answer under a horizontal
line. Carry and borrow keep each column within a single digit.
</p>
</div>
<h2 id="why-columns" class="book-prose__heading">Why columns?</h2>
<p>
In <strong>counting and numbers</strong> you learned <strong>place value</strong>: the ones column,
tens column, hundreds column. In vertical form, digits that share a place must sit in the same
column. If you shift a digit left or right, you are changing its value.
</p>
<p>
Always line up the <strong>ones</strong> under the ones, then tens under tens, and so on. Draw a
horizontal line under the stack before you write the answer.
</p>
<h2 id="carry-borrow" class="book-prose__heading">Carry and borrow</h2>
<p>
In the four operations, a column sometimes gives a digit from <strong>0 to 9</strong> and sometimes
gives <strong>10 or more</strong> (or too small to subtract). Then you adjust the next column to the
<strong>left</strong>.
</p>
<ul>
<li>
<strong>Carry</strong> — in addition, when a column sums to 10 or more, you write the ones
digit of that sum in the column and pass the extra ten to the next column on the left.
</li>
<li>
<strong>Borrow</strong> — in subtraction, when the top digit is smaller than the bottom
digit, you take one ten from the column on the left, add 10 to the current column, then subtract.
</li>
</ul>
<p>Work from the <strong>right</strong> (ones) toward the <strong>left</strong>, one column at a time.</p>
<h3 id="carry-example" class="book-prose__heading">Carry: 27 + 59 = 86</h3>
<pre class="va-static" aria-label="27 plus 59 equals 86 with carry"><span class="va-static__carry">¹</span>
27
+ 59
────
86</pre>
<p>
In the ones column: \(7 + 9 = 16\). Write <strong>6</strong> in the ones place. Carry
<strong>1</strong> to the tens column (written small above the tens). In the tens column:
\(2 + 5 + 1 = 8\).
</p>
<h3 id="borrow-example" class="book-prose__heading">Borrow: 47 − 19 = 28</h3>
<pre class="va-static" aria-label="47 minus 19 equals 28 with borrow"><span class="va-static__borrow">−1</span>
47
− 19
────
28</pre>
<p>
In the ones column: \(7 - 9\) does not work. Borrow 10 from the tens: the <strong>4</strong> becomes
<strong>3</strong>, and the ones digit becomes <strong>17</strong>. Then \(17 - 9 = 8\). In the tens
column: \(3 - 1 = 2\).
</p>
<h2 id="addition" class="book-prose__heading">Addition</h2>
<p>
Write the numbers one above the other, plus sign to the left of the bottom number, line underneath,
sum below. Add each column from right to left; carry when needed.
</p>
<h2 id="subtraction" class="book-prose__heading">Subtraction</h2>
<p>
Put the <strong>larger</strong> number on top. Subtract column by column. When the top digit is too
small, borrow from the left (decrease that digit by 1, add 10 to the current column). Example with a
zero in the middle: \(503 - 278 = 225\) needs borrowing across the tens column.
</p>
<h2 id="multiplication" class="book-prose__heading">Multiplication</h2>
<p>
Write the larger number on top (often). Multiply the top number by each digit of the bottom number,
one <strong>partial product</strong> per digit, shifted so ones line up with ones. Add the partial
products for the final answer.
</p>
<pre class="va-static" aria-label="23 times 14 equals 322">
23
× 14
────
92
230
────
322</pre>
<p>Here \(23 \times 4 = 92\) and \(23 \times 10 = 230\); \(92 + 230 = 322\).</p>
<h2 id="division" class="book-prose__heading">Division</h2>
<p>
<strong>Long division</strong> writes the divisor outside a bracket, the dividend inside, and the
quotient on top. At each step: how many times does the divisor fit into the current part of the
dividend? Write that digit in the quotient, multiply, subtract, then bring down the next digit. If
the division is not exact, the amount left is the <strong>remainder</strong>.
</p>
<h2 id="terminology" class="book-prose__heading">Names of the parts</h2>
<div class="book-table-wrap">
<table class="book-table written-terms-table">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Parts</th>
<th scope="col">Answer</th>
</tr>
</thead>
<tbody>
<tr>
<td>Addition</td>
<td>addend + addend</td>
<td>sum</td>
</tr>
<tr>
<td>Subtraction</td>
<td>minuend − subtrahend</td>
<td>difference</td>
</tr>
<tr>
<td>Multiplication</td>
<td>factor × factor</td>
<td>product</td>
</tr>
<tr>
<td>Division</td>
<td>dividend ÷ divisor</td>
<td>quotient (and remainder if any)</td>
</tr>
</tbody>
</table>
</div>
<h2 id="try-it-yourself" class="book-prose__heading">Try it yourself</h2>
<p>
Use the visualizer below: enter two or more whole numbers (one per line), choose an operation, and
click <strong>Generate visualization</strong>. Step through with <strong>Play</strong> or the arrow
buttons to see each carry, borrow, and column update.
</p>
<div class="math-widget-mount" data-widget="vertical-arithmetic-workbench" data-pagefind-ignore></div>
</div>

View file

@ -0,0 +1,129 @@
<div class="book-prose">
<h2 id="history" class="book-prose__heading">History</h2>
<p>
People calculated with tokens, rods, and abacuses long before everyone used pencil and paper. In many
places, including China, India, and the Arab world, scribes lined up digits in columns so addition
and subtraction could be done digit by digit.
</p>
<p>
<strong>Mechanical calculators</strong> (1600s–1900s) had to solve the same puzzle: when one wheel
moved from 9 to 0, it had to add 1 to the wheel for the next place. Early machines often did addition
first, then ran a separate <strong>carry cycle</strong> to update higher digits. Later designs passed
the carry instantly along the whole machine. That idea appears again inside modern chips: a
<strong>carry flag</strong> records whether the last step overflowed a column.
</p>
<h2 id="derivation" class="book-prose__heading">Derivation</h2>
<h3 id="place-value-add" class="book-prose__heading">Why column addition works</h3>
<p>
Split each number by place value. For example,
\(27 + 59 = (20 + 7) + (50 + 9) = (20 + 50) + (7 + 9) = 70 + 16 = 86\). The ones column adds
\(7 + 9\); the tens column adds \(2 + 5\) plus any carry. Each column only mixes the same place —
that is why alignment matters.
</p>
<h3 id="inverse" class="book-prose__heading">Subtraction and addition</h3>
<p>
Subtraction is the inverse of addition: \(c = a - b\) means the same as \(c + b = a\). Borrowing
moves one ten from the left so the subtraction in the current column matches that story.
</p>
<h2 id="checkpoints" class="book-prose__heading">Checkpoints</h2>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>When you add 7 and 9 in the ones column, what do you write in the ones place? What do you carry?</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>In \(47 - 19\), why does the 4 in the tens column become 3 before you subtract the ones?</p>
</div>
<div class="book-callout" role="note">
<p class="book-callout__label">Checkpoint</p>
<p>In \(23 \times 14\), why are there two rows under the line before the final sum?</p>
</div>
<h2 id="applications" class="book-prose__heading">Real life</h2>
<ul>
<li><strong>Shopping</strong> — adding prices on a receipt column by column.</li>
<li><strong>Scores</strong> — finding the difference between two team totals.</li>
<li><strong>Sharing</strong> — dividing a total equally (e.g. \(157\) stickers among \(12\) children: quotient 13, remainder 1).</li>
</ul>
<h2 id="questions-easy" class="book-prose__heading">Question bank — Easy</h2>
<details class="book-question">
<summary class="book-question__prompt">Work out \(34 + 25\).</summary>
<div class="book-question__solution">
<pre class="va-static"> 34
+ 25
────
59</pre>
<p><strong>Answer:</strong> 59</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(61 - 28\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 33 (borrow from tens in the ones column).</p>
</div>
</details>
<h2 id="questions-intermediate" class="book-prose__heading">Intermediate</h2>
<details class="book-question">
<summary class="book-question__prompt">Work out \(27 + 59\).</summary>
<div class="book-question__solution">
<pre class="va-static"><span class="va-static__carry">¹</span>
27
+ 59
────
86</pre>
<p><strong>Answer:</strong> 86</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(503 - 278\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 225</p>
<p><strong>Steps:</strong> Borrow across the tens (0) from hundreds; ones become 13−8=5; tens 9−7=2; hundreds 4−2=2.</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(23 \times 14\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 322</p>
</div>
</details>
<h2 id="questions-difficult" class="book-prose__heading">Difficult</h2>
<details class="book-question">
<summary class="book-question__prompt">Work out \(156 \div 12\).</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> 13</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Work out \(157 \div 12\) and state the remainder.</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> Quotient 13, remainder 1.</p>
</div>
</details>
<h2 id="questions-hardcore" class="book-prose__heading">Hardcore</h2>
<details class="book-question">
<summary class="book-question__prompt">A book has 864 pages. You read 379 pages. How many pages are left?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \(864 - 379 = 485\) pages.</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">A classroom orders 24 packs of 15 pencils. How many pencils is that?</summary>
<div class="book-question__solution">
<p><strong>Answer:</strong> \(24 \times 15 = 360\) pencils.</p>
</div>
</details>
<h2 id="references" class="book-prose__heading">References</h2>
<ul>
<li>Wikipedia — <em>Carry (arithmetic)</em> and <em>Subtraction</em>.</li>
<li>Cajori, Florian — <em>A History of Mathematical Notations</em>.</li>
<li>Other school methods (Austrian/additions, partial differences) exist; this chapter uses column borrow as in most HK primary texts.</li>
</ul>
</div>

View file

@ -0,0 +1,841 @@
export type VisualizerColors = {
text: string;
operator: string;
carry: string;
borrow: string;
result: string;
line: string;
highlight: string;
highlightBorder: string;
error: string;
};
type CellData = {
text?: string;
color?: string;
crossedOut?: boolean;
smallText?: string;
smallColor?: string;
};
type Frame = {
cells: Record<string, CellData>;
lines: Array<{ y: number; startX: number; endX: number }>;
highlights: Array<{ x: number; y: number; w: number; h: number }>;
message: string;
};
export class MathVisualizer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private wrapper: HTMLElement;
private themeRoot: HTMLElement;
frames: Frame[] = [];
currentFrame = 0;
isPlaying = false;
private playInterval: ReturnType<typeof setInterval> | null = null;
speed = 800;
operation: "+" | "-" | "*" | "/" = "+";
private colors: VisualizerColors = {
text: "#1e293b",
operator: "#64748b",
carry: "#ef4444",
borrow: "#9a6b2e",
result: "#16a34a",
line: "#cbd5e1",
highlight: "rgba(59, 130, 246, 0.15)",
highlightBorder: "rgba(59, 130, 246, 0.4)",
error: "#ef4444",
};
onMessage?: (msg: string, isError?: boolean) => void;
onStep?: (current: number, total: number) => void;
onPlayState?: (playing: boolean) => void;
constructor(opts: {
canvas: HTMLCanvasElement;
wrapper: HTMLElement;
themeRoot: HTMLElement;
}) {
this.canvas = opts.canvas;
const ctx = opts.canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D not available");
this.ctx = ctx;
this.wrapper = opts.wrapper;
this.themeRoot = opts.themeRoot;
this.refreshColors();
}
refreshColors() {
const s = getComputedStyle(this.themeRoot);
const get = (name: string, fallback: string) =>
s.getPropertyValue(name).trim() || fallback;
this.colors = {
text: get("--va-text", "#1a1a1a"),
operator: get("--va-operator", "#767676"),
carry: get("--va-carry", "#dc2626"),
borrow: get("--va-borrow", "#9a6b2e"),
result: get("--va-result", "#2a7d52"),
line: get("--va-line", "#e8e8e8"),
highlight: get("--va-highlight", "rgba(154, 107, 46, 0.15)"),
highlightBorder: get("--va-highlight-border", "rgba(154, 107, 46, 0.4)"),
error: get("--va-error", "#dc2626"),
};
}
resizeCanvas() {
const rect = this.wrapper.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.scale(dpr, dpr);
this.canvas.style.width = `${rect.width}px`;
this.canvas.style.height = `${rect.height}px`;
this.render();
}
setSpeed(ms: number) {
this.speed = ms;
if (this.isPlaying) {
this.pause();
this.play();
}
}
setOperation(op: "+" | "-" | "*" | "/") {
this.operation = op;
}
generateFromInput(raw: string) {
const lines = raw
.split("\n")
.map((s) => s.trim())
.filter((s) => /^\d+$/.test(s));
if (lines.length < 2) {
this.showMessage("Please enter at least two numbers.", true);
return;
}
this.frames = [];
this.currentFrame = 0;
this.pause();
try {
if (this.operation === "+") {
this.generateAddition(lines);
} else {
let current = lines[0];
const finalStepFrames: Frame[] = [];
for (let i = 1; i < lines.length; i++) {
const next = lines[i];
if (i > 1) {
const transFrame = this.createEmptyFrame();
transFrame.message = `Intermediate result: ${current}. Preparing next operation...`;
for (let k = 0; k < current.length; k++) {
this.setCell(transFrame, k, 1, {
text: current[k],
color: this.colors.result,
});
}
this.frames.push(transFrame);
}
if (this.operation === "-") {
this.generateSubtraction(current, next);
const diff = BigInt(current) - BigInt(next);
if (diff < 0n) {
throw new Error(
"Result dropped below zero. Negative visualization not supported."
);
}
current = diff.toString();
} else if (this.operation === "*") {
this.generateMultiplication(current, next);
current = (BigInt(current) * BigInt(next)).toString();
} else if (this.operation === "/") {
if (BigInt(next) === 0n) throw new Error("Cannot divide by zero.");
this.generateDivision(current, next);
current = (BigInt(current) / BigInt(next)).toString();
}
finalStepFrames.push(this.cloneGrid(this.frames[this.frames.length - 1]));
}
if (finalStepFrames.length > 1) {
this.frames.push(this.mergeFramesToSummary(finalStepFrames));
}
}
if (this.frames.length > 0) {
this.render();
this.play();
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.showMessage(`Error: ${msg}`, true);
}
}
private showMessage(msg: string, isError = false) {
this.onMessage?.(msg, isError);
}
private cloneGrid(grid: Frame): Frame {
return JSON.parse(JSON.stringify(grid)) as Frame;
}
private createEmptyFrame(): Frame {
return { cells: {}, lines: [], highlights: [], message: "" };
}
private setCell(frame: Frame, x: number, y: number, data: CellData) {
frame.cells[`${x},${y}`] = { ...frame.cells[`${x},${y}`], ...data };
}
private getCell(frame: Frame, x: number, y: number): CellData | null {
return frame.cells[`${x},${y}`] ?? null;
}
private mergeFramesToSummary(finalFrames: Frame[]): Frame {
const summaryFrame = this.createEmptyFrame();
summaryFrame.message = "Final Summary of All Steps";
let currentOffsetX = 0;
for (let i = 0; i < finalFrames.length; i++) {
const f = finalFrames[i];
let minX = Infinity;
let maxX = -Infinity;
let hasCells = false;
Object.keys(f.cells).forEach((key) => {
const [x] = key.split(",").map(Number);
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
hasCells = true;
});
if (!hasCells) continue;
const shiftX = currentOffsetX - minX;
Object.entries(f.cells).forEach(([key, cell]) => {
const [x, y] = key.split(",").map(Number);
summaryFrame.cells[`${x + shiftX},${y}`] = { ...cell };
});
f.lines.forEach((line) => {
summaryFrame.lines.push({
y: line.y,
startX: line.startX + shiftX,
endX: line.endX + shiftX,
});
});
if (i > 0) {
summaryFrame.cells[`${currentOffsetX - 2},2`] = {
text: "→",
color: this.colors.operator,
};
}
currentOffsetX += maxX - minX + 1 + 3;
}
return summaryFrame;
}
private generateAddition(numbers: string[]) {
const maxLen = Math.max(...numbers.map((s) => s.length));
const padded = numbers.map((s) => s.padStart(maxLen, "0"));
const frame = this.createEmptyFrame();
frame.message = `Setting up addition for ${numbers.join(" + ")}`;
const numCount = numbers.length;
const width = maxLen + 2;
this.setCell(frame, 0, numCount, { text: "+", color: this.colors.operator });
for (let r = 0; r < numCount; r++) {
const pStr = padded[r];
for (let i = 0; i < maxLen; i++) {
if (i >= maxLen - numbers[r].length) {
this.setCell(frame, i + 2, r + 1, { text: pStr[i], color: this.colors.text });
}
}
}
const lineY = numCount + 1;
frame.lines.push({ y: lineY, startX: 0, endX: width });
this.frames.push(this.cloneGrid(frame));
let carry = 0;
for (let i = maxLen - 1; i >= 0; i--) {
let sum = carry;
const columnDigits: number[] = [];
for (let r = 0; r < numCount; r++) {
const d = parseInt(padded[r][i], 10);
sum += d;
columnDigits.push(d);
}
const nextCarry = Math.floor(sum / 10);
const digit = sum % 10;
const col = i + 2;
const stepFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
stepFrame.highlights.push({
x: col,
y: carry > 0 ? 0 : 1,
w: 1,
h: carry > 0 ? numCount + 1 : numCount,
});
let msg = `Add column: ${columnDigits.join(" + ")}`;
if (carry > 0) msg += ` + ${carry} (carry) = ${sum}.`;
else msg += ` = ${sum}.`;
if (nextCarry > 0) msg += ` Write ${digit}, carry ${nextCarry}.`;
else msg += ` Write ${digit}.`;
stepFrame.message = msg;
this.frames.push(stepFrame);
const resultFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
resultFrame.highlights = [];
this.setCell(resultFrame, col, lineY + 1, {
text: digit.toString(),
color: this.colors.result,
});
if (nextCarry > 0) {
this.setCell(resultFrame, col - 1, 0, {
text: nextCarry.toString(),
color: this.colors.carry,
});
}
resultFrame.message = msg;
this.frames.push(resultFrame);
carry = nextCarry;
}
if (carry > 0) {
const finalFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
const carryStr = carry.toString();
for (let c = 0; c < carryStr.length; c++) {
this.setCell(finalFrame, 1 - (carryStr.length - 1 - c), lineY + 1, {
text: carryStr[c],
color: this.colors.result,
});
}
finalFrame.message = "Write final carry down.";
this.frames.push(finalFrame);
}
}
private generateSubtraction(s1: string, s2: string) {
const n1 = BigInt(s1);
const n2 = BigInt(s2);
if (n1 < n2) {
throw new Error(
"First number must be greater than or equal to second number for visualizer."
);
}
const maxLen = Math.max(s1.length, s2.length);
const p1 = s1.padStart(maxLen, "0").split("").map(Number);
const p2 = s2.padStart(maxLen, "0").split("").map(Number);
const frame = this.createEmptyFrame();
frame.message = `Setting up subtraction for ${s1} - ${s2}`;
const width = maxLen + 2;
this.setCell(frame, 0, 2, { text: "−", color: this.colors.operator });
for (let i = 0; i < maxLen; i++) {
if (i >= maxLen - s1.length) {
this.setCell(frame, i + 2, 1, { text: p1[i].toString(), color: this.colors.text });
}
if (i >= maxLen - s2.length) {
this.setCell(frame, i + 2, 2, { text: p2[i].toString(), color: this.colors.text });
}
}
frame.lines.push({ y: 3, startX: 0, endX: width });
this.frames.push(this.cloneGrid(frame));
for (let i = maxLen - 1; i >= 0; i--) {
let d1 = p1[i];
const d2 = p2[i];
const col = i + 2;
if (d1 < d2) {
let borrowIdx = i - 1;
while (borrowIdx >= 0 && p1[borrowIdx] === 0) borrowIdx--;
for (let j = borrowIdx; j < i; j++) {
const bFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
const cellToCross = this.getCell(bFrame, j + 2, 1);
this.setCell(bFrame, j + 2, 1, { crossedOut: true });
const newVal = p1[j] === 0 ? 9 : p1[j] - 1;
p1[j] = newVal;
this.setCell(bFrame, j + 2, 0, {
text: newVal.toString(),
color: this.colors.borrow,
});
if (j < i - 1) {
bFrame.message = "Borrowing across zero. Cross out 0, make it 9.";
} else {
bFrame.message = `Borrow 1 from ${cellToCross?.text ?? ""}, leaving ${newVal}.`;
}
bFrame.highlights = [{ x: j + 2, y: 0, w: 1, h: 2 }];
this.frames.push(bFrame);
}
p1[i] += 10;
d1 = p1[i];
const addFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
this.setCell(addFrame, col, 1, { crossedOut: true });
this.setCell(addFrame, col, 0, { text: d1.toString(), color: this.colors.borrow });
addFrame.message = `Add 10 to current column, making it ${d1}.`;
addFrame.highlights = [{ x: col, y: 0, w: 1, h: 2 }];
this.frames.push(addFrame);
}
const subFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
const origDigit = parseInt(s1.padStart(maxLen, "0")[i], 10);
subFrame.highlights = [
{
x: col,
y: p1[i] > origDigit ? 0 : 1,
w: 1,
h: p1[i] > origDigit ? 3 : 2,
},
];
subFrame.message = `Subtract: ${d1} - ${d2} = ${d1 - d2}.`;
this.frames.push(subFrame);
const resultFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
resultFrame.highlights = [];
const res = d1 - d2;
const currentP1 = BigInt(p1.slice(0, i + 1).join(""));
const currentP2 = BigInt(p2.slice(0, i + 1).join(""));
if (res > 0 || i === maxLen - 1 || currentP1 > currentP2) {
this.setCell(resultFrame, col, 4, {
text: res.toString(),
color: this.colors.result,
});
}
resultFrame.message = `Write ${res}.`;
this.frames.push(resultFrame);
}
}
private generateMultiplication(s1: string, s2: string) {
const topOffset = 2;
const stackWidth = Math.max(s1.length, s2.length);
const rightOffset = stackWidth;
const opCol = rightOffset - s2.length;
const width = rightOffset + 1;
const frame = this.createEmptyFrame();
frame.message = `Setting up multiplication: ${s1} × ${s2}`;
this.setCell(frame, opCol, topOffset + 1, {
text: "×",
color: this.colors.operator,
});
for (let i = 0; i < s1.length; i++) {
this.setCell(frame, rightOffset - s1.length + 1 + i, topOffset, {
text: s1[i],
color: this.colors.text,
});
}
for (let i = 0; i < s2.length; i++) {
this.setCell(frame, rightOffset - s2.length + 1 + i, topOffset + 1, {
text: s2[i],
color: this.colors.text,
});
}
frame.lines.push({ y: topOffset + 2, startX: opCol, endX: rightOffset });
this.frames.push(this.cloneGrid(frame));
const partialProducts: string[] = [];
let rowOffset = topOffset + 2;
for (let j = s2.length - 1; j >= 0; j--) {
const d2 = parseInt(s2[j], 10);
let carry = 0;
let partial = "";
const shift = s2.length - 1 - j;
const startFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
Object.keys(startFrame.cells).forEach((k) => {
if (k.endsWith(",0") || k.endsWith(",1")) delete startFrame.cells[k];
});
for (let i = s1.length - 1; i >= 0; i--) {
const d1 = parseInt(s1[i], 10);
const prod = d1 * d2 + carry;
const nextCarry = Math.floor(prod / 10);
const digit = prod % 10;
const highlightCol = rightOffset - s1.length + 1 + i;
const bottomCol = rightOffset - s2.length + 1 + j;
const stepFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
stepFrame.highlights = [
{ x: highlightCol, y: topOffset, w: 1, h: 1 },
{ x: bottomCol, y: topOffset + 1, w: 1, h: 1 },
];
let msg = `${d1} × ${d2}`;
if (carry > 0) msg += ` + ${carry} (carry) = ${prod}.`;
else msg += ` = ${prod}.`;
if (nextCarry > 0) msg += ` Write ${digit}, carry ${nextCarry}.`;
else msg += ` Write ${digit}.`;
stepFrame.message = msg;
if (carry > 0) {
this.setCell(stepFrame, highlightCol, topOffset - 1, {
text: carry.toString(),
color: this.colors.carry,
});
}
this.frames.push(stepFrame);
const writeFrame = this.cloneGrid(stepFrame);
writeFrame.highlights = [];
const writeCol = rightOffset - shift - (s1.length - 1 - i);
this.setCell(writeFrame, writeCol, rowOffset, {
text: digit.toString(),
color: this.colors.result,
});
if (nextCarry > 0) {
this.setCell(writeFrame, highlightCol - 1, topOffset - 1, {
text: nextCarry.toString(),
color: this.colors.carry,
});
}
this.frames.push(writeFrame);
carry = nextCarry;
partial = digit.toString() + partial;
}
if (carry > 0) {
const finalFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
const writeCol = rightOffset - shift - s1.length;
this.setCell(finalFrame, writeCol, rowOffset, {
text: carry.toString(),
color: this.colors.result,
});
finalFrame.message = "Write final carry down.";
this.frames.push(finalFrame);
partial = carry.toString() + partial;
}
if (shift > 0) {
const padFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
for (let k = 0; k < shift; k++) {
this.setCell(padFrame, rightOffset - k, rowOffset, {
text: "0",
color: this.colors.operator,
});
}
padFrame.message = "Add placeholder zero(s).";
this.frames.push(padFrame);
partial = partial + "0".repeat(shift);
}
partialProducts.push(partial);
rowOffset++;
}
if (partialProducts.length > 1) {
const addSetupFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
addSetupFrame.lines.push({ y: rowOffset, startX: opCol, endX: rightOffset });
this.setCell(addSetupFrame, opCol, rowOffset - 1, {
text: "+",
color: this.colors.operator,
});
addSetupFrame.message = "Add the partial products together.";
Object.keys(addSetupFrame.cells).forEach((k) => {
if (k.endsWith(`,${topOffset - 1}`)) delete addSetupFrame.cells[k];
});
this.frames.push(addSetupFrame);
let carry = 0;
const maxP = Math.max(...partialProducts.map((p) => p.length));
for (let i = 0; i < maxP; i++) {
let sum = carry;
for (let j = 0; j < partialProducts.length; j++) {
const pStr = partialProducts[j];
if (i < pStr.length) sum += parseInt(pStr[pStr.length - 1 - i], 10);
}
const digit = sum % 10;
const nextCarry = Math.floor(sum / 10);
const col = rightOffset - i;
const resFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
this.setCell(resFrame, col, rowOffset, {
text: digit.toString(),
color: this.colors.result,
});
let msg = `Sum column = ${sum}. Write ${digit}.`;
if (nextCarry > 0) {
this.setCell(resFrame, col - 1, topOffset + 1, {
text: nextCarry.toString(),
color: this.colors.carry,
});
msg += ` Carry ${nextCarry}.`;
}
resFrame.message = msg;
this.frames.push(resFrame);
carry = nextCarry;
}
if (carry > 0) {
const finalFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
this.setCell(finalFrame, rightOffset - maxP, rowOffset, {
text: carry.toString(),
color: this.colors.result,
});
finalFrame.message = "Write final addition carry.";
this.frames.push(finalFrame);
}
}
}
private generateDivision(s1: string, s2: string) {
if (s2.length > 15) throw new Error("Divisor too large for visualizer (max 15 digits).");
const div = parseInt(s2, 10);
if (div === 0) throw new Error("Cannot divide by zero");
if (BigInt(s1) === 0n) throw new Error("0 divided by anything is 0");
const frame = this.createEmptyFrame();
const divLen = s2.length;
const divOffset = 0;
const divStart = divLen + 1;
frame.message = `Setup long division: ${s1} ÷ ${s2}`;
for (let i = 0; i < divLen; i++) {
this.setCell(frame, divOffset + i, 2, { text: s2[i], color: this.colors.operator });
}
this.setCell(frame, divLen, 2, { text: ")", color: this.colors.text });
for (let i = 0; i < s1.length; i++) {
this.setCell(frame, divStart + i, 2, { text: s1[i], color: this.colors.text });
}
frame.lines.push({ y: 1.5, startX: divStart - 0.2, endX: divStart + s1.length });
this.frames.push(this.cloneGrid(frame));
const dividendArr = s1.split("");
let currentVal = 0;
let row = 3;
let quotientStr = "";
for (let i = 0; i < dividendArr.length; i++) {
const nextDigit = parseInt(dividendArr[i], 10);
currentVal = currentVal * 10 + nextDigit;
const bringFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
if (row > 3) {
this.setCell(bringFrame, divStart + i, row - 1, {
text: nextDigit.toString(),
color: this.colors.text,
});
bringFrame.message = `Bring down ${nextDigit}. Current value is ${currentVal}.`;
bringFrame.highlights = [{ x: divStart + i, y: 2, w: 1, h: row - 2 }];
} else {
bringFrame.message = `Consider ${currentVal}.`;
bringFrame.highlights = [{ x: divStart, y: 2, w: i + 1, h: 1 }];
}
this.frames.push(bringFrame);
if (currentVal >= div || i === dividendArr.length - 1 || quotientStr.length > 0) {
const qDigit = Math.floor(currentVal / div);
quotientStr += qDigit.toString();
const qFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
qFrame.highlights = [];
this.setCell(qFrame, divStart + i, 0, {
text: qDigit.toString(),
color: this.colors.result,
});
qFrame.message = `${div} goes into ${currentVal} exactly ${qDigit} times.`;
this.frames.push(qFrame);
if (qDigit > 0 || i === dividendArr.length - 1) {
const subVal = qDigit * div;
const subStr = subVal.toString();
const subFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
this.setCell(subFrame, divStart + i - subStr.length, row, {
text: "-",
color: this.colors.operator,
});
for (let k = 0; k < subStr.length; k++) {
this.setCell(subFrame, divStart + i - subStr.length + 1 + k, row, {
text: subStr[k],
color: this.colors.borrow,
});
}
subFrame.lines.push({
y: row + 1,
startX: divStart + i - subStr.length,
endX: divStart + i + 1,
});
subFrame.message = `Multiply ${qDigit} × ${div} = ${subVal}, then subtract.`;
this.frames.push(subFrame);
currentVal = currentVal - subVal;
row += 2;
const resFrame = this.cloneGrid(this.frames[this.frames.length - 1]);
const currStr = currentVal.toString();
for (let k = 0; k < currStr.length; k++) {
this.setCell(resFrame, divStart + i - currStr.length + 1 + k, row - 1, {
text: currStr[k],
color: this.colors.text,
});
}
resFrame.message = `Remainder is ${currentVal}.`;
this.frames.push(resFrame);
}
}
}
}
render() {
this.refreshColors();
if (this.frames.length === 0) {
const dpr = window.devicePixelRatio || 1;
this.ctx.clearRect(0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
return;
}
const frame = this.frames[this.currentFrame];
const ctx = this.ctx;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
Object.keys(frame.cells).forEach((key) => {
const [x, y] = key.split(",").map(Number);
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
});
if (!Number.isFinite(minX)) {
this.updateUI();
return;
}
minX -= 1;
maxX += 1;
minY -= 1;
maxY += 1;
const cols = maxX - minX + 1;
const rows = maxY - minY + 1;
const w = this.canvas.width / dpr;
const h = this.canvas.height / dpr;
const cellSize = Math.min(w / cols, h / rows, 60);
const offsetX = (w - cols * cellSize) / 2 - minX * cellSize;
const offsetY = (h - rows * cellSize) / 2 - minY * cellSize;
ctx.textBaseline = "middle";
ctx.textAlign = "center";
frame.highlights.forEach((hl) => {
ctx.fillStyle = this.colors.highlight;
ctx.fillRect(
offsetX + hl.x * cellSize,
offsetY + hl.y * cellSize,
hl.w * cellSize,
hl.h * cellSize
);
ctx.strokeStyle = this.colors.highlightBorder;
ctx.lineWidth = 2;
ctx.strokeRect(
offsetX + hl.x * cellSize,
offsetY + hl.y * cellSize,
hl.w * cellSize,
hl.h * cellSize
);
});
ctx.strokeStyle = this.colors.line;
ctx.lineWidth = 3;
frame.lines.forEach((line) => {
ctx.beginPath();
ctx.moveTo(offsetX + line.startX * cellSize, offsetY + line.y * cellSize);
ctx.lineTo(offsetX + line.endX * cellSize, offsetY + line.y * cellSize);
ctx.stroke();
});
const mono = getComputedStyle(this.themeRoot).getPropertyValue("--mono").trim() || "monospace";
ctx.font = `bold ${cellSize * 0.6}px ${mono}`;
Object.entries(frame.cells).forEach(([key, cell]) => {
const [x, y] = key.split(",").map(Number);
const cx = offsetX + x * cellSize + cellSize / 2;
const cy = offsetY + y * cellSize + cellSize / 2;
ctx.fillStyle = cell.color || this.colors.text;
if (cell.text) ctx.fillText(cell.text, cx, cy);
if (cell.crossedOut) {
ctx.strokeStyle = this.colors.carry;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - cellSize * 0.3, cy + cellSize * 0.3);
ctx.lineTo(cx + cellSize * 0.3, cy - cellSize * 0.3);
ctx.stroke();
}
});
this.updateUI();
}
private updateUI() {
if (this.frames.length > 0) {
const msg = this.frames[this.currentFrame].message;
this.onMessage?.(msg, false);
this.onStep?.(this.currentFrame + 1, this.frames.length);
}
}
play() {
if (this.frames.length === 0) return;
if (this.currentFrame >= this.frames.length - 1) this.currentFrame = 0;
this.isPlaying = true;
this.onPlayState?.(true);
this.playInterval = setInterval(() => {
if (this.currentFrame < this.frames.length - 1) this.step(1);
else this.pause();
}, this.speed);
}
pause() {
this.isPlaying = false;
this.onPlayState?.(false);
if (this.playInterval) clearInterval(this.playInterval);
this.playInterval = null;
}
togglePlay() {
if (this.isPlaying) this.pause();
else this.play();
}
step(dir: number) {
const newFrame = this.currentFrame + dir;
if (newFrame >= 0 && newFrame < this.frames.length) {
this.currentFrame = newFrame;
this.render();
}
}
destroy() {
this.pause();
}
}

View file

@ -0,0 +1,231 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { MathVisualizer } from "./lib/math-visualizer";
type Op = "+" | "-" | "*" | "/";
const DEFAULT_NUMBERS = "27\n59";
function VerticalArithmeticWorkbench() {
let rootRef!: HTMLElement;
let canvasRef!: HTMLCanvasElement;
let canvasWrapRef!: HTMLDivElement;
let visualizer: MathVisualizer | undefined;
const [operation, setOperation] = createSignal<Op>("+");
const [numbersInput, setNumbersInput] = createSignal(DEFAULT_NUMBERS);
const [message, setMessage] = createSignal("Enter numbers and click Generate");
const [isError, setIsError] = createSignal(false);
const [stepCurrent, setStepCurrent] = createSignal(0);
const [stepTotal, setStepTotal] = createSignal(0);
const [playing, setPlaying] = createSignal(false);
const [speed, setSpeed] = createSignal(800);
const progress = () =>
stepTotal() > 0 ? (stepCurrent() / stepTotal()) * 100 : 0;
onMount(() => {
visualizer = new MathVisualizer({
canvas: canvasRef,
wrapper: canvasWrapRef,
themeRoot: rootRef,
});
visualizer.onMessage = (msg, err) => {
setMessage(msg);
setIsError(!!err);
};
visualizer.onStep = (current, total) => {
setStepCurrent(current);
setStepTotal(total);
};
visualizer.onPlayState = setPlaying;
const resize = () => visualizer?.resizeCanvas();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvasWrapRef);
window.addEventListener("resize", resize);
const themeObserver = new MutationObserver(() => {
visualizer?.refreshColors();
visualizer?.render();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
onCleanup(() => {
ro.disconnect();
window.removeEventListener("resize", resize);
themeObserver.disconnect();
visualizer?.destroy();
});
setTimeout(() => generate(), 100);
});
createEffect(() => {
visualizer?.setOperation(operation());
visualizer?.setSpeed(speed());
});
const generate = () => {
visualizer?.setOperation(operation());
visualizer?.generateFromInput(numbersInput());
};
return (
<section
ref={rootRef}
class="math-widget va-workbench"
aria-labelledby="va-workbench-title"
>
<h2 id="va-workbench-title" class="math-widget__title">
Carry &amp; borrow arithmetic visualizer
</h2>
<div class="va-workbench__shell">
<aside class="va-workbench__sidebar">
<div class="va-workbench__brand">
<span class="va-workbench__brand-icon" aria-hidden="true">
±
</span>
<span class="va-workbench__brand-text">
Arithmetic <strong>Visualizer</strong>
</span>
</div>
<div class="va-workbench__field">
<span class="va-workbench__label">Operation</span>
<div class="va-workbench__op-grid" role="group" aria-label="Operation">
{(
[
["+", "Add"],
["-", "Subtract"],
["*", "Multiply"],
["/", "Divide"],
] as const
).map(([op, label]) => (
<button
type="button"
class="va-workbench__op-btn"
classList={{ "va-workbench__op-btn--active": operation() === op }}
aria-pressed={operation() === op}
aria-label={label}
onClick={() => setOperation(op)}
>
{op === "*" ? "×" : op === "/" ? "÷" : op}
</button>
))}
</div>
</div>
<div class="va-workbench__field">
<label class="va-workbench__label" for="va-numbers">
Numbers (one per line)
</label>
<textarea
id="va-numbers"
class="va-workbench__textarea"
rows={4}
value={numbersInput()}
placeholder={"99\n99\n273"}
onInput={(e) => setNumbersInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
generate();
}
}}
/>
<p class="va-workbench__hint">
Multi-step operations run in order (e.g. subtract or divide each line from the
result so far).
</p>
</div>
<button type="button" class="va-workbench__generate" onClick={generate}>
Generate visualization
</button>
<div class="va-workbench__anim">
<span class="va-workbench__label">Animation controls</span>
<div class="va-workbench__transport">
<button
type="button"
class="va-workbench__transport-btn"
title="Previous step"
aria-label="Previous step"
onClick={() => visualizer?.step(-1)}
>
‹
</button>
<button
type="button"
class="va-workbench__play"
title={playing() ? "Pause" : "Play"}
onClick={() => visualizer?.togglePlay()}
>
{playing() ? "Pause" : "Play"}
</button>
<button
type="button"
class="va-workbench__transport-btn"
title="Next step"
aria-label="Next step"
onClick={() => visualizer?.step(1)}
>
›
</button>
</div>
<div class="va-workbench__speed">
<span>Slow</span>
<input
type="range"
class="va-workbench__speed-slider"
min={200}
max={2000}
step={100}
value={speed()}
dir="rtl"
aria-label="Animation speed"
onInput={(e) => setSpeed(Number(e.currentTarget.value))}
/>
<span>Fast</span>
</div>
</div>
<p class="va-workbench__footer-note">
Max 8 digits recommended for best visualization.
</p>
</aside>
<div class="va-workbench__stage">
<div
class="va-workbench__message-box"
classList={{ "va-workbench__message-box--error": isError() }}
>
<p class="va-workbench__message" aria-live="polite">
{message()}
</p>
<div class="va-workbench__progress-track">
<div
class="va-workbench__progress-fill"
style={{ width: `${progress()}%` }}
/>
</div>
<p class="va-workbench__step-counter">
Step {stepCurrent()} / {stepTotal()}
</p>
</div>
<div ref={canvasWrapRef} class="va-workbench__canvas-wrap">
<canvas ref={canvasRef} class="va-workbench__canvas" />
</div>
</div>
</div>
</section>
);
}
export default VerticalArithmeticWorkbench;