@jarenjs/mermaid 0.46.4 → 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.
- package/README.md +63 -3
- package/dist/types/gantt-calendar.d.ts +99 -0
- package/dist/types/index.d.ts +7 -1
- package/dist/types/layout/gantt.d.ts +31 -0
- package/dist/types/parser/gantt-grammar.d.ts +174 -0
- package/dist/types/parser/gantt.d.ts +35 -5
- package/dist/types/parser/index.d.ts +5 -1
- package/dist/types/render/gantt.d.ts +26 -0
- package/dist/types/render/misc.d.ts +1 -1
- package/docs/MERMAID-FORMAT.md +106 -4
- package/package.json +4 -4
- package/schemas/jaren-mermaid-ast.schema.json +379 -37
- package/src/gantt-calendar.js +152 -0
- package/src/index.js +5 -1
- package/src/layout/gantt.js +269 -0
- package/src/parser/gantt-grammar.js +298 -0
- package/src/parser/gantt.js +456 -8
- package/src/parser/index.js +5 -2
- package/src/render/gantt.js +169 -0
- package/src/render/index.js +6 -2
- package/src/render/misc.js +5 -13
- package/styles/mermaid.css +21 -0
package/src/parser/gantt.js
CHANGED
|
@@ -1,19 +1,84 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/**
|
|
3
|
-
* @file Gantt grammar → gantt AST: a schedule
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
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
|
-
|
|
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({
|
|
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
|
package/src/parser/index.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|