Download index.html from SMKBK1/Graph: direct link, hf CLI and curl.
- Browser
- Download file 83.3 kB
-
https://huggingface.co/spaces/SMKBK1/Graph/resolve/main/index.html
- Command line
-
hf download hf://spaces/SMKBK1/Graph/index.html
-
curl -L -o index.html https://huggingface.co/spaces/SMKBK1/Graph/resolve/main/index.html
83.3 kB
| <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> |