Skip to content

Instantly share code, notes, and snippets.

@nsdevaraj
Created March 14, 2026 12:39
Show Gist options
  • Select an option

  • Save nsdevaraj/64d6a03695d10bb5cc404fe2ab42a81c to your computer and use it in GitHub Desktop.

Select an option

Save nsdevaraj/64d6a03695d10bb5cc404fe2ab42a81c to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morse Code Tutor + Trainer</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useRef } = React;
const MORSE = {
A: ".-", B: "-...", C: "-.-.", D: "-..", E: ".", F: "..-.", G: "--.", H: "....",
I: "..", J: ".---", K: "-.-", L: ".-..", M: "--", N: "-.", O: "---", P: ".--.",
Q: "--.-", R: ".-.", S: "...", T: "-", U: "..-", V: "...-", W: ".--", X: "-..-",
Y: "-.--", Z: "--.."
};
const N_ = {
START: [460, 32],
T: [340, 72], E: [560, 72],
M: [200, 72], I: [680, 72],
O: [68, 72], S: [790, 72], H: [905, 72],
G: [228, 205], U: [610, 205],
Q: [78, 205], V: [755, 205],
Z: [268, 335], F: [648, 335],
N: [368, 455], A: [530, 455], K: [208, 455], R: [680, 455],
Y: [68, 455], L: [832, 455],
C: [230, 555],
D: [388, 645],
X: [158, 735], W: [548, 735], P: [710, 735],
B: [318, 835], J: [588, 835],
};
const EDGES = [
["START", "T", "dash"], ["START", "E", "dot"],
["T", "M", "dash"], ["M", "O", "dash"],
["E", "I", "dot"], ["I", "S", "dot"], ["S", "H", "dot"],
["M", "G", "dot"], ["G", "Q", "dash"], ["G", "Z", "dot"],
["I", "U", "dash"], ["U", "F", "dot"], ["S", "V", "dash"],
["T", "N", "dot"], ["E", "A", "dash"],
["N", "K", "dash"], ["K", "Y", "dash"], ["K", "C", "dot"],
["A", "R", "dot"], ["R", "L", "dot"],
["N", "D", "dot"], ["D", "X", "dash"], ["D", "B", "dot"],
["A", "W", "dash"], ["W", "P", "dot"], ["W", "J", "dash"],
];
const childMap = {};
EDGES.forEach(([p, c, t]) => {
if (!childMap[p]) childMap[p] = {};
childMap[p][t] = c;
});
function getPath(letter) {
const morse = MORSE[letter];
if (!morse) return [];
const path = ["START"];
let cur = "START";
for (const s of morse) {
const type = s === "." ? "dot" : "dash";
const next = childMap[cur]?.[type];
if (!next) break;
path.push(next);
cur = next;
}
return path;
}
function getActiveEdges(path) {
const set = new Set();
for (let i = 0; i < path.length - 1; i++) {
set.add(`${path[i]}-${path[i + 1]}`);
}
return set;
}
function midXY(a, b) {
return [(N_[a][0] + N_[b][0]) / 2, (N_[a][1] + N_[b][1]) / 2];
}
const Rod = ({ x1, y1, x2, y2, glow }) => (
<line x1={x1} y1={y1} x2={x2} y2={y2}
stroke={glow ? "#b45309" : "#3a3a3a"}
strokeWidth={glow ? 5 : 4}
strokeLinecap="round"
/>
);
const DotShape = ({ cx, cy, glow, vertical }) => (
<g>
{glow && <circle cx={cx} cy={cy} r={16} fill="#f59e0b" opacity={0.2} />}
<circle cx={cx} cy={cy} r={11}
fill={glow ? "url(#dotGlow)" : "url(#dotDark)"}
stroke={glow ? "#fbbf24" : "#555"} strokeWidth={1.5} />
<ellipse cx={cx - 3} cy={cy - 3} rx={4} ry={3} fill="rgba(255,255,255,0.12)" />
</g>
);
const DashShape = ({ cx, cy, glow, vertical }) => {
const w = vertical ? 18 : 44;
const h = vertical ? 44 : 18;
return (
<g>
{glow && <rect x={cx - w / 2 - 3} y={cy - h / 2 - 3} width={w + 6} height={h + 6} rx={6} fill="#f59e0b" opacity={0.18} />}
<rect x={cx - w / 2} y={cy - h / 2} width={w} height={h} rx={4}
fill={glow ? "url(#dashGlow)" : "url(#dashDark)"}
stroke={glow ? "#fbbf24" : "#555"} strokeWidth={1.5} />
<rect x={cx - w / 2 + 3} y={cy - h / 2 + 3} width={vertical ? 12 : 26} height={vertical ? 12 : 5} rx={2} fill="rgba(255,255,255,0.08)" />
</g>
);
};
const NodeCircle = ({ cx, cy, glow, label, labelPos }) => {
const off = labelPos || "below";
let lx = cx, ly = cy;
if (off === "below") { ly = cy + 22; }
else if (off === "above") { ly = cy - 16; }
else if (off === "left") { lx = cx - 18; ly = cy + 2; }
else if (off === "right") { lx = cx + 18; ly = cy + 2; }
return (
<g>
{glow && <circle cx={cx} cy={cy} r={14} fill="#f59e0b" opacity={0.25} />}
<circle cx={cx} cy={cy} r={8}
fill={glow ? "#92400e" : "#222"}
stroke={glow ? "#fbbf24" : "#555"} strokeWidth={1.5} />
{glow && <circle cx={cx} cy={cy} r={4} fill="#fde68a" opacity={0.6} />}
<text x={lx} y={ly} textAnchor="middle" dominantBaseline="central"
fontSize={16} fontWeight={800} fontFamily="'Courier New',monospace"
fill={glow ? "#fde68a" : "#777"}
style={glow ? { filter: "url(#textGlow)" } : {}}>
{label}
</text>
</g>
);
};
const labelPositions = {
O: "above", M: "above", T: "above", E: "above", I: "above", S: "above", H: "above",
Q: "left", G: "right", U: "left", V: "right",
Z: "right", F: "right",
Y: "above", K: "above", N: "above", A: "above", R: "above", L: "above",
C: "right", D: "right", X: "left", W: "left", P: "right",
B: "right", J: "right"
};
function MorseTutor() {
const [text, setText] = useState("");
const [charIdx, setCharIdx] = useState(0);
const [playing, setPlaying] = useState(false);
const [playIdx, setPlayIdx] = useState(-1);
const [mode, setMode] = useState("visual");
const [wpm, setWpm] = useState(15);
const [quizScore, setQuizScore] = useState(0);
const [quizTotal, setQuizTotal] = useState(0);
const [currentLetter, setCurrentLetter] = useState("");
const [userInput, setUserInput] = useState("");
const [showFeedback, setShowFeedback] = useState(false);
const [isCorrect, setIsCorrect] = useState(false);
const [isPlayingQuiz, setIsPlayingQuiz] = useState(false);
const stopRef = useRef(false);
const audioCtx = useRef(null);
const chars = text.toUpperCase().split("").filter(c => MORSE[c] || c === " ");
const currentChar = playing ? (chars[playIdx] || "") : (chars[charIdx] || "");
const path = currentChar && currentChar !== " " ? getPath(currentChar) : [];
const activeNodes = new Set(path);
const activeEdges = getActiveEdges(path);
const getAudioCtx = () => {
if (!audioCtx.current) audioCtx.current = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx.current;
};
const beep = (dur) => new Promise(res => {
const ctx = getAudioCtx();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.frequency.value = 620; o.type = "sine"; g.gain.value = 0.12;
o.connect(g); g.connect(ctx.destination);
o.start();
setTimeout(() => { o.stop(); res(); }, dur);
});
const sleep = ms => new Promise(r => setTimeout(r, ms));
const playMorse = async () => {
if (playing) { stopRef.current = true; return; }
if (chars.length === 0) return;
stopRef.current = false;
setPlaying(true);
for (let i = 0; i < chars.length; i++) {
if (stopRef.current) break;
setPlayIdx(i);
const c = chars[i];
const m = MORSE[c];
if (!m) { await sleep(400); continue; }
for (const s of m) {
if (stopRef.current) break;
await beep(s === "." ? 80 : 240);
await sleep(80);
}
await sleep(350);
}
setPlayIdx(-1);
setPlaying(false);
};
const handleInput = (e) => {
setText(e.target.value);
setCharIdx(0);
};
const morseStr = currentChar && MORSE[currentChar] ? MORSE[currentChar] : "";
const btnStyle = (bg, color) => ({
background: bg, border: "1px solid #444", borderRadius: 6, padding: "6px 16px",
color, fontSize: 12, fontWeight: 700, cursor: "pointer", letterSpacing: 1,
textTransform: "uppercase", fontFamily: "'Courier New',monospace",
});
// === TRAINER HELPERS ===
const getRandomLetter = () => Object.keys(MORSE)[Math.floor(Math.random() * Object.keys(MORSE).length)];
const calculateTimings = (speed) => {
const dit = Math.max(20, 1200 / speed);
return { dot: dit, dash: 3 * dit, intra: dit, letterSpace: 3 * dit };
};
const playSingleLetter = async (letter, speed) => {
if (isPlayingQuiz) return;
setIsPlayingQuiz(true);
const timings = calculateTimings(speed);
const m = MORSE[letter];
if (!m) {
setIsPlayingQuiz(false);
return;
}
for (const s of m) {
await beep(s === "." ? timings.dot : timings.dash);
await sleep(timings.intra);
}
await sleep(timings.letterSpace);
setIsPlayingQuiz(false);
};
const nextQuiz = () => {
const letter = getRandomLetter();
setCurrentLetter(letter);
setUserInput("");
setShowFeedback(false);
setIsCorrect(false);
playSingleLetter(letter, wpm);
};
const startQuiz = () => {
setQuizScore(0);
setQuizTotal(0);
setCurrentLetter("");
nextQuiz();
};
const submitGuess = () => {
if (!currentLetter || userInput.trim() === "") return;
const guess = userInput.trim().toUpperCase();
const correct = guess === currentLetter;
setIsCorrect(correct);
setShowFeedback(true);
setQuizScore(prev => prev + (correct ? 1 : 0));
setQuizTotal(prev => prev + 1);
setTimeout(() => {
nextQuiz();
}, 1200);
};
const endQuiz = () => {
setCurrentLetter("");
setUserInput("");
setShowFeedback(false);
};
return (
<div style={{
minHeight: "100vh",
background: "linear-gradient(160deg,#0a0a0a 0%,#151515 40%,#0e0e0e 100%)",
color: "#ccc", fontFamily: "'Segoe UI',sans-serif",
padding: "16px 8px", boxSizing: "border-box",
}}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 12 }}>
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#ddd", margin: 0, letterSpacing: 6, fontFamily: "'Courier New',monospace", textTransform: "uppercase" }}>
Morse Code Tutor
</h1>
<div style={{ width: 50, height: 2, margin: "6px auto", background: "linear-gradient(90deg,transparent,#f59e0b,transparent)" }} />
</div>
{/* TABS */}
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginBottom: 16 }}>
<button
onClick={() => setMode("visual")}
style={btnStyle(mode === "visual" ? "#78350f" : "#1a1a1a", mode === "visual" ? "#fde68a" : "#888")}>
Visual Tutor
</button>
<button
onClick={() => setMode("trainer")}
style={btnStyle(mode === "trainer" ? "#78350f" : "#1a1a1a", mode === "trainer" ? "#fde68a" : "#888")}>
Trainer (Speed + Quiz)
</button>
</div>
{/* VISUAL MODE */}
{mode === "visual" && (
<div style={{ background: "#111", borderRadius: 12, border: "1px solid #252525", padding: "8px", marginBottom: 10, boxShadow: "inset 0 4px 20px rgba(0,0,0,0.7)" }}>
<svg viewBox="0 0 960 890" width="100%" style={{ display: "block" }}>
<defs>
<radialGradient id="dotGlow" cx="50%" cy="40%" r="60%">
<stop offset="0%" stopColor="#fde68a" />
<stop offset="60%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#92400e" />
</radialGradient>
<radialGradient id="dotDark" cx="35%" cy="35%" r="65%">
<stop offset="0%" stopColor="#444" />
<stop offset="100%" stopColor="#1a1a1a" />
</radialGradient>
<linearGradient id="dashGlow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#fde68a" />
<stop offset="50%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#92400e" />
</linearGradient>
<linearGradient id="dashDark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#444" />
<stop offset="50%" stopColor="#2a2a2a" />
<stop offset="100%" stopColor="#1a1a1a" />
</linearGradient>
<filter id="textGlow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
<filter id="lineGlow">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<rect width="960" height="890" fill="#0f0f0f" rx="8" />
{EDGES.map(([p, c, t], i) => {
const [x1, y1] = N_[p];
const [x2, y2] = N_[c];
const key = `${p}-${c}`;
const glow = activeEdges.has(key);
return (
<g key={i} style={glow ? { filter: "url(#lineGlow)" } : {}}>
<Rod x1={x1} y1={y1} x2={x2} y2={y2} glow={glow} />
</g>
);
})}
{EDGES.map(([p, c, t], i) => {
const [mx, my] = midXY(p, c);
const key = `${p}-${c}`;
const glow = activeEdges.has(key);
const vertical = Math.abs(N_[p][1] - N_[c][1]) > Math.abs(N_[p][0] - N_[c][0]);
if (t === "dot") return <DotShape key={`s${i}`} cx={mx} cy={my} glow={glow} vertical={vertical} />;
return <DashShape key={`s${i}`} cx={mx} cy={my} glow={glow} vertical={vertical} />;
})}
{(() => {
const [sx, sy] = N_.START;
const glow = activeNodes.has("START");
return (
<g>
<polygon points={`${sx - 12},${sy - 16} ${sx + 12},${sy - 16} ${sx},${sy + 4}`}
fill={glow ? "#92400e" : "#2a2a2a"} stroke={glow ? "#fbbf24" : "#555"} strokeWidth={2} />
{glow && <polygon points={`${sx - 12},${sy - 16} ${sx + 12},${sy - 16} ${sx},${sy + 4}`}
fill="#f59e0b" opacity={0.3} />}
</g>
);
})()}
{Object.entries(N_).filter(([k]) => k !== "START").map(([letter, [cx, cy]]) => (
<NodeCircle
key={letter}
cx={cx}
cy={cy}
glow={activeNodes.has(letter)}
label={letter}
labelPos={labelPositions[letter]}
/>
))}
</svg>
<div style={{ textAlign: "center", marginBottom: 8, minHeight: 50 }}>
{currentChar && currentChar !== " " && (
<div style={{ display: "inline-flex", alignItems: "center", gap: 12, background: "rgba(245,158,11,0.08)", border: "1px solid rgba(245,158,11,0.2)", borderRadius: 10, padding: "8px 20px" }}>
<span style={{ fontSize: 28, fontWeight: 900, fontFamily: "'Courier New',monospace", color: "#fde68a" }}>{currentChar}</span>
<span style={{ fontSize: 20, fontFamily: "monospace", color: "#f59e0b", letterSpacing: 4 }}>{morseStr}</span>
<span style={{ fontSize: 13, color: "#888", fontFamily: "monospace" }}>
{morseStr.split("").map(s => s === "." ? "dot" : "dash").join(" β†’ ")}
</span>
</div>
)}
</div>
<div style={{ background: "#131313", borderRadius: 10, border: "1px solid #222", padding: 12 }}>
<input type="text" value={text} onChange={handleInput} placeholder="Type a message..."
maxLength={40}
style={{
width: "100%", background: "#0a0a0a", border: "1px solid #333", borderRadius: 8,
padding: "10px 14px", fontSize: 18, fontFamily: "'Courier New',monospace",
color: "#fde68a", letterSpacing: 3, outline: "none", boxSizing: "border-box",
caretColor: "#f59e0b",
}} />
<div style={{ display: "flex", gap: 8, marginTop: 10, justifyContent: "center", flexWrap: "wrap", alignItems: "center" }}>
{chars.length > 1 && !playing && (
<>
<button onClick={() => setCharIdx(Math.max(0, charIdx - 1))} style={btnStyle("#1a1a1a", "#666")}>Prev</button>
<span style={{ color: "#888", fontSize: 13, fontFamily: "monospace", minWidth: 60, textAlign: "center" }}>
{charIdx + 1} / {chars.length}
</span>
<button onClick={() => setCharIdx(Math.min(chars.length - 1, charIdx + 1))} style={btnStyle("#1a1a1a", "#666")}>Next</button>
<div style={{ width: 1, height: 24, background: "#333", margin: "0 4px" }} />
</>
)}
<button onClick={playMorse} style={btnStyle(playing ? "#7f1d1d" : "#78350f", playing ? "#fca5a5" : "#fde68a")}>
{playing ? "Stop" : "Transmit"}
</button>
<button onClick={() => { setText(""); setCharIdx(0); }} style={btnStyle("#1a1a1a", "#888")}>Clear</button>
</div>
</div>
</div>
)}
{/* TRAINER MODE - NEW FEATURE */}
{mode === "trainer" && (
<div style={{ background: "#111", borderRadius: 12, border: "1px solid #252525", padding: "20px", boxShadow: "inset 0 4px 20px rgba(0,0,0,0.7)" }}>
<h2 style={{ fontSize: 18, color: "#f59e0b", textAlign: "center", marginBottom: 16, fontFamily: "'Courier New',monospace" }}>TRAINER MODE</h2>
{/* SPEED SETTINGS */}
<div style={{ textAlign: "center", marginBottom: 24 }}>
<label style={{ display: "block", marginBottom: 8, color: "#ccc", fontSize: 15 }}>
Speed: <strong>{wpm} WPM</strong> (dit = {Math.round(1200 / wpm)} ms)
</label>
<input
type="range"
min="5"
max="40"
step="1"
value={wpm}
onChange={(e) => setWpm(parseInt(e.target.value))}
style={{ width: "70%", accentColor: "#f59e0b" }}
/>
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginTop: 12 }}>
<button onClick={() => setWpm(8)} style={btnStyle("#1a1a1a", "#888")}>Slow 8</button>
<button onClick={() => setWpm(15)} style={btnStyle("#1a1a1a", "#888")}>Medium 15</button>
<button onClick={() => setWpm(25)} style={btnStyle("#1a1a1a", "#888")}>Fast 25</button>
</div>
</div>
{/* QUIZ CONTROLS */}
<div style={{ display: "flex", justifyContent: "center", gap: 12, marginBottom: 20 }}>
<button onClick={startQuiz} style={btnStyle("#78350f", "#fde68a")}>START NEW QUIZ</button>
{quizTotal > 0 && <button onClick={endQuiz} style={btnStyle("#1a1a1a", "#888")}>END SESSION</button>}
</div>
{quizTotal > 0 && (
<div style={{ textAlign: "center", fontSize: 18, marginBottom: 20, color: "#fde68a" }}>
SCORE: <strong>{quizScore}</strong> / {quizTotal} ({quizTotal > 0 ? Math.round((quizScore / quizTotal) * 100) : 0}%)
</div>
)}
{currentLetter ? (
<div style={{ background: "#131313", borderRadius: 12, padding: 24, textAlign: "center" }}>
<p style={{ marginBottom: 16, color: "#ccc", fontSize: 15 }}>Listen &amp; type the letter:</p>
<button
onClick={() => playSingleLetter(currentLetter, wpm)}
disabled={isPlayingQuiz}
style={btnStyle(isPlayingQuiz ? "#4a2a0f" : "#78350f", "#fde68a")}>
{isPlayingQuiz ? "Playing..." : "πŸ” Replay"}
</button>
<div style={{ margin: "20px 0" }}>
<input
type="text"
value={userInput}
onChange={(e) => setUserInput(e.target.value.toUpperCase().slice(0, 1))}
maxLength={1}
onKeyPress={(e) => e.key === "Enter" && submitGuess()}
style={{
width: 90, textAlign: "center", fontSize: 42,
fontFamily: "'Courier New',monospace",
background: "#0a0a0a", border: "3px solid #f59e0b",
color: "#fde68a", borderRadius: 12, padding: 12,
}}
placeholder="?"
/>
</div>
<button onClick={submitGuess} style={btnStyle("#78350f", "#fde68a")}>SUBMIT GUESS</button>
{showFeedback && (
<div style={{
marginTop: 20,
padding: 14,
borderRadius: 10,
background: isCorrect ? "rgba(74, 222, 128, 0.15)" : "rgba(248, 113, 113, 0.15)",
color: isCorrect ? "#4ade80" : "#f87171",
fontWeight: 700,
fontSize: 16
}}>
{isCorrect ? "βœ… Correct!" : `❌ It was ${currentLetter}`}
</div>
)}
</div>
) : quizTotal === 0 ? (
<div style={{ textAlign: "center", color: "#666", padding: "60px 20px", fontSize: 15 }}>
Click <strong>START NEW QUIZ</strong> to begin.<br />
Random letters will play at your chosen speed.<br />
Type the letter you hear and hit Submit (or Enter).
</div>
) : null}
</div>
)}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<MorseTutor />);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment