|
10 | 10 | import type { MemoryState, PlotSpec } from '../engine/types'; |
11 | 11 | import { escapeHtml } from './dialog'; |
12 | 12 |
|
| 13 | +// Plot palette mirrors the memory canvas (memory.ts) so the two lenses read as |
| 14 | +// one board: amber = data, teal = the fitted trend, muted ink for axes/labels. |
| 15 | +const PLOT = { |
| 16 | + ink: '#E7EEF2', |
| 17 | + muted: '#8FA0A8', |
| 18 | + amber: '#F0A050', |
| 19 | + teal: '#5BC4B0', |
| 20 | + line: '#2A353B', |
| 21 | +}; |
| 22 | +const PLOT_MONO = 'IBM Plex Mono, monospace'; |
| 23 | +const PLOT_SANS = 'IBM Plex Sans, sans-serif'; |
| 24 | + |
| 25 | +/** Shorten an axis value for a tick label without losing its meaning. */ |
| 26 | +function fmtTick(n: number): string { |
| 27 | + if (!isFinite(n)) return ''; |
| 28 | + const abs = Math.abs(n); |
| 29 | + if (abs >= 10000 || (abs > 0 && abs < 0.01)) return n.toExponential(1); |
| 30 | + return String(Math.round(n * 100) / 100); |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Render one plot spec as a self-contained SVG: light grid, labelled axes, and |
| 35 | + * the series (amber dots for `scatter`, a teal polyline for `line`). Returns the |
| 36 | + * full `<svg>` markup — no live DOM, so it composes safely into innerHTML. |
| 37 | + */ |
| 38 | +function plotSvg(spec: PlotSpec): string { |
| 39 | + const W = 480; |
| 40 | + const H = 360; |
| 41 | + const m = { l: 52, r: 18, t: 32, b: 42 }; |
| 42 | + const px0 = m.l; |
| 43 | + const px1 = W - m.r; |
| 44 | + const py0 = H - m.b; |
| 45 | + const py1 = m.t; |
| 46 | + const pts = spec.points; |
| 47 | + |
| 48 | + // Data bounds, padded 8% so points never sit on the axis edges. |
| 49 | + let xmin = Infinity; |
| 50 | + let xmax = -Infinity; |
| 51 | + let ymin = Infinity; |
| 52 | + let ymax = -Infinity; |
| 53 | + for (const [x, y] of pts) { |
| 54 | + if (x < xmin) xmin = x; |
| 55 | + if (x > xmax) xmax = x; |
| 56 | + if (y < ymin) ymin = y; |
| 57 | + if (y > ymax) ymax = y; |
| 58 | + } |
| 59 | + if (pts.length === 0) { |
| 60 | + xmin = 0; xmax = 1; ymin = 0; ymax = 1; |
| 61 | + } |
| 62 | + if (xmax === xmin) { xmin -= 1; xmax += 1; } |
| 63 | + if (ymax === ymin) { ymin -= 1; ymax += 1; } |
| 64 | + const padX = (xmax - xmin) * 0.08; |
| 65 | + const padY = (ymax - ymin) * 0.08; |
| 66 | + xmin -= padX; xmax += padX; |
| 67 | + ymin -= padY; ymax += padY; |
| 68 | + |
| 69 | + const sx = (x: number) => px0 + ((x - xmin) / (xmax - xmin)) * (px1 - px0); |
| 70 | + const sy = (y: number) => py0 - ((y - ymin) / (ymax - ymin)) * (py0 - py1); |
| 71 | + |
| 72 | + // Faint gridlines + tick labels, evenly spaced across the padded range. |
| 73 | + const TICKS = 5; |
| 74 | + let grid = ''; |
| 75 | + let ticks = ''; |
| 76 | + for (let i = 0; i < TICKS; i++) { |
| 77 | + const fx = xmin + ((xmax - xmin) * i) / (TICKS - 1); |
| 78 | + const fy = ymin + ((ymax - ymin) * i) / (TICKS - 1); |
| 79 | + const X = sx(fx).toFixed(1); |
| 80 | + const Y = sy(fy).toFixed(1); |
| 81 | + grid += `<line x1="${X}" y1="${py1}" x2="${X}" y2="${py0}" stroke="${PLOT.line}" stroke-width="1"/>`; |
| 82 | + grid += `<line x1="${px0}" y1="${Y}" x2="${px1}" y2="${Y}" stroke="${PLOT.line}" stroke-width="1"/>`; |
| 83 | + ticks += `<text x="${X}" y="${py0 + 18}" fill="${PLOT.muted}" font-size="11" text-anchor="middle" font-family="${PLOT_MONO}">${fmtTick(fx)}</text>`; |
| 84 | + ticks += `<text x="${px0 - 8}" y="${Number(Y) + 4}" fill="${PLOT.muted}" font-size="11" text-anchor="end" font-family="${PLOT_MONO}">${fmtTick(fy)}</text>`; |
| 85 | + } |
| 86 | + |
| 87 | + // The series: a connected teal line (with node markers) or amber dots. |
| 88 | + let series = ''; |
| 89 | + if (spec.kind === 'line' && pts.length >= 2) { |
| 90 | + const d = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${sx(x).toFixed(1)},${sy(y).toFixed(1)}`).join(' '); |
| 91 | + series += `<path d="${d}" fill="none" stroke="${PLOT.teal}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`; |
| 92 | + series += pts.map(([x, y]) => `<circle cx="${sx(x).toFixed(1)}" cy="${sy(y).toFixed(1)}" r="3.5" fill="${PLOT.teal}"/>`).join(''); |
| 93 | + } else { |
| 94 | + series = pts.map(([x, y]) => `<circle cx="${sx(x).toFixed(1)}" cy="${sy(y).toFixed(1)}" r="4.5" fill="${PLOT.amber}" fill-opacity="0.9" stroke="#0b0f12" stroke-width="1"/>`).join(''); |
| 95 | + } |
| 96 | + |
| 97 | + const axes = |
| 98 | + `<line x1="${px0}" y1="${py0}" x2="${px1}" y2="${py0}" stroke="${PLOT.muted}" stroke-width="1.5"/>` + |
| 99 | + `<line x1="${px0}" y1="${py1}" x2="${px0}" y2="${py0}" stroke="${PLOT.muted}" stroke-width="1.5"/>`; |
| 100 | + |
| 101 | + const title = spec.title |
| 102 | + ? `<text x="${(px0 + px1) / 2}" y="20" fill="${PLOT.ink}" font-size="13" text-anchor="middle" font-family="${PLOT_SANS}" font-weight="600">${escapeHtml(spec.title)}</text>` |
| 103 | + : ''; |
| 104 | + |
| 105 | + return `<svg class="plot-svg" viewBox="0 0 ${W} ${H}" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${escapeHtml(spec.title || spec.kind)}">${title}${grid}${axes}${ticks}${series}</svg>`; |
| 106 | +} |
| 107 | + |
13 | 108 | export type BoardTab = 'memory' | 'plot' | 'ram'; |
14 | 109 |
|
15 | 110 | export class BoardView { |
@@ -211,9 +306,10 @@ export class BoardView { |
211 | 306 | '<div class="board-empty"><p>No plot yet. Call <code>scatter(x, y)</code> or <code>line(x, y)</code> to draw one.</p></div>'; |
212 | 307 | return; |
213 | 308 | } |
214 | | - // Phase C fills this with real SVG. For now render the spec as a list. |
| 309 | + // Draw every plot as a real SVG chart (scatter = dots, line = connected |
| 310 | + // trend). Each keeps its title so the student can see what they plotted. |
215 | 311 | const items = this.plots |
216 | | - .map((p) => `<div class="plot-card"><strong>${escapeHtml(p.title || p.kind)}</strong> · ${p.points.length} points</div>`) |
| 312 | + .map((p) => `<figure class="plot-card">${plotSvg(p)}</figure>`) |
217 | 313 | .join(''); |
218 | 314 | this.plotBody.innerHTML = `<div class="plot-cards">${items}</div>`; |
219 | 315 | } |
|
0 commit comments