Graph / index.html
SMKBK1's picture
Update index.html
3c4d62d verified
Raw History Blame Contribute Delete
83.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Physics Graph Master — Offline Lab Simulator</title>
<!-- Tailwind CSS Play CDN for premium UI styling -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom thin scrollbars for table data */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.5);
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(71, 85, 105, 0.8);
border-radius: 9999px;
}
</style>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen flex flex-col font-sans antialiased">
<!-- HEADER -->
<header class="bg-slate-800 border-b border-slate-700 py-4 px-6 shadow-md">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="p-2.5 bg-indigo-600 rounded-xl text-white shadow-lg shadow-indigo-500/20">
<!-- TrendingUp SVG -->
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
</div>
<div>
<div class="flex items-center gap-2">
<h1 class="text-xl font-bold tracking-tight bg-gradient-to-r from-indigo-400 to-cyan-400 bg-clip-text text-transparent">
Physics Graph Master
</h1>
<span class="text-[10px] bg-indigo-500/10 text-indigo-300 border border-indigo-500/20 px-2 py-0.5 rounded-full font-bold">
By Cikgu Wee
</span>
</div>
<p class="text-xs text-slate-400 mt-0.5">
Self-Contained "5 or 10 boxes per cm" Offline Simulator
</p>
</div>
</div>
<!-- Quick Template Selector -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 mr-2 flex items-center gap-1.5">
<!-- BookOpen SVG -->
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
Templates:
</span>
<div id="templates-container" class="flex flex-wrap gap-1.5">
<!-- Injected dynamically -->
</div>
</div>
</div>
</header>
<!-- MAIN CONTAINER -->
<main class="flex-1 max-w-7xl w-full mx-auto p-4 lg:p-6 grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- LEFT COLUMN (Controls & Table) - 5 Cols -->
<div class="lg:col-span-5 flex flex-col gap-6">
<!-- AXES & CONFIG PANEL -->
<section class="bg-slate-800 rounded-2xl border border-slate-700 p-5 shadow-sm">
<h2 class="text-sm font-bold text-slate-300 uppercase tracking-wider mb-4 flex items-center gap-2">
<!-- Settings SVG -->
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.8-1.175c.426-1.156-.595-2.616-1.963-2.352z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
1. Axis Definitions & Scale
</h2>
<div class="grid grid-cols-2 gap-4 mb-4">
<!-- Y Axis Setup -->
<div class="space-y-2 p-3 bg-slate-900/60 rounded-xl border border-slate-700/40">
<label class="text-xs font-bold text-indigo-400">Y-Axis Variable</label>
<input
type="text"
id="yLabelInput"
placeholder="Symbol (e.g. T)"
maxlength="10"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1.5 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
<input
type="text"
id="yUnitInput"
placeholder="Unit (e.g. s)"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-indigo-500"
/>
</div>
<!-- X Axis Setup -->
<div class="space-y-2 p-3 bg-slate-900/60 rounded-xl border border-slate-700/40">
<label class="text-xs font-bold text-cyan-400">X-Axis Variable</label>
<input
type="text"
id="xLabelInput"
placeholder="Symbol (e.g. l)"
maxlength="10"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1.5 text-sm text-white focus:outline-none focus:border-cyan-500"
/>
<input
type="text"
id="xUnitInput"
placeholder="Unit (e.g. cm)"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-cyan-500"
/>
</div>
</div>
<!-- QUICK SYMBOL PICKER -->
<div class="mb-4 p-3 bg-slate-900/30 rounded-xl border border-slate-700/30">
<span class="text-[10px] text-slate-400 font-semibold mb-2 block uppercase tracking-wider">Quick Symbols (Click to insert into active field)</span>
<div class="flex flex-wrap gap-1.5" id="symbol-picker">
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">Ω</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">θ</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">λ</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">μ</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">π</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">°</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">²</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">³</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">Δ</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">√</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">±</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">α</button>
<button class="symbol-btn px-2.5 py-1 bg-slate-800 hover:bg-indigo-600 text-slate-300 hover:text-white rounded border border-slate-700 hover:border-indigo-500 text-xs font-medium shadow-sm transition">β</button>
</div>
</div>
<div class="space-y-3">
<!-- Grid Density Control -->
<div class="flex items-center justify-between p-3 bg-slate-900/30 rounded-xl border border-slate-700/30">
<div class="flex flex-col">
<span class="text-xs text-slate-200 font-bold">Boxes per major division</span>
<span class="text-[10px] text-slate-400">Choose grid density</span>
</div>
<div class="flex bg-slate-700 p-0.5 rounded-lg border border-slate-600">
<button id="btn-density-10" class="px-3 py-1 text-xs font-bold rounded-md transition bg-indigo-600 text-white shadow">
10 (Default)
</button>
<button id="btn-density-5" class="px-3 py-1 text-xs font-bold rounded-md transition text-slate-300 hover:text-white">
5
</button>
</div>
</div>
<!-- Origin Locking Toggle -->
<div class="flex items-center justify-between">
<span class="text-xs text-slate-300 font-medium">Force scale through Origin (0,0)</span>
<button id="toggle-origin" class="w-11 h-6 flex items-center rounded-full p-1 cursor-pointer transition-colors bg-indigo-600">
<div id="toggle-origin-circle" class="bg-white w-4 h-4 rounded-full shadow-md transform transition-transform translate-x-5"></div>
</button>
</div>
<!-- Coordinate label Toggle -->
<div class="flex items-center justify-between">
<span class="text-xs text-slate-300 font-medium">Show Point Coordinates</span>
<button id="toggle-coords" class="w-11 h-6 flex items-center rounded-full p-1 cursor-pointer transition-colors bg-slate-700">
<div id="toggle-coords-circle" class="bg-white w-4 h-4 rounded-full shadow-md transform transition-transform translate-x-0"></div>
</button>
</div>
</div>
</section>
<!-- DATA TABLE PANEL -->
<section class="bg-slate-800 rounded-2xl border border-slate-700 p-5 shadow-sm flex-1 flex flex-col min-h-[300px]">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
<!-- FileSpreadsheet SVG -->
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
2. Experimental Data Table
</h2>
<button id="btn-reset" class="p-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-white rounded-lg transition text-xs flex items-center gap-1">
<!-- RefreshCw SVG -->
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H17"></path>
</svg>
Reset
</button>
</div>
<div class="flex-1 overflow-y-auto max-h-[320px] bg-slate-900/40 rounded-xl border border-slate-700/50 p-2 scrollbar-thin">
<table class="w-full text-left">
<thead>
<tr class="border-b border-slate-700 text-xs text-slate-400 uppercase font-semibold">
<th class="py-2 px-3 text-center w-12">No.</th>
<th id="th-x-header" class="py-2 px-3 font-mono">l / cm</th>
<th id="th-y-header" class="py-2 px-3 font-mono">T / s</th>
<th class="py-2 px-3 text-center w-12">Action</th>
</tr>
</thead>
<tbody id="table-body" class="divide-y divide-slate-800/60">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
<div class="mt-4">
<button id="btn-add-row" class="w-full bg-indigo-600/90 hover:bg-indigo-600 text-white font-medium py-2 px-4 rounded-xl flex items-center justify-center gap-2 text-sm shadow-md transition-all duration-150">
<!-- Plus SVG -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
Add Data Point
</button>
</div>
</section>
</div>
<!-- RIGHT COLUMN (Canvas Graph & Reports) - 7 Cols -->
<div class="lg:col-span-7 flex flex-col gap-6">
<!-- GRAPH PAPER BOARD -->
<section class="bg-slate-800 rounded-2xl border border-slate-700 p-5 shadow-sm flex flex-col items-center">
<div class="w-full flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-4 border-b border-slate-700 pb-3">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-slate-300 uppercase tracking-wider">Fit Curve Line:</span>
<select id="fit-type-selector" class="bg-slate-700 border border-slate-600 rounded-lg text-xs text-white px-2.5 py-1.5 focus:outline-none focus:border-indigo-500 font-bold font-sans">
<option value="auto">✨ Auto-Detect Best Fit</option>
<option value="none">No Fit Line</option>
<option value="linear">Linear Fit (y = mx + c)</option>
<option value="quadratic">Quadratic Curve (y = ax² + bx + c)</option>
<option value="sqrt">Square Root Fit (y = a√x + c)</option>
<option value="power">Power Fit (y = axᵇ)</option>
<option value="exponential">Exponential Fit (y = aeᵇˣ)</option>
</select>
</div>
<div class="flex items-center gap-2 self-stretch sm:self-auto justify-between sm:justify-end">
<div class="flex items-center gap-1.5">
<span class="text-xs text-slate-400">Paper Color:</span>
<button id="btn-paper-green" class="w-4 h-4 rounded-full bg-emerald-600 border border-white scale-125" title="Green grid"></button>
<button id="btn-paper-blue" class="w-4 h-4 rounded-full bg-blue-600 border border-transparent" title="Blue grid"></button>
<button id="btn-paper-grey" class="w-4 h-4 rounded-full bg-zinc-600 border border-transparent" title="Grey grid"></button>
</div>
<div class="h-4 w-px bg-slate-700 mx-1"></div>
<button id="btn-export" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-200 hover:text-white rounded-lg transition text-xs flex items-center gap-1.5 font-medium shadow-sm" title="Save graph plot as image">
<!-- Download SVG -->
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Export PNG
</button>
</div>
</div>
<!-- CANVAS FRAME (Updated for 9x11 Portrait) -->
<div class="relative w-full aspect-[9/11] max-w-[550px] mx-auto bg-white rounded-xl overflow-hidden border-2 border-slate-700 shadow-lg">
<canvas id="graph-canvas" class="w-full h-full cursor-crosshair block"></canvas>
<div id="hover-coordinates" class="hidden absolute top-3 right-3 bg-slate-950/95 border border-slate-700 px-3 py-1.5 rounded-lg text-[10px] font-mono shadow-md text-slate-300 pointer-events-none animate-none">
<!-- Coordinates -->
</div>
</div>
<div class="w-full mt-3 flex items-center gap-2 text-xs text-slate-400 justify-center">
<!-- Info SVG -->
<svg class="w-3.5 h-3.5 text-indigo-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-center">Interactive 9x11 (2cm) boxes graph board. Drag coordinates directly on the canvas to trace slopes!</span>
</div>
</section>
<!-- WARNING SYSTEM (ANOMALIES PANEL) -->
<section id="warning-section" class="hidden bg-amber-950/60 border border-amber-600/50 rounded-2xl p-4 shadow-md text-slate-200">
<div class="flex items-start gap-3">
<!-- AlertTriangle SVG -->
<svg class="w-5 h-5 text-amber-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div class="space-y-1">
<h3 class="text-sm font-bold text-amber-300 tracking-wide uppercase">
Data Consistency Warning
</h3>
<p class="text-xs text-amber-200/90 leading-relaxed">
Some points deviate significantly from the best-fit model. We recommend reviewing:
</p>
<ul id="warning-list" class="list-disc pl-5 mt-2 space-y-1 text-xs text-amber-100 font-mono">
<!-- Outliers injected here -->
</ul>
<p class="text-[11px] text-amber-300/80 pt-1.5 italic">
💡 Checklist: Did you misread a scale division? Did you transpose coordinates or forget a decimal place?
</p>
</div>
</div>
</section>
<!-- CALCULATIONS REPORT PANEL -->
<section id="gradient-panel" class="bg-slate-800 rounded-2xl border border-slate-700 p-5 shadow-sm transition hover:border-slate-600 cursor-pointer">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-4">
<div class="flex items-center gap-2">
<!-- Sparkles SVG -->
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
<h2 class="text-sm font-bold text-slate-300 uppercase tracking-wider">
3. Physics Calculations & Gradient Analysis
</h2>
</div>
<div class="flex items-center gap-1.5 font-sans">
<button id="btn-toggle-triangle" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-1 px-2.5 rounded text-[11px] transition shadow flex items-center gap-1">
<span>📐 Gradient Triangle:</span>
<span id="triangle-status-label" class="text-yellow-300">OFF</span>
</button>
<span id="badge-auto-select" class="hidden bg-amber-500/10 text-amber-400 border border-amber-500/20 px-2 py-0.5 rounded text-[11px] font-bold items-center gap-1.5 shadow-sm">
<span id="badge-auto-text">Auto-Selected: Linear</span>
</span>
</div>
</div>
<div id="calculations-filled" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Equation Display -->
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-700/40 space-y-2">
<span class="text-xs font-semibold uppercase text-slate-400 tracking-wider">
Model Equation
</span>
<div id="equation-math" class="text-lg font-bold font-mono text-indigo-300">
y = mx + c
</div>
<div id="equation-desc" class="text-xs text-slate-400">
Directly representing linear physical relationships.
</div>
</div>
<!-- Gradient / Tangent Display -->
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-700/40 space-y-2 flex flex-col justify-between">
<div>
<span class="text-xs font-semibold uppercase text-slate-400 tracking-wider flex items-center justify-between">
Gradient (Slope)
<span id="badge-varying" class="hidden bg-rose-500/10 text-rose-400 border border-rose-500/20 px-1.5 py-0.5 rounded text-[9px]">
Varying Curve
</span>
</span>
<div id="gradient-output" class="mt-1">
<!-- Populated dynamically based on fit type -->
</div>
</div>
</div>
</div>
<div id="calculations-empty" class="hidden bg-slate-900/40 p-6 rounded-xl border border-slate-700/40 text-center text-slate-400 text-sm">
Please input at least 3 valid coordinate pairs to calculate gradients.
</div>
<div id="teacher-tip" class="mt-4 p-3 bg-indigo-950/40 rounded-xl border border-indigo-900/40 text-xs text-indigo-200">
<span class="font-bold text-indigo-300">Cikgu Wee's Physics Tip:</span> The gradient represented is calculated using high-precision Least Squares Regression. Click on this section to toggle the Gradient Triangle on the graph sheet to test alignment!
</div>
</section>
</div>
</main>
<!-- FOOTER -->
<footer class="bg-slate-950 border-t border-slate-800 text-center py-4 text-xs text-slate-500 mt-12">
<div class="max-w-7xl mx-auto px-4">
Physics Graph Simulator (Offline App) — Designed for Secondary School and College Lab Experiments by <span class="text-indigo-400 font-bold">Cikgu Wee</span>
</div>
</footer>
<!-- CORE LOGIC ENGINE (100% SELF-CONTAINED JS) -->
<script>
// --- LAB DATA PRESETS ---
const PRESETS = {
pendulum: {
title: "Simple Pendulum (T vs l)",
xLabel: "l",
xUnit: "cm",
yLabel: "T",
yUnit: "s",
fitType: "auto",
forceOrigin: true,
data: [
{ x: "20.0", y: "0.90" },
{ x: "30.0", y: "1.10" },
{ x: "40.0", y: "1.27" },
{ x: "50.0", y: "1.42" },
{ x: "60.0", y: "1.57" }
]
},
ohms_law: {
title: "Ohm's Law (V vs I)",
xLabel: "I",
xUnit: "A",
yLabel: "V",
yUnit: "V",
fitType: "auto",
forceOrigin: true,
data: [
{ x: "0.10", y: "0.52" },
{ x: "0.20", y: "0.98" },
{ x: "0.30", y: "1.55" },
{ x: "0.40", y: "2.01" },
{ x: "0.50", y: "2.48" },
{ x: "0.60", y: "3.02" }
]
},
acceleration: {
title: "Free Fall (Distance vs Time²)",
xLabel: "t²",
xUnit: "s²",
yLabel: "s",
yUnit: "m",
fitType: "auto",
forceOrigin: true,
data: [
{ x: "0.04", y: "0.20" },
{ x: "0.16", y: "0.78" },
{ x: "0.36", y: "1.76" },
{ x: "0.64", y: "3.14" },
{ x: "1.00", y: "4.90" }
]
},
hookes_law: {
title: "Hooke's Law (Force vs Extension)",
xLabel: "x",
xUnit: "cm",
yLabel: "F",
yUnit: "N",
fitType: "auto",
forceOrigin: true,
data: [
{ x: "1.2", y: "0.5" },
{ x: "2.5", y: "1.0" },
{ x: "3.7", y: "1.5" },
{ x: "5.0", y: "2.0" },
{ x: "6.2", y: "2.5" },
{ x: "7.5", y: "3.0" }
]
},
charging_capacitor: {
title: "Discharging Capacitor (V vs t)",
xLabel: "t",
xUnit: "s",
yLabel: "V",
yUnit: "V",
fitType: "auto",
forceOrigin: false,
data: [
{ x: "0", y: "6.0" },
{ x: "2", y: "4.1" },
{ x: "4", y: "2.8" },
{ x: "6", y: "1.9" },
{ x: "8", y: "1.3" },
{ x: "10", y: "0.9" }
]
}
};
// --- APPLICATION STATE ---
let activePreset = "pendulum";
let xLabel = PRESETS.pendulum.xLabel;
let xUnit = PRESETS.pendulum.xUnit;
let yLabel = PRESETS.pendulum.yLabel;
let yUnit = PRESETS.pendulum.yUnit;
let fitType = "auto";
let forceOrigin = true;
let boxesPerDivision = 10;
let paperColor = "green";
let pointColor = "#f43f5e";
let lineColor = "#2563eb";
let showCoordinates = false;
let dataPoints = JSON.parse(JSON.stringify(PRESETS.pendulum.data));
let tangentPoint = null;
// --- GLOBAL GRID CONSTANTS (Updated to 9x11 Portrait) ---
const GRID_X_BLOCKS = 9;
const GRID_Y_BLOCKS = 11;
function getGraphDimensions(width, height) {
const paddingLeftBase = 70;
const paddingRightBase = 95;
const paddingTopBase = 50;
const paddingBottomBase = 60;
const availWidth = width - paddingLeftBase - paddingRightBase;
const availHeight = height - paddingTopBase - paddingBottomBase;
// Force square boxes (2cm x 2cm representation)
const boxSize = Math.min(availWidth / GRID_X_BLOCKS, availHeight / GRID_Y_BLOCKS);
const graphWidth = boxSize * GRID_X_BLOCKS;
const graphHeight = boxSize * GRID_Y_BLOCKS;
// Center the grid in the remaining space
const extraWidth = (availWidth - graphWidth) / 2;
const extraHeight = (availHeight - graphHeight) / 2;
return {
paddingLeft: paddingLeftBase + extraWidth,
paddingRight: paddingRightBase + extraWidth,
paddingTop: paddingTopBase + extraHeight,
paddingBottom: paddingBottomBase + extraHeight,
graphWidth,
graphHeight
};
}
// --- NEW GRADIENT TRIANGLE SYSTEM ---
let showGradientTriangle = false; // Toggled by clicking gradient block or button
let triangleX1 = null; // Independent variable fraction
let triangleX2 = null; // Independent variable fraction
let activeDragPoint = null; // 'x1' or 'x2'
// --- MATHEMATICAL REgRESSION ENGINE ---
// Linear regression.
// "Force scale through origin" only pins the intercept when that line
// still fits. A negative straight line that does not pass through (0,0)
// must stay a negative linear fit, not get replaced by a curve.
function calculateLinearFit(points) {
if (points.length < 2) return null;
const fit = (throughOrigin) => {
const n = points.length;
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
for (let p of points) {
sumX += p.x;
sumY += p.y;
sumXY += p.x * p.y;
sumXX += p.x * p.x;
}
let m, c;
if (throughOrigin) {
m = sumXX === 0 ? 0 : sumXY / sumXX;
c = 0;
} else {
const denominator = (n * sumXX) - (sumX * sumX);
if (denominator === 0) return null;
m = ((n * sumXY) - (sumX * sumY)) / denominator;
c = (sumY - (m * sumX)) / n;
}
const yMean = sumY / n;
let ssTot = 0, ssRes = 0;
for (let p of points) {
ssTot += Math.pow(p.y - yMean, 2);
const yPred = m * p.x + c;
ssRes += Math.pow(p.y - yPred, 2);
}
const r2 = ssTot === 0 ? 1 : 1 - (ssRes / ssTot);
return { m, c, r2, type: 'linear', label: 'Linear Fit', f: (x) => m * x + c };
};
const free = fit(false);
if (!free) return null;
if (!forceOrigin) return free;
const forced = fit(true);
// Origin constraint is only kept when it does not destroy a clearly linear trend
// (including a negative gradient whose intercept is not zero).
if (forced && forced.r2 >= 0.95 && forced.r2 >= free.r2 - 0.02) return forced;
return free;
}
// Quadratic regression
function calculateQuadraticFit(points) {
if (points.length < 3) return null;
const n = points.length;
let sumX = 0, sumY = 0, sumX2 = 0, sumX3 = 0, sumX4 = 0;
let sumXY = 0, sumX2Y = 0;
for (let p of points) {
const x2 = p.x * p.x;
sumX += p.x;
sumY += p.y;
sumX2 += x2;
sumX3 += x2 * p.x;
sumX4 += x2 * x2;
sumXY += p.x * p.y;
sumX2Y += x2 * p.y;
}
if (forceOrigin) {
const d = sumX4 * sumX2 - sumX3 * sumX3;
if (Math.abs(d) < 1e-12) return null;
const a = (sumX2Y * sumX2 - sumXY * sumX3) / d;
const b = (sumX4 * sumXY - sumX3 * sumX2Y) / d;
const c = 0;
const yMean = sumY / n;
let ssTot = 0, ssRes = 0;
for (let p of points) {
ssTot += Math.pow(p.y - yMean, 2);
const yPred = a * p.x * p.x + b * p.x + c;
ssRes += Math.pow(p.y - yPred, 2);
}
const r2 = ssTot === 0 ? 1 : 1 - (ssRes / ssTot);
return { a, b, c, r2, type: 'quadratic', label: 'Quadratic Curve', f: (x) => a * x * x + b * x + c };
} else {
const d = n * (sumX2 * sumX4 - sumX3 * sumX3) - sumX * (sumX * sumX4 - sumX2 * sumX3) + sumX2 * (sumX * sumX3 - sumX2 * sumX2);
if (Math.abs(d) < 1e-12) return null;
const dc = sumY * (sumX2 * sumX4 - sumX3 * sumX3) - sumX * (sumXY * sumX4 - sumX2Y * sumX3) + sumX2 * (sumXY * sumX3 - sumX2Y * sumX2);
const db = n * (sumXY * sumX4 - sumX2Y * sumX3) - sumY * (sumX * sumX4 - sumX2 * sumX3) + sumX2 * (sumX * sumX2Y - sumXY * sumX2);
const da = n * (sumX2 * sumX2Y - sumXY * sumX3) - sumX * (sumX * sumX2Y - sumXY * sumX2) + sumY * (sumX * sumX3 - sumX2 * sumX2);
const a = da / d;
const b = db / d;
const c = dc / d;
const yMean = sumY / n;
let ssTot = 0, ssRes = 0;
for (let p of points) {
ssTot += Math.pow(p.y - yMean, 2);
const yPred = a * p.x * p.x + b * p.x + c;
ssRes += Math.pow(p.y - yPred, 2);
}
const r2 = ssTot === 0 ? 1 : 1 - (ssRes / ssTot);
return { a, b, c, r2, type: 'quadratic', label: 'Quadratic Curve', f: (x) => a * x * x + b * x + c };
}
}
// Square Root regression
function calculateSqrtFit(points) {
const validPoints = points.filter(p => p.x >= 0);
if (validPoints.length < 2) return null;
const n = validPoints.length;
let sumSqrtX = 0, sumY = 0, sumSqrtXY = 0, sumX = 0;
for (let p of validPoints) {
const sx = Math.sqrt(p.x);
sumSqrtX += sx;
sumY += p.y;
sumSqrtXY += sx * p.y;
sumX += p.x;
}
let a, c;
if (forceOrigin) {
a = sumX === 0 ? 0 : sumSqrtXY / sumX;
c = 0;
} else {
const denominator = (n * sumX) - (sumSqrtX * sumSqrtX);
if (denominator === 0) return null;
a = ((n * sumSqrtXY) - (sumSqrtX * sumY)) / denominator;
c = (sumY - (a * sumSqrtX)) / n;
}
const yMean = sumY / n;
let ssTot = 0, ssRes = 0;
for (let p of validPoints) {
ssTot += Math.pow(p.y - yMean, 2);
const yPred = a * Math.sqrt(p.x) + c;
ssRes += Math.pow(p.y - yPred, 2);
}
const r2 = ssTot === 0 ? 1 : 1 - (ssRes / ssTot);
return { a, c, r2, type: 'sqrt', label: 'Square Root Fit', f: (x) => x >= 0 ? (a * Math.sqrt(x) + c) : 0 };
}
// Power regression
function calculatePowerFit(points) {
const validPoints = points.filter(p => p.x > 0 && p.y > 0);
if (validPoints.length < 2) return null;
const n = validPoints.length;
let sumLnX = 0, sumLnY = 0, sumLnXLnY = 0, sumLnXX = 0;
for (let p of validPoints) {
const lx = Math.log(p.x);
const ly = Math.log(p.y);
sumLnX += lx;
sumLnY += ly;
sumLnXLnY += lx * ly;
sumLnXX += lx * lx;
}
const denominator = (n * sumLnXX) - (sumLnX * sumLnX);
if (denominator === 0) return null;
const b = ((n * sumLnXLnY) - (sumLnX * sumLnY)) / denominator;
const lnA = (sumLnY - (b * sumLnX)) / n;
const a = Math.exp(lnA);
const sumY = validPoints.reduce((sum, p) => sum + p.y, 0);
const yMean = sumY / n;
let ssTot = 0, ssRes = 0;
for (let p of validPoints) {
ssTot += Math.pow(p.y - yMean, 2);
const yPred = a * Math.pow(p.x, b);
ssRes += Math.pow(p.y - yPred, 2);
}
const r2 = ssTot === 0 ? 1 : 1 - (ssRes / ssTot);
return { a, b, r2, type: 'power', label: 'Power Fit', f: (x) => x > 0 ? (a * Math.pow(x, b)) : 0 };
}
// Exponential regression
function calculateExponentialFit(points) {
const validPoints = points.filter(p => p.y > 0);
if (validPoints.length < 2) return null;
const n = validPoints.length;
let sumX = 0, sumLnY = 0, sumXLnY = 0, sumXX = 0;
for (let p of validPoints) {
const ly = Math.log(p.y);
sumX += p.x;
sumLnY += ly;
sumXLnY += p.x * ly;
sumXX += p.x * p.x;
}
const denominator = (n * sumXX) - (sumX * sumX);
if (denominator === 0) return null;
const b = ((n * sumXLnY) - (sumX * sumLnY)) / denominator;
const lnA = (sumLnY - (b * sumX)) / n;
const a = Math.exp(lnA);
const sumY = validPoints.reduce((sum, p) => sum + p.y, 0);
const yMean = sumY / n;
let ssTot = 0, ssRes = 0;
for (let p of validPoints) {
ssTot += Math.pow(p.y - yMean, 2);
const yPred = a * Math.exp(b * p.x);
ssRes += Math.pow(p.y - yPred, 2);
}
const r2 = ssTot === 0 ? 1 : 1 - (ssRes / ssTot);
return { a, b, r2, type: 'exponential', label: 'Exponential Fit', f: (x) => a * Math.exp(b * x) };
}
// Dynamic statistical Ockham's Razor auto-fit detector
function getAutoDetectedFit(points) {
if (points.length < 2) return null;
const candidates = [];
const lin = calculateLinearFit(points);
if (lin && !isNaN(lin.r2)) candidates.push({ model: lin, score: lin.r2 });
const sqrt = calculateSqrtFit(points);
if (sqrt && !isNaN(sqrt.r2)) candidates.push({ model: sqrt, score: sqrt.r2 - 0.001 });
const exp = calculateExponentialFit(points);
if (exp && !isNaN(exp.r2)) candidates.push({ model: exp, score: exp.r2 - 0.004 });
const power = calculatePowerFit(points);
if (power && !isNaN(power.r2)) candidates.push({ model: power, score: power.r2 - 0.004 });
const quad = calculateQuadraticFit(points);
if (quad && !isNaN(quad.r2)) candidates.push({ model: quad, score: quad.r2 - 0.015 });
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.score - a.score);
return candidates[0].model;
}
function getActiveModel(points) {
if (fitType === "none") return null;
if (fitType === "auto") return getAutoDetectedFit(points);
if (fitType === "linear") return calculateLinearFit(points);
if (fitType === "quadratic") return calculateQuadraticFit(points);
if (fitType === "sqrt") return calculateSqrtFit(points);
if (fitType === "power") return calculatePowerFit(points);
if (fitType === "exponential") return calculateExponentialFit(points);
return null;
}
// --- SCALING ENGINE ---
// Standardizing to specific major divisions for a wide, beautiful graph paper
function calculateEvenScale(minVal, maxVal, numDivisions = 10, alignToOrigin = false) {
let low = alignToOrigin ? 0 : minVal;
let high = maxVal;
if (high <= low) high = low + numDivisions;
let span = high - low;
let idealStep = span / numDivisions;
const logValue = Math.log10(idealStep);
const powerOfTen = Math.floor(logValue);
const fractionalPart = idealStep / Math.pow(10, powerOfTen);
let selectedStepFactor;
if (fractionalPart <= 1.0) selectedStepFactor = 1.0;
else if (fractionalPart <= 2.0) selectedStepFactor = 2.0;
else if (fractionalPart <= 5.0) selectedStepFactor = 5.0;
else selectedStepFactor = 10.0;
const finalStepValue = selectedStepFactor * Math.pow(10, powerOfTen);
const calculatedMin = alignToOrigin ? 0 : Math.floor(low / finalStepValue) * finalStepValue;
const rawMax = calculatedMin + (finalStepValue * numDivisions);
const calculatedMax = rawMax >= high ? rawMax : Math.ceil(high / finalStepValue) * finalStepValue;
const actualDivisionsCount = Math.round((calculatedMax - calculatedMin) / finalStepValue);
return { min: calculatedMin, max: calculatedMax, step: finalStepValue, divisions: actualDivisionsCount };
}
function getDecimalPrecision(step) {
if (!step) return 0;
const stepStr = step.toString();
if (stepStr.includes('.')) return stepStr.split('.')[1].length;
return 0;
}
// Get current parsed numerical points
function getValidPoints() {
return dataPoints
.map(p => ({ x: parseFloat(p.x), y: parseFloat(p.y) }))
.filter(p => !isNaN(p.x) && !isNaN(p.y));
}
// --- ANOMALY DETECTOR ---
function getIrregularPoints() {
const points = getValidPoints();
const model = getActiveModel(points);
if (points.length < 3 || !model) return [];
const residuals = points.map((p, idx) => {
const expectedY = model.f(p.x);
return { index: idx, point: p, diff: Math.abs(p.y - expectedY) };
});
const totalDiff = residuals.reduce((sum, r) => sum + r.diff, 0);
const meanDiff = totalDiff / points.length;
const sumSqDiff = residuals.reduce((sum, r) => sum + Math.pow(r.diff - meanDiff, 2), 0);
const stdDev = Math.sqrt(sumSqDiff / (points.length - 1 || 1));
const ys = points.map(p => p.y);
const ySpan = Math.max(...ys) - Math.min(...ys) || 1;
if (model.r2 > 0.999) return [];
return residuals.filter(r => r.diff > (stdDev * 1.6) && r.diff > (ySpan * 0.08)).map(i => ({
rowNumber: i.index + 1,
x: i.point.x,
y: i.point.y,
expected: model.f(i.point.x)
}));
}
// --- DOM AND INTERFACE UPDATER ---
const canvas = document.getElementById("graph-canvas");
const warningSection = document.getElementById("warning-section");
const warningList = document.getElementById("warning-list");
const calculationsFilled = document.getElementById("calculations-filled");
const calculationsEmpty = document.getElementById("calculations-empty");
const badgeAutoSelect = document.getElementById("badge-auto-select");
const badgeAutoText = document.getElementById("badge-auto-text");
const equationMath = document.getElementById("equation-math");
const equationDesc = document.getElementById("equation-desc");
const gradientOutput = document.getElementById("gradient-output");
const tableBody = document.getElementById("table-body");
// Globally binding the hover element correctly to fix DOM initialization bugs
const hoverDiv = document.getElementById("hover-coordinates");
// --- NEW: SYMBOL PICKER LOGIC ---
let lastFocusedInput = null;
// Set default focused input when page loads so clicking a symbol immediately works
window.addEventListener("DOMContentLoaded", () => {
lastFocusedInput = document.getElementById("yLabelInput");
});
document.querySelectorAll('#yLabelInput, #yUnitInput, #xLabelInput, #xUnitInput').forEach(input => {
input.addEventListener('focus', () => {
lastFocusedInput = input;
});
});
document.querySelectorAll('.symbol-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
if (lastFocusedInput) {
const symbol = e.target.innerText;
const start = lastFocusedInput.selectionStart;
const end = lastFocusedInput.selectionEnd;
const text = lastFocusedInput.value;
const before = text.substring(0, start);
const after = text.substring(end, text.length);
lastFocusedInput.value = before + symbol + after;
lastFocusedInput.selectionStart = lastFocusedInput.selectionEnd = start + symbol.length;
lastFocusedInput.focus();
// Trigger input event to update variables and graph
lastFocusedInput.dispatchEvent(new Event('input'));
}
});
});
// ---------------------------------
// Sync button toggles and densities to the visual state
function updateTogglesAndControls() {
// Force scale through Origin Toggle Sync
const toggleOrig = document.getElementById("toggle-origin");
const toggleOrigCircle = document.getElementById("toggle-origin-circle");
if (forceOrigin) {
toggleOrig.className = "w-11 h-6 flex items-center rounded-full p-1 cursor-pointer transition-colors bg-indigo-600";
toggleOrigCircle.className = "bg-white w-4 h-4 rounded-full shadow-md transform transition-transform translate-x-5";
} else {
toggleOrig.className = "w-11 h-6 flex items-center rounded-full p-1 cursor-pointer transition-colors bg-slate-700";
toggleOrigCircle.className = "bg-white w-4 h-4 rounded-full shadow-md transform transition-transform translate-x-0";
}
// Show Point Coordinates Toggle Sync
const toggleCoords = document.getElementById("toggle-coords");
const toggleCoordsCircle = document.getElementById("toggle-coords-circle");
if (showCoordinates) {
toggleCoords.className = "w-11 h-6 flex items-center rounded-full p-1 cursor-pointer transition-colors bg-indigo-600";
toggleCoordsCircle.className = "bg-white w-4 h-4 rounded-full shadow-md transform transition-transform translate-x-5";
} else {
toggleCoords.className = "w-11 h-6 flex items-center rounded-full p-1 cursor-pointer transition-colors bg-slate-700";
toggleCoordsCircle.className = "bg-white w-4 h-4 rounded-full shadow-md transform transition-transform translate-x-0";
}
// Density Buttons Sync
const btn10 = document.getElementById("btn-density-10");
const btn5 = document.getElementById("btn-density-5");
if (boxesPerDivision === 10) {
btn10.className = "px-3 py-1 text-xs font-bold rounded-md transition bg-indigo-600 text-white shadow";
btn5.className = "px-3 py-1 text-xs font-bold rounded-md transition text-slate-300 hover:text-white";
} else {
btn5.className = "px-3 py-1 text-xs font-bold rounded-md transition bg-indigo-600 text-white shadow";
btn10.className = "px-3 py-1 text-xs font-bold rounded-md transition text-slate-300 hover:text-white";
}
}
// Redraw Table Headers
function updateTableHeaderLabels() {
const headerX = document.getElementById("th-x-header");
const headerY = document.getElementById("th-y-header");
headerX.innerText = xUnit ? `${xLabel} / ${xUnit}` : xLabel;
headerY.innerText = yUnit ? `${yLabel} / ${yUnit}` : yLabel;
}
// Populate dynamic editable tables
function renderTableRows() {
tableBody.innerHTML = "";
dataPoints.forEach((point, index) => {
const tr = document.createElement("tr");
tr.className = "hover:bg-slate-800/40 transition";
tr.innerHTML = `
<td class="py-2 px-3 text-center text-xs font-bold text-slate-500">${index + 1}</td>
<td class="py-2 px-3">
<input type="text" data-index="${index}" data-field="x" value="${point.x}"
class="table-cell-input w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500 text-center font-mono" />
</td>
<td class="py-2 px-3">
<input type="text" data-index="${index}" data-field="y" value="${point.y}"
class="table-cell-input w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500 text-center font-mono" />
</td>
<td class="py-2 px-3 text-center">
<button class="btn-delete-row p-1 text-slate-400 hover:text-rose-400 disabled:opacity-30 transition" data-index="${index}" ${dataPoints.length <= 2 ? 'disabled' : ''}>
<!-- Trash SVG -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</td>
`;
tableBody.appendChild(tr);
});
// Bind input events to allow typing decimals directly without losing focus
document.querySelectorAll(".table-cell-input").forEach(input => {
input.addEventListener("input", (e) => {
const idx = parseInt(e.target.dataset.index);
const field = e.target.dataset.field;
const val = e.target.value;
if (/^-?\d*\.?\d*$/.test(val) || val === "") {
dataPoints[idx][field] = val;
drawGraph();
updateCalculationsAndAlerts();
} else {
e.target.value = dataPoints[idx][field]; // restore
}
});
});
// Delete Row Button listener
document.querySelectorAll(".btn-delete-row").forEach(btn => {
btn.addEventListener("click", (e) => {
const idx = parseInt(e.currentTarget.dataset.index);
if (dataPoints.length > 2) {
dataPoints.splice(idx, 1);
renderTableRows();
drawGraph();
updateCalculationsAndAlerts();
}
});
});
}
// Initialize Default Triangle Coordinates
function initGradientTriangleBounds(minVal, maxVal) {
if (triangleX1 === null || triangleX2 === null) {
// Position them widely across the scale range (e.g., 15% and 85%)
const span = maxVal - minVal;
triangleX1 = minVal + span * 0.15;
triangleX2 = minVal + span * 0.85;
}
}
// Update Warnings & Statistics Calculations Panels
function updateCalculationsAndAlerts() {
const points = getValidPoints();
const model = getActiveModel(points);
const irregulars = getIrregularPoints();
// Show/hide outlier alert warnings
if (irregulars.length > 0) {
warningSection.classList.remove("hidden");
warningList.innerHTML = irregulars.map(err => `
<li>Row #${err.rowNumber}: Point (${err.x}, ${err.y}) deviates. Expected around ${err.expected.toFixed(2)}.</li>
`).join("");
} else {
warningSection.classList.add("hidden");
}
// Calculations details
if (points.length >= 2 && model) {
calculationsFilled.classList.remove("hidden");
calculationsEmpty.classList.add("hidden");
// Set badge for auto fit select
if (fitType === "auto") {
badgeAutoSelect.classList.remove("hidden");
badgeAutoSelect.style.display = "inline-flex";
badgeAutoText.innerText = `Auto-Selected: ${model.label}`;
} else {
badgeAutoSelect.classList.add("hidden");
badgeAutoSelect.style.display = "none";
}
// Output Equation and display R2 accuracy inside card
if (model.type === 'linear') {
equationMath.innerText = `y = ${model.m.toFixed(3)}x ${model.c >= 0 ? `+ ${model.c.toFixed(3)}` : `- ${Math.abs(model.c).toFixed(3)}`}`;
equationDesc.innerHTML = `Directly representing linear physical relationships.<br><span class="text-sky-400 font-mono font-bold">R² Accuracy: ${model.r2.toFixed(4)}</span>`;
} else if (model.type === 'quadratic') {
equationMath.innerText = `y = ${model.a.toFixed(3)}x² ${model.b >= 0 ? `+ ${model.b.toFixed(3)}` : `- ${Math.abs(model.b).toFixed(3)}x`} ${model.c !== 0 ? (model.c > 0 ? `+ ${model.c.toFixed(3)}` : `- ${Math.abs(model.c).toFixed(3)}`) : ""}`;
equationDesc.innerHTML = `Polynomial parabola approximation.<br><span class="text-sky-400 font-mono font-bold">R² Accuracy: ${model.r2.toFixed(4)}</span>`;
} else if (model.type === 'sqrt') {
equationMath.innerText = `y = ${model.a.toFixed(3)}√x ${model.c !== 0 ? (model.c > 0 ? `+ ${model.c.toFixed(3)}` : `- ${Math.abs(model.c).toFixed(3)}`) : ""}`;
equationDesc.innerHTML = `Ideal for pendulum period & inverse square gravity profiles.<br><span class="text-sky-400 font-mono font-bold">R² Accuracy: ${model.r2.toFixed(4)}</span>`;
} else if (model.type === 'power') {
equationMath.innerText = `y = ${model.a.toFixed(3)}x^(${model.b.toFixed(3)})`;
equationDesc.innerHTML = `Generalised Power Law representation: y = axᵇ.<br><span class="text-sky-400 font-mono font-bold">R² Accuracy: ${model.r2.toFixed(4)}</span>`;
} else if (model.type === 'exponential') {
equationMath.innerText = `y = ${model.a.toFixed(3)}e^(${model.b.toFixed(3)}x)`;
equationDesc.innerHTML = `Exponential scaling (e.g. radioactive half-life decay).<br><span class="text-sky-400 font-mono font-bold">R² Accuracy: ${model.r2.toFixed(4)}</span>`;
}
// Output Gradient
const unitLabel = (yUnit && xUnit) ? `${yUnit} ${xUnit}⁻¹` : (yUnit ? yUnit : (xUnit ? `${xUnit}⁻¹` : "no unit"));
const badgeVarying = document.getElementById("badge-varying");
if (model.type === 'linear') {
badgeVarying.classList.add("hidden");
gradientOutput.innerHTML = `
<div class="text-2xl font-black text-emerald-400 tracking-tight">${model.m.toFixed(3)}</div>
<div class="text-xs font-mono font-medium text-slate-300 mt-1">Unit: <span class="text-yellow-400">${unitLabel}</span></div>
`;
} else {
badgeVarying.classList.remove("hidden");
if (tangentPoint) {
let slope = 0;
const tx = tangentPoint.x;
if (model.type === 'quadratic') slope = 2 * model.a * tx + model.b;
else if (model.type === 'sqrt') slope = tx > 0 ? (model.a / (2 * Math.sqrt(tx))) : 0;
else if (model.type === 'power') slope = tx > 0 ? (model.a * model.b * Math.pow(tx, model.b - 1)) : 0;
else if (model.type === 'exponential') slope = model.a * model.b * Math.exp(model.b * tx);
gradientOutput.innerHTML = `
<div class="text-xs font-semibold text-slate-300">
Slope at x = ${tx.toFixed(2)} is: <strong class="text-rose-400 text-lg">${slope.toFixed(3)}</strong>
</div>
<div class="text-xs font-mono text-slate-300 mt-1">Unit: <span class="text-yellow-400">${unitLabel}</span></div>
`;
} else {
gradientOutput.innerHTML = `
<span class="text-slate-400 text-xs italic leading-relaxed block">
Click on the graph paper curve to inspect the tangent gradient instantly.
</span>
`;
}
}
// Triangle boundaries synchronized
if (showGradientTriangle) {
const xs = points.map(p => p.x);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, GRID_X_BLOCKS);
const xScaleInfo = calculateEvenScale(minX, maxX, GRID_X_BLOCKS, forceOrigin);
initGradientTriangleBounds(xScaleInfo.min, xScaleInfo.max);
}
} else {
calculationsFilled.classList.add("hidden");
calculationsEmpty.classList.remove("hidden");
badgeAutoSelect.classList.add("hidden");
}
}
// --- CANVAS GRAPH DRAWING ENGINE (Crisp Black Labels, White surrounding bg) ---
function drawGraph() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
// Clean, bright white canvas sheet background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
// Dynamic Boundaries & Margins to ensure perfect square grid boxes
const dims = getGraphDimensions(width, height);
const paddingLeft = dims.paddingLeft;
const paddingRight = dims.paddingRight;
const paddingTop = dims.paddingTop;
const paddingBottom = dims.paddingBottom;
const graphWidth = dims.graphWidth;
const graphHeight = dims.graphHeight;
const points = getValidPoints();
let minX = 0, maxX = GRID_X_BLOCKS, minY = 0, maxY = GRID_Y_BLOCKS;
if (points.length > 0) {
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
minX = Math.min(...xs);
maxX = Math.max(...xs);
minY = Math.min(...ys);
maxY = Math.max(...ys);
}
// Graph grid set to specified major blocks
const xMajorBlocks = GRID_X_BLOCKS;
const yMajorBlocks = GRID_Y_BLOCKS;
const xScaleInfo = calculateEvenScale(minX, maxX, xMajorBlocks, forceOrigin);
const yScaleInfo = calculateEvenScale(minY, maxY, yMajorBlocks, forceOrigin);
// Initialize triangle boundaries within actual auto-scales
initGradientTriangleBounds(xScaleInfo.min, xScaleInfo.max);
// Grid colors on paper
let majorColor, mediumColor, minorColor, paperBg;
if (paperColor === "green") {
paperBg = "#f0fbf4";
majorColor = "rgba(16, 185, 129, 0.65)";
mediumColor = "rgba(16, 185, 129, 0.35)";
minorColor = "rgba(16, 185, 129, 0.14)";
} else if (paperColor === "blue") {
paperBg = "#f0f7ff";
majorColor = "rgba(59, 130, 246, 0.6)";
mediumColor = "rgba(59, 130, 246, 0.32)";
minorColor = "rgba(59, 130, 246, 0.14)";
} else {
paperBg = "#f8fafc";
majorColor = "rgba(148, 163, 184, 0.65)";
mediumColor = "rgba(148, 163, 184, 0.38)";
minorColor = "rgba(148, 163, 184, 0.16)";
}
// 1. Draw grid paper section bg
ctx.fillStyle = paperBg;
ctx.fillRect(paddingLeft, paddingTop, graphWidth, graphHeight);
// 2. Draw subdivisions
const totalMinorGridX = xMajorBlocks * boxesPerDivision;
const totalMinorGridY = yMajorBlocks * boxesPerDivision;
// Horizontal lines
for (let i = 0; i <= totalMinorGridY; i++) {
const yPos = paddingTop + (graphHeight * i) / totalMinorGridY;
const isMajor = i % boxesPerDivision === 0;
const isMedium = (boxesPerDivision === 10) && (i % 5 === 0) && (i % 10 !== 0);
ctx.beginPath();
ctx.moveTo(paddingLeft, yPos);
ctx.lineTo(paddingLeft + graphWidth, yPos);
if (isMajor) {
ctx.lineWidth = 1.6;
ctx.strokeStyle = majorColor;
} else if (isMedium) {
ctx.lineWidth = 1.0;
ctx.strokeStyle = mediumColor;
} else {
ctx.lineWidth = 0.5;
ctx.strokeStyle = minorColor;
}
ctx.stroke();
}
// Vertical lines
for (let j = 0; j <= totalMinorGridX; j++) {
const xPos = paddingLeft + (graphWidth * j) / totalMinorGridX;
const isMajor = j % boxesPerDivision === 0;
const isMedium = (boxesPerDivision === 10) && (j % 5 === 0) && (j % 10 !== 0);
ctx.beginPath();
ctx.moveTo(xPos, paddingTop);
ctx.lineTo(xPos, paddingTop + graphHeight);
if (isMajor) {
ctx.lineWidth = 1.6;
ctx.strokeStyle = majorColor;
} else if (isMedium) {
ctx.lineWidth = 1.0;
ctx.strokeStyle = mediumColor;
} else {
ctx.lineWidth = 0.5;
ctx.strokeStyle = minorColor;
}
ctx.stroke();
}
// Coordinate translators
const getCanvasX = (val) => {
const pct = (val - xScaleInfo.min) / (xScaleInfo.max - xScaleInfo.min);
return paddingLeft + pct * graphWidth;
};
const getCanvasY = (val) => {
const pct = (val - yScaleInfo.min) / (yScaleInfo.max - yScaleInfo.min);
return paddingTop + graphHeight - pct * graphHeight;
};
const getValFromCanvasX = (pixelX) => {
const pct = (pixelX - paddingLeft) / graphWidth;
return xScaleInfo.min + pct * (xScaleInfo.max - xScaleInfo.min);
};
// 3. Draw Axis Scales (Crisp Solid Black for high visibility on white paper)
ctx.fillStyle = "#000000";
ctx.font = "bold 11px monospace";
const xPrecision = getDecimalPrecision(xScaleInfo.step);
const yPrecision = getDecimalPrecision(yScaleInfo.step);
// X Ticks Numbers
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let j = 0; j <= xMajorBlocks; j++) {
const val = xScaleInfo.min + j * xScaleInfo.step;
const xPos = getCanvasX(val);
if (xPos >= paddingLeft && xPos <= paddingLeft + graphWidth) {
ctx.beginPath();
ctx.moveTo(xPos, paddingTop + graphHeight);
ctx.lineTo(xPos, paddingTop + graphHeight + 6);
ctx.lineWidth = 2;
ctx.strokeStyle = "#000000";
ctx.stroke();
ctx.fillText(val.toFixed(xPrecision), xPos, paddingTop + graphHeight + 9);
}
}
// Y Ticks Numbers
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let i = 0; i <= yMajorBlocks; i++) {
const val = yScaleInfo.min + i * yScaleInfo.step;
const yPos = getCanvasY(val);
if (yPos >= paddingTop && yPos <= paddingTop + graphHeight) {
ctx.beginPath();
ctx.moveTo(paddingLeft - 6, yPos);
ctx.lineTo(paddingLeft, yPos);
ctx.lineWidth = 2;
ctx.strokeStyle = "#000000";
ctx.stroke();
ctx.fillText(val.toFixed(yPrecision), paddingLeft - 10, yPos);
}
}
// 4. Solid Black Axis Borders with Arrowheads
ctx.beginPath();
ctx.moveTo(paddingLeft, paddingTop);
ctx.lineTo(paddingLeft, paddingTop + graphHeight);
ctx.lineTo(paddingLeft + graphWidth, paddingTop + graphHeight);
ctx.lineWidth = 2.5;
ctx.strokeStyle = "#000000";
ctx.stroke();
// Top Y Arrow
ctx.beginPath();
ctx.moveTo(paddingLeft - 5, paddingTop + 8);
ctx.lineTo(paddingLeft, paddingTop);
ctx.lineTo(paddingLeft + 5, paddingTop + 8);
ctx.lineWidth = 2;
ctx.strokeStyle = "#000000";
ctx.stroke();
// Right X Arrow
ctx.beginPath();
ctx.moveTo(paddingLeft + graphWidth - 8, paddingTop + graphHeight - 5);
ctx.lineTo(paddingLeft + graphWidth, paddingTop + graphHeight);
ctx.lineTo(paddingLeft + graphWidth - 8, paddingTop + graphHeight + 5);
ctx.lineWidth = 2;
ctx.strokeStyle = "#000000";
ctx.stroke();
// 5. Symbol & Units strictly placed at outer edges
ctx.fillStyle = "#000000";
ctx.font = "bold 13px sans-serif";
const xFullLabel = xUnit ? `${xLabel} / ${xUnit}` : xLabel;
const yFullLabel = yUnit ? `${yLabel} / ${yUnit}` : yLabel;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(yFullLabel, paddingLeft, paddingTop - 12);
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(xFullLabel, paddingLeft + graphWidth + 12, paddingTop + graphHeight);
// CENTERED FORMAL GRAPH TITLE AT THE VERY TOP
ctx.save();
ctx.fillStyle = "#000000";
ctx.font = "bold 14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "top";
const graphTitleText = `Graph of ${yLabel ? yLabel : 'Y'} against ${xLabel ? xLabel : 'X'}`;
ctx.fillText(graphTitleText, paddingLeft + graphWidth / 2, 16);
ctx.restore();
// 6. Draw Best-Fit Curve Line
const model = getActiveModel(points);
if (model) {
ctx.beginPath();
let first = true;
for (let px = paddingLeft; px <= paddingLeft + graphWidth; px++) {
const xVal = getValFromCanvasX(px);
const yVal = model.f(xVal);
const py = getCanvasY(yVal);
if (py >= paddingTop && py <= paddingTop + graphHeight) {
if (first) {
ctx.moveTo(px, py);
first = false;
} else {
ctx.lineTo(px, py);
}
}
}
ctx.lineWidth = 3;
ctx.strokeStyle = lineColor;
ctx.stroke();
// 7. PEDAGOGICAL GRADIENT TRIANGLE SYSTEM (Only shows when showGradientTriangle is TRUE)
if (showGradientTriangle) {
const x1Pixel = getCanvasX(triangleX1);
const x2Pixel = getCanvasX(triangleX2);
const y1Pixel = getCanvasY(model.f(triangleX1));
const y2Pixel = getCanvasY(model.f(triangleX2));
// Sort vertices from left to right to build correct below-the-line boundaries
const pxLeft = Math.min(x1Pixel, x2Pixel);
const pxRight = Math.max(x1Pixel, x2Pixel);
const pyLeft = x1Pixel < x2Pixel ? y1Pixel : y2Pixel;
const pyRight = x1Pixel < x2Pixel ? y2Pixel : y1Pixel;
// Always locate the triangle BELOW the line:
// Lower physical coordinates correspond to LARGER canvas pixel Y values
const pyCorner = Math.max(pyLeft, pyRight);
const pxCorner = (pyCorner === pyLeft) ? pxRight : pxLeft;
// Draw Right-Angled dotted triangle
ctx.beginPath();
ctx.setLineDash([5, 5]);
ctx.lineWidth = 2;
ctx.strokeStyle = "#4f46e5"; // Indigo-600
// Leg 1: From left point to the corner
ctx.moveTo(pxLeft, pyLeft);
ctx.lineTo(pxCorner, pyCorner);
// Leg 2: From corner to the right point
ctx.lineTo(pxRight, pyRight);
ctx.stroke();
ctx.setLineDash([]); // clear dash
// Draw Right Angle Box Marker at the completed right-angled corner
const angleMarkerSize = 10;
const otherX = (pxCorner === pxRight) ? pxLeft : pxRight;
const otherY = (pxCorner === pxRight) ? pyRight : pyLeft;
const dirX = Math.sign(otherX - pxCorner);
const dirY = Math.sign(otherY - pyCorner);
ctx.beginPath();
ctx.moveTo(pxCorner + dirX * angleMarkerSize, pyCorner);
ctx.lineTo(pxCorner + dirX * angleMarkerSize, pyCorner + dirY * angleMarkerSize);
ctx.lineTo(pxCorner, pyCorner + dirY * angleMarkerSize);
ctx.strokeStyle = "#4f46e5";
ctx.lineWidth = 1.5;
ctx.stroke();
// Draw Drag Handle Nodes at P1 and P2
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = "#4f46e5";
ctx.lineWidth = 3;
// Node 1
ctx.beginPath();
ctx.arc(x1Pixel, y1Pixel, 6, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// Node 2
ctx.beginPath();
ctx.arc(x2Pixel, y2Pixel, 6, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// Draw Coordinate Labels near the nodes
ctx.fillStyle = "#000000";
ctx.font = "bold 10px monospace";
// Label for P1
ctx.textAlign = "right";
ctx.fillText(`(${triangleX1.toFixed(2)}, ${model.f(triangleX1).toFixed(2)})`, x1Pixel - 10, y1Pixel - 5);
// Label for P2
ctx.textAlign = "left";
ctx.fillText(`(${triangleX2.toFixed(2)}, ${model.f(triangleX2).toFixed(2)})`, x2Pixel + 10, y2Pixel - 5);
}
}
// 8. Interactive Single-Point Tangent Line Marker (Only active when triangle is toggled off)
if (tangentPoint && model && !showGradientTriangle) {
const tx = tangentPoint.x;
const ty = model.f(tx);
let slope = 0;
if (model.type === 'linear') slope = model.m;
else if (model.type === 'quadratic') slope = 2 * model.a * tx + model.b;
else if (model.type === 'sqrt') slope = tx > 0 ? (model.a / (2 * Math.sqrt(tx))) : 0;
else if (model.type === 'power') slope = tx > 0 ? (model.a * model.b * Math.pow(tx, model.b - 1)) : 0;
else if (model.type === 'exponential') slope = model.a * model.b * Math.exp(model.b * tx);
const tCanvasX = getCanvasX(tx);
const tCanvasY = getCanvasY(ty);
ctx.beginPath();
let tLineFirst = true;
for (let px = paddingLeft; px <= paddingLeft + graphWidth; px++) {
const xVal = getValFromCanvasX(px);
const yVal = slope * (xVal - tx) + ty;
const py = getCanvasY(yVal);
if (py >= paddingTop && py <= paddingTop + graphHeight) {
if (tLineFirst) {
ctx.moveTo(px, py);
tLineFirst = false;
} else {
ctx.lineTo(px, py);
}
}
}
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.strokeStyle = "#fb7185";
ctx.stroke();
ctx.setLineDash([]); // clear dash
// Draw dot node
ctx.beginPath();
ctx.arc(tCanvasX, tCanvasY, 6, 0, 2 * Math.PI);
ctx.fillStyle = "#fb7185";
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
}
// 9. Draw Coordinate Crosses (Student Points)
points.forEach(point => {
const cx = getCanvasX(point.x);
const cy = getCanvasY(point.y);
if (cx >= paddingLeft && cx <= paddingLeft + graphWidth && cy >= paddingTop && cy <= paddingTop + graphHeight) {
ctx.beginPath();
ctx.moveTo(cx - 5, cy);
ctx.lineTo(cx + 5, cy);
ctx.moveTo(cx, cy - 5);
ctx.lineTo(cx, cy + 5);
ctx.strokeStyle = pointColor;
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, 3.5, 0, 2 * Math.PI);
ctx.strokeStyle = pointColor;
ctx.lineWidth = 1;
ctx.stroke();
if (showCoordinates) {
ctx.fillStyle = "#000000";
ctx.font = "9px monospace";
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
ctx.fillText(`(${point.x}, ${point.y})`, cx + 6, cy - 3);
}
}
});
}
// --- CONTROLLER EVENTS & LISTENERS ---
// Preset Builder
function initPresets() {
const container = document.getElementById("templates-container");
container.innerHTML = "";
Object.keys(PRESETS).forEach(key => {
const btn = document.createElement("button");
btn.innerText = PRESETS[key].title.split(" (")[0];
btn.className = `px-3 py-1.5 text-xs font-medium rounded-lg transition-all duration-200 ${
activePreset === key ? "bg-indigo-600 text-white shadow-md shadow-indigo-500/10" : "bg-slate-700 hover:bg-slate-600 text-slate-300"
}`;
btn.addEventListener("click", () => {
activePreset = key;
const p = PRESETS[key];
xLabel = p.xLabel;
xUnit = p.xUnit;
yLabel = p.yLabel;
yUnit = p.yUnit;
fitType = p.fitType;
forceOrigin = p.forceOrigin;
dataPoints = JSON.parse(JSON.stringify(p.data));
tangentPoint = null;
triangleX1 = null; // force recalculate on template switch
triangleX2 = null;
// Sync inputs
document.getElementById("xLabelInput").value = xLabel;
document.getElementById("xUnitInput").value = xUnit;
document.getElementById("yLabelInput").value = yLabel;
document.getElementById("yUnitInput").value = yUnit;
document.getElementById("fit-type-selector").value = fitType;
initPresets(); // Redraw preset buttons
renderTableRows();
updateTableHeaderLabels();
updateTogglesAndControls();
drawGraph();
updateCalculationsAndAlerts();
});
container.appendChild(btn);
});
}
// Input event handlers for axes labels & units
document.getElementById("yLabelInput").addEventListener("input", (e) => {
yLabel = e.target.value;
updateTableHeaderLabels();
drawGraph();
updateCalculationsAndAlerts();
});
document.getElementById("yUnitInput").addEventListener("input", (e) => {
yUnit = e.target.value;
updateTableHeaderLabels();
drawGraph();
updateCalculationsAndAlerts();
});
document.getElementById("xLabelInput").addEventListener("input", (e) => {
xLabel = e.target.value;
updateTableHeaderLabels();
drawGraph();
updateCalculationsAndAlerts();
});
document.getElementById("xUnitInput").addEventListener("input", (e) => {
xUnit = e.target.value;
updateTableHeaderLabels();
drawGraph();
updateCalculationsAndAlerts();
});
// Boxes per division listeners
document.getElementById("btn-density-10").addEventListener("click", () => {
boxesPerDivision = 10;
updateTogglesAndControls();
drawGraph();
});
document.getElementById("btn-density-5").addEventListener("click", () => {
boxesPerDivision = 5;
updateTogglesAndControls();
drawGraph();
});
// Origin lock listener
document.getElementById("toggle-origin").addEventListener("click", () => {
forceOrigin = !forceOrigin;
triangleX1 = null; // Reset triangle ranges to match new scale bounds
triangleX2 = null;
updateTogglesAndControls();
drawGraph();
updateCalculationsAndAlerts();
});
// Coordinates show/hide listener
document.getElementById("toggle-coords").addEventListener("click", () => {
showCoordinates = !showCoordinates;
updateTogglesAndControls();
drawGraph();
});
// Fit Type selector listener
document.getElementById("fit-type-selector").addEventListener("change", (e) => {
fitType = e.target.value;
tangentPoint = null;
drawGraph();
updateCalculationsAndAlerts();
});
// Paper color listeners
document.getElementById("btn-paper-green").addEventListener("click", () => {
paperColor = "green";
document.getElementById("btn-paper-green").className = "w-4 h-4 rounded-full bg-emerald-600 border border-white scale-12 class px-1 bg-transparent py-1 cursor-pointer";
document.getElementById("btn-paper-blue").className = "w-4 h-4 rounded-full bg-blue-600 border border-transparent";
document.getElementById("btn-paper-grey").className = "w-4 h-4 rounded-full bg-zinc-600 border border-transparent";
drawGraph();
});
document.getElementById("btn-paper-blue").addEventListener("click", () => {
paperColor = "blue";
document.getElementById("btn-paper-blue").className = "w-4 h-4 rounded-full bg-blue-600 border border-white scale-125";
document.getElementById("btn-paper-green").className = "w-4 h-4 rounded-full bg-emerald-600 border border-transparent";
document.getElementById("btn-paper-grey").className = "w-4 h-4 rounded-full bg-zinc-600 border border-transparent";
drawGraph();
});
document.getElementById("btn-paper-grey").addEventListener("click", () => {
paperColor = "grey";
document.getElementById("btn-paper-grey").className = "w-4 h-4 rounded-full bg-zinc-600 border border-white scale-125";
document.getElementById("btn-paper-green").className = "w-4 h-4 rounded-full bg-emerald-600 border border-transparent";
document.getElementById("btn-paper-blue").className = "w-4 h-4 rounded-full bg-blue-600 border border-transparent";
drawGraph();
});
// Row Insertion and reset buttons
document.getElementById("btn-add-row").addEventListener("click", () => {
let nextX = 0;
let nextY = 0;
if (dataPoints.length > 0) {
const last = dataPoints[dataPoints.length - 1];
const secondLast = dataPoints[dataPoints.length - 2];
const lastX = parseFloat(last.x);
const lastY = parseFloat(last.y);
const secondLastX = secondLast ? parseFloat(secondLast.x) : NaN;
const secondLastY = secondLast ? parseFloat(secondLast.y) : NaN;
const diffX = (!isNaN(secondLastX) && !isNaN(lastX)) ? (lastX - secondLastX) : 1;
const diffY = (!isNaN(secondLastY) && !isNaN(lastY)) ? (lastY - secondLastY) : 1;
nextX = !isNaN(lastX) ? parseFloat((lastX + diffX).toFixed(2)) : 0;
nextY = !isNaN(lastY) ? parseFloat((lastY + diffY).toFixed(2)) : 0;
}
dataPoints.push({ x: nextX.toString(), y: nextY.toString() });
renderTableRows();
drawGraph();
updateCalculationsAndAlerts();
});
function clearAllData() {
dataPoints = [{ x: "", y: "" }, { x: "", y: "" }];
tangentPoint = null;
triangleX1 = null;
triangleX2 = null;
}
// Toggle gradient triangle visibility
function toggleGradientTriangleAction() {
showGradientTriangle = !showGradientTriangle;
const statusLabel = document.getElementById("triangle-status-label");
if (showGradientTriangle) {
statusLabel.innerText = "ON";
statusLabel.className = "text-emerald-400 font-black";
} else {
statusLabel.innerText = "OFF";
statusLabel.className = "text-yellow-300";
}
drawGraph();
updateCalculationsAndAlerts();
}
document.getElementById("btn-toggle-triangle").addEventListener("click", (e) => {
e.stopPropagation();
toggleGradientTriangleAction();
});
document.getElementById("gradient-panel").addEventListener("click", () => {
toggleGradientTriangleAction();
});
document.getElementById("btn-reset").addEventListener("click", () => {
clearAllData();
renderTableRows();
drawGraph();
updateCalculationsAndAlerts();
});
// Export Canvas Image Download
function handleExportPNG() {
const imageURI = canvas.toDataURL("image/png");
const link = document.createElement("a");
// Format uses standard characters to ensure cross-OS filename compatibility
link.download = `The graph of ${yLabel || 'Y'} against ${xLabel || 'X'}.png`;
link.href = imageURI;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
document.getElementById("btn-export").addEventListener("click", handleExportPNG);
// --- CANVAS INTERACTION: INTERACTIVE TOUCH/CLICK & DRAGGING VERTICES ---
// Check if user is clicking close to a vertex
function getDistanceToX(clickX, targetX, xScaleInfo, rect) {
const dims = getGraphDimensions(rect.width, rect.height);
const paddingLeft = dims.paddingLeft;
const graphWidth = dims.graphWidth;
const targetPct = (targetX - xScaleInfo.min) / (xScaleInfo.max - xScaleInfo.min);
const targetPixelX = paddingLeft + targetPct * graphWidth;
return Math.abs(clickX - targetPixelX);
}
canvas.addEventListener("mousedown", (e) => {
const points = getValidPoints();
if (points.length < 2 || !showGradientTriangle) return;
const xs = points.map(p => p.x);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, GRID_X_BLOCKS);
const xScaleInfo = calculateEvenScale(minX, maxX, GRID_X_BLOCKS, forceOrigin);
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const d1 = getDistanceToX(clickX, triangleX1, xScaleInfo, rect);
const d2 = getDistanceToX(clickX, triangleX2, xScaleInfo, rect);
// If within 15 pixels of either vertex, register drag
if (d1 < 15 && d1 < d2) {
activeDragPoint = 'x1';
} else if (d2 < 15) {
activeDragPoint = 'x2';
}
});
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const currX = e.clientX - rect.left;
const dims = getGraphDimensions(rect.width, rect.height);
const paddingLeft = dims.paddingLeft;
const graphWidth = dims.graphWidth;
const pct = (currX - paddingLeft) / graphWidth;
const points = getValidPoints();
const xs = points.map(p => p.x);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, GRID_X_BLOCKS);
const xScaleInfo = calculateEvenScale(minX, maxX, GRID_X_BLOCKS, forceOrigin);
// Handle dragging
if (activeDragPoint && pct >= 0 && pct <= 1) {
const realX = xScaleInfo.min + pct * (xScaleInfo.max - xScaleInfo.min);
if (activeDragPoint === 'x1') {
triangleX1 = realX;
} else {
triangleX2 = realX;
}
drawGraph();
updateCalculationsAndAlerts();
return;
}
// Handle hover cursor updates
if (showGradientTriangle) {
const d1 = getDistanceToX(currX, triangleX1, xScaleInfo, rect);
const d2 = getDistanceToX(currX, triangleX2, xScaleInfo, rect);
if (d1 < 15 || d2 < 15) {
canvas.style.cursor = "ew-resize";
} else {
canvas.style.cursor = "crosshair";
}
} else {
canvas.style.cursor = "crosshair";
}
// Default hover coordinate popup logic
if (pct >= 0 && pct <= 1) {
const realX = xScaleInfo.min + pct * (xScaleInfo.max - xScaleInfo.min);
const model = getActiveModel(points);
if (hoverDiv) {
hoverDiv.classList.remove("hidden");
if (model) {
const realY = model.f(realX);
hoverDiv.innerHTML = `X: ${realX.toFixed(3)}<br />Y: ${realY.toFixed(3)}`;
} else {
hoverDiv.innerHTML = `X: ${realX.toFixed(3)}`;
}
}
} else {
if (hoverDiv) {
hoverDiv.classList.add("hidden");
}
}
});
window.addEventListener("mouseup", () => {
activeDragPoint = null;
});
// Touch support for mobile devices
canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 0) return;
const touch = e.touches[0];
const points = getValidPoints();
if (points.length < 2 || !showGradientTriangle) return;
const xs = points.map(p => p.x);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, GRID_X_BLOCKS);
const xScaleInfo = calculateEvenScale(minX, maxX, GRID_X_BLOCKS, forceOrigin);
const rect = canvas.getBoundingClientRect();
const clickX = touch.clientX - rect.left;
const d1 = getDistanceToX(clickX, triangleX1, xScaleInfo, rect);
const d2 = getDistanceToX(clickX, triangleX2, xScaleInfo, rect);
if (d1 < 25 && d1 < d2) {
activeDragPoint = 'x1';
} else if (d2 < 25) {
activeDragPoint = 'x2';
}
});
canvas.addEventListener("touchmove", (e) => {
if (e.touches.length === 0 || !activeDragPoint) return;
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const currX = touch.clientX - rect.left;
const dims = getGraphDimensions(rect.width, rect.height);
const paddingLeft = dims.paddingLeft;
const graphWidth = dims.graphWidth;
const pct = (currX - paddingLeft) / graphWidth;
const points = getValidPoints();
const xs = points.map(p => p.x);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, GRID_X_BLOCKS);
const xScaleInfo = calculateEvenScale(minX, maxX, GRID_X_BLOCKS, forceOrigin);
if (pct >= 0 && pct <= 1) {
const realX = xScaleInfo.min + pct * (xScaleInfo.max - xScaleInfo.min);
if (activeDragPoint === 'x1') {
triangleX1 = realX;
} else {
triangleX2 = realX;
}
drawGraph();
updateCalculationsAndAlerts();
}
});
canvas.addEventListener("touchend", () => {
activeDragPoint = null;
});
// Single click tangent point placement fallback
canvas.addEventListener("click", (e) => {
if (showGradientTriangle) return; // ignore if active triangle is showing
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const dims = getGraphDimensions(rect.width, rect.height);
const paddingLeft = dims.paddingLeft;
const graphWidth = dims.graphWidth;
const pct = (clickX - paddingLeft) / graphWidth;
if (pct >= 0 && pct <= 1) {
const points = getValidPoints();
const xs = points.map(p => p.x);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, GRID_X_BLOCKS);
const xScaleInfo = calculateEvenScale(minX, maxX, GRID_X_BLOCKS, forceOrigin);
const targetX = xScaleInfo.min + pct * (xScaleInfo.max - xScaleInfo.min);
tangentPoint = { x: targetX };
drawGraph();
updateCalculationsAndAlerts();
}
});
canvas.addEventListener("mouseleave", () => {
if (hoverDiv) {
hoverDiv.classList.add("hidden");
}
});
window.addEventListener("resize", () => {
drawGraph();
});
// --- INITIALIZATION ---
window.onload = function() {
// Synchronize input fields
document.getElementById("xLabelInput").value = xLabel;
document.getElementById("xUnitInput").value = xUnit;
document.getElementById("yLabelInput").value = yLabel;
document.getElementById("yUnitInput").value = yUnit;
document.getElementById("fit-type-selector").value = fitType;
initPresets();
renderTableRows();
updateTableHeaderLabels();
updateTogglesAndControls();
drawGraph();
updateCalculationsAndAlerts();
};
</script>
</body>
</html>