this repo has no description
1"use strict";
2
3// Canvas rendering — circuit diagram and V(z) plot.
4// All draw functions are pure with respect to app state; they receive
5// everything they need as parameters (ctx, dimensions, dynamic state, theme).
6//
7// Multi-segment support:
8// drawCircuit takes a `segments` array and draws N abutting T-line boxes.
9// Wave voltage helpers respect each packet's z-range [zStart, zEnd].
10// drawPlot draws light vertical tick marks at internal segment boundaries.
11
12const TLRender = (() => {
13 // ---- plot layout constants ----
14 const PLOT_PANEL_H = 150;
15 const PLOT_PANEL_GAP = 10;
16 const PLOT_PAD_T = 14;
17 const PLOT_PAD_B = 26;
18
19 // ---- theme ----
20 function getTheme() {
21 const get = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
22 return {
23 bg: get("--bg"),
24 panel: get("--panel"),
25 ink: get("--ink"),
26 muted: get("--muted"),
27 grid: get("--grid"),
28 accent: get("--accent"),
29 accent2: get("--accent2"),
30 warn: get("--warn"),
31 ok: get("--ok"),
32 tdr: get("--tdr"),
33 };
34 }
35
36 // ---- primitive helpers ----
37 function line(ctx, x0, y0, x1, y1) {
38 ctx.beginPath();
39 ctx.moveTo(x0, y0);
40 ctx.lineTo(x1, y1);
41 ctx.stroke();
42 }
43
44 function circle(ctx, x, y, r, strokeStyle) {
45 ctx.strokeStyle = strokeStyle;
46 ctx.lineWidth = 2;
47 ctx.beginPath();
48 ctx.arc(x, y, r, 0, Math.PI * 2);
49 ctx.stroke();
50 }
51
52 function circleFill(ctx, x, y, r, fillStyle) {
53 ctx.fillStyle = fillStyle;
54 ctx.beginPath();
55 ctx.arc(x, y, r, 0, Math.PI * 2);
56 ctx.fill();
57 }
58
59 function label(ctx, text, x, y, color) {
60 ctx.fillStyle = color;
61 ctx.font = "13px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial";
62 ctx.fillText(text, x, y);
63 }
64
65 // Return a "nice" tick step for a given range and target number of ticks.
66 function niceStep(range, targetCount) {
67 const raw = range / targetCount;
68 const mag = Math.pow(10, Math.floor(Math.log10(raw)));
69 const norm = raw / mag;
70 const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
71 return nice * mag;
72 }
73
74 // ---- circuit component primitives ----
75 function drawResistor(ctx, x0, y0, x1, y1, zigZagCount, amp, theme) {
76 ctx.strokeStyle = theme.ink;
77 ctx.lineWidth = 2;
78 const dx = x1 - x0, dy = y1 - y0;
79 const len = Math.hypot(dx, dy);
80 if (len < 1e-6) return;
81 const ux = dx / len, uy = dy / len;
82 const px = -uy, py = ux;
83
84 const lead = 10;
85 const start = { x: x0 + ux * lead, y: y0 + uy * lead };
86 const end = { x: x1 - ux * lead, y: y1 - uy * lead };
87
88 line(ctx, x0, y0, start.x, start.y);
89 line(ctx, end.x, end.y, x1, y1);
90
91 const segs = zigZagCount * 2 - 1;
92 const segLen = (len - 2 * lead) / segs;
93 ctx.beginPath();
94 ctx.moveTo(start.x, start.y);
95 for (let i = 1; i < segs; i++) {
96 const s = i * segLen;
97 const flip = (i % 2 === 0) ? -1 : 1;
98 ctx.lineTo(start.x + ux * s + px * amp * flip,
99 start.y + uy * s + py * amp * flip);
100 }
101 ctx.lineTo(end.x, end.y);
102 ctx.stroke();
103 }
104
105 function drawSwitch(ctx, x, y, tn, theme) {
106 const closed = tn > 0;
107 ctx.strokeStyle = theme.ink;
108 ctx.lineWidth = 2;
109 const a = { x: x - 18, y };
110 const b = { x: x + 18, y };
111 circleFill(ctx, a.x, a.y, 3.2, theme.ink);
112 circleFill(ctx, b.x, b.y, 3.2, theme.ink);
113 if (closed) {
114 line(ctx, a.x, a.y, b.x, b.y);
115 } else {
116 line(ctx, a.x + 1, a.y - 1, b.x - 2, b.y - 14);
117 }
118 label(ctx, "t = 0", x - 18, y + 26, theme.muted);
119 }
120
121 function drawVoltageProbe(ctx, x, yTop, yBot, name, theme) {
122 ctx.fillStyle = theme.muted;
123 ctx.font = "13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
124 ctx.fillText("+", x + 6, yTop + 12);
125 ctx.fillText("\u2212", x + 6, yBot - 4);
126 label(ctx, name, x + 5, (yTop + yBot) / 2 - 6, theme.muted);
127 }
128
129 // ---- circuit canvas ----
130 // segments = model.segments = [{Z0}, …] (N entries, equal-length)
131 // RL = model.RL — pass Infinity for open circuit
132 function drawCircuit(ctx, w, h, tn, dyn, theme, segments, RL) {
133 const rlIsOpen = !isFinite(RL);
134 const rlIsShort = RL === 0;
135 ctx.clearRect(0, 0, w, h);
136 ctx.fillStyle = theme.panel;
137 ctx.fillRect(0, 0, w, h);
138
139 const pad = 18;
140 const yTop = 70;
141 const yBot = 190;
142
143 const xSourceL = pad + 60;
144 const xSwitch = pad + 170;
145 const xTL0 = pad + 240;
146 const xTL1 = w - pad - 210;
147 const xLoad = w - pad - 160;
148 const xRight = w - pad - 90;
149
150 ctx.lineWidth = 2;
151 ctx.strokeStyle = theme.ink;
152
153 // Top and bottom wires — always run to xRight regardless of termination
154 line(ctx, xSwitch + 18, yTop, xTL0, yTop);
155 line(ctx, xTL1, yTop, xRight, yTop);
156 line(ctx, xSourceL, yBot, xTL0, yBot);
157 line(ctx, xTL1, yBot, xRight, yBot);
158
159 // Voltage source
160 const vsx = xSourceL, vsy = (yTop + yBot) / 2;
161 line(ctx, xSourceL, yTop, xSourceL, vsy - 20);
162 line(ctx, xSourceL, vsy + 20, xSourceL, yBot);
163 circle(ctx, vsx, vsy, 20, theme.ink);
164 ctx.fillStyle = theme.ink;
165 ctx.font = "14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
166 ctx.fillText("+", vsx - 4, vsy - 6);
167 ctx.fillText("\u2212", vsx - 4, vsy + 14);
168 label(ctx, "Vg", vsx - 38, vsy - 20, theme.muted);
169
170 // Source resistor Rg
171 const r0 = xSourceL + 20, r1 = xSwitch - 30;
172 line(ctx, xSourceL, yTop, r0, yTop);
173 drawResistor(ctx, r0, yTop, r1, yTop, 5, 8, theme);
174 line(ctx, r1, yTop, xSwitch - 18, yTop);
175 label(ctx, "Rg", (r0 + r1) / 2 - 10, yTop - 22, theme.muted);
176
177 drawSwitch(ctx, xSwitch, yTop, tn, theme);
178
179 // Transmission line boxes — N abutting pairs (top + bottom conductor)
180 const N = segments.length;
181 const tlW = xTL1 - xTL0;
182 const segPxW = tlW / N;
183 ctx.lineWidth = 2;
184 ctx.strokeStyle = theme.ink;
185
186 ctx.font = "13px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial";
187 for (let i = 0; i < N; i++) {
188 const bx = xTL0 + i * segPxW;
189 ctx.strokeRect(bx, yTop - 16, segPxW, 32);
190 ctx.strokeRect(bx, yBot - 16, segPxW, 32);
191
192 // Label above top box: "Z₀" for single segment, "50Ω" etc. for multi
193 const lbl = N === 1
194 ? "Z\u2080"
195 : `${segments[i].Z0}\u03A9`;
196 const lblX = bx + segPxW / 2 - ctx.measureText(lbl).width / 2;
197 label(ctx, lbl, lblX, yTop - 22, theme.muted);
198 }
199
200 // Load — resistor, short circuit wire, or open-circuit terminals
201 const rlTop = yTop + 22, rlBot = yBot - 22;
202 ctx.strokeStyle = theme.ink;
203 ctx.lineWidth = 2;
204 if (rlIsOpen) {
205 circleFill(ctx, xRight, yTop, 4.5, theme.ink);
206 circleFill(ctx, xRight, yBot, 4.5, theme.ink);
207 } else if (rlIsShort) {
208 ctx.lineWidth = 3;
209 line(ctx, xLoad, yTop, xLoad, yBot);
210 ctx.lineWidth = 2;
211 } else {
212 line(ctx, xLoad, yTop, xLoad, rlTop);
213 drawResistor(ctx, xLoad, rlTop, xLoad, rlBot, 5, 8, theme);
214 line(ctx, xLoad, rlBot, xLoad, yBot);
215 label(ctx, "RL", xLoad + 10, (yTop + yBot) / 2, theme.muted);
216 }
217
218 // Voltage probes
219 drawVoltageProbe(ctx, xSwitch + 12, yTop, yBot, "VS", theme);
220 drawVoltageProbe(ctx, xLoad + 55, yTop, yBot, "VL", theme);
221
222 // Terminal dots at the VL port — top and bottom
223 if (!rlIsOpen) {
224 circleFill(ctx, xRight, yTop, 3.5, theme.ink);
225 circleFill(ctx, xRight, yBot, 3.5, theme.ink);
226 }
227
228 // TDR detection point — green dot on top wire between switch and T-line input
229 const xDetect = Math.round((xSwitch + 18 + xTL0) / 2);
230 circleFill(ctx, xDetect, yTop, 5.5, theme.tdr);
231 ctx.fillStyle = theme.tdr;
232 ctx.font = "11px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial";
233 ctx.textAlign = "left";
234
235 // Wavefront markers (dashed vertical lines inside T-line boxes)
236 const wfY0 = yTop - 16;
237 const wfY1 = yBot + 16;
238 for (const wf of dyn.activeWaves) {
239 const xw = xTL0 + wf.front * tlW;
240 ctx.strokeStyle = (wf.dir > 0) ? theme.accent : theme.accent2;
241 ctx.lineWidth = 3;
242 ctx.setLineDash([9, 7]);
243 line(ctx, xw, wfY0, xw, wfY1);
244 ctx.setLineDash([]);
245 }
246
247 label(ctx, "z = 0", xTL0 - 20, yBot + 34, theme.muted);
248 label(ctx, "z = \u2113", xTL1 - 18, yBot + 34, theme.muted);
249 }
250
251 // ---- wave-shape helpers ----
252
253 // Voltage profile of a single wave packet as piecewise-constant segments over [0,1].
254 // The packet only contributes amplitude in its own z-range [zStart, zEnd].
255 function segmentsForWave(wf) {
256 const { dir, zStart, A, front } = wf;
257 let segs;
258 if (dir > 0) {
259 // Nonzero in [zStart, front]; zero elsewhere.
260 segs = [
261 { a: 0, b: zStart, V: 0 },
262 { a: zStart, b: front, V: A },
263 { a: front, b: 1, V: 0 },
264 ];
265 } else {
266 // Leftward: zStart is the right edge; nonzero in [front, zStart].
267 segs = [
268 { a: 0, b: front, V: 0 },
269 { a: front, b: zStart, V: A },
270 { a: zStart, b: 1, V: 0 },
271 ];
272 }
273 const valid = segs.filter(s => s.b > s.a + 1e-9);
274 return valid.length ? valid : [{ a: 0, b: 1, V: 0 }];
275 }
276
277 // Sum of all launched waves as piecewise-constant segments over [0,1].
278 // Breakpoints include every wave's front, zStart, and zEnd so that the
279 // midpoint probe correctly captures all z-range transitions.
280 function totalSegmentsForWaves(waves) {
281 const breakpoints = [0, 1];
282 for (const wf of waves) {
283 breakpoints.push(wf.front);
284 breakpoints.push(wf.zStart);
285 breakpoints.push(wf.zEnd);
286 }
287 breakpoints.sort((a, b) => a - b);
288
289 const pts = [];
290 for (const x of breakpoints) {
291 if (!pts.length || Math.abs(x - pts[pts.length - 1]) > 1e-6) pts.push(x);
292 }
293
294 const segs = [];
295 for (let i = 0; i < pts.length - 1; i++) {
296 const a = pts[i], b = pts[i + 1];
297 const m = (a + b) / 2;
298 let V = 0;
299 for (const wf of waves) {
300 if (wf.dir > 0 && m >= wf.zStart - 1e-9 && m <= wf.front + 1e-9) V += wf.A;
301 if (wf.dir < 0 && m >= wf.front - 1e-9 && m <= wf.zStart + 1e-9) V += wf.A;
302 }
303 segs.push({ a, b, V });
304 }
305 return segs.length ? segs : [{ a: 0, b: 1, V: 0 }];
306 }
307
308 // Draw a piecewise-constant voltage profile.
309 function drawPiecewise(ctx, xOfZ, yOfV, segments, color, width) {
310 ctx.strokeStyle = color;
311 ctx.lineWidth = width;
312 ctx.beginPath();
313 ctx.moveTo(xOfZ(segments[0].a), yOfV(segments[0].V));
314 for (const seg of segments) {
315 ctx.lineTo(xOfZ(seg.a), yOfV(seg.V));
316 ctx.lineTo(xOfZ(seg.b), yOfV(seg.V));
317 }
318 ctx.stroke();
319 }
320
321 // Draw a continuous voltage profile by sampling vFn(z) at N evenly-spaced z values.
322 function drawSampledWave(ctx, xOfZ, yOfV, vFn, color, width, N = 400) {
323 ctx.strokeStyle = color;
324 ctx.lineWidth = width;
325 ctx.beginPath();
326 for (let i = 0; i <= N; i++) {
327 const z = i / N;
328 if (i === 0) ctx.moveTo(xOfZ(z), yOfV(vFn(z)));
329 else ctx.lineTo(xOfZ(z), yOfV(vFn(z)));
330 }
331 ctx.stroke();
332 }
333
334 // ---- plot canvas ----
335 // segments = model.segments (used to draw boundary tick marks)
336 function drawPlot(ctx, w, h, tn, dyn, theme, riseTimeTr = 0, riseShape = "step", segments = [{ Z0: 50 }]) {
337 ctx.clearRect(0, 0, w, h);
338 ctx.fillStyle = theme.panel;
339 ctx.fillRect(0, 0, w, h);
340
341 const circuitPad = 18;
342 const xPlot0 = circuitPad + 240;
343 const xPlot1 = w - circuitPad - 210;
344 const plotW = xPlot1 - xPlot0;
345 const xOfZ = (z) => xPlot0 + z * plotW;
346
347 const launched = [...dyn.launchedWaves].sort((a, b) => a.n - b.n);
348 const sumSegs = totalSegmentsForWaves(launched);
349
350 const maxAbsWave = Math.max(1e-6, ...launched.map((wf) => Math.abs(wf.A)));
351 const maxAbsSum = Math.max(1e-6, sumSegs.reduce((m, s) => Math.max(m, Math.abs(s.V)), 0));
352 const vScale = Math.max(maxAbsWave, maxAbsSum);
353 const vLo = -1.15 * vScale;
354 const vHi = 1.15 * vScale;
355
356 ctx.fillStyle = theme.muted;
357 ctx.font = "12px ui-sans-serif, system-ui";
358 ctx.fillText("Voltage along the T-line", 12, 14);
359
360 // Draw axes/border for one panel, with y-axis tick marks and labels.
361 function drawPanelFrame(top, bot, yOfV, labelText, vLo, vHi) {
362 const step = niceStep(vHi - vLo, 5);
363 const decimals = step >= 1 ? 0 : step >= 0.1 ? 1 : 2;
364 // Index of first tick at or above vLo
365 const iTick0 = Math.ceil(vLo / step);
366
367 // Horizontal grid lines at each tick (drawn first, behind everything)
368 ctx.strokeStyle = theme.grid;
369 ctx.lineWidth = 1;
370 for (let i = iTick0; ; i++) {
371 const v = i * step;
372 if (v > vHi + step * 0.01) break;
373 const yv = yOfV(v);
374 if (yv < top - 1 || yv > bot + 1) continue;
375 line(ctx, xPlot0, yv, xPlot1, yv);
376 }
377
378 // Border (on top of grid lines)
379 ctx.strokeStyle = theme.grid;
380 ctx.lineWidth = 1;
381 line(ctx, xPlot0, top, xPlot1, top);
382 line(ctx, xPlot0, bot, xPlot1, bot);
383 line(ctx, xPlot0, top, xPlot0, bot);
384 line(ctx, xPlot1, top, xPlot1, bot);
385
386 // Zero line — slightly more visible than other grid lines
387 const yZero = yOfV(0);
388 if (yZero >= top - 1 && yZero <= bot + 1) {
389 ctx.strokeStyle = theme.muted;
390 ctx.lineWidth = 1.2;
391 line(ctx, xPlot0, yZero, xPlot1, yZero);
392 }
393
394 // Tick marks and labels on the left y-axis
395 ctx.textAlign = "right";
396 ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
397 for (let i = iTick0; ; i++) {
398 const v = i * step;
399 if (v > vHi + step * 0.01) break;
400 const yv = yOfV(v);
401 if (yv < top - 1 || yv > bot + 1) continue;
402 ctx.strokeStyle = theme.muted;
403 ctx.lineWidth = 1;
404 line(ctx, xPlot0 - 4, yv, xPlot0, yv);
405 ctx.fillStyle = theme.muted;
406 ctx.fillText(v.toFixed(decimals), xPlot0 - 7, yv + 4);
407 }
408 ctx.textAlign = "left";
409
410 // Panel title and axis labels
411 ctx.fillStyle = theme.muted;
412 ctx.font = "12px ui-sans-serif, system-ui";
413 ctx.fillText(labelText, xPlot0 + 8, top + 14);
414 ctx.fillText("z", xPlot1 + 6, (top + bot) / 2 + 4);
415
416 // Segment boundary tick marks (for multi-segment lines)
417 const N = segments.length;
418 if (N > 1) {
419 ctx.strokeStyle = theme.grid;
420 ctx.lineWidth = 1;
421 ctx.setLineDash([3, 4]);
422 for (let i = 1; i < N; i++) {
423 const xb = xOfZ(i / N);
424 line(ctx, xb, top, xb, bot);
425 }
426 ctx.setLineDash([]);
427 }
428 }
429
430 // Dashed wave-front position markers
431 function drawFrontMarkers(top, bot, fronts) {
432 if (!fronts.length) return;
433 ctx.strokeStyle = theme.warn;
434 ctx.lineWidth = 1.2;
435 ctx.setLineDash([4, 5]);
436 for (const zf of fronts) line(ctx, xOfZ(zf), top, xOfZ(zf), bot);
437 ctx.setLineDash([]);
438 }
439
440 const panelH = PLOT_PANEL_H;
441 const { waveVoltageAt, totalVoltageAt } = TLPhysics;
442 const smooth = riseTimeTr > 0 && riseShape !== "step";
443
444 // Panel 1 — sum of all waves
445 const top0 = PLOT_PAD_T;
446 const bot0 = top0 + panelH;
447 const y0 = (V) => top0 + 4 + (vHi - V) / (vHi - vLo) * (panelH - 8);
448 drawPanelFrame(top0, bot0, y0, "Sum (all waves)", vLo, vHi);
449 if (smooth) {
450 drawSampledWave(ctx, xOfZ, y0, (z) => totalVoltageAt(z, launched, riseTimeTr, riseShape, segments.length), theme.ok, 2.4);
451 } else {
452 drawPiecewise(ctx, xOfZ, y0, sumSegs, theme.ok, 2.4);
453 }
454 drawFrontMarkers(top0, bot0, dyn.activeWaves.map((wf) => wf.front));
455
456 // Panel 2 — individual component waves
457 const top1 = PLOT_PAD_T + panelH + PLOT_PANEL_GAP;
458 const bot1 = top1 + panelH;
459 const y1 = (V) => top1 + 4 + (vHi - V) / (vHi - vLo) * (panelH - 8);
460 drawPanelFrame(top1, bot1, y1, "Components (all waves)", vLo, vHi);
461
462 const waveStyles = [
463 { color: theme.accent, dash: [] },
464 { color: theme.accent2, dash: [] },
465 { color: theme.accent, dash: [9, 6] },
466 { color: theme.accent2, dash: [9, 6] },
467 { color: theme.accent, dash: [2, 5] },
468 { color: theme.accent2, dash: [2, 5] },
469 { color: theme.accent, dash: [16, 5] },
470 { color: theme.accent2, dash: [16, 5] },
471 { color: theme.accent, dash: [8, 4, 2, 4] },
472 { color: theme.accent2, dash: [8, 4, 2, 4] },
473 ];
474
475 const FADE_DURATION = 4;
476 const FADE_MIN = 0.08;
477
478 for (let i = 0; i < launched.length; i++) {
479 const wf = launched[i];
480 const style = waveStyles[i % waveStyles.length];
481 // Fade after the wave front has reached its endpoint (tDie).
482 const alpha = TLUtils.clamp(1 - (tn - wf.tDie) / FADE_DURATION, FADE_MIN, 1);
483 ctx.globalAlpha = alpha;
484 ctx.setLineDash(style.dash);
485 if (smooth) {
486 drawSampledWave(ctx, xOfZ, y1, (z) => waveVoltageAt(wf, z, riseTimeTr, riseShape), style.color, 2.0);
487 } else {
488 drawPiecewise(ctx, xOfZ, y1, segmentsForWave(wf), style.color, 2.0);
489 }
490 ctx.setLineDash([]);
491 ctx.globalAlpha = 1;
492 }
493 drawFrontMarkers(top1, bot1, launched.filter((wf) => wf.u < Math.abs(wf.zEnd - wf.zStart)).map((wf) => wf.front));
494
495 // z-axis endpoint labels
496 ctx.fillStyle = theme.muted;
497 ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
498 ctx.fillText("0", xPlot0 - 4, h - 6);
499 ctx.fillText("\u2113", xPlot1 - 4, h - 6);
500 }
501
502 // ---- TDR canvas ----
503 // Draws VS(t) — voltage at the detection point (z=0, between Rg and T-line) versus time.
504 // The trace grows up to the current tNorm; a cursor line shows current time.
505 function drawTDR(ctx, w, h, tn, bounce, riseTimeTr, riseShape, timeHorizon, theme) {
506 ctx.clearRect(0, 0, w, h);
507 ctx.fillStyle = theme.panel;
508 ctx.fillRect(0, 0, w, h);
509
510 const padL = 55, padR = 30, padT = 20, padB = 30;
511 const plotW = w - padL - padR;
512 const plotH = h - padT - padB;
513
514 const tMax = timeHorizon;
515 const xOfT = (t) => padL + (t / tMax) * plotW;
516
517 const { sumEventsAtTime, sumEventsWithRise, sumEventsWithLinearRamp } = TLPhysics;
518 const vsAtTime = (t) => {
519 if (riseTimeTr <= 0 || riseShape === "step") return sumEventsAtTime(bounce.srcEvents, t);
520 if (riseShape === "linear") return sumEventsWithLinearRamp(bounce.srcEvents, t, riseTimeTr);
521 return sumEventsWithRise(bounce.srcEvents, t, riseTimeTr);
522 };
523
524 // y range from all srcEvents (cumulative sum)
525 const events = [...bounce.srcEvents].sort((a, b) => a.t - b.t);
526 let cumV = 0, vMax = 0, vMin = 0;
527 for (const ev of events) {
528 cumV += ev.dV;
529 if (cumV > vMax) vMax = cumV;
530 if (cumV < vMin) vMin = cumV;
531 }
532 vMax = Math.max(vMax, 0.01);
533 vMin = Math.min(vMin, 0);
534 const span = Math.max(vMax - vMin, 0.05);
535 const vHi = vMax + 0.2 * span;
536 const vLo = vMin - 0.15 * span;
537 const yOfV = (v) => padT + (vHi - v) / (vHi - vLo) * plotH;
538
539 // Title
540 ctx.fillStyle = theme.muted;
541 ctx.font = "12px ui-sans-serif, system-ui";
542 ctx.fillText("TDR \u2014 V at detection point vs time", padL + 8, padT + 14);
543
544 // Grid lines
545 const vStep = niceStep(vHi - vLo, 4);
546 ctx.strokeStyle = theme.grid;
547 ctx.lineWidth = 1;
548 for (let i = Math.ceil(vLo / vStep); ; i++) {
549 const v = i * vStep;
550 if (v > vHi + vStep * 0.01) break;
551 const yv = yOfV(v);
552 if (yv < padT - 1 || yv > padT + plotH + 1) continue;
553 line(ctx, padL, yv, padL + plotW, yv);
554 }
555
556 // Border
557 ctx.strokeStyle = theme.grid;
558 ctx.lineWidth = 1;
559 line(ctx, padL, padT, padL + plotW, padT);
560 line(ctx, padL, padT + plotH, padL + plotW, padT + plotH);
561 line(ctx, padL, padT, padL, padT + plotH);
562 line(ctx, padL + plotW, padT, padL + plotW, padT + plotH);
563
564 // Zero line
565 const yZero = yOfV(0);
566 if (yZero >= padT && yZero <= padT + plotH) {
567 ctx.strokeStyle = theme.muted;
568 ctx.lineWidth = 1.2;
569 line(ctx, padL, yZero, padL + plotW, yZero);
570 }
571
572 // Y-axis ticks + labels
573 const vDec = vStep >= 1 ? 0 : vStep >= 0.1 ? 1 : 2;
574 ctx.textAlign = "right";
575 ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
576 for (let i = Math.ceil(vLo / vStep); ; i++) {
577 const v = i * vStep;
578 if (v > vHi + vStep * 0.01) break;
579 const yv = yOfV(v);
580 if (yv < padT - 1 || yv > padT + plotH + 1) continue;
581 ctx.strokeStyle = theme.muted;
582 ctx.lineWidth = 1;
583 line(ctx, padL - 4, yv, padL, yv);
584 ctx.fillStyle = theme.muted;
585 ctx.fillText(v.toFixed(vDec), padL - 7, yv + 4);
586 }
587 ctx.textAlign = "left";
588
589 // X-axis ticks + labels
590 const tStep = niceStep(tMax, 6);
591 const tDec = tStep >= 1 ? 0 : 1;
592 ctx.textAlign = "center";
593 ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
594 for (let t = 0; t <= tMax + tStep * 0.01; t += tStep) {
595 const xt = xOfT(t);
596 if (xt < padL - 1 || xt > padL + plotW + 1) continue;
597 ctx.strokeStyle = theme.muted;
598 ctx.lineWidth = 1;
599 line(ctx, xt, padT + plotH, xt, padT + plotH + 4);
600 ctx.fillStyle = theme.muted;
601 ctx.fillText(t.toFixed(tDec), xt, padT + plotH + 16);
602 }
603 ctx.textAlign = "left";
604
605 // X-axis label
606 ctx.fillStyle = theme.muted;
607 ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
608 ctx.fillText("t / \u03C4d", padL + plotW + 6, padT + plotH + 4);
609
610 // VS(t) trace up to tn
611 const tnClamped = Math.min(tn, tMax);
612 const smooth = riseTimeTr > 0 && riseShape !== "step";
613 ctx.strokeStyle = theme.tdr;
614 ctx.lineWidth = 2.2;
615 ctx.beginPath();
616
617 if (smooth) {
618 const SAMPLES = 500;
619 for (let i = 0; i <= SAMPLES; i++) {
620 const t = (i / SAMPLES) * tnClamped;
621 const v = vsAtTime(t);
622 if (i === 0) ctx.moveTo(xOfT(t), yOfV(v));
623 else ctx.lineTo(xOfT(t), yOfV(v));
624 }
625 } else {
626 // Staircase: horizontal segment then vertical jump at each srcEvent
627 let prevV = 0;
628 ctx.moveTo(xOfT(0), yOfV(0));
629 for (const ev of events) {
630 if (ev.t > tnClamped) break;
631 ctx.lineTo(xOfT(ev.t), yOfV(prevV));
632 prevV += ev.dV;
633 ctx.lineTo(xOfT(ev.t), yOfV(prevV));
634 }
635 ctx.lineTo(xOfT(tnClamped), yOfV(prevV));
636 }
637 ctx.stroke();
638
639 // Current-time cursor (vertical dashed line)
640 if (tn > 0 && tn <= tMax) {
641 ctx.strokeStyle = theme.warn;
642 ctx.lineWidth = 1;
643 ctx.setLineDash([4, 5]);
644 line(ctx, xOfT(tn), padT, xOfT(tn), padT + plotH);
645 ctx.setLineDash([]);
646 }
647
648 // Current voltage readout
649 const vNow = vsAtTime(tnClamped);
650 ctx.fillStyle = theme.tdr;
651 ctx.font = "13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
652 ctx.textAlign = "right";
653 ctx.fillText("V(det) = " + vNow.toFixed(4) + " V", padL + plotW - 6, padT + 18);
654 ctx.textAlign = "left";
655 }
656
657 // Resize the plot canvas height to fit the given number of panels.
658 function ensurePlotCanvasHeight(plotEl, nPanels) {
659 const targetH = Math.round(
660 PLOT_PAD_T + PLOT_PAD_B +
661 nPanels * PLOT_PANEL_H +
662 (nPanels - 1) * PLOT_PANEL_GAP
663 );
664 const curr = parseInt(plotEl.getAttribute("height") || "0", 10);
665 if (curr !== targetH) plotEl.setAttribute("height", String(targetH));
666 }
667
668 return {
669 PLOT_PANEL_H, PLOT_PANEL_GAP, PLOT_PAD_T, PLOT_PAD_B,
670 getTheme,
671 drawCircuit,
672 drawPlot,
673 drawTDR,
674 ensurePlotCanvasHeight,
675 };
676})();