@diagrammo/dgmo 0.39.0 → 0.40.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.
- package/dist/advanced.cjs +324 -116
- package/dist/advanced.js +324 -116
- package/dist/auto.cjs +325 -117
- package/dist/auto.js +92 -92
- package/dist/auto.mjs +325 -117
- package/dist/cli.cjs +115 -115
- package/dist/index.cjs +502 -171
- package/dist/index.js +502 -171
- package/gallery/fixtures/block.dgmo +16 -0
- package/gallery/fixtures/event-line.dgmo +26 -0
- package/package.json +1 -1
- package/src/diagnostics.ts +0 -0
- package/src/echarts.ts +5 -0
- package/src/error-card.ts +200 -0
- package/src/event-line/parser.ts +91 -1
- package/src/event-line/renderer.ts +350 -139
- package/src/index.ts +7 -30
- package/src/journey-map/renderer.ts +50 -18
|
@@ -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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
317
|
-
//
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
.
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
493
|
-
const x0 = Math.min(...
|
|
494
|
-
const x1 = Math.max(...
|
|
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 `⊓`
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
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.
|
|
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
|
-
|
|
826
|
-
|
|
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
|