<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Antarctic Mission Calibration</title>
<style>
:root {
--bg: #f7f7fb;
--card: #ffffff;
--text: #111827;
--muted: #6b7280;
--line: #d1d5db;
--accent: #111827;
--ok: #065f46;
--warn: #92400e;
--err: #991b1b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.45;
}
.wrap {
max-width: 1500px;
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.topbar-left {
min-width: 0;
}
.topbar-actions {
flex-shrink: 0;
}
.page {
display: none;
}
.page.active {
display: block;
}
.report {
max-width: 980px;
margin: 0 auto;
}
.report h2 {
font-size: 1.35rem;
margin-top: 0;
margin-bottom: 14px;
}
.report p {
margin: 0 0 14px;
font-size: 1rem;
line-height: 1.6;
}
.report .section {
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px;
background: #fcfcfe;
margin-bottom: 16px;
}
.math-display {
margin: 10px 0 2px;
overflow-x: auto;
}
h1 {
font-size: 1.8rem;
margin: 0 0 8px;
}
.subtitle {
color: var(--muted);
margin-bottom: 24px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
h2 {
font-size: 1.15rem;
margin: 0 0 14px;
}
label {
display: block;
font-weight: 700;
margin-bottom: 6px;
}
.hint {
font-size: 0.92rem;
color: var(--muted);
margin-bottom: 10px;
}
input[type="number"] {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
font-size: 1.08rem;
background: #fff;
}
.input-layout {
display: grid;
grid-template-columns: 240px 460px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.input-panel,
.output-panel {
min-width: 0;
}
.panel-box {
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
background: #fcfcfe;
height: 100%;
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 12px;
}
.measurements {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
margin-top: 10px;
}
.measure-box {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
background: #ffffff;
min-width: 0;
}
.measure-box .mini {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 6px;
}
.ambient-summary {
margin-top: 14px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
background: #ffffff;
}
.ambient-summary-label {
color: var(--muted);
font-size: 0.88rem;
margin-bottom: 6px;
}
.ambient-summary-value {
font-size: 1.12rem;
font-weight: 700;
line-height: 1.3;
}
.actions {
display: flex;
gap: 10px;
margin-top: 18px;
flex-wrap: wrap;
}
button {
appearance: none;
border: 0;
border-radius: 12px;
padding: 11px 16px;
font-size: 0.98rem;
font-weight: 700;
cursor: pointer;
background: var(--accent);
color: white;
}
button.secondary {
background: #e5e7eb;
color: #111827;
}
.status {
margin-top: 14px;
padding: 12px 14px;
border-radius: 12px;
display: none;
font-size: 0.95rem;
}
.status.ok {
display: block;
background: #ecfdf5;
color: var(--ok);
border: 1px solid #a7f3d0;
}
.status.warn {
display: block;
background: #fffbeb;
color: var(--warn);
border: 1px solid #fde68a;
}
.status.err {
display: block;
background: #fef2f2;
color: var(--err);
border: 1px solid #fecaca;
}
.output-layout {
display: grid;
grid-template-columns: minmax(700px, 1.45fr) minmax(520px, 1fr);
gap: 18px;
align-items: stretch;
}
.result-block {
margin-top: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-content: start;
}
.stat {
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
background: #ffffff;
min-height: 88px;
}
.stat.wide {
grid-column: 1 / -1;
}
.stat .k {
color: var(--muted);
font-size: 0.88rem;
margin-bottom: 6px;
}
.stat .v {
font-size: 1.12rem;
font-weight: 700;
margin-top: 2px;
word-break: break-word;
line-height: 1.3;
}
.piecewise {
display: flex;
align-items: center;
gap: 10px;
padding: 22px;
border: 1px solid var(--line);
border-radius: 16px;
background: #ffffff;
overflow-x: auto;
min-height: 100%;
}
.lhs {
font-size: 2.2rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
padding-top: 0;
align-self: center;
}
.brace {
font-size: 7rem;
line-height: 1;
transform: translateY(-16px) scaleY(1.32);
margin-top: 0;
user-select: none;
align-self: center;
}
.cases {
display: grid;
grid-template-columns: max-content max-content;
row-gap: 10px;
column-gap: 2cm;
align-items: center;
min-width: 0;
padding-top: 0;
}
.expr,
.cond {
font-family: "Times New Roman", Times, serif;
font-size: 1.32rem;
white-space: nowrap;
line-height: 1.2;
}
.cond {
color: #111827;
}
.mono {
font-family: Consolas, Monaco, monospace;
}
.formula {
font-family: "Times New Roman", Times, serif;
font-style: italic;
white-space: nowrap;
}
.overbar {
display: inline-block;
text-decoration: overline;
text-decoration-thickness: 1.5px;
padding: 0 0.08em;
line-height: 1;
}
.foot {
color: var(--muted);
font-size: 0.9rem;
margin-top: 12px;
}
.graph-card {
padding-top: 18px;
}
.graph-wrap {
border: 1px solid var(--line);
border-radius: 16px;
background: #ffffff;
padding: 12px;
overflow: hidden;
}
.graph-canvas {
display: block;
width: 100%;
height: 460px;
cursor: crosshair;
}
@media (max-width: 1200px) {
.input-layout {
grid-template-columns: 1fr;
}
.output-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lhs {
font-size: 1.7rem;
}
.brace {
font-size: 5.7rem;
transform: translateY(-12px) scaleY(1.32);
}
.cases {
min-width: 380px;
grid-template-columns: max-content max-content;
row-gap: 8px;
column-gap: 1.2cm;
}
.expr,
.cond {
font-size: 1.05rem;
}
.stats {
grid-template-columns: 1fr;
}
.stat.wide {
grid-column: auto;
}
}
/* Snow and Penguins CSS */
@keyframes snowfall {
0% {
transform: translateY(-10vh) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(110vh) rotate(360deg);
opacity: 0;
}
}
.snowflake {
position: fixed;
top: -10px;
z-index: 9999;
pointer-events: none;
color: #93c5fd;
animation: snowfall linear forwards;
}
@keyframes slidePenguinRight {
0% {
left: -150px;
}
100% {
left: 110vw;
}
}
@keyframes slidePenguinReverse {
0% {
left: 110vw;
}
100% {
left: -150px;
}
}
@keyframes waddleRight {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-15px) rotate(10deg);
}
75% {
transform: translateY(-15px) rotate(-10deg);
}
}
@keyframes waddleReverse {
0%,
100% {
transform: scaleX(-1) translateY(0) rotate(0deg);
}
25% {
transform: scaleX(-1) translateY(-15px) rotate(10deg);
}
75% {
transform: scaleX(-1) translateY(-15px) rotate(-10deg);
}
}
.penguin {
position: fixed;
z-index: 9998;
pointer-events: none;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.penguin-inner {
font-size: 4.5rem;
animation-duration: 0.4s;
animation-iteration-count: infinite;
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.2));
}
</style>
<script>
window.MathJax = {
tex: {
inlineMath: [["\\(", "\\)"]],
displayMath: [["\\[", "\\]"]],
},
svg: { fontCache: "global" },
};
</script>
<script
defer
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"
></script>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="topbar-left">
<h1>Antarctic Mission Calibration</h1>
<div class="subtitle">
The sensors need to be calibrated before the mission!
</div>
</div>
<div class="topbar-actions">
<button id="mainBtn" class="secondary">Calibration</button>
<button id="scaffoldBtn" class="secondary">Arduino Scaffold</button>
<button id="explanationBtn" class="secondary">Nerd stuff</button>
<button
id="snowBtn"
title="Make it snow! 🐧"
style="
font-size: 1.15rem;
padding: 7px 12px;
background: #e0f2fe;
border: 1px solid #bae6fd;
border-radius: 12px;
cursor: pointer;
transform: translateY(2px);
"
>
❄️
</button>
</div>
</div>
<div id="mainPage" class="page active">
<div class="grid">
<section class="card">
<h2>Input data</h2>
<div class="input-layout">
<div class="input-panel panel-box">
<label for="boardLength">Length of the board in cm</label>
<input
id="boardLength"
type="number"
min="20"
step="1"
value=""
/>
</div>
<div class="input-panel panel-box">
<label>Ambient temperature set of 6 measurements in °C</label>
<div class="row" id="ambientInputs"></div>
<div class="ambient-summary">
<div class="ambient-summary-label">
Average ambient temperature
<span class="formula">T̄<sub>amb</sub></span>
</div>
<div class="ambient-summary-value" id="avgAmbient">—</div>
</div>
</div>
<div class="input-panel panel-box">
<label>Warm object temperature set in °C</label>
<div class="hint">
Enter one measurement at 5 cm, one at 10 cm, and then one
additional measurement every 10 cm.
</div>
<div id="warmInputs" class="measurements"></div>
</div>
</div>
<div class="actions">
<button id="calcBtn">Calibrate</button>
<button id="resetBtn" class="secondary">Reset</button>
</div>
<div id="status" class="status"></div>
</section>
<section class="card">
<h2>Output</h2>
<div class="output-layout">
<div class="output-panel">
<div class="piecewise" id="piecewiseBox" aria-live="polite">
<div class="lhs">T(x) =</div>
<div class="brace">{</div>
<div class="cases">
<div class="expr" id="topExpr">
T<sub>max</sub>(T̄<sub>amb</sub>)
</div>
<div class="cond" id="cond1">for x ≤ x<sub>max</sub></div>
<div class="expr" id="midExpr">
T̄<sub>amb</sub> + A·e<sup>B·x</sup>
</div>
<div class="cond" id="cond2">
for x<sub>max</sub> < x < x<sub>min</sub>
</div>
<div class="expr" id="bottomExpr">
T<sub>min</sub>(T̄<sub>amb</sub>)
</div>
<div class="cond" id="cond3">for x ≥ x<sub>min</sub></div>
</div>
</div>
</div>
<div class="output-panel panel-box result-block">
<div class="stats">
<div class="stat">
<div class="k">
Upper threshold
<span class="formula">T<sub>max</sub></span>
</div>
<div class="v" id="tMaxText">—</div>
</div>
<div class="stat">
<div class="k">
Lower threshold
<span class="formula">T<sub>min</sub></span>
</div>
<div class="v" id="tMinText">—</div>
</div>
<div class="stat">
<div class="k">
Exponential coefficient <span class="formula">A</span>
</div>
<div class="v" id="coefA">—</div>
</div>
<div class="stat">
<div class="k">
Exponential coefficient <span class="formula">B</span>
</div>
<div class="v" id="coefB">—</div>
</div>
<div class="stat wide">
<div class="k">Fitted middle function</div>
<div class="v mono" id="functionText">—</div>
</div>
<div class="stat">
<div class="k">
Transition at <span class="formula">T<sub>max</sub></span>
</div>
<div class="v" id="x30Text">—</div>
</div>
<div class="stat">
<div class="k">
Transition at <span class="formula">T<sub>min</sub></span>
</div>
<div class="v" id="x245Text">—</div>
</div>
</div>
</div>
</div>
</section>
<section class="card graph-card">
<h2>Step-function graph</h2>
<div class="graph-wrap">
<canvas id="graphCanvas" class="graph-canvas"></canvas>
</div>
</section>
</div>
</div>
<div id="explanationPage" class="page">
<div class="report card">
<h2>Explanation</h2>
<div class="section">
<p>
The 6 ambient measurements are averaged to define the reference
ambient temperature.
</p>
<div class="math-display">
\[ \displaystyle \bar{T}_{amb}=\frac{1}{6}\sum_{i=1}^{6} T_{amb,i}
\]
</div>
</div>
<div class="section">
<p>
The warm-object data are fitted with an exponential curve that
decreases with distance.
</p>
<div class="math-display">
\[ \displaystyle T(x)=\bar{T}_{amb}+Ae^{Bx} \]
</div>
<div class="math-display">
\[ \displaystyle Y_i=\ln\left(T_i-\bar{T}_{amb}\right) \]
</div>
<div class="math-display">
\[ \displaystyle B=\frac{\sum x_iY_i-\frac{1}{n}\left(\sum
x_i\right)\left(\sum Y_i\right)}{\sum x_i^2-\frac{1}{n}\left(\sum
x_i\right)^2} \qquad \alpha=\bar{Y}-B\bar{x} \qquad A=e^{\alpha}
\]
</div>
</div>
<div class="section">
<p>Two practical thresholds are then defined.</p>
<div class="math-display">
\[ \displaystyle T_{max}=\max\left(30,\bar{T}_{amb}+1\right)
\qquad T_{min}=\max\left(24.5,\bar{T}_{amb}+1\right) \]
</div>
</div>
<div class="section">
<p>
The transition distances are obtained where the exponential curve
meets those thresholds.
</p>
<div class="math-display">
\[ \displaystyle T_{max}=\bar{T}_{amb}+Ae^{Bx_{max}} \qquad
T_{min}=\bar{T}_{amb}+Ae^{Bx_{min}} \]
</div>
<div class="math-display">
\[ \displaystyle
x_{max}=\frac{\ln\left(\frac{T_{max}-\bar{T}_{amb}}{A}\right)}{B}
\qquad
x_{min}=\frac{\ln\left(\frac{T_{min}-\bar{T}_{amb}}{A}\right)}{B}
\]
</div>
</div>
<div class="section">
<p>
The final result is shown as a continuous piecewise threshold and
as an interactive graph.
</p>
<div class="math-display">
\[ \displaystyle T(x)= \begin{cases} \displaystyle T_{max}, & x
\le x_{max} \\[1ex] \displaystyle \bar{T}_{amb}+Ae^{Bx}, & x_{max}
\lt x \lt x_{min} \\[1ex] \displaystyle T_{min}, & x \ge x_{min}
\end{cases} \]
</div>
</div>
</div>
</div>
<div id="scaffoldPage" class="page">
<div class="report card">
<div class="topbar" style="margin-top: 0; margin-bottom: 18px">
<div class="topbar-left">
<h2>Generated Arduino Scaffold</h2>
</div>
<div class="topbar-actions">
<button id="copyScaffoldBtn">Copy Code</button>
</div>
</div>
<p>
This is the full Arduino project scaffold including your calibrated
threshold function and dynamically assigned constants.
</p>
<pre
id="scaffoldCode"
style="
background: #f8fafc;
color: #111827;
border: 1px solid #e2e8f0;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
font-family: Consolas, Monaco, monospace;
font-size: 0.95rem;
line-height: 1.4;
margin: 0;
"
>
// Scaffold will appear here after calibration</pre
>
</div>
</div>
</div>
<script>
const boardLengthInput = document.getElementById("boardLength");
const ambientInputs = document.getElementById("ambientInputs");
const warmInputs = document.getElementById("warmInputs");
const calcBtn = document.getElementById("calcBtn");
const resetBtn = document.getElementById("resetBtn");
const explanationBtn = document.getElementById("explanationBtn");
const scaffoldBtn = document.getElementById("scaffoldBtn");
const mainBtn = document.getElementById("mainBtn");
const mainPage = document.getElementById("mainPage");
const explanationPage = document.getElementById("explanationPage");
const scaffoldPage = document.getElementById("scaffoldPage");
const statusBox = document.getElementById("status");
const avgAmbientEl = document.getElementById("avgAmbient");
const coefAEl = document.getElementById("coefA");
const coefBEl = document.getElementById("coefB");
const functionTextEl = document.getElementById("functionText");
const topExprEl = document.getElementById("topExpr");
const midExprEl = document.getElementById("midExpr");
const bottomExprEl = document.getElementById("bottomExpr");
const cond1El = document.getElementById("cond1");
const cond2El = document.getElementById("cond2");
const cond3El = document.getElementById("cond3");
const tMaxTextEl = document.getElementById("tMaxText");
const tMinTextEl = document.getElementById("tMinText");
const x30TextEl = document.getElementById("x30Text");
const x245TextEl = document.getElementById("x245Text");
const graphCanvas = document.getElementById("graphCanvas");
const scaffoldCodeEl = document.getElementById("scaffoldCode");
const copyScaffoldBtn = document.getElementById("copyScaffoldBtn");
copyScaffoldBtn.addEventListener("click", () => {
const code = scaffoldCodeEl.textContent;
if (code && !code.startsWith("// Scaffold will")) {
navigator.clipboard.writeText(code).then(() => {
const original = copyScaffoldBtn.textContent;
copyScaffoldBtn.textContent = "Copied!";
setTimeout(() => (copyScaffoldBtn.textContent = original), 2000);
});
}
});
copyScaffoldBtn.addEventListener("mouseover", () => {
copyScaffoldBtn.style.background = "#f1f5f9";
copyScaffoldBtn.style.color = "#111827";
});
copyScaffoldBtn.addEventListener("mouseout", () => {
copyScaffoldBtn.style.background = "var(--accent)";
copyScaffoldBtn.style.color = "white";
});
const defaultAmbient = [23.5, 23.4, 23.6, 23.5, 23.5, 23.4];
let currentGraphState = { mode: "placeholder" };
function setStatus(type, text) {
statusBox.className = `status ${type}`;
statusBox.textContent = text;
}
function clearStatus() {
statusBox.className = "status";
statusBox.textContent = "";
}
function formatNum(value, digits = 2) {
if (!Number.isFinite(value)) return "—";
return Number(value)
.toFixed(digits)
.replace(/\.?0+$/, (m) => (m === "." ? "" : m));
}
function formatCm(value, digits = 2) {
return `${formatNum(value, digits)} cm`;
}
function computeTransitionX(target, Tamb, A, B) {
if (!(A > 0) || Math.abs(B) < 1e-12) {
throw new Error(
"The fitted exponential cannot be used to compute continuous transition points.",
);
}
const delta = target - Tamb;
if (delta <= 0) {
throw new Error(
`Cannot compute the continuous transition at ${target} °C because it is not above the average ambient temperature.`,
);
}
const ratio = delta / A;
if (ratio <= 0) {
throw new Error(
`Cannot compute the continuous transition at ${target} °C.`,
);
}
const x = Math.log(ratio) / B;
if (!Number.isFinite(x)) {
throw new Error(
"The transition-point computation produced an invalid value.",
);
}
return x;
}
function resetPiecewiseDisplay() {
topExprEl.innerHTML = "T<sub>max</sub>(T̄<sub>amb</sub>)";
midExprEl.innerHTML = "T̄<sub>amb</sub> + A·e<sup>B·x</sup>";
bottomExprEl.innerHTML = "T<sub>min</sub>(T̄<sub>amb</sub>)";
cond1El.innerHTML = "for x ≤ x<sub>max</sub>";
cond2El.innerHTML = "for x<sub>max</sub> < x < x<sub>min</sub>";
cond3El.innerHTML = "for x ≥ x<sub>min</sub>";
tMaxTextEl.textContent = "—";
tMinTextEl.textContent = "—";
x30TextEl.textContent = "—";
x245TextEl.textContent = "—";
}
function setConstantPiecewiseOutput(tMax, d, x1) {
topExprEl.textContent = `${formatNum(tMax, 2)} °C`;
midExprEl.innerHTML = " ";
bottomExprEl.innerHTML = " ";
cond1El.innerHTML = `for x ≤ ${formatCm(d, 2)}`;
cond2El.innerHTML = " ";
cond3El.innerHTML = " ";
x30TextEl.textContent = formatCm(x1, 2);
x245TextEl.textContent = "—";
}
function setFullPiecewiseOutput(tMax, tMin, x1, x2, exprHtml, cutAtD, d) {
topExprEl.textContent = `${formatNum(tMax, 2)} °C`;
midExprEl.innerHTML = exprHtml;
if (cutAtD) {
bottomExprEl.innerHTML = " ";
cond1El.innerHTML = `for x ≤ ${formatCm(x1, 2)}`;
cond2El.innerHTML = `for ${formatCm(x1, 2)} < x ≤ ${formatCm(d, 2)}`;
cond3El.innerHTML = " ";
x30TextEl.textContent = formatCm(x1, 2);
x245TextEl.textContent = `> ${formatCm(d, 2)} (off-board)`;
} else {
bottomExprEl.textContent = `${formatNum(tMin, 2)} °C`;
cond1El.innerHTML = `for x ≤ ${formatCm(x1, 2)}`;
cond2El.innerHTML = `for ${formatCm(x1, 2)} < x < ${formatCm(x2, 2)}`;
cond3El.innerHTML = `for x ≥ ${formatCm(x2, 2)}`;
x30TextEl.textContent = formatCm(x1, 2);
x245TextEl.textContent = formatCm(x2, 2);
}
}
function getPositions(d) {
const positions = [];
if (d >= 15) positions.push(5);
for (let x = 10; x <= d - 10; x += 10) {
positions.push(x);
}
return positions;
}
function buildAmbientInputs(prefill = true) {
ambientInputs.innerHTML = "";
for (let i = 0; i < 6; i++) {
const input = document.createElement("input");
input.type = "number";
input.step = "0.01";
input.value = prefill ? defaultAmbient[i] : "";
input.placeholder = `T${i + 1}`;
input.dataset.kind = "ambient";
ambientInputs.appendChild(input);
}
}
function buildWarmInputs() {
const raw = boardLengthInput.value.trim();
const d = Number(raw);
warmInputs.innerHTML = "";
if (raw === "") {
clearStatus();
return;
}
if (!Number.isFinite(d) || d < 20) {
setStatus(
"warn",
"Enter a board length of at least 20 cm to generate the warm-object measurement points.",
);
return;
}
clearStatus();
const positions = getPositions(d);
positions.forEach((x) => {
const box = document.createElement("div");
box.className = "measure-box";
const label = document.createElement("div");
label.className = "mini";
label.textContent = `${x} cm`;
const input = document.createElement("input");
input.type = "number";
input.step = "0.01";
input.placeholder = "°C";
input.dataset.kind = "warm";
input.dataset.x = String(x);
input.value = "";
box.appendChild(label);
box.appendChild(input);
warmInputs.appendChild(box);
});
}
function readAmbientValues() {
const values = [...ambientInputs.querySelectorAll("input")].map(
(input) => Number(input.value),
);
if (values.some((v) => !Number.isFinite(v))) {
throw new Error(
"All 6 ambient temperature values must be valid numbers.",
);
}
return values;
}
function readWarmValues() {
const fields = [...warmInputs.querySelectorAll("input")];
const points = fields.map((input) => ({
x: Number(input.dataset.x),
T: Number(input.value),
}));
if (points.length < 2) {
throw new Error("At least 2 warm-object measurements are required.");
}
if (points.some((p) => !Number.isFinite(p.T))) {
throw new Error(
"All warm-object temperature values must be valid numbers.",
);
}
return points;
}
function setupCanvasResolution(canvas) {
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, Math.round(rect.width));
const height = Math.max(320, Math.round(rect.height));
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const ctx = canvas.getContext("2d");
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
return { ctx, width, height };
}
function drawPlaceholderGraph() {
const { ctx, width, height } = setupCanvasResolution(graphCanvas);
currentGraphState = { mode: "placeholder" };
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = "#d1d5db";
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
ctx.fillStyle = "#6b7280";
ctx.font = "18px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
"Calibrate to display the step-function graph",
width / 2,
height / 2,
);
}
function evaluateThreshold(x, params) {
const {
Tamb,
A,
B,
x1,
x2,
tMax,
tMin,
constantUpper = false,
} = params;
if (constantUpper) {
return tMax;
}
if (x <= x1) {
return tMax;
}
if (x < x2) {
return Tamb + A * Math.exp(B * x);
}
return tMin;
}
function drawHoverOverlay(ctx, hoverX, state) {
const { params, layout } = state;
const { xMax, yMin, yMax, pad, plotW, plotH, width } = layout;
const x = Math.max(0, Math.min(xMax, hoverX));
const y = evaluateThreshold(x, params);
function pxX(value) {
return pad.left + (value / xMax) * plotW;
}
function pxY(value) {
return pad.top + ((yMax - value) / (yMax - yMin)) * plotH;
}
const xp = pxX(x);
const yp = pxY(y);
ctx.save();
ctx.strokeStyle = "#6b7280";
ctx.lineWidth = 1.2;
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(xp, pad.top);
ctx.lineTo(xp, pad.top + plotH);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#000000";
ctx.beginPath();
ctx.arc(xp, yp, 5.5, 0, 2 * Math.PI);
ctx.fill();
const text1 = `x = ${formatNum(x, 2)} cm`;
const text2 = `T(x) = ${formatNum(y, 2)} °C`;
ctx.font = "14px Arial";
const boxW =
Math.max(ctx.measureText(text1).width, ctx.measureText(text2).width) +
20;
const boxH = 50;
let boxX = xp + 14;
let boxY = yp - boxH - 12;
if (boxX + boxW > width - 8) {
boxX = xp - boxW - 14;
}
if (boxY < pad.top + 4) {
boxY = yp + 12;
}
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = "#111827";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxW, boxH, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#111827";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText(text1, boxX + 10, boxY + 9);
ctx.fillText(text2, boxX + 10, boxY + 28);
ctx.restore();
}
function drawGraph(params, hoverX = null) {
const {
d,
Tamb,
A,
B,
x1,
x2,
tMax,
tMin,
constantUpper = false,
} = params;
const { ctx, width, height } = setupCanvasResolution(graphCanvas);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
const pad = { left: 86, right: 24, top: 20, bottom: 64 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const transitionExtent = constantUpper ? x1 : params.cutAtD ? d : x2;
const xUpper = Math.max(d, transitionExtent + 10, 10);
const xMax = Math.ceil(xUpper / 10) * 10;
const simLimit = params.cutAtD ? Math.min(x2, d) : x2;
let yMinData = params.cutAtD ? Tamb + A * Math.exp(B * simLimit) : tMin;
let yMaxData = tMax;
if (!constantUpper && simLimit > x1) {
for (
let x = x1;
x <= simLimit;
x += Math.max(0.2, (simLimit - x1) / 200)
) {
const y = Tamb + A * Math.exp(B * x);
if (Number.isFinite(y)) {
yMinData = Math.min(yMinData, y);
yMaxData = Math.max(yMaxData, y);
}
}
}
const yMin = Math.floor((Math.min(tMin, yMinData) - 0.8) * 2) / 2;
const yMax = Math.ceil((Math.max(tMax, yMaxData) + 0.8) * 2) / 2;
currentGraphState = {
mode: "data",
params,
layout: { width, height, pad, plotW, plotH, xMax, yMin, yMax },
};
function pxX(x) {
return pad.left + (x / xMax) * plotW;
}
function pxY(y) {
return pad.top + ((yMax - y) / (yMax - yMin)) * plotH;
}
ctx.strokeStyle = "#e5e7eb";
ctx.lineWidth = 1;
for (let xt = 0; xt <= xMax; xt += 10) {
const xp = pxX(xt);
ctx.beginPath();
ctx.moveTo(xp, pad.top);
ctx.lineTo(xp, pad.top + plotH);
ctx.stroke();
}
const yStep = 1;
for (let yt = Math.ceil(yMin); yt <= Math.floor(yMax); yt += yStep) {
const yp = pxY(yt);
ctx.beginPath();
ctx.moveTo(pad.left, yp);
ctx.lineTo(pad.left + plotW, yp);
ctx.stroke();
}
ctx.strokeStyle = "#111827";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(pad.left, pad.top + plotH);
ctx.lineTo(pad.left + plotW, pad.top + plotH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pad.left, pad.top + plotH);
ctx.lineTo(pad.left, pad.top);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pad.left + plotW, pad.top + plotH);
ctx.lineTo(pad.left + plotW - 9, pad.top + plotH - 5);
ctx.moveTo(pad.left + plotW, pad.top + plotH);
ctx.lineTo(pad.left + plotW - 9, pad.top + plotH + 5);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left - 5, pad.top + 9);
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left + 5, pad.top + 9);
ctx.stroke();
ctx.fillStyle = "#111827";
ctx.font = "15px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let xt = 0; xt <= xMax; xt += 10) {
ctx.fillText(String(xt), pxX(xt), pad.top + plotH + 10);
}
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let yt = Math.ceil(yMin); yt <= Math.floor(yMax); yt += yStep) {
ctx.fillText(String(yt), pad.left - 12, pxY(yt));
}
ctx.save();
ctx.translate(28, pad.top + plotH / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = '16px "Times New Roman"';
ctx.fillText("T(x) [°C]", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.font = '16px "Times New Roman"';
ctx.fillText("x [cm]", pad.left + plotW / 2, height - 14);
ctx.strokeStyle = "#000000";
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(pxX(0), pxY(tMax));
ctx.lineTo(pxX(x1), pxY(tMax));
ctx.stroke();
if (constantUpper) {
ctx.beginPath();
ctx.moveTo(pxX(x1), pxY(tMax));
ctx.lineTo(pxX(xMax), pxY(tMax));
ctx.stroke();
ctx.fillStyle = "#000000";
ctx.beginPath();
ctx.arc(pxX(x1), pxY(tMax), 6.5, 0, 2 * Math.PI);
ctx.fill();
} else if (x2 > x1 + 1e-9) {
const drawX2 = params.cutAtD ? d : x2;
ctx.beginPath();
let started = false;
const segments = 240;
for (let i = 0; i <= segments; i++) {
const x = x1 + (i / segments) * (drawX2 - x1);
const y = Tamb + A * Math.exp(B * x);
const xp = pxX(x);
const yp = pxY(y);
if (!started) {
ctx.moveTo(xp, yp);
started = true;
} else {
ctx.lineTo(xp, yp);
}
}
ctx.stroke();
if (!params.cutAtD) {
ctx.beginPath();
ctx.moveTo(pxX(x2), pxY(tMin));
ctx.lineTo(pxX(xMax), pxY(tMin));
ctx.stroke();
}
ctx.fillStyle = "#000000";
ctx.beginPath();
ctx.arc(pxX(x1), pxY(tMax), 6.5, 0, 2 * Math.PI);
ctx.fill();
if (!params.cutAtD) {
ctx.beginPath();
ctx.arc(pxX(x2), pxY(tMin), 6.5, 0, 2 * Math.PI);
ctx.fill();
}
} else {
ctx.beginPath();
ctx.moveTo(pxX(x1), pxY(tMax));
ctx.lineTo(pxX(xMax), pxY(tMax));
ctx.stroke();
ctx.fillStyle = "#000000";
ctx.beginPath();
ctx.arc(pxX(x1), pxY(tMax), 6.5, 0, 2 * Math.PI);
ctx.fill();
}
if (hoverX !== null) {
drawHoverOverlay(ctx, hoverX, currentGraphState);
}
}
function scaffoldTemplateText(Tamb, d, thresholdCode) {
return `// ===================== INCLUDES =====================
#include "Seeed_AMG8833_driver.h"
#include <Grove_I2C_Motor_Driver.h>
#include <Wire.h>
#include <stdint.h>
#include <math.h>
// ===================== CONSTANTS / GLOBALS =====================
// Motor driver settings
#define I2C_ADDRESS 0x0f
// --- CALIBRATION VALUES ---
// Replace these only after validation
float ambientTempC = ${formatNum(Tamb, 2)};
float boardSideCm = ${formatNum(d, 2)};
// --- MOTOR CONFIGURATION ---
// Rotation range will be defined by the user later
const int START_OFFSET_STEPS = 0;
int SWEEP_STEPS = 128;
int currentMotorPosition = 0;
// Approximate conversion between steps and angle
// Update this once the rotation range is defined
float stepsPerDegree = 128.0 / 90.0;
// Ultrasonic sensor pin (Grove)
const int ultrasonicPin = 2;
// Thermal camera
AMG8833 sensor;
#define ROWS 8
#define COLS 8
#define TOTAL_PIXELS 64
// ===================== HELPER FUNCTIONS =====================
${thresholdCode}
// --- MOTOR / ANGLE HELPERS ---
// TODO:
// - set SWEEP_STEPS from the user-defined rotation range
// - convert motor position to angle
// - keep 0° -> X° -> 0° sweep behavior
// --- ULTRASONIC HELPERS ---
long readUltrasonicDuration() {
pinMode(ultrasonicPin, OUTPUT);
digitalWrite(ultrasonicPin, LOW);
delayMicroseconds(2);
digitalWrite(ultrasonicPin, HIGH);
delayMicroseconds(10);
digitalWrite(ultrasonicPin, LOW);
pinMode(ultrasonicPin, INPUT);
return pulseIn(ultrasonicPin, HIGH, 30000);
}
float microsecondsToCentimeters(long duration) {
if (duration == 0) return -1.0;
return (duration * 0.0343) / 2.0;
}
// TODO:
// - optionally average multiple readings
// - return distance in cm
// - apply valid-range rule 5 < x < d in detection logic
// --- THERMAL HELPERS ---
// TODO:
// - read all 64 pixels
// - compute max temperature over the full matrix
// - do not use only middle columns
// --- DETECTION / REPORTING HELPERS ---
// TODO:
// - combine valid distance + thermal threshold condition
// - print:
// current distance
// current motor angle
// maximum temperature
// detection status
// ===================== SETUP =====================
void setup() {
Serial.begin(9600);
Wire.begin();
Motor.begin(I2C_ADDRESS);
Motor.stop(MOTOR1);
Motor.stop(MOTOR2);
sensor.init();
delay(1000);
// TODO:
// - set SWEEP_STEPS from the user-defined rotation range
// - optionally move to starting offset
if (START_OFFSET_STEPS != 0) {
int direction = (START_OFFSET_STEPS > 0) ? 1 : -1;
int stepsToMove = abs(START_OFFSET_STEPS);
for (int i = 0; i < stepsToMove; i++) {
Motor.StepperRun(direction, 0, 0);
delay(10);
}
currentMotorPosition = START_OFFSET_STEPS;
}
}
// ===================== MAIN LOOP =====================
void loop() {
currentMotorPosition = 0;
delay(500);
// ---------- Sweep Forward 0 -> X ----------
for (int i = 0; i < SWEEP_STEPS; i++) {
Motor.StepperRun(1, 0, 0);
currentMotorPosition++;
// TODO:
// - compute current angle in degrees
// - read ultrasonic sensor
// - read all thermal pixels and max temperature
// - compute threshold
// - detect only if:
// valid distance AND maxTemp > threshold
// - print serial status
delayMicroseconds(5000);
}
delay(2000);
// ---------- Sweep Backward X -> 0 ----------
for (int i = 0; i < SWEEP_STEPS; i++) {
Motor.StepperRun(-1, 0, 0);
currentMotorPosition--;
// TODO:
// - same logic as forward sweep
// - keep angle tracking consistent
delayMicroseconds(5000);
}
delay(2000);
}`;
}
function calibrate() {
try {
clearStatus();
const d = Number(boardLengthInput.value);
if (!Number.isFinite(d) || d < 20) {
throw new Error("The board length must be at least 20 cm.");
}
const ambient = readAmbientValues();
const warm = readWarmValues();
const Tamb = ambient.reduce((a, b) => a + b, 0) / ambient.length;
const valid = warm
.map((p) => ({ x: p.x, delta: p.T - Tamb }))
.filter((p) => p.delta > 0);
if (valid.length < 2) {
throw new Error(
"Not enough valid points for linearization. Each warm-object temperature used in the fit must be strictly greater than the average ambient temperature.",
);
}
let sumX = 0,
sumY = 0,
sumXX = 0,
sumXY = 0;
for (const p of valid) {
const Y = Math.log(p.delta);
sumX += p.x;
sumY += Y;
sumXX += p.x * p.x;
sumXY += p.x * Y;
}
const n = valid.length;
const denom = sumXX - (sumX * sumX) / n;
if (Math.abs(denom) < 1e-12) {
throw new Error(
"The regression denominator is zero. Check the input distances.",
);
}
const B = (sumXY - (sumX * sumY) / n) / denom;
const alpha = sumY / n - B * (sumX / n);
const A = Math.exp(alpha);
const tMax = Math.max(30, Tamb + 1);
const tMin = Math.max(24.5, Tamb + 1);
let x1 = computeTransitionX(tMax, Tamb, A, B);
let x2 = computeTransitionX(tMin, Tamb, A, B);
if (x2 + 1e-9 < x1) {
throw new Error(
"The computed transition points are not ordered correctly, so a continuous step function cannot be built from these inputs.",
);
}
x1 = Math.max(0, x1);
x2 = Math.max(0, x2);
avgAmbientEl.textContent = `${formatNum(Tamb, 2)} °C`;
tMaxTextEl.textContent = `${formatNum(tMax, 2)} °C`;
tMinTextEl.textContent = `${formatNum(tMin, 2)} °C`;
coefAEl.textContent = formatNum(A, 2);
coefBEl.textContent = formatNum(B, 4);
const exprHtml = `${formatNum(Tamb, 2)} + ${formatNum(A, 2)}·e<sup>${formatNum(B, 4)}·x</sup>`;
const exprText = `${formatNum(Tamb, 2)} + ${formatNum(A, 2)} * exp(${formatNum(B, 4)} * x)`;
functionTextEl.textContent = exprText;
const ratio1 = x1 / d;
const ratio2 = x2 / d;
const fullCodeString = `// --- REQUIRED THRESHOLD FUNCTION ---
float getHotThreshold(float distanceCm, float boardSideLength, float ambientTempC) {
if (distanceCm <= ${formatNum(ratio1, 3)} * boardSideLength) {
return ${formatNum(tMax, 1)};
} else if (distanceCm < ${formatNum(ratio2, 3)} * boardSideLength) {
return ambientTempC + ${formatNum(A, 2)} * exp(${formatNum(B, 5)} * distanceCm);
} else {
return max(24.5, ambientTempC + 1.0);
}
}`;
const fallbackCodeString = `// --- REQUIRED THRESHOLD FUNCTION ---
float getHotThreshold(float distanceCm, float boardSideLength, float ambientTempC) {
if (distanceCm <= 1.000 * boardSideLength) {
return ${formatNum(tMax, 1)};
}
return max(24.5, ambientTempC + 1.0);
}`;
const activeThresholdCode =
x1 >= d || x2 <= x1 + 1e-9 ? fallbackCodeString : fullCodeString;
scaffoldCodeEl.textContent = scaffoldTemplateText(
Tamb,
d,
activeThresholdCode,
);
if (x1 >= d) {
setConstantPiecewiseOutput(tMax, d, x1);
drawGraph({
d,
Tamb,
A,
B,
x1,
x2,
tMax,
tMin,
constantUpper: true,
});
setStatus(
"warn",
"Calibration completed. Since the T_max transition point lies beyond the board length, the threshold is shown as a constant upper-threshold line.",
);
} else if (x2 > x1 + 1e-9) {
const cutAtD = x2 >= d;
setFullPiecewiseOutput(tMax, tMin, x1, x2, exprHtml, cutAtD, d);
drawGraph({ d, Tamb, A, B, x1, x2, tMax, tMin, cutAtD });
if (cutAtD) {
setStatus(
"ok",
"Calibration completed successfully. Since the T_min transition happens past the board length, the curve is cleanly clipped at the board edge for display.",
);
} else {
setStatus(
"ok",
"Calibration completed successfully. The adaptive thresholds and transition points were computed from the continuity equations.",
);
}
} else {
setConstantPiecewiseOutput(tMax, d, x1);
drawGraph({
d,
Tamb,
A,
B,
x1,
x2,
tMax,
tMin,
constantUpper: true,
});
setStatus(
"warn",
"Calibration completed. Here T_max = T_min, so the threshold becomes a constant upper-threshold line.",
);
}
} catch (err) {
avgAmbientEl.textContent = "—";
tMaxTextEl.textContent = "—";
tMinTextEl.textContent = "—";
coefAEl.textContent = "—";
coefBEl.textContent = "—";
functionTextEl.textContent = "—";
midExprEl.innerHTML = "T̄<sub>amb</sub> + A·e<sup>B·x</sup>";
scaffoldCodeEl.textContent =
"// Scaffold will appear here after calibration";
resetPiecewiseDisplay();
drawPlaceholderGraph();
setStatus("err", err.message || "Calibration failed.");
}
}
boardLengthInput.addEventListener("input", () => {
buildWarmInputs();
});
calcBtn.addEventListener("click", calibrate);
resetBtn.addEventListener("click", () => {
boardLengthInput.value = "";
buildAmbientInputs(false);
warmInputs.innerHTML = "";
avgAmbientEl.textContent = "—";
tMaxTextEl.textContent = "—";
tMinTextEl.textContent = "—";
coefAEl.textContent = "—";
coefBEl.textContent = "—";
functionTextEl.textContent = "—";
midExprEl.innerHTML = "T̄<sub>amb</sub> + A·e<sup>B·x</sup>";
scaffoldCodeEl.textContent =
"// Scaffold will appear here after calibration";
resetPiecewiseDisplay();
clearStatus();
drawPlaceholderGraph();
});
buildAmbientInputs(false);
buildWarmInputs();
resetPiecewiseDisplay();
drawPlaceholderGraph();
function showPage(pageEl) {
document
.querySelectorAll(".page")
.forEach((p) => p.classList.remove("active"));
pageEl.classList.add("active");
}
mainBtn.addEventListener("click", () => showPage(mainPage));
scaffoldBtn.addEventListener("click", () => showPage(scaffoldPage));
explanationBtn.addEventListener("click", () => {
showPage(explanationPage);
if (window.MathJax && window.MathJax.typesetPromise) {
window.MathJax.typesetPromise([explanationPage]);
}
});
function getCanvasPlotX(event) {
if (currentGraphState.mode !== "data") return null;
const rect = graphCanvas.getBoundingClientRect();
const clientX = event.touches
? event.touches[0].clientX
: event.clientX;
const xPx = clientX - rect.left;
const { pad, plotW, xMax } = currentGraphState.layout;
if (xPx < pad.left || xPx > pad.left + plotW) {
return null;
}
return ((xPx - pad.left) / plotW) * xMax;
}
graphCanvas.addEventListener("mousemove", (event) => {
const hoverX = getCanvasPlotX(event);
if (currentGraphState.mode !== "data") return;
if (hoverX === null) {
drawGraph(currentGraphState.params);
} else {
drawGraph(currentGraphState.params, hoverX);
}
});
graphCanvas.addEventListener("mouseleave", () => {
if (currentGraphState.mode === "data") {
drawGraph(currentGraphState.params);
}
});
graphCanvas.addEventListener(
"touchstart",
(event) => {
const hoverX = getCanvasPlotX(event);
if (currentGraphState.mode !== "data" || hoverX === null) return;
event.preventDefault();
drawGraph(currentGraphState.params, hoverX);
},
{ passive: false },
);
graphCanvas.addEventListener(
"touchmove",
(event) => {
const hoverX = getCanvasPlotX(event);
if (currentGraphState.mode !== "data" || hoverX === null) return;
event.preventDefault();
drawGraph(currentGraphState.params, hoverX);
},
{ passive: false },
);
graphCanvas.addEventListener("touchend", () => {
if (currentGraphState.mode === "data") {
drawGraph(currentGraphState.params);
}
});
window.addEventListener("resize", () => {
if (
coefAEl.textContent !== "—" &&
coefBEl.textContent !== "—" &&
avgAmbientEl.textContent !== "—"
) {
calibrate();
} else {
drawPlaceholderGraph();
}
});
// SNOW AND PENGUINS EFFECT
const snowBtn = document.getElementById("snowBtn");
let snowInterval;
snowBtn.addEventListener("click", () => {
if (snowInterval) return;
let elapsed = 0;
snowInterval = setInterval(() => {
createSnowflake();
createSnowflake();
elapsed += 100;
if (elapsed >= 5000) {
clearInterval(snowInterval);
snowInterval = null;
}
}, 100);
for (let i = 0; i < Math.floor(Math.random() * 3) + 3; i++) {
setTimeout(createPenguin, Math.random() * 2000);
}
});
function createSnowflake() {
const fl = document.createElement("div");
fl.className = "snowflake";
fl.textContent = Math.random() > 0.5 ? "❄️" : "❅";
fl.style.left = Math.random() * 100 + "vw";
fl.style.animationDuration = Math.random() * 3 + 2 + "s";
fl.style.fontSize = Math.random() * 1.5 + 0.8 + "rem";
document.body.appendChild(fl);
setTimeout(() => fl.remove(), 5000);
}
function createPenguin() {
const p = document.createElement("div");
p.className = "penguin";
p.style.bottom = Math.random() * 30 + 10 + "px";
p.style.animationDuration = Math.random() * 2 + 3 + "s";
const inner = document.createElement("div");
inner.className = "penguin-inner";
inner.textContent = "🐧";
const isReverse = Math.random() > 0.5;
if (isReverse) {
p.style.animationName = "slidePenguinReverse";
inner.style.animationName = "waddleReverse";
} else {
p.style.animationName = "slidePenguinRight";
inner.style.animationName = "waddleRight";
}
p.appendChild(inner);
document.body.appendChild(p);
setTimeout(() => p.remove(), 6000);
}
</script>
</body>
</html>