@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.
@@ -2399,6 +2399,23 @@ tag Genre as g
2399
2399
  - **Tag** — trailing same-line metadata (`g: Pop`), like timeline; colors the dot, leader, and card.
2400
2400
  - **Description** — bare indented body lines (like `pyramid`/`ring`); `- ` makes a bullet; inline markdown (`**bold**`, `*italic*`, `` `code` ``) supported.
2401
2401
 
2402
+ #### Eras
2403
+
2404
+ Group a run of events into a labeled section with a `[Name]` bracket, then **indent the events beneath it** (the org / version-control nesting idiom). An event belongs to the era it is indented under; its description sits one level deeper still. An event at indent 0 sits **outside** any era.
2405
+
2406
+ ```
2407
+ [The Early Web]
2408
+ 1991 WorldWideWeb t: Protocol
2409
+ Tim Berners-Lee publishes the first site.
2410
+ 1993 Mosaic t: Browser
2411
+
2412
+ [The App Era] collapsed: true
2413
+ 2005 Ajax t: Platform
2414
+ ```
2415
+
2416
+ - **Trailing metadata** after `]` — optional `collapsed: true` and/or a named color (`[The 1960s] collapsed: true blue`).
2417
+ - **`collapsed: true`** folds the era into a single summary card (its name + a bulleted list of member events) while a bracket stays on the spine; in the app a reader toggles it live.
2418
+
2402
2419
  #### Directives
2403
2420
 
2404
2421
  - `no-scale` — space events evenly instead of by date (dates become captions).
@@ -0,0 +1,16 @@
1
+ block Web Service Architecture
2
+
3
+ tag Layer as l
4
+ Edge blue
5
+ Service green
6
+ Data orange
7
+
8
+ [Clients] l: Edge
9
+ [Browser] [Mobile] [CLI]
10
+
11
+ [Backend] l: Service
12
+ [Auth] [Orders]
13
+ [Inventory] [Billing]
14
+
15
+ [Data] l: Data collapsed
16
+ [Postgres] [Redis]
@@ -0,0 +1,26 @@
1
+ event-line A Short History of the Web
2
+ no-scale
3
+
4
+ tag Theme as t
5
+ Protocol blue
6
+ Browser green
7
+ Platform purple
8
+ Standard orange
9
+
10
+ [The Early Web]
11
+ 1991 WorldWideWeb t: Protocol
12
+ Tim Berners-Lee publishes the first website at **CERN**.
13
+ 1993 Mosaic t: Browser
14
+ The first popular *graphical* browser.
15
+
16
+ [The Standards Era] collapsed: true
17
+ 1995 JavaScript t: Platform
18
+ Brendan Eich writes the language in ten days.
19
+ 1996 CSS t: Standard
20
+ Styling splits from structure.
21
+
22
+ [The App Era]
23
+ 2005 Ajax t: Platform
24
+ `XMLHttpRequest` updates pages without a reload.
25
+ 2014 HTML5 t: Standard
26
+ The living standard — video, canvas, semantics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
package/src/echarts.ts CHANGED
@@ -283,6 +283,9 @@ const CHART_BASE: Pick<
283
283
  tooltip: { show: false },
284
284
  };
285
285
  const CHART_BORDER_WIDTH = 2;
286
+ // Scatter bubbles render semi-transparent so overlapping points stay visible.
287
+ // Emphasis bumps back to full opacity on hover (see emphasisConfig).
288
+ const SCATTER_FILL_OPACITY = 0.65;
286
289
 
287
290
  // ============================================================
288
291
  // Parser
@@ -1930,6 +1933,7 @@ function buildScatterOption(
1930
1933
  }),
1931
1934
  borderColor: catColor,
1932
1935
  borderWidth: CHART_BORDER_WIDTH,
1936
+ opacity: SCATTER_FILL_OPACITY,
1933
1937
  },
1934
1938
  label: labelConfig,
1935
1939
  emphasis: emphasisConfig,
@@ -1961,6 +1965,7 @@ function buildScatterOption(
1961
1965
  {
1962
1966
  type: 'scatter' as const,
1963
1967
  data,
1968
+ itemStyle: { opacity: SCATTER_FILL_OPACITY },
1964
1969
  label: labelConfig,
1965
1970
  emphasis: emphasisConfig,
1966
1971
  blur: blurConfig,
@@ -0,0 +1,200 @@
1
+ // ============================================================
2
+ // Error Card — fallback image rendered when a diagram can't parse
3
+ // ============================================================
4
+ //
5
+ // When `render(text, { onError: 'svg' })` (the default) hits parse errors,
6
+ // hosts that embed dgmo — remark/astro/docusaurus/fumadocs, Obsidian, the
7
+ // web editor — show whatever SVG we return in place of the diagram. A blank
8
+ // or cryptic box is a dead end for the author, so we render a friendly card:
9
+ // a frown mark, the dgmo wordmark, and per-error the line number, the
10
+ // message, and the actual offending source line with a caret at the column.
11
+ //
12
+ // Pure string builder — no DOM, so it works in every consumer (SSR, browser,
13
+ // resvg→PNG in the CLI). resvg has no color-mix(); all colors are pre-resolved.
14
+
15
+ import { truncateText, measureText } from './utils/text-measure';
16
+ import { FONT_FAMILY } from './fonts';
17
+ import type { PaletteConfig } from './palettes/types';
18
+ import type { Theme } from './themes';
19
+ import type { DgmoError } from './diagnostics';
20
+
21
+ const MONO_FAMILY =
22
+ "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace";
23
+
24
+ const W = 660;
25
+ const PAD = 24;
26
+ const CONTENT_W = W - PAD * 2;
27
+ const MAX_ERRORS = 4;
28
+ // Monospace advance ≈ 0.6em — used to place the caret under the bad column.
29
+ const MONO_CH = 0.6;
30
+ const CODE_FS = 12.5;
31
+
32
+ /**
33
+ * Render a self-contained SVG error card for a failed diagram parse.
34
+ *
35
+ * @param errors Error-severity diagnostics (line-numbered).
36
+ * @param source Original DGMO source, used to quote the offending lines.
37
+ */
38
+ export function renderErrorCard(
39
+ errors: DgmoError[],
40
+ source: string,
41
+ palette: PaletteConfig,
42
+ theme?: Theme
43
+ ): string {
44
+ const c = palette[theme === 'dark' ? 'dark' : 'light'];
45
+ const isTransparent = theme === 'transparent';
46
+ const card = c.surface;
47
+ const codeBg = c.bg;
48
+ const border = c.border;
49
+ const danger = c.destructive ?? c.colors.red;
50
+ const text = c.text;
51
+ const muted = c.textMuted;
52
+ const srcLines = source.split('\n');
53
+
54
+ const shown = errors.slice(0, MAX_ERRORS);
55
+ const overflow = errors.length - shown.length;
56
+
57
+ const parts: string[] = [];
58
+ let y = PAD + 8;
59
+
60
+ // ── Header: frown mark + title, dgmo wordmark right ────────
61
+ parts.push(frownMark(PAD + 14, y + 12, danger));
62
+ parts.push(
63
+ `<text x="${PAD + 38}" y="${y + 11}" fill="${text}" font-family="${FONT_FAMILY}" font-size="16" font-weight="700">Couldn't render this diagram</text>`,
64
+ `<text x="${W - PAD}" y="${y + 10}" text-anchor="end" fill="${muted}" font-family="${FONT_FAMILY}" font-size="12" font-weight="600" letter-spacing="0.5">dgmo</text>`
65
+ );
66
+ y += 24;
67
+ const errLabel = errors.length === 1 ? '1 error' : `${errors.length} errors`;
68
+ parts.push(
69
+ `<text x="${PAD + 38}" y="${y + 4}" fill="${muted}" font-family="${FONT_FAMILY}" font-size="12.5">${errLabel} — fix the lines below and re-render</text>`
70
+ );
71
+ y += 22;
72
+ parts.push(
73
+ `<line x1="${PAD}" y1="${y}" x2="${W - PAD}" y2="${y}" stroke="${border}" stroke-width="1"/>`
74
+ );
75
+ y += 24;
76
+
77
+ // ── Per-error blocks ───────────────────────────────────────
78
+ for (const e of shown) {
79
+ const hasLine = e.line > 0;
80
+
81
+ // Line badge (skip when line is unknown / 0), with the message inline.
82
+ let msgX = PAD;
83
+ if (hasLine) {
84
+ const label = e.column ? `Line ${e.line}:${e.column}` : `Line ${e.line}`;
85
+ const badgeW = 14 + label.length * 7;
86
+ parts.push(
87
+ `<rect x="${PAD}" y="${y - 13}" width="${badgeW}" height="19" rx="4" fill="${danger}"/>`,
88
+ `<text x="${PAD + badgeW / 2}" y="${y + 1}" text-anchor="middle" fill="${c.bg}" font-family="${FONT_FAMILY}" font-size="11.5" font-weight="700">${escapeXml(label)}</text>`
89
+ );
90
+ msgX = PAD + badgeW + 12;
91
+ }
92
+
93
+ // Message: first line sits to the right of the badge, continuation lines
94
+ // wrap to the full content width on the rows below.
95
+ const lines = wrapMessage(e.message, CONTENT_W - (msgX - PAD), CONTENT_W);
96
+ lines.forEach((line, i) => {
97
+ parts.push(
98
+ `<text x="${i === 0 ? msgX : PAD}" y="${y + 1}" fill="${text}" font-family="${FONT_FAMILY}" font-size="13">${escapeXml(line)}</text>`
99
+ );
100
+ if (i < lines.length - 1) y += 18;
101
+ });
102
+ y += 24;
103
+
104
+ // Offending source line, monospace, with a caret under the column.
105
+ const raw = hasLine ? srcLines[e.line - 1] : undefined;
106
+ if (raw !== undefined && raw.trim().length > 0) {
107
+ const gutter = `${e.line}`;
108
+ const gutterW = gutter.length * CODE_FS * MONO_CH + 16;
109
+ const codeX = PAD + gutterW;
110
+ const codeMaxW = CONTENT_W - gutterW - 12;
111
+ const codeText = truncateText(
112
+ raw.replace(/\t/g, ' '),
113
+ CODE_FS,
114
+ codeMaxW
115
+ );
116
+ const hasCaret = e.column !== undefined && e.column > 0;
117
+ const blockH = hasCaret ? 40 : 26;
118
+ parts.push(
119
+ `<rect x="${PAD}" y="${y - 14}" width="${CONTENT_W}" height="${blockH}" rx="5" fill="${codeBg}" stroke="${border}" stroke-width="1"/>`,
120
+ `<line x1="${codeX - 8}" y1="${y - 14}" x2="${codeX - 8}" y2="${y - 14 + blockH}" stroke="${border}" stroke-width="1"/>`,
121
+ `<text x="${PAD + 8}" y="${y + 3}" fill="${muted}" font-family="${MONO_FAMILY}" font-size="${CODE_FS}">${escapeXml(gutter)}</text>`,
122
+ `<text x="${codeX}" y="${y + 3}" fill="${text}" font-family="${MONO_FAMILY}" font-size="${CODE_FS}" xml:space="preserve">${escapeXml(codeText)}</text>`
123
+ );
124
+ if (hasCaret) {
125
+ const caretX = codeX + (e.column! - 1) * CODE_FS * MONO_CH;
126
+ parts.push(
127
+ `<text x="${caretX}" y="${y + 17}" fill="${danger}" font-family="${MONO_FAMILY}" font-size="${CODE_FS}" font-weight="700">^</text>`
128
+ );
129
+ y += 14;
130
+ }
131
+ y += 24;
132
+ }
133
+
134
+ y += 14;
135
+ }
136
+
137
+ if (overflow > 0) {
138
+ parts.push(
139
+ `<text x="${PAD}" y="${y}" fill="${muted}" font-family="${FONT_FAMILY}" font-size="12.5" font-style="italic">+ ${overflow} more error${overflow === 1 ? '' : 's'}</text>`
140
+ );
141
+ y += 22;
142
+ }
143
+
144
+ const height = Math.round(y + PAD - 14);
145
+ const pageBg = isTransparent
146
+ ? ''
147
+ : `<rect width="100%" height="100%" fill="${c.bg}"/>`;
148
+
149
+ return [
150
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}" font-family="${FONT_FAMILY}">`,
151
+ pageBg,
152
+ `<rect x="0.5" y="0.5" width="${W - 1}" height="${height - 1}" rx="10" fill="${card}" stroke="${border}" stroke-width="1"/>`,
153
+ parts.join(''),
154
+ `</svg>`,
155
+ ].join('');
156
+ }
157
+
158
+ // Greedy word-wrap where the first line is narrower (it shares its row with
159
+ // the line badge) and continuation lines use the full content width.
160
+ function wrapMessage(
161
+ message: string,
162
+ firstWidth: number,
163
+ fullWidth: number
164
+ ): string[] {
165
+ const words = message.split(/\s+/).filter(Boolean);
166
+ if (words.length === 0) return [''];
167
+ const lines: string[] = [];
168
+ let cur = '';
169
+ for (const word of words) {
170
+ const limit = lines.length === 0 ? firstWidth : fullWidth;
171
+ const test = cur ? `${cur} ${word}` : word;
172
+ if (measureText(test, 13) <= limit || !cur) {
173
+ cur = test;
174
+ } else {
175
+ lines.push(cur);
176
+ cur = word;
177
+ }
178
+ }
179
+ if (cur) lines.push(cur);
180
+ return lines;
181
+ }
182
+
183
+ // Frown face: ringed circle, two eyes, a downturned mouth.
184
+ function frownMark(cx: number, cy: number, color: string): string {
185
+ const r = 12;
186
+ return [
187
+ `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="2"/>`,
188
+ `<circle cx="${cx - 4.5}" cy="${cy - 3}" r="1.6" fill="${color}"/>`,
189
+ `<circle cx="${cx + 4.5}" cy="${cy - 3}" r="1.6" fill="${color}"/>`,
190
+ `<path d="M ${cx - 5} ${cy + 6} Q ${cx} ${cy + 1} ${cx + 5} ${cy + 6}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`,
191
+ ].join('');
192
+ }
193
+
194
+ function escapeXml(s: string): string {
195
+ return s
196
+ .replace(/&/g, '&amp;')
197
+ .replace(/</g, '&lt;')
198
+ .replace(/>/g, '&gt;')
199
+ .replace(/"/g, '&quot;');
200
+ }
@@ -55,6 +55,7 @@ export const EVENT_LINE_DIAGNOSTIC_CODES = {
55
55
  NO_EVENTS: 'E_EVENT_LINE_NO_EVENTS',
56
56
  BAD_DATE: 'E_EVENT_LINE_BAD_DATE',
57
57
  UNSUPPORTED: 'E_EVENT_LINE_UNSUPPORTED',
58
+ ERA_DATE_ORDER: 'E_EVENT_LINE_ERA_DATE_ORDER',
58
59
  } as const;
59
60
 
60
61
  /** A non-ISO date attempt: leading digits with a slash or dot separator. */
@@ -113,6 +114,11 @@ export function parseEventLine(
113
114
  let currentTagGroup: Writable<TagGroup> | null = null;
114
115
  let currentEvent: Writable<EventLineEvent> | null = null;
115
116
  let currentEra: string | null = null;
117
+ // Indent of the open era's `[Name]` bracket; events must be indented deeper
118
+ // to belong to it. -1 ⇒ no era open (an indent-0 event is then era-less).
119
+ let eraIndent = -1;
120
+ // Indent of the current event; a line indented deeper is its description.
121
+ let currentEventIndent = 0;
116
122
  const aliasMap = new Map<string, string>();
117
123
 
118
124
  for (let i = 0; i < lines.length; i++) {
@@ -197,15 +203,17 @@ export function parseEventLine(
197
203
  continue;
198
204
  }
199
205
 
200
- // ── Indented line in content phase = description body ──
201
- if (indent > 0) {
202
- if (!currentEvent) {
203
- pushWarning(
204
- lineNumber,
205
- `Indented description "${trimmed}" has no event above it — add an event line first. (§28)`
206
- );
207
- continue;
208
- }
206
+ // ── Content phase: indentation classifies each line. ──
207
+ //
208
+ // Eras are top-level `[Name]` section headers whose member events are
209
+ // INDENTED beneath them (the org §7 / version-control §29 idiom); an event
210
+ // at indent 0 sits OUTSIDE any era. A line indented deeper than its event
211
+ // is that event's description body (§28.4).
212
+
213
+ // Description body — any line indented deeper than its event. Checked
214
+ // before the era bracket so a bracketed prose line (` [aside]`) under an
215
+ // event stays a description rather than opening a spurious era.
216
+ if (currentEvent && indent > currentEventIndent) {
209
217
  const descLine = trimmed.startsWith('- ')
210
218
  ? `• ${trimmed.substring(2)}`
211
219
  : trimmed;
@@ -213,47 +221,14 @@ export function parseEventLine(
213
221
  continue;
214
222
  }
215
223
 
216
- // ── Indent-0 line: directive, reserved seam, or event ──
217
- currentTagGroup = null;
218
-
219
- if (trimmed.toLowerCase() === 'no-scale') {
220
- options.scale = false;
221
- continue;
222
- }
223
- const sideMatch = trimmed.match(SIDE_RE);
224
- if (sideMatch) {
225
- options.side = sideMatch[1]!.toLowerCase() as EventLineOptions['side'];
226
- continue;
227
- }
228
- if (trimmed.toLowerCase() === 'no-box') {
229
- options.noBox = true;
230
- continue;
231
- }
232
- if (trimmed.toLowerCase() === 'no-legend') {
233
- options.noLegend = true;
234
- continue;
235
- }
236
- const dirMatch = trimmed.match(DIRECTION_RE);
237
- if (dirMatch) {
238
- const dir = dirMatch[1]!.toUpperCase();
239
- if (dir !== 'LR') {
240
- pushWarning(
241
- lineNumber,
242
- `event-line is horizontal-only in v1; \`direction ${dir}\` (vertical orientation) is a fast-follow.`,
243
- EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
244
- );
245
- }
246
- continue;
247
- }
248
- if (tryParseSharedOption(trimmed, sharedOptions)) continue;
249
-
250
- // ── Era run delimiter: `[Name]` opens a section that runs to the next
251
- // `[Name]` or EOF (§28.6a). NOT an indentation container — events stay at
252
- // indent 0, so flat bare-body descriptions are preserved. Optional trailing
253
- // `collapsed: true` and/or a color name tint/fold the era.
224
+ // ── Era run delimiter: `[Name]` (§28.6a) — a section header whose member
225
+ // events are indented beneath it. Dedenting to (or past) the bracket's
226
+ // own indent leaves the era. Optional trailing `collapsed: true` and/or
227
+ // a color name tint/fold the era.
254
228
  const eraMatch = trimmed.match(ERA_RE);
255
229
  if (eraMatch) {
256
230
  contentStarted = true;
231
+ currentTagGroup = null;
257
232
  currentEvent = null;
258
233
  const name = eraMatch[1]!.trim();
259
234
  let rest = (eraMatch[2] ?? '').trim();
@@ -261,7 +236,9 @@ export function parseEventLine(
261
236
  const cm = rest.match(ERA_COLLAPSED_RE);
262
237
  if (cm) {
263
238
  collapsed = cm[1]!.toLowerCase() === 'true';
264
- rest = (rest.slice(0, cm.index) + rest.slice(cm.index! + cm[0].length)).trim();
239
+ rest = (
240
+ rest.slice(0, cm.index) + rest.slice(cm.index! + cm[0].length)
241
+ ).trim();
265
242
  }
266
243
  let color: string | null = null;
267
244
  if (rest) {
@@ -280,19 +257,59 @@ export function parseEventLine(
280
257
  const era: EventLineEra = { name, color, collapsed, lineNumber };
281
258
  result.eras.push(era);
282
259
  currentEra = name;
260
+ eraIndent = indent;
283
261
  continue;
284
262
  }
285
263
 
286
- if (SECTION_SEAM_RE.test(trimmed)) {
287
- pushWarning(
288
- lineNumber,
289
- 'Group events with `[Name]` era brackets (§28.6a), not `section`.',
290
- EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
291
- );
292
- continue;
264
+ // ── Top-level (indent 0) directives and reserved seams. ──
265
+ if (indent === 0) {
266
+ currentTagGroup = null;
267
+ if (trimmed.toLowerCase() === 'no-scale') {
268
+ options.scale = false;
269
+ continue;
270
+ }
271
+ const sideMatch = trimmed.match(SIDE_RE);
272
+ if (sideMatch) {
273
+ options.side = sideMatch[1]!.toLowerCase() as EventLineOptions['side'];
274
+ continue;
275
+ }
276
+ if (trimmed.toLowerCase() === 'no-box') {
277
+ options.noBox = true;
278
+ continue;
279
+ }
280
+ if (trimmed.toLowerCase() === 'no-legend') {
281
+ options.noLegend = true;
282
+ continue;
283
+ }
284
+ const dirMatch = trimmed.match(DIRECTION_RE);
285
+ if (dirMatch) {
286
+ const dir = dirMatch[1]!.toUpperCase();
287
+ if (dir !== 'LR') {
288
+ pushWarning(
289
+ lineNumber,
290
+ `event-line is horizontal-only in v1; \`direction ${dir}\` (vertical orientation) is a fast-follow.`,
291
+ EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
292
+ );
293
+ }
294
+ continue;
295
+ }
296
+ if (tryParseSharedOption(trimmed, sharedOptions)) continue;
297
+ if (SECTION_SEAM_RE.test(trimmed)) {
298
+ pushWarning(
299
+ lineNumber,
300
+ 'Group events with `[Name]` era brackets (§28.6a), not `section`.',
301
+ EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
302
+ );
303
+ continue;
304
+ }
293
305
  }
294
306
 
295
307
  // ── Event line ──
308
+ // An event belongs to the open era only when indented beneath that era's
309
+ // bracket; an indent-0 event sits outside any era and closes the run
310
+ // (events outside eras are fully supported — they simply have `era: null`).
311
+ const inEra = currentEra !== null && indent > eraIndent;
312
+ if (!inEra) currentEra = null;
296
313
  contentStarted = true;
297
314
  const event = parseEventHeader(
298
315
  trimmed,
@@ -304,6 +321,7 @@ export function parseEventLine(
304
321
  );
305
322
  result.events.push(event);
306
323
  currentEvent = event;
324
+ currentEventIndent = indent;
307
325
  }
308
326
 
309
327
  options.noTitle = sharedOptions['no-title'] === 'on';
@@ -323,9 +341,91 @@ export function parseEventLine(
323
341
  );
324
342
  }
325
343
 
344
+ // Eras render as date-spanning brackets, so an event dated outside its era's
345
+ // chronological position makes adjacent era brackets overlap (§28.6a). Only
346
+ // meaningful when the date scale drives x-position.
347
+ if (options.scale) {
348
+ validateEraDateOrder(result.events, result.eras, pushWarning);
349
+ }
350
+
326
351
  return result;
327
352
  }
328
353
 
354
+ /**
355
+ * Warn when a dated event sits in an era whose chronological run it breaks —
356
+ * i.e. it is dated after a later era begins, or before an earlier era ends.
357
+ * Eras are left-to-right date bands; a straggler makes their brackets overlap.
358
+ */
359
+ function validateEraDateOrder(
360
+ events: readonly EventLineEvent[],
361
+ eras: readonly EventLineEra[],
362
+ pushWarning: (line: number, message: string, code?: string) => void
363
+ ): void {
364
+ if (eras.length < 2) return;
365
+ const order = new Map(eras.map((e, i) => [e.name, i]));
366
+ const dated = events.filter(
367
+ (e) => e.dateValue !== null && e.era !== null && order.has(e.era)
368
+ );
369
+ if (dated.length === 0) return;
370
+
371
+ // Earliest and latest dated event per era index.
372
+ const perEra = new Map<
373
+ number,
374
+ { min: EventLineEvent; max: EventLineEvent }
375
+ >();
376
+ for (const ev of dated) {
377
+ const idx = order.get(ev.era!)!;
378
+ const cur = perEra.get(idx);
379
+ if (!cur) {
380
+ perEra.set(idx, { min: ev, max: ev });
381
+ } else {
382
+ if (ev.dateValue! < cur.min.dateValue!) cur.min = ev;
383
+ if (ev.dateValue! > cur.max.dateValue!) cur.max = ev;
384
+ }
385
+ }
386
+
387
+ const n = eras.length;
388
+ // prefixMax[k] = the latest-dated event across eras with index < k.
389
+ // suffixMin[k] = the earliest-dated event across eras with index > k.
390
+ const prefixMax: (EventLineEvent | null)[] = new Array(n).fill(null);
391
+ const suffixMin: (EventLineEvent | null)[] = new Array(n).fill(null);
392
+ for (let k = 1; k < n; k++) {
393
+ const prev = perEra.get(k - 1)?.max ?? null;
394
+ const carried = prefixMax[k - 1] ?? null;
395
+ prefixMax[k] =
396
+ prev && (!carried || prev.dateValue! > carried.dateValue!)
397
+ ? prev
398
+ : carried;
399
+ }
400
+ for (let k = n - 2; k >= 0; k--) {
401
+ const next = perEra.get(k + 1)?.min ?? null;
402
+ const carried = suffixMin[k + 1] ?? null;
403
+ suffixMin[k] =
404
+ next && (!carried || next.dateValue! < carried.dateValue!)
405
+ ? next
406
+ : carried;
407
+ }
408
+
409
+ for (const ev of dated) {
410
+ const k = order.get(ev.era!)!;
411
+ const ahead = suffixMin[k];
412
+ const behind = prefixMax[k];
413
+ if (ahead && ev.dateValue! > ahead.dateValue!) {
414
+ pushWarning(
415
+ ev.lineNumber,
416
+ `"${ev.label}" (${ev.date}) is in era "${ev.era}" but dated after era "${ahead.era}" begins (${ahead.date}). event-line eras run left-to-right by date — fix the date or move it to the right era, or their brackets will overlap.`,
417
+ EVENT_LINE_DIAGNOSTIC_CODES.ERA_DATE_ORDER
418
+ );
419
+ } else if (behind && ev.dateValue! < behind.dateValue!) {
420
+ pushWarning(
421
+ ev.lineNumber,
422
+ `"${ev.label}" (${ev.date}) is in era "${ev.era}" but dated before era "${behind.era}" ends (${behind.date}). event-line eras run left-to-right by date — fix the date or move it to the right era, or their brackets will overlap.`,
423
+ EVENT_LINE_DIAGNOSTIC_CODES.ERA_DATE_ORDER
424
+ );
425
+ }
426
+ }
427
+ }
428
+
329
429
  function parseEventHeader(
330
430
  trimmed: string,
331
431
  lineNumber: number,