@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.
@@ -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.40.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++) {
@@ -261,7 +267,9 @@ export function parseEventLine(
261
267
  const cm = rest.match(ERA_COLLAPSED_RE);
262
268
  if (cm) {
263
269
  collapsed = cm[1]!.toLowerCase() === 'true';
264
- rest = (rest.slice(0, cm.index) + rest.slice(cm.index! + cm[0].length)).trim();
270
+ rest = (
271
+ rest.slice(0, cm.index) + rest.slice(cm.index! + cm[0].length)
272
+ ).trim();
265
273
  }
266
274
  let color: string | null = null;
267
275
  if (rest) {
@@ -323,9 +331,91 @@ export function parseEventLine(
323
331
  );
324
332
  }
325
333
 
334
+ // Eras render as date-spanning brackets, so an event dated outside its era's
335
+ // chronological position makes adjacent era brackets overlap (§28.6a). Only
336
+ // meaningful when the date scale drives x-position.
337
+ if (options.scale) {
338
+ validateEraDateOrder(result.events, result.eras, pushWarning);
339
+ }
340
+
326
341
  return result;
327
342
  }
328
343
 
344
+ /**
345
+ * Warn when a dated event sits in an era whose chronological run it breaks —
346
+ * i.e. it is dated after a later era begins, or before an earlier era ends.
347
+ * Eras are left-to-right date bands; a straggler makes their brackets overlap.
348
+ */
349
+ function validateEraDateOrder(
350
+ events: readonly EventLineEvent[],
351
+ eras: readonly EventLineEra[],
352
+ pushWarning: (line: number, message: string, code?: string) => void
353
+ ): void {
354
+ if (eras.length < 2) return;
355
+ const order = new Map(eras.map((e, i) => [e.name, i]));
356
+ const dated = events.filter(
357
+ (e) => e.dateValue !== null && e.era !== null && order.has(e.era)
358
+ );
359
+ if (dated.length === 0) return;
360
+
361
+ // Earliest and latest dated event per era index.
362
+ const perEra = new Map<
363
+ number,
364
+ { min: EventLineEvent; max: EventLineEvent }
365
+ >();
366
+ for (const ev of dated) {
367
+ const idx = order.get(ev.era!)!;
368
+ const cur = perEra.get(idx);
369
+ if (!cur) {
370
+ perEra.set(idx, { min: ev, max: ev });
371
+ } else {
372
+ if (ev.dateValue! < cur.min.dateValue!) cur.min = ev;
373
+ if (ev.dateValue! > cur.max.dateValue!) cur.max = ev;
374
+ }
375
+ }
376
+
377
+ const n = eras.length;
378
+ // prefixMax[k] = the latest-dated event across eras with index < k.
379
+ // suffixMin[k] = the earliest-dated event across eras with index > k.
380
+ const prefixMax: (EventLineEvent | null)[] = new Array(n).fill(null);
381
+ const suffixMin: (EventLineEvent | null)[] = new Array(n).fill(null);
382
+ for (let k = 1; k < n; k++) {
383
+ const prev = perEra.get(k - 1)?.max ?? null;
384
+ const carried = prefixMax[k - 1] ?? null;
385
+ prefixMax[k] =
386
+ prev && (!carried || prev.dateValue! > carried.dateValue!)
387
+ ? prev
388
+ : carried;
389
+ }
390
+ for (let k = n - 2; k >= 0; k--) {
391
+ const next = perEra.get(k + 1)?.min ?? null;
392
+ const carried = suffixMin[k + 1] ?? null;
393
+ suffixMin[k] =
394
+ next && (!carried || next.dateValue! < carried.dateValue!)
395
+ ? next
396
+ : carried;
397
+ }
398
+
399
+ for (const ev of dated) {
400
+ const k = order.get(ev.era!)!;
401
+ const ahead = suffixMin[k];
402
+ const behind = prefixMax[k];
403
+ if (ahead && ev.dateValue! > ahead.dateValue!) {
404
+ pushWarning(
405
+ ev.lineNumber,
406
+ `"${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.`,
407
+ EVENT_LINE_DIAGNOSTIC_CODES.ERA_DATE_ORDER
408
+ );
409
+ } else if (behind && ev.dateValue! < behind.dateValue!) {
410
+ pushWarning(
411
+ ev.lineNumber,
412
+ `"${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.`,
413
+ EVENT_LINE_DIAGNOSTIC_CODES.ERA_DATE_ORDER
414
+ );
415
+ }
416
+ }
417
+ }
418
+
329
419
  function parseEventHeader(
330
420
  trimmed: string,
331
421
  lineNumber: number,