@helping-ai-workflow/md2doc 3.4.2 → 3.6.0

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.
@@ -44,6 +44,64 @@
44
44
 
45
45
  const codec = require('./wave-codec.js');
46
46
 
47
+ /**
48
+ * MEASURED against `node_modules/wavedrom` 3.5.0 (`node_modules/wavedrom/package.json`
49
+ * `"version"`), by reading its `waveSkin` object directly in Node:
50
+ *
51
+ * - socket: `["g",{"id":"socket"},["rect",{"y":"15","x":"6","height":"20","width":"20"}]]`.
52
+ * A brick is `width` wide (20), a cycle is two bricks (40), and the engine
53
+ * anchors a `node` marker at the socket rect's `x` — so `anchorRatio` is
54
+ * `6 / 40 = 0.15`.
55
+ * - transition brick `0m1` — a REAL 0->1 change. Measure the ramp from
56
+ * THIS brick, not from `0m0`: the id looks similar, but `0m0` is a
57
+ * same-LEVEL blip (a down-and-back notch that never changes level) and
58
+ * is not a transition at all.
59
+ * `["g",{"id":"0m1"},["path",{"d":"M0,20 3,20 9,0 20,0", …}]]`, i.e.
60
+ * absolute points `(0,20)->(3,20)->(9,0)->(20,0)`. The flat lead-in at
61
+ * the old level ends at x=3, so `slewStartRatio` is `3 / 40 = 0.075`;
62
+ * the diagonal reaches the new level at x=9, so `slewEndRatio` is
63
+ * `9 / 40 = 0.225`. Cross-checked against `1m0` (1->0):
64
+ * `["g",{"id":"1m0"},["path",{"d":"m0,0 3,0 6,20 11,0", …}]]` (relative
65
+ * coordinates, resolving to `(0,0)->(3,0)->(9,20)->(20,20)`) — the same
66
+ * 3-to-9 span, mirrored.
67
+ * - `anchorRatio` (0.15, x=6) is neither ramp endpoint — it is the ramp's
68
+ * MIDPOINT: `(slewStartRatio + slewEndRatio) / 2 === (0.075+0.225)/2
69
+ * === 0.15`. That is the transition's 50% crossing, which is why the
70
+ * engine anchors an annotation there rather than at either edge of the
71
+ * ramp.
72
+ * - Cross-checked end-to-end: rendering `{signal:[{wave:'0.1.0...',node:'a.b.c...'}],
73
+ * edge:['a-b']}` through `wavedrom.renderAny` puts the `gmark_a_b` path's
74
+ * endpoints at `cycle * 40 + 0.15 * 40` for both cycle 0 (`a`) and cycle 2
75
+ * (`b`) — see `test/wave-geometry.test.js`'s SKIN_METRICS block, which
76
+ * re-derives these same three ratios from `waveSkin` (from `0m1`,
77
+ * cross-checked against `1m0` — not from `0m0`) and re-runs this exact
78
+ * render, so a wavedrom bump that moves the skin turns that test red and
79
+ * names the number to re-pin.
80
+ *
81
+ * v3.6.0 Task 7 fix round 1 (ruling R23): `slewEndRatio` was originally
82
+ * pinned at 0.15 (`=== anchorRatio`) on the belief that the ramp finishes
83
+ * exactly where the anchor sits. It does not: that belief was measured
84
+ * against `0m0`, whose down-and-back peak coincidentally lands on the same
85
+ * x as the anchor while telling you nothing about where a REAL transition's
86
+ * ramp ends. Repinned to the measured 0.225 above once `0m1`/`1m0` (the
87
+ * actual transition bricks) were read instead.
88
+ *
89
+ * Pinned rather than derived at module load: this file cannot pull in the
90
+ * `wavedrom` package itself. In the browser, `lib/editor/server.js`'s CommonJS
91
+ * shim resolves that same import against a page-global registry keyed by module
92
+ * basename, and the shipped `wavedrom.min.js` bundle only attaches `ProcessAll` /
93
+ * `RenderWaveForm` / `EditorRefresh` / `eva` / `version` to that page global — no
94
+ * `waveSkin` — so a page-time derivation would read `undefined` on every real
95
+ * page, not just as a rare fallback. The tests, which run in Node against the
96
+ * real npm package, are where the derivation actually happens and where a
97
+ * version bump would be caught.
98
+ */
99
+ const SKIN_METRICS = Object.freeze({
100
+ anchorRatio: 0.15,
101
+ slewStartRatio: 0.075,
102
+ slewEndRatio: 0.225,
103
+ });
104
+
47
105
  /**
48
106
  * Sizes to use when the caller has nothing better, read out of the engine's own
49
107
  * `lane` module after a render rather than inferred: `lane.yo` (row pitch) is
@@ -59,6 +117,47 @@ const codec = require('./wave-codec.js');
59
117
  */
60
118
  const DEFAULTS = { laneHeight: 30, cycleWidth: 40, nameColWidth: 40 };
61
119
 
120
+ /**
121
+ * v3.6.0 Task 6: the decorated bands above lane 0 — a ruler (always), an
122
+ * optional head-title band, and an optional foot-tick band below the last
123
+ * lane.
124
+ *
125
+ * v3.6.0 Task 9 fix round 1 (ruling R29): foot now gets a SECOND stacked
126
+ * band too, mirroring head's own two — `parse-config.js` sets `yf0 = 20`
127
+ * for a foot `tick`/`tock` and `yf1 = 46` for a foot `text`, the exact
128
+ * mirror of `yh0`/`yh1` for head, and both are legal at once in real
129
+ * WaveJSON. Before this fix `footHeight` was one band shared by text AND
130
+ * ticks, so a document setting both drew them on top of each other.
131
+ *
132
+ * 22 / 20 are layout constants, not derived from the engine: the engine's own
133
+ * canvas carries its own margin for this, and this editor's canvas only needs
134
+ * to reserve enough room to paint a row of 11px numerals (ruler) or a title
135
+ * (head text) without clipping it. `RULER_HEIGHT` and `FOOT_HEIGHT` are the
136
+ * same 22 because a foot band draws the same kind of tick row the ruler does,
137
+ * just below the last lane instead of above the first, and `FOOT_TEXT_HEIGHT`
138
+ * matches `HEAD_TEXT_HEIGHT` (20) for the same reason. It is the STRUCTURE —
139
+ * two stacked bands per side, not one shared band — that has to mirror the
140
+ * engine, never the pixel counts: the engine's own 46 for a text band is not
141
+ * a target to hit here, do not "correct" 20 to it.
142
+ */
143
+ const RULER_HEIGHT = 22;
144
+ const HEAD_TEXT_HEIGHT = 20;
145
+ const FOOT_HEIGHT = 22;
146
+ const FOOT_TEXT_HEIGHT = 20;
147
+
148
+ /**
149
+ * Whether `doc[which]` (`'head'` or `'foot'`) is a WaveJSON banner object —
150
+ * any of the four keys the engine draws something for. A banner with none of
151
+ * these keys draws nothing, so it does not reserve a band either.
152
+ */
153
+ function hasBanner(doc, which) {
154
+ if (doc === null || typeof doc !== 'object') return false;
155
+ const b = doc[which];
156
+ if (b === null || typeof b !== 'object') return false;
157
+ return b.text !== undefined || b.tick !== undefined ||
158
+ b.tock !== undefined || b.every !== undefined;
159
+ }
160
+
62
161
  /**
63
162
  * How many lanes are laid out before the rest are dropped.
64
163
  *
@@ -185,42 +284,190 @@ function cyclesOf(lane) {
185
284
  * overflow `width` to Infinity. Both are outside anything `getBoundingClientRect`
186
285
  * can report, and neither is defended against here.
187
286
  */
287
+ /**
288
+ * `config.hscale`, normalized exactly the way the pinned engine's own
289
+ * `parseConfig` does it (`node_modules/wavedrom/lib/parse-config.js:4-21`,
290
+ * measured this session): `tonumber(x) = x > 0 ? Math.round(x) : 1`, rounded
291
+ * a second time, then clamped to 100. So `hscale: 2.5` renders as **3** in
292
+ * the engine, not 2.5, and `hscale: 250` clamps to **100** — a caller that
293
+ * skipped this and used the raw value would draw a canvas that disagrees
294
+ * with the engine's own preview, which is the exact defect class v3.6.0
295
+ * exists to remove (v3.6.0 Task 4 fix round 1, R15).
296
+ *
297
+ * A value that rounds to 0 or below (the engine's own `tonumber` maps every
298
+ * non-positive input to 1, and `Math.round(0.4)` is 0) is the engine's
299
+ * default of 1, same as a missing or non-numeric `hscale` — this is not an
300
+ * error, `config` is user data and a hand-edited stray value must still lay
301
+ * out rather than take the editor down.
302
+ */
303
+ function hscaleOf(cfg) {
304
+ if (cfg === null || typeof cfg.hscale !== 'number' || !isFinite(cfg.hscale) ||
305
+ cfg.hscale <= 0) {
306
+ return 1;
307
+ }
308
+ const rounded = Math.round(cfg.hscale);
309
+ if (rounded <= 0) return 1;
310
+ return rounded > 100 ? 100 : rounded;
311
+ }
312
+
188
313
  function layoutOf(doc, opts) {
189
314
  const o = options(opts);
190
315
  const laneHeight = size(o.laneHeight, DEFAULTS.laneHeight, 'laneHeight', false);
191
316
  const cycleWidth = size(o.cycleWidth, DEFAULTS.cycleWidth, 'cycleWidth', false);
192
317
  const nameColWidth = size(o.nameColWidth, DEFAULTS.nameColWidth, 'nameColWidth', true);
193
318
 
319
+ // `config.hscale` scales every lane's cycle width the same way the engine's
320
+ // own `lane.hscale` does — see `hscaleOf` for the exact rounding/clamping
321
+ // this mirrors.
322
+ const cfg = (doc !== null && typeof doc === 'object' &&
323
+ doc.config !== null && typeof doc.config === 'object') ? doc.config : null;
324
+ const hscale = hscaleOf(cfg);
325
+
326
+ // v3.6.0 Task 6: the ruler band always reserves room; a head.text banner
327
+ // adds a title band above it; lane 0 starts below both.
328
+ const headTextHeight = (doc !== null && typeof doc === 'object' &&
329
+ doc.head !== null && typeof doc.head === 'object' &&
330
+ doc.head.text !== undefined) ? HEAD_TEXT_HEIGHT : 0;
331
+ const originY = headTextHeight + RULER_HEIGHT;
332
+ const footHeight = hasBanner(doc, 'foot') ? FOOT_HEIGHT : 0;
333
+ // v3.6.0 Task 9 fix round 1 (R29): stacked BELOW `footHeight`, mirroring
334
+ // `headTextHeight` stacked ABOVE `RULER_HEIGHT` — `footHeight` alone still
335
+ // covers a foot with only `tick`/`tock`/`every` (untouched: a document
336
+ // that sets none of those keeps `footTextHeight` at 0 and its `height`
337
+ // pin does not move), and this adds the second band only when `foot.text`
338
+ // is actually present, exactly the condition `headTextHeight` already
339
+ // uses for `head.text`.
340
+ const footTextHeight = (doc !== null && typeof doc === 'object' &&
341
+ doc.foot !== null && typeof doc.foot === 'object' &&
342
+ doc.foot.text !== undefined) ? FOOT_TEXT_HEIGHT : 0;
343
+
194
344
  const found = enumerateLanes(doc);
195
345
  const lanes = [];
196
346
  let cycles = 0;
197
347
  for (let i = 0; i < found.lanes.length; i++) {
198
348
  const laneCycles = cyclesOf(found.lanes[i].lane);
199
349
  if (laneCycles > cycles) cycles = laneCycles;
350
+ const lane = found.lanes[i].lane;
351
+ // A wave character spans `period` diagram cycles, and the whole lane is
352
+ // then scaled by `hscale`. `phase` only shifts the origin, and by the
353
+ // BASE cycle width, not the effective one — the engine's own expression
354
+ // is `xs * (2*i*period*hscale - phase)`, and that `phase` term is never
355
+ // multiplied by hscale (measured in node_modules/wavedrom/lib/render-arcs.js).
356
+ const laneObj = (lane !== null && typeof lane === 'object') ? lane : {};
357
+ // Deliberate divergence from the engine (R15): the engine's own
358
+ // `element.period ? element.period : 1` passes a negative value straight
359
+ // through, which the renderer then turns into a negative pixel width.
360
+ // Here a non-positive `period` normalizes to 1 instead — a negative
361
+ // `cycleWidth` would make `reachOf`'s width bound run backwards and
362
+ // break R14(b) (`cellRect`/`cellAt` inverting each other), not just
363
+ // look wrong. Do not "fix" this back to match the engine bit-for-bit;
364
+ // the engine's own behaviour here is the bug, not this file's.
365
+ const period = (typeof laneObj.period === 'number' && isFinite(laneObj.period) &&
366
+ laneObj.period > 0) ? laneObj.period : 1;
367
+ const phase = (typeof laneObj.phase === 'number' && isFinite(laneObj.phase)) ?
368
+ laneObj.phase : 0;
200
369
  lanes.push({
201
370
  index: i,
202
- y: i * laneHeight,
371
+ y: originY + i * laneHeight,
203
372
  height: laneHeight,
204
373
  cycles: laneCycles,
205
374
  depth: found.lanes[i].path.length - 2,
206
375
  path: found.lanes[i].path,
207
- lane: found.lanes[i].lane,
376
+ lane: lane,
377
+ cycleWidth: cycleWidth * period * hscale,
378
+ originX: nameColWidth - cycleWidth * phase,
208
379
  });
209
380
  }
210
381
  if (lanes.length > 0 && cycles === 0) cycles = 1;
211
382
 
383
+ // Each lane can now have its own cycleWidth/originX, so the diagram's width
384
+ // is the widest lane's own right edge, not a single global expression. A
385
+ // lane with zero cycles of its own (an empty or wave-less lane) still
386
+ // reserves the same one-cycle floor documented above `layoutOf` — the
387
+ // engine renders a placeholder cycle for it too, so width must not collapse
388
+ // to the name column just because THAT lane happens to be the widest one.
389
+ let width = nameColWidth;
390
+ for (const row of lanes) {
391
+ const rowCycles = row.cycles > 0 ? row.cycles : 1;
392
+ const right = row.originX + rowCycles * row.cycleWidth;
393
+ if (right > width) width = right;
394
+ }
395
+ if (lanes.length === 0) width = nameColWidth;
396
+
212
397
  return {
213
398
  laneHeight: laneHeight,
214
399
  cycleWidth: cycleWidth,
215
400
  nameColWidth: nameColWidth,
401
+ hscale: hscale,
216
402
  lanes: lanes,
217
403
  cycles: cycles,
218
404
  truncated: found.truncated,
219
- width: nameColWidth + cycles * cycleWidth,
220
- height: lanes.length * laneHeight,
405
+ width: width,
406
+ // v3.6.0 Task 6: originY / rulerHeight / headTextHeight / footHeight —
407
+ // the vertical bands lane 0 and the last lane now sit between.
408
+ // v3.6.0 Task 9 fix round 1 (R29): footTextHeight — the second, outer
409
+ // foot band, present only when `foot.text` is set. `footHeight` stays
410
+ // the inner band (adjacent to the last lane), same as before this fix.
411
+ originY: originY,
412
+ rulerHeight: RULER_HEIGHT,
413
+ headTextHeight: headTextHeight,
414
+ footHeight: footHeight,
415
+ footTextHeight: footTextHeight,
416
+ height: originY + lanes.length * laneHeight + footHeight + footTextHeight,
221
417
  };
222
418
  }
223
419
 
420
+ /**
421
+ * The group titles in the lane tree, each with the span of display rows it
422
+ * covers.
423
+ *
424
+ * A group title occupies NO ROW — measured in Task 2 against the pinned
425
+ * engine, and `wave-geometry` is built on it. What it occupies is horizontal
426
+ * space in the name column, so that is where this puts it: a label in the
427
+ * lane list's rail, spanning its lanes' rows. Drawing it as a row of its own
428
+ * would put the lane list one row out of step with the waveform beside it for
429
+ * every lane below the group, which is exactly the class of silent
430
+ * disagreement this batch keeps producing.
431
+ *
432
+ * The span is derived from the lanes' own paths — `layout.lanes[i].path` is
433
+ * the codec's — so a group with no lanes under it has no span and is not
434
+ * shown here; it is not addressable through the bridge either.
435
+ *
436
+ * v3.6.0 Task 2: moved here from `lib/editor/wave-ui.js` per ruling R9 — both
437
+ * `wave-panels.js` (the lane rail) and `wave-draw.js` (the canvas) need it,
438
+ * and this file is the one place both already depend on. Behaviour
439
+ * unchanged; it now reads this file's own `valueAt` instead of a copy that
440
+ * used to live in `wave-ui.js`.
441
+ */
442
+ function groupSpansOf(doc, layout) {
443
+ const byKey = new Map();
444
+ for (let i = 0; i < layout.lanes.length; i++) {
445
+ const path = layout.lanes[i].path;
446
+ // Every proper ancestor between `signal` and the lane itself is a group.
447
+ for (let cut = 2; cut < path.length; cut++) {
448
+ const prefix = path.slice(0, cut);
449
+ const key = JSON.stringify(prefix);
450
+ const seen = byKey.get(key);
451
+ if (seen === undefined) {
452
+ byKey.set(key, { path: prefix, depth: cut - 1, from: i, to: i });
453
+ } else {
454
+ seen.to = i;
455
+ }
456
+ }
457
+ }
458
+ const out = [];
459
+ for (const span of byKey.values()) {
460
+ const container = valueAt(doc, span.path);
461
+ const title = Array.isArray(container) && typeof container[0] === 'string'
462
+ ? container[0] : null;
463
+ if (title === null) continue; // an untitled group has nothing to show
464
+ out.push({ path: span.path, title: title, depth: span.depth,
465
+ from: span.from, to: span.to });
466
+ }
467
+ out.sort(function (a, b) { return a.from - b.from || a.depth - b.depth; });
468
+ return out;
469
+ }
470
+
224
471
  /** A real, finite coordinate — `'70'` and `NaN` are not one. */
225
472
  function isCoord(n) {
226
473
  return typeof n === 'number' && isFinite(n);
@@ -258,6 +505,35 @@ function indexAt(pos, origin, step, count) {
258
505
  return i + 0; // `Math.floor(-0.2)` is -0, and -0 fails deepStrictEqual against 0
259
506
  }
260
507
 
508
+ /**
509
+ * How many cells of `step` width, starting at `origin`, fit before `limit` —
510
+ * the largest `n >= 0` with `origin + n * step <= limit`.
511
+ *
512
+ * v3.6.0 Task 4 fix round 1 (Critical, ruling R14): once every lane can carry
513
+ * its own `cycleWidth` (via `period`/`hscale`), the "paint one past a short
514
+ * lane" affordance can no longer borrow the DIAGRAM's overall cycle count
515
+ * (`layout.cycles`) and multiply it by THIS lane's pixel width — that was
516
+ * exactly the bug: the two only ever agreed while every lane shared one
517
+ * `cycleWidth`, and diverged the moment `period` did not. `layout.width` is
518
+ * itself built from this same per-lane `originX + n * cycleWidth` expression
519
+ * (see `layoutOf`), so asking "how many of THIS lane's own cells fit inside
520
+ * that width" is the one bound guaranteed never to exceed it — R14(a).
521
+ *
522
+ * A floor-division estimate is only a proposal, for the identical reason
523
+ * `indexAt` above does not trust its own division: subtraction and division
524
+ * round differently, so the answer is snapped against the exact expression
525
+ * `cellRect` builds a cell with (`origin + n * step`), not derived from it
526
+ * algebraically. This is the same technique, run at the boundary between
527
+ * "reachable" and "not", instead of between one cell and the next.
528
+ */
529
+ function reachOf(origin, step, limit) {
530
+ let n = Math.floor((limit - origin) / step);
531
+ if (n < 0) n = 0;
532
+ while (n > 0 && origin + n * step > limit) n--;
533
+ while (origin + (n + 1) * step <= limit) n++;
534
+ return n;
535
+ }
536
+
261
537
  /**
262
538
  * Which cell a point is in, or `null` for the name column, outside the diagram,
263
539
  * or a coordinate that is not a number.
@@ -269,21 +545,40 @@ function indexAt(pos, origin, step, count) {
269
545
  *
270
546
  * A cycle past the end of that particular lane still hits: the lane is shorter
271
547
  * than the diagram, painting there is how a lane gets extended, and the caller
272
- * can see it went past from `layout.lanes[laneIndex].cycles`.
548
+ * can see it went past from `layout.lanes[laneIndex].cycles`. How far past is
549
+ * bounded by `reachOf` against THIS lane's own `originX`/`cycleWidth` and
550
+ * `layout.width` (v3.6.0 Task 4 fix round 1, R14) — never by reusing another
551
+ * lane's cycle count against this lane's pixel width, which is what let a
552
+ * short, wide-`period` lane hand out cells entirely outside the canvas.
553
+ *
554
+ * A lane whose `phase` shifts it left of the diagram's own x=0 has that many
555
+ * of its leading cycles entirely off-canvas; per R14(a) a cell only counts as
556
+ * hit if its WHOLE rect is inside `[0, layout.width]`, so those leading
557
+ * cycles are excluded the same way cycles past `layout.width` are.
273
558
  */
274
559
  function cellAt(layout, x, y) {
275
560
  if (layout === null || typeof layout !== 'object' || !Array.isArray(layout.lanes)) {
276
561
  return null;
277
562
  }
278
563
  if (!isCoord(x) || !isCoord(y)) return null;
279
- if (x < layout.nameColWidth || x >= layout.width) return null;
280
- if (y < 0 || y >= layout.height) return null;
564
+ if (x < 0) return null;
565
+ // v3.6.0 Task 6: lane 0 no longer starts at y=0 — the ruler band (and an
566
+ // optional head-title band) sits above it, and a press there must land on
567
+ // NOTHING, not fall through to lane 0 the way a naive `y < 0` guard would.
568
+ if (y < layout.originY || y >= layout.originY + layout.lanes.length * layout.laneHeight) {
569
+ return null;
570
+ }
281
571
  if (layout.lanes.length === 0 || layout.cycles === 0) return null;
282
572
 
283
- return {
284
- laneIndex: indexAt(y, 0, layout.laneHeight, layout.lanes.length),
285
- cycle: indexAt(x, layout.nameColWidth, layout.cycleWidth, layout.cycles),
286
- };
573
+ const laneIndex = indexAt(y, layout.originY, layout.laneHeight, layout.lanes.length);
574
+ const row = layout.lanes[laneIndex];
575
+ const reach = reachOf(row.originX, row.cycleWidth, layout.width);
576
+ if (reach === 0) return null;
577
+ if (x < row.originX || x >= row.originX + reach * row.cycleWidth) return null;
578
+
579
+ const cycle = indexAt(x, row.originX, row.cycleWidth, reach);
580
+ if (row.originX + cycle * row.cycleWidth < 0) return null; // R14(a): left edge off-canvas
581
+ return { laneIndex: laneIndex, cycle: cycle };
287
582
  }
288
583
 
289
584
  /**
@@ -294,27 +589,365 @@ function cellAt(layout, x, y) {
294
589
  * top-left corner. A cycle past the diagram's width has no box, so extending a
295
590
  * lane is an edit the caller makes and then lays out again, not a rectangle it
296
591
  * can ask for in advance.
592
+ *
593
+ * v3.6.0 Task 4 fix round 1 (Critical, R14): "past the diagram's width" is now
594
+ * judged against THIS lane's own `originX`/`cycleWidth` via `reachOf`, not
595
+ * against `layout.cycles` (the diagram-wide cycle COUNT) — see `reachOf`'s own
596
+ * comment for why those two stopped being interchangeable the moment `period`
597
+ * could differ per lane. R14(a): every non-null rect returned here lies
598
+ * entirely within `[0, layout.width]`, both edges — a lane shifted left of
599
+ * x=0 by `phase` has that many of its own leading cycles return `null` too.
297
600
  */
298
601
  function cellRect(layout, laneIndex, cycle) {
299
602
  if (layout === null || typeof layout !== 'object' || !Array.isArray(layout.lanes)) {
300
603
  return null;
301
604
  }
302
605
  if (!isIndex(laneIndex) || laneIndex < 0 || laneIndex >= layout.lanes.length) return null;
303
- if (!isIndex(cycle) || cycle < 0 || cycle >= layout.cycles) return null;
606
+ if (!isIndex(cycle) || cycle < 0) return null;
304
607
 
305
608
  const row = layout.lanes[laneIndex];
609
+ const reach = reachOf(row.originX, row.cycleWidth, layout.width);
610
+ if (cycle >= reach) return null;
611
+ const x = row.originX + cycle * row.cycleWidth;
612
+ if (x < 0) return null; // R14(a): left edge off-canvas (a large positive `phase`)
613
+
306
614
  return {
307
- x: layout.nameColWidth + cycle * layout.cycleWidth,
615
+ x: x,
308
616
  y: row.y,
309
- width: layout.cycleWidth,
617
+ width: row.cycleWidth,
310
618
  height: row.height,
311
619
  };
312
620
  }
313
621
 
622
+ /** 端點把手的邊長(px)。夠大到手指按得到,小到不會蓋住相鄰格。 */
623
+ const EDGE_HANDLE = 12;
624
+
625
+ /**
626
+ * 一格的錨點——引擎畫 `node` 字母的那一點。
627
+ *
628
+ * 不是格子中心。使用者回報「連線應該要頭尾相接」,量到的病因是:v3.5.0 的畫布
629
+ * 把 edge 端點放在格子正中央,引擎的 `render-arcs.js` 卻把錨點放在
630
+ * `lane.xlabel`,也就是 skin 的 `socket.x`——`SKIN_METRICS.anchorRatio` 那個
631
+ * 比例——而轉態磚的斜坡正好在那一點跨過一半(`anchorRatio === (slewStartRatio +
632
+ * slewEndRatio) / 2`,是斜坡的中點、50% 跨越點,不是斜坡的終點——
633
+ * `SKIN_METRICS` 上方註解已經逐值對過引擎,R23 修正過這裡的舊說法)。MEASURED(pinned wavedrom
634
+ * 3.5.0):cycle 寬 40 時 a(cycle 0)的錨點在 x=6、b(cycle 2)在 x=86,
635
+ * 格線在 0/40/80。v3.5.0 用格子中心,於是畫布的線比引擎的右移了
636
+ * `(0.5 - anchorRatio)` 個 cycle。v3.6.0 Task 18 重算了這個偏移:引擎自己的
637
+ * 40px cycle 上是 **14px**(20 − 6),而使用者看到的是編輯器畫布的 48px cycle
638
+ * (`wave-ui.js` 的 `SIZES`),也就是 **16.8px**——先前這裡寫的「17px@40px
639
+ * cycle」把量到的距離(48px 尺度)配了錯的尺度名。一條標注上升緣的線因此
640
+ * 肉眼可見地沒有接上那個緣。
641
+ *
642
+ * 沿用 `cellRect` 而不是自己重算一次格子的位置——那一格「在不在圖裡」只有
643
+ * 一個答案,`cellRect`/`reachOf` 已經是它,錨點只是在那個答案上再加一個
644
+ * 固定比例的偏移。
645
+ */
646
+ function anchorOfCell(layout, laneIndex, cell) {
647
+ const r = cellRect(layout, laneIndex, cell);
648
+ if (r === null) return null;
649
+ return {
650
+ x: r.x + SKIN_METRICS.anchorRatio * r.width,
651
+ y: r.y + r.height / 2,
652
+ };
653
+ }
654
+
655
+ /**
656
+ * `(x, y)` 最接近哪一個 cell 的錨點。
657
+ *
658
+ * 用 round 而不是 `cellAt` 的 floor:`cellAt` 回答「滑鼠現在壓在哪一格上」,
659
+ * 目標是游標底下那一格,floor 是對的——格子的左緣屬於自己是 `cellAt` 存在的
660
+ * 理由。`boundaryAt` 回答的是另一個問題:使用者想把 edge 端點釘在哪一個
661
+ * 「線」上,那條線畫在錨點,不是格子邊界,使用者的眼睛瞄準的是那個點,往左
662
+ * 偏一點點不該跳到前一格的錨點去。兩個函式故意不同,不是選了兩種寫法裡的
663
+ * 一種將就,統一成同一個以後有人「簡化」掉這個差異,行為就會跟著壞掉。
664
+ *
665
+ * 上界跟 `cellRect` 用的是同一顆 `reachOf`,不是 `layout.cycles`(整張圖的
666
+ * cycle 數)——v3.6.0 Task 4 fix round 1(R14)已經證明過後者在每條 lane
667
+ * 自己的 `period`/`hscale` 不同時會跟這條 lane 自己的像素寬度對不上,
668
+ * `boundaryAt` 若另外相信 `layout.cycles`,就是同一個「兩處定義,中間那層
669
+ * 假設它們一致」的錯誤又長出一次:這裡要保證的不變量是「`boundaryAt` 回傳
670
+ * 的 cell,`cellRect` 對它永遠不是 null」,兩個函式各自的上界只能有一份。
671
+ *
672
+ * v3.6.0 Task 5 fix round 1(Critical):`reachOf` 只是 `cellRect` 兩道關卡的
673
+ * 右邊那一道,左邊那一道(`phase` 把 `originX` 推成負值時,這一格的左緣整個
674
+ * 在畫面外——R14(a))先前這裡漏掉了,於是 `boundaryAt` 對著一條大 `phase` 的
675
+ * lane 會把左邊那幾格也算進可觸及範圍,回傳一個 `cellRect` 判成 null 的
676
+ * cell,UI 拿著它去編輯會寫到不存在的地方。修法不是另外發明一個左界算式,
677
+ * 是照抄 `cellRect` 自己判左緣的那一行(`row.originX + cell * row.cycleWidth
678
+ * < 0`)——兩個函式判斷「這一格在不在畫面裡」只能有一份答案,`reachOf` 判
679
+ * 右邊、這一行判左邊,加起來才是 `cellRect` 的完整兩道關卡。
680
+ */
681
+ function boundaryAt(layout, x, y) {
682
+ if (layout === null || typeof layout !== 'object' || !Array.isArray(layout.lanes)) {
683
+ return null;
684
+ }
685
+ if (!isCoord(x) || !isCoord(y)) return null;
686
+ // v3.6.0 Task 6: same vertical origin shift as `cellAt` — a press in the
687
+ // ruler/head band must not resolve to lane 0. `boundaryAt` picks the
688
+ // NEAREST boundary rather than floor-dividing, but it still has to agree
689
+ // with `cellAt` on where lane 0's own row starts; two different vertical
690
+ // origins in the same file is exactly the "two definitions, one layer
691
+ // assumes they agree" defect class this codebase has already paid for.
692
+ if (y < layout.originY || y >= layout.originY + layout.lanes.length * layout.laneHeight) {
693
+ return null;
694
+ }
695
+ if (layout.lanes.length === 0 || layout.cycles === 0) return null;
696
+ const laneIndex = indexAt(y, layout.originY, layout.laneHeight, layout.lanes.length);
697
+ const row = layout.lanes[laneIndex];
698
+ const reach = reachOf(row.originX, row.cycleWidth, layout.width);
699
+ if (reach === 0) return null;
700
+ const raw = (x - row.originX - SKIN_METRICS.anchorRatio * row.cycleWidth) / row.cycleWidth;
701
+ let cell = Math.round(raw);
702
+ if (cell < 0) cell = 0;
703
+ if (cell > reach - 1) cell = reach - 1;
704
+ if (row.originX + cell * row.cycleWidth < 0) return null; // R14(a): same left-edge exclusion as cellRect
705
+ return { laneIndex: laneIndex, cell: cell + 0 }; // `+0` folds a possible -0, same reason as `indexAt`
706
+ }
707
+
708
+ /**
709
+ * 一條 lane 的轉態 cell。
710
+ *
711
+ * v3.6.0 Task 11 fix round 3:這裡原本寫「一個 cycle 若電位跟前一個 cycle
712
+ * 不同(或是文件第一拍本身),使用者才會想把 edge 端點釘在那裡」——那句話
713
+ * 描述的是另一支函式,不是下面這支。真正的規則是「引擎會在那一格畫出東西
714
+ * 的那些格」:wave 字串裡每一個顯式宣告電位的字元(不是 `.`、不是 `|`),
715
+ * 包含跟前一格同值的顯式重複,因為引擎在那裡照樣畫出東西——一般電位在
716
+ * 顯式重複處是 `0m0`/`1m1` 的凹痕,bus 是 `vmv-*` 的接縫,`.`(延續上一格)
717
+ * 才是真正「什麼都不畫」的那一種。MEASURED(pinned wavedrom 3.5.0,逐
718
+ * half-brick 讀出來的):
719
+ *
720
+ * '0011' -> 000, 000, 0m0, 000, 0m1, 111, 1m1, 111 顯式重複處也有凹痕
721
+ * '0.1.' -> 000, 000, 000, 000, 0m1, 111, 111, 111 用 `.` 的地方什麼都沒有
722
+ * '22' -> vvv-2, vvv-2, vmv-2-2, vvv-2 bus 顯式重複也有接縫
723
+ *
724
+ * 「跟前一個 cycle 電位不同」不是這條規則、也不能拿來當判斷式:`0011` 的
725
+ * 第二格是顯式 `0`、跟前一格同值,量到的 `0m0` 證明它依然是引擎會畫出東西
726
+ * 的一格,是真的轉態。若照著那句錯的舊註解把這支函式「修」成比對前後電位
727
+ * 是否相同,會把這一格漏掉——這正是這次要防的那個方向的錯,不是要修的
728
+ * 方向。
729
+ *
730
+ * (我們自己的畫布不畫這些凹痕——`brickPath` 在 `prevLevel === level`
731
+ * 時回傳一條平線,見 `wave-draw.js`——所以一顆轉態標記有可能落在畫布上什麼
732
+ * 都沒畫的地方。這是畫磚層本身的真實落差,v3.5.0 就已經如此,不在這個
733
+ * function 的職責範圍內,這裡只負責如實描述 `transitionsOf` 自己做的事。)
734
+ *
735
+ * 資料問的是 `codec.expandWave`,跟畫布展開波形用的是同一份:另寫一份展開
736
+ * 邏輯會跟它漂移,那正是這個版本要清掉的病。`held`(來源寫的是 `.`)不算
737
+ * 轉態;`|`(斷點)本身也不算,但它後面那格算——斷點之後電位重新宣告,即使
738
+ * 值沒變,那正是「不算 `.`」的同一條規則自然帶出來的結果。
739
+ *
740
+ * 走訪順序是 codec 攤平後的顯示序,跟 `layoutOf`/`enumerateLanes` 問
741
+ * `codec.lanePaths` 是同一份列舉:直接問它,而不是先 `layoutOf` 整份文件只
742
+ * 為了讀一個欄位——`layoutOf` 的列舉是 O(N)、量到的成本見上方 `MAX_LANES`
743
+ * 的註解,但它同時算版面的每一格座標、每條 lane 的 cycles,這裡只需要一條
744
+ * lane 的 `wave` 字串,兩者要做的工不同,沒有理由把後者的工都做一遍。
745
+ * `MAX_LANES` 這條線在這裡也照樣守:超過上限的 lane 對 `layoutOf` 是截斷、
746
+ * 不可觸及,`transitionsOf` 問同一個 lane 也該是同一個「不可觸及」的答案,
747
+ * 不能因為抄了近路就多看見一條 `layoutOf` 看不到的 lane。
748
+ */
749
+ function transitionsOf(doc, laneIndex) {
750
+ if (doc === null || typeof doc !== 'object') return [];
751
+ const paths = codec.lanePaths(doc);
752
+ const n = paths.length < MAX_LANES ? paths.length : MAX_LANES;
753
+ if (!isIndex(laneIndex) || laneIndex < 0 || laneIndex >= n) return [];
754
+ const lane = valueAt(doc, paths[laneIndex]);
755
+ const wave = (lane !== null && typeof lane === 'object' && typeof lane.wave === 'string') ?
756
+ lane.wave : '';
757
+ const cells = codec.expandWave(wave);
758
+ const out = [];
759
+ for (let i = 0; i < cells.length; i += 1) {
760
+ if (cells[i].held === true) continue;
761
+ if (cells[i].ch === '|') continue;
762
+ out.push(i);
763
+ }
764
+ return out;
765
+ }
766
+
767
+ /**
768
+ * 每條 edge 的兩個端點座標、SVG path 與標籤錨點。
769
+ *
770
+ * 字母的位置問 `codec.nodesOf(doc)`,不自己走樹——`layoutOf` 已經立下這個規矩
771
+ * (它問 `codec.lanePaths`),第二份走樹邏輯會跟它漂移。用的是本檔案頂端那顆
772
+ * 模組層級的 `codec`(`enumerateLanes`/`cyclesOf` 也是同一顆):這個檔案已經
773
+ * 對 `wave-codec.js` 有一份 `require`,purity guard 也只准這一份,injected 參數
774
+ * 不會讓檔案更「純」,只會讓同名綁定的讀者分不清是哪一顆、也讓少傳一個參數的
775
+ * 改動悄悄落回外層而不報錯。
776
+ *
777
+ * 引用到不存在字母的 edge 直接跳過:那是一份使用者手寫壞掉的文件,畫不出那條線
778
+ * 是對的,讓整張圖倒掉不是。
779
+ */
780
+ /**
781
+ * `edgeLayout`'s three `continue`s, WITH the reason each one fired —
782
+ * final review finding 3. `edgeLayout` itself stays a thin wrapper around
783
+ * this (`.drawn`), so every existing caller keeps its array-of-drawn-edges
784
+ * shape; a caller that also needs to explain a gap (`renderUnmodelled` in
785
+ * `wave-ui.js`) asks this one instead of re-deriving any of the three checks
786
+ * from scratch — there is exactly one walk of `doc.edge`, and exactly one
787
+ * place that decides whether an entry counts as drawn.
788
+ *
789
+ * `skipped[i].reason` is one of:
790
+ * - `'unparseable'` — `codec.parseEdge` returned `null` (the shape is not
791
+ * one of `EDGE_SHAPES`, or the entry does not have `<letter><shape><letter>`
792
+ * to begin with). `edge` is `null`; there is no `from`/`to` to report.
793
+ * - `'dangling'` — the shape parsed, but `from` or `to` names a letter
794
+ * `codec.nodesOf` never placed anywhere in the document (a hand-edited or
795
+ * half-finished `edge:` array). This is the one case the pinned engine
796
+ * ALSO refuses to draw — measured against `node_modules/wavedrom`, an
797
+ * edge naming an absent letter renders nothing, same as here.
798
+ * - `'out-of-range'` — the shape parsed and both letters resolve to a real
799
+ * `{lane, cell}`, but that cell has no box in this layout: `anchorOfCell`
800
+ * returned `null`, which happens when a lane's `node` string outlives the
801
+ * lane itself (`insertCycles`/`deleteCycles` narrow `wave` but do not
802
+ * reindex `node`, so a deleted cycle can leave an anchor past the lane's
803
+ * new length). Measured against the pinned engine, THIS case is the
804
+ * opposite of `'dangling'`: the engine draws a line for it anyway, so a
805
+ * silent skip here is exactly the canvas/preview divergence this version
806
+ * exists to prevent.
807
+ *
808
+ * Anything reaching this file only through `doc.edge` being non-empty but
809
+ * `layout` being unusable, or `doc` itself not carrying an `edge` array,
810
+ * still returns `{drawn: [], skipped: []}` — there is nothing to classify,
811
+ * not a fourth reason.
812
+ */
813
+ function edgeLayoutDetail(doc, layout) {
814
+ const drawn = [];
815
+ const skipped = [];
816
+ if (doc === null || typeof doc !== 'object' || !Array.isArray(doc.edge)) {
817
+ return { drawn: drawn, skipped: skipped };
818
+ }
819
+ if (layout === null || typeof layout !== 'object') {
820
+ return { drawn: drawn, skipped: skipped };
821
+ }
822
+ const nodes = codec.nodesOf(doc);
823
+ for (let i = 0; i < doc.edge.length; i += 1) {
824
+ const edge = codec.parseEdge(doc.edge[i]);
825
+ if (edge === null) {
826
+ skipped.push({ index: i, edge: null, reason: 'unparseable' });
827
+ continue;
828
+ }
829
+ const a = nodes[edge.from];
830
+ const b = nodes[edge.to];
831
+ if (a === undefined || b === undefined) {
832
+ skipped.push({ index: i, edge: edge, reason: 'dangling' });
833
+ continue;
834
+ }
835
+ const from = anchorOfCell(layout, a.at, a.cell);
836
+ const to = anchorOfCell(layout, b.at, b.cell);
837
+ if (from === null || to === null) {
838
+ skipped.push({ index: i, edge: edge, reason: 'out-of-range' });
839
+ continue;
840
+ }
841
+ from.at = a.at; from.cell = a.cell;
842
+ to.at = b.at; to.cell = b.cell;
843
+ drawn.push({
844
+ index: i,
845
+ edge: edge,
846
+ from: from,
847
+ to: to,
848
+ d: pathFor(edge.shape, from, to),
849
+ labelPos: { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 },
850
+ });
851
+ }
852
+ return { drawn: drawn, skipped: skipped };
853
+ }
854
+
855
+ function edgeLayout(doc, layout) {
856
+ return edgeLayoutDetail(doc, layout).drawn;
857
+ }
858
+
859
+ /**
860
+ * 折線(`-|` 系)三種轉角次序,抄自 pin 住的
861
+ * node_modules/wavedrom/lib/arc-shape.js 的三組 case(座標系裡的 `m` 相對位移
862
+ * 換成這裡的絕對 `L`):
863
+ *
864
+ * - MID:先橫走半程、再直走全程、再橫走半程——轉角在 `(from.x+to.x)/2`。
865
+ * - VERT_FIRST:先直(`from.x, to.y`)再橫——`|-` 系,垂直先行。
866
+ * - 其餘(HORIZ_FIRST):先橫(`to.x, from.y`)再直——`-|` 系,水平先行。
867
+ *
868
+ * 三個陣列各自窮舉 EDGE_SHAPES 裡屬於這族的每個成員(含箭頭與雙箭頭變體),
869
+ * 用精確字串比對而非子字串——shape 一律來自 `codec.parseEdge` 收斂過的
870
+ * EDGE_SHAPES 集合,不會有夾雜字元,子字串比對(舊寫法 `has('|')`)分不出
871
+ * `-|` 與 `|-` 誰先誰後,是這裡曾經量到的缺陷。
872
+ */
873
+ const ELBOW_MID = ['-|-', '-|->', '<-|->'];
874
+ const ELBOW_VERT_FIRST = ['|-', '|->'];
875
+ const ELBOW_HORIZ_FIRST = ['-|', '-|>', '<-|>'];
876
+
877
+ /**
878
+ * 形狀 → SVG path。
879
+ *
880
+ * 三族:直線(`-` 系)、曲線(`~` 系)、折線(`-|` 系)。箭頭本身不在 path 裡,
881
+ * 由 UI 掛 marker——這裡只負責「線走哪裡」。
882
+ */
883
+ function pathFor(shape, from, to) {
884
+ if (ELBOW_MID.indexOf(shape) !== -1) {
885
+ const midX = (from.x + to.x) / 2;
886
+ return 'M' + from.x + ',' + from.y + ' L' + midX + ',' + from.y +
887
+ ' L' + midX + ',' + to.y + ' L' + to.x + ',' + to.y;
888
+ }
889
+ if (ELBOW_VERT_FIRST.indexOf(shape) !== -1) {
890
+ return 'M' + from.x + ',' + from.y + ' L' + from.x + ',' + to.y +
891
+ ' L' + to.x + ',' + to.y;
892
+ }
893
+ if (ELBOW_HORIZ_FIRST.indexOf(shape) !== -1) {
894
+ return 'M' + from.x + ',' + from.y + ' L' + to.x + ',' + from.y +
895
+ ' L' + to.x + ',' + to.y;
896
+ }
897
+ const has = function (s) { return shape.indexOf(s) !== -1; };
898
+ if (has('~')) {
899
+ const dx = (to.x - from.x) / 2;
900
+ return 'M' + from.x + ',' + from.y +
901
+ ' C' + (from.x + dx) + ',' + from.y + ' ' + (to.x - dx) + ',' + to.y +
902
+ ' ' + to.x + ',' + to.y;
903
+ }
904
+ return 'M' + from.x + ',' + from.y + ' L' + to.x + ',' + to.y;
905
+ }
906
+
907
+ /** 端點把手的矩形,以端點為中心。 */
908
+ function edgeHandleRect(point) {
909
+ return {
910
+ x: point.x - EDGE_HANDLE / 2,
911
+ y: point.y - EDGE_HANDLE / 2,
912
+ width: EDGE_HANDLE,
913
+ height: EDGE_HANDLE,
914
+ };
915
+ }
916
+
917
+ /**
918
+ * `(x, y)` 命中哪一個端點把手。
919
+ *
920
+ * 用的是 `edgeHandleRect` 產生的同一個矩形——命中與繪製共用一個運算式,
921
+ * 就像 `cellAt` 是 `cellRect` 的構造反函數。另寫一份圓形碰撞會慢慢跟畫面分家。
922
+ */
923
+ function edgeHandleAt(edges, x, y) {
924
+ if (!Array.isArray(edges)) return null;
925
+ for (let i = edges.length - 1; i >= 0; i -= 1) {
926
+ for (const end of ['to', 'from']) {
927
+ const r = edgeHandleRect(edges[i][end]);
928
+ if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
929
+ return { index: edges[i].index, end: end };
930
+ }
931
+ }
932
+ }
933
+ return null;
934
+ }
935
+
314
936
  module.exports = {
315
937
  layoutOf: layoutOf,
938
+ valueAt: valueAt,
939
+ groupSpansOf: groupSpansOf,
316
940
  cellAt: cellAt,
317
941
  cellRect: cellRect,
942
+ anchorOfCell: anchorOfCell,
943
+ boundaryAt: boundaryAt,
944
+ transitionsOf: transitionsOf,
945
+ edgeLayout: edgeLayout,
946
+ edgeLayoutDetail: edgeLayoutDetail,
947
+ edgeHandleRect: edgeHandleRect,
948
+ edgeHandleAt: edgeHandleAt,
949
+ EDGE_HANDLE: EDGE_HANDLE,
318
950
  DEFAULTS: DEFAULTS,
319
951
  MAX_LANES: MAX_LANES,
952
+ SKIN_METRICS: SKIN_METRICS,
320
953
  };