- Look around you. How many birds? How many cups? You can answer by
- counting — pointing at one thing at a time until everything has been pointed at.
- This chapter uses pictures of dots to stand for real things, like marbles or
- fingers.
+ You already met 0 and 1 in
+ Zero and one. Now look around: how many birds? how many cups?
+ You answer by counting — pointing at one thing at a time until everything has
+ been pointed at. This chapter uses pictures of dots to stand for real things,
+ like marbles or fingers.
What is counting?
@@ -27,20 +28,13 @@ strand = "Number & Algebra"
the number of fingers matches the number of objects.
-
Zero
+
Zero and one (recap)
- When there is nothing to point at, we write 0 and say
- zero. An empty plate, no toys in the box — zero dots:
+ 0 means nothing to count; 1 means exactly one. When you count
+ aloud you still say zero before one, then two, and so on. If you want the full picture of
+ nothing versus one — and how that pairs with false and true — revisit
+ Zero and one.
-
- (none)
-
-
Zero comes before one when we count: first nothing, then one, then two, and so on.
-
-
-
Remember
-
Zero means “none here,” not “forgot to count.”
-
Numbers 1 to 10
diff --git a/content/math/counting-and-numbers/chapter.scss b/content/math/counting-and-numbers/chapter.scss
index 915a5f2..a3cc682 100644
--- a/content/math/counting-and-numbers/chapter.scss
+++ b/content/math/counting-and-numbers/chapter.scss
@@ -1,77 +1,4 @@
-// Figurative dots and patterns in chapter prose
-.counting-dots {
- display: inline-flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 5px;
- vertical-align: middle;
-}
-
-.counting-dots__dot {
- display: block;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: #0f766e;
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12);
- flex-shrink: 0;
-}
-
-.counting-dots--empty .counting-dots__none {
- font-size: 0.85rem;
- font-style: italic;
- color: var(--muted);
-}
-
-.counting-dots--compare {
- max-width: 100px;
-}
-
-.counting-dots--pair {
- display: inline-flex;
- flex-wrap: wrap;
- gap: 8px;
- max-width: 200px;
-}
-
-.counting-dots__pair {
- display: inline-flex;
- gap: 4px;
- padding: 4px 6px;
- border-radius: 6px;
- background: rgba(20, 184, 166, 0.12);
- border: 1px dashed rgba(15, 118, 110, 0.35);
-}
-
-.counting-dots--odd {
- display: inline-flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- max-width: 220px;
-}
-
-.counting-dots__singleton {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 4px 8px;
- border-radius: 6px;
- background: rgba(234, 88, 12, 0.14);
- border: 2px solid rgba(180, 83, 9, 0.45);
-}
-
-.counting-dots__singleton .counting-dots__dot {
- background: #c2410c;
-}
-
-.counting-pattern-ladder--odd .counting-pattern-step {
- background: rgba(234, 88, 12, 0.06);
-}
-
-.counting-number-table td:last-child {
- min-width: 110px;
-}
+// counting-dots and shared dot rules: content/math/subject.scss
.counting-compare {
display: flex;
@@ -473,235 +400,314 @@
gap: 6px;
}
-// Big numbers scale widget
-.big-numbers-scale__picker {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- justify-content: center;
+// Big numbers scale widget (canvas + audio)
+.book-prose .math-widget-mount:has(.big-numbers-scale) {
+ max-width: none;
}
-.big-numbers-scale__btn {
- padding: 6px 12px;
+.big-numbers-scale {
+ --big-scale-canvas-bg: var(--paper);
+ --big-scale-grid: color-mix(in srgb, var(--line) 70%, transparent);
+ --big-scale-star: color-mix(in srgb, var(--soft-ink) 48%, transparent);
+
+ width: min(1320px, 96vw);
+ max-width: none;
+}
+
+[data-theme="dark"] .big-numbers-scale,
+:root:not([data-theme="light"]) .big-numbers-scale {
+ --big-scale-canvas-bg: color-mix(in srgb, var(--card-bg) 90%, black);
+ --big-scale-grid: color-mix(in srgb, var(--line) 78%, rgba(148, 163, 184, 0.2));
+ --big-scale-star: color-mix(in srgb, var(--soft-ink) 62%, rgba(255, 255, 255, 0.18));
+}
+
+.big-numbers-scale__layout {
+ display: grid;
+ gap: 14px;
+}
+
+.big-numbers-scale__canvas-panel,
+.big-numbers-scale__controls,
+.big-numbers-scale__section {
+ min-width: 0;
+}
+
+.big-numbers-scale__canvas-panel {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--paper);
+ overflow: hidden;
+}
+
+.big-numbers-scale__toolbar,
+.big-numbers-scale__slider-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--line);
+}
+
+.big-numbers-scale__slider-wrap {
+ border-top: 1px solid var(--line);
+ border-bottom: 0;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.big-numbers-scale__slider-meta {
+ display: flex;
+ justify-content: space-between;
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ color: var(--soft-ink);
+}
+
+.big-numbers-scale__slider {
+ width: 100%;
+}
+
+.big-numbers-scale__tool-btn,
+.big-numbers-scale__autoplay-btn,
+.speed-btn,
+.preset-step-btn {
border: 1px solid var(--line);
border-radius: 8px;
- font-family: var(--mono);
- font-size: 0.8rem;
background: var(--card-bg);
color: var(--ink);
+ font-family: var(--mono);
cursor: pointer;
}
-.big-numbers-scale__btn--active {
- border-color: #0f766e;
+.big-numbers-scale__tool-btn,
+.big-numbers-scale__autoplay-btn {
+ padding: 7px 10px;
+ font-size: 0.76rem;
+}
+
+.big-numbers-scale__autoplay-btn {
+ border-color: rgba(20, 184, 166, 0.45);
background: rgba(20, 184, 166, 0.15);
font-weight: 700;
}
-.big-numbers-scale__display {
- margin: 16px 0 8px;
- text-align: center;
+.big-numbers-scale__autoplay-btn--pause {
+ border-color: rgba(234, 88, 12, 0.45);
+ background: rgba(234, 88, 12, 0.16);
}
-.big-numbers-scale__number {
+.big-numbers-scale__badge {
+ padding: 3px 7px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
font-family: var(--mono);
- font-size: 2rem;
- font-weight: 700;
-}
-
-.big-numbers-scale__compare {
- margin: 0 0 12px;
- font-size: 0.9rem;
- text-align: center;
+ font-size: 0.72rem;
color: var(--soft-ink);
}
-.big-numbers-scale__visual {
- display: flex;
- justify-content: center;
- min-height: 120px;
- margin: 12px 0;
- padding: 16px;
- border: 1px dashed var(--line);
+.big-numbers-scale__hud {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 8px;
+ padding: 10px 12px;
+}
+
+.big-numbers-scale__hud-card {
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--card-bg) 84%, #0b1220);
+}
+
+.big-numbers-scale__hud-card--right {
+ text-align: right;
+}
+
+.big-numbers-scale__hud-label {
+ display: block;
+ font-size: 0.7rem;
+ color: var(--soft-ink);
+}
+
+.big-numbers-scale__hud-value {
+ display: block;
+ margin-top: 2px;
+ font-family: var(--mono);
+ font-size: 1.35rem;
+ font-weight: 700;
+}
+
+.big-numbers-scale__hud-value--small {
+ font-size: 1.05rem;
+}
+
+.big-numbers-scale__hud-sub {
+ display: block;
+ margin-top: 2px;
+ font-size: 0.72rem;
+ color: var(--soft-ink);
+}
+
+.big-numbers-scale__tier-badge {
+ display: inline-block;
+ margin-top: 4px;
+ padding: 2px 6px;
+ border-radius: 6px;
+ font-size: 0.66rem;
+ font-family: var(--mono);
+ font-weight: 700;
+ color: #08111f;
+ background: #14b8a6;
+}
+
+.big-numbers-scale__tier-badge--green {
+ background: #14b8a6;
+}
+.big-numbers-scale__tier-badge--purple {
+ background: #8b5cf6;
+}
+.big-numbers-scale__tier-badge--orange {
+ background: #f97316;
+}
+.big-numbers-scale__tier-badge--yellow {
+ background: #f59e0b;
+}
+.big-numbers-scale__tier-badge--cyan {
+ background: #06b6d4;
+}
+
+.big-numbers-scale__canvas-wrap {
+ position: relative;
+ height: 400px;
+ background: var(--big-scale-canvas-bg);
+}
+
+.big-numbers-scale__canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ cursor: grab;
+}
+
+.big-numbers-scale__canvas:active {
+ cursor: grabbing;
+}
+
+.big-numbers-scale__status {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ background: rgba(2, 6, 23, 0.85);
+ border: 1px solid var(--line);
+ color: var(--soft-ink);
+ font-size: 0.74rem;
+ opacity: 0;
+ transform: translateY(5px);
+ transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.big-numbers-scale__status--visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.big-numbers-scale__controls {
+ display: grid;
+ gap: 10px;
+}
+
+.big-numbers-scale__section {
+ border: 1px solid var(--line);
border-radius: 10px;
background: var(--card-bg);
+ padding: 10px;
}
-.big-numbers-scale__grid {
- display: grid;
- gap: 4px;
-}
-
-.big-numbers-scale__grid--small .big-numbers-scale__cell {
- width: 6px;
- height: 6px;
-}
-
-.big-numbers-scale__cell {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: #0f766e;
-}
-
-.big-numbers-scale__blocks {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- justify-content: center;
- max-width: 280px;
-}
-
-.big-numbers-scale__block {
- padding: 6px 8px;
- border-radius: 4px;
- font-family: var(--mono);
- font-size: 0.65rem;
- font-weight: 700;
- background: rgba(20, 184, 166, 0.2);
- border: 1px solid rgba(15, 118, 110, 0.35);
-}
-
-.big-numbers-scale__block--hundred {
- min-width: 36px;
- text-align: center;
-}
-
-.big-numbers-scale__visual-caption {
- flex: 1 1 100%;
- margin: 10px 0 0;
+.big-numbers-scale__section-title {
+ margin: 0 0 8px;
font-size: 0.82rem;
- line-height: 1.45;
- text-align: center;
color: var(--soft-ink);
}
-.big-numbers-scale__visual-caption--inline {
- margin: 8px 0 0;
-}
-
-.big-numbers-scale__math-hint {
- font-family: var(--mono);
- font-size: 0.85em;
-}
-
-// 1 million: 100 × 10,000
-.big-numbers-scale__million {
- width: 100%;
-}
-
-.big-numbers-scale__mega-grid {
+.big-numbers-scale__preset-grid {
display: grid;
- grid-template-columns: repeat(10, 1fr);
- gap: 3px;
- max-width: 280px;
- margin: 0 auto;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
}
-.big-numbers-scale__mega-cell {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 22px;
- border-radius: 3px;
- font-family: var(--mono);
- font-size: 0.5rem;
- font-weight: 700;
- color: #0f766e;
- background: rgba(20, 184, 166, 0.22);
- border: 1px solid rgba(15, 118, 110, 0.35);
-}
-
-// 1 billion: mini million × 1000 → grid of 1M
-.big-numbers-scale__billion {
- width: 100%;
-}
-
-.big-numbers-scale__billion-compare {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- gap: 10px 14px;
-}
-
-.big-numbers-scale__billion-unit {
+.preset-step-btn {
display: flex;
flex-direction: column;
- align-items: center;
- gap: 6px;
- padding: 8px;
- border-radius: 8px;
- background: rgba(20, 184, 166, 0.06);
- border: 1px solid var(--line);
+ align-items: flex-start;
+ gap: 2px;
+ padding: 7px;
+ min-height: 58px;
}
-.big-numbers-scale__billion-unit--wide {
- flex: 1 1 200px;
- max-width: 320px;
- background: rgba(234, 88, 12, 0.06);
- border-color: rgba(180, 83, 9, 0.35);
+.preset-step-btn__power {
+ font-size: 0.65rem;
+ color: var(--soft-ink);
+ font-family: var(--mono);
}
-.big-numbers-scale__billion-unit-label {
- font-size: 0.72rem;
+.preset-step-btn__label {
+ font-size: 0.78rem;
font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+}
+
+.preset-step-btn--active {
+ border-color: rgba(20, 184, 166, 0.55);
+ background: rgba(20, 184, 166, 0.15);
+}
+
+.big-numbers-scale__speed-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.speed-btn {
+ padding: 6px 8px;
+ font-size: 0.75rem;
+}
+
+.speed-btn--active {
+ border-color: rgba(20, 184, 166, 0.55);
+ background: rgba(20, 184, 166, 0.18);
+ font-weight: 700;
+}
+
+.big-numbers-scale__nav-row {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.big-numbers-scale__stat-text,
+.big-numbers-scale__analogy {
+ margin: 0;
+ font-size: 0.78rem;
color: var(--soft-ink);
}
-.big-numbers-scale__mini-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 2px;
-}
-
-.big-numbers-scale__mini-cell {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 18px;
- border-radius: 2px;
+.big-numbers-scale__stat-value {
+ margin: 8px 0;
+ padding: 8px;
+ border-radius: 8px;
+ border: 1px solid var(--line);
font-family: var(--mono);
- font-size: 0.45rem;
font-weight: 700;
- color: #0f766e;
- background: rgba(20, 184, 166, 0.25);
}
-.big-numbers-scale__billion-times {
- font-family: var(--mono);
- font-size: 1rem;
- font-weight: 700;
- color: var(--accent);
-}
-
-.big-numbers-scale__billion-equals {
- font-family: var(--mono);
- font-size: 1.25rem;
- font-weight: 700;
- color: var(--muted);
-}
-
-.big-numbers-scale__giga-grid {
- display: grid;
- grid-template-columns: repeat(10, 1fr);
- gap: 2px;
- width: 100%;
-}
-
-.big-numbers-scale__giga-cell {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 18px;
- border-radius: 2px;
- font-family: var(--mono);
- font-size: 0.48rem;
- font-weight: 700;
- color: #9a3412;
- background: rgba(234, 88, 12, 0.28);
- border: 1px solid rgba(180, 83, 9, 0.4);
+@media (min-width: 960px) {
+ .big-numbers-scale__layout {
+ grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr);
+ }
+ .big-numbers-scale__hud {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
diff --git a/content/math/counting-and-numbers/core.html b/content/math/counting-and-numbers/core.html
index 5a89b4d..a193666 100644
--- a/content/math/counting-and-numbers/core.html
+++ b/content/math/counting-and-numbers/core.html
@@ -1,9 +1,10 @@
- Look around you. How many birds? How many cups? You can answer by
- counting — pointing at one thing at a time until everything has been pointed at.
- This chapter uses pictures of dots to stand for real things, like marbles or
- fingers.
+ You already met 0 and 1 in
+ Zero and one. Now look around: how many birds? how many cups?
+ You answer by counting — pointing at one thing at a time until everything has
+ been pointed at. This chapter uses pictures of dots to stand for real things,
+ like marbles or fingers.
What is counting?
@@ -16,20 +17,13 @@
the number of fingers matches the number of objects.
-
Zero
+
Zero and one (recap)
- When there is nothing to point at, we write 0 and say
- zero. An empty plate, no toys in the box — zero dots:
+ 0 means nothing to count; 1 means exactly one. When you count
+ aloud you still say zero before one, then two, and so on. If you want the full picture of
+ nothing versus one — and how that pairs with false and true — revisit
+ Zero and one.
-
- (none)
-
-
Zero comes before one when we count: first nothing, then one, then two, and so on.
-
-
-
Remember
-
Zero means “none here,” not “forgot to count.”
-
Numbers 1 to 10
diff --git a/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx b/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx
index 6d8cdd8..829bbfd 100644
--- a/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx
+++ b/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx
@@ -1,202 +1,865 @@
-import { createMemo, createSignal, For, Show } from "solid-js";
-
-type VisualKind = "one" | "ten" | "hundred" | "thousand" | "million" | "billion";
-
-type ScaleStep = {
- id: string;
- label: string;
- value: number;
- display: string;
- example: string;
- visual: VisualKind;
-};
-
-const STEPS: ScaleStep[] = [
- {
- id: "one",
- label: "1",
- value: 1,
- display: "1",
- example: "One apple on a plate.",
- visual: "one",
- },
- {
- id: "ten",
- label: "10",
- value: 10,
- display: "10",
- example: "Fingers on two hands — easy to count one by one.",
- visual: "ten",
- },
- {
- id: "hundred",
- label: "100",
- value: 100,
- display: "100",
- example: "A full stadium section; too many to count quickly without grouping.",
- visual: "hundred",
- },
- {
- id: "thousand",
- label: "1 thousand",
- value: 1_000,
- display: "1,000",
- example: "A long walk in steps; a big school’s students.",
- visual: "thousand",
- },
- {
- id: "million",
- label: "1 million",
- value: 1_000_000,
- display: "1,000,000",
- example: "A large city’s population — one dot per person would fill this whole grid.",
- visual: "million",
- },
- {
- id: "billion",
- label: "1 billion",
- value: 1_000_000_000,
- display: "1,000,000,000",
- example: "About how many people live on Earth — one thousand times a whole city-million.",
- visual: "billion",
- },
-];
-
-function DotGrid(props: { count: number; columns: number; small?: boolean }) {
- const cells = () => Array.from({ length: props.count });
- return (
-
- {() => }
-
- );
-}
-
-/** 10×10 = 100 cells; each stands for 10,000 → 1 million total */
-function MillionVisual() {
- const cells = Array.from({ length: 100 });
- return (
-
-
- {() => 10k}
-
-
- 100 squares × 10,000 each = 1 million
-
-
- );
-}
-
-/** Mini million (4×4 of 10k) beside ×1000 beside 10×10 grid of 1M blocks */
-function BillionVisual() {
- const millionTiles = Array.from({ length: 16 });
- const billionTiles = Array.from({ length: 100 });
- return (
-
-
-
-
1 million
-
- {() => 10k}
-
-
-
× 1,000
-
=
-
-
1 billion
-
- {() => 1M}
-
-
-
-
- Each orange square is a whole million. This board shows 100 of them — imagine
- 10 identical boards stacked (10 × 100 = 1,000
- millions) to reach one billion.
-
-
- );
-}
+import { onCleanup, onMount } from "solid-js";
function BigNumbersScale() {
- const [index, setIndex] = createSignal(2);
- const step = createMemo(() => STEPS[index()]!);
- const prev = createMemo(() => STEPS[Math.max(0, index() - 1)]);
- const scaleRatio = createMemo(() => {
- const p = prev().value;
- const c = step().value;
- if (p === c) return null;
- return c / p;
+ let rootRef: HTMLDivElement | undefined;
+
+ onMount(() => {
+ const root = rootRef;
+ if (!root) return;
+
+ const byId =
(id: string) => root.querySelector(`#${id}`);
+ const canvas = byId("scaleCanvas");
+ const canvasContainer = byId("canvasContainer");
+ const logSlider = byId("logSlider");
+ const currentValDisplay = byId("currentValDisplay");
+ const tierInfoBadge = byId("tierInfoBadge");
+ const dotCountDisplay = byId("dotCountDisplay");
+ const tierValueLabel = byId("tierValueLabel");
+ const powerIndicator = byId("powerIndicator");
+ const analogyValueText = byId("analogyValueText");
+ const timeStatValue = byId("timeStatValue");
+ const analogyText = byId("analogyText");
+ const btnPlayPause = byId("btnPlayPause");
+ const btnPrevStep = byId("btnPrevStep");
+ const btnNextStep = byId("btnNextStep");
+ const stateIndicator = byId("stateIndicator");
+ const stateIndicatorText = byId("stateIndicatorText");
+ const soundToggle = byId("soundToggle");
+ const soundIcon = byId("soundIcon");
+ const soundText = byId("soundText");
+ const presetStepsContainer = byId("presetStepsContainer");
+
+ if (
+ !canvas ||
+ !canvasContainer ||
+ !logSlider ||
+ !currentValDisplay ||
+ !tierInfoBadge ||
+ !dotCountDisplay ||
+ !tierValueLabel ||
+ !powerIndicator ||
+ !analogyValueText ||
+ !timeStatValue ||
+ !analogyText ||
+ !btnPlayPause ||
+ !btnPrevStep ||
+ !btnNextStep ||
+ !stateIndicator ||
+ !stateIndicatorText ||
+ !soundToggle ||
+ !soundIcon ||
+ !soundText ||
+ !presetStepsContainer
+ ) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const STEPS = [
+ { id: "one", label: "1", value: 1, power: "10⁰", tone: "green", example: "A single heartbeat. The primary unit of all structures.", rawTime: 1 },
+ { id: "ten", label: "10", value: 10, power: "10¹", tone: "green", example: "The fingers on two hands. The basic structural unit of decimal arithmetic.", rawTime: 10 },
+ { id: "hundred", label: "100", value: 100, power: "10²", tone: "purple", example: "Grains of salt in a pinch. Page count of a short story.", rawTime: 100 },
+ { id: "thousand", label: "1,000", value: 1_000, power: "10³", tone: "purple", example: "Words on a book page. The steps taken in a casual 10-minute walk.", rawTime: 1_000 },
+ { id: "ten_thousand", label: "10,000", value: 10_000, power: "10⁴", tone: "orange", example: "Total stars visible to the naked human eye under a perfectly clear night sky.", rawTime: 10_000 },
+ { id: "hundred_thousand", label: "100,000", value: 100_000, power: "10⁵", tone: "orange", example: "The total number of hairs on a full human head. Capacity of a massive stadium.", rawTime: 100_000 },
+ { id: "million", label: "1 Million", value: 1_000_000, power: "10⁶", tone: "yellow", example: "Characters in a massive novel. Total running steps in a standard marathon.", rawTime: 1_000_000 },
+ { id: "ten_million", label: "10 Million", value: 10_000_000, power: "10⁷", tone: "yellow", example: "Population of a major mega-city like Tokyo, Seoul, or London.", rawTime: 10_000_000 },
+ { id: "hundred_million", label: "100 Million", value: 100_000_000, power: "10⁸", tone: "cyan", example: "Total books published in human history. Heartbeats of a cat over its lifetime.", rawTime: 100_000_000 },
+ { id: "billion", label: "1 Billion", value: 1_000_000_000, power: "10⁹", tone: "cyan", example: "Grains of sand in a typical children's playground. Light travel distance in meters in 3.3 seconds.", rawTime: 1_000_000_000 },
+ ] as const;
+
+ const CORES = {
+ green: { hex: "#14b8a6", rgb: [20, 184, 166] as [number, number, number], glow: "rgba(20, 184, 166, 0.4)" },
+ purple: { hex: "#8b5cf6", rgb: [139, 92, 246] as [number, number, number], glow: "rgba(139, 92, 246, 0.4)" },
+ orange: { hex: "#f97316", rgb: [249, 115, 22] as [number, number, number], glow: "rgba(249, 115, 22, 0.4)" },
+ yellow: { hex: "#f59e0b", rgb: [245, 158, 11] as [number, number, number], glow: "rgba(245, 158, 11, 0.4)" },
+ cyan: { hex: "#06b6d4", rgb: [6, 182, 212] as [number, number, number], glow: "rgba(6, 182, 212, 0.4)" },
+ } as const;
+
+ const TIERS = [
+ { index: 0, unitValue: 1, colorName: "green", label: "Units (Green)" },
+ { index: 1, unitValue: 100, colorName: "purple", label: "Hundreds (Purple)" },
+ { index: 2, unitValue: 10_000, colorName: "orange", label: "Ten-Thousands (Orange)" },
+ { index: 3, unitValue: 1_000_000, colorName: "yellow", label: "Millions (Yellow)" },
+ { index: 4, unitValue: 100_000_000, colorName: "cyan", label: "Hundred-Millions (Cyan)" },
+ ] as const;
+
+ type VisualMode = "NORMAL" | "COMPRESSING" | "EXPANDING";
+ type Speed = "slow" | "normal" | "fast";
+
+ const state = {
+ currentValue: 1,
+ targetValue: 1,
+ isPlaying: false,
+ speed: "normal" as Speed,
+ speedMult: 1,
+ currentTierIdx: 0,
+ visualMode: "NORMAL" as VisualMode,
+ transitionProgress: 0,
+ panX: 0,
+ panY: 0,
+ isDragging: false,
+ startX: 0,
+ startY: 0,
+ particles: [] as Particle[],
+ ambientStars: [] as CosmicStar[],
+ shockwaves: [] as Shockwave[],
+ isScrubbing: false,
+ };
+
+ class SoundSynth {
+ ctx: AudioContext | null = null;
+ muted = true;
+ lastTickTime = 0;
+
+ init() {
+ if (!this.ctx) {
+ this.ctx = new (window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext)();
+ }
+ if (this.ctx.state === "suspended") {
+ void this.ctx.resume();
+ }
+ }
+
+ toggleMute() {
+ this.init();
+ this.muted = !this.muted;
+ return this.muted;
+ }
+
+ playTick(value: number) {
+ if (this.muted || !this.ctx) return;
+ const now = this.ctx.currentTime;
+ if (now - this.lastTickTime < 0.04) return;
+ this.lastTickTime = now;
+
+ const osc = this.ctx.createOscillator();
+ const gainNode = this.ctx.createGain();
+ osc.connect(gainNode);
+ gainNode.connect(this.ctx.destination);
+
+ const logVal = Math.log10(value || 1);
+ const baseFreq = 600 - logVal * 50;
+
+ osc.type = logVal > 6 ? "triangle" : "sine";
+ osc.frequency.setValueAtTime(baseFreq, this.ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(baseFreq * 0.2, this.ctx.currentTime + 0.08);
+
+ gainNode.gain.setValueAtTime(0.08, this.ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.08);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.09);
+ }
+
+ playCompress() {
+ if (this.muted || !this.ctx) return;
+ const osc = this.ctx.createOscillator();
+ const gainNode = this.ctx.createGain();
+ osc.connect(gainNode);
+ gainNode.connect(this.ctx.destination);
+
+ osc.type = "sawtooth";
+ osc.frequency.setValueAtTime(350, this.ctx.currentTime);
+ osc.frequency.linearRampToValueAtTime(80, this.ctx.currentTime + 0.5);
+
+ gainNode.gain.setValueAtTime(0.12, this.ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.55);
+ }
+
+ playExpand() {
+ if (this.muted || !this.ctx) return;
+ const osc = this.ctx.createOscillator();
+ const gainNode = this.ctx.createGain();
+ osc.connect(gainNode);
+ gainNode.connect(this.ctx.destination);
+
+ osc.type = "triangle";
+ osc.frequency.setValueAtTime(80, this.ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(440, this.ctx.currentTime + 0.5);
+
+ gainNode.gain.setValueAtTime(0.15, this.ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.55);
+ }
+ }
+
+ const audio = new SoundSynth();
+
+ const getTierForValue = (val: number) => {
+ for (let i = TIERS.length - 1; i >= 0; i -= 1) {
+ if (val >= TIERS[i]!.unitValue) return TIERS[i]!;
+ }
+ return TIERS[0]!;
+ };
+
+ const formatNumber = (num: number) => Math.round(num).toLocaleString();
+
+ const formatTimeAnalogy = (seconds: number) => {
+ if (seconds < 60) return `${seconds.toFixed(0)} Second${seconds !== 1 ? "s" : ""}`;
+ const minutes = seconds / 60;
+ if (minutes < 60) return `${minutes.toFixed(1)} Minute${minutes !== 1 ? "s" : ""}`;
+ const hours = minutes / 60;
+ if (hours < 24) return `${hours.toFixed(1)} Hour${hours !== 1 ? "s" : ""}`;
+ const days = hours / 24;
+ if (days < 365) return `${days.toFixed(1)} Day${days !== 1 ? "s" : ""}`;
+ const years = days / 365;
+ return `${years.toFixed(1)} Year${years !== 1 ? "s" : ""}`;
+ };
+
+ const lerpColor = (c1: [number, number, number], c2: [number, number, number], factor: number) => {
+ const r = Math.round(c1[0] + factor * (c2[0] - c1[0]));
+ const g = Math.round(c1[1] + factor * (c2[1] - c1[1]));
+ const b = Math.round(c1[2] + factor * (c2[2] - c1[2]));
+ return `rgb(${r}, ${g}, ${b})`;
+ };
+
+ class Particle {
+ index: number;
+ tierIdx: number;
+ gx = 0;
+ gy = 0;
+ x = 0;
+ y = 0;
+ radius = 5;
+ alpha = 1;
+ colorInfo = CORES.green;
+
+ constructor(index: number, tierIdx: number) {
+ this.index = index;
+ this.tierIdx = tierIdx;
+ this.reset();
+ }
+
+ reset() {
+ const tier = TIERS[this.tierIdx]!;
+ const col = this.index % 10;
+ const row = Math.floor(this.index / 10);
+ const spacing = 32;
+ this.gx = (col - 4.5) * spacing;
+ this.gy = (row - 4.5) * spacing;
+ this.x = 0;
+ this.y = 0;
+ this.radius = this.tierIdx % 2 === 0 ? 5 : 4;
+ this.colorInfo = CORES[tier.colorName];
+ }
+
+ update() {
+ if (state.visualMode === "NORMAL") {
+ const k = 0.15 * state.speedMult;
+ this.x += (this.gx - this.x) * k;
+ this.y += (this.gy - this.y) * k;
+ this.radius = this.tierIdx % 2 === 0 ? 5 : 4;
+ this.alpha += (1 - this.alpha) * 0.15;
+ return;
+ }
+
+ if (state.visualMode === "COMPRESSING") {
+ const t = state.transitionProgress;
+ this.x = this.gx * (1 - t);
+ this.y = this.gy * (1 - t);
+ this.radius = Math.max(1, (this.tierIdx % 2 === 0 ? 5 : 4) * (1 - t));
+ this.alpha = 1 - t;
+ return;
+ }
+
+ const t = state.transitionProgress;
+ this.x = this.gx * t;
+ this.y = this.gy * t;
+ this.radius = Math.max(0.1, (this.tierIdx % 2 === 0 ? 5 : 4) * t);
+ this.alpha = t;
+ }
+
+ draw(offsetX: number, offsetY: number) {
+ ctx.save();
+ ctx.translate(offsetX, offsetY);
+
+ let currentHex = this.colorInfo.hex;
+ if (state.visualMode === "COMPRESSING") {
+ const nextTier = TIERS[Math.min(TIERS.length - 1, this.tierIdx + 1)]!;
+ currentHex = lerpColor(this.colorInfo.rgb, CORES[nextTier.colorName].rgb, state.transitionProgress);
+ } else if (state.visualMode === "EXPANDING") {
+ const prevTier = TIERS[Math.max(0, this.tierIdx - 1)]!;
+ currentHex = lerpColor(CORES[prevTier.colorName].rgb, this.colorInfo.rgb, state.transitionProgress);
+ }
+
+ ctx.shadowBlur = state.visualMode === "COMPRESSING" ? 15 : 8;
+ ctx.shadowColor = currentHex;
+ ctx.fillStyle = currentHex;
+ ctx.globalAlpha = Math.max(0, Math.min(1, this.alpha));
+ ctx.beginPath();
+ ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.shadowBlur = 0;
+ ctx.font = "600 7px var(--mono)";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
+ ctx.fillText(formatNumber(TIERS[this.tierIdx]!.unitValue), this.x, this.y + this.radius + 3);
+ ctx.restore();
+ }
+ }
+
+ class CosmicStar {
+ x = 0;
+ y = 0;
+ depth = 0;
+ size = 0;
+ alpha = 0;
+ speed = 0;
+ constructor() {
+ this.reset();
+ }
+ reset() {
+ this.x = (Math.random() - 0.5) * window.innerWidth;
+ this.y = (Math.random() - 0.5) * window.innerHeight;
+ this.depth = Math.random() * 0.9 + 0.1;
+ this.size = Math.random() * 1.5 + 0.5;
+ this.alpha = Math.random() * 0.5 + 0.1;
+ this.speed = Math.random() * 0.05 + 0.01;
+ }
+ update() {
+ this.y += this.speed * state.speedMult;
+ if (this.y > window.innerHeight / 2) this.y = -window.innerHeight / 2;
+ }
+ draw(cx: number, cy: number, starColor: string) {
+ ctx.fillStyle = starColor;
+ ctx.globalAlpha = this.alpha;
+ ctx.beginPath();
+ ctx.arc(cx + this.x, cy + this.y, this.size * this.depth, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+ }
+
+ class Shockwave {
+ radius = 1;
+ maxRadius = 120 + Math.random() * 100;
+ alpha = 1;
+ color = "#8b5cf6";
+ update() {
+ this.radius += 5 * state.speedMult;
+ this.alpha = 1 - this.radius / this.maxRadius;
+ }
+ draw(cx: number, cy: number) {
+ if (this.alpha <= 0) return;
+ ctx.save();
+ ctx.strokeStyle = this.color;
+ ctx.globalAlpha = this.alpha;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(cx, cy, this.radius, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ for (let i = 0; i < 150; i += 1) {
+ state.ambientStars.push(new CosmicStar());
+ }
+
+ const updatePresetsUI = () => {
+ root.querySelectorAll(".preset-step-btn").forEach((btn) => {
+ const val = Number(btn.dataset.value);
+ const active = Math.abs(state.targetValue - val) < 0.1;
+ btn.classList.toggle("preset-step-btn--active", active);
+ });
+ };
+
+ const setTargetValue = (val: number) => {
+ state.targetValue = Math.max(1, Math.min(1_000_000_000, val));
+ updatePresetsUI();
+ };
+
+ const showStatusIndicator = (text: string) => {
+ stateIndicatorText.textContent = text;
+ stateIndicator.classList.add("big-numbers-scale__status--visible");
+ };
+
+ const hideStatusIndicator = () => {
+ stateIndicator.classList.remove("big-numbers-scale__status--visible");
+ };
+
+ const syncParticles = () => {
+ const tier = TIERS[state.currentTierIdx]!;
+
+ if (state.visualMode === "COMPRESSING") {
+ if (state.particles.length !== 100) {
+ state.particles = [];
+ for (let i = 0; i < 100; i += 1) {
+ const p = new Particle(i, state.currentTierIdx);
+ p.x = p.gx;
+ p.y = p.gy;
+ state.particles.push(p);
+ }
+ }
+ return;
+ }
+
+ if (state.visualMode === "EXPANDING") {
+ if (state.particles.length !== 100) {
+ state.particles = [];
+ const lowerTierIdx = Math.max(0, state.currentTierIdx - 1);
+ for (let i = 0; i < 100; i += 1) {
+ const p = new Particle(i, lowerTierIdx);
+ p.x = 0;
+ p.y = 0;
+ state.particles.push(p);
+ }
+ }
+ return;
+ }
+
+ const relativeVal = state.currentValue / tier.unitValue;
+ const numDots = Math.min(100, Math.max(1, Math.floor(relativeVal)));
+
+ state.particles.forEach((p) => {
+ if (p.tierIdx !== state.currentTierIdx) {
+ p.tierIdx = state.currentTierIdx;
+ p.colorInfo = CORES[tier.colorName];
+ }
+ });
+
+ if (state.particles.length !== numDots) {
+ if (state.particles.length < numDots) {
+ const toAdd = numDots - state.particles.length;
+ for (let i = 0; i < toAdd; i += 1) {
+ const idx = state.particles.length;
+ const p = new Particle(idx, state.currentTierIdx);
+ p.x = 0;
+ p.y = 0;
+ p.alpha = 0;
+ state.particles.push(p);
+ }
+ } else {
+ state.particles.splice(numDots);
+ }
+ }
+ };
+
+ const updateState = () => {
+ const activeTier = TIERS[state.currentTierIdx]!;
+ const diff = state.targetValue - state.currentValue;
+
+ if (Math.abs(diff) < 0.01) {
+ state.currentValue = state.targetValue;
+ state.visualMode = "NORMAL";
+ hideStatusIndicator();
+ } else if (state.visualMode === "NORMAL") {
+ if (diff > 0 && state.currentValue >= activeTier.unitValue * 100) {
+ if (state.currentTierIdx < TIERS.length - 1) {
+ state.visualMode = "COMPRESSING";
+ state.transitionProgress = 0;
+ audio.playCompress();
+ showStatusIndicator("Compressing 100 units...");
+ } else {
+ state.currentValue = state.targetValue;
+ }
+ } else if (diff < 0 && state.currentValue <= activeTier.unitValue && state.currentTierIdx > 0) {
+ state.visualMode = "EXPANDING";
+ state.transitionProgress = 0;
+ audio.playExpand();
+ showStatusIndicator("Expanding unit dot into 100 component dots...");
+ } else {
+ const stepSign = Math.sign(diff);
+ const logDistance = Math.max(1, Math.log10(Math.abs(diff) / activeTier.unitValue));
+ const baseVelocity = 0.15 * activeTier.unitValue * state.speedMult;
+ const increment = stepSign * Math.min(Math.abs(diff), baseVelocity * Math.pow(1.5, logDistance));
+ const dotsBefore = Math.floor(state.currentValue / activeTier.unitValue);
+ state.currentValue += increment;
+ const dotsAfter = Math.floor(state.currentValue / activeTier.unitValue);
+ if (dotsBefore !== dotsAfter && increment !== 0) {
+ audio.playTick(state.currentValue);
+ }
+ }
+ }
+
+ if (state.visualMode === "COMPRESSING") {
+ state.transitionProgress += 0.02 * state.speedMult;
+ if (state.transitionProgress >= 1) {
+ state.currentTierIdx += 1;
+ state.currentValue = TIERS[state.currentTierIdx]!.unitValue;
+ state.visualMode = "NORMAL";
+ hideStatusIndicator();
+ const sw = new Shockwave();
+ sw.color = CORES[TIERS[state.currentTierIdx]!.colorName].hex;
+ state.shockwaves.push(sw);
+ audio.playTick(state.currentValue);
+ }
+ }
+
+ if (state.visualMode === "EXPANDING") {
+ state.transitionProgress += 0.02 * state.speedMult;
+ if (state.transitionProgress >= 1) {
+ state.currentTierIdx -= 1;
+ state.currentValue = TIERS[state.currentTierIdx]!.unitValue * 100;
+ state.visualMode = "NORMAL";
+ hideStatusIndicator();
+ const sw = new Shockwave();
+ sw.color = CORES[TIERS[state.currentTierIdx]!.colorName].hex;
+ state.shockwaves.push(sw);
+ audio.playTick(state.currentValue);
+ }
+ }
+
+ syncParticles();
+ state.particles.forEach((p) => p.update());
+ state.ambientStars.forEach((s) => s.update());
+ state.shockwaves = state.shockwaves.filter((sw) => sw.alpha > 0);
+ state.shockwaves.forEach((sw) => sw.update());
+
+ if (!state.isScrubbing) {
+ logSlider.value = Math.log10(state.currentValue).toFixed(2);
+ }
+ };
+
+ const draw = () => {
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ const styles = getComputedStyle(root);
+ const canvasBg = styles.getPropertyValue("--big-scale-canvas-bg").trim() || "var(--paper)";
+ const gridColor = styles.getPropertyValue("--big-scale-grid").trim() || "rgba(30, 41, 59, 0.22)";
+ const starColor = styles.getPropertyValue("--big-scale-star").trim() || "rgba(148, 163, 184, 0.35)";
+
+ ctx.fillStyle = canvasBg;
+ ctx.fillRect(0, 0, width, height);
+
+ const cx = width / 2 + state.panX;
+ const cy = height / 2 + state.panY;
+
+ state.ambientStars.forEach((s) => s.draw(cx, cy, starColor));
+
+ ctx.save();
+ ctx.strokeStyle = gridColor;
+ ctx.lineWidth = 1;
+ const gridSpacing = 40;
+ const gridCols = Math.ceil(width / gridSpacing);
+ const gridRows = Math.ceil(height / gridSpacing);
+ const startX = state.panX % gridSpacing;
+ const startY = state.panY % gridSpacing;
+ for (let i = 0; i < gridCols; i += 1) {
+ ctx.beginPath();
+ ctx.moveTo(startX + i * gridSpacing, 0);
+ ctx.lineTo(startX + i * gridSpacing, height);
+ ctx.stroke();
+ }
+ for (let j = 0; j < gridRows; j += 1) {
+ ctx.beginPath();
+ ctx.moveTo(0, startY + j * gridSpacing);
+ ctx.lineTo(width, startY + j * gridSpacing);
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ state.shockwaves.forEach((sw) => sw.draw(cx, cy));
+ state.particles.forEach((p) => p.draw(cx, cy));
+ };
+
+ const updateHUD = () => {
+ const activeTier = TIERS[state.currentTierIdx]!;
+ currentValDisplay.textContent = formatNumber(state.currentValue);
+ tierInfoBadge.textContent = `${activeTier.label.toUpperCase()} — UNIT VALUE = ${formatNumber(activeTier.unitValue)}`;
+ tierInfoBadge.className = "big-numbers-scale__tier-badge";
+ tierInfoBadge.classList.add(`big-numbers-scale__tier-badge--${activeTier.colorName}`);
+ const currentUnitDots = Math.floor(state.currentValue / activeTier.unitValue);
+ dotCountDisplay.textContent = `${currentUnitDots} Dot${currentUnitDots !== 1 ? "s" : ""}`;
+ tierValueLabel.innerHTML = `Each dot represents ${formatNumber(activeTier.unitValue)}`;
+ powerIndicator.textContent = `10^${Math.log10(state.currentValue).toFixed(2)}`;
+ const stepMeta = STEPS.find((s) => s.value <= state.currentValue && s.value * 10 > state.currentValue) || STEPS[0];
+ analogyText.textContent = `"${stepMeta.example}"`;
+ analogyValueText.textContent = formatNumber(state.currentValue);
+ timeStatValue.textContent = formatTimeAnalogy(state.currentValue);
+ };
+
+ const resizeCanvas = () => {
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.floor(canvasContainer.clientWidth * dpr);
+ canvas.height = Math.floor(canvasContainer.clientHeight * dpr);
+ canvas.style.width = `${canvasContainer.clientWidth}px`;
+ canvas.style.height = `${canvasContainer.clientHeight}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+
+ STEPS.forEach((step) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "preset-step-btn";
+ btn.dataset.value = `${step.value}`;
+ btn.innerHTML = `${step.power}${step.label}`;
+ btn.addEventListener("click", () => {
+ setTargetValue(step.value);
+ audio.playTick(step.value);
+ });
+ presetStepsContainer.appendChild(btn);
+ });
+ updatePresetsUI();
+
+ const speedButtons = root.querySelectorAll(".speed-btn");
+ speedButtons.forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ const target = e.currentTarget as HTMLButtonElement;
+ const speed = (target.dataset.speed || "normal") as Speed;
+ state.speed = speed;
+ speedButtons.forEach((b) => b.classList.remove("speed-btn--active"));
+ target.classList.add("speed-btn--active");
+ if (speed === "slow") {
+ state.speedMult = 0.35;
+ byId("speedIndicatorText")!.textContent = "Speed: Slow";
+ } else if (speed === "normal") {
+ state.speedMult = 1;
+ byId("speedIndicatorText")!.textContent = "Speed: Normal";
+ } else {
+ state.speedMult = 3;
+ byId("speedIndicatorText")!.textContent = "Speed: Fast";
+ }
+ });
+ });
+
+ soundToggle.addEventListener("click", () => {
+ const muted = audio.toggleMute();
+ if (muted) {
+ soundIcon.textContent = "🔇";
+ soundText.textContent = "Muted";
+ } else {
+ soundIcon.textContent = "🔊";
+ soundText.textContent = "Active";
+ }
+ });
+
+ logSlider.addEventListener("input", (e) => {
+ const val = Math.pow(10, Number((e.target as HTMLInputElement).value));
+ setTargetValue(val);
+ });
+ logSlider.addEventListener("mousedown", () => {
+ state.isScrubbing = true;
+ });
+ logSlider.addEventListener("mouseup", () => {
+ state.isScrubbing = false;
+ });
+ logSlider.addEventListener("touchstart", () => {
+ state.isScrubbing = true;
+ });
+ logSlider.addEventListener("touchend", () => {
+ state.isScrubbing = false;
+ });
+
+ btnPrevStep.addEventListener("click", () => {
+ let closestSmallerStep = STEPS[0]!.value;
+ for (let i = STEPS.length - 1; i >= 0; i -= 1) {
+ if (STEPS[i]!.value < state.targetValue) {
+ closestSmallerStep = STEPS[i]!.value;
+ break;
+ }
+ }
+ setTargetValue(closestSmallerStep);
+ audio.playTick(closestSmallerStep);
+ });
+
+ btnNextStep.addEventListener("click", () => {
+ let closestLargerStep = STEPS[STEPS.length - 1]!.value;
+ for (let i = 0; i < STEPS.length; i += 1) {
+ if (STEPS[i]!.value > state.targetValue) {
+ closestLargerStep = STEPS[i]!.value;
+ break;
+ }
+ }
+ setTargetValue(closestLargerStep);
+ audio.playTick(closestLargerStep);
+ });
+
+ btnPlayPause.addEventListener("click", () => {
+ state.isPlaying = !state.isPlaying;
+ if (state.isPlaying) {
+ btnPlayPause.textContent = "Pause Autoplay";
+ btnPlayPause.classList.add("big-numbers-scale__autoplay-btn--pause");
+ showStatusIndicator("Autoplay running...");
+ } else {
+ btnPlayPause.textContent = "Auto Increment";
+ btnPlayPause.classList.remove("big-numbers-scale__autoplay-btn--pause");
+ hideStatusIndicator();
+ }
+ });
+
+ const toLocal = (evtX: number, evtY: number) => {
+ const rect = canvas.getBoundingClientRect();
+ return { x: evtX - rect.left, y: evtY - rect.top };
+ };
+
+ canvas.addEventListener("mousedown", (e) => {
+ state.isDragging = true;
+ const p = toLocal(e.clientX, e.clientY);
+ state.startX = p.x - state.panX;
+ state.startY = p.y - state.panY;
+ });
+
+ canvas.addEventListener("mousemove", (e) => {
+ if (!state.isDragging) return;
+ const p = toLocal(e.clientX, e.clientY);
+ state.panX = p.x - state.startX;
+ state.panY = p.y - state.startY;
+ });
+
+ window.addEventListener("mouseup", () => {
+ state.isDragging = false;
+ });
+
+ canvas.addEventListener("touchstart", (e) => {
+ if (e.touches.length !== 1) return;
+ state.isDragging = true;
+ const p = toLocal(e.touches[0]!.clientX, e.touches[0]!.clientY);
+ state.startX = p.x - state.panX;
+ state.startY = p.y - state.panY;
+ });
+
+ canvas.addEventListener("touchmove", (e) => {
+ if (!state.isDragging || e.touches.length !== 1) return;
+ const p = toLocal(e.touches[0]!.clientX, e.touches[0]!.clientY);
+ state.panX = p.x - state.startX;
+ state.panY = p.y - state.startY;
+ });
+
+ canvas.addEventListener("touchend", () => {
+ state.isDragging = false;
+ });
+
+ let rafId = 0;
+ let lastTime = performance.now();
+ const loop = (now: number) => {
+ const delta = now - lastTime;
+ lastTime = now;
+ if (state.isPlaying && state.visualMode === "NORMAL") {
+ const activeTier = getTierForValue(state.currentValue);
+ const multiplier = state.speed === "slow" ? 0.35 : state.speed === "normal" ? 1 : 3;
+ const logDistance = Math.max(1, Math.log10(Math.abs(state.targetValue - state.currentValue) / activeTier.unitValue));
+ const stepIncrement = activeTier.unitValue * 0.12 * multiplier * Math.pow(1.5, logDistance);
+ if (state.targetValue < 1_000_000_000) {
+ setTargetValue(state.targetValue + stepIncrement);
+ } else {
+ state.isPlaying = false;
+ setTargetValue(1);
+ btnPlayPause.click();
+ }
+ }
+ void delta;
+ updateState();
+ draw();
+ updateHUD();
+ rafId = requestAnimationFrame(loop);
+ };
+
+ resizeCanvas();
+ setTargetValue(1);
+ rafId = requestAnimationFrame(loop);
+ window.addEventListener("resize", resizeCanvas);
+
+ onCleanup(() => {
+ cancelAnimationFrame(rafId);
+ window.removeEventListener("resize", resizeCanvas);
+ });
});
return (
-