Why Sine Waves Come From Circles
The code of the Why Sine Waves Come From Circles demo, 2 files.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Why Sine Waves Come From Circles | WildflowerJS Demo</title>
<script defer src="/js/analytics.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=block" rel="stylesheet">
<!-- The framework, then Kinetic, then the lesson. With no HyperFrames
runtime on the page, Kinetic runs the clock itself, and the page is
a lesson in interactive mode. With ?preview in the URL the preview bar
comes up, and the page is the lesson's video. -->
<script defer src="/js/dist/wildflower.lite.min.js"></script>
<script defer src="/js/extensions/kinetic.wf.min.js"></script>
<script defer src="sine.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #e4dbca; color: #26282d; font-family: 'Work Sans', sans-serif; }
.demo-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 8px 20px; background: #efe8db; border-bottom: 1px solid #ddd3c2; }
.demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #26282d; font-size: 1rem; white-space: nowrap; }
.demo-toolbar .logo { height: 32px; }
.demo-toolbar .brand-wf { font-weight: 520; }
.demo-toolbar .brand-js { font-weight: 700; }
.demo-toolbar .side { display: flex; align-items: center; gap: 16px; }
.demo-toolbar .demo-info { font-size: 12px; color: #6e685f; text-align: right; }
.demo-toolbar .demo-info strong { color: #26282d; }
/* The page's own switch between the lesson and its video, outside the root, so never in the video. */
.demo-toolbar button { padding: 7px 14px; border: 0; border-radius: 10px; background: #e4dbca; color: #26282d; font: 600 14px/1.2 'Work Sans', sans-serif; white-space: nowrap; cursor: pointer; }
/* One line of toolbar on a narrow screen, so the stage keeps the room under it. */
@media (max-width: 1000px) { .demo-toolbar .demo-info { display: none; } }
/* The page: the stage as wide as the window, and no taller than the room under the toolbar (49 px on one line). */
.lesson { position: relative; width: min(100%, calc((100vh - 49px) * 16 / 9)); margin: 0 auto; }
#stage { position: relative; overflow: hidden; background: #efe8db; }
.layer { position: absolute; inset: 0; pointer-events: none; }
/* Hidden until Kinetic marks the root ready. Before that, the stage
holds unbound markup and every clip at once. */
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
/* On a phone held upright, a note over the frame, outside the root, so no check or render sees it. */
.turn { display: none; }
@media (orientation: portrait) and (max-width: 600px) {
.turn { display: block; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); margin: 0; padding: 10px 16px; border-radius: 10px; background: #26282d; color: #efe8db; font-size: 16px; font-weight: 600; white-space: nowrap; }
}
/* The illustration: the circle and the wave, right of the left column and above the band. */
.plot { position: absolute; inset: 0; width: 1280px; height: 720px; }
.axis { fill: none; stroke: #ddd3c2; stroke-width: 2; }
.unit { fill: none; stroke: #6e685f; stroke-width: 3; }
/* The circle and the wave's axis carry pathLength="1", so a dash
offset from 1 to 0 draws each on. */
.draw { stroke-dasharray: 1; }
.wedge { fill: #EFC187; }
.drop { stroke: #B1A8FF; stroke-width: 5; stroke-linecap: round; }
.link { stroke: #6e685f; stroke-width: 2; stroke-dasharray: 3 7; }
.wave { fill: none; stroke: #B1A8FF; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
.point { fill: #26282d; }
.tick { fill: #6e685f; font: 600 15px 'Work Sans', sans-serif; text-anchor: middle; }
.formula { position: absolute; left: 630px; top: 492px; margin: 0; font-size: 26px; font-weight: 600; }
.formula b { display: inline-block; min-width: 3.4em; padding: 2px 10px; border-radius: 8px; background: #B1A8FF; font-variant-numeric: tabular-nums; text-align: right; }
/* The left column, from the safe area's top left: the heading, the body, the prompt, the choices or the step's control, the note. */
.cap h2 { position: absolute; left: 64px; top: 64px; width: 520px; margin: 0; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.cap p { position: absolute; left: 64px; top: 120px; width: 480px; margin: 0; font-size: 22px; font-weight: 500; line-height: 1.3; }
.prompt { position: absolute; left: 64px; top: 196px; margin: 0; padding: 6px 12px; border-radius: 8px; background: #EFC187; font-size: 18px; font-weight: 600; }
/* The choices have no transitions. A transition runs on the page's
clock, so a render would capture it part-way. */
.choices { position: absolute; left: 64px; top: 252px; display: flex; flex-direction: column; gap: 8px; width: 220px; }
.choices button { pointer-events: auto; padding: 10px 18px; border: 0; border-radius: 10px; background: #e4dbca; color: #26282d; font: 600 22px/1.2 'Work Sans', sans-serif; text-align: left; cursor: pointer; }
.choices button:disabled { cursor: default; }
.choices button.right { background: #B1A8FF; }
.choices button.wrong { background: #EF9481; }
.cap p.note, .note { position: absolute; left: 64px; top: 484px; width: 460px; margin: 0; font-size: 22px; font-weight: 600; line-height: 1.3; }
.ctl { position: absolute; left: 64px; top: 252px; width: 360px; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 12px; pointer-events: auto; font-size: 20px; font-weight: 600; }
.ctl b { justify-self: start; padding: 2px 10px; border-radius: 8px; background: #B1A8FF; font-variant-numeric: tabular-nums; }
.ctl input { grid-column: 1 / -1; width: 100%; }
/* The band along the bottom of the safe area: where the lesson is, Play and Replay, Back and Next. */
.band { position: absolute; left: 64px; right: 64px; top: 600px; height: 56px; display: flex; align-items: center; gap: 32px; pointer-events: auto; }
.where { width: 240px; }
.count { font-size: 15px; font-weight: 600; color: #6e685f; }
.title { margin-top: 2px; font-size: 22px; font-weight: 800; letter-spacing: -0.02em; white-space: nowrap; }
.transport { flex: 1; display: flex; align-items: center; gap: 8px; }
.nav { display: flex; gap: 8px; }
.band button { padding: 10px 18px; border: 0; border-radius: 10px; background: #e4dbca; color: #26282d; font: 600 18px/1.2 'Work Sans', sans-serif; white-space: nowrap; cursor: pointer; }
.band button.play { width: 96px; } /* Play and Pause, one width */
.band button.go { background: #B1A8FF; }
.band button:disabled { background: #e4dbca; color: #6e685f; cursor: default; } /* Next sits quiet until it can be used, then lights up in the accent */
input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; margin: 0; background: transparent; cursor: pointer; }
input[type="range"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: #ddd3c2; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; margin-top: -7px; border-radius: 50%; background: #26282d; }
input[type="range"]::-moz-range-track { height: 6px; border-radius: 3px; background: #ddd3c2; }
input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border: 0; border-radius: 50%; background: #26282d; }
.about { max-width: 760px; margin: 0 auto; padding: 32px 24px 96px; color: #26282d; font-size: 16px; line-height: 1.55; }
.about p { margin: 0 0 12px; }
.about b { font-weight: 700; }
.about code { font-size: 14px; }
</style>
</head>
<body>
<div class="demo-toolbar">
<a href="/demos/">
<img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
<span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
</a>
<div class="side" data-component="page-mode">
<div class="demo-info"><strong>Why Sine Waves Come From Circles</strong>: a lesson</div>
<button type="button" id="watch-video" data-action="watchVideo" data-show="offerVideo">Watch it as a video</button>
<button type="button" id="back-to-lesson" data-action="backToLesson" data-show="offerLesson">Back to the lesson</button>
</div>
</div>
<main class="lesson">
<div id="stage" data-composition-id="sine" data-start="0" data-duration="24"
data-width="1280" data-height="720" data-fps="30">
<!-- The circle, all through the lesson. -->
<div class="layer" data-component="sine-circle" data-start="0" data-duration="24">
<svg class="plot" viewBox="0 0 1280 720" aria-hidden="true">
<path class="axis" d="M610 320 H910 M760 170 V470"></path>
<circle class="unit draw" pathLength="1" cx="760" cy="320" r="130" data-bind-style="circleDraw"></circle>
<path class="wedge" data-bind-attr="wedge"></path>
<line class="drop" data-bind-attr="drop"></line>
<circle class="point" r="9" data-bind-attr="point"></circle>
</svg>
<!-- MathML, drawn in the system's fonts; the page loads no math font. -->
<p class="formula"><math><mi>y</mi><mo>=</mo><mi>sin</mi><mo>⁡</mo><mi>θ</mi></math> = <b data-bind="sinText"></b></p>
</div>
<!-- The wave, from the end of the first step to the end. Its local
time counts from there. -->
<div class="layer" data-component="sine-wave" data-start="circle" data-duration="18">
<svg class="plot" viewBox="0 0 1280 720" aria-hidden="true">
<path class="axis draw" pathLength="1" d="M930 320 H1216" data-bind-style="axisDraw"></path>
<g data-bind-style="ticksIn">
<text class="tick" x="930" y="484">0°</text>
<text class="tick" x="1001.5" y="484">90°</text>
<text class="tick" x="1073" y="484">180°</text>
<text class="tick" x="1144.5" y="484">270°</text>
<text class="tick" x="1216" y="484">360°</text>
</g>
<line class="link" data-bind-attr="link"></line>
<path class="wave" data-bind-attr="wave"></path>
</svg>
</div>
<!-- The steps, in the left column. Each is a clip, and its id is its name. -->
<section id="circle" class="layer cap" data-step data-start="0" data-duration="6">
<h2>A point on a circle</h2>
<p>A point goes round a circle of radius 1. Its height is sin θ.</p>
</section>
<section id="unwrap" class="layer cap" data-step data-start="circle" data-duration="8">
<h2>The height draws a wave</h2>
<p>Lay the angle out along a line, and the height draws a wave.</p>
</section>
<section id="ask" class="layer cap" data-step data-start="unwrap" data-duration="5">
<h2>The lowest point</h2>
<p>At what angle is the wave at its lowest?</p>
</section>
<section id="find" class="layer cap" data-step data-start="ask" data-duration="5" data-component="sine-find">
<h2>The bottom of the circle</h2>
<p class="searching" data-show="searching">The wave is lowest where the point is lowest on the circle.</p>
<p class="found" data-show="found">There: 270°, where sin θ is −1.</p>
</section>
<!-- The question's choices, stacked under its body. -->
<div class="layer" data-component="sine-quiz" data-start="unwrap" data-duration="5">
<div class="choices" data-list="choices" data-key="a">
<template><button type="button" data-action="choose" data-bind="label" data-bind-class="cls" data-bind-attr="{ disabled: off }"></button></template>
</div>
<p class="note" data-show="why" data-bind="why"></p>
</div>
<!-- The explanation, after the question's hold at 15 s: it plays once the answer is right. -->
<div class="layer cap" data-start="16" data-duration="3"><p class="note">Right: at 270° the point is at the bottom of the circle, where its height is −1.</p></div>
<!-- Prompts to the learner, in interactive mode only, so never in the video. -->
<div class="layer" data-component="prompt" data-start="0" data-duration="24">
<p class="prompt" data-show="text" data-bind="text"></p>
</div>
<!-- The step's own control and the band: inside the root, shown in interactive mode alone,
so the video keeps their space as empty paper. -->
<div class="layer" data-component="lesson-controls" data-start="0" data-duration="24">
<label class="ctl" data-show="turning">
<span>Angle</span><b data-bind="angleText"></b>
<input type="range" min="0" max="360" step="1" aria-label="Angle" data-model="sine.angle" data-model-number>
</label>
<div class="band" data-show="$kinetic.mode === 'interactive'">
<div class="where"><div class="count" data-bind="countText"></div><div class="title" data-bind="title"></div></div>
<div class="transport">
<button type="button" class="play" data-action="toggle" data-bind="playLabel"></button>
<button type="button" data-action="replay">Replay step</button>
</div>
<div class="nav">
<button type="button" data-action="back" data-bind-attr="backAttrs">Back</button>
<button type="button" class="go" data-action="next" data-show="more" data-bind-attr="nextAttrs">Next</button>
<button type="button" class="go" data-action="again" data-show="last">Start again</button>
</div>
</div>
</div>
</div>
<p class="turn">Turn your phone sideways</p>
</main>
<div class="about">
<p>Drag the angle and answer the questions; each step waits for you. The same page plays straight through as a video.</p>
</div>
</body>
</html>
sine.js
// Why sine waves come from circles: a lesson in four steps, and the same
// lesson as a video. On the page each step plays and waits for the learner;
// as a video the steps play straight through, and each step's script writes
// what the learner would have. The lesson uses the one layout every lesson
// from Kinetic's skill uses (the skill's patterns.md, "A lesson's layout"):
// everything is inside the 1280 by 720 composition, which Kinetic scales to
// the window, with the step's heading, body, choices and angle in the left
// column, the circle and the wave beside them, and the band along the
// bottom. The angle, the prompts and the band show in interactive mode
// alone, so the video keeps their space as empty paper. On a phone held
// upright the page shows "Turn your phone sideways" over the frame, outside
// the root (index.html's CSS). In a render, or with ?embed, Kinetic shows
// the stage alone.
const { scene, step, shape, format, ramp } = wildflower.kinetic;
// Motion blur in a render, the stage fitted to the page, and the paper's still grain.
wildflower.kinetic.config({ shutter: 180, fit: true, finish: { grain: 0.08 } });
// ---- The state ----------------------------------------------------------------
// The lesson's state. The learner writes it on the page, the steps' scripts
// in the video.
wildflower.store('sine', {
state: { angle: 30, choice: null },
computed: {
right() { return this.choice === 270; },
atBottom() { return Math.abs(this.angle - 270) <= 2; },
},
});
// ---- The steps ----------------------------------------------------------------
// Seconds from each step's start. wait, until and entry are for the page;
// script is for the video.
step('circle', {
wait: 2,
script: { 'sine.angle': { at: [2, 5.5], to: [30, 120], ease: 'power2.inOut' } },
});
step('unwrap', {
wait: 1.5,
script: { 'sine.angle': { at: [1.5, 2.3, 7.5], to: [120, 0, 360], ease: ['power2.inOut', 'linear'] } },
});
step('ask', {
wait: 1, // holds before the explanation at 2 s
until: 'sine.right', // a wrong answer keeps it waiting; the right one plays the explanation
entry: { 'sine.choice': null, 'sine.angle': 360 }, // cleared on the way in, with the whole wave drawn to look at
script: { 'sine.choice': { at: [0, 2], set: [null, 270] } }, // the video answers 270° at 2 s, as the explanation starts
});
step('find', {
wait: 0.5,
until: 'sine.atBottom',
script: { 'sine.angle': { at: [0.5, 2.5], to: [360, 270], ease: 'power2.inOut' } },
});
// ---- The scenes ---------------------------------------------------------------
const CX = 760, CY = 320, R = 130; // the circle, in px
const WX = 930, WS = 286 / 360; // the wave: where 0° is, and px per degree
const RAD = Math.PI / 180;
const fx = (v) => v.toFixed(1);
const at = (deg) => ({ x: CX + R * Math.cos(deg * RAD), y: CY - R * Math.sin(deg * RAD) });
// Both scenes follow the same control, so both draw the same eased angle.
const FOLLOW = { angle: 'sine.angle' };
scene('sine-circle', {
subscribe: ['sine'],
follow: FOLLOW,
keyframes: {
circleDraw: { strokeDashoffset: { at: [0, 1.5], to: [1, 0], ease: 'power2.inOut' } },
},
computed: {
p() { return at(this.angle); },
point() { return { cx: fx(this.p.x), cy: fx(this.p.y) }; },
// Degrees in shape() run clockwise from up, so the angle from +x is 90 - angle.
// A whole turn draws no wedge, as 0° does.
wedge() {
const a = this.angle % 360;
return { d: a < 0.5 ? '' : shape('sector', { cx: CX, cy: CY, radius: 40, from: 90, to: 90 - a }) };
},
// The height: from the axis up (or down) to the point.
drop() { return { x1: fx(this.p.x), y1: CY, x2: fx(this.p.x), y2: fx(this.p.y) }; },
// signDisplay 'negative' shows a value just under 0 as 0.00, with no minus;
// a real minus sign in place of the hyphen, as in the text.
sinText() { return format(Math.sin(this.angle * RAD), { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: 'negative' }).replace('-', '−'); },
},
});
scene('sine-wave', {
subscribe: ['sine'],
follow: FOLLOW,
keyframes: {
axisDraw: { strokeDashoffset: { at: [0, 1], to: [1, 0], ease: 'power2.inOut' } },
},
computed: {
// The angles under the wave's axis, once the axis is drawn.
ticksIn() { return { opacity: ramp(this.local, 0.6, 1.2).toFixed(3) }; },
tipX() { return WX + this.angle * WS; },
// The height carried across from the circle to the wave.
link() { const p = at(this.angle); return { x1: fx(p.x), y1: fx(p.y), x2: fx(this.tipX), y2: fx(p.y) }; },
// The wave from 0° to the angle, a point a degree, ending on the angle itself.
wave() {
let d = 'M' + WX + ' ' + CY;
for (let a = 1; a < this.angle; a++) d += ' L' + fx(WX + a * WS) + ' ' + fx(CY - R * Math.sin(a * RAD));
return { d: d + ' L' + fx(this.tipX) + ' ' + fx(at(this.angle).y) };
},
},
});
// ---- The question and the last step ----------------------------------------------
// Why each wrong answer is wrong, shown while the question waits.
const WHY = {
90: 'Not quite: at 90° the point is at the top, where the wave is highest. Try another.',
180: 'Not quite: at 180° the point is on the left, at a height of 0. Try another.',
360: 'Not quite: 360° is a whole turn, back at the start, at a height of 0. Try another.',
};
// The choices. In the video the script chooses 270° two seconds into the
// question, and the same computed marks the answer right.
wildflower.component('sine-quiz', {
subscribe: ['sine', 'kinetic'],
computed: {
choices() {
const c = this.stores.sine.choice, off = this.stores.kinetic.mode === 'video'; // the video's script answers
return [90, 180, 270, 360].map((a) => ({ a, off, label: a + '°', cls: c !== a ? '' : a === 270 ? 'right' : 'wrong' }));
},
why() { return WHY[this.stores.sine.choice] || ''; },
},
choose(event, element, details) { this.stores.sine.choice = details.item.a; },
});
// The last step's body changes once the angle is at the bottom.
wildflower.component('sine-find', {
subscribe: ['sine'],
computed: {
found() { return this.stores.sine.atBottom; },
searching() { return !this.stores.sine.atBottom; },
},
});
// ---- The lesson's own parts ---------------------------------------------------------
// Prompts to the learner, in the lesson alone, until the step has what it asks for.
const PROMPTS = {
circle: 'Your turn: drag the angle, and watch the height.',
unwrap: 'Your turn: drag the angle, and the wave follows it.',
ask: 'Your turn: pick an answer.',
find: 'Your turn: drag the angle until the point is at the bottom.',
};
wildflower.component('prompt', {
subscribe: ['sine', 'kinetic'],
computed: {
text() {
const k = this.stores.kinetic, s = this.stores.sine;
if (k.mode !== 'interactive') return '';
if ((k.step === 'ask' && s.right) || (k.step === 'find' && s.atBottom)) return '';
return PROMPTS[k.step] || '';
},
},
});
// The steps' titles, for the band.
const TITLES = { circle: 'Round the circle', unwrap: 'Drawing the wave', ask: 'The lowest point', find: 'Find the bottom' };
// The step's own control and the band, both in the frame and in the lesson alone.
wildflower.component('lesson-controls', {
subscribe: ['sine', 'kinetic'],
computed: {
countText() { const k = this.stores.kinetic; return k.stepCount ? 'Step ' + (k.stepIndex + 1) + ' of ' + k.stepCount : ''; },
title() { return TITLES[this.stores.kinetic.step] || ''; },
backAttrs() { return { disabled: this.stores.kinetic.stepIndex <= 0 }; },
nextAttrs() { return { disabled: !this.stores.kinetic.canAdvance }; }, // off while until is false: a question not yet answered
last() { const k = this.stores.kinetic; return k.stepCount > 0 && k.stepIndex === k.stepCount - 1; },
more() { return !this.last; },
playLabel() { return this.stores.kinetic.playing ? 'Pause' : 'Play'; },
// The angle belongs to every step but the question, in the lesson alone.
turning() { const k = this.stores.kinetic; return k.mode === 'interactive' && k.stepIndex >= 0 && k.step !== 'ask'; },
angleText() { return Math.round(this.stores.sine.angle) + '°'; },
},
back() { wildflower.kinetic.back(); },
next() { wildflower.kinetic.next(); },
again() { wildflower.kinetic.go(0); },
replay() { wildflower.kinetic.go(this.stores.kinetic.stepIndex); },
toggle() { if (this.stores.kinetic.playing) wildflower.kinetic.pause(); else wildflower.kinetic.play(); },
});
// ---- The page's switch between the lesson and its video ---------------------------
// In the toolbar, outside the root, so never in the video. With ?preview the
// preview bar is up and the page is the lesson's video, so it offers no way
// back to the lesson.
const PREVIEW = wildflower.kinetic.config().preview;
wildflower.component('page-mode', {
subscribe: ['kinetic'],
state: { returnTo: 0 },
computed: {
offerVideo() { return this.stores.kinetic.mode === 'interactive'; },
offerLesson() { return this.stores.kinetic.mode === 'video' && !PREVIEW; },
},
// The video from the start, and back to the step the learner was on.
watchVideo() {
this.returnTo = Math.max(0, this.stores.kinetic.stepIndex);
wildflower.kinetic.config({ mode: 'video' });
wildflower.kinetic.play({ from: 0 });
},
backToLesson() {
wildflower.kinetic.config({ mode: 'interactive' });
wildflower.kinetic.go(this.returnTo);
},
});