@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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Gantt layout: a resolved schedule → a deterministic
|
|
4
|
+
* `PositionedDiagram` scene. Pure, host-free and vnode-free, so the
|
|
5
|
+
* geometry can be asserted without rendering anything.
|
|
6
|
+
*
|
|
7
|
+
* The time axis is not this file's invention: the domain comes from the
|
|
8
|
+
* AST, the tick positions come from `@jarenjs/core/dates` — either the
|
|
9
|
+
* document's own `tickInterval` through `timeTicksEvery`, or the shared
|
|
10
|
+
* step ladder through `axisTicksTime`, the same one the chart component
|
|
11
|
+
* draws its time axis with — and the tick labels come from the
|
|
12
|
+
* document's `axisFormat` compiled once here. A second tick ladder in a
|
|
13
|
+
* renderer would be a second set of positions.
|
|
14
|
+
*
|
|
15
|
+
* Two decisions are made here rather than in the renderer, because both
|
|
16
|
+
* are geometry:
|
|
17
|
+
*
|
|
18
|
+
* - **Tick labels thin out.** `tickInterval 1day` over two years is 730
|
|
19
|
+
* ticks; the marks all stay, and labels are kept at the largest
|
|
20
|
+
* stride that leaves them from overlapping. The scene says which.
|
|
21
|
+
* - **Row names elide.** A name wider than the label column is cut with
|
|
22
|
+
* an ellipsis at layout time, so the measured width and the drawn text
|
|
23
|
+
* are the same string.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { textWidth } from '@jarenjs/view/helpers';
|
|
27
|
+
import {
|
|
28
|
+
compileDateFormat, axisTicksTime, timeTicksEvery, partsFromEpoch,
|
|
29
|
+
} from '@jarenjs/core/dates';
|
|
30
|
+
import { coord as round } from '../utils.js';
|
|
31
|
+
import { strftimeToLdml } from '../parser/gantt-grammar.js';
|
|
32
|
+
import { createExcluder, DAY_MS } from '../gantt-calendar.js';
|
|
33
|
+
|
|
34
|
+
const FONT_SIZE = 13;
|
|
35
|
+
const MARGIN = 12;
|
|
36
|
+
const TITLE_H = 30;
|
|
37
|
+
const AXIS_H = 26;
|
|
38
|
+
const ROW_H = 24;
|
|
39
|
+
const BAR_H = 15;
|
|
40
|
+
const SECTION_H = 22;
|
|
41
|
+
const LABEL_PAD = 10;
|
|
42
|
+
const LABEL_MAX = 220;
|
|
43
|
+
const PLOT_W = 640;
|
|
44
|
+
const TICK_LABEL_GAP = 12;
|
|
45
|
+
const MILESTONE_R = 7;
|
|
46
|
+
const MIN_BAR_W = 2;
|
|
47
|
+
const LINK_INSET = 6;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {any} ast gantt AST (`parser/gantt.js`)
|
|
51
|
+
* @param {{ dateNames?: import('@jarenjs/core/dates').DateNames }} [options]
|
|
52
|
+
* @returns {any} PositionedDiagram
|
|
53
|
+
*/
|
|
54
|
+
export function layoutGantt(ast, options = {}) {
|
|
55
|
+
const rules = ast.rules;
|
|
56
|
+
const title = ast.meta.title ?? null;
|
|
57
|
+
|
|
58
|
+
// --- the label column
|
|
59
|
+
let labelW = 0;
|
|
60
|
+
for (const section of ast.sections)
|
|
61
|
+
for (const task of section.tasks)
|
|
62
|
+
labelW = Math.max(labelW, textWidth(task.name, FONT_SIZE));
|
|
63
|
+
labelW = Math.min(LABEL_MAX, Math.ceil(labelW)) + 2 * LABEL_PAD;
|
|
64
|
+
|
|
65
|
+
const plotX = MARGIN + labelW;
|
|
66
|
+
const plotW = PLOT_W;
|
|
67
|
+
const width = plotX + plotW + MARGIN;
|
|
68
|
+
|
|
69
|
+
// --- the shared time domain
|
|
70
|
+
const domain = ast.domain;
|
|
71
|
+
const span = domain.end - domain.start || DAY_MS;
|
|
72
|
+
const xOf = (at) => round(plotX + ((at - domain.start) / span) * plotW);
|
|
73
|
+
|
|
74
|
+
// --- rows and sections, top to bottom
|
|
75
|
+
const top = (title === null ? 0 : TITLE_H) + AXIS_H + MARGIN;
|
|
76
|
+
const sections = [];
|
|
77
|
+
const rows = [];
|
|
78
|
+
/** @type {Map<string, any>} */
|
|
79
|
+
const byId = new Map();
|
|
80
|
+
let y = top;
|
|
81
|
+
let index = 0;
|
|
82
|
+
for (let si = 0; si < ast.sections.length; si++) {
|
|
83
|
+
const section = ast.sections[si];
|
|
84
|
+
const named = section.name !== null && section.name !== '';
|
|
85
|
+
const sectionTop = y;
|
|
86
|
+
if (named) y += SECTION_H;
|
|
87
|
+
for (const task of section.tasks) {
|
|
88
|
+
const milestone = task.flags.includes('milestone');
|
|
89
|
+
const barW = milestone ? 0 : Math.max(MIN_BAR_W, xOf(task.end) - xOf(task.start));
|
|
90
|
+
// a task far shorter than a pixel still has to be visible, and the
|
|
91
|
+
// minimum width would otherwise push the last bar of a long
|
|
92
|
+
// schedule past the right edge of its own plot
|
|
93
|
+
const barX = milestone
|
|
94
|
+
? xOf((task.start + task.end) / 2)
|
|
95
|
+
: Math.min(xOf(task.start), plotX + plotW - barW);
|
|
96
|
+
const row = {
|
|
97
|
+
index,
|
|
98
|
+
id: task.id,
|
|
99
|
+
name: elide(task.name, labelW - 2 * LABEL_PAD),
|
|
100
|
+
line: task.line,
|
|
101
|
+
start: task.start,
|
|
102
|
+
end: task.end,
|
|
103
|
+
milestone,
|
|
104
|
+
flags: task.flags,
|
|
105
|
+
after: task.after,
|
|
106
|
+
y,
|
|
107
|
+
h: ROW_H,
|
|
108
|
+
labelX: MARGIN + LABEL_PAD,
|
|
109
|
+
labelY: round(y + ROW_H / 2),
|
|
110
|
+
x: round(barX),
|
|
111
|
+
w: round(barW),
|
|
112
|
+
barY: round(y + (ROW_H - BAR_H) / 2),
|
|
113
|
+
barH: BAR_H,
|
|
114
|
+
r: MILESTONE_R,
|
|
115
|
+
classes: classesFor(task.flags),
|
|
116
|
+
};
|
|
117
|
+
rows.push(row);
|
|
118
|
+
byId.set(task.id, row);
|
|
119
|
+
y += ROW_H;
|
|
120
|
+
index++;
|
|
121
|
+
}
|
|
122
|
+
sections.push({
|
|
123
|
+
name: named ? section.name : null,
|
|
124
|
+
index: si,
|
|
125
|
+
y: sectionTop,
|
|
126
|
+
h: y - sectionTop,
|
|
127
|
+
labelY: round(sectionTop + SECTION_H / 2),
|
|
128
|
+
band: si % 2 === 1,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const plotBottom = Math.max(y, top);
|
|
132
|
+
const height = plotBottom + MARGIN;
|
|
133
|
+
|
|
134
|
+
// --- ticks, positions from the core kernel and labels from axisFormat
|
|
135
|
+
const ticks = planTicks(domain, rules, xOf, options.dateNames);
|
|
136
|
+
// --- excluded days, as shaded bands clipped to the domain
|
|
137
|
+
const bands = planBands(domain, rules, xOf);
|
|
138
|
+
// --- dependency anchors
|
|
139
|
+
const links = planLinks(rows, byId);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
kind: 'gantt',
|
|
143
|
+
width,
|
|
144
|
+
height,
|
|
145
|
+
fontSize: FONT_SIZE,
|
|
146
|
+
title,
|
|
147
|
+
domain,
|
|
148
|
+
plot: { x: plotX, y: top, w: plotW, h: plotBottom - top },
|
|
149
|
+
axisY: (title === null ? 0 : TITLE_H) + AXIS_H,
|
|
150
|
+
titleY: title === null ? 0 : 22,
|
|
151
|
+
labelWidth: labelW,
|
|
152
|
+
ticks,
|
|
153
|
+
bands,
|
|
154
|
+
sections,
|
|
155
|
+
rows,
|
|
156
|
+
links,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Tick positions and the labels that survive collision.
|
|
162
|
+
* @param {{ start: number, end: number }} domain
|
|
163
|
+
* @param {any} rules
|
|
164
|
+
* @param {(at: number) => number} xOf
|
|
165
|
+
* @param {any} dateNames
|
|
166
|
+
* @returns {any[]}
|
|
167
|
+
*/
|
|
168
|
+
function planTicks(domain, rules, xOf, dateNames) {
|
|
169
|
+
const at = rules.tick === null
|
|
170
|
+
? axisTicksTime(domain.start, domain.end, Math.max(2, Math.round(PLOT_W / 110)))
|
|
171
|
+
: timeTicksEvery(domain.start, domain.end, rules.tick.unit, rules.tick.amount,
|
|
172
|
+
{ weekStart: rules.weekStart });
|
|
173
|
+
|
|
174
|
+
const adapted = strftimeToLdml(rules.axisFormat);
|
|
175
|
+
// the parser already refused an unsupported specifier, so this compiles
|
|
176
|
+
const label = compileDateFormat(adapted.value, dateNames);
|
|
177
|
+
const parts = at.map((ms) => label(partsFromEpoch(ms)));
|
|
178
|
+
|
|
179
|
+
// keep every `stride`-th label, the smallest stride that fits
|
|
180
|
+
let widest = 0;
|
|
181
|
+
for (const text of parts) widest = Math.max(widest, textWidth(text, FONT_SIZE - 1));
|
|
182
|
+
const need = widest + TICK_LABEL_GAP;
|
|
183
|
+
const step = at.length > 1 ? Math.abs(xOf(at[1]) - xOf(at[0])) : need;
|
|
184
|
+
const stride = step >= need ? 1 : Math.max(1, Math.ceil(need / Math.max(step, 0.5)));
|
|
185
|
+
|
|
186
|
+
return at.map((ms, i) => ({
|
|
187
|
+
at: ms,
|
|
188
|
+
x: xOf(ms),
|
|
189
|
+
label: i % stride === 0 ? parts[i] : null,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The excluded days, as bands over the domain. Rebuilt from the AST's
|
|
195
|
+
* published rules rather than carried through it, because a working
|
|
196
|
+
* calendar is a closure and the AST is plain JSON.
|
|
197
|
+
* @param {{ start: number, end: number }} domain
|
|
198
|
+
* @param {any} rules
|
|
199
|
+
* @param {(at: number) => number} xOf
|
|
200
|
+
* @returns {any[]}
|
|
201
|
+
*/
|
|
202
|
+
function planBands(domain, rules, xOf) {
|
|
203
|
+
const excluder = createExcluder(rules.excludes, rules.weekendStart);
|
|
204
|
+
if (!excluder.any) return [];
|
|
205
|
+
const fromDay = Math.floor(domain.start / DAY_MS);
|
|
206
|
+
const toDay = Math.ceil(domain.end / DAY_MS);
|
|
207
|
+
// a domain wider than a few years shades nothing: the bands would be
|
|
208
|
+
// narrower than a hairline and the answer is the axis, not stripes
|
|
209
|
+
if (toDay - fromDay > 1200) return [];
|
|
210
|
+
return excluder.intervalsIn(fromDay, toDay).map((span) => {
|
|
211
|
+
const start = Math.max(span.start, domain.start);
|
|
212
|
+
const end = Math.min(span.end, domain.end);
|
|
213
|
+
return { start, end, x: xOf(start), w: round(Math.max(0, xOf(end) - xOf(start))) };
|
|
214
|
+
}).filter((band) => band.w > 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One orthogonal connector per `after` dependency: out of the
|
|
219
|
+
* predecessor's right edge, across, and into this row's left edge.
|
|
220
|
+
* @param {any[]} rows
|
|
221
|
+
* @param {Map<string, any>} byId
|
|
222
|
+
* @returns {any[]}
|
|
223
|
+
*/
|
|
224
|
+
function planLinks(rows, byId) {
|
|
225
|
+
const links = [];
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
for (const id of row.after) {
|
|
228
|
+
const from = byId.get(id);
|
|
229
|
+
if (from === undefined || from.index >= row.index) continue;
|
|
230
|
+
const x1 = round(from.milestone ? from.x + from.r : from.x + from.w);
|
|
231
|
+
const y1 = round(from.y + from.h / 2);
|
|
232
|
+
const x2 = round(row.milestone ? row.x - row.r : row.x);
|
|
233
|
+
const y2 = round(row.y + row.h / 2);
|
|
234
|
+
if (Math.abs(x2 - x1) < 1) {
|
|
235
|
+
// the successor starts where its predecessor ended: a straight
|
|
236
|
+
// drop reads as "immediately after", a hook reads as a delay
|
|
237
|
+
links.push({ from: id, to: row.id, points: [[x1, y1], [x1, y2]] });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const mid = round(Math.max(x1 + LINK_INSET, x2 - LINK_INSET));
|
|
241
|
+
links.push({ from: id, to: row.id, points: [[x1, y1], [mid, y1], [mid, y2], [x2, y2]] });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return links;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {string[]} flags
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
function classesFor(flags) {
|
|
252
|
+
let out = 'mm-gantt-task';
|
|
253
|
+
for (const flag of flags) out += ` mm-gantt-${flag}`;
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Cut a name to the label column, measuring the string that will
|
|
259
|
+
* actually be drawn.
|
|
260
|
+
* @param {string} text
|
|
261
|
+
* @param {number} max
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function elide(text, max) {
|
|
265
|
+
if (textWidth(text, FONT_SIZE) <= max) return text;
|
|
266
|
+
let cut = text.length;
|
|
267
|
+
while (cut > 1 && textWidth(text.slice(0, cut) + '…', FONT_SIZE) > max) cut--;
|
|
268
|
+
return text.slice(0, cut) + '…';
|
|
269
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The three Gantt token grammars, adapted to the core date
|
|
4
|
+
* kernel. Mermaid's Gantt header speaks **three different pattern
|
|
5
|
+
* languages** and none of them is Unicode LDML:
|
|
6
|
+
*
|
|
7
|
+
* - `dateFormat` is dayjs' `customParseFormat` vocabulary (moment's
|
|
8
|
+
* spelling): `YYYY-MM-DD`, where `YYYY` is the calendar year;
|
|
9
|
+
* - `axisFormat` is d3-time-format's strftime vocabulary: `%Y-%m-%d`;
|
|
10
|
+
* - `tickInterval` and a task's duration are two small regular
|
|
11
|
+
* grammars of their own (`1week`, `3d`).
|
|
12
|
+
*
|
|
13
|
+
* Each gets its own tokenizer here, and each token is mapped
|
|
14
|
+
* individually onto the LDML token `compileDateFormat`/`compileDateParser`
|
|
15
|
+
* understand. Handing `YYYY` straight to the core compiler would be a
|
|
16
|
+
* silent lie — LDML's `YYYY` is the *week-numbering* year, which is
|
|
17
|
+
* moment's most reported footgun and the reason `@jarenjs/core/dates`
|
|
18
|
+
* refuses that spelling in the first place.
|
|
19
|
+
*
|
|
20
|
+
* Every function here returns `{ value, error }` rather than throwing:
|
|
21
|
+
* the caller owns the source line, and a `JM` error without one is not
|
|
22
|
+
* worth much.
|
|
23
|
+
*
|
|
24
|
+
* The vendored conformance table for all of this — which tokens the
|
|
25
|
+
* shipped Mermaid version documents, which of them this engine
|
|
26
|
+
* implements and why the rest are refused — is
|
|
27
|
+
* `test/mermaid/fixtures/gantt-grammar.json`, pinned against this file
|
|
28
|
+
* by `test/mermaid/gantt.test.js`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Mermaid's own default when a diagram declares no `dateFormat`. */
|
|
32
|
+
export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
|
33
|
+
|
|
34
|
+
/** Mermaid's own default when a diagram declares no `axisFormat`. */
|
|
35
|
+
export const DEFAULT_AXIS_FORMAT = '%Y-%m-%d';
|
|
36
|
+
|
|
37
|
+
/** dayjs token → the LDML token that reads and writes the same field. */
|
|
38
|
+
const MOMENT_TO_LDML = Object.freeze({
|
|
39
|
+
YYYY: 'yyyy', YY: 'yy', Y: 'y',
|
|
40
|
+
MMMM: 'MMMM', MMM: 'MMM', MM: 'MM', M: 'M',
|
|
41
|
+
DD: 'dd', D: 'd',
|
|
42
|
+
HH: 'HH', H: 'H', hh: 'hh', h: 'h',
|
|
43
|
+
mm: 'mm', m: 'm', ss: 'ss', s: 's',
|
|
44
|
+
SSS: 'SSS', S: 'S',
|
|
45
|
+
A: 'a', a: 'a',
|
|
46
|
+
ZZ: 'XX', Z: 'XXX',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Documented dayjs tokens this engine will not read, and why. */
|
|
50
|
+
const MOMENT_REFUSED = Object.freeze({
|
|
51
|
+
SS: 'the fraction tokens are tenths (S) and milliseconds (SSS); there is no hundredths field',
|
|
52
|
+
Q: 'a quarter is derived from a month, not a field of a date',
|
|
53
|
+
Do: 'the ordinal suffix is locale text and this engine ships no ordinal data',
|
|
54
|
+
DDDD: 'the day of the year is derived from a date, not a field of one',
|
|
55
|
+
DDD: 'the day of the year is derived from a date, not a field of one',
|
|
56
|
+
X: 'an epoch second is a whole value, not a calendar field',
|
|
57
|
+
x: 'an epoch millisecond is a whole value, not a calendar field',
|
|
58
|
+
ww: 'the ISO week number is derived from a date, not a field of one',
|
|
59
|
+
w: 'the ISO week number is derived from a date, not a field of one',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** strftime specifier → the LDML fragment that writes the same field. */
|
|
63
|
+
const STRFTIME_TO_LDML = Object.freeze({
|
|
64
|
+
Y: 'yyyy', y: 'yy', m: 'MM', d: 'dd',
|
|
65
|
+
H: 'HH', I: 'hh', M: 'mm', S: 'ss', L: 'SSS', j: 'DDD',
|
|
66
|
+
a: 'EEE', A: 'EEEE', b: 'MMM', B: 'MMMM', p: 'a',
|
|
67
|
+
Z: 'XX',
|
|
68
|
+
x: "MM'/'dd'/'yyyy", X: "HH':'mm':'ss",
|
|
69
|
+
'%': "'%'",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Documented strftime specifiers this engine will not write, and why. */
|
|
73
|
+
const STRFTIME_REFUSED = Object.freeze({
|
|
74
|
+
e: 'a space-padded day is a token the formatter does not have',
|
|
75
|
+
c: 'it expands to %e, which is unsupported',
|
|
76
|
+
U: 'd3 counts weeks from the first Sunday; the core week number is ISO, so the two disagree',
|
|
77
|
+
W: 'd3 counts weeks from the first Monday; the core week number is ISO, so the two disagree',
|
|
78
|
+
w: 'd3 numbers Sunday 0; the core weekday number is ISO, where 1 is Monday',
|
|
79
|
+
f: 'microseconds are below the millisecond this suite measures time in',
|
|
80
|
+
g: 'the week-based year is a second year field, and the core parts record has one',
|
|
81
|
+
G: 'the week-based year is a second year field, and the core parts record has one',
|
|
82
|
+
q: 'a quarter on a time axis is a label, not a tick this engine plans',
|
|
83
|
+
Q: 'an epoch is a whole value, not a calendar field',
|
|
84
|
+
s: 'an epoch is a whole value, not a calendar field',
|
|
85
|
+
u: 'the ISO weekday number is a tick label this engine does not plan',
|
|
86
|
+
V: 'the ISO week number is a tick label this engine does not plan',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** The LDML tokens that need a `names` provider before they compile. */
|
|
90
|
+
const NEEDS_NAMES = new Set(['MMM', 'MMMM', 'EEE', 'EEEE', 'a']);
|
|
91
|
+
|
|
92
|
+
const MAX_MOMENT_TOKEN = 4;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {{ value: any, error: string | null }} Adapted
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/** @param {string} message @returns {Adapted} */
|
|
99
|
+
function bad(message) {
|
|
100
|
+
return { value: null, error: message };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {any} value @returns {Adapted} */
|
|
104
|
+
function ok(value) {
|
|
105
|
+
return { value, error: null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Quote a run of literal text so `compileDateFormat` cannot read a
|
|
110
|
+
* letter inside it as a token.
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function quoted(text) {
|
|
115
|
+
return text === '' ? '' : `'${text.replace(/'/g, "''")}'`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Adapt a Mermaid `dateFormat` to an LDML pattern.
|
|
120
|
+
* @param {string} pattern - a dayjs `customParseFormat` pattern
|
|
121
|
+
* @returns {Adapted} `value` is the LDML pattern
|
|
122
|
+
*/
|
|
123
|
+
export function momentToLdml(pattern) {
|
|
124
|
+
let out = '';
|
|
125
|
+
let literal = '';
|
|
126
|
+
for (let i = 0; i < pattern.length;) {
|
|
127
|
+
const ch = pattern[i];
|
|
128
|
+
if (ch === '[') { // dayjs' bracket escape
|
|
129
|
+
const end = pattern.indexOf(']', i + 1);
|
|
130
|
+
if (end < 0)
|
|
131
|
+
return bad(`unterminated '[' escape in dateFormat '${pattern}'`);
|
|
132
|
+
literal += pattern.slice(i + 1, end);
|
|
133
|
+
i = end + 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
let matched = null;
|
|
137
|
+
for (let len = MAX_MOMENT_TOKEN; len >= 1; len--) {
|
|
138
|
+
const candidate = pattern.slice(i, i + len);
|
|
139
|
+
if (candidate.length !== len) continue;
|
|
140
|
+
if (MOMENT_TO_LDML[candidate] !== undefined || MOMENT_REFUSED[candidate] !== undefined) {
|
|
141
|
+
matched = candidate;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (matched === null) {
|
|
146
|
+
literal += ch;
|
|
147
|
+
i += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (MOMENT_REFUSED[matched] !== undefined) {
|
|
151
|
+
return bad(`dateFormat token '${matched}' is not supported: ${MOMENT_REFUSED[matched]}`);
|
|
152
|
+
}
|
|
153
|
+
out += quoted(literal);
|
|
154
|
+
literal = '';
|
|
155
|
+
out += MOMENT_TO_LDML[matched];
|
|
156
|
+
i += matched.length;
|
|
157
|
+
}
|
|
158
|
+
out += quoted(literal);
|
|
159
|
+
return ok(out);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Adapt a Mermaid `axisFormat` to an LDML pattern. This is a separate
|
|
164
|
+
* grammar from `dateFormat` on purpose: `%m` is a month and `m` is a
|
|
165
|
+
* minute, so one table serving both would silently mis-read half of
|
|
166
|
+
* every axis.
|
|
167
|
+
* @param {string} pattern - a d3-time-format (strftime) pattern
|
|
168
|
+
* @returns {Adapted} `value` is the LDML pattern
|
|
169
|
+
*/
|
|
170
|
+
export function strftimeToLdml(pattern) {
|
|
171
|
+
let out = '';
|
|
172
|
+
let literal = '';
|
|
173
|
+
for (let i = 0; i < pattern.length;) {
|
|
174
|
+
if (pattern[i] !== '%') {
|
|
175
|
+
literal += pattern[i];
|
|
176
|
+
i += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const spec = pattern[i + 1];
|
|
180
|
+
if (spec === undefined)
|
|
181
|
+
return bad(`axisFormat '${pattern}' ends in a bare '%'`);
|
|
182
|
+
if (STRFTIME_REFUSED[spec] !== undefined)
|
|
183
|
+
return bad(`axisFormat specifier '%${spec}' is not supported: ${STRFTIME_REFUSED[spec]}`);
|
|
184
|
+
const ldml = STRFTIME_TO_LDML[spec];
|
|
185
|
+
if (ldml === undefined)
|
|
186
|
+
return bad(`axisFormat specifier '%${spec}' is not a d3-time-format directive`);
|
|
187
|
+
out += quoted(literal);
|
|
188
|
+
literal = '';
|
|
189
|
+
out += ldml;
|
|
190
|
+
i += 2;
|
|
191
|
+
}
|
|
192
|
+
out += quoted(literal);
|
|
193
|
+
return ok(out);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether an LDML pattern uses a token that needs locale names, and
|
|
198
|
+
* which token that is. Used to turn `compileDateFormat`'s TypeError into
|
|
199
|
+
* a `JM` error that names the Mermaid spelling and the way out.
|
|
200
|
+
* @param {string} ldml
|
|
201
|
+
* @returns {string | null}
|
|
202
|
+
*/
|
|
203
|
+
export function ldmlNeedsNames(ldml) {
|
|
204
|
+
let inQuote = false;
|
|
205
|
+
for (let i = 0; i < ldml.length;) {
|
|
206
|
+
if (ldml[i] === "'") {
|
|
207
|
+
if (ldml[i + 1] === "'") { i += 2; continue; }
|
|
208
|
+
inQuote = !inQuote;
|
|
209
|
+
i += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (inQuote) { i += 1; continue; }
|
|
213
|
+
for (let len = 4; len >= 1; len--) {
|
|
214
|
+
const candidate = ldml.slice(i, i + len);
|
|
215
|
+
if (candidate.length === len && NEEDS_NAMES.has(candidate))
|
|
216
|
+
return candidate;
|
|
217
|
+
}
|
|
218
|
+
i += 1;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `tickInterval` is one regular grammar in the shipped renderer, and
|
|
224
|
+
// this is that regular expression. It is a module constant: a Gantt
|
|
225
|
+
// header is read once per parse, but building a pattern per parse is
|
|
226
|
+
// the habit this codebase does not have.
|
|
227
|
+
const TICK_INTERVAL = /^([1-9]\d*)(millisecond|second|minute|hour|day|week|month)$/;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Parse a `tickInterval` directive.
|
|
231
|
+
* @param {string} text
|
|
232
|
+
* @returns {Adapted} `value` is `{ amount, unit }`, the unit a core
|
|
233
|
+
* `DATE_UNITS` member
|
|
234
|
+
*/
|
|
235
|
+
export function parseTickInterval(text) {
|
|
236
|
+
const match = TICK_INTERVAL.exec(text.trim());
|
|
237
|
+
if (match === null) {
|
|
238
|
+
return bad(`tickInterval '${text}' is not a whole count followed by`
|
|
239
|
+
+ ' millisecond, second, minute, hour, day, week or month');
|
|
240
|
+
}
|
|
241
|
+
return ok({ amount: Number(match[1]), unit: match[2] });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The task duration grammar, likewise straight from the shipped chunk.
|
|
245
|
+
const DURATION = /^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/;
|
|
246
|
+
|
|
247
|
+
/** Mermaid's one-letter duration units → core calendar units. */
|
|
248
|
+
const DURATION_UNITS = Object.freeze({
|
|
249
|
+
ms: 'millisecond', s: 'second', m: 'minute', h: 'hour',
|
|
250
|
+
d: 'day', w: 'week', M: 'month', y: 'year',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse a task duration (`3d`, `1.5w`, `2M`).
|
|
255
|
+
* @param {string} text
|
|
256
|
+
* @returns {Adapted} `value` is `{ amount, unit }`, or `null` with no
|
|
257
|
+
* error when the text is simply not a duration (the caller then tries
|
|
258
|
+
* to read it as a date)
|
|
259
|
+
*/
|
|
260
|
+
export function parseTaskDuration(text) {
|
|
261
|
+
const match = DURATION.exec(text.trim());
|
|
262
|
+
if (match === null)
|
|
263
|
+
return { value: null, error: null };
|
|
264
|
+
return ok({ amount: Number(match[1]), unit: DURATION_UNITS[match[2]] });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** ISO weekday numbers, 1 is Monday — the numbering `isoWeekdayFromDays` uses. */
|
|
268
|
+
export const ISO_WEEKDAYS = Object.freeze({
|
|
269
|
+
monday: 1, tuesday: 2, wednesday: 3, thursday: 4,
|
|
270
|
+
friday: 5, saturday: 6, sunday: 7,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse a `weekday` directive: which day a whole-week tick starts on.
|
|
275
|
+
* @param {string} text
|
|
276
|
+
* @returns {Adapted} `value` is an ISO weekday, 1 to 7
|
|
277
|
+
*/
|
|
278
|
+
export function parseWeekday(text) {
|
|
279
|
+
const day = ISO_WEEKDAYS[text.trim().toLowerCase()];
|
|
280
|
+
if (day === undefined)
|
|
281
|
+
return bad(`weekday '${text}' is not a day name (monday … sunday)`);
|
|
282
|
+
return ok(day);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse a `weekend` directive: the first of the two days
|
|
287
|
+
* `excludes weekends` removes.
|
|
288
|
+
* @param {string} text
|
|
289
|
+
* @returns {Adapted} `value` is an ISO weekday, 5 (friday) or 6 (saturday)
|
|
290
|
+
*/
|
|
291
|
+
export function parseWeekend(text) {
|
|
292
|
+
const name = text.trim().toLowerCase();
|
|
293
|
+
if (name !== 'friday' && name !== 'saturday')
|
|
294
|
+
return bad(`weekend '${text}' is neither friday nor saturday`);
|
|
295
|
+
return ok(ISO_WEEKDAYS[name]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { MOMENT_TO_LDML, MOMENT_REFUSED, STRFTIME_TO_LDML, STRFTIME_REFUSED };
|