@jarenjs/mermaid 0.46.5 → 0.49.2

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.
@@ -1,19 +1,84 @@
1
1
  //@ts-check
2
2
  /**
3
- * @file Gantt grammar → gantt AST: a schedule DAG. Header
4
- * directives (`title`, `dateFormat`, `axisFormat`, `excludes`) go to
5
- * `meta`; `section` groups tasks; task rows keep their raw metadata
6
- * string (`:done, id, 2014-01-06, 3d`) verbatim, geometry-free.
3
+ * @file Gantt grammar → gantt AST: a schedule, resolved.
4
+ *
5
+ * Header directives (`title`, `dateFormat`, `axisFormat`, `excludes`,
6
+ * `tickInterval`, `weekday`, `weekend`, `todayMarker`) are kept verbatim
7
+ * in `meta` so the printer stays a fixed point, and are *also*
8
+ * interpreted into `rules` — a compiled date parser, an axis pattern, a
9
+ * tick step, a working calendar. `section` groups tasks; a task row
10
+ * keeps its raw metadata string (`:done, id, 2014-01-06, 3d`) in `info`
11
+ * for the same printing reason and gains the semantic members a
12
+ * timeline needs: `{ id, flags, start, end, duration, after, line }`,
13
+ * with `start`/`end` epoch milliseconds and the interval half-open.
14
+ *
15
+ * The AST stays geometry-free and plain JSON: no compiled closure and
16
+ * no coordinate crosses this boundary. Layout re-compiles the axis
17
+ * pattern from `rules`, once per diagram.
18
+ *
19
+ * Three refusals are the whole difference from a permissive reader, and
20
+ * each one is a `JM` error carrying its source line:
21
+ *
22
+ * 1. **No clock.** Mermaid starts an undated first task *today*. This
23
+ * engine has no clock (`docs/ROADMAP.md`, "there is no now"), so a
24
+ * schedule with no dated anchor is an error rather than a diagram
25
+ * that means something different tomorrow.
26
+ * 2. **No silent literals.** An unsupported `dateFormat` or
27
+ * `axisFormat` token is refused by name; Mermaid passes it through as
28
+ * literal text, which turns a typo into a date that reads as
29
+ * something else.
30
+ * 3. **No guessed dependencies.** A missing id, a duplicate id, a cycle
31
+ * and a reversed or empty span are all errors.
7
32
  */
8
33
 
9
- const HEADER_KEYS = new Set(['title', 'dateFormat', 'axisFormat', 'excludes', 'todayMarker', 'tickInterval', 'weekday']);
34
+ import { fail } from '../errors.js';
35
+ import { compileDateParser, epochOfRFC3339Parts, addToParts, partsFromEpoch } from '@jarenjs/core/dates';
36
+ import {
37
+ DEFAULT_DATE_FORMAT, DEFAULT_AXIS_FORMAT,
38
+ momentToLdml, strftimeToLdml, ldmlNeedsNames,
39
+ parseTickInterval, parseTaskDuration, parseWeekday, parseWeekend,
40
+ ISO_WEEKDAYS,
41
+ } from './gantt-grammar.js';
42
+ import { createExcluder, pushEndPastExclusions, dayIndexOf, DAY_MS } from '../gantt-calendar.js';
43
+
44
+ const HEADER_KEYS = new Set([
45
+ 'title', 'dateFormat', 'axisFormat', 'excludes', 'todayMarker',
46
+ 'tickInterval', 'weekday', 'weekend',
47
+ ]);
48
+
49
+ /** The task flags Mermaid documents, in the order the AST lists them. */
50
+ const FLAGS = Object.freeze(['done', 'active', 'crit', 'milestone']);
51
+ const FLAG_SET = new Set(FLAGS);
10
52
 
11
53
  /**
12
54
  * @param {string[]} lines
55
+ * @param {number} [offset] - 0-based index of `lines[0]` in the source
56
+ * @param {string} [_header]
57
+ * @param {{ dateNames?: import('@jarenjs/core/dates').DateNames }} [options]
13
58
  * @returns {object}
14
59
  */
15
- export function parseGantt(lines) {
60
+ export function parseGantt(lines, offset = 0, _header = '', options = {}) {
61
+ const { meta, metaLine, sections } = scan(lines, offset);
62
+ const rules = interpret(meta, metaLine, options.dateNames);
63
+ resolve(sections, rules);
64
+ return { meta, rules: rules.published, sections, domain: domainOf(sections) };
65
+ }
66
+
67
+ //#region scanning ---------------------------------------------------
68
+
69
+ /**
70
+ * Read the source into raw meta and raw task rows. Nothing is
71
+ * interpreted here, so the printer's inputs exist even for a document
72
+ * that later fails to resolve.
73
+ * @param {string[]} lines
74
+ * @param {number} offset
75
+ * @returns {{ meta: any, metaLine: any, sections: any[] }}
76
+ */
77
+ function scan(lines, offset) {
78
+ /** @type {Record<string, string>} */
16
79
  const meta = {};
80
+ /** @type {Record<string, number>} */
81
+ const metaLine = {};
17
82
  const sections = [];
18
83
  let current = { name: null, tasks: [] };
19
84
  sections.push(current);
@@ -21,11 +86,13 @@ export function parseGantt(lines) {
21
86
  for (let li = 0; li < lines.length; li++) {
22
87
  const line = lines[li].trim();
23
88
  if (line === '' || line.startsWith('%%')) continue;
89
+ const at = offset + li + 1;
24
90
  const sp = line.indexOf(' ');
25
91
  const key = sp === -1 ? line : line.slice(0, sp);
26
92
 
27
93
  if (HEADER_KEYS.has(key)) {
28
94
  meta[key] = sp === -1 ? '' : line.slice(sp + 1).trim();
95
+ metaLine[key] = at;
29
96
  continue;
30
97
  }
31
98
  if (key === 'section') {
@@ -38,12 +105,393 @@ export function parseGantt(lines) {
38
105
  if (colon !== -1) {
39
106
  const name = line.slice(0, colon).trim();
40
107
  const info = line.slice(colon + 1).trim();
41
- current.tasks.push({ name, info });
108
+ current.tasks.push({
109
+ name, info,
110
+ id: null, flags: [], start: 0, end: 0, duration: 0, after: [],
111
+ line: at,
112
+ });
42
113
  }
43
114
  }
44
115
 
45
116
  // Drop a leading empty default section if unused.
46
117
  const trimmed = sections[0].name === null && sections[0].tasks.length === 0
47
118
  ? sections.slice(1) : sections;
48
- return { meta, sections: trimmed };
119
+ return { meta, metaLine, sections: trimmed };
120
+ }
121
+
122
+ //#endregion
123
+ //#region header interpretation --------------------------------------
124
+
125
+ /**
126
+ * Turn the raw header strings into the compiled rules the resolver and
127
+ * the layout need. Every failure names its own source line.
128
+ * @param {Record<string, string>} meta
129
+ * @param {Record<string, number>} metaLine
130
+ * @param {any} dateNames
131
+ * @returns {any}
132
+ */
133
+ function interpret(meta, metaLine, dateNames) {
134
+ const dateFormat = meta.dateFormat === undefined || meta.dateFormat === ''
135
+ ? DEFAULT_DATE_FORMAT : meta.dateFormat;
136
+ const axisFormat = meta.axisFormat === undefined || meta.axisFormat === ''
137
+ ? DEFAULT_AXIS_FORMAT : meta.axisFormat;
138
+
139
+ const readDate = compilePattern(dateFormat, metaLine.dateFormat ?? 0,
140
+ momentToLdml, 'dateFormat', dateNames, true);
141
+ // the axis pattern is only VALIDATED here; layout compiles it, so no
142
+ // closure lands in the AST
143
+ compilePattern(axisFormat, metaLine.axisFormat ?? 0,
144
+ strftimeToLdml, 'axisFormat', dateNames, false);
145
+
146
+ let tick = null;
147
+ if (meta.tickInterval !== undefined && meta.tickInterval !== '') {
148
+ const got = parseTickInterval(meta.tickInterval);
149
+ if (got.error !== null) fail(got.error, metaLine.tickInterval);
150
+ tick = got.value;
151
+ }
152
+
153
+ let weekStart = 1;
154
+ if (meta.weekday !== undefined && meta.weekday !== '') {
155
+ const got = parseWeekday(meta.weekday);
156
+ if (got.error !== null) fail(got.error, metaLine.weekday);
157
+ weekStart = got.value;
158
+ }
159
+
160
+ let weekendStart = ISO_WEEKDAYS.saturday;
161
+ if (meta.weekend !== undefined && meta.weekend !== '') {
162
+ const got = parseWeekend(meta.weekend);
163
+ if (got.error !== null) fail(got.error, metaLine.weekend);
164
+ weekendStart = got.value;
165
+ }
166
+
167
+ const excludes = readExcludes(meta.excludes, metaLine.excludes ?? 0, readDate);
168
+ const excluder = createExcluder(excludes, weekendStart);
169
+
170
+ return {
171
+ readDate,
172
+ excluder,
173
+ published: {
174
+ dateFormat, axisFormat, tick, weekStart, weekendStart,
175
+ excludes, todayMarker: meta.todayMarker ?? null,
176
+ },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Adapt a Mermaid pattern to LDML and compile it, turning both the
182
+ * adapter's refusal and the core compiler's into a line-aware `JM`.
183
+ * @param {string} pattern
184
+ * @param {number} line
185
+ * @param {(p: string) => { value: any, error: string | null }} adapt
186
+ * @param {string} directive - `'dateFormat'` or `'axisFormat'`
187
+ * @param {any} names
188
+ * @param {boolean} asParser
189
+ * @returns {any}
190
+ */
191
+ function compilePattern(pattern, line, adapt, directive, names, asParser) {
192
+ const adapted = adapt(pattern);
193
+ if (adapted.error !== null) fail(adapted.error, line);
194
+ const needed = ldmlNeedsNames(adapted.value);
195
+ if (needed !== null && (names === undefined || names === null)) {
196
+ fail(`${directive} '${pattern}' asks for a locale name, so it needs a`
197
+ + " 'dateNames' provider (parseMermaid(source, { dateNames }));"
198
+ + ' this engine ships no month or weekday names of its own', line);
199
+ }
200
+ if (!asParser) return null;
201
+ try {
202
+ return compileDateParser(adapted.value, names);
203
+ }
204
+ catch (err) {
205
+ return fail(`${directive} '${pattern}' cannot be read: ${err.message}`, line);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Parse the `excludes` terms: `weekends`, weekday names, and explicit
211
+ * dates in the document's own `dateFormat` (or plain ISO, which Mermaid
212
+ * also accepts).
213
+ * @param {string | undefined} text
214
+ * @param {number} line
215
+ * @param {(s: string) => any} readDate
216
+ * @returns {{ weekends: boolean, weekdays: number[], days: number[] }}
217
+ */
218
+ function readExcludes(text, line, readDate) {
219
+ const out = { weekends: false, weekdays: [], days: [] };
220
+ if (text === undefined || text === '') return out;
221
+ for (const term of text.toLowerCase().split(/[\s,]+/)) {
222
+ if (term === '') continue;
223
+ if (term === 'weekends') { out.weekends = true; continue; }
224
+ const weekday = ISO_WEEKDAYS[term];
225
+ if (weekday !== undefined) {
226
+ if (!out.weekdays.includes(weekday)) out.weekdays.push(weekday);
227
+ continue;
228
+ }
229
+ const at = readInstant(term, readDate);
230
+ if (Number.isNaN(at)) {
231
+ fail(`excludes '${term}' is neither 'weekends', a weekday name,`
232
+ + ' nor a date in this diagram\'s dateFormat', line);
233
+ }
234
+ const day = dayIndexOf(at);
235
+ if (!out.days.includes(day)) out.days.push(day);
236
+ }
237
+ out.weekdays.sort((a, b) => a - b);
238
+ out.days.sort((a, b) => a - b);
239
+ return out;
240
+ }
241
+
242
+ /** ISO `yyyy-MM-dd`, which Mermaid accepts for an excluded date whatever the dateFormat is. */
243
+ const READ_ISO_DAY = compileDateParser('yyyy-MM-dd');
244
+
245
+ /**
246
+ * @param {string} text
247
+ * @param {(s: string) => any} readDate
248
+ * @returns {number} epoch milliseconds, or NaN
249
+ */
250
+ function readInstant(text, readDate) {
251
+ const parts = readDate(text) ?? READ_ISO_DAY(text);
252
+ if (parts === null) return NaN;
253
+ return epochOfRFC3339Parts(parts);
254
+ }
255
+
256
+ //#endregion
257
+ //#region schedule resolution ----------------------------------------
258
+
259
+ const AFTER = /^after\s+(?<ids>[\w\- ]+)$/;
260
+ const UNTIL = /^until\s+(?<ids>[\w\- ]+)$/;
261
+
262
+ /**
263
+ * Give every task an id, an interval and its dependencies, in an order
264
+ * that respects them.
265
+ * @param {any[]} sections
266
+ * @param {any} rules
267
+ * @returns {void}
268
+ */
269
+ function resolve(sections, rules) {
270
+ const tasks = [];
271
+ for (const section of sections)
272
+ for (const task of section.tasks) tasks.push(task);
273
+ if (tasks.length === 0) return;
274
+
275
+ /** @type {Map<string, any>} */
276
+ const byId = new Map();
277
+ // the parsed specs live beside the tasks, never on them: a task node
278
+ // is born with its final member set and keeps it, so every task in
279
+ // every diagram shares one hidden class (ast.js §4)
280
+ /** @type {Map<any, any>} */
281
+ const specs = new Map();
282
+ let auto = 0;
283
+
284
+ // pass 1: split the raw info into flags, id and the two specs
285
+ for (let i = 0; i < tasks.length; i++) {
286
+ const task = tasks[i];
287
+ const spec = splitInfo(task);
288
+ task.flags = spec.flags;
289
+ task.id = spec.id ?? `task${++auto}`;
290
+ task.after = spec.after;
291
+ spec.previous = i === 0 ? null : tasks[i - 1];
292
+ specs.set(task, spec);
293
+ if (byId.has(task.id)) {
294
+ fail(`task id '${task.id}' is declared twice`, task.line);
295
+ }
296
+ byId.set(task.id, task);
297
+ }
298
+
299
+ // pass 2: resolve in dependency order, refusing cycles
300
+ const state = new Map(); // task -> 'open' | 'done'
301
+ for (const task of tasks) resolveTask(task, byId, specs, state, rules);
302
+ }
303
+
304
+ /**
305
+ * Read the flags, the id and the two specs out of a raw `info` string.
306
+ * The field-count rule is Mermaid's own: one field is an end, two are a
307
+ * start and an end, three add an explicit id in front.
308
+ * @param {any} task
309
+ * @returns {any}
310
+ */
311
+ function splitInfo(task) {
312
+ const fields = task.info.split(',').map((f) => f.trim());
313
+ const flags = [];
314
+ while (fields.length > 0 && FLAG_SET.has(fields[0].toLowerCase())) {
315
+ const flag = fields.shift().toLowerCase();
316
+ if (!flags.includes(flag)) flags.push(flag);
317
+ }
318
+ flags.sort((a, b) => FLAGS.indexOf(a) - FLAGS.indexOf(b));
319
+
320
+ let id = null;
321
+ let startText = null;
322
+ let endText = null;
323
+ if (fields.length === 1) {
324
+ endText = fields[0];
325
+ }
326
+ else if (fields.length === 2) {
327
+ startText = fields[0];
328
+ endText = fields[1];
329
+ }
330
+ else if (fields.length === 3) {
331
+ id = fields[0];
332
+ startText = fields[1];
333
+ endText = fields[2];
334
+ }
335
+ else {
336
+ fail(`task '${task.name}' has ${fields.length} fields after its flags;`
337
+ + ' a task is written as [flags,] [id,] [start,] end', task.line);
338
+ }
339
+ if (id === '') {
340
+ fail(`task '${task.name}' has an empty id`, task.line);
341
+ }
342
+ if (endText === '') {
343
+ fail(`task '${task.name}' has no end, duration or 'until'`, task.line);
344
+ }
345
+
346
+ const afterMatch = startText === null ? null : AFTER.exec(startText);
347
+ const untilMatch = UNTIL.exec(endText);
348
+ return {
349
+ flags, id, startText, endText,
350
+ after: afterMatch === null ? [] : idsOf(afterMatch.groups.ids),
351
+ until: untilMatch === null ? [] : idsOf(untilMatch.groups.ids),
352
+ previous: null,
353
+ };
354
+ }
355
+
356
+ /** @param {string} text @returns {string[]} */
357
+ function idsOf(text) {
358
+ return text.split(' ').map((s) => s.trim()).filter((s) => s !== '');
359
+ }
360
+
361
+ /**
362
+ * Resolve one task, resolving whatever it depends on first.
363
+ * @param {any} task
364
+ * @param {Map<string, any>} byId
365
+ * @param {Map<any, any>} specs
366
+ * @param {Map<any, string>} state
367
+ * @param {any} rules
368
+ * @returns {void}
369
+ */
370
+ function resolveTask(task, byId, specs, state, rules) {
371
+ const seen = state.get(task);
372
+ if (seen === 'done') return;
373
+ if (seen === 'open') {
374
+ fail(`task '${task.id}' depends on itself, directly or through a cycle`, task.line);
375
+ }
376
+ state.set(task, 'open');
377
+ const spec = specs.get(task);
378
+
379
+ /** @param {string} id @param {string} keyword @returns {any} */
380
+ const need = (id, keyword) => {
381
+ const other = byId.get(id);
382
+ if (other === undefined)
383
+ fail(`task '${task.id}' says '${keyword} ${id}', and no task has that id`, task.line);
384
+ resolveTask(other, byId, specs, state, rules);
385
+ return other;
386
+ };
387
+
388
+ // --- start
389
+ let start;
390
+ if (spec.after.length > 0) {
391
+ start = -Infinity;
392
+ for (const id of spec.after) start = Math.max(start, need(id, 'after').end);
393
+ }
394
+ else if (spec.startText !== null) {
395
+ start = readInstant(spec.startText, rules.readDate);
396
+ if (Number.isNaN(start)) {
397
+ fail(`task '${task.id}' starts at '${spec.startText}', which is neither`
398
+ + ` a date in '${rules.published.dateFormat}' nor 'after <id>'`, task.line);
399
+ }
400
+ }
401
+ else if (spec.previous !== null) {
402
+ resolveTask(spec.previous, byId, specs, state, rules);
403
+ start = spec.previous.end;
404
+ }
405
+ else {
406
+ fail(`task '${task.id}' has no start date and no task before it to follow;`
407
+ + ' Mermaid would start it today, and this engine has no clock', task.line);
408
+ }
409
+
410
+ // --- end
411
+ let end;
412
+ let fromDuration = false;
413
+ if (spec.until.length > 0) {
414
+ end = Infinity;
415
+ for (const id of spec.until) end = Math.min(end, need(id, 'until').start);
416
+ }
417
+ else {
418
+ const at = readInstant(spec.endText, rules.readDate);
419
+ if (!Number.isNaN(at)) {
420
+ end = at;
421
+ }
422
+ else {
423
+ const duration = parseTaskDuration(spec.endText);
424
+ if (duration.value === null) {
425
+ fail(`task '${task.id}' ends at '${spec.endText}', which is neither`
426
+ + ` a date in '${rules.published.dateFormat}', a duration (3d, 1.5w, 2M)`
427
+ + ", nor 'until <id>'", task.line);
428
+ }
429
+ end = addDurationTo(start, duration.value, task);
430
+ fromDuration = true;
431
+ }
432
+ }
433
+
434
+ // --- exclusions, then the span rules
435
+ if (fromDuration) {
436
+ const pushed = pushEndPastExclusions(start, end, rules.excluder);
437
+ if (Number.isNaN(pushed)) {
438
+ fail(`task '${task.id}' can never finish: 'excludes' removes every day`
439
+ + ' it would need', task.line);
440
+ }
441
+ end = pushed;
442
+ }
443
+ if (end < start) {
444
+ fail(`task '${task.id}' ends before it starts`, task.line);
445
+ }
446
+ if (end === start && !task.flags.includes('milestone')) {
447
+ fail(`task '${task.id}' is empty; only a milestone has no width`, task.line);
448
+ }
449
+
450
+ task.start = start;
451
+ task.end = end;
452
+ task.duration = end - start;
453
+ state.set(task, 'done');
49
454
  }
455
+
456
+ /**
457
+ * Add a Mermaid duration to an instant through the core calendar. A
458
+ * fixed-width unit is integer milliseconds; `M` and `y` are calendar
459
+ * additions, and the core refuses a fractional one rather than
460
+ * approximating a month, which becomes a `JM` error here.
461
+ * @param {number} start
462
+ * @param {{ amount: number, unit: string }} duration
463
+ * @param {any} task
464
+ * @returns {number}
465
+ */
466
+ function addDurationTo(start, duration, task) {
467
+ try {
468
+ const parts = addToParts(partsFromEpoch(start), duration.amount, duration.unit);
469
+ return epochOfRFC3339Parts(parts);
470
+ }
471
+ catch (err) {
472
+ return fail(`task '${task.id}' has an unusable duration: ${err.message}`, task.line);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * The half-open span every task falls inside — the shared time domain
478
+ * layout scales against. A schedule of one milestone has no width, so
479
+ * it is padded to a day to stay drawable.
480
+ * @param {any[]} sections
481
+ * @returns {{ start: number, end: number }}
482
+ */
483
+ function domainOf(sections) {
484
+ let start = Infinity;
485
+ let end = -Infinity;
486
+ for (const section of sections) {
487
+ for (const task of section.tasks) {
488
+ if (task.start < start) start = task.start;
489
+ if (task.end > end) end = task.end;
490
+ }
491
+ }
492
+ if (start === Infinity) return { start: 0, end: 0 };
493
+ if (start === end) return { start, end: end + DAY_MS };
494
+ return { start, end };
495
+ }
496
+
497
+ //#endregion
@@ -60,7 +60,10 @@ export const SECONDARY_TYPES = new Set([
60
60
  /**
61
61
  * Parse Mermaid source into a `DiagramDocument`.
62
62
  * @param {string} source
63
- * @param {{ parseFrontmatter?: (text: string) => any }} [options]
63
+ * @param {{ parseFrontmatter?: (text: string) => any,
64
+ * dateNames?: import('@jarenjs/core/dates').DateNames }} [options] -
65
+ * `dateNames` is the locale-name record a Gantt's `dateFormat` needs
66
+ * for a month name token; this engine ships none of its own
64
67
  * @returns {import('../ast.js').DiagramDocument}
65
68
  */
66
69
  export function parseMermaid(source, options = {}) {
@@ -92,7 +95,7 @@ export function parseMermaid(source, options = {}) {
92
95
  ast = parseSequence(bodyLines, bodyOffset);
93
96
  }
94
97
  else if (TYPE_PARSERS[type] !== undefined) {
95
- ast = TYPE_PARSERS[type](bodyLines, bodyOffset, header);
98
+ ast = TYPE_PARSERS[type](bodyLines, bodyOffset, header, options);
96
99
  }
97
100
  else {
98
101
  // Secondary: preserve the raw body so `toMermaid` round-trips and
@@ -0,0 +1,169 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Gantt renderer: a positioned schedule → pure-vnode SVG. A real
4
+ * timeline — an aligned time axis, section bands, task bars, milestone
5
+ * diamonds, dependency connectors and shaded excluded days — where the
6
+ * engine used to draw a list of strings in a box.
7
+ *
8
+ * It imports `@jarenjs/core` and `@jarenjs/view` and nothing else: the
9
+ * tick ladder is core's, so this file acquires no dependency on the
10
+ * chart component to draw a time axis.
11
+ *
12
+ * Accessibility: the root carries a `<title>` (the diagram's own title,
13
+ * or a generic one) and a `<desc>` naming the span and the task count,
14
+ * so a screen reader gets the shape of the schedule rather than a
15
+ * hundred unlabelled rectangles. Bars carry `data-id` and a status
16
+ * class, which is also what an interactive host hit-tests on.
17
+ */
18
+
19
+ import { svgRoot, group, rect, path, polygon, textAt, num } from '@jarenjs/view/helpers';
20
+ import { compileDateFormat, partsFromEpoch } from '@jarenjs/core/dates';
21
+
22
+ // the summary line is a date range, and it is the one label on the
23
+ // diagram that is NOT the document's axisFormat: it must stay readable
24
+ // when the axis is showing bare month numbers
25
+ const SUMMARY_DATE = compileDateFormat('yyyy-MM-dd');
26
+
27
+ /**
28
+ * @param {any} scene PositionedDiagram (gantt)
29
+ * @param {{ tokens: Record<string,string>, cssVars: Record<string,string> }} theme
30
+ * @param {string} hash
31
+ * @returns {any}
32
+ */
33
+ export function renderGantt(scene, theme, hash) {
34
+ const t = theme.tokens;
35
+ const fs = scene.fontSize;
36
+ const children = [];
37
+ const plot = scene.plot;
38
+
39
+ children.push(['title', {}, titleText(scene)]);
40
+ children.push(['desc', {}, descText(scene)]);
41
+
42
+ // Section bands (behind everything).
43
+ for (const section of scene.sections) {
44
+ if (!section.band) continue;
45
+ children.push(rect(0, section.y, scene.width, section.h, {
46
+ class: 'mm-gantt-band', fill: t.clusterFill, key: 'band-' + section.index,
47
+ }));
48
+ }
49
+
50
+ // Excluded days.
51
+ for (let i = 0; i < scene.bands.length; i++) {
52
+ const band = scene.bands[i];
53
+ children.push(rect(band.x, plot.y, band.w, plot.h, {
54
+ class: 'mm-gantt-excluded', fill: t.activationFill, key: 'excl-' + i,
55
+ }));
56
+ }
57
+
58
+ // Grid lines and axis labels.
59
+ for (let i = 0; i < scene.ticks.length; i++) {
60
+ const tick = scene.ticks[i];
61
+ children.push(path(`M${num(tick.x)},${num(plot.y)} V${num(plot.y + plot.h)}`, {
62
+ class: 'mm-gantt-grid', stroke: t.clusterStroke, 'stroke-width': 1, key: 'grid-' + i,
63
+ }));
64
+ if (tick.label !== null) {
65
+ children.push(textAt(tick.x, scene.axisY - 8, tick.label, fs - 1, {
66
+ class: 'mm-gantt-tick', 'text-anchor': 'middle', fill: t.nodeText, key: 'tick-' + i,
67
+ }));
68
+ }
69
+ }
70
+ children.push(path(`M${num(plot.x)},${num(plot.y)} H${num(plot.x + plot.w)}`, {
71
+ class: 'mm-gantt-axis', stroke: t.lineColor, 'stroke-width': 1,
72
+ }));
73
+
74
+ // Dependency connectors, under the bars.
75
+ for (let i = 0; i < scene.links.length; i++) {
76
+ const link = scene.links[i];
77
+ children.push(path(polyline(link.points), {
78
+ class: 'mm-gantt-link', fill: 'none', stroke: t.lineColor,
79
+ 'stroke-width': 1, 'stroke-dasharray': '3 3', key: 'link-' + i,
80
+ }));
81
+ }
82
+
83
+ // Section headings.
84
+ for (const section of scene.sections) {
85
+ if (section.name === null) continue;
86
+ children.push(textAt(0 + 12, section.labelY + 4, section.name, fs, {
87
+ class: 'mm-gantt-section', 'font-weight': 'bold', fill: t.nodeText,
88
+ key: 'sect-' + section.index,
89
+ }));
90
+ }
91
+
92
+ // Rows: the name, then the bar or the milestone.
93
+ for (const row of scene.rows) {
94
+ children.push(group({ class: row.classes, key: 'row-' + row.id, 'data-id': row.id }, [
95
+ textAt(row.labelX, row.labelY + 4, row.name, fs, {
96
+ class: 'mm-gantt-label', fill: t.nodeText,
97
+ }),
98
+ row.milestone
99
+ ? polygon(diamond(row.x, row.labelY, row.r), {
100
+ class: 'mm-gantt-shape', fill: fillFor(row.flags, t), stroke: strokeFor(row.flags, t),
101
+ 'stroke-width': 1,
102
+ })
103
+ : rect(row.x, row.barY, row.w, row.barH, {
104
+ class: 'mm-gantt-shape', rx: 3, fill: fillFor(row.flags, t),
105
+ stroke: strokeFor(row.flags, t), 'stroke-width': 1,
106
+ }),
107
+ ]));
108
+ }
109
+
110
+ // The title last, so nothing paints over it.
111
+ if (scene.title !== null) {
112
+ children.push(textAt(12, scene.titleY, scene.title, fs + 3, {
113
+ class: 'mm-gantt-title', 'font-weight': 'bold', fill: t.nodeText,
114
+ }));
115
+ }
116
+
117
+ return svgRoot('mermaid mm-svg mm-gantt', scene.width, scene.height, theme, children,
118
+ 'mmgantt-' + hash, { fit: false });
119
+ }
120
+
121
+ /**
122
+ * A task's fill: `crit` is the warning ink, `done` the muted panel and
123
+ * `active` the accent, which is the precedence Mermaid documents.
124
+ * @param {string[]} flags @param {Record<string,string>} t
125
+ * @returns {string}
126
+ */
127
+ function fillFor(flags, t) {
128
+ if (flags.includes('crit')) return t.noteFill;
129
+ if (flags.includes('done')) return t.activationFill;
130
+ return t.nodeFill;
131
+ }
132
+
133
+ /** @param {string[]} flags @param {Record<string,string>} t @returns {string} */
134
+ function strokeFor(flags, t) {
135
+ if (flags.includes('crit')) return t.noteStroke;
136
+ if (flags.includes('done')) return t.activationStroke;
137
+ return t.nodeStroke;
138
+ }
139
+
140
+ /** @param {number[][]} points @returns {string} */
141
+ function polyline(points) {
142
+ let d = '';
143
+ for (let i = 0; i < points.length; i++)
144
+ d += (i === 0 ? 'M' : 'L') + num(points[i][0]) + ',' + num(points[i][1]) + ' ';
145
+ return d.trim();
146
+ }
147
+
148
+ /** @param {number} cx @param {number} cy @param {number} r @returns {{x:number,y:number}[]} */
149
+ function diamond(cx, cy, r) {
150
+ return [
151
+ { x: cx, y: cy - r }, { x: cx + r, y: cy },
152
+ { x: cx, y: cy + r }, { x: cx - r, y: cy },
153
+ ];
154
+ }
155
+
156
+ /** @param {any} scene @returns {string} */
157
+ function titleText(scene) {
158
+ return scene.title === null ? 'Gantt chart' : `Gantt chart: ${scene.title}`;
159
+ }
160
+
161
+ /** @param {any} scene @returns {string} */
162
+ function descText(scene) {
163
+ const count = scene.rows.length;
164
+ const tasks = `${count} task${count === 1 ? '' : 's'}`;
165
+ if (count === 0) return 'An empty schedule.';
166
+ const from = SUMMARY_DATE(partsFromEpoch(scene.domain.start));
167
+ const to = SUMMARY_DATE(partsFromEpoch(scene.domain.end));
168
+ return `${tasks} from ${from} to ${to}.`;
169
+ }