@diagrammo/dgmo 0.39.0 → 0.41.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.
@@ -74,12 +74,20 @@ const DATE_OFFSET = 28;
74
74
  // Era `]` bracket band: depth reserved on the side opposite the cards.
75
75
  const ERA_BLOCK = 30;
76
76
  const ERA_BRACKET_CAP = 8;
77
+ // Padding each era bracket extends past its first/last member dot, and the gap
78
+ // kept on each side of a seam when two adjacent brackets are clamped apart.
79
+ const ERA_BRACKET_PAD = 14;
80
+ const ERA_SEAM_GAP = 3;
81
+ // Collapsed-era axis-break glyph (`≈` rotated 90°): two short parallel wavy
82
+ // strokes that cross the spine, with the spine blanked between them so the
83
+ // timeline visibly breaks where the folded span is.
84
+ const BREAK_HALF_H = 6; // squiggle extends this far above AND below the spine
85
+ const BREAK_AMP = 2.2; // horizontal wave amplitude
86
+ const BREAK_GAP = 5; // spacing between the two waves (= the spine gap width)
77
87
  // Collapsed era: the `⊓` bar floats this far off the spine (on the card's side);
78
88
  // its legs drop from the bar to rest their feet ON the timeline.
79
89
  const ERA_LEG = 13;
80
90
  const ERA_LABEL_FONT = 11.5;
81
- // A collapsed era folds its members into one card; cap the bulleted member list.
82
- const ERA_MEMBER_MAX = 6;
83
91
 
84
92
  type Side = 'above' | 'below';
85
93
 
@@ -106,6 +114,9 @@ interface Placed {
106
114
  side: Side;
107
115
  lane: number;
108
116
  left: number;
117
+ /** Collapsed era only: half the date-span width in px, so its bracket stretches
118
+ * across the folded run (`x` is the span MIDPOINT). 0 for events. */
119
+ spanHalf: number;
109
120
  }
110
121
 
111
122
  export function renderEventLine(
@@ -156,9 +167,6 @@ export function renderEventLine(
156
167
  const hasExpandedEra = parsed.events.some(
157
168
  (e) => e.era && eraByName.has(e.era) && !collapsedSet.has(e.era)
158
169
  );
159
- const anyCollapsed = parsed.events.some(
160
- (e) => e.era && collapsedSet.has(e.era)
161
- );
162
170
  const sideOpt = parsed.options.side;
163
171
  const alternate = sideOpt === 'alternate';
164
172
  const eraSide: Side = alternate
@@ -249,6 +257,7 @@ export function renderEventLine(
249
257
  side,
250
258
  lane: 0,
251
259
  left: 0,
260
+ spanHalf: 0,
252
261
  };
253
262
  }
254
263
  // Collapsed era → an event-like summary card: era name + bulleted members.
@@ -274,21 +283,26 @@ export function renderEventLine(
274
283
  palette.textOnFillLight,
275
284
  palette.textOnFillDark
276
285
  );
277
- const overflow = slot.members.length > ERA_MEMBER_MAX;
278
- const shown = overflow
279
- ? slot.members.slice(0, ERA_MEMBER_MAX - 1)
280
- : slot.members;
281
- const memberStrs = shown.map(
286
+ // A collapsed era folds a date RANGE into one card. Anchor it at the span
287
+ // MIDPOINT so its card sits over the middle of the run, and its bracket (drawn
288
+ // later) stretches across the whole [first … last] member range — the folded
289
+ // span keeps its true timeline footprint instead of collapsing to a point.
290
+ let spanLo: number | null = null;
291
+ let spanHi: number | null = null;
292
+ for (const m of slot.members) {
293
+ if (m.dateValue === null) continue;
294
+ if (spanLo === null || m.dateValue < spanLo) spanLo = m.dateValue;
295
+ if (spanHi === null || m.dateValue > spanHi) spanHi = m.dateValue;
296
+ }
297
+ const repDateValue =
298
+ spanLo !== null && spanHi !== null ? (spanLo + spanHi) / 2 : null;
299
+ // Show every folded member — a collapsed era summarizes the whole run, so
300
+ // the card grows to fit rather than truncating with a "+N more" line.
301
+ const memberStrs = slot.members.map(
282
302
  (m) => `• ${m.date ? `${m.date} ` : ''}${m.label}`
283
303
  );
284
- const bulletColors = shown.map(eventColor);
304
+ const bulletColors = slot.members.map(eventColor);
285
305
  const lines = wrapDescription(memberStrs, charsPerLine);
286
- if (overflow) {
287
- lines.push({
288
- text: `+${slot.members.length - (ERA_MEMBER_MAX - 1)} more`,
289
- kind: 'plain',
290
- });
291
- }
292
306
  return {
293
307
  kind: 'era',
294
308
  event: null,
@@ -296,7 +310,7 @@ export function renderEventLine(
296
310
  members: slot.members,
297
311
  label: era.name,
298
312
  date: null,
299
- dateValue: null,
313
+ dateValue: repDateValue,
300
314
  lineNumber: era.lineNumber,
301
315
  eraName: era.name,
302
316
  color: solid,
@@ -309,126 +323,265 @@ export function renderEventLine(
309
323
  side,
310
324
  lane: 0,
311
325
  left: 0,
326
+ spanHalf: 0,
312
327
  };
313
328
  });
314
329
 
315
330
  // ── X positions ──
316
- // Collapse re-flows the spine, which breaks a linear date scale → fall back to
317
- // even spacing when any era is collapsed (broken-axis is a fast-follow).
331
+ // Draw to scale whenever every slot has a date — a collapsed era is anchored at
332
+ // its earliest member (above), so it scales like any other point. Even spacing
333
+ // is reserved for explicit `no-scale` or a slot that genuinely lacks a date.
318
334
  const scaled =
319
- parsed.options.scale &&
320
- !anyCollapsed &&
321
- placed.every((p) => p.dateValue !== null);
335
+ parsed.options.scale && placed.every((p) => p.dateValue !== null);
322
336
  const innerW = width - H_MARGIN * 2;
323
- if (scaled) {
324
- const vals = placed.map((p) => p.dateValue!);
325
- const lo = Math.min(...vals);
326
- const hi = Math.max(...vals);
327
- placed.forEach((p) => {
328
- p.x =
329
- H_MARGIN +
330
- (hi > lo ? ((p.dateValue! - lo) / (hi - lo)) * innerW : innerW / 2);
337
+ const MIN_DOT_GAP = 16; // legibility floor between coincident-ish dots
338
+ const STRETCH_CAP = 8; // widen the date axis at most this many ×
339
+
340
+ // BROKEN AXIS: a collapsed era is COMPRESSED to a fixed-width capsule (its
341
+ // squiggle says "lots of timeline folded here"), so the expanded events take
342
+ // over the freed width and draw to scale among themselves. The timeline is a
343
+ // chronological sequence of SEGMENTS — a capsule for each collapsed era, and a
344
+ // to-scale "run" for each maximal stretch of expanded events between them. Dead
345
+ // time (gaps with no expanded events) costs only a fixed `SEG_GAP`, never width.
346
+ const COLLAPSE_W = 64; // fixed px width of a collapsed-era capsule
347
+ const SEG_GAP = 16; // gap between adjacent axis segments
348
+ type Seg =
349
+ | { kind: 'capsule'; p: Placed }
350
+ | { kind: 'run'; events: Placed[]; lo: number; hi: number };
351
+ const segments: Seg[] = [];
352
+ let run: Placed[] = [];
353
+ const flushRun = (): void => {
354
+ if (!run.length) return;
355
+ const ds = run.map((e) => e.dateValue!);
356
+ segments.push({
357
+ kind: 'run',
358
+ events: run,
359
+ lo: Math.min(...ds),
360
+ hi: Math.max(...ds),
331
361
  });
332
- // Nudge near-coincident dots apart so they read as distinct (and give their
333
- // labels room). Events with the SAME date keep a shared position; only
334
- // distinct times are separated.
335
- const MIN_DOT_GAP = 16;
336
- let prevX = -Infinity;
337
- let prevVal: number | null = null;
338
- for (const p of [...placed].sort((a, b) => a.x - b.x)) {
339
- if (p.dateValue === prevVal) {
340
- p.x = prevX;
341
- continue;
342
- }
343
- if (p.x < prevX + MIN_DOT_GAP) p.x = prevX + MIN_DOT_GAP;
344
- prevX = p.x;
345
- prevVal = p.dateValue;
362
+ run = [];
363
+ };
364
+ for (const p of placed) {
365
+ if (p.kind === 'era') {
366
+ flushRun();
367
+ segments.push({ kind: 'capsule', p });
368
+ } else {
369
+ run.push(p);
346
370
  }
347
- } else {
348
- const n = placed.length;
349
- const spacing = n > 1 ? Math.max(MIN_SPACING, innerW / (n - 1)) : 0;
350
- placed.forEach((p, i) => {
351
- p.x = H_MARGIN + i * spacing;
352
- });
353
371
  }
354
- // ── Horizontal placement: fan side-by-side, stack into lanes when too tight ──
355
- // Each card keeps its dot within [left+INSET, left+CARD_W-INSET] so a vertical
356
- // leader lands on it. A card joins the innermost lane where it clears the
357
- // previous card in that lane by FAN_GAP; otherwise it opens a new lane. Cards
358
- // are NEVER pulled back over a neighbour (no right-edge clamp) — boxes must
359
- // never overlap, so the canvas grows instead (contentW below).
360
- for (const side of ['above', 'below'] as Side[]) {
361
- const arr = placed.filter((p) => p.side === side).sort((a, b) => a.x - b.x);
362
- const laneRight: number[] = [];
363
- for (let i = 0; i < arr.length; i++) {
364
- const p = arr[i]!;
365
- const next = arr[i + 1];
366
- const crowdedRight = !!next && next.x - p.x < CARD_W + FAN_GAP;
367
- const preferred = crowdedRight
368
- ? p.x - CARD_W + CARD_INSET
369
- : p.x - CARD_W / 2;
370
- const maxLeft = p.x - CARD_INSET;
371
- const minLeft = p.x - CARD_W + CARD_INSET;
372
- // Default: open a fresh lane at the preferred position.
373
- let lane = laneRight.length;
374
- let left = Math.max(preferred, minLeft);
375
- // Prefer the innermost existing lane the card fits in side-by-side.
376
- for (let l = 0; l < laneRight.length; l++) {
377
- const want = Math.max(preferred, laneRight[l]! + FAN_GAP, minLeft);
378
- if (want <= maxLeft) {
379
- lane = l;
380
- left = want;
381
- break;
372
+ flushRun();
373
+ // Total to-scale time = the sum of every expanded run's own date span.
374
+ const totalRunTime = segments.reduce(
375
+ (s, seg) => s + (seg.kind === 'run' ? seg.hi - seg.lo : 0),
376
+ 0
377
+ );
378
+
379
+ // Position every entry by walking the segments left-to-right. Expanded runs
380
+ // collectively scale across `innerW × stretch`; capsules + inter-segment gaps add
381
+ // a fixed amount on top. `stretch` (≥1) widens the expanded scale to fill a wide
382
+ // panel without distorting proportions; a small MIN_DOT_GAP floor only de-overlaps
383
+ // coincident-ish dots. Re-runnable for any stretch so the search below probes freely.
384
+ const place = (stretch: number): void => {
385
+ if (scaled) {
386
+ const pxPerUnit =
387
+ totalRunTime > 0 ? (innerW * stretch) / totalRunTime : 0;
388
+ let cursor = H_MARGIN;
389
+ for (const seg of segments) {
390
+ if (seg.kind === 'capsule') {
391
+ seg.p.x = cursor + COLLAPSE_W / 2;
392
+ seg.p.spanHalf = COLLAPSE_W / 2;
393
+ cursor += COLLAPSE_W + SEG_GAP;
394
+ } else {
395
+ for (const e of seg.events) {
396
+ e.x = cursor + (e.dateValue! - seg.lo) * pxPerUnit;
397
+ e.spanHalf = 0;
398
+ }
399
+ cursor += (seg.hi - seg.lo) * pxPerUnit + SEG_GAP;
382
400
  }
383
401
  }
384
- p.lane = lane;
385
- p.left = Math.max(6, left); // clamp the LEFT edge only — never the right
386
- laneRight[lane] = p.left + CARD_W;
402
+ // Within a run, distinct dates that map closer than MIN_DOT_GAP get nudged
403
+ // apart for legibility; identical dates keep a shared x.
404
+ let prevX = -Infinity;
405
+ let prevVal: number | null = null;
406
+ for (const p of [...placed].sort((a, b) => a.x - b.x)) {
407
+ if (p.kind === 'era') {
408
+ prevX = p.x + p.spanHalf;
409
+ prevVal = null;
410
+ continue;
411
+ }
412
+ if (p.dateValue === prevVal) {
413
+ p.x = prevX;
414
+ continue;
415
+ }
416
+ if (p.x < prevX + MIN_DOT_GAP) p.x = prevX + MIN_DOT_GAP;
417
+ prevX = p.x;
418
+ prevVal = p.dateValue;
419
+ }
420
+ } else {
421
+ placed.forEach((p) => {
422
+ p.spanHalf = 0;
423
+ });
424
+ const n = placed.length;
425
+ const spacing = n > 1 ? Math.max(MIN_SPACING, innerW / (n - 1)) : 0;
426
+ placed.forEach((p, i) => {
427
+ p.x = H_MARGIN + i * spacing;
428
+ });
387
429
  }
388
- }
389
- // Grow the canvas to fit the widest card edge so nothing clips or overlaps;
390
- // the preview scales this to fit, so a wider box budget costs no real estate.
391
- const contentW = Math.max(
392
- width,
393
- Math.max(...placed.map((p) => p.x)) + H_MARGIN,
394
- Math.max(...placed.map((p) => p.left + CARD_W)) + 6
395
- );
396
- const rowGap = (side: Side): number =>
397
- Math.max(0, ...placed.filter((p) => p.side === side).map((p) => p.cardH)) +
398
- LANE_GAP;
399
- const rowGapA = rowGap('above');
400
- const rowGapB = rowGap('below');
401
- const ext = (side: Side): number =>
402
- Math.max(
403
- 0,
404
- ...placed
430
+ // ── Fan side-by-side, stack into lanes when too tight ──
431
+ // Each card keeps its dot within [left+INSET, left+CARD_W-INSET] so a vertical
432
+ // leader lands on it. A card joins the innermost lane where it clears the
433
+ // previous card in that lane by FAN_GAP; otherwise it opens a new lane. Cards
434
+ // are NEVER pulled back over a neighbour (no right-edge clamp) — boxes must
435
+ // never overlap, so the canvas grows instead (contentW below).
436
+ for (const side of ['above', 'below'] as Side[]) {
437
+ const arr = placed
405
438
  .filter((p) => p.side === side)
406
- .map(
407
- (p) =>
408
- (side === 'above' ? LEADER_ABOVE : LEADER_BELOW) +
409
- p.lane * (side === 'above' ? rowGapA : rowGapB) +
410
- p.cardH
411
- )
439
+ .sort((a, b) => a.x - b.x);
440
+ const laneRight: number[] = [];
441
+ for (let i = 0; i < arr.length; i++) {
442
+ const p = arr[i]!;
443
+ const next = arr[i + 1];
444
+ const crowdedRight = !!next && next.x - p.x < CARD_W + FAN_GAP;
445
+ const preferred = crowdedRight
446
+ ? p.x - CARD_W + CARD_INSET
447
+ : p.x - CARD_W / 2;
448
+ const maxLeft = p.x - CARD_INSET;
449
+ const minLeft = p.x - CARD_W + CARD_INSET;
450
+ let lane = laneRight.length;
451
+ let left = Math.max(preferred, minLeft);
452
+ for (let l = 0; l < laneRight.length; l++) {
453
+ const want = Math.max(preferred, laneRight[l]! + FAN_GAP, minLeft);
454
+ if (want <= maxLeft) {
455
+ lane = l;
456
+ left = want;
457
+ break;
458
+ }
459
+ }
460
+ p.lane = lane;
461
+ p.left = Math.max(6, left); // clamp the LEFT edge only — never the right
462
+ laneRight[lane] = p.left + CARD_W;
463
+ }
464
+ }
465
+ };
466
+
467
+ interface Layout {
468
+ contentW: number;
469
+ contentH: number; // unclamped content height — used to target the aspect
470
+ totalH: number;
471
+ spineY: number;
472
+ eraBaseY: number;
473
+ laneNear: (p: Placed) => number;
474
+ }
475
+
476
+ // Derive all size-dependent geometry from the current placement.
477
+ const derive = (): Layout => {
478
+ // Grow the canvas to fit the widest card edge so nothing clips or overlaps.
479
+ const contentW = Math.max(
480
+ width,
481
+ Math.max(...placed.map((p) => p.x + p.spanHalf)) + H_MARGIN,
482
+ Math.max(...placed.map((p) => p.left + CARD_W)) + 6
412
483
  );
413
- // Date labels sit on the side opposite their card; reserve room for that band
414
- // so a one-sided line (e.g. `side above`) doesn't push dates into the legend.
415
- const dateAbove = placed.some((p) => p.side === 'below' && p.date);
416
- const dateBelow = placed.some((p) => p.side === 'above' && p.date);
417
- const contentAbove = Math.max(ext('above'), dateAbove ? DATE_OFFSET + 10 : 0);
418
- const contentBelow = Math.max(ext('below'), dateBelow ? DATE_OFFSET + 10 : 0);
419
- // The era `]` bracket band lives beyond the content on the side opposite the cards.
420
- const aboveExt =
421
- contentAbove + (hasExpandedEra && eraSide === 'above' ? ERA_BLOCK : 0);
422
- const belowExt =
423
- contentBelow + (hasExpandedEra && eraSide === 'below' ? ERA_BLOCK : 0);
424
- const TOP_PAD = 14;
425
- const BOT_PAD = 14;
426
- const spineY = topUsed + TOP_PAD + aboveExt;
427
- const eraBaseY =
428
- eraSide === 'above'
429
- ? spineY - (contentAbove + 14)
430
- : spineY + (contentBelow + 14);
431
- const totalH = Math.max(heightHint, spineY + belowExt + BOT_PAD);
484
+ // Lanes are packed with VARIABLE heights: each lane is only as tall as its own
485
+ // tallest card, so a single 3-bullet card no longer pushes every lane on that
486
+ // side down by its height. `laneOffset[side][lane]` is the distance from the
487
+ // spine-side leader to that lane's near edge = Σ(prior lane heights + gap).
488
+ const laneOffset: Record<Side, number[]> = { above: [], below: [] };
489
+ for (const side of ['above', 'below'] as Side[]) {
490
+ const laneH: number[] = [];
491
+ for (const p of placed) {
492
+ if (p.side !== side) continue;
493
+ laneH[p.lane] = Math.max(laneH[p.lane] ?? 0, p.cardH);
494
+ }
495
+ const off: number[] = [];
496
+ let acc = 0;
497
+ for (let l = 0; l < laneH.length; l++) {
498
+ off[l] = acc;
499
+ acc += (laneH[l] ?? 0) + LANE_GAP;
500
+ }
501
+ laneOffset[side] = off;
502
+ }
503
+ const laneNear = (p: Placed): number =>
504
+ (p.side === 'above' ? LEADER_ABOVE : LEADER_BELOW) +
505
+ (laneOffset[p.side][p.lane] ?? 0);
506
+ const ext = (side: Side): number =>
507
+ Math.max(
508
+ 0,
509
+ ...placed
510
+ .filter((p) => p.side === side)
511
+ .map((p) => laneNear(p) + p.cardH)
512
+ );
513
+ // Date labels sit on the side opposite their card; reserve room for that band
514
+ // so a one-sided line (e.g. `side above`) doesn't push dates into the legend.
515
+ const dateAbove = placed.some((p) => p.side === 'below' && p.date);
516
+ const dateBelow = placed.some((p) => p.side === 'above' && p.date);
517
+ const contentAbove = Math.max(
518
+ ext('above'),
519
+ dateAbove ? DATE_OFFSET + 10 : 0
520
+ );
521
+ const contentBelow = Math.max(
522
+ ext('below'),
523
+ dateBelow ? DATE_OFFSET + 10 : 0
524
+ );
525
+ // The era `]` bracket band lives beyond the content opposite the cards.
526
+ const aboveExt =
527
+ contentAbove + (hasExpandedEra && eraSide === 'above' ? ERA_BLOCK : 0);
528
+ const belowExt =
529
+ contentBelow + (hasExpandedEra && eraSide === 'below' ? ERA_BLOCK : 0);
530
+ const TOP_PAD = 14;
531
+ const BOT_PAD = 14;
532
+ const spineY = topUsed + TOP_PAD + aboveExt;
533
+ const eraBaseY =
534
+ eraSide === 'above'
535
+ ? spineY - (contentAbove + 14)
536
+ : spineY + (contentBelow + 14);
537
+ const contentH = spineY + belowExt + BOT_PAD;
538
+ const totalH = Math.max(heightHint, contentH);
539
+ return { contentW, contentH, totalH, spineY, eraBaseY, laneNear };
540
+ };
541
+
542
+ // Choose the axis stretch. The preview/export both fit the whole diagram
543
+ // (`meet`), so when the natural layout is TALLER than the available panel the
544
+ // fit is height-limited and wastes horizontal space. Widen the date axis — which
545
+ // also shortens the layout (fewer lanes) — until the content aspect reaches the
546
+ // panel aspect, where the fit-scale peaks. Past that, extra width only shrinks
547
+ // it, so stop there. `DGMO_EVT_STRETCH` forces a fixed multiplier.
548
+ const envStretch = Number(
549
+ (globalThis as { process?: { env?: Record<string, string> } }).process
550
+ ?.env?.['DGMO_EVT_STRETCH']
551
+ );
552
+ let layout: Layout;
553
+ if (Number.isFinite(envStretch) && envStretch > 0) {
554
+ place(envStretch);
555
+ layout = derive();
556
+ } else {
557
+ place(1);
558
+ layout = derive();
559
+ const panelAspect = width / heightHint;
560
+ const aspectOf = (l: Layout): number => l.contentW / l.contentH;
561
+ if (
562
+ scaled &&
563
+ layout.contentH > heightHint &&
564
+ aspectOf(layout) < panelAspect
565
+ ) {
566
+ place(STRETCH_CAP);
567
+ const capped = derive();
568
+ if (aspectOf(capped) <= panelAspect) {
569
+ layout = capped; // even the max stretch can't reach the panel aspect
570
+ } else {
571
+ let loF = 1;
572
+ let hiF = STRETCH_CAP;
573
+ for (let it = 0; it < 8; it++) {
574
+ const mid = (loF + hiF) / 2;
575
+ place(mid);
576
+ if (aspectOf(derive()) >= panelAspect) hiF = mid;
577
+ else loF = mid;
578
+ }
579
+ place(hiF);
580
+ layout = derive();
581
+ }
582
+ }
583
+ }
584
+ const { contentW, totalH, spineY, eraBaseY, laneNear } = layout;
432
585
 
433
586
  // ── SVG root ──
434
587
  const svg = d3Selection
@@ -489,9 +642,9 @@ export function renderEventLine(
489
642
  }
490
643
 
491
644
  // ── Spine ──
492
- const xs = placed.map((p) => p.x);
493
- const x0 = Math.min(...xs);
494
- const x1 = Math.max(...xs);
645
+ // Extend to cover collapsed-era brackets, which reach p.x ± spanHalf.
646
+ const x0 = Math.min(...placed.map((p) => p.x - p.spanHalf));
647
+ const x1 = Math.max(...placed.map((p) => p.x + p.spanHalf));
495
648
  svg
496
649
  .append('line')
497
650
  .attr('x1', x0 - 20)
@@ -507,9 +660,7 @@ export function renderEventLine(
507
660
  // BEHIND every card (a card always covers the leaders beneath it).
508
661
  const geo = placed.map((p) => {
509
662
  const near =
510
- p.side === 'above'
511
- ? spineY - LEADER_ABOVE - p.lane * rowGapA
512
- : spineY + LEADER_BELOW + p.lane * rowGapB;
663
+ p.side === 'above' ? spineY - laneNear(p) : spineY + laneNear(p);
513
664
  const top = p.side === 'above' ? near - p.cardH : near;
514
665
  return { p, near, top };
515
666
  });
@@ -737,12 +888,13 @@ export function renderEventLine(
737
888
  // ── Dots (events) + span brackets (collapsed eras) on the spine ──
738
889
  for (const p of placed) {
739
890
  if (p.kind === 'era') {
740
- // A collapsed era terminates on the spine as a `⊓` spanning a width
741
- // representative of the events it folds (wider = more members). No dot, no
742
- // separate bottom bracket — the card title names it, this marks its
743
- // footprint. Matches the card's color so the leader reads straight through.
891
+ // A collapsed era terminates on the spine as a `⊓` stretched across its
892
+ // folded date range (`p.spanHalf`), so it keeps its true timeline footprint;
893
+ // a minimum width keeps a single-date era visible. No dot, no separate bottom
894
+ // bracket — the card title names it. Matches the card's color so the leader
895
+ // reads straight through.
744
896
  const col = p.color;
745
- const half = Math.min(46, 16 + (p.members.length - 1) * 9);
897
+ const half = Math.max(16, p.spanHalf);
746
898
  // Clamp the legs to the drawn spine extent so the feet always land ON the
747
899
  // line (never poking off the left/right end of the timeline).
748
900
  const spineLeft = x0 - 20;
@@ -767,6 +919,28 @@ export function renderEventLine(
767
919
  .attr('stroke-width', 1.5)
768
920
  .attr('stroke-linecap', 'round')
769
921
  .attr('stroke-linejoin', 'round');
922
+ // Axis-break glyph bisecting the bracket: the folded span is not to scale.
923
+ // Blank the spine between the two waves first so the timeline reads as
924
+ // genuinely broken, then draw the squiggles over the gap.
925
+ const breakCx = (bx0 + bx1) / 2;
926
+ eg.append('line')
927
+ .attr('x1', breakCx - BREAK_GAP / 2)
928
+ .attr('y1', spineY)
929
+ .attr('x2', breakCx + BREAK_GAP / 2)
930
+ .attr('y2', spineY)
931
+ .attr('stroke', palette.bg)
932
+ .attr('stroke-width', 4)
933
+ .attr('stroke-linecap', 'butt');
934
+ for (const d of axisBreakPaths(breakCx, spineY)) {
935
+ eg.append('path')
936
+ .attr('d', d)
937
+ .attr('fill', 'none')
938
+ .attr('stroke', col)
939
+ .attr('stroke-width', 1.25)
940
+ .attr('stroke-linecap', 'round')
941
+ .attr('stroke-linejoin', 'round')
942
+ .attr('stroke-opacity', 0.85);
943
+ }
770
944
  if (onClickItem) {
771
945
  const ln = p.lineNumber;
772
946
  eg.style('cursor', 'pointer').on('click', () => onClickItem(ln));
@@ -822,15 +996,32 @@ export function renderEventLine(
822
996
  const clampX = (v: number): number =>
823
997
  Math.max(4, Math.min(contentW - 4, v));
824
998
  const cap = eraSide === 'above' ? 1 : -1; // bracket caps point toward the spine
825
- for (const r of runs) {
826
- if (r.collapsed) continue; // collapsed eras are marked on the spine instead
999
+ // Each expanded era's bracket pads its run by ±ERA_BRACKET_PAD. When two
1000
+ // adjacent eras' boundary events sit close on the date scale those pads
1001
+ // collide, so split the seam at the midpoint between the runs (kept outside
1002
+ // each run's own dots) — neighbouring brackets then never overlap.
1003
+ const drawn = runs
1004
+ .filter((r) => !r.collapsed) // collapsed eras are marked on the spine instead
1005
+ .map((r) => ({
1006
+ run: r,
1007
+ x0: clampX(r.firstX - ERA_BRACKET_PAD),
1008
+ x1: clampX(r.lastX + ERA_BRACKET_PAD),
1009
+ }))
1010
+ .sort((a, b) => a.run.firstX - b.run.firstX);
1011
+ for (let i = 1; i < drawn.length; i++) {
1012
+ const prev = drawn[i - 1]!;
1013
+ const cur = drawn[i]!;
1014
+ if (prev.x1 <= cur.x0) continue;
1015
+ const mid = (prev.run.lastX + cur.run.firstX) / 2;
1016
+ prev.x1 = Math.max(prev.run.lastX, Math.min(prev.x1, mid - ERA_SEAM_GAP));
1017
+ cur.x0 = Math.min(cur.run.firstX, Math.max(cur.x0, mid + ERA_SEAM_GAP));
1018
+ }
1019
+ for (const { run: r, x0, x1 } of drawn) {
827
1020
  const neutral = !r.era.color;
828
1021
  const col = neutral
829
1022
  ? palette.text
830
1023
  : (resolveColor(r.era.color!, palette) ?? palette.text);
831
1024
  const op = neutral ? 0.5 : 0.85;
832
- const x0 = clampX(r.firstX - 14);
833
- const x1 = clampX(r.lastX + 14);
834
1025
  const y = eraBaseY;
835
1026
  const eg = svg
836
1027
  .append('g')
@@ -887,6 +1078,26 @@ export function renderEventLineForExport(
887
1078
 
888
1079
  // ── helpers ──
889
1080
 
1081
+ // A vertical "≈ rotated 90°" axis-break glyph: two parallel wavy strokes that
1082
+ // cross the timeline at a collapsed era to signal the span there is folded and
1083
+ // NOT drawn to scale. Returns the two path `d` strings, centered on (cx, cy).
1084
+ function axisBreakPaths(cx: number, cy: number): [string, string] {
1085
+ const HUMPS = 2;
1086
+ const STEPS = 14;
1087
+ const wave = (x0: number): string => {
1088
+ let d = '';
1089
+ for (let i = 0; i <= STEPS; i++) {
1090
+ const t = i / STEPS;
1091
+ const y = cy - BREAK_HALF_H + t * 2 * BREAK_HALF_H;
1092
+ const x = x0 + BREAK_AMP * Math.sin(t * Math.PI * HUMPS);
1093
+ d += `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
1094
+ if (i < STEPS) d += ' ';
1095
+ }
1096
+ return d;
1097
+ };
1098
+ return [wave(cx - BREAK_GAP / 2), wave(cx + BREAK_GAP / 2)];
1099
+ }
1100
+
890
1101
  function wrapDescription(
891
1102
  lines: readonly string[],
892
1103
  charsPerLine: number