@helping-ai-workflow/md2doc 3.12.1 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,26 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * The canvas drawing layer.
4
+ * The editor's own marks, and the brush glyphs.
5
5
  *
6
- * The nine functions moved out of `wave-ui.js`, behaviour unchanged line for
7
- * line -- this module is a pure move, not a rewrite. The interface is a
8
- * factory: `wave-ui.js` hands in its own `document`, `geometry`, `codec` and
9
- * `SIZES` (plus a handful of small helpers/constants the moved functions
10
- * turned out to depend on -- see task-1-report.md for the exact list), and
11
- * gets back a set of drawing functions closed over those dependencies.
6
+ * Since the wave redesign (spec §3) the WaveDrom engine draws the waveform
7
+ * itself (lib/editor/wave-canvas.js). This module draws only what sits on
8
+ * top of it — `createLayer`, the interaction layer — plus the toolbar's
9
+ * per-brush glyphs (`createDrawer().levelIcon`) and the small brick model
10
+ * those glyphs and wave-ui.js's bus test share (`brickOf`, `isBus`,
11
+ * `isClock`, `brickPath`). The hand-drawn canvas that used to live here
12
+ * (renderCanvas and its lane, name, ruler, band and edge painters) was
13
+ * removed in Task 6a: four of the seven defects in spec §5 were that second
14
+ * picture disagreeing with the engine's.
12
15
  *
13
- * This layer does not require `wave-panels.js` (the dependency direction is
14
- * one-way, see the plan's File Structure). Any state the drawing functions
15
- * need from the dialog is passed in per call, either via an explicit
16
- * parameter (the SVG element being drawn into) or via the `state` parameter,
17
- * a plain data object the caller builds fresh for each call.
16
+ * Every dependency is handed in (the document, geometry, the SVG namespace);
17
+ * this module names no global and requires nothing.
18
18
  */
19
19
 
20
20
  function createDrawer(deps) {
21
21
  const d = deps.d;
22
22
  const geometry = deps.geometry;
23
- const codec = deps.codec;
24
- const SIZES = deps.SIZES;
25
23
  const SVGNS = deps.SVGNS;
26
- const BRUSHES = deps.BRUSHES;
27
- const labelsOf = deps.labelsOf;
28
24
 
29
25
  /**
30
26
  * What wavedrom 3.5.0 actually draws for one level, by its own brick id.
@@ -74,8 +70,9 @@ function createDrawer(deps) {
74
70
 
75
71
  /**
76
72
  * The SVG path for one cell's picture, in LOCAL coordinates (0,0 is the
77
- * cell's own top-left; `d.createElementNS` callers translate it onto the
78
- * real canvas — see `drawLane`'s `translatePath`).
73
+ * cell's own top-left). Its one production caller since Task 6a is
74
+ * `levelIcon`, which only ever asks for the flat case; the ramp case is
75
+ * kept because test/wave-draw.test.js pins it against the engine's skin.
79
76
  *
80
77
  * A real level change is not a vertical line: the engine holds the OLD
81
78
  * level until `slewStartRatio` into the cell, ramps to the NEW level, and
@@ -122,76 +119,9 @@ function createDrawer(deps) {
122
119
  ' L' + width + ',' + yNow;
123
120
  }
124
121
 
125
- /**
126
- * The set of brick-id PAIRS `brickPath`'s straight ramp applies to.
127
- *
128
- * v3.6.0 Task 7 fix round 2 (ruling R24): this used to be a DENY-list
129
- * (exclude bus/xxx/clock, ramp everything else), which silently opted
130
- * `zzz`/`uuu`/`ddd` into the same straight-line ramp shape as `000`/`111`
131
- * even though nobody had measured whether the engine actually draws them
132
- * that way. It does not. MEASURED against `node_modules/wavedrom` 3.5.0's
133
- * `waveSkin` — every brick a transition touching `z`/`u`/`d` can produce:
134
- *
135
- * 0mz "m0,20 3,0 C 10,10 15,10 20,10" curve; reaches mid only at x=20 (ratio 0.5)
136
- * 1mz "m0,0 3,0 c 7,10 12,10 17,10" curve
137
- * zm1 "M0,10 6,10 9,0 20,0" ramp starts at x=6 (0.15), not x=3
138
- * zm0 "m0,10 6,0 3,10 11,0" same: ramp 6->9, not 3->9
139
- * 0mu "m0,20 3,0 C 7,10 10.107603,0 20,0" curve
140
- * um0 "m0,0 3,0 6,20 11,0" straight 3->9 -- matches ours by coincidence
141
- * 0md "m8,20 10,0" a stub, not a ramp at all
142
- * 1mu "M0,0 5,0" a stub, not a ramp at all
143
- *
144
- * Only `0m1`/`1m0` (see `brickPath`'s own comment) are the straight
145
- * `slewStartRatio`-to-`slewEndRatio` ramp this file draws. Everything
146
- * else touching `z`/`u`/`d` either curves, starts its ramp at a different
147
- * ratio, or isn't a ramp at all, so this is now an ALLOW-list of exactly
148
- * the measured, straight case (`000`<->`111`) rather than a deny-list of
149
- * the shapes known not to work — a deny-list opts every brick nobody has
150
- * measured yet INTO unverified new behaviour by default; an allow-list
151
- * makes the next brush someone adds default back to the pre-existing
152
- * vertical `<line>` instead. Bus (hexagon) and `xxx` (box) already carry
153
- * their own slanted/boxed edges and stay out of scope the same way; clock
154
- * bricks draw a whole period with their own sharp vertical edges built in
155
- * (MEASURED: `pclk`/`nclk` are plain verticals, `'M0,20 0,0 20,0'`/
156
- * `'m0,0 0,20 20,0'`, no ramp at all) and never reach here (`drawLane`'s
157
- * `!isClock` guard on both sides).
158
- *
159
- * Deliberately checked on the BRICK ids, not on the level chars
160
- * `lineLevelOf` returns: `lineLevelOf('uuu') === '1'`, same as `'111'`,
161
- * so a check written against the level chars would let `uuu` straight
162
- * back through this allow-list.
163
- */
164
- function isRampPair(a, b) {
165
- return (a === '000' || a === '111') && (b === '000' || b === '111');
166
- }
167
-
168
- /**
169
- * `brickPath`'s own `yFor` wants a level char, not a brick id. Now that
170
- * `isRampPair` (R24) only ever admits `000`/`111`, this only ever sees
171
- * those two — it does not need to (and must not silently claim to) cover
172
- * `zzz`/`uuu`/`ddd` any more.
173
- */
174
- function lineLevelOf(brick) {
175
- return brick === '111' ? '1' : '0';
176
- }
177
-
178
- /**
179
- * `brickPath` returns LOCAL coordinates (matching how
180
- * `test/wave-draw.test.js` calls it directly) — every other shape in this
181
- * file is drawn in absolute canvas coordinates with no `transform`
182
- * attribute anywhere, so this shifts the returned path onto the cell it
183
- * is actually drawn at rather than introducing the one `transform` this
184
- * file would otherwise have.
185
- */
186
- function translatePath(pathStr, dx, dy) {
187
- return pathStr.replace(/(-?[0-9.]+),(-?[0-9.]+)/g, function (_, x, y) {
188
- return (Number(x) + dx) + ',' + (Number(y) + dy);
189
- });
190
- }
191
-
192
122
  /**
193
123
  * Which vertical band a brick's own flat run sits at, in PIXELS. Lifted
194
- * out of `drawLane` (v3.6.0 Task 10) so it can also drive `levelIcon`'s
124
+ * out of the old hand-drawn lane painter (v3.6.0 Task 10) to drive `levelIcon`'s
195
125
  * height decision — `uuu`/`ddd` painting at the SAME height as `111`/`000`
196
126
  * (not at mid, which is `brickPath`'s own `yFor` default for any char it
197
127
  * does not explicitly recognise) is a fact this function already owns,
@@ -204,17 +134,14 @@ function createDrawer(deps) {
204
134
  return [hi, lo];
205
135
  }
206
136
 
207
- /** The bus hexagon's point list — lifted out of `drawLane`'s bus branch
208
- * (v3.6.0 Task 10) so `levelIcon`'s bus glyph is the exact same hexagon,
209
- * not a second hand-drawn approximation of it. */
137
+ /** The bus hexagon's point list (v3.6.0 Task 10), `levelIcon`'s bus glyph. */
210
138
  function busPolygonPoints(x0, x1, hi, lo, mid, s) {
211
139
  return (x0 + s) + ',' + hi + ' ' + (x1 - s) + ',' + hi + ' ' +
212
140
  x1 + ',' + mid + ' ' + (x1 - s) + ',' + lo + ' ' +
213
141
  (x0 + s) + ',' + lo + ' ' + x0 + ',' + mid;
214
142
  }
215
143
 
216
- /** The clock brick's stepped edge — lifted out of `drawLane`'s `isClock`
217
- * branch (v3.6.0 Task 10), same reasoning as `busPolygonPoints`. */
144
+ /** The clock brick's stepped edge (v3.6.0 Task 10), `levelIcon`'s clock glyph. */
218
145
  function clockStepPath(brick, x0, x1, hi, lo) {
219
146
  const xm = (x0 + x1) / 2;
220
147
  return brick === 'nclk'
@@ -224,9 +151,8 @@ function createDrawer(deps) {
224
151
  ' L' + xm + ',' + hi + ' L' + x1 + ',' + hi;
225
152
  }
226
153
 
227
- /** The `P`/`N` explicit-edge arrowhead — lifted out of `drawLane`'s
228
- * `isClock` branch (v3.6.0 Task 10). `null` for any other level char,
229
- * same as the inline `if` it replaces. */
154
+ /** The `P`/`N` explicit-edge arrowhead (v3.6.0 Task 10). `null` for any
155
+ * other level char. */
230
156
  function clockArrowPath(edgeLevel, x0, xm, hi, lo) {
231
157
  if (edgeLevel !== 'P' && edgeLevel !== 'N') return null;
232
158
  const sx = (xm - x0) / 20;
@@ -238,8 +164,7 @@ function createDrawer(deps) {
238
164
  ' L' + (x0 + 3 * sx) + ',' + baseY + ' Z';
239
165
  }
240
166
 
241
- /** The gap `|` marker's double chevron — lifted out of `drawLane`'s gap
242
- * loop (v3.6.0 Task 10), same reasoning as `busPolygonPoints`. */
167
+ /** The gap `|` marker's double chevron (v3.6.0 Task 10), `levelIcon`'s gap glyph. */
243
168
  function gapMarkPath(xm, hi, lo, mid) {
244
169
  return 'M' + (xm - 5) + ',' + (lo + 3) + ' L' + (xm + 1) + ',' + mid +
245
170
  ' L' + (xm - 5) + ',' + (hi - 3) +
@@ -250,13 +175,12 @@ function createDrawer(deps) {
250
175
  /**
251
176
  * The toolbar's per-brush glyph — v3.6.0 Task 10.
252
177
  *
253
- * Every shape below is built from the SAME functions `drawLane` calls to
254
- * paint the real canvas (`brickOf`, `bandOf`, `brickPath`, `isBus`,
255
- * `isClock`, `busPolygonPoints`, `clockStepPath`, `clockArrowPath`,
256
- * `gapMarkPath`) — never a second, hand-drawn set. That is the one rule
257
- * this task exists to enforce: change a brush's on-canvas appearance and
258
- * every toolbar icon updates with it, because there is only one place
259
- * that appearance is decided.
178
+ * Every shape below is built from the brick helpers above (`brickOf`,
179
+ * `bandOf`, `brickPath`, `isBus`, `isClock`, `busPolygonPoints`,
180
+ * `clockStepPath`, `clockArrowPath`, `gapMarkPath`). They were the old
181
+ * hand-drawn canvas's painters; since Task 6a the engine draws the canvas,
182
+ * so these glyphs are a simplified picture of each brush, not a copy of
183
+ * what the canvas shows.
260
184
  *
261
185
  * A glyph is a single cell with no previous level, so `brickPath` is only
262
186
  * called here with the SAME char as both `level` and `prevLevel` — its
@@ -297,8 +221,7 @@ function createDrawer(deps) {
297
221
  if (level === '|') {
298
222
  // `brickOf('|')` itself falls through to its own `'xxx'` default (it
299
223
  // has no case for `|` — production code never asks it: `levelsOf`
300
- // erases `|` before `brickOf` ever sees it, per `drawLane`'s own
301
- // `gaps` comment above). So this has to be checked ahead of the
224
+ // erases `|` before `brickOf` ever sees it). So this has to be checked ahead of the
302
225
  // `brick === 'xxx'` branch below, or the gap brush would silently
303
226
  // draw the `x` hatch instead of its own chevron marker.
304
227
  const gap = d.createElementNS(SVGNS, 'path');
@@ -349,1122 +272,206 @@ function createDrawer(deps) {
349
272
  return svg;
350
273
  }
351
274
 
352
- /**
353
- * The hand-drawn waveform.
354
- *
355
- * Every cycle's picture comes from `codec.levelsOf` through `brickOf`, and
356
- * from nowhere else — constraint 3. The levels and the bricks are both
357
- * published on the element (`data-levels-N`, `data-bricks-N`) so the
358
- * agreement with the preview beside it is something a test can measure
359
- * rather than something this comment can promise: the preview's own SVG
360
- * names its bricks in its `<use xlink:href="#…">` list.
361
- *
362
- * v3.6.0 Task 1: this function WRITES BACK through two of its own
363
- * parameters, not only through the DOM it builds — `state.cursor` and
364
- * `state.selection` are clamped in place (a document that got shorter can
365
- * leave either pointing past the end), and the return value is a brand new
366
- * `<svg>` that replaces the one `canvas` named on the way in. The caller in
367
- * wave-ui.js is expected to read `state.cursor`/`state.selection` back into
368
- * its own locals and reassign its own `canvas` from the return value —
369
- * see every `wds`/`canvas = drawer.renderCanvas(...)` call site there. The
370
- * round trip is the price of the split: the alternative is this drawing
371
- * layer holding a live reference to the dialog's own variables, which is
372
- * exactly the coupling moving it into its own module is meant to remove.
373
- */
374
- /**
375
- * The name column, drawn on the canvas itself.
376
- *
377
- * The lane rail (`wave-panels.js`) already shows every name — this is
378
- * deliberate duplication, not an oversight. The rail is the OPERATING
379
- * surface (select, reorder, rename); the canvas is a MIRROR of what the
380
- * engine's own preview renders. The engine's output has names on it, so a
381
- * user comparing the hand-drawn canvas against the preview beside it
382
- * should not have to align two separate columns by eye to know which row
383
- * is which.
384
- *
385
- * v3.6.0 Task 8 (R? — see task-8-report.md): these are flat children of
386
- * the canvas `<svg>`, appended in the same paint pass as every brick and
387
- * edge, which is exactly what makes them a landmine for
388
- * `test/editor-journey.test.js`'s T6b: its `paintedCounts` classifies a
389
- * shape into a lane's row purely by the shape's vertical center (`cy`),
390
- * with NO x-bound — so any shape whose `cy` falls inside a lane's row
391
- * band counts toward that lane's painted-shape total, wherever it sits on
392
- * the x axis. A lane-name label is drawn at exactly that lane's row
393
- * center, so without an exclusion it would inflate every lane's count by
394
- * one and desync `paintedCounts` from `expectedCounts` (the engine's own
395
- * run count) on every fixture that has named lanes — which is all of
396
- * them. `.ed-wave-lane-label` / `.ed-wave-group-label` are NOT in T6b's
397
- * `shapes` exclusion list (`ed-wave-grid` / `-selection` / `-cursor` /
398
- * `-gap` / `-buslabel` / `-edge`) as of this commit — T6b's filter needs
399
- * to grow those two classes before that suite can pass. Per this task's
400
- * brief: "Do not change T6b yourself" — flagged in task-8-report.md
401
- * instead of edited here.
402
- */
403
- const NAME_GAP = 10;
404
- const GROUP_INDENT = 12;
405
-
406
- function drawNames(svg, doc, layout) {
407
- for (let i = 0; i < layout.lanes.length; i += 1) {
408
- const row = layout.lanes[i];
409
- const name = typeof row.lane.name === 'string' ? row.lane.name : '';
410
- if (name === '') continue;
411
- const t = d.createElementNS(SVGNS, 'text');
412
- t.setAttribute('class', 'ed-wave-lane-label');
413
- t.setAttribute('x', layout.nameColWidth - NAME_GAP);
414
- t.setAttribute('y', row.y + row.height / 2);
415
- t.setAttribute('text-anchor', 'end');
416
- t.setAttribute('dominant-baseline', 'middle');
417
- t.appendChild(d.createTextNode(name));
418
- svg.appendChild(t);
419
- }
420
- }
421
-
422
- /**
423
- * The group titles — v3.5.0 measured against the pinned engine (and
424
- * `wave-geometry.js`'s own `groupSpansOf` comment repeats it): a group
425
- * title occupies horizontal space in the name column, never a row of its
426
- * own. So each title here is one vertical (`writing-mode: vertical-rl`,
427
- * matching the rail's own `.ed-wave-group` styling in `lib/md2doc.js`)
428
- * label spanning its lanes' rows, indented from the LEFT edge of the name
429
- * column by nesting depth — depth 1 (outermost) sits in the leftmost
430
- * `GROUP_INDENT`-wide strip, each deeper level one strip further right,
431
- * the same left-to-right order the rail's own tags use. A lane's own name
432
- * always right-aligns at the same `nameColWidth - NAME_GAP` regardless of
433
- * depth (see `drawNames`); Task 8's measurement (task-8-report.md) checks
434
- * the longest fixture name still clears that fixed edge even at the
435
- * deepest strip a group label reserves.
436
- */
437
- function drawGroupLabels(svg, doc, layout) {
438
- const spans = geometry.groupSpansOf(doc, layout);
439
- for (let i = 0; i < spans.length; i += 1) {
440
- const span = spans[i];
441
- const from = layout.lanes[span.from];
442
- const to = layout.lanes[span.to];
443
- const t = d.createElementNS(SVGNS, 'text');
444
- t.setAttribute('class', 'ed-wave-group-label');
445
- t.setAttribute('x', (span.depth - 0.5) * GROUP_INDENT);
446
- t.setAttribute('y', (from.y + to.y + to.height) / 2);
447
- t.setAttribute('text-anchor', 'middle');
448
- t.setAttribute('dominant-baseline', 'middle');
449
- t.appendChild(d.createTextNode(span.title));
450
- svg.appendChild(t);
451
- }
452
- }
453
-
454
- /**
455
- * The cycle ruler and the head/foot bands.
456
- *
457
- * v3.5.0 shipped eight WaveJSON banner fields (`head.text`/`tick`/`tock`/
458
- * `every` and the four `foot.*` equivalents) that the hand-drawn canvas
459
- * never painted — filling one in changed nothing a user could see without
460
- * opening the preview. v3.6.0 Task 6 already made room for this (`layout`'s
461
- * `originY`/`rulerHeight`/`headTextHeight`/`footHeight`); this task paints
462
- * into that room.
463
- *
464
- * v3.6.0 Task 9 fix round 1 (ruling R29): foot gets TWO stacked bands, the
465
- * same as head — `footHeight` (the tick band, adjacent to the last lane)
466
- * and `footTextHeight` (the title band, stacked beyond it). Before this
467
- * fix `footHeight` was the only band and `foot.text`/`foot.tick` shared
468
- * one y, so a document setting both drew them on top of each other. The
469
- * engine agrees a document can set both at once (`parse-config.js`'s
470
- * `yf0`/`yf1`, mirroring `yh0`/`yh1` for head) — see
471
- * `wave-geometry.js`'s own comment above `FOOT_TEXT_HEIGHT` for why the
472
- * STRUCTURE (two bands) mirrors the engine while the pixel heights do not.
473
- *
474
- * `tick` labels sit ON cycle boundaries (`cycles + 1` of them); `tock`
475
- * labels sit at cycle CENTRES (`cycles` of them) — MEASURED against
476
- * `node_modules/wavedrom` 3.5.0's `lib/render-marks.js` `renderMarks()`:
477
- * `ticktock(lane, 'head', 'tick', 0, mmstep, -5, marks + 1)` vs
478
- * `ticktock(lane, 'head', 'tock', mmstep / 2, mmstep, -5, marks)` — the
479
- * `mmstep / 2` starting x and the `marks` (not `marks + 1`) length are the
480
- * engine's own distinction, not one invented here.
481
- *
482
- * `every` is "every N cycles" — but MEASURED against that same
483
- * `ticktock()`, the engine's own filter is
484
- * `(i + offset) % cxt[ref1].every != 0`, anchored to the SAME base the
485
- * label values are built from (`offset = Number(head.tick)`, etc), not a
486
- * bare `i % every`. Live-rendered check (dumped, not recalled):
487
- * `{signal:[{name:'a',wave:'0101'}],head:{tick:3,every:2}}` keeps exactly
488
- * two ticks, `"4"` and `"6"` (`i=1,3`; `(1+3)%2===0`) — `i=0,2,4` all drop
489
- * (`(0+3)%2===1`). A bare `c % every` would instead keep `i=0,2,4`
490
- * (values 3/5/7) — three ticks, not two, and the wrong ones. Pinned by
491
- * `test/wave-draw.test.js`'s "every 錨在 (index+base)" case.
492
- *
493
- * Read-only with respect to the document: an absent field means no mark,
494
- * full stop. `setBannerField` (wave-codec.js) is the only place that
495
- * writes these keys, and it already deletes the key rather than writing
496
- * `''` — the engine's own `captext` uses a truthy test while its
497
- * `ticktock` uses `=== undefined`, so a stray `''` here would make the
498
- * latter think the field was "set, draw the default ruler". This layer
499
- * must not "fix" that by normalizing anything itself.
500
- *
501
- * v3.6.0 Task 9 fix round 2 (ruling R30): the tick/tock boundary count and
502
- * pitch come from `diagramCycleCount`/`diagramPitch` below — the DIAGRAM's
503
- * own extent — never from a lane's own `cycles`/`cycleWidth`/`originX`.
504
- * See those two functions' own comment for why: `layout.cycles` counts
505
- * CELLS, not diagram cycles, and a lane's own `cycleWidth`/`originX` are
506
- * deliberately distorted by THAT lane's `period`/`phase`/`hscale` (Task 4)
507
- * — right for painting that one lane's bricks, wrong for an overlay drawn
508
- * once for the whole canvas.
509
- */
510
- function drawRuler(svg, doc, layout) {
511
- drawBand(svg, doc, layout, 'head');
512
- drawBand(svg, doc, layout, 'foot');
513
- }
514
-
515
- /**
516
- * The diagram-wide cycle pitch (px per diagram cycle) and how many
517
- * boundaries that pitch divides `layout.width` into — shared by the ruler
518
- * (`drawBand`, below) and the cycle grid (`renderCanvas`'s own "cycle
519
- * grid" section), so there is exactly one place that computes it rather
520
- * than two that can drift apart.
521
- *
522
- * MEASURED (`period: 4`, `head:{tick:0}`, against `node_modules/wavedrom`
523
- * 3.5.0): the engine draws 17 ticks, values 0..16, 40px apart, on a 700px
524
- * canvas — not `layout.cycles + 1` (5), which is what a lane's own CELL
525
- * count gives. `layout.cycles` is `codec.levelsOf(wave).length`, i.e. a
526
- * count of CELLS, and with `period: 4` one cell IS four diagram cycles —
527
- * the same failure family this repo's CLAUDE.md already names ("node
528
- * string indices are CELLS, while the drawing thinks in CYCLES"),
529
- * invisible before Task 4 because cells and diagram cycles were the same
530
- * number for every period-1 document. A per-lane `row.cycleWidth`/
531
- * `row.originX` does not fix it either (it is a PITCH bug, not just a
532
- * COUNT bug) — those already carry that same lane's `period`, so reading
533
- * lane 0's alone is right for lane 0's own bricks and wrong for a ruler
534
- * drawn once, diagram-wide.
535
- *
536
- * Derived from `layout.width` — never a second cycle-count field of its
537
- * own — so the ruler is structurally incapable of disagreeing with the
538
- * canvas's own extent: `(width - nameColWidth) / (cycleWidth * hscale)`.
539
- * Checked against the engine on two fixtures before landing:
540
- * `period: 4` → (888 − 120) / 48 + 1 = 17 ticks (engine: 17)
541
- * `hscale: 2, period: 1` → (504 − 120) / 96 + 1 = 5 ticks (engine: 5)
542
- * `hscale` scales the PITCH (the engine's own `mmstep = 2 * hscale * xs`),
543
- * not the count, which is why it multiplies `cycleWidth` here rather than
544
- * dividing the span by it. `Math.round` absorbs the float noise a
545
- * fractional `laneHeight`/`cycleWidth`/`nameColWidth` size leaves in
546
- * `layout.width` (each lane's own `cycleWidth`/`originX` is one
547
- * multiplication away from exact) — the true count is structurally an
548
- * integer (`layoutOf` only ever widens `width` by whole `period`-scaled
549
- * cells), so rounding cannot paper over a real off-by-one the way it
550
- * would for a value with no reason to land on an integer.
551
- */
552
- function diagramPitch(layout) {
553
- return layout.cycleWidth * layout.hscale;
554
- }
555
-
556
- function diagramCycleCount(layout) {
557
- const pitch = diagramPitch(layout);
558
- if (!(pitch > 0)) return 0;
559
- const span = layout.width - layout.nameColWidth;
560
- if (!(span > 0)) return 0;
561
- return Math.round(span / pitch);
562
- }
563
-
564
- function drawBand(svg, doc, layout, which) {
565
- const b = (doc !== null && typeof doc === 'object' &&
566
- doc[which] !== null && typeof doc[which] === 'object') ? doc[which] : null;
567
- if (b === null) return;
568
- const isHead = which === 'head';
569
- // Head: title band (0..headTextHeight) stacked ABOVE the ruler band
570
- // (headTextHeight..originY). Foot: tick band (originY+lanes*laneHeight
571
- // .. that + footHeight) stacked BELOW the title band beyond it (R29) —
572
- // the mirror image, title band on the outer edge, tick band against the
573
- // lanes, matching the engine's own captext y (further from the lanes
574
- // than its ticktock y, both for head and for foot).
575
- const textY = isHead
576
- ? layout.headTextHeight / 2
577
- : layout.height - layout.footTextHeight / 2;
578
- const scaleY = isHead
579
- ? layout.headTextHeight + layout.rulerHeight / 2
580
- : layout.height - layout.footTextHeight - layout.footHeight / 2;
581
-
582
- if (typeof b.text === 'string' && b.text !== '') {
583
- const t = d.createElementNS(SVGNS, 'text');
584
- t.setAttribute('class', isHead ? 'ed-wave-head-text' : 'ed-wave-foot-text');
585
- t.setAttribute('x', layout.width / 2);
586
- t.setAttribute('y', textY);
587
- t.setAttribute('text-anchor', 'middle');
588
- t.appendChild(d.createTextNode(b.text));
589
- svg.appendChild(t);
590
- }
275
+ return {
276
+ brickOf: brickOf,
277
+ isClock: isClock,
278
+ isBus: isBus,
279
+ brickPath: brickPath,
280
+ levelIcon: levelIcon,
281
+ };
282
+ }
591
283
 
592
- const every = (typeof b.every === 'number' && isFinite(b.every) && b.every > 0)
593
- ? Math.floor(b.every) : 1;
594
- // No lanes, no diagram to rule off of — same "draw nothing" this branch
595
- // already had before round 2 (the guard used to be `row === null`, a
596
- // per-lane check; the diagram-wide replacement is `layout.lanes.length`
597
- // itself, since `diagramCycleCount` has no lane to read at all here).
598
- if (layout.lanes.length === 0) return;
599
- const pitch = diagramPitch(layout);
600
- const boundaries = diagramCycleCount(layout);
601
284
 
602
- if (b.tick !== undefined) {
603
- const base = Number(b.tick);
604
- for (let c = 0; c <= boundaries; c += 1) {
605
- if ((c + base) % every !== 0) continue;
606
- const t = d.createElementNS(SVGNS, 'text');
607
- t.setAttribute('class', 'ed-wave-ruler-tick');
608
- t.setAttribute('x', layout.nameColWidth + c * pitch);
609
- t.setAttribute('y', scaleY);
610
- t.setAttribute('text-anchor', 'middle');
611
- t.appendChild(d.createTextNode(String(base + c)));
612
- svg.appendChild(t);
613
- }
614
- }
615
- if (b.tock !== undefined) {
616
- const base = Number(b.tock);
617
- for (let c = 0; c < boundaries; c += 1) {
618
- if ((c + base) % every !== 0) continue;
619
- const t = d.createElementNS(SVGNS, 'text');
620
- t.setAttribute('class', 'ed-wave-ruler-tock');
621
- t.setAttribute('x', layout.nameColWidth + (c + 0.5) * pitch);
622
- t.setAttribute('y', scaleY);
623
- t.setAttribute('text-anchor', 'middle');
624
- t.appendChild(d.createTextNode(String(base + c)));
625
- svg.appendChild(t);
626
- }
627
- }
285
+ /**
286
+ * The interaction layer: only the editor's own marks, painted into a separate
287
+ * SVG laid over the engine's drawing. Everything is in LAYER-LOCAL PIXELS;
288
+ * the caller builds `view.cellRect` / `view.anchor` and subtracts the layer's
289
+ * own client rect, so this module needs neither the canvas nor the geometry.
290
+ *
291
+ * view.cells[i] = { laneIndex, cycles, transitions?: number[] } -- `transitions`
292
+ * lists the cell indices (as `view.anchor` takes them) where a dot belongs.
293
+ * state.selection = { laneIndex, from, to } in cycles; state.cursor and
294
+ * state.hover = { laneIndex, cycle }; state.hotDot = { laneIndex, cell }.
295
+ * state.rulerPlus = the cycle boundary (0..cycles) the ruler's + sits on.
296
+ * state.rowHover = a lane row lit whole; state.rowHoverTo (optional) the last
297
+ * row of a lit span (a group's). state.dropLine = { y } in layer px.
298
+ * view.bodyTop (optional, default 0) = where the lanes start in the layer.
299
+ *
300
+ * Task 9, the edges' hit targets (spec section 4.5), all carrying
301
+ * `data-edge-index` (the entry's index in `doc.edge`):
302
+ * state.edgeHits = [{ index, d, transform, width }] -- the engine's own path
303
+ * `d` with the matrix that maps it into the layer and the stroke width in
304
+ * that path's units, drawn as a transparent wide stroke UNDER the dots, so
305
+ * an anchor an edge already uses still starts a new drag from its dot;
306
+ * state.labelHits = [{ index, x, y, width, height }] -- the engine's label
307
+ * boxes (an edge's label, a point note's text), OVER the dots;
308
+ * state.loopHits = [{ index, x, y }] -- an unlabelled self-loop's anchor: its
309
+ * path has zero length and no hit area, so a small ring stands in for it.
310
+ * state.selectedEdge = { d, transform?, from, to } draws the selection and
311
+ * its two end handles, each a transparent ring (`ed-wave-edge-end-hit`,
312
+ * the press target) under its visible mark; state.selectedNote = { x, y,
313
+ * width, height } outlines a selected point note.
314
+ * `paint` replaces the layer's children on every call.
315
+ */
316
+ function createLayer(deps) {
317
+ const d = deps.d;
318
+ const SVGNS = deps.SVGNS;
319
+ const DOT_R = 3;
320
+ const DOT_R_HOT = 5;
321
+ const HIT_R = 8;
322
+ const END_R = 4;
323
+ const END_HIT_R = 8;
324
+ const LOOP_HIT_R = 4;
325
+ const PLUS_HIT_R = 9;
326
+ const PLUS_LIFT = 5;
327
+
328
+ function mk(tag, cls, attrs) {
329
+ const el = d.createElementNS(SVGNS, tag);
330
+ if (cls) el.setAttribute('class', cls);
331
+ Object.keys(attrs || {}).forEach(function (k) { el.setAttribute(k, String(attrs[k])); });
332
+ return el;
628
333
  }
629
334
 
630
- function renderCanvas(canvas, doc, layout, state) {
631
- // v3.5.0 Task 11: the data-label field's node sits beside the canvas
632
- // SVG inside `canvasWrap` (see `openDataEdit`), and the wipe two lines
633
- // below (`canvasWrap.textContent = ''`) would tear it out from under
634
- // the user on the very next cursor move if this did not retire it
635
- // first — every path that reaches this function counts, not only
636
- // `render()`, which is why this lives HERE and not only next to
637
- // `groupRename.retire()` in `render()` itself.
638
- if (state.dataEdit !== null) state.dataEdit.retire();
639
- // Every repaint REPLACES the canvas element, so a canvas that had the
640
- // keyboard loses it to `document.body` the instant it is detached —
641
- // exactly the state F7 and T6h exist to prevent, and here it would fire
642
- // on every arrow key. `render()`'s own `data-focus-key` restore covers
643
- // the full-repaint path; this covers the three that call renderCanvas
644
- // directly (a drag's two, and the keyboard's own), so the property holds
645
- // for the canvas no matter which road the repaint came down.
646
- const hadFocus = canvas !== null && d.activeElement === canvas;
647
- // The cursor is an index pair, and the document under it can get shorter:
648
- // deleting cycles or removing the bottom lane leaves it pointing past the
649
- // end. Clamped HERE, where the layout that decides what "past the end"
650
- // means is already in hand — so nothing downstream ever has to ask.
651
- if (state.cursor !== null) {
652
- if (layout.lanes.length === 0 || layout.cycles === 0) {
653
- state.cursor = null;
654
- } else {
655
- state.cursor = {
656
- laneIndex: Math.max(0, Math.min(state.cursor.laneIndex, layout.lanes.length - 1)),
657
- cycle: Math.max(0, Math.min(state.cursor.cycle, layout.cycles - 1)),
658
- };
335
+ function paint(layer, view, state) {
336
+ while (layer.firstChild) layer.removeChild(layer.firstChild);
337
+ // Fake DOMs without removeChild/firstChild expose `children` only.
338
+ if (layer.children && layer.children.length) layer.children.length = 0;
339
+ const cells = view.cells || [];
340
+ const first = cells.length > 0 ? cells[0] : null;
341
+ let maxCycles = 0;
342
+ cells.forEach(function (c) { if (c.cycles > maxCycles) maxCycles = c.cycles; });
343
+ const on = new Set();
344
+ if (state.hover) on.add(state.hover.cycle);
345
+ if (state.cursor) on.add(state.cursor.cycle);
346
+
347
+ // ruler: one number per cycle, over the first lane's cell columns
348
+ if (first !== null) {
349
+ for (let c = 0; c < maxCycles; c += 1) {
350
+ const r = view.cellRect(first.laneIndex, c);
351
+ const t = mk('text', 'ed-wave-ruler-num' + (on.has(c) ? ' is-on' : ''), {
352
+ x: r.x + r.width / 2, y: view.rulerY, 'text-anchor': 'middle', 'data-cycle': c,
353
+ });
354
+ t.appendChild(d.createTextNode(String(c)));
355
+ layer.appendChild(t);
659
356
  }
660
- }
661
- // …and `selection` with it, which is the other half of the same fact and
662
- // was left out of the first cut. Measured before this: Shift-select four
663
- // cycles, press 刪除 cycle, and a one-cycle document was left with the
664
- // cursor on cycle 1, no visible box, 複製 reporting「複製了 4 個 cycle」
665
- // and 貼上(插入)growing the document back to five. The toolbar was
666
- // acting on a range the drawing no longer had anywhere to put.
667
- //
668
- // The two axes are not treated alike, and that is the point: a CYCLE
669
- // range that overshoots is the same range in a shorter document, so it
670
- // clamps; a LANE that is gone is not a lane the user can have meant, so
671
- // the selection goes rather than silently moving to whichever lane
672
- // inherited the index. (`lane ✕` already nulls it on its own path; this
673
- // catches the undo/redo of that.)
674
- //
675
- // It does NOT catch every route, and the earlier wording here said it
676
- // did. MEASURED: `+` at 0, drag-select cycles 1-3 on the new lane 0,
677
- // then Ctrl+Z — the selection box and the rail highlight both stay on
678
- // row 0, which is now `clk`. The trace-derived marks above fix the
679
- // CURSOR for that case; the selection is still carried by row number,
680
- // so it lands on whoever inherited the row. The cost is confined to the
681
- // highlight and to where the next Shift+arrow anchors, because every
682
- // cycle operation is document-wide — but it is a real gap, not a
683
- // covered one.
684
- if (state.selection !== null) {
685
- if (layout.lanes.length === 0 || layout.cycles === 0 ||
686
- state.selection.laneIndex < 0 || state.selection.laneIndex >= layout.lanes.length) {
687
- state.selection = null;
688
- } else {
689
- // v3.6.0 Task 14: `laneTo` — the far end of the lane range — is
690
- // rebuilt here, not merely copied. This function REPLACES the
691
- // caller's selection object (`wave-ui.js` reads it back out of the
692
- // state bag afterwards), so a key left out here is a key deleted
693
- // from the live selection, and `laneRow`'s `i <= laneTo` test would
694
- // then be comparing against `undefined` for every row — no row
695
- // highlighted, and 建立群組 disabled, after any repaint at all.
696
- // Clamped into the shortened document the same way `laneIndex` is
697
- // checked above, and never allowed below `laneIndex`, so the
698
- // normalised invariant the whole file relies on survives a delete.
699
- const lastLane = layout.lanes.length - 1;
700
- const far = typeof state.selection.laneTo === 'number'
701
- ? Math.min(state.selection.laneTo, lastLane) : state.selection.laneIndex;
702
- state.selection = {
703
- laneIndex: state.selection.laneIndex,
704
- laneTo: Math.max(state.selection.laneIndex, far),
705
- from: Math.max(0, Math.min(state.selection.from, layout.cycles - 1)),
706
- to: Math.max(0, Math.min(state.selection.to, layout.cycles - 1)),
707
- };
357
+ if (state.rulerPlus !== null && state.rulerPlus !== undefined) {
358
+ const r = view.cellRect(first.laneIndex, state.rulerPlus);
359
+ // A <g> ignores x/y: it is placed by its transform, and it carries
360
+ // its own visible glyph (styled by .ed-wave-ruler-plus in the CSS).
361
+ const plus = mk('g', 'ed-wave-ruler-plus', {
362
+ transform: 'translate(' + r.x + ',' + view.rulerY + ')', 'data-cycle': state.rulerPlus,
363
+ });
364
+ const glyph = mk('text', 'ed-wave-ruler-plus-glyph', { x: 0, y: 0, 'text-anchor': 'middle' });
365
+ glyph.appendChild(d.createTextNode('+'));
366
+ plus.appendChild(glyph);
367
+ // Task 7: the plus is pressed to insert a cycle, so it carries a
368
+ // transparent ring the size of a dot's hit circle, centred on the
369
+ // glyph (which sits on the ruler baseline, so its middle is
370
+ // PLUS_LIFT above it). The CSS gives only this ring presses.
371
+ plus.appendChild(mk('circle', 'ed-wave-ruler-plus-hit', {
372
+ cx: 0, cy: -PLUS_LIFT, r: PLUS_HIT_R, fill: 'transparent',
373
+ }));
374
+ layer.appendChild(plus);
708
375
  }
709
376
  }
710
- state.canvasWrap.textContent = '';
711
- canvas = d.createElementNS(SVGNS, 'svg');
712
- canvas.setAttribute('class', 'ed-wave-canvas');
713
- // In the Tab cycle, and therefore in the focus trap, like every other
714
- // control in here: the drawing is this dialog's main surface and was the
715
- // one thing in it a keyboard could not reach.
716
- canvas.setAttribute('tabindex', '0');
717
- canvas.setAttribute('data-focus-key', 'canvas');
718
- // This label is the one keyboard manual a screen-reader user ever gets
719
- // for this surface, so every claim in it has to be true in every state.
720
- //
721
- // v3.6.0 Task 16 fix round 3 REPLACES what round 1 put here. Round 1 had
722
- // to say "while the 關聯線 gesture is armed, ← / → stop walking one cell
723
- // and Shift stops extending", because that is what round 0's binding did.
724
- // Round 3 took the jump off the unmodified arrows (see
725
- // `handleDrawingKey`'s own comment and T9b), so the first two clauses of
726
- // this label are unconditionally true again and the mode-dependent
727
- // sentence is gone with the mode-dependence. What remains is an EXTRA
728
- // key, Alt+←/→, which is named because a key nobody can discover is a
729
- // key that does not exist for a keyboard user. Alt+↑/↓ is still
730
- // deliberately absent: it moves a LANE, is dialog-wide rather than a
731
- // drawing key, and is named in the opening status line instead.
732
- canvas.setAttribute('aria-label',
733
- '波形繪圖區:方向鍵移動游標,Shift+左右鍵選一段 cycle,' +
734
- '按電位字元(' + BRUSHES.join(' ') + ')塗上去;' +
735
- '武裝關聯線之後,Alt+左右鍵可以在轉態點之間跳');
736
- const width = Math.max(layout.width, SIZES.cycleWidth);
737
- canvas.setAttribute('width', String(width));
738
- canvas.setAttribute('height', String(Math.max(layout.height, layout.laneHeight)));
739
- canvas.setAttribute('viewBox', '0 0 ' + width + ' ' +
740
- Math.max(layout.height, layout.laneHeight));
741
- canvas.setAttribute('data-lane-count', String(layout.lanes.length));
742
- canvas.setAttribute('data-cycle-count', String(layout.cycles));
743
- // v3.6.0 Task 6 (ruling R18): the geometry the layout actually used, so a
744
- // caller measuring the rendered SVG (a puppeteer test, in particular)
745
- // reads these rather than re-deriving lane pitch / cycle pitch by
746
- // dividing `getBoundingClientRect()` by a count — that division silently
747
- // breaks the moment the canvas carries a ruler band or a name column,
748
- // because the SVG's total width/height then includes space no lane or
749
- // cycle occupies. Written straight from `layout`, never recomputed here.
750
- // `originX`/`cycleWidth` are lane 0's own (Task 4 made both per-lane via
751
- // `period`/`phase`/`hscale`); a document with no lanes falls back to the
752
- // layout-level `nameColWidth`/`cycleWidth` so the attributes are still
753
- // present (and still correct — an empty canvas has no cell to disagree).
754
- const originRow = layout.lanes.length > 0 ? layout.lanes[0] : null;
755
- canvas.setAttribute('data-origin-x', String(originRow !== null ? originRow.originX : layout.nameColWidth));
756
- canvas.setAttribute('data-origin-y', String(layout.originY));
757
- canvas.setAttribute('data-lane-height', String(layout.laneHeight));
758
- canvas.setAttribute('data-cycle-width', String(originRow !== null ? originRow.cycleWidth : layout.cycleWidth));
759
-
760
- // cycle grid
761
- // v3.6.0 Task 6: shifted by `layout.nameColWidth` — this grid is drawn
762
- // once for the whole canvas at the diagram's default (phase 0) cycle
763
- // pitch, the same base `layout.cycleWidth` every lane's own `cycleWidth`
764
- // is derived from, so `nameColWidth` (not a per-lane `originX`) is the
765
- // matching x origin for it. Left at x=0 this grid would sit under the
766
- // name column instead of under the cells it is meant to mark — the
767
- // "make room" the geometry layer already does for hit-testing has to be
768
- // visible too, or a click on a drawn cell lands in what `cellAt` now
769
- // calls the name column and does nothing.
770
- //
771
- // v3.6.0 Task 9 fix round 2 (R30): the boundary count and pitch now come
772
- // from `diagramCycleCount`/`diagramPitch` (see their own comment above
773
- // `drawBand`) instead of `layout.cycles`/`layout.cycleWidth` directly —
774
- // `layout.cycles` is a CELL count and `layout.cycleWidth` carries no
775
- // `hscale`, so under `period` or `config.hscale` this grid used to sit
776
- // at the wrong pitch (and the wrong count, under `period`) while the
777
- // bricks it is meant to mark painted at the per-lane, `hscale`-scaled
778
- // one. The engine's own grid (`render-marks.js`'s `gmarkLines`) is the
779
- // same `marks + 1` diagram-cycle boundary count the ruler uses, not a
780
- // second definition — sharing `diagramCycleCount` here is not a
781
- // convenience, it is the two DOM elements agreeing with the one thing
782
- // the engine agrees with itself.
783
- const gridPitch = diagramPitch(layout);
784
- const gridBoundaries = diagramCycleCount(layout);
785
- for (let c = 0; c <= gridBoundaries; c++) {
786
- const g = d.createElementNS(SVGNS, 'line');
787
- g.setAttribute('class', 'ed-wave-grid');
788
- g.setAttribute('x1', String(layout.nameColWidth + c * gridPitch));
789
- g.setAttribute('x2', String(layout.nameColWidth + c * gridPitch));
790
- // v3.6.0 Task 6 fix round 1: `layout.originY` (not `'0'`), bounded to
791
- // the lane rows' own extent (not `layout.height`, which also carries
792
- // `footHeight`) — same reasoning as the x-axis fix just above, missed
793
- // in the first pass. Left at `'0'`/`layout.height` this line bled into
794
- // the ruler band above lane 0 and the foot band below the last lane;
795
- // `test/editor-journey.test.js`'s T6b measures this exact line's
796
- // `y1`/`y2` to find the row band, so a grid starting at 0 made T6b's
797
- // own row-height arithmetic wrong too (R19, same fix round).
798
- g.setAttribute('y1', String(layout.originY));
799
- g.setAttribute('y2', String(layout.originY + layout.lanes.length * layout.laneHeight));
800
- canvas.appendChild(g);
801
- }
802
-
803
- // v3.6.0 Task 9: the ruler/head/foot bands sit strictly above `originY`
804
- // and strictly below the last lane's row (see `drawRuler`'s own
805
- // comment), so paint order against the name column and the lanes below
806
- // does not matter — drawn first only because it is the topmost band on
807
- // screen.
808
- drawRuler(canvas, doc, layout);
809
-
810
- // v3.6.0 Task 8: names before lanes, so the bricks paint OVER the name
811
- // column's right edge in the unlikely case a lane's own cell art ever
812
- // reached that far left — not that it should, but paint order should
813
- // not be the only thing preventing it.
814
- drawNames(canvas, doc, layout);
815
- drawGroupLabels(canvas, doc, layout);
816
-
817
- for (let i = 0; i < layout.lanes.length; i++) {
818
- drawLane(canvas, layout, i);
819
- }
820
377
 
821
- const edges = renderEdges(canvas, doc, layout, state);
822
-
823
- // v3.6.0 Task 11: 轉態標記——只在 `edgeMode !== 'idle'` 時畫。平常畫面
824
- // 不該多這一排點,而且它同時是「你現在在連線模式」這個狀態的唯一視覺
825
- // 證據——在這之前只有工具列那顆按鈕變亮,畫布上看不出來。
826
- //
827
- // 落在 `geometry.anchorOfCell`,不是格子中心:這批標記的整個存在理由
828
- // 是讓使用者瞄準跟畫線用的同一個點,一個畫在格中心的標記會教使用者瞄
829
- // 準錯的地方,比完全不畫還糟。
830
- //
831
- // 資料來自 `geometry.transitionsOf`,不是「這條 lane 的每一格」:它只
832
- // 問 `codec.expandWave` 已展開的真實資料格,`reachOf` 允許一條 lane 的
833
- // 可觸及範圍遠超過它自己的 `cycles`(畫過去是延伸 lane 的手段,見
834
- // `reachOf`/`cellAt` 自己的註解),但那些延伸格沒有資料、也就沒有轉
835
- // 態——`transitionsOf` 天生不會把它們算進來,這裡不需要另外擋。
836
- drawTransitions(canvas, doc, layout, state);
837
-
838
- // v3.5.0 Task 8: the in-flight endpoint drag's own preview — UI state
839
- // only, never a document read (rule 3: nothing is written until
840
- // `finishEndpointDrag` commits it, and Escape mid-drag never reaches
841
- // that far). `renderEdges` above already faded the dragged edge's OWN,
842
- // still-current path (`.is-drag-source`, read from this same
843
- // `endpointDrag`); this draws the other half — a dashed line from the
844
- // edge's UNMOVED end to wherever the drag is currently over. Reuses
845
- // `.ed-wave-edge-pending`, the exact dashed style Task 7's own
846
- // not-yet-written preview line already uses, for the identical
847
- // reason: this line is not `doc.edge` either, until the drop.
848
- //
849
- // Looked up in `edges` — the SAME array `renderEdges` just built this
850
- // pass, not a second call to `geometry.edgeLayout` — so the two can
851
- // never draw from different snapshots of the layout. `render()`'s own
852
- // clamp guarantees `endpointDrag.index === selectedEdge` whenever
853
- // `endpointDrag` is non-null, so a lookup miss here would mean that
854
- // invariant broke, not that the edge is merely unselected — the `!==
855
- // undefined` check is defensive rather than expected to matter.
856
- let endpointDragMark = '';
857
- if (state.endpointDrag !== null) {
858
- const dragged = edges.filter(function (e) { return e.index === state.endpointDrag.index; })[0];
859
- if (dragged !== undefined) {
860
- const otherEnd = state.endpointDrag.end === 'from' ? 'to' : 'from';
861
- const from = dragged[otherEnd];
862
- // Falls back to the dragged end's OWN current position — the
863
- // degenerate "no visible change yet" line — until a `pointermove`
864
- // has actually landed on a real cell (`endpointDrag.at !== null`).
865
- let to = dragged[state.endpointDrag.end];
866
- if (state.endpointDrag.at !== null) {
867
- const cell = geometry.cellRect(layout, state.endpointDrag.at, state.endpointDrag.cell);
868
- if (cell !== null) {
869
- to = { x: cell.x + cell.width / 2, y: cell.y + cell.height / 2 };
870
- endpointDragMark = state.endpointDrag.index + ':' + state.endpointDrag.end + '@' +
871
- state.endpointDrag.at + ',' + state.endpointDrag.cell;
872
- }
873
- }
874
- const line = d.createElementNS(SVGNS, 'line');
875
- line.setAttribute('class', 'ed-wave-edge-pending');
876
- line.setAttribute('x1', String(from.x));
877
- line.setAttribute('y1', String(from.y));
878
- line.setAttribute('x2', String(to.x));
879
- line.setAttribute('y2', String(to.y));
880
- canvas.appendChild(line);
881
- }
378
+ if (state.rowHover !== null && state.rowHover !== undefined) {
379
+ // Task 8: `rowHoverTo` (optional) widens it to a group's span of rows.
380
+ const r = view.cellRect(state.rowHover, 0);
381
+ const last = typeof state.rowHoverTo === 'number' && state.rowHoverTo > state.rowHover
382
+ ? view.cellRect(state.rowHoverTo, 0) : r;
383
+ layer.appendChild(mk('rect', 'ed-wave-row-hover', {
384
+ x: 0, y: r.y, width: view.width, height: last.y + last.height - r.y,
385
+ }));
882
386
  }
883
- state.overlay.setAttribute('data-wave-endpoint-drag', endpointDragMark);
884
-
885
- if (state.selection !== null) {
886
- // v3.6.0 Task 6: this lane's OWN row (`originX`/`y`/`cycleWidth`), not
887
- // `layout.cycleWidth`/`i * layout.laneHeight` from x=0/y=0 — the
888
- // selection box has to land on the same cell `cellRect` (and the
889
- // cursor box below, which already asks `cellRect`) says it is, and
890
- // `state.selection.laneIndex` is guaranteed a valid index into
891
- // `layout.lanes` by the clamp above this function.
892
- const selRow = layout.lanes[state.selection.laneIndex];
893
- const from = Math.min(state.selection.from, state.selection.to);
894
- const to = Math.max(state.selection.from, state.selection.to);
895
- const box = d.createElementNS(SVGNS, 'rect');
896
- box.setAttribute('class', 'ed-wave-selection');
897
- box.setAttribute('x', String(selRow.originX + from * selRow.cycleWidth));
898
- box.setAttribute('y', String(selRow.y));
899
- box.setAttribute('width', String((to - from + 1) * selRow.cycleWidth));
900
- box.setAttribute('height', String(selRow.height));
901
- canvas.appendChild(box);
387
+ if (state.hover) {
388
+ // The whole column under the lanes; `view.bodyTop` is where the lanes
389
+ // start when the layer also covers the ruler band above them.
390
+ const r = view.cellRect(state.hover.laneIndex, state.hover.cycle);
391
+ const top = typeof view.bodyTop === 'number' ? view.bodyTop : 0;
392
+ layer.appendChild(mk('rect', 'ed-wave-hover-col', { x: r.x, y: top, width: r.width, height: view.height - top }));
902
393
  }
903
-
904
- // v3.5.0 Task 7: the pending edge — `pendingFrom` to wherever `cursor`
905
- // is now — drawn from UI state alone, never from the document (rule 2:
906
- // nothing about the doc changes until the drop). `cursor` is shared
907
- // with the keyboard cell below it in paint order for the same reason
908
- // the two are drawn from the same variable: a mouse drag and the
909
- // keyboard's two-Enter version are one gesture wearing two input
910
- // devices, so there is exactly one line to preview, however it is
911
- // being aimed at this instant. Skipped once `pendingFrom` is null,
912
- // which is every state except "armed with a marked start" and
913
- // "dragging" — see `setEdgeMode`.
914
- let pendingCell = '';
915
- if (state.pendingFrom !== null && state.cursor !== null) {
916
- const from = geometry.cellRect(layout, state.pendingFrom.at, state.pendingFrom.cell);
917
- const to = geometry.cellRect(layout, state.cursor.laneIndex, state.cursor.cycle);
918
- if (from !== null && to !== null) {
919
- pendingCell = state.pendingFrom.at + ',' + state.pendingFrom.cell;
920
- const line = d.createElementNS(SVGNS, 'line');
921
- line.setAttribute('class', 'ed-wave-edge-pending');
922
- line.setAttribute('x1', String(from.x + from.width / 2));
923
- line.setAttribute('y1', String(from.y + from.height / 2));
924
- line.setAttribute('x2', String(to.x + to.width / 2));
925
- line.setAttribute('y2', String(to.y + to.height / 2));
926
- canvas.appendChild(line);
927
- }
394
+ if (state.selection) {
395
+ const a = view.cellRect(state.selection.laneIndex, Math.min(state.selection.from, state.selection.to));
396
+ const b = view.cellRect(state.selection.laneIndex, Math.max(state.selection.from, state.selection.to));
397
+ layer.appendChild(mk('rect', 'ed-wave-selection', {
398
+ x: a.x, y: a.y, width: b.x + b.width - a.x, height: a.height,
399
+ }));
928
400
  }
929
- state.overlay.setAttribute('data-wave-edge-pending', pendingCell);
930
-
931
- // The keyboard's cell, drawn last so it is over the selection tint and
932
- // over the trace. Its geometry comes from `wave-geometry.cellRect` — the
933
- // inverse of the hit test the pointer uses — so the cell the cursor is
934
- // DRAWN on and the cell an edit lands in cannot drift apart.
935
- let cursorCell = '';
936
- if (state.cursor !== null) {
937
- const cell = geometry.cellRect(layout, state.cursor.laneIndex, state.cursor.cycle);
938
- if (cell !== null) {
939
- cursorCell = state.cursor.laneIndex + ',' + state.cursor.cycle;
940
- const cur = d.createElementNS(SVGNS, 'rect');
941
- cur.setAttribute('class', 'ed-wave-cursor');
942
- cur.setAttribute('data-ed-wave-cursor', '');
943
- cur.setAttribute('data-cell', cursorCell);
944
- cur.setAttribute('x', String(cell.x));
945
- cur.setAttribute('y', String(cell.y));
946
- cur.setAttribute('width', String(cell.width));
947
- cur.setAttribute('height', String(cell.height));
948
- canvas.appendChild(cur);
949
- }
401
+ (state.edgeHits || []).forEach(function (h) {
402
+ const attrs = { 'data-edge-index': h.index, d: h.d, fill: 'none', stroke: 'transparent', 'stroke-width': h.width };
403
+ if (h.transform) attrs.transform = h.transform;
404
+ layer.appendChild(mk('path', 'ed-wave-edge-hit', attrs));
405
+ });
406
+ if (state.dots && state.dots !== 'off') {
407
+ const lit = new Set();
408
+ if (state.hover) lit.add(state.hover.laneIndex);
409
+ if (state.rowHover !== null && state.rowHover !== undefined) lit.add(state.rowHover);
410
+ cells.forEach(function (c) {
411
+ if (state.dots === 'hover' && !lit.has(c.laneIndex)) return;
412
+ (c.transitions || []).forEach(function (cell) {
413
+ const a = view.anchor(c.laneIndex, cell);
414
+ const hot = !!state.hotDot && state.hotDot.laneIndex === c.laneIndex && state.hotDot.cell === cell;
415
+ const g = mk('g', 'ed-wave-dot' + (hot ? ' is-hot' : ''), { 'data-lane': c.laneIndex, 'data-cell': cell });
416
+ g.appendChild(mk('circle', 'ed-wave-dot-hit', { cx: a.x, cy: a.y, r: HIT_R, fill: 'transparent' }));
417
+ g.appendChild(mk('circle', 'ed-wave-dot-mark', { cx: a.x, cy: a.y, r: hot ? DOT_R_HOT : DOT_R }));
418
+ layer.appendChild(g);
419
+ });
420
+ });
950
421
  }
951
- // The same value on the overlay: what a scenario reads when the rect is
952
- // not the thing under test. It is assigned INSIDE the branch that draws
953
- // the rect and defaults to empty, so "there is no rect" and "the overlay
954
- // says none" are one decision rather than two that happen to agree —
955
- // they did not, in the first cut: this line read `cursor` directly, so a
956
- // cursor the clamp had not caught published a cell that `cellRect`
957
- // refused to give a rectangle to, and a build with the clamp removed
958
- // reported `{cell: null, published: "5,4"}`.
959
- state.overlay.setAttribute('data-wave-cursor', cursorCell);
960
-
961
- canvas.addEventListener('mousedown', state.onCanvasDown);
962
- // v3.6.0 Task 11: hover, unlike the document-level drag pair
963
- // `wave-ui.js` adds/removes around `onCanvasDown`/`onCanvasUp`, has to
964
- // be live from the moment the canvas exists — a plain hover with no
965
- // button down never reaches a mousedown handler at all. Optional
966
- // (`state.onCanvasHover` may be absent, e.g. `test/wave-draw.test.js`'s
967
- // `fakeCanvasState()`, which never exercises pointer movement): guarded
968
- // the same way `state.dataEdit`/`state.cursor` above tolerate a minimal
969
- // test fixture, rather than requiring every caller to supply a no-op.
970
- if (state.onCanvasHover) canvas.addEventListener('mousemove', state.onCanvasHover);
971
- // Tabbing onto the drawing is an entry like any other, so it puts the
972
- // cursor on screen rather than leaving a focused surface with no visible
973
- // cursor in it. Re-entrant only once: `enterDrawing()` repaints, the
974
- // repaint re-focuses the new canvas and fires this again, and by then
975
- // `cursor` is set.
976
- canvas.addEventListener('focus', function () {
977
- if (state.cursor === null) state.enterDrawing();
422
+ (state.labelHits || []).forEach(function (h) {
423
+ layer.appendChild(mk('rect', 'ed-wave-edge-hit ed-wave-edge-label-hit', {
424
+ 'data-edge-index': h.index, x: h.x, y: h.y, width: h.width, height: h.height, fill: 'transparent',
425
+ }));
978
426
  });
979
- state.canvasWrap.appendChild(canvas);
980
- if (hadFocus) canvas.focus();
981
- return canvas;
982
- }
983
-
984
- /** 轉態標記的半徑:一般 / 游標懸停在上面那一顆。 */
985
- const TRANSITION_R = 3;
986
- const TRANSITION_R_HOT = 5;
987
-
988
- /**
989
- * 轉態標記——使用者瞄準 edge 端點的那些點,畫在 `geometry.anchorOfCell`
990
- * 上,不是格中心(見上方 `renderCanvas` 呼叫處的註解)。
991
- *
992
- * `state.hoverBoundary` 是 `wave-ui.js` 的 `boundaryAt` 原生形狀
993
- * `{laneIndex, cell}`(不是 `cellFromEvent` 對外一致改叫的 `cycle`——
994
- * 這裡直接比對 `cell`,兩處欄位名故意不同,混用會在這裡悄悄比對失敗)。
995
- */
996
- function drawTransitions(svg, doc, layout, state) {
997
- if (!state || state.edgeMode === undefined || state.edgeMode === 'idle') return;
998
- for (let i = 0; i < layout.lanes.length; i += 1) {
999
- const cells = geometry.transitionsOf(doc, i);
1000
- for (const c of cells) {
1001
- const a = geometry.anchorOfCell(layout, i, c);
1002
- if (a === null) continue;
1003
- const hot = state.hoverBoundary !== undefined && state.hoverBoundary !== null &&
1004
- state.hoverBoundary.laneIndex === i && state.hoverBoundary.cell === c;
1005
- const dot = d.createElementNS(SVGNS, 'circle');
1006
- dot.setAttribute('class', 'ed-wave-transition');
1007
- dot.setAttribute('cx', a.x);
1008
- dot.setAttribute('cy', a.y);
1009
- dot.setAttribute('r', hot ? TRANSITION_R_HOT : TRANSITION_R);
1010
- if (hot) dot.setAttribute('data-hot', '');
1011
- svg.appendChild(dot);
1012
- }
427
+ (state.loopHits || []).forEach(function (h) {
428
+ layer.appendChild(mk('circle', 'ed-wave-edge-hit ed-wave-edge-loop-hit', {
429
+ 'data-edge-index': h.index, cx: h.x, cy: h.y, r: LOOP_HIT_R, fill: 'transparent',
430
+ }));
431
+ });
432
+ if (state.pending) {
433
+ layer.appendChild(mk('line', 'ed-wave-pending', {
434
+ x1: state.pending.from.x, y1: state.pending.from.y, x2: state.pending.to.x, y2: state.pending.to.y,
435
+ }));
1013
436
  }
1014
- }
1015
-
1016
- function drawLane(svg, layout, i) {
1017
- const row = layout.lanes[i];
1018
- const lane = row.lane;
1019
- // An ABSENT `wave` and an EMPTY one are two different pictures, and
1020
- // collapsing them into one was the regression the previous round shipped
1021
- // on the very axis it was fixing. MEASURED against the pinned engine,
1022
- // second half-brick per cycle:
1023
- //
1024
- // {name:'a', wave:''} ["xxx","xxx"] ONE x cycle
1025
- // {name:'a'} [] NOTHING
1026
- // {} (spacer) [] NOTHING
1027
- // {name:'a', wave:1234} [] NOTHING
1028
- // {name:'a', wave:null} [] NOTHING
1029
- //
1030
- // and in a mixed diagram the engine simply emits no bricks for that row:
1031
- // `{signal:[{a,'0101'},{},{b,'1010'}]}` is 16 uses, i.e. 2 lanes × 4 × 2.
1032
- // So the rule is exactly "a lane draws cycles only when `wave` is a
1033
- // string"; `''` is a string, which is why it gets its one cycle, and
1034
- // `{}` — the canonical WaveDrom blank-row spacer — gets none.
1035
- //
1036
- // The empty-string case is not a corner either: `deleteCycles` covering
1037
- // every cycle sets every lane's wave to `''`, two clicks away.
1038
- const hasWave = typeof lane.wave === 'string';
1039
- const wave = hasWave ? lane.wave : '';
1040
- const cells = hasWave ? codec.expandWave(wave) : [];
1041
- const levels = !hasWave ? [] : (wave === '' ? ['x'] : codec.levelsOf(cells));
1042
- const bricks = levels.map(brickOf);
1043
- // Which cycles carry the engine's discontinuity marker. `levelsOf`
1044
- // deliberately erases `|` — it answers what a cycle SHOWS, and a gap
1045
- // shows whatever is in front of it — so the marker has to be read off the
1046
- // cells. Measured: `01|10` renders one `<use xlink:href="#gap">` at
1047
- // translate(100), which is the centre of cycle 2, the `|` cycle.
1048
- const gaps = [];
1049
- for (let c = 0; c < cells.length; c++) {
1050
- if (cells[c].held !== true && cells[c].ch === '|') gaps.push(c);
437
+ if (state.selectedEdge) {
438
+ const sel = { d: state.selectedEdge.d };
439
+ if (state.selectedEdge.transform) sel.transform = state.selectedEdge.transform;
440
+ layer.appendChild(mk('path', 'ed-wave-edge-sel', sel));
441
+ ['from', 'to'].forEach(function (end) {
442
+ const p = state.selectedEdge[end];
443
+ layer.appendChild(mk('circle', 'ed-wave-edge-end-hit', {
444
+ 'data-end': end, cx: p.x, cy: p.y, r: END_HIT_R, fill: 'transparent',
445
+ }));
446
+ layer.appendChild(mk('circle', 'ed-wave-edge-end', { 'data-end': end, cx: p.x, cy: p.y, r: END_R }));
447
+ });
1051
448
  }
1052
- // v3.5.0 Task 10 fix round 2: which cycles carry the P/N explicit-edge
1053
- // arrow, and which one. Read off `levels` directly rather than off
1054
- // `bricks`, for the same reason `gaps` above reads off `cells` rather
1055
- // than off `bricks`: `brickOf` collapses `P` into the SAME brick id as
1056
- // `p` (that is what makes the arrow the one thing `brickOf`'s own
1057
- // model cannot carry — see its comment, and the measurement above the
1058
- // arrow's own drawing code below), so asking `bricks` here would
1059
- // always come back empty. One entry per cycle that has an arrow,
1060
- // `"<cycle>:<P|N>"`, in the same index-not-one-slot-per-cycle shape
1061
- // `data-gaps-<i>` already uses — an empty lane (no `P`/`N` anywhere,
1062
- // e.g. every existing fixture's plain `p`/`n` clocks) reads as `''`,
1063
- // which is the ABSENCE half of the contract this attribute exists to
1064
- // let a test pin: a `P` lane must show an entry here, a `p` lane must
1065
- // not, and drawing the arrow on every clock (the regression this
1066
- // attribute is for) would make the second half false.
1067
- const clockEdges = [];
1068
- for (let c = 0; c < levels.length; c++) {
1069
- if (levels[c] === 'P' || levels[c] === 'N') clockEdges.push(c + ':' + levels[c]);
449
+ if (state.selectedNote) {
450
+ const n = state.selectedNote;
451
+ layer.appendChild(mk('rect', 'ed-wave-note-sel', { x: n.x, y: n.y, width: n.width, height: n.height, rx: 2 }));
1070
452
  }
1071
- // `data-wave-<i>` cannot carry the difference between "no `wave` key" and
1072
- // `wave: ''` — both are the empty string — and that difference is exactly
1073
- // what the engine draws differently, so it gets its own attribute.
1074
- svg.setAttribute('data-haswave-' + i, hasWave ? '1' : '0');
1075
- // v3.6.0 Task 6 (ruling R18 point 3): this lane's OWN cycle width, not
1076
- // the canvas-level `data-cycle-width` (lane 0's) — Task 4 made
1077
- // `cycleWidth` per lane via `period`/`hscale`, so a caller that wants to
1078
- // trust the single canvas-level attribute has to be able to check every
1079
- // lane actually agrees with it. `editor-journey.test.js`'s T6i reads
1080
- // exactly this attribute to prove `period`/`config.hscale` reach the
1081
- // canvas at all (spec A5): its `period: 2` lane under a global
1082
- // `hscale: 2` must measure 4× the same lane in the control document,
1083
- // and the `period`-less sibling 2×.
1084
- svg.setAttribute('data-cycle-width-' + i, String(row.cycleWidth));
1085
- svg.setAttribute('data-wave-' + i, wave);
1086
- svg.setAttribute('data-levels-' + i, levels.join(''));
1087
- svg.setAttribute('data-bricks-' + i, bricks.join(' '));
1088
- svg.setAttribute('data-gaps-' + i, gaps.join(' '));
1089
- svg.setAttribute('data-clock-edge-' + i, clockEdges.join(' '));
1090
-
1091
- const hi = row.y + 7;
1092
- const lo = row.y + row.height - 9;
1093
- const mid = (hi + lo) / 2;
1094
- // v3.6.0 Task 6: this row's OWN `cycleWidth` (Task 4 made it per-lane via
1095
- // `period`/`hscale`), not the diagram-wide `layout.cycleWidth` — and
1096
- // every x built from it below starts at `row.originX`, not 0. Before
1097
- // this task `row.originX` was always exactly 0 (SIZES.nameColWidth was
1098
- // 0 and no fixture set `phase`), so the two were indistinguishable and
1099
- // this file never had to care; `nameColWidth` going to 120 makes the gap
1100
- // between "drawn at x=0" and "hit-tested from `row.originX`" the whole
1101
- // width of the name column — a cell would still LOOK painted, in the
1102
- // wrong 120px, while `cellAt` reported it was in the name column.
1103
- const cw = row.cycleWidth;
1104
- const labels = labelsOf(lane);
1105
- // Which cycles carry a `data` label: the explicit value cycles, in order.
1106
- // `levelsOf` repeats a bus's level through its continuation cycles, so the
1107
- // label belongs to the cycle that STARTS the run, which is what the run
1108
- // grouping below already knows.
1109
- let labelSlot = 0;
1110
-
1111
- // Runs of one picture. Clock bricks never merge — each `p` cycle is a
1112
- // whole clock period, and a merged run would draw one long one.
1113
- const runs = [];
1114
- for (let c = 0; c < bricks.length; c++) {
1115
- const last = runs.length === 0 ? null : runs[runs.length - 1];
1116
- if (last !== null && last.brick === bricks[c] && !isClock(bricks[c])) last.to = c;
1117
- else runs.push({ brick: bricks[c], from: c, to: c });
453
+ // Task 8: where a lane dragged by its grip will land (spec section 4.4),
454
+ // a line across the whole layer at that row boundary.
455
+ if (state.dropLine) {
456
+ layer.appendChild(mk('line', 'ed-wave-drop-line', {
457
+ x1: 0, y1: state.dropLine.y, x2: view.width, y2: state.dropLine.y,
458
+ }));
1118
459
  }
1119
-
1120
- // `bandOf` (module scope, v3.6.0 Task 10 — also driving `levelIcon`)
1121
- // takes hi/lo/mid explicitly rather than closing over this function's
1122
- // locals, so it is the one belief this file has about which height a
1123
- // brick paints at, not a copy of it.
1124
-
1125
- for (let r = 0; r < runs.length; r++) {
1126
- const run = runs[r];
1127
- const x0 = row.originX + run.from * cw;
1128
- const x1 = row.originX + (run.to + 1) * cw;
1129
- const brick = run.brick;
1130
- if (isBus(brick)) {
1131
- const poly = d.createElementNS(SVGNS, 'polygon');
1132
- const s = Math.min(6, cw / 4);
1133
- poly.setAttribute('class', 'ed-wave-bus ed-wave-bus-' + brick.slice(4));
1134
- poly.setAttribute('points', busPolygonPoints(x0, x1, hi, lo, mid, s));
1135
- svg.appendChild(poly);
1136
- const text = labels[labelSlot];
1137
- if (typeof text === 'string' && text !== '') {
1138
- const t = d.createElementNS(SVGNS, 'text');
1139
- t.setAttribute('class', 'ed-wave-buslabel');
1140
- t.setAttribute('x', String((x0 + x1) / 2));
1141
- t.setAttribute('y', String(mid + 4));
1142
- t.setAttribute('text-anchor', 'middle');
1143
- t.textContent = text;
1144
- svg.appendChild(t);
1145
- } else {
1146
- // v3.5.0 Task 11 fix round 3: an UNLABELLED bus run has no
1147
- // `<text>` for a press to land on — fix round 2's own rule
1148
- // ("open the field only on a press that hits the label text")
1149
- // otherwise leaves the single most ordinary flow in this editor
1150
- // (paint a `=`, then name it) mouse-unreachable. This dot is
1151
- // that run's hit target when there is no label yet: same
1152
- // class as the text (`ed-wave-buslabel`, so `onCanvasDown`'s
1153
- // existing hit test needs no change at all — it already asks
1154
- // for that one class, not for an element type) plus
1155
- // `ed-wave-buslabel-empty` for the dim, small styling
1156
- // (`lib/md2doc.js`) that keeps a diagram full of unlabelled
1157
- // buses from turning into a field of marks. Radius bounded by
1158
- // the run's own box on both axes, not a bare constant, so the
1159
- // box stays MOSTLY paintable even at the narrowest cycle width
1160
- // this editor draws, and a press anywhere else in the box still
1161
- // falls through to the ordinary paint dispatch exactly as
1162
- // round 2 established.
1163
- const r = Math.min(3.5, (x1 - x0) / 6, (lo - hi) / 6);
1164
- const dot = d.createElementNS(SVGNS, 'circle');
1165
- dot.setAttribute('class', 'ed-wave-buslabel ed-wave-buslabel-empty');
1166
- dot.setAttribute('cx', String((x0 + x1) / 2));
1167
- dot.setAttribute('cy', String(mid));
1168
- dot.setAttribute('r', String(r));
1169
- svg.appendChild(dot);
1170
- }
1171
- labelSlot++;
1172
- } else if (brick === 'xxx') {
1173
- const rect = d.createElementNS(SVGNS, 'rect');
1174
- rect.setAttribute('class', 'ed-wave-x');
1175
- rect.setAttribute('x', String(x0));
1176
- rect.setAttribute('y', String(hi));
1177
- rect.setAttribute('width', String(x1 - x0));
1178
- rect.setAttribute('height', String(lo - hi));
1179
- svg.appendChild(rect);
1180
- } else if (isClock(brick)) {
1181
- const xm = (x0 + x1) / 2;
1182
- const p = d.createElementNS(SVGNS, 'path');
1183
- p.setAttribute('class', 'ed-wave-line ed-wave-clock');
1184
- p.setAttribute('d', clockStepPath(brick, x0, x1, hi, lo));
1185
- svg.appendChild(p);
1186
-
1187
- // v3.5.0 Task 10: `P`/`N` are explicit-edge clocks. `brickOf` maps
1188
- // both to the SAME brick id as `p`/`n` (`nclk`/`pclk` — see its own
1189
- // comment), which is correct for the level the engine SHOWS but
1190
- // erases the one thing that makes `P`/`N` look different: an
1191
- // arrowhead. MEASURED against the pinned wavedrom 3.5.0 — rendered
1192
- // `{signal:[{name:'a',wave:'zpp'}]}` next to `{wave:'zPP'}` via
1193
- // `wd.renderAny(0, doc, wd.waveSkin)` and diffed the `<use
1194
- // xlink:href>` list:
1195
- //
1196
- // p: #zzz #zzz #pclk #nclk #pclk #nclk
1197
- // P: #zzz #zzz #Pclk #nclk #Pclk #nclk
1198
- // n: #zzz #zzz #nclk #pclk #nclk #pclk
1199
- // N: #zzz #zzz #Nclk #pclk #Nclk #pclk
1200
- //
1201
- // — same SECOND half-brick every time (that is what `brickOf`'s own
1202
- // measurement already pins), but the FIRST half-brick gets its own
1203
- // symbol. Read straight out of `node_modules/wavedrom/skins/default.js`:
1204
- //
1205
- // pclk 'M0,20 0,0 20,0' (just the edge)
1206
- // Pclk same edge PLUS 'M-3,12 0,3 3,12 C 1,11 -1,11 -3,12 z' (filled, class s6)
1207
- // nclk 'm0,0 0,20 20,0'
1208
- // Nclk same edge PLUS 'M-3,8 0,17 3,8 C 1,9 -1,9 -3,8 z' (filled, class s6)
1209
- //
1210
- // i.e. in the engine's own 20-unit symbol space (y=0 top, y=20
1211
- // bottom, matching this file's hi/lo), a small filled triangle
1212
- // straddling local x=0 — `P`'s apex at local y=3 pointing UP
1213
- // (toward hi), `N`'s apex at local y=17 pointing DOWN (toward lo).
1214
- // Both sit on the FIRST half-brick, which in this drawing's
1215
- // one-brick-per-period model (a whole `p`/`P` cycle is ONE `nclk`
1216
- // path, not two half-bricks — see `isClock`'s own comment) is
1217
- // always the vertical stroke at x0, the edge INTO this cycle. So
1218
- // the level char (not the brick id, which is identical for
1219
- // `p`/`P`) is what has to be read to know an arrow is owed, and
1220
- // `levels[run.from]` is it — clock bricks never merge (the `!
1221
- // isClock` guard below), so `run.from === run.to` and this is
1222
- // exactly the cycle being drawn. `data-clock-edge-<i>`, set above
1223
- // (this function's own top, next to `data-gaps-<i>`) from the same
1224
- // `levels` array, is the one place this fact is exposed for a test
1225
- // to pin — nothing here duplicates it onto the shape itself, so
1226
- // there is one belief about which cycles have an arrow, not two.
1227
- //
1228
- // Reproduced as a plain filled triangle, not the engine's
1229
- // bezier-curved one: every other brick this file draws is already
1230
- // a simplified redraw (the bus is a hexagon, the gap is a pair of
1231
- // chevrons), not a pixel copy of wavedrom's own glyphs. Scaled out
1232
- // of the engine's 20-unit symbol into this cell's own geometry —
1233
- // local x by (xm - x0) / 20, local y by (lo - hi) / 20, both
1234
- // anchored at (x0, hi) — rather than hand-picked pixel offsets, so
1235
- // it tracks `SIZES.cycleWidth`/`laneHeight` if those ever change.
1236
- const edgeLevel = levels[run.from];
1237
- const arrowD = clockArrowPath(edgeLevel, x0, xm, hi, lo);
1238
- if (arrowD !== null) {
1239
- const arrow = d.createElementNS(SVGNS, 'path');
1240
- arrow.setAttribute('class', 'ed-wave-clock-arrow');
1241
- arrow.setAttribute('d', arrowD);
1242
- svg.appendChild(arrow);
1243
- }
1244
- } else {
1245
- const y = bandOf(brick, hi, lo, mid)[0];
1246
- const line = d.createElementNS(SVGNS, 'line');
1247
- line.setAttribute('class', brick === 'uuu' || brick === 'ddd'
1248
- ? 'ed-wave-line ed-wave-weak' : 'ed-wave-line');
1249
- line.setAttribute('x1', String(x0));
1250
- line.setAttribute('x2', String(x1));
1251
- line.setAttribute('y1', String(y));
1252
- line.setAttribute('y2', String(y));
1253
- svg.appendChild(line);
1254
- }
1255
-
1256
- // The edge into this run. Clock bricks draw their own.
1257
- if (r > 0 && !isClock(brick) && !isClock(runs[r - 1].brick)) {
1258
- const prevBrick = runs[r - 1].brick;
1259
- if (isRampPair(brick, prevBrick)) {
1260
- // v3.6.0 Task 7: a real level change draws the ramp the engine
1261
- // draws, not a vertical instant edge — a vertical edge left
1262
- // Task 5's `anchorOfCell` marker floating `anchorRatio` (0.15) of
1263
- // a cycle to the right of anything painted (48px pitch -> 7.2px)
1264
- // because nothing was drawn there yet. See `brickPath`'s own
1265
- // comment for the measured ramp geometry.
1266
- const local = brickPath(lineLevelOf(brick), lineLevelOf(prevBrick), cw, lo - hi);
1267
- const edge = d.createElementNS(SVGNS, 'path');
1268
- edge.setAttribute('class', 'ed-wave-edge');
1269
- edge.setAttribute('d', translatePath(local, x0, hi));
1270
- svg.appendChild(edge);
1271
- } else {
1272
- const a = bandOf(prevBrick, hi, lo, mid);
1273
- const b = bandOf(brick, hi, lo, mid);
1274
- const top = Math.min(a[0], a[1], b[0], b[1]);
1275
- const bot = Math.max(a[0], a[1], b[0], b[1]);
1276
- if (bot > top) {
1277
- const edge = d.createElementNS(SVGNS, 'line');
1278
- edge.setAttribute('class', 'ed-wave-edge');
1279
- edge.setAttribute('x1', String(x0));
1280
- edge.setAttribute('x2', String(x0));
1281
- edge.setAttribute('y1', String(top));
1282
- edge.setAttribute('y2', String(bot));
1283
- svg.appendChild(edge);
1284
- }
1285
- }
1286
- }
1287
- }
1288
-
1289
- // The gap marker, last so it sits over the trace it interrupts. The
1290
- // engine draws one per `|`; drawing none made the two pictures disagree
1291
- // on a lane whose levels agreed exactly, which is the quietest form of
1292
- // the disagreement this overlay exists to surface.
1293
- for (const c of gaps) {
1294
- const xm = row.originX + (c + 0.5) * cw;
1295
- const mark = d.createElementNS(SVGNS, 'path');
1296
- mark.setAttribute('class', 'ed-wave-gap');
1297
- mark.setAttribute('d', gapMarkPath(xm, hi, lo, mid));
1298
- svg.appendChild(mark);
1299
- }
1300
- }
1301
-
1302
- /**
1303
- * Every `edge` entry, drawn after every lane: one `<path>` (`d` from
1304
- * `geometry.edgeLayout`), a label `<text>` when the entry has one, and —
1305
- * for `selectedEdge` only — two endpoint handles from
1306
- * `geometry.edgeHandleRect`, exactly the rectangles `onCanvasDown` below
1307
- * hit-tests against, so the drawn handle and the clickable one cannot
1308
- * drift apart.
1309
- *
1310
- * A second, invisible, wide-stroke `<path>` (`class="ed-wave-edge-hit"`)
1311
- * is layered over each visible one purely so it can be clicked: the
1312
- * visible path is 1px wide, and `document.elementFromPoint` (what
1313
- * `onCanvasDown` reads to answer "did this press land on a line") would
1314
- * otherwise miss any press that is not on that exact pixel.
1315
- * `lib/md2doc.js`'s `.ed-wave-edge-hit` rule sets `pointer-events: stroke`
1316
- * — what makes a `stroke: transparent` path hit-testable at all, without
1317
- * which a transparent stroke paints nothing and "nothing painted" is what
1318
- * `visiblePainted` (the SVG default) refuses to hit.
1319
- *
1320
- * Arrow markers are picked from the shape string, MEASURED against
1321
- * `node_modules/wavedrom/lib/arc-shape.js`'s own `style` table (every
1322
- * `case` there that sets a `style` string): a shape containing `<` sets
1323
- * `marker-start:url(#arrowtail)`, one containing `>` sets
1324
- * `marker-end:url(#arrowhead)`, and `+` is the one shape that sets BOTH
1325
- * markers, to `url(#tee)` — the seven plain shapes (`-`, `~`, `-~`, `~-`,
1326
- * `-|`, `|-`, `-|-`) set no `style` at all and draw no arrowhead.
1327
- * (`applyEdgeMarkers` below is unaffected by the exact count either way —
1328
- * it tests `indexOf('<')` / `indexOf('>')` rather than enumerating a
1329
- * list — but this comment's own count should still match the pinned
1330
- * engine.) `edgeArrowMarker` below reproduces the head/tail split with
1331
- * ONE shared marker definition rather than two: `orient="auto-start-reverse"`
1332
- * is what flips a `marker-start` 180° from a `marker-end` automatically,
1333
- * so the single triangle already points the right way at both ends.
1334
- */
1335
- function renderEdges(svg, doc, layout, state) {
1336
- const edges = geometry.edgeLayout(doc, layout);
1337
- state.overlay.setAttribute('data-wave-edge-count', String(edges.length));
1338
- if (edges.length === 0) return edges;
1339
-
1340
- const defs = d.createElementNS(SVGNS, 'defs');
1341
- defs.appendChild(edgeArrowMarker());
1342
- defs.appendChild(edgeTeeMarker());
1343
- svg.appendChild(defs);
1344
-
1345
- for (const e of edges) {
1346
- const isSelected = e.index === state.selectedEdge;
1347
- // v3.5.0 Task 8: the edge an in-flight endpoint drag is dragging
1348
- // keeps drawing at its ACTUAL (still-committed) position — the
1349
- // document is untouched until the drop (rule 3) — but is faded so
1350
- // it reads as "not where this will end up" once the dashed preview
1351
- // (`renderCanvas`, below `renderEdges`'s own call) is standing next
1352
- // to it. `endpointDrag` can only ever name `selectedEdge` itself
1353
- // (`render()`'s own clamp), so `isSelected` is already true here too
1354
- // and this only ever adds a class, never substitutes for `.is-on`.
1355
- const isDragSource = state.endpointDrag !== null && e.index === state.endpointDrag.index;
1356
- const path = d.createElementNS(SVGNS, 'path');
1357
- // Every visual property (stroke, fill, width) lives in
1358
- // `lib/md2doc.js`'s `.ed-wave-edge-path` / `.is-on` CSS, the same
1359
- // mechanism the rest of this drawing's colours already go through
1360
- // (`.ed-wave-line`, `.ed-wave-selection`, `.ed-wave-cursor`, …) —
1361
- // one place to repaint the editor, not two. Only genuinely geometric
1362
- // attributes (`d`, the marker refs) stay here as attributes.
1363
- path.setAttribute('class', 'ed-wave-edge-path' + (isSelected ? ' is-on' : '') +
1364
- (isDragSource ? ' is-drag-source' : ''));
1365
- path.setAttribute('data-edge-index', String(e.index));
1366
- path.setAttribute('d', e.d);
1367
- applyEdgeMarkers(path, e.edge.shape);
1368
- svg.appendChild(path);
1369
-
1370
- const hit = d.createElementNS(SVGNS, 'path');
1371
- hit.setAttribute('class', 'ed-wave-edge-hit');
1372
- hit.setAttribute('data-edge-index', String(e.index));
1373
- hit.setAttribute('d', e.d);
1374
- svg.appendChild(hit);
1375
-
1376
- if (e.edge.label !== '') {
1377
- const t = d.createElementNS(SVGNS, 'text');
1378
- t.setAttribute('class', 'ed-wave-edge-label');
1379
- t.setAttribute('x', String(e.labelPos.x));
1380
- t.setAttribute('y', String(e.labelPos.y - 4));
1381
- // `text-anchor` positions the glyphs against (x, y) — geometry, not
1382
- // colour — so it stays an attribute, exactly as the existing
1383
- // `.ed-wave-buslabel` text already does a few lines up.
1384
- t.setAttribute('text-anchor', 'middle');
1385
- t.textContent = e.edge.label;
1386
- svg.appendChild(t);
1387
- }
1388
-
1389
- if (isSelected) {
1390
- for (const end of ['from', 'to']) {
1391
- const r = geometry.edgeHandleRect(e[end]);
1392
- const h = d.createElementNS(SVGNS, 'rect');
1393
- h.setAttribute('class', 'ed-wave-edge-handle');
1394
- h.setAttribute('data-edge-handle', end);
1395
- h.setAttribute('data-edge-index', String(e.index));
1396
- h.setAttribute('x', String(r.x));
1397
- h.setAttribute('y', String(r.y));
1398
- h.setAttribute('width', String(r.width));
1399
- h.setAttribute('height', String(r.height));
1400
- svg.appendChild(h);
1401
- }
1402
- }
1403
- }
1404
- return edges;
1405
- }
1406
-
1407
- /** `marker-start` / `marker-end` for one shape — see `renderEdges`'s own
1408
- * comment for the measurement behind this split. */
1409
- function applyEdgeMarkers(path, shape) {
1410
- if (shape === '+') {
1411
- path.setAttribute('marker-start', 'url(#ed-wave-tee)');
1412
- path.setAttribute('marker-end', 'url(#ed-wave-tee)');
1413
- return;
460
+ (state.overflow || []).forEach(function (o) {
461
+ layer.appendChild(mk('rect', 'ed-wave-overflow', { x: o.left, y: o.top, width: o.width, height: o.height }));
462
+ });
463
+ // the cursor last, over everything else
464
+ if (state.cursor) {
465
+ const r = view.cellRect(state.cursor.laneIndex, state.cursor.cycle);
466
+ const cur = mk('rect', 'ed-wave-cursor', {
467
+ 'data-ed-wave-cursor': '', 'data-cell': state.cursor.laneIndex + ',' + state.cursor.cycle,
468
+ x: r.x, y: r.y, width: r.width, height: r.height,
469
+ });
470
+ layer.appendChild(cur);
1414
471
  }
1415
- if (shape.indexOf('<') !== -1) path.setAttribute('marker-start', 'url(#ed-wave-arrowhead)');
1416
- if (shape.indexOf('>') !== -1) path.setAttribute('marker-end', 'url(#ed-wave-arrowhead)');
1417
- }
1418
-
1419
- /** One triangle, reused for both ends via `auto-start-reverse`. */
1420
- function edgeArrowMarker() {
1421
- const m = d.createElementNS(SVGNS, 'marker');
1422
- m.setAttribute('id', 'ed-wave-arrowhead');
1423
- m.setAttribute('viewBox', '0 0 8 8');
1424
- m.setAttribute('markerWidth', '8');
1425
- m.setAttribute('markerHeight', '8');
1426
- m.setAttribute('refX', '7');
1427
- m.setAttribute('refY', '4');
1428
- m.setAttribute('orient', 'auto-start-reverse');
1429
- const poly = d.createElementNS(SVGNS, 'polygon');
1430
- poly.setAttribute('points', '0,0 8,4 0,8');
1431
- poly.setAttribute('class', 'ed-wave-edge-arrow');
1432
- m.appendChild(poly);
1433
- return m;
1434
- }
1435
-
1436
- /** The `+` shape's tee: a crossbar perpendicular to the path at either
1437
- * end, symmetric under a 180° flip so `orient="auto"` needs no reverse. */
1438
- function edgeTeeMarker() {
1439
- const m = d.createElementNS(SVGNS, 'marker');
1440
- m.setAttribute('id', 'ed-wave-tee');
1441
- m.setAttribute('viewBox', '0 0 8 8');
1442
- m.setAttribute('markerWidth', '8');
1443
- m.setAttribute('markerHeight', '8');
1444
- m.setAttribute('refX', '4');
1445
- m.setAttribute('refY', '4');
1446
- m.setAttribute('orient', 'auto');
1447
- const line = d.createElementNS(SVGNS, 'line');
1448
- line.setAttribute('x1', '4');
1449
- line.setAttribute('y1', '0');
1450
- line.setAttribute('x2', '4');
1451
- line.setAttribute('y2', '8');
1452
- line.setAttribute('class', 'ed-wave-edge-tee');
1453
- m.appendChild(line);
1454
- return m;
1455
472
  }
1456
473
 
1457
-
1458
- return {
1459
- renderCanvas: renderCanvas,
1460
- drawLane: drawLane,
1461
- renderEdges: renderEdges,
1462
- brickOf: brickOf,
1463
- isClock: isClock,
1464
- isBus: isBus,
1465
- brickPath: brickPath,
1466
- levelIcon: levelIcon,
1467
- };
474
+ return { paint: paint };
1468
475
  }
1469
476
 
1470
- module.exports = { createDrawer: createDrawer };
477
+ module.exports = { createDrawer: createDrawer, createLayer: createLayer };