Hma47 commited on
Commit
4fd4566
·
verified ·
1 Parent(s): 2e203c9

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +819 -0
index.html ADDED
@@ -0,0 +1,819 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Sonicos Grapher - One File</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0A0B0E;
10
+ --card: #151619;
11
+ --border: #2A2D35;
12
+ --muted: #8E9299;
13
+ --text: #E1E1E6;
14
+ --cyan: #00D1FF;
15
+ --teal: #2dd4bf;
16
+ --red: #ef4444;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body {
20
+ margin: 0;
21
+ min-height: 100vh;
22
+ background: var(--bg);
23
+ color: var(--text);
24
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ }
26
+ .app {
27
+ width: min(1100px, 100%);
28
+ margin: 0 auto;
29
+ padding: 24px;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 24px;
33
+ }
34
+ header {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ gap: 16px;
39
+ border-bottom: 1px solid var(--border);
40
+ padding-bottom: 16px;
41
+ }
42
+ .brand { display: flex; align-items: center; gap: 12px; }
43
+ .logo {
44
+ width: 26px;
45
+ height: 26px;
46
+ border-radius: 7px;
47
+ background: var(--cyan);
48
+ box-shadow: 0 0 14px rgba(0,209,255,.7);
49
+ display: grid;
50
+ place-items: center;
51
+ color: var(--bg);
52
+ font-weight: 900;
53
+ }
54
+ h1 {
55
+ margin: 0;
56
+ font-size: 14px;
57
+ letter-spacing: 2px;
58
+ text-transform: uppercase;
59
+ }
60
+ .badges { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }
61
+ .badge {
62
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
63
+ font-size: 11px;
64
+ color: var(--muted);
65
+ border: 1px solid var(--border);
66
+ border-radius: 7px;
67
+ padding: 6px 8px;
68
+ background: rgba(255,255,255,.02);
69
+ }
70
+ .live {
71
+ color: var(--cyan);
72
+ border-color: rgba(0,209,255,.35);
73
+ background: rgba(0,209,255,.12);
74
+ display: none;
75
+ align-items: center;
76
+ gap: 7px;
77
+ }
78
+ .live.active { display: flex; }
79
+ .pulse {
80
+ width: 7px;
81
+ height: 7px;
82
+ border-radius: 50%;
83
+ background: var(--cyan);
84
+ box-shadow: 0 0 7px var(--cyan);
85
+ animation: pulse 1s infinite alternate;
86
+ }
87
+ @keyframes pulse { from { opacity: .35; } to { opacity: 1; } }
88
+
89
+ .layout {
90
+ display: grid;
91
+ grid-template-columns: minmax(0, 3fr) minmax(190px, 1fr);
92
+ gap: 24px;
93
+ }
94
+ .main, .side { display: flex; flex-direction: column; gap: 16px; }
95
+ .card {
96
+ background: var(--card);
97
+ border: 1px solid var(--border);
98
+ border-radius: 16px;
99
+ padding: 16px;
100
+ box-shadow: 0 10px 30px rgba(0,0,0,.16);
101
+ }
102
+ .toolbar {
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ gap: 6px;
106
+ background: var(--bg);
107
+ border: 1px solid var(--border);
108
+ border-radius: 12px;
109
+ padding: 10px;
110
+ margin-bottom: 12px;
111
+ }
112
+ button {
113
+ border: 0;
114
+ font: inherit;
115
+ cursor: pointer;
116
+ }
117
+ .tool-btn, .preset-btn {
118
+ background: var(--card);
119
+ color: var(--text);
120
+ border: 1px solid transparent;
121
+ border-radius: 8px;
122
+ transition: .15s ease;
123
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
124
+ }
125
+ .tool-btn {
126
+ min-width: 38px;
127
+ padding: 8px 10px;
128
+ font-size: 14px;
129
+ }
130
+ .tool-btn:hover, .preset-btn:hover {
131
+ color: var(--cyan);
132
+ background: #20232a;
133
+ border-color: rgba(0,209,255,.45);
134
+ }
135
+ .formula-wrap {
136
+ display: flex;
137
+ gap: 10px;
138
+ align-items: center;
139
+ background: var(--bg);
140
+ border: 1px solid var(--border);
141
+ border-radius: 12px;
142
+ padding: 12px 14px;
143
+ }
144
+ .formula-wrap:focus-within {
145
+ outline: 2px solid rgba(0,209,255,.7);
146
+ border-color: transparent;
147
+ }
148
+ #formulaInput {
149
+ width: 100%;
150
+ min-height: 44px;
151
+ resize: vertical;
152
+ background: transparent;
153
+ color: var(--text);
154
+ border: none;
155
+ outline: none;
156
+ font-size: 20px;
157
+ line-height: 1.35;
158
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
159
+ }
160
+ .hint {
161
+ margin-top: 10px;
162
+ color: var(--muted);
163
+ font-size: 12px;
164
+ line-height: 1.5;
165
+ }
166
+ .error {
167
+ display: none;
168
+ margin-top: 12px;
169
+ padding: 12px;
170
+ border-radius: 10px;
171
+ color: #fca5a5;
172
+ border: 1px solid rgba(239,68,68,.5);
173
+ background: rgba(239,68,68,.12);
174
+ font-size: 13px;
175
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
176
+ overflow-wrap: anywhere;
177
+ }
178
+ .error.active { display: block; }
179
+
180
+ .canvas-card {
181
+ position: relative;
182
+ overflow: hidden;
183
+ display: grid;
184
+ place-items: center;
185
+ padding: 0;
186
+ min-height: 260px;
187
+ background-color: var(--card);
188
+ background-image:
189
+ linear-gradient(var(--border) 1px, transparent 1px),
190
+ linear-gradient(90deg, var(--border) 1px, transparent 1px);
191
+ background-size: 40px 40px;
192
+ }
193
+ canvas {
194
+ display: block;
195
+ width: 100%;
196
+ height: auto;
197
+ max-height: 520px;
198
+ aspect-ratio: 2 / 1;
199
+ }
200
+ .controls { display: flex; gap: 14px; align-items: center; }
201
+ .primary, .secondary {
202
+ border-radius: 14px;
203
+ padding: 16px 20px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 1.7px;
206
+ font-size: 13px;
207
+ font-weight: 800;
208
+ transition: .15s ease;
209
+ display: flex;
210
+ align-items: center;
211
+ justify-content: center;
212
+ gap: 8px;
213
+ }
214
+ .primary {
215
+ flex: 1;
216
+ background: var(--cyan);
217
+ color: var(--bg);
218
+ }
219
+ .primary:hover { filter: brightness(.94); }
220
+ .primary.stop {
221
+ background: var(--card);
222
+ color: var(--text);
223
+ border: 1px solid var(--border);
224
+ }
225
+ .primary.stop:hover { border-color: var(--cyan); }
226
+ .primary:disabled {
227
+ cursor: not-allowed;
228
+ background: var(--border);
229
+ color: var(--muted);
230
+ }
231
+ .secondary {
232
+ background: var(--bg);
233
+ color: var(--muted);
234
+ border: 1px solid var(--border);
235
+ }
236
+ .secondary:hover { border-color: var(--muted); color: var(--text); }
237
+
238
+ .side-title {
239
+ margin: 0 0 2px;
240
+ color: var(--muted);
241
+ font-size: 10px;
242
+ letter-spacing: 1.5px;
243
+ text-transform: uppercase;
244
+ font-weight: 900;
245
+ }
246
+ .preset-list {
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 9px;
250
+ max-height: 620px;
251
+ overflow-y: auto;
252
+ padding-right: 4px;
253
+ }
254
+ .preset-btn {
255
+ width: 100%;
256
+ text-align: left;
257
+ padding: 11px 12px;
258
+ color: var(--muted);
259
+ border-color: var(--border);
260
+ background: var(--bg);
261
+ font-size: 12px;
262
+ }
263
+ .preset-fn {
264
+ display: block;
265
+ margin-top: 3px;
266
+ color: #cbd5e1;
267
+ opacity: .7;
268
+ overflow-wrap: anywhere;
269
+ font-size: 11px;
270
+ }
271
+
272
+ @media (max-width: 800px) {
273
+ .app { padding: 16px; }
274
+ header { align-items: flex-start; flex-direction: column; }
275
+ .layout { grid-template-columns: 1fr; }
276
+ .preset-list { flex-direction: row; overflow-x: auto; max-height: none; }
277
+ .preset-btn { min-width: 160px; }
278
+ .controls { flex-direction: column; }
279
+ .primary, .secondary { width: 100%; }
280
+ }
281
+ </style>
282
+ </head>
283
+ <body>
284
+ <main class="app">
285
+ <header>
286
+ <div class="brand">
287
+ <div class="logo">∿</div>
288
+ <h1>Sonicos Grapher</h1>
289
+ </div>
290
+ <div class="badges">
291
+ <div id="liveBadge" class="badge live"><span class="pulse"></span> STREAMING LIVE</div>
292
+ <div class="badge">AUDIO: POLY.SINE</div>
293
+ <div class="badge">ONE FILE VERSION</div>
294
+ </div>
295
+ </header>
296
+
297
+ <section class="layout">
298
+ <div class="main">
299
+ <div class="card">
300
+ <div id="toolbar" class="toolbar"></div>
301
+ <div class="formula-wrap">
302
+ <textarea id="formulaInput" spellcheck="false">sin(x)</textarea>
303
+ </div>
304
+ <div class="hint">
305
+ Use formulas such as <strong>sin(x)</strong>, <strong>x^2</strong>, <strong>x^2+y^2=25</strong>, or <strong>(x^2+y^2-25)^3-25*x^2*y^3=0</strong>.
306
+ </div>
307
+ <div id="errorBox" class="error"></div>
308
+ </div>
309
+
310
+ <div class="card canvas-card">
311
+ <canvas id="graphCanvas" width="800" height="400"></canvas>
312
+ </div>
313
+
314
+ <div class="controls">
315
+ <button id="playBtn" class="primary">▶ Initialize Stream</button>
316
+ <button id="resetBtn" class="secondary">↻ Reset</button>
317
+ </div>
318
+ </div>
319
+
320
+ <aside class="side">
321
+ <h2 class="side-title">Pattern Presets</h2>
322
+ <div id="presetList" class="preset-list"></div>
323
+ </aside>
324
+ </section>
325
+ </main>
326
+
327
+ <script>
328
+ const PRESETS = [
329
+ { name: 'Sine', func: 'sin(x)', min: -6.28, max: 6.28, ymin: -2, ymax: 2 },
330
+ { name: 'Cosine', func: 'cos(x)', min: -6.28, max: 6.28, ymin: -2, ymax: 2 },
331
+ { name: 'Parabola', func: 'x^2', min: -5, max: 5, ymin: -1, ymax: 25 },
332
+ { name: 'Cubic', func: 'x^3', min: -5, max: 5, ymin: -125, ymax: 125 },
333
+ { name: 'Circle', func: 'x^2+y^2=25', min: -10, max: 10, ymin: -10, ymax: 10 },
334
+ { name: 'Heart', func: '(x^2+y^2-25)^3-25*x^2*y^3=0', min: -10, max: 10, ymin: -10, ymax: 10 },
335
+ { name: 'Ripple', func: 'sin(3*x)+0.4*sin(9*x)', min: -6.28, max: 6.28, ymin: -2, ymax: 2 },
336
+ { name: 'Beat', func: 'sin(5*x)*cos(0.7*x)', min: -10, max: 10, ymin: -2, ymax: 2 },
337
+ { name: 'Hyperbola', func: 'x^2-y^2=10', min: -10, max: 10, ymin: -10, ymax: 10 },
338
+ { name: 'Custom', func: 'y=sin(x)*x', min: -20, max: 20, ymin: -20, ymax: 20 }
339
+ ];
340
+
341
+ const MATH_TOOLS = [
342
+ ['+', '+'], ['−', '-'], ['×', '*'], ['÷', '/'], ['=', '='], ['>', '>'], ['<', '<'], ['≥', '>='], ['≤', '<='],
343
+ ['(', '('], [')', ')'], ['xⁿ', '^'], ['√', 'sqrt()'], ['frac', '()/()'], ['π', 'pi'], ['e', 'e'],
344
+ ['sin', 'sin()'], ['cos', 'cos()'], ['tan', 'tan()'], ['|x|', 'abs()'],
345
+ ['7', '7'], ['8', '8'], ['9', '9'], ['4', '4'], ['5', '5'], ['6', '6'], ['1', '1'], ['2', '2'], ['3', '3'], ['0', '0'], ['.', '.'], ['x', 'x'], ['y', 'y']
346
+ ];
347
+
348
+ const CW = 800;
349
+ const CH = 400;
350
+ const OSC_COUNT = 5;
351
+
352
+ const canvas = document.getElementById('graphCanvas');
353
+ const ctx = canvas.getContext('2d');
354
+ const formulaInput = document.getElementById('formulaInput');
355
+ const errorBox = document.getElementById('errorBox');
356
+ const playBtn = document.getElementById('playBtn');
357
+ const resetBtn = document.getElementById('resetBtn');
358
+ const toolbar = document.getElementById('toolbar');
359
+ const presetList = document.getElementById('presetList');
360
+ const liveBadge = document.getElementById('liveBadge');
361
+
362
+ let isPlaying = false;
363
+ let rafId = 0;
364
+ let lastTime = 0;
365
+ let audioContext = null;
366
+ let oscs = [];
367
+ let gains = [];
368
+
369
+ const state = {
370
+ currentPix: 0,
371
+ lines: [],
372
+ fnObj: null,
373
+ minY: -1,
374
+ maxY: 1,
375
+ xMin: -6.28,
376
+ xMax: 6.28
377
+ };
378
+
379
+ function insertAtCursor(textarea, text) {
380
+ const start = textarea.selectionStart;
381
+ const end = textarea.selectionEnd;
382
+ const current = textarea.value;
383
+ textarea.value = current.slice(0, start) + text + current.slice(end);
384
+ let cursor = start + text.length;
385
+ if (text.endsWith('()')) cursor -= 1;
386
+ if (text === '()/()') cursor = start + 1;
387
+ textarea.focus();
388
+ textarea.setSelectionRange(cursor, cursor);
389
+ updateFormula();
390
+ }
391
+
392
+ MATH_TOOLS.forEach(([label, insert]) => {
393
+ const btn = document.createElement('button');
394
+ btn.className = 'tool-btn';
395
+ btn.textContent = label;
396
+ btn.addEventListener('click', () => insertAtCursor(formulaInput, insert));
397
+ toolbar.appendChild(btn);
398
+ });
399
+
400
+ PRESETS.forEach((preset) => {
401
+ const btn = document.createElement('button');
402
+ btn.className = 'preset-btn';
403
+ btn.innerHTML = `<strong>${preset.name}</strong><span class="preset-fn">${escapeHtml(preset.func)}</span>`;
404
+ btn.addEventListener('click', () => {
405
+ formulaInput.value = preset.func;
406
+ updateFormula();
407
+ });
408
+ presetList.appendChild(btn);
409
+ });
410
+
411
+ function escapeHtml(value) {
412
+ return String(value)
413
+ .replace(/&/g, '&amp;')
414
+ .replace(/</g, '&lt;')
415
+ .replace(/>/g, '&gt;')
416
+ .replace(/"/g, '&quot;')
417
+ .replace(/'/g, '&#039;');
418
+ }
419
+
420
+ function normalizeMath(raw) {
421
+ let expr = String(raw || '').trim();
422
+ expr = expr.replace(/×/g, '*').replace(/÷/g, '/').replace(/−/g, '-').replace(/π/g, 'pi');
423
+ expr = expr.replace(/\s+/g, '');
424
+ expr = expr.replace(/\^/g, '**');
425
+ expr = expr.replace(/\bln\(/g, 'log(');
426
+ expr = expr.replace(/\|([^|]+)\|/g, 'abs($1)');
427
+ expr = expr.replace(/(\d|\)|x|y|pi|e)(?=(x|y|pi|e|\())/gi, '$1*');
428
+ expr = expr.replace(/(x|y|pi|e|\))(?=\d)/gi, '$1*');
429
+ expr = expr.replace(/(x|y)(?=(x|y))/gi, '$1*');
430
+ return expr;
431
+ }
432
+
433
+ function validateIdentifiers(expr) {
434
+ const allowed = new Set([
435
+ 'x', 'y', 'pi', 'e', 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sqrt', 'abs', 'log', 'exp', 'pow',
436
+ 'min', 'max', 'floor', 'ceil', 'round', 'sign'
437
+ ]);
438
+ const tokens = expr.match(/[A-Za-z_]\w*/g) || [];
439
+ for (const token of tokens) {
440
+ if (!allowed.has(token)) throw new Error(`Unsupported token: ${token}`);
441
+ }
442
+ if (/[^0-9A-Za-z_+\-*/().,=<>&!\s]/.test(expr)) throw new Error('Unsupported character in formula');
443
+ }
444
+
445
+ function buildEvaluator(rawExpr) {
446
+ const js = normalizeMath(rawExpr);
447
+ validateIdentifiers(js);
448
+ const names = ['sin','cos','tan','asin','acos','atan','sqrt','abs','log','exp','pow','min','max','floor','ceil','round','sign','pi','e'];
449
+ const values = [Math.sin,Math.cos,Math.tan,Math.asin,Math.acos,Math.atan,Math.sqrt,Math.abs,Math.log,Math.exp,Math.pow,Math.min,Math.max,Math.floor,Math.ceil,Math.round,Math.sign,Math.PI,Math.E];
450
+ let fn;
451
+ try {
452
+ fn = new Function('x', 'y', ...names, `"use strict"; return (${js});`);
453
+ } catch (err) {
454
+ throw new Error('Invalid formula syntax');
455
+ }
456
+ return (x, y) => {
457
+ const value = fn(x, y, ...values);
458
+ if (typeof value === 'boolean') return value ? 1 : -1;
459
+ return Number(value);
460
+ };
461
+ }
462
+
463
+ function compileFunction(input) {
464
+ if (!input || !String(input).trim()) throw new Error('Empty expression');
465
+ const raw = String(input).trim();
466
+ let expr = raw;
467
+ let isEquation = false;
468
+
469
+ if (raw.includes('=')) {
470
+ const parts = raw.split('=');
471
+ if (parts.length !== 2) throw new Error('Multiple "=" signs are not supported');
472
+ expr = `(${parts[0]})-(${parts[1]})`;
473
+ isEquation = true;
474
+ }
475
+
476
+ const normalized = normalizeMath(expr);
477
+ const hasY = /(^|[^A-Za-z_])y([^A-Za-z_]|$)/.test(normalized);
478
+ const evaluator = buildEvaluator(expr);
479
+ const test = evaluator(1, 1);
480
+ if (!Number.isFinite(test) && Number.isNaN(test)) {
481
+ // Do not fail here because some valid formulas are undefined at x=1.
482
+ }
483
+ return { isImplicit: isEquation || hasY, eval: evaluator };
484
+ }
485
+
486
+ function setError(message) {
487
+ if (!message) {
488
+ errorBox.textContent = '';
489
+ errorBox.classList.remove('active');
490
+ playBtn.disabled = false;
491
+ } else {
492
+ errorBox.textContent = message;
493
+ errorBox.classList.add('active');
494
+ playBtn.disabled = true;
495
+ }
496
+ }
497
+
498
+ function getPreset(func) {
499
+ return PRESETS.find(p => p.func === func.trim());
500
+ }
501
+
502
+ function updateFormula() {
503
+ try {
504
+ const functionStr = formulaInput.value.trim();
505
+ if (!functionStr) {
506
+ state.fnObj = null;
507
+ state.lines = [];
508
+ setError(null);
509
+ if (isPlaying) stop();
510
+ renderFrame(performance.now());
511
+ return;
512
+ }
513
+
514
+ const fnObj = compileFunction(functionStr);
515
+ const preset = getPreset(functionStr);
516
+ let x0 = preset ? preset.min : -10;
517
+ let x1 = preset ? preset.max : 10;
518
+ let uY0 = preset && preset.ymin !== undefined ? preset.ymin : NaN;
519
+ let uY1 = preset && preset.ymax !== undefined ? preset.ymax : NaN;
520
+
521
+ if (Number.isNaN(uY0) || Number.isNaN(uY1)) {
522
+ if (fnObj.isImplicit) {
523
+ uY0 = -10;
524
+ uY1 = 10;
525
+ } else {
526
+ uY0 = Infinity;
527
+ uY1 = -Infinity;
528
+ for (let i = 0; i <= 500; i++) {
529
+ const x = x0 + (i / 500) * (x1 - x0);
530
+ let y = NaN;
531
+ try { y = fnObj.eval(x, 0); } catch (_) {}
532
+ if (Number.isFinite(y)) {
533
+ if (y < uY0) uY0 = y;
534
+ if (y > uY1) uY1 = y;
535
+ }
536
+ }
537
+ if (!Number.isFinite(uY0) || !Number.isFinite(uY1)) {
538
+ uY0 = -10;
539
+ uY1 = 10;
540
+ }
541
+ }
542
+ }
543
+
544
+ if (uY0 >= uY1) {
545
+ uY0 -= 1;
546
+ uY1 += 1;
547
+ }
548
+
549
+ const pad = (uY1 - uY0) * 0.1;
550
+ const rMinY = uY0 - pad;
551
+ const rMaxY = uY1 + pad;
552
+ const mapY = (y) => CH - CH * (y - rMinY) / (rMaxY - rMinY);
553
+ const computedLines = [];
554
+
555
+ if (fnObj.isImplicit) {
556
+ const cols = 220;
557
+ const rows = 120;
558
+ const grid = new Float32Array((cols + 1) * (rows + 1));
559
+ const dx = CW / cols;
560
+ const dy = CH / rows;
561
+
562
+ for (let j = 0; j <= rows; j++) {
563
+ const mathY = rMaxY - (j / rows) * (rMaxY - rMinY);
564
+ for (let i = 0; i <= cols; i++) {
565
+ const mathX = x0 + (i / cols) * (x1 - x0);
566
+ let val = NaN;
567
+ try { val = fnObj.eval(mathX, mathY); } catch (_) {}
568
+ grid[j * (cols + 1) + i] = Number.isFinite(val) ? val : NaN;
569
+ }
570
+ }
571
+
572
+ const safeDiv = (n, d) => d === 0 ? 0.5 : n / d;
573
+ const getPt = (i, j, edge) => {
574
+ const v0 = grid[j * (cols + 1) + i];
575
+ const v1 = grid[j * (cols + 1) + i + 1];
576
+ const v2 = grid[(j + 1) * (cols + 1) + i + 1];
577
+ const v3 = grid[(j + 1) * (cols + 1) + i];
578
+ let t = 0;
579
+ if (edge === 0) { t = safeDiv(v0, v0 - v1); return [(i + t) * dx, j * dy]; }
580
+ if (edge === 1) { t = safeDiv(v1, v1 - v2); return [(i + 1) * dx, (j + t) * dy]; }
581
+ if (edge === 2) { t = safeDiv(v3, v3 - v2); return [(i + t) * dx, (j + 1) * dy]; }
582
+ if (edge === 3) { t = safeDiv(v0, v0 - v3); return [i * dx, (j + t) * dy]; }
583
+ return [i * dx, j * dy];
584
+ };
585
+
586
+ const lookup = [[], [[3,2]], [[2,1]], [[3,1]], [[1,0]], [[1,0],[3,2]], [[2,0]], [[3,0]], [[0,3]], [[0,2]], [[0,1],[2,3]], [[0,1]], [[1,3]], [[1,2]], [[2,3]], []];
587
+
588
+ for (let j = 0; j < rows; j++) {
589
+ for (let i = 0; i < cols; i++) {
590
+ const v0 = grid[j * (cols + 1) + i];
591
+ const v1 = grid[j * (cols + 1) + i + 1];
592
+ const v2 = grid[(j + 1) * (cols + 1) + i + 1];
593
+ const v3 = grid[(j + 1) * (cols + 1) + i];
594
+ if (Number.isNaN(v0) || Number.isNaN(v1) || Number.isNaN(v2) || Number.isNaN(v3)) continue;
595
+ let c = 0;
596
+ if (v0 > 0) c |= 8;
597
+ if (v1 > 0) c |= 4;
598
+ if (v2 > 0) c |= 2;
599
+ if (v3 > 0) c |= 1;
600
+ if (c === 0 || c === 15) continue;
601
+ for (const edgePair of lookup[c]) {
602
+ const p1 = getPt(i, j, edgePair[0]);
603
+ const p2 = getPt(i, j, edgePair[1]);
604
+ computedLines.push([p1[0], p1[1], p2[0], p2[1]]);
605
+ }
606
+ }
607
+ }
608
+ } else {
609
+ let pxPrev = null;
610
+ let pyPrev = null;
611
+ for (let i = 0; i < CW; i++) {
612
+ const x = x0 + (i / CW) * (x1 - x0);
613
+ let y = NaN;
614
+ try { y = fnObj.eval(x, 0); } catch (_) {}
615
+ if (Number.isFinite(y)) {
616
+ const py = mapY(y);
617
+ if (pxPrev !== null && pyPrev !== null && Math.abs(py - pyPrev) < CH * 2) {
618
+ computedLines.push([pxPrev, pyPrev, i, py]);
619
+ }
620
+ pxPrev = i;
621
+ pyPrev = py;
622
+ } else {
623
+ pxPrev = null;
624
+ pyPrev = null;
625
+ }
626
+ }
627
+ }
628
+
629
+ state.fnObj = fnObj;
630
+ state.xMin = x0;
631
+ state.xMax = x1;
632
+ state.minY = uY0;
633
+ state.maxY = uY1;
634
+ state.lines = computedLines;
635
+ setError(null);
636
+ renderFrame(performance.now());
637
+ } catch (err) {
638
+ setError(err.message || 'Invalid formula');
639
+ if (isPlaying) stop();
640
+ state.fnObj = null;
641
+ renderFrame(performance.now());
642
+ }
643
+ }
644
+
645
+ function startAudio() {
646
+ if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
647
+ if (audioContext.state === 'suspended') audioContext.resume();
648
+ stopAudio();
649
+ for (let i = 0; i < OSC_COUNT; i++) {
650
+ const osc = audioContext.createOscillator();
651
+ const gain = audioContext.createGain();
652
+ osc.type = 'sine';
653
+ gain.gain.value = 0;
654
+ osc.connect(gain);
655
+ gain.connect(audioContext.destination);
656
+ osc.start();
657
+ oscs.push(osc);
658
+ gains.push(gain);
659
+ }
660
+ }
661
+
662
+ function stopAudio() {
663
+ if (audioContext) {
664
+ const ctx = audioContext;
665
+ for (const gain of gains) {
666
+ try { gain.gain.setTargetAtTime(0, ctx.currentTime, 0.05); } catch (_) {}
667
+ }
668
+ const oldOscs = oscs.slice();
669
+ setTimeout(() => {
670
+ for (const osc of oldOscs) {
671
+ try { osc.stop(); osc.disconnect(); } catch (_) {}
672
+ }
673
+ }, 100);
674
+ }
675
+ oscs = [];
676
+ gains = [];
677
+ }
678
+
679
+ function updateAudioFreqs(freqs) {
680
+ if (!audioContext) return;
681
+ const activeCount = Math.min(freqs.length, OSC_COUNT);
682
+ const masterVol = 0.5 / (activeCount || 1);
683
+ for (let i = 0; i < OSC_COUNT; i++) {
684
+ const gain = gains[i];
685
+ const osc = oscs[i];
686
+ if (!gain || !osc) continue;
687
+ if (i < activeCount) {
688
+ osc.frequency.setTargetAtTime(freqs[i], audioContext.currentTime, 0.05);
689
+ gain.gain.setTargetAtTime(masterVol, audioContext.currentTime, 0.05);
690
+ } else {
691
+ gain.gain.setTargetAtTime(0, audioContext.currentTime, 0.05);
692
+ }
693
+ }
694
+ }
695
+
696
+ function play() {
697
+ if (!state.fnObj || errorBox.classList.contains('active')) return;
698
+ isPlaying = true;
699
+ state.currentPix = 0;
700
+ lastTime = performance.now();
701
+ startAudio();
702
+ playBtn.classList.add('stop');
703
+ playBtn.textContent = '■ Stop Signal';
704
+ liveBadge.classList.add('active');
705
+ if (rafId) cancelAnimationFrame(rafId);
706
+ rafId = requestAnimationFrame(renderFrame);
707
+ }
708
+
709
+ function stop() {
710
+ isPlaying = false;
711
+ stopAudio();
712
+ if (rafId) cancelAnimationFrame(rafId);
713
+ rafId = 0;
714
+ playBtn.classList.remove('stop');
715
+ playBtn.textContent = '▶ Initialize Stream';
716
+ liveBadge.classList.remove('active');
717
+ renderFrame(performance.now());
718
+ }
719
+
720
+ function reset() {
721
+ stop();
722
+ formulaInput.value = '';
723
+ state.currentPix = 0;
724
+ updateFormula();
725
+ }
726
+
727
+ function renderFrame(time) {
728
+ if (!ctx) return;
729
+ if (!lastTime) lastTime = time;
730
+ const dt = Math.min((time - lastTime) / 1000, 0.1);
731
+ lastTime = time;
732
+
733
+ const { fnObj, minY, maxY, lines } = state;
734
+ ctx.clearRect(0, 0, CW, CH);
735
+
736
+ const yRange = maxY === minY ? 1 : maxY - minY;
737
+ const pad = yRange * 0.1;
738
+ const rMinY = minY - pad;
739
+ const rMaxY = maxY + pad;
740
+ const mapY = (y) => CH - CH * (y - rMinY) / (rMaxY - rMinY);
741
+
742
+ ctx.strokeStyle = '#334155';
743
+ ctx.lineWidth = 1;
744
+ ctx.beginPath();
745
+ const zeroY = mapY(0);
746
+ ctx.moveTo(0, zeroY);
747
+ ctx.lineTo(CW, zeroY);
748
+ ctx.stroke();
749
+
750
+ if (fnObj) {
751
+ ctx.beginPath();
752
+ ctx.strokeStyle = '#64748b';
753
+ ctx.lineWidth = 2;
754
+ ctx.globalAlpha = 0.45;
755
+ for (const line of lines) {
756
+ ctx.moveTo(Math.floor(line[0]), Math.floor(line[1]));
757
+ ctx.lineTo(Math.floor(line[2]), Math.floor(line[3]));
758
+ }
759
+ ctx.stroke();
760
+ ctx.globalAlpha = 1;
761
+
762
+ if (isPlaying) {
763
+ const sweepDuration = 3.0;
764
+ const pxPerSec = CW / sweepDuration;
765
+ state.currentPix = (state.currentPix + pxPerSec * dt) % CW;
766
+ const sweepX = state.currentPix;
767
+ const targetPixels = [];
768
+
769
+ for (const line of lines) {
770
+ const lx0 = Math.min(line[0], line[2]);
771
+ const lx1 = Math.max(line[0], line[2]);
772
+ if (lx0 <= sweepX && lx1 >= sweepX) {
773
+ if (lx0 === lx1) {
774
+ targetPixels.push((line[1] + line[3]) / 2);
775
+ } else {
776
+ const t = (sweepX - line[0]) / (line[2] - line[0]);
777
+ if (t >= -0.01 && t <= 1.01) {
778
+ targetPixels.push(line[1] + t * (line[3] - line[1]));
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ ctx.beginPath();
785
+ ctx.moveTo(sweepX, 0);
786
+ ctx.lineTo(sweepX, CH);
787
+ ctx.strokeStyle = '#2dd4bf';
788
+ ctx.lineWidth = 2;
789
+ ctx.stroke();
790
+
791
+ targetPixels.sort((a, b) => b - a);
792
+ ctx.fillStyle = '#ffffff';
793
+ for (const yp of targetPixels.slice(0, OSC_COUNT)) {
794
+ ctx.beginPath();
795
+ ctx.arc(sweepX, yp, 5, 0, Math.PI * 2);
796
+ ctx.fill();
797
+ }
798
+
799
+ const minFreq = 160;
800
+ const maxFreq = 1200;
801
+ const mappedFreqs = targetPixels.slice(0, OSC_COUNT).map(yp => {
802
+ const normY = Math.max(0, Math.min(1, 1 - yp / CH));
803
+ return minFreq * Math.pow(maxFreq / minFreq, normY);
804
+ });
805
+ updateAudioFreqs(mappedFreqs);
806
+ }
807
+ }
808
+
809
+ if (isPlaying) rafId = requestAnimationFrame(renderFrame);
810
+ }
811
+
812
+ formulaInput.addEventListener('input', updateFormula);
813
+ playBtn.addEventListener('click', () => isPlaying ? stop() : play());
814
+ resetBtn.addEventListener('click', reset);
815
+
816
+ updateFormula();
817
+ </script>
818
+ </body>
819
+ </html>