dflow-sdd-ddd 0.13.0 → 0.15.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.
- package/CHANGELOG.md +824 -1
- package/CONTRIBUTING.md +16 -10
- package/README.en.md +156 -200
- package/README.md +89 -144
- package/TEMPLATE-COVERAGE.md +15 -8
- package/TEMPLATE-LANGUAGE-GLOSSARY.md +15 -1
- package/bin/dflow.js +36 -4
- package/docs/commands.en.md +110 -0
- package/docs/commands.md +101 -0
- package/docs/doctor-uncertainty.en.md +212 -0
- package/docs/doctor-uncertainty.md +212 -0
- package/docs/evaluating-dflow.en.md +29 -11
- package/docs/evaluating-dflow.md +8 -6
- package/docs/npm-publish-checklist.md +3 -1
- package/docs/release-versioning-policy.md +8 -2
- package/docs/upgrading.en.md +196 -0
- package/docs/upgrading.md +197 -0
- package/docs/using-with-claude-code.en.md +25 -10
- package/docs/using-with-claude-code.md +20 -7
- package/docs/using-with-codex.en.md +18 -6
- package/docs/using-with-codex.md +16 -5
- package/docs/using-with-github-copilot.en.md +25 -10
- package/docs/using-with-github-copilot.md +21 -8
- package/lib/doc-shapes.json +997 -0
- package/lib/doctor-checks.js +2654 -0
- package/lib/init.js +3583 -107
- package/lib/render-diagrams.js +1474 -0
- package/lib/render.js +865 -49
- package/package.json +2 -2
- package/templates/brownfield/references/drift-verification.md +4 -0
- package/templates/brownfield/references/finish-feature-flow.md +635 -88
- package/templates/brownfield/references/finish-feature-follow-up.md +60 -0
- package/templates/brownfield/references/finish-feature-minimal-host.md +406 -0
- package/templates/brownfield/references/finish-feature-post-hoc-hotfix.md +95 -0
- package/templates/brownfield/references/git-integration.md +160 -15
- package/templates/brownfield/references/init-project-flow.md +26 -4
- package/templates/brownfield/references/modify-existing-flow.md +412 -87
- package/templates/brownfield/references/modify-existing-follow-up.md +121 -0
- package/templates/brownfield/references/modify-existing-post-hoc-hotfix.md +82 -0
- package/templates/brownfield/references/new-feature-flow.md +61 -6
- package/templates/brownfield/references/new-phase-flow.md +57 -7
- package/templates/brownfield/references/pr-review-checklist.md +303 -10
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +158 -34
- package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +1 -1
- package/templates/brownfield/scaffolding/Git-principles-gitflow.md +75 -6
- package/templates/brownfield/scaffolding/Git-principles-trunk.md +82 -8
- package/templates/brownfield/scaffolding/_conventions.md +50 -28
- package/templates/brownfield/scaffolding/_overview.md +1 -0
- package/templates/brownfield/templates/_index.md +151 -7
- package/templates/brownfield/templates/analysis.md +79 -0
- package/templates/brownfield/templates/behavior.md +1 -0
- package/templates/brownfield/templates/context-definition.md +1 -0
- package/templates/brownfield/templates/context-map.md +2 -1
- package/templates/brownfield/templates/glossary.md +1 -0
- package/templates/brownfield/templates/lightweight-spec.md +154 -11
- package/templates/brownfield/templates/models.md +1 -0
- package/templates/brownfield/templates/phase-spec.md +9 -1
- package/templates/brownfield/templates/rules.md +1 -0
- package/templates/brownfield/templates/tech-debt.md +1 -0
- package/templates/common/references/ddd-modeling-guide.md +33 -16
- package/templates/{greenfield → common}/references/dflow-feedback-flow.md +2 -1
- package/templates/common/references/flow-rationale-registry.md +130 -0
- package/templates/common/skill/SKILL.md +13 -11
- package/templates/greenfield/references/drift-verification.md +4 -0
- package/templates/greenfield/references/finish-feature-flow.md +625 -89
- package/templates/greenfield/references/finish-feature-follow-up.md +60 -0
- package/templates/greenfield/references/finish-feature-minimal-host.md +363 -0
- package/templates/greenfield/references/finish-feature-post-hoc-hotfix.md +95 -0
- package/templates/greenfield/references/git-integration.md +148 -15
- package/templates/greenfield/references/init-project-flow.md +28 -8
- package/templates/greenfield/references/modify-existing-flow.md +378 -85
- package/templates/greenfield/references/modify-existing-follow-up.md +103 -0
- package/templates/greenfield/references/modify-existing-post-hoc-hotfix.md +82 -0
- package/templates/greenfield/references/new-feature-flow.md +67 -4
- package/templates/greenfield/references/new-phase-flow.md +56 -7
- package/templates/greenfield/references/pr-review-checklist.md +287 -8
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +153 -32
- package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +5 -2
- package/templates/greenfield/scaffolding/Git-principles-gitflow.md +74 -6
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +88 -12
- package/templates/greenfield/scaffolding/_conventions.md +50 -28
- package/templates/greenfield/scaffolding/_overview.md +6 -2
- package/templates/greenfield/templates/_index.md +137 -7
- package/templates/greenfield/templates/aggregate-design.md +1 -0
- package/templates/greenfield/templates/analysis.md +79 -0
- package/templates/greenfield/templates/behavior.md +1 -0
- package/templates/greenfield/templates/context-definition.md +1 -0
- package/templates/greenfield/templates/context-map.md +2 -1
- package/templates/greenfield/templates/events.md +4 -1
- package/templates/greenfield/templates/glossary.md +1 -0
- package/templates/greenfield/templates/lightweight-spec.md +154 -11
- package/templates/greenfield/templates/models.md +1 -0
- package/templates/greenfield/templates/phase-spec.md +9 -1
- package/templates/greenfield/templates/rules.md +1 -0
- package/templates/greenfield/templates/tech-debt.md +1 -0
- package/templates/brownfield/references/dflow-feedback-flow.md +0 -251
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
// dflow render — lifecycle and flow diagrams for analysis.md (PROPOSAL-100).
|
|
2
|
+
//
|
|
3
|
+
// The tables stay the record. This module only adds a picture above the
|
|
4
|
+
// cards of an `### LC-nn` / `### FL-nn` subsection of a file named
|
|
5
|
+
// analysis.md, or a one-line note saying why it drew none. Recognition reads
|
|
6
|
+
// marked's top-level token list — the same parse the cards come from —
|
|
7
|
+
// handed over by the processAllTokens hook renderFile() attaches; the picture
|
|
8
|
+
// is inline SVG built here, so pages keep no JavaScript and the package no
|
|
9
|
+
// new dependency. Anything the tables do not state for certain is not drawn
|
|
10
|
+
// (never half a picture), and no failure in here can fail the render:
|
|
11
|
+
// whatever throws while one subsection's tables are read, checked or drawn
|
|
12
|
+
// becomes that subsection's note and leaves the rest of the page untouched;
|
|
13
|
+
// should finding the subsections itself throw, the page renders as it would
|
|
14
|
+
// without this module.
|
|
15
|
+
//
|
|
16
|
+
// Layout (the p100lay-x1 layout specification): a lifecycle is one column of
|
|
17
|
+
// state boxes in the author's order — the first transition to the next state
|
|
18
|
+
// runs straight down the middle, other forward transitions on the right,
|
|
19
|
+
// returns on the left, in lanes beside the boxes (transitions share a lane
|
|
20
|
+
// only when the states they span do not overlap) — with the transition
|
|
21
|
+
// texts in a separate numbered column on the right. A flow is a sequence
|
|
22
|
+
// diagram: participant columns in first-appearance order, one band per step.
|
|
23
|
+
// There are no font metrics in Node, so text width is estimated per
|
|
24
|
+
// character class and deliberately wide; the cards below always carry the
|
|
25
|
+
// full text. Layout is computed completely (and audited) before any SVG is
|
|
26
|
+
// emitted.
|
|
27
|
+
|
|
28
|
+
const ENTRY_HEADING = /^(LC|FL)-(\d+)(?![\p{L}\p{N}_-])/u;
|
|
29
|
+
|
|
30
|
+
// The template's placeholder rows, exactly as the shipped analysis.md
|
|
31
|
+
// template writes them (both tracks ship the same file; test/render.mjs
|
|
32
|
+
// asserts each row below still equals the template's). A row is a
|
|
33
|
+
// placeholder only when every listed column holds exactly this raw cell
|
|
34
|
+
// text — never by "looks like a {…} placeholder" guessing. When the
|
|
35
|
+
// template's placeholder wording changes, keep the old row here as a second
|
|
36
|
+
// entry: copies adopters already wrote carry the wording of the template
|
|
37
|
+
// they were created from.
|
|
38
|
+
const TEMPLATE_PLACEHOLDER_ROWS = {
|
|
39
|
+
flow: [{
|
|
40
|
+
'#': '1',
|
|
41
|
+
from: '{Context A}',
|
|
42
|
+
to: '{Context B}',
|
|
43
|
+
'handed over': '{交出去的是什麼:欄位、識別鍵或事件}',
|
|
44
|
+
'state change': '{造成什麼狀態變化}',
|
|
45
|
+
evidence: '{code|data|confirmed by {role}|document|inferred|assumed} - {複查入口} ({date})'
|
|
46
|
+
}],
|
|
47
|
+
states: [{
|
|
48
|
+
state: '`{狀態值}`',
|
|
49
|
+
means: '{這個狀態允許或擋住接下來的什麼}'
|
|
50
|
+
}],
|
|
51
|
+
transitions: [{
|
|
52
|
+
from: '`{原狀態}`',
|
|
53
|
+
trigger: '{誰做了什麼}',
|
|
54
|
+
to: '`{新狀態}`',
|
|
55
|
+
guard: '{決定它的 BR-ID;沒有規則決定就寫必須成立的條件,都沒有就留空}',
|
|
56
|
+
evidence: '{code|data|confirmed by {role}|document|inferred|assumed} - {複查入口} ({date})'
|
|
57
|
+
}]
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Limits. Over any of them a subsection gets a note instead of a picture.
|
|
61
|
+
const DIAGRAM_LIMITS = Object.freeze({
|
|
62
|
+
LC_MAX_STATES: 12,
|
|
63
|
+
LC_MAX_TRANSITIONS: 20,
|
|
64
|
+
LC_MAX_SIDE_LANES: 4,
|
|
65
|
+
LC_MAX_SIDE_PORTS: 6,
|
|
66
|
+
FL_MAX_PARTICIPANTS: 8,
|
|
67
|
+
FL_PRINT_MAX_PARTICIPANTS: 4,
|
|
68
|
+
FL_MAX_STEPS: 10,
|
|
69
|
+
DIAGRAM_MAX_WIDTH: 864,
|
|
70
|
+
DIAGRAM_MAX_HEIGHT: 960,
|
|
71
|
+
IDENTITY_MAX_CODEPOINTS: 64,
|
|
72
|
+
FIELD_MAX_CODEPOINTS: 256,
|
|
73
|
+
FIELD_MAX_ITEMS: 8,
|
|
74
|
+
IDENTITY_MAX_LINES: 2,
|
|
75
|
+
PRIMARY_MAX_LINES: 3,
|
|
76
|
+
SECONDARY_MAX_LINES: 3,
|
|
77
|
+
MAX_TRUNCATED_FIELDS: 2,
|
|
78
|
+
LC_MAX_CROSSINGS: 4,
|
|
79
|
+
LC_MAX_CROSSINGS_PER_EDGE: 2,
|
|
80
|
+
CROSSING_ENDPOINT_CLEARANCE: 12,
|
|
81
|
+
CROSSING_SEPARATION: 16,
|
|
82
|
+
CROSSING_HALF_GAP: 5
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Layout measurements, in SVG user units (CSS pixels at natural size).
|
|
86
|
+
const GEOMETRY = Object.freeze({
|
|
87
|
+
margin: 24,
|
|
88
|
+
stateMinWidth: 160,
|
|
89
|
+
stateTextPadding: 12,
|
|
90
|
+
stateWrapWidth: 184,
|
|
91
|
+
stateRadius: 6,
|
|
92
|
+
stateMinHeight: 48,
|
|
93
|
+
portPitch: 24,
|
|
94
|
+
bandGap: 40,
|
|
95
|
+
laneOffset: 48,
|
|
96
|
+
lanePitch: 28,
|
|
97
|
+
labelGap: 32,
|
|
98
|
+
numberColumn: 28,
|
|
99
|
+
descriptionWidth: 352,
|
|
100
|
+
descriptionGap: 12,
|
|
101
|
+
guardKeyWidth: 64,
|
|
102
|
+
headerWidth: 144,
|
|
103
|
+
headerHeight: 48,
|
|
104
|
+
headerWrapWidth: 120,
|
|
105
|
+
firstParticipantX: 96,
|
|
106
|
+
participantPitch: 184,
|
|
107
|
+
lifelineTop: 72,
|
|
108
|
+
firstBandTop: 88,
|
|
109
|
+
labelClearance: 12,
|
|
110
|
+
selfLoopWidth: 48,
|
|
111
|
+
selfLoopHeight: 24,
|
|
112
|
+
stepSpacing: 20,
|
|
113
|
+
selfLabelWidth: 160,
|
|
114
|
+
maxLabelWidth: 352,
|
|
115
|
+
identitySize: 14,
|
|
116
|
+
identityLine: 20,
|
|
117
|
+
primarySize: 14,
|
|
118
|
+
primaryLine: 20,
|
|
119
|
+
secondarySize: 13,
|
|
120
|
+
secondaryLine: 18,
|
|
121
|
+
smallSize: 12,
|
|
122
|
+
tagWidth: 88,
|
|
123
|
+
tagHeight: 16,
|
|
124
|
+
tagRow: 20,
|
|
125
|
+
tagInset: 6,
|
|
126
|
+
headSize: 8
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Evidence types (the first word of an Evidence cell) whose arrow is dashed.
|
|
130
|
+
const DASHED_EVIDENCE = new Set(['inferred', 'assumed']);
|
|
131
|
+
|
|
132
|
+
const TABLE_NAME = { states: '狀態表', transitions: '轉移表', flow: '流程表' };
|
|
133
|
+
|
|
134
|
+
// ------------------------------------------------------------ text helpers
|
|
135
|
+
// Self-contained on purpose: lib/render.js requires this module, so this
|
|
136
|
+
// module must not require it back.
|
|
137
|
+
|
|
138
|
+
function escapeXml(text) {
|
|
139
|
+
return String(text)
|
|
140
|
+
.replace(/&/g, '&')
|
|
141
|
+
.replace(/</g, '<')
|
|
142
|
+
.replace(/>/g, '>')
|
|
143
|
+
.replace(/"/g, '"')
|
|
144
|
+
.replace(/'/g, ''');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A numeric reference decodes only when it names a character (U+0001 to
|
|
148
|
+
// U+10FFFF, not a surrogate); any other stays as written — decoding it would
|
|
149
|
+
// throw or invent a character.
|
|
150
|
+
function numericReference(codePoint, written) {
|
|
151
|
+
const isCharacter = codePoint >= 1 && codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff);
|
|
152
|
+
return isCharacter ? String.fromCodePoint(codePoint) : written;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Numeric references and the handful of named ones marked leaves in text;
|
|
156
|
+
// any other named reference stays literal (and is escaped on output).
|
|
157
|
+
function decodeEntities(text) {
|
|
158
|
+
return String(text)
|
|
159
|
+
.replace(/&#(\d+);/g, (m, dec) => numericReference(Number(dec), m))
|
|
160
|
+
.replace(/&#[xX]([0-9a-fA-F]+);/g, (m, hex) => numericReference(parseInt(hex, 16), m))
|
|
161
|
+
.replace(/</g, '<')
|
|
162
|
+
.replace(/>/g, '>')
|
|
163
|
+
.replace(/"/g, '"')
|
|
164
|
+
.replace(/'/g, "'")
|
|
165
|
+
.replace(/ /g, ' ')
|
|
166
|
+
.replace(/&/g, '&');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const BR_TAG = /^<br\s*\/?>$/i;
|
|
170
|
+
|
|
171
|
+
// An inline token list as plain-text items, split at <br> — the template's
|
|
172
|
+
// in-cell item separator. Code span text counts as text; raw inline HTML
|
|
173
|
+
// other than <br> contributes nothing; entities in ordinary text are decoded
|
|
174
|
+
// (code span text is literal).
|
|
175
|
+
function inlineItems(tokens) {
|
|
176
|
+
const items = [''];
|
|
177
|
+
const walk = (list) => {
|
|
178
|
+
for (const token of list || []) {
|
|
179
|
+
if (token.type === 'html') {
|
|
180
|
+
if (BR_TAG.test(String(token.text).trim())) {
|
|
181
|
+
items.push('');
|
|
182
|
+
}
|
|
183
|
+
} else if (token.type === 'br') {
|
|
184
|
+
items.push('');
|
|
185
|
+
} else if (token.type === 'codespan') {
|
|
186
|
+
items[items.length - 1] += token.text;
|
|
187
|
+
} else if (token.tokens && token.tokens.length) {
|
|
188
|
+
walk(token.tokens);
|
|
189
|
+
} else {
|
|
190
|
+
items[items.length - 1] += decodeEntities(token.text || '');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
walk(tokens);
|
|
195
|
+
return items.map((item) => item.replace(/\s+/g, ' ').trim()).filter((item) => item !== '');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isBracePlaceholder(text) {
|
|
199
|
+
return /^\{[\s\S]*\}$/.test(text);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Bidi controls and right-to-left scripts: this layout does not arrange RTL text.
|
|
203
|
+
const RTL_RANGES = [[0x61c, 0x61c], [0x200e, 0x200f], [0x202a, 0x202e], [0x2066, 0x2069], [0x590, 0x8ff], [0xfb1d, 0xfdff], [0xfe70, 0xfeff]];
|
|
204
|
+
|
|
205
|
+
function hasRtl(text) {
|
|
206
|
+
return [...text].some((ch) => RTL_RANGES.some(([lo, hi]) => ch.codePointAt(0) >= lo && ch.codePointAt(0) <= hi));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function codepoints(text) {
|
|
210
|
+
return [...text].length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function headerName(cell) {
|
|
214
|
+
return inlineItems(cell.tokens).join(' ').toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ------------------------------------------------------------ recognition
|
|
218
|
+
|
|
219
|
+
function readTable(token, tokenIndex) {
|
|
220
|
+
const headers = token.header.map(headerName);
|
|
221
|
+
const rows = token.rows.map((row) => row.map((cell) => ({
|
|
222
|
+
raw: String(cell.text).trim(),
|
|
223
|
+
items: inlineItems(cell.tokens)
|
|
224
|
+
})));
|
|
225
|
+
return { token, tokenIndex, headers, rows };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hasColumns(table, names) {
|
|
229
|
+
return names.every((name) => table.headers.includes(name));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cellAt(table, rowIndex, name) {
|
|
233
|
+
const i = table.headers.indexOf(name);
|
|
234
|
+
return i === -1 ? null : table.rows[rowIndex][i] || { raw: '', items: [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isTemplateRow(table, rowIndex, role) {
|
|
238
|
+
return TEMPLATE_PLACEHOLDER_ROWS[role].some((tpl) =>
|
|
239
|
+
Object.entries(tpl).every(([name, text]) => {
|
|
240
|
+
const cell = cellAt(table, rowIndex, name);
|
|
241
|
+
return cell !== null && cell.raw === text;
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Top-level `### LC-nn` / `### FL-nn` subsections: the heading, and the
|
|
247
|
+
// index of every top-level table up to the next heading of depth 3 or less.
|
|
248
|
+
// Tables nested in lists or quotes are not part of the record's shape and
|
|
249
|
+
// are ignored. Only headings are read here; a subsection's tables are read
|
|
250
|
+
// with the rest of that subsection's work (readSubsection).
|
|
251
|
+
function locateEntrySubsections(tokens) {
|
|
252
|
+
const located = [];
|
|
253
|
+
let current = null;
|
|
254
|
+
tokens.forEach((token, index) => {
|
|
255
|
+
if (token.type === 'heading') {
|
|
256
|
+
if (token.depth <= 3) {
|
|
257
|
+
current = null;
|
|
258
|
+
}
|
|
259
|
+
if (token.depth === 3) {
|
|
260
|
+
const match = ENTRY_HEADING.exec(inlineItems(token.tokens).join(' '));
|
|
261
|
+
if (match) {
|
|
262
|
+
current = { kind: match[1], entryId: `${match[1]}-${match[2]}`, tableIndexes: [] };
|
|
263
|
+
located.push(current);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} else if (token.type === 'table' && current) {
|
|
267
|
+
current.tableIndexes.push(index);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
return located;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readSubsection(tokens, { kind, entryId, tableIndexes }) {
|
|
274
|
+
return { kind, entryId, tables: tableIndexes.map((index) => readTable(tokens[index], index)) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function findEntrySubsections(tokens) {
|
|
278
|
+
return locateEntrySubsections(tokens).map((located) => readSubsection(tokens, located));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ------------------------------------------------------------ table checks
|
|
282
|
+
// Each check returns null when the tables pass, or the reason for the note.
|
|
283
|
+
|
|
284
|
+
function rowIssue(role, rowIndex, message) {
|
|
285
|
+
return `${TABLE_NAME[role]}第 ${rowIndex + 1} 列${message}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A key cell (a state, an endpoint, a participant) must hold exactly one
|
|
289
|
+
// value that is not a {…} placeholder. The whole cell is the value.
|
|
290
|
+
function keyValue(table, role, rowIndex, name, label) {
|
|
291
|
+
const cell = cellAt(table, rowIndex, name);
|
|
292
|
+
const items = cell ? cell.items : [];
|
|
293
|
+
if (items.length === 0) {
|
|
294
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 是空的`) };
|
|
295
|
+
}
|
|
296
|
+
if (items.length > 1) {
|
|
297
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 有 ${items.length} 個值,圖上一格只能畫一個`) };
|
|
298
|
+
}
|
|
299
|
+
const value = items[0];
|
|
300
|
+
if (isBracePlaceholder(value)) {
|
|
301
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 還是佔位文字(${value})`) };
|
|
302
|
+
}
|
|
303
|
+
if (codepoints(value) > DIAGRAM_LIMITS.IDENTITY_MAX_CODEPOINTS) {
|
|
304
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 有 ${codepoints(value)} 個字元,上限 ${DIAGRAM_LIMITS.IDENTITY_MAX_CODEPOINTS}`) };
|
|
305
|
+
}
|
|
306
|
+
if (hasRtl(value)) {
|
|
307
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 含有由右到左的文字,這個圖還不會排`) };
|
|
308
|
+
}
|
|
309
|
+
return { value };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function textItems(table, rowIndex, name) {
|
|
313
|
+
const cell = cellAt(table, rowIndex, name);
|
|
314
|
+
return cell ? cell.items : [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A descriptive cell (Trigger, Guard, Handed over, State change): bounded in
|
|
318
|
+
// items and length; `required` ones must not be empty.
|
|
319
|
+
function fieldItems(table, role, rowIndex, name, label, required) {
|
|
320
|
+
const items = textItems(table, rowIndex, name);
|
|
321
|
+
if (required && items.length === 0) {
|
|
322
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 是空的`) };
|
|
323
|
+
}
|
|
324
|
+
if (items.length > DIAGRAM_LIMITS.FIELD_MAX_ITEMS) {
|
|
325
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 有 ${items.length} 個值,上限 ${DIAGRAM_LIMITS.FIELD_MAX_ITEMS}`) };
|
|
326
|
+
}
|
|
327
|
+
const length = items.reduce((sum, item) => sum + codepoints(item), 0);
|
|
328
|
+
if (length > DIAGRAM_LIMITS.FIELD_MAX_CODEPOINTS) {
|
|
329
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 有 ${length} 個字元,上限 ${DIAGRAM_LIMITS.FIELD_MAX_CODEPOINTS}`) };
|
|
330
|
+
}
|
|
331
|
+
if (items.some((item) => hasRtl(item))) {
|
|
332
|
+
return { issue: rowIssue(role, rowIndex, `的 ${label} 含有由右到左的文字,這個圖還不會排`) };
|
|
333
|
+
}
|
|
334
|
+
return { items };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The Evidence type is the cell's leading word (`inferred - …`,
|
|
338
|
+
// `confirmed by …`); a cell that does not start with a Latin word has none.
|
|
339
|
+
function evidenceType(table, rowIndex) {
|
|
340
|
+
const items = textItems(table, rowIndex, 'evidence');
|
|
341
|
+
const word = items.length ? /^[a-z]+/i.exec(items[0]) : null;
|
|
342
|
+
return word ? word[0].toLowerCase() : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Placeholder rows, checked on a complete set of tables (a missing or doubled
|
|
346
|
+
// table is reported first, even beside an untouched template table): when
|
|
347
|
+
// every table holds only template rows the subsection is not content yet;
|
|
348
|
+
// a template row left beside real rows makes the table's shape uncertain.
|
|
349
|
+
function placeholderIssue(tables) {
|
|
350
|
+
const templateOnly = tables.map(({ table, role }) =>
|
|
351
|
+
table.rows.length > 0 && table.rows.every((row, r) => isTemplateRow(table, r, role))
|
|
352
|
+
);
|
|
353
|
+
if (templateOnly.every(Boolean)) {
|
|
354
|
+
return { skip: true };
|
|
355
|
+
}
|
|
356
|
+
for (let t = 0; t < tables.length; t++) {
|
|
357
|
+
const { table, role } = tables[t];
|
|
358
|
+
if (templateOnly[t]) {
|
|
359
|
+
return { issue: `${TABLE_NAME[role]}還只有範本的佔位列` };
|
|
360
|
+
}
|
|
361
|
+
const r = table.rows.findIndex((row, i) => isTemplateRow(table, i, role));
|
|
362
|
+
if (r !== -1) {
|
|
363
|
+
return { issue: rowIssue(role, r, '還是範本的佔位列') };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function checkLimit(label, count, limit) {
|
|
370
|
+
return count > limit ? `${label} ${count} 個,超過上限 ${limit}` : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ------------------------------------------------------------ models
|
|
374
|
+
|
|
375
|
+
function lifecycleModel(sub) {
|
|
376
|
+
const transitionTables = sub.tables.filter((t) => hasColumns(t, ['from', 'trigger', 'to']));
|
|
377
|
+
const stateTables = sub.tables.filter((t) => !transitionTables.includes(t) && hasColumns(t, ['state']));
|
|
378
|
+
if (stateTables.length === 0 && transitionTables.length === 0) {
|
|
379
|
+
return { issue: '找不到狀態表(要有 State 欄)與轉移表(要有 From、Trigger、To 欄)' };
|
|
380
|
+
}
|
|
381
|
+
if (stateTables.length === 0) {
|
|
382
|
+
return { issue: '找不到狀態表(要有 State 欄)' };
|
|
383
|
+
}
|
|
384
|
+
if (transitionTables.length === 0) {
|
|
385
|
+
return { issue: '找不到轉移表(要有 From、Trigger、To 欄)' };
|
|
386
|
+
}
|
|
387
|
+
if (stateTables.length > 1) {
|
|
388
|
+
return { issue: `有 ${stateTables.length} 張狀態表,只能有一張` };
|
|
389
|
+
}
|
|
390
|
+
if (transitionTables.length > 1) {
|
|
391
|
+
return { issue: `有 ${transitionTables.length} 張轉移表,只能有一張` };
|
|
392
|
+
}
|
|
393
|
+
const stateTable = stateTables[0];
|
|
394
|
+
const transitionTable = transitionTables[0];
|
|
395
|
+
const placeholder = placeholderIssue([
|
|
396
|
+
{ table: stateTable, role: 'states' },
|
|
397
|
+
{ table: transitionTable, role: 'transitions' }
|
|
398
|
+
]);
|
|
399
|
+
if (placeholder) {
|
|
400
|
+
return placeholder;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const states = [];
|
|
404
|
+
const firstRow = new Map();
|
|
405
|
+
for (let r = 0; r < stateTable.rows.length; r++) {
|
|
406
|
+
const key = keyValue(stateTable, 'states', r, 'state', 'State');
|
|
407
|
+
if (key.issue) {
|
|
408
|
+
return key;
|
|
409
|
+
}
|
|
410
|
+
if (firstRow.has(key.value)) {
|
|
411
|
+
return { issue: rowIssue('states', r, `的狀態 ${key.value} 重複(第 ${firstRow.get(key.value) + 1} 列已有)`) };
|
|
412
|
+
}
|
|
413
|
+
firstRow.set(key.value, r);
|
|
414
|
+
states.push(key.value);
|
|
415
|
+
}
|
|
416
|
+
if (states.length === 0) {
|
|
417
|
+
return { issue: '狀態表沒有任何一列' };
|
|
418
|
+
}
|
|
419
|
+
if (transitionTable.rows.length === 0) {
|
|
420
|
+
return { issue: '轉移表沒有任何一列' };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const transitions = [];
|
|
424
|
+
for (let r = 0; r < transitionTable.rows.length; r++) {
|
|
425
|
+
const ends = {};
|
|
426
|
+
for (const [name, label] of [['from', 'From'], ['to', 'To']]) {
|
|
427
|
+
const key = keyValue(transitionTable, 'transitions', r, name, label);
|
|
428
|
+
if (key.issue) {
|
|
429
|
+
return key;
|
|
430
|
+
}
|
|
431
|
+
if (!firstRow.has(key.value)) {
|
|
432
|
+
return { issue: rowIssue('transitions', r, `的 ${label}(${key.value})不在狀態表裡`) };
|
|
433
|
+
}
|
|
434
|
+
ends[name] = key.value;
|
|
435
|
+
}
|
|
436
|
+
const trigger = fieldItems(transitionTable, 'transitions', r, 'trigger', 'Trigger', true);
|
|
437
|
+
if (trigger.issue) {
|
|
438
|
+
return trigger;
|
|
439
|
+
}
|
|
440
|
+
const guard = fieldItems(transitionTable, 'transitions', r, 'guard', 'Guard', false);
|
|
441
|
+
if (guard.issue) {
|
|
442
|
+
return guard;
|
|
443
|
+
}
|
|
444
|
+
transitions.push({
|
|
445
|
+
n: r + 1,
|
|
446
|
+
from: ends.from,
|
|
447
|
+
to: ends.to,
|
|
448
|
+
trigger: trigger.items,
|
|
449
|
+
guard: guard.items,
|
|
450
|
+
evidenceType: evidenceType(transitionTable, r)
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const limit =
|
|
455
|
+
checkLimit('狀態', states.length, DIAGRAM_LIMITS.LC_MAX_STATES) ||
|
|
456
|
+
checkLimit('轉移', transitions.length, DIAGRAM_LIMITS.LC_MAX_TRANSITIONS);
|
|
457
|
+
if (limit) {
|
|
458
|
+
return { issue: limit };
|
|
459
|
+
}
|
|
460
|
+
const firstTable = Math.min(stateTable.tokenIndex, transitionTable.tokenIndex);
|
|
461
|
+
return { model: { kind: 'LC', entryId: sub.entryId, states, transitions }, insertAt: firstTable };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function flowModel(sub) {
|
|
465
|
+
const flowTables = sub.tables.filter((t) => hasColumns(t, ['from', 'to', 'handed over']));
|
|
466
|
+
if (flowTables.length === 0) {
|
|
467
|
+
return { issue: '找不到流程表(要有 From、To、Handed over 欄)' };
|
|
468
|
+
}
|
|
469
|
+
if (flowTables.length > 1) {
|
|
470
|
+
return { issue: `有 ${flowTables.length} 張流程表,只能有一張` };
|
|
471
|
+
}
|
|
472
|
+
const table = flowTables[0];
|
|
473
|
+
const placeholder = placeholderIssue([{ table, role: 'flow' }]);
|
|
474
|
+
if (placeholder) {
|
|
475
|
+
return placeholder;
|
|
476
|
+
}
|
|
477
|
+
if (table.rows.length === 0) {
|
|
478
|
+
return { issue: '流程表沒有任何一列' };
|
|
479
|
+
}
|
|
480
|
+
const hasNumber = table.headers.includes('#');
|
|
481
|
+
const participants = [];
|
|
482
|
+
const steps = [];
|
|
483
|
+
for (let r = 0; r < table.rows.length; r++) {
|
|
484
|
+
if (hasNumber) {
|
|
485
|
+
const number = textItems(table, r, '#').join(' ');
|
|
486
|
+
if (!/^\d+$/.test(number) || Number(number) !== r + 1) {
|
|
487
|
+
const written = number ? `「${number}」` : '空的';
|
|
488
|
+
return { issue: rowIssue('flow', r, `的 # 是${written},照列序應該是 ${r + 1}`) };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const ends = {};
|
|
492
|
+
for (const [name, label] of [['from', 'From'], ['to', 'To']]) {
|
|
493
|
+
const key = keyValue(table, 'flow', r, name, label);
|
|
494
|
+
if (key.issue) {
|
|
495
|
+
return key;
|
|
496
|
+
}
|
|
497
|
+
if (!participants.includes(key.value)) {
|
|
498
|
+
participants.push(key.value);
|
|
499
|
+
}
|
|
500
|
+
ends[name] = key.value;
|
|
501
|
+
}
|
|
502
|
+
const handedOver = fieldItems(table, 'flow', r, 'handed over', 'Handed over', true);
|
|
503
|
+
if (handedOver.issue) {
|
|
504
|
+
return handedOver;
|
|
505
|
+
}
|
|
506
|
+
const stateChange = fieldItems(table, 'flow', r, 'state change', 'State change', false);
|
|
507
|
+
if (stateChange.issue) {
|
|
508
|
+
return stateChange;
|
|
509
|
+
}
|
|
510
|
+
steps.push({
|
|
511
|
+
n: r + 1,
|
|
512
|
+
from: ends.from,
|
|
513
|
+
to: ends.to,
|
|
514
|
+
handedOver: handedOver.items,
|
|
515
|
+
stateChange: stateChange.items,
|
|
516
|
+
evidenceType: evidenceType(table, r)
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const limit =
|
|
520
|
+
checkLimit('參與者', participants.length, DIAGRAM_LIMITS.FL_MAX_PARTICIPANTS) ||
|
|
521
|
+
checkLimit('步驟', steps.length, DIAGRAM_LIMITS.FL_MAX_STEPS);
|
|
522
|
+
if (limit) {
|
|
523
|
+
return { issue: limit };
|
|
524
|
+
}
|
|
525
|
+
return { model: { kind: 'FL', entryId: sub.entryId, participants, steps }, insertAt: table.tokenIndex };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ------------------------------------------------------------ text measure
|
|
529
|
+
// Estimated advance per character class, in hundredths of the font size.
|
|
530
|
+
// Widths are deliberately wide: a wrong estimate may only wrap a label early.
|
|
531
|
+
|
|
532
|
+
function charWidth(cp) {
|
|
533
|
+
if (cp === 0x20) {
|
|
534
|
+
return 35;
|
|
535
|
+
}
|
|
536
|
+
if (cp < 0x80) {
|
|
537
|
+
if (cp === 0x4d || cp === 0x57 || cp === 0x6d || cp === 0x77) {
|
|
538
|
+
return 95; // M W m w
|
|
539
|
+
}
|
|
540
|
+
if ((cp >= 0x30 && cp <= 0x39) || (cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a)) {
|
|
541
|
+
return 70;
|
|
542
|
+
}
|
|
543
|
+
return cp === 0x40 || cp === 0x23 || cp === 0x25 || cp === 0x26 ? 95 : 50; // @ # % & | punctuation
|
|
544
|
+
}
|
|
545
|
+
return cp >= 0x1f000 && cp <= 0x1faff ? 220 : 110;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Combining marks, variation selectors, emoji modifiers and ZWJ add nothing
|
|
549
|
+
// and stay with the character before them.
|
|
550
|
+
function isExtender(cp) {
|
|
551
|
+
return (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
552
|
+
(cp >= 0x20d0 && cp <= 0x20ff) || (cp >= 0xfe20 && cp <= 0xfe2f) || (cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
553
|
+
(cp >= 0xe0100 && cp <= 0xe01ef) || (cp >= 0x1f3fb && cp <= 0x1f3ff) || cp === 0x200d;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isRegionalIndicator(cp) {
|
|
557
|
+
return cp >= 0x1f1e6 && cp <= 0x1f1ff;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Fixed clusters (not platform Unicode segmentation): a base with its
|
|
561
|
+
// extenders, a ZWJ sequence, a regional-indicator pair.
|
|
562
|
+
function clusters(text) {
|
|
563
|
+
const out = [];
|
|
564
|
+
let joinNext = false;
|
|
565
|
+
let openFlag = false;
|
|
566
|
+
for (const ch of text) {
|
|
567
|
+
const cp = ch.codePointAt(0);
|
|
568
|
+
const last = out[out.length - 1];
|
|
569
|
+
if (last && (isExtender(cp) || joinNext || (openFlag && isRegionalIndicator(cp)))) {
|
|
570
|
+
last.text += ch;
|
|
571
|
+
joinNext = cp === 0x200d;
|
|
572
|
+
openFlag = false;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
out.push({ text: ch, width: charWidth(cp) });
|
|
576
|
+
joinNext = false;
|
|
577
|
+
openFlag = isRegionalIndicator(cp);
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function textWidth(text, size) {
|
|
583
|
+
return clusters(text).reduce((sum, c) => sum + c.width, 0) * size / 100;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ------------------------------------------------------------ wrapping
|
|
587
|
+
|
|
588
|
+
const OPENERS = new Set([...'(【《〈「『〔[{']);
|
|
589
|
+
const CLOSERS = new Set([...',。、;:?!)】》〉」』〕]}/']);
|
|
590
|
+
|
|
591
|
+
// One item -> units a line may break between. An ASCII graphic run is one
|
|
592
|
+
// unit (keeps `ExpenseReport.Submit()` whole); every other cluster is its
|
|
593
|
+
// own unit (CJK breaks between characters); an opening mark joins the unit
|
|
594
|
+
// after it and a closing mark the unit before it; a space is a break
|
|
595
|
+
// opportunity that is dropped at a line boundary.
|
|
596
|
+
function lineUnits(text) {
|
|
597
|
+
const raw = [];
|
|
598
|
+
for (const c of clusters(text)) {
|
|
599
|
+
const cp = c.text.codePointAt(0);
|
|
600
|
+
const prev = raw[raw.length - 1];
|
|
601
|
+
if (cp === 0x20) {
|
|
602
|
+
raw.push({ kind: 'space' });
|
|
603
|
+
} else if (cp > 0x20 && cp < 0x7f) {
|
|
604
|
+
if (prev && prev.kind === 'ascii') {
|
|
605
|
+
prev.text += c.text;
|
|
606
|
+
} else {
|
|
607
|
+
raw.push({ kind: 'ascii', text: c.text });
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
raw.push({ kind: OPENERS.has(c.text) ? 'open' : CLOSERS.has(c.text) ? 'close' : 'char', text: c.text });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const units = [];
|
|
614
|
+
let spaceBefore = false;
|
|
615
|
+
for (const u of raw) {
|
|
616
|
+
const prev = units[units.length - 1];
|
|
617
|
+
if (u.kind === 'space') {
|
|
618
|
+
spaceBefore = true;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (u.kind === 'close' && prev && !spaceBefore) {
|
|
622
|
+
prev.text += u.text;
|
|
623
|
+
} else if (prev && prev.open && !spaceBefore) {
|
|
624
|
+
prev.text += u.text;
|
|
625
|
+
prev.open = u.kind === 'open';
|
|
626
|
+
} else {
|
|
627
|
+
units.push({ text: u.text, spaceBefore, open: u.kind === 'open' });
|
|
628
|
+
}
|
|
629
|
+
spaceBefore = false;
|
|
630
|
+
}
|
|
631
|
+
return units;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// A unit wider than the line: break after . / :: _ - first, then between clusters.
|
|
635
|
+
function splitWide(text, maxWidth, size) {
|
|
636
|
+
const pieces = [];
|
|
637
|
+
for (const part of text.split(/(?<=\.|\/|::|_|-)/)) {
|
|
638
|
+
if (textWidth(part, size) <= maxWidth) {
|
|
639
|
+
pieces.push(part);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
let line = '';
|
|
643
|
+
for (const c of clusters(part)) {
|
|
644
|
+
if (line && textWidth(line + c.text, size) > maxWidth) {
|
|
645
|
+
pieces.push(line);
|
|
646
|
+
line = '';
|
|
647
|
+
}
|
|
648
|
+
line += c.text;
|
|
649
|
+
}
|
|
650
|
+
if (line) {
|
|
651
|
+
pieces.push(line);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return pieces;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Greedy fill of one item into lines no wider than maxWidth.
|
|
658
|
+
function wrapItem(text, maxWidth, size) {
|
|
659
|
+
const space = textWidth(' ', size);
|
|
660
|
+
const lines = [];
|
|
661
|
+
let line = '';
|
|
662
|
+
let width = 0;
|
|
663
|
+
for (const unit of lineUnits(text)) {
|
|
664
|
+
const w = textWidth(unit.text, size);
|
|
665
|
+
const pieces = w > maxWidth ? splitWide(unit.text, maxWidth, size) : [unit.text];
|
|
666
|
+
pieces.forEach((piece, k) => {
|
|
667
|
+
const pw = textWidth(piece, size);
|
|
668
|
+
const gap = line && k === 0 && unit.spaceBefore ? space : 0;
|
|
669
|
+
if (line && width + gap + pw > maxWidth) {
|
|
670
|
+
lines.push(line);
|
|
671
|
+
line = '';
|
|
672
|
+
width = 0;
|
|
673
|
+
}
|
|
674
|
+
if (line) {
|
|
675
|
+
line += (gap ? ' ' : '') + piece;
|
|
676
|
+
width += gap + pw;
|
|
677
|
+
} else {
|
|
678
|
+
line = piece;
|
|
679
|
+
width = pw;
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
if (line) {
|
|
684
|
+
lines.push(line);
|
|
685
|
+
}
|
|
686
|
+
return lines;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// A descriptive field: items are hard line breaks; past maxLines the third
|
|
690
|
+
// line ends in an ellipsis and the field counts as truncated.
|
|
691
|
+
function wrapField(items, maxWidth, size, maxLines) {
|
|
692
|
+
const lines = items.flatMap((item) => wrapItem(item, maxWidth, size));
|
|
693
|
+
if (lines.length <= maxLines) {
|
|
694
|
+
return { lines, truncated: false };
|
|
695
|
+
}
|
|
696
|
+
const kept = lines.slice(0, maxLines);
|
|
697
|
+
const last = clusters(kept[maxLines - 1]);
|
|
698
|
+
const ellipsis = textWidth('…', size);
|
|
699
|
+
while (last.length && last.reduce((sum, c) => sum + c.width, 0) * size / 100 + ellipsis > maxWidth) {
|
|
700
|
+
last.pop();
|
|
701
|
+
}
|
|
702
|
+
kept[maxLines - 1] = `${last.map((c) => c.text).join('').replace(/ +$/, '')}…`;
|
|
703
|
+
return { lines: kept, truncated: true };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ------------------------------------------------------------ geometry
|
|
707
|
+
|
|
708
|
+
const roundUp8 = (value) => Math.ceil(value / 8) * 8;
|
|
709
|
+
|
|
710
|
+
// Text rectangle used by the overlap audit: font size above the baseline,
|
|
711
|
+
// about a third below.
|
|
712
|
+
function textRect(x, baseline, text, size, anchor) {
|
|
713
|
+
const w = textWidth(text, size);
|
|
714
|
+
return { x: anchor === 'middle' ? x - w / 2 : x, y: baseline - size, w, h: size + Math.round(size * 0.3) };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function rectsOverlap(a, b) {
|
|
718
|
+
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// An orthogonal segment as a thin rectangle (half the stroke on each side).
|
|
722
|
+
function segmentRect(a, b) {
|
|
723
|
+
const x = Math.min(a[0], b[0]);
|
|
724
|
+
const y = Math.min(a[1], b[1]);
|
|
725
|
+
return { x: x - 0.75, y: y - 0.75, w: Math.abs(a[0] - b[0]) + 1.5, h: Math.abs(a[1] - b[1]) + 1.5 };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The arrowhead's box: an 8 x 8 triangle whose tip is the route's end point.
|
|
729
|
+
function headRect(points) {
|
|
730
|
+
const [x1, y1] = points[points.length - 2];
|
|
731
|
+
const [x2, y2] = points[points.length - 1];
|
|
732
|
+
const dx = Math.sign(x2 - x1);
|
|
733
|
+
const dy = Math.sign(y2 - y1);
|
|
734
|
+
const s = GEOMETRY.headSize;
|
|
735
|
+
const bx = x2 - dx * s;
|
|
736
|
+
const by = y2 - dy * s;
|
|
737
|
+
const xs = [x2, bx - dy * (s / 2), bx + dy * (s / 2)];
|
|
738
|
+
const ys = [y2, by - dx * (s / 2), by + dx * (s / 2)];
|
|
739
|
+
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function segments(points) {
|
|
743
|
+
return points.slice(1).map((p, k) => [points[k], p]);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Shared audit: text never overlaps text, a box it does not belong to, a
|
|
747
|
+
// line or an arrowhead; everything sits inside the viewBox.
|
|
748
|
+
function auditLayout({ width, height, boxes, texts, routes, lifelines = [] }) {
|
|
749
|
+
const inside = (r) => r.x >= 0 && r.y >= 0 && r.x + r.w <= width && r.y + r.h <= height;
|
|
750
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
751
|
+
if (!inside(boxes[i])) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
for (let j = i + 1; j < boxes.length; j++) {
|
|
755
|
+
if (rectsOverlap(boxes[i], boxes[j])) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const lineRects = [
|
|
761
|
+
...routes.flatMap((r) => r.pieces.flatMap((piece) => segments(piece).map(([a, b]) => segmentRect(a, b)))),
|
|
762
|
+
...routes.map((r) => headRect(r.pieces[r.pieces.length - 1])),
|
|
763
|
+
...lifelines.map(([a, b]) => segmentRect(a, b))
|
|
764
|
+
];
|
|
765
|
+
if (!lineRects.every(inside)) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
for (let i = 0; i < texts.length; i++) {
|
|
769
|
+
const t = texts[i];
|
|
770
|
+
if (!inside(t.rect)) {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
for (let j = i + 1; j < texts.length; j++) {
|
|
774
|
+
if (rectsOverlap(t.rect, texts[j].rect)) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (boxes.some((box, k) => k !== t.box && rectsOverlap(t.rect, box))) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
if (lineRects.some((r) => rectsOverlap(t.rect, r))) {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ------------------------------------------------------------ lifecycle layout
|
|
789
|
+
|
|
790
|
+
function layoutLifecycle(model) {
|
|
791
|
+
const G = GEOMETRY;
|
|
792
|
+
const L = DIAGRAM_LIMITS;
|
|
793
|
+
const index = new Map(model.states.map((state, i) => [state, i]));
|
|
794
|
+
|
|
795
|
+
const stateLines = [];
|
|
796
|
+
for (const state of model.states) {
|
|
797
|
+
const lines = wrapItem(state, G.stateWrapWidth, G.identitySize);
|
|
798
|
+
if (lines.length > L.IDENTITY_MAX_LINES) {
|
|
799
|
+
return { issue: `狀態 ${state} 的名稱要排成 ${lines.length} 行,上限 ${L.IDENTITY_MAX_LINES} 行` };
|
|
800
|
+
}
|
|
801
|
+
stateLines.push(lines);
|
|
802
|
+
}
|
|
803
|
+
let truncated = 0;
|
|
804
|
+
const edges = model.transitions.map((t) => {
|
|
805
|
+
const trigger = wrapField(t.trigger, G.descriptionWidth, G.primarySize, L.PRIMARY_MAX_LINES);
|
|
806
|
+
const guard = wrapField(t.guard, G.descriptionWidth - G.guardKeyWidth, G.secondarySize, L.SECONDARY_MAX_LINES);
|
|
807
|
+
truncated += Number(trigger.truncated) + Number(guard.truncated);
|
|
808
|
+
const tag = DASHED_EVIDENCE.has(t.evidenceType) ? t.evidenceType : null;
|
|
809
|
+
return { t, from: index.get(t.from), to: index.get(t.to), trigger: trigger.lines, guard: guard.lines, tag };
|
|
810
|
+
});
|
|
811
|
+
if (truncated > L.MAX_TRUNCATED_FIELDS) {
|
|
812
|
+
return { issue: `有 ${truncated} 格文字要節略才放得下,上限 ${L.MAX_TRUNCATED_FIELDS} 格` };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Routes: the lowest-numbered transition to the next state runs down the
|
|
816
|
+
// middle; other forward ones and self-transitions on the right, returns on
|
|
817
|
+
// the left.
|
|
818
|
+
const directFrom = new Set();
|
|
819
|
+
for (const e of [...edges].sort((a, b) => a.t.n - b.t.n)) {
|
|
820
|
+
if (e.to === e.from + 1 && !directFrom.has(e.from)) {
|
|
821
|
+
directFrom.add(e.from);
|
|
822
|
+
e.side = 'direct';
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
for (const e of edges) {
|
|
826
|
+
if (!e.side) {
|
|
827
|
+
e.side = e.to < e.from ? 'left' : 'right';
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Lanes: shortest spans nearest the boxes; a shared state index overlaps.
|
|
832
|
+
const laneCount = {};
|
|
833
|
+
for (const side of ['left', 'right']) {
|
|
834
|
+
const spans = edges
|
|
835
|
+
.filter((e) => e.side === side)
|
|
836
|
+
.map((e) => ({ e, lo: Math.min(e.from, e.to), hi: Math.max(e.from, e.to) }))
|
|
837
|
+
.sort((a, b) => (a.hi - a.lo) - (b.hi - b.lo) || a.lo - b.lo || a.hi - b.hi || a.e.t.n - b.e.t.n);
|
|
838
|
+
const lanes = [];
|
|
839
|
+
for (const span of spans) {
|
|
840
|
+
let k = 0;
|
|
841
|
+
while (lanes[k] && lanes[k].some((o) => o.lo <= span.hi && span.lo <= o.hi)) {
|
|
842
|
+
k += 1;
|
|
843
|
+
}
|
|
844
|
+
(lanes[k] = lanes[k] || []).push(span);
|
|
845
|
+
span.e.lane = k;
|
|
846
|
+
}
|
|
847
|
+
laneCount[side] = lanes.length;
|
|
848
|
+
}
|
|
849
|
+
for (const [side, label] of [['right', '右'], ['left', '左']]) {
|
|
850
|
+
if (laneCount[side] > L.LC_MAX_SIDE_LANES) {
|
|
851
|
+
return { issue: `${label}側需要 ${laneCount[side]} 條分道,上限 ${L.LC_MAX_SIDE_LANES}` };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Ports on each box side, top to bottom: ends whose other state is above
|
|
856
|
+
// (inner lane first), a self-transition's source then target, ends whose
|
|
857
|
+
// other state is below (outer lane first) — so nested brackets never cross.
|
|
858
|
+
const ports = model.states.map(() => ({ left: [], right: [] }));
|
|
859
|
+
for (const e of edges) {
|
|
860
|
+
if (e.side === 'direct') {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (e.from === e.to) {
|
|
864
|
+
ports[e.from][e.side].push({ e, end: 'from', cat: 2 }, { e, end: 'to', cat: 3 });
|
|
865
|
+
} else {
|
|
866
|
+
ports[e.from][e.side].push({ e, end: 'from', cat: e.to < e.from ? 1 : 4 });
|
|
867
|
+
ports[e.to][e.side].push({ e, end: 'to', cat: e.from < e.to ? 1 : 4 });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const portOrder = (a, b) => a.cat - b.cat ||
|
|
871
|
+
(a.cat === 1 || a.cat === 3 ? a.e.lane - b.e.lane : b.e.lane - a.e.lane) ||
|
|
872
|
+
a.e.t.n - b.e.t.n;
|
|
873
|
+
for (let i = 0; i < ports.length; i++) {
|
|
874
|
+
for (const [side, label] of [['right', '右'], ['left', '左']]) {
|
|
875
|
+
ports[i][side].sort(portOrder);
|
|
876
|
+
if (ports[i][side].length > L.LC_MAX_SIDE_PORTS) {
|
|
877
|
+
return { issue: `狀態 ${model.states[i]} 的${label}側需要 ${ports[i][side].length} 個接點,上限 ${L.LC_MAX_SIDE_PORTS}` };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Sizes and positions.
|
|
883
|
+
const widest = Math.max(...stateLines.flat().map((line) => textWidth(line, G.identitySize)));
|
|
884
|
+
const nodeWidth = Math.max(G.stateMinWidth, roundUp8(widest + 2 * G.stateTextPadding));
|
|
885
|
+
const nodeHeights = model.states.map((state, i) => roundUp8(Math.max(
|
|
886
|
+
G.stateMinHeight,
|
|
887
|
+
G.identityLine * stateLines[i].length + 16,
|
|
888
|
+
G.portPitch * (Math.max(ports[i].left.length, ports[i].right.length) + 1)
|
|
889
|
+
)));
|
|
890
|
+
const nodeX = G.margin + (laneCount.left === 0 ? 0 : G.laneOffset + G.lanePitch * (laneCount.left - 1));
|
|
891
|
+
const nodeRight = nodeX + nodeWidth;
|
|
892
|
+
const nodeCenter = nodeX + nodeWidth / 2;
|
|
893
|
+
const laneX = (side, k) => (side === 'left'
|
|
894
|
+
? nodeX - G.laneOffset - G.lanePitch * k
|
|
895
|
+
: nodeRight + G.laneOffset + G.lanePitch * k);
|
|
896
|
+
const graphRight = laneCount.right === 0 ? nodeRight : laneX('right', laneCount.right - 1);
|
|
897
|
+
const numberX = graphRight + G.labelGap;
|
|
898
|
+
const textX = numberX + G.numberColumn;
|
|
899
|
+
const width = textX + G.descriptionWidth + G.margin;
|
|
900
|
+
|
|
901
|
+
const describe = (e) => G.primaryLine * e.trigger.length + G.secondaryLine * e.guard.length + (e.tag ? G.tagRow : 0);
|
|
902
|
+
const bySource = model.states.map((state, i) => edges.filter((e) => e.from === i).sort((a, b) => a.t.n - b.t.n));
|
|
903
|
+
const tops = [];
|
|
904
|
+
const bands = [];
|
|
905
|
+
let top = G.margin;
|
|
906
|
+
for (let i = 0; i < model.states.length; i++) {
|
|
907
|
+
const list = bySource[i];
|
|
908
|
+
const text = list.reduce((sum, e) => sum + describe(e), 0) + G.descriptionGap * Math.max(0, list.length - 1);
|
|
909
|
+
tops.push(top);
|
|
910
|
+
bands.push(Math.max(nodeHeights[i], text));
|
|
911
|
+
top += bands[i] + G.bandGap;
|
|
912
|
+
}
|
|
913
|
+
const height = tops[tops.length - 1] + bands[bands.length - 1] + G.margin;
|
|
914
|
+
if (width > L.DIAGRAM_MAX_WIDTH) {
|
|
915
|
+
return { issue: `圖寬 ${width},上限 ${L.DIAGRAM_MAX_WIDTH}` };
|
|
916
|
+
}
|
|
917
|
+
if (height > L.DIAGRAM_MAX_HEIGHT) {
|
|
918
|
+
return { issue: `圖高 ${height},上限 ${L.DIAGRAM_MAX_HEIGHT}` };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const boxes = model.states.map((state, i) => ({ x: nodeX, y: tops[i], w: nodeWidth, h: nodeHeights[i] }));
|
|
922
|
+
const texts = [];
|
|
923
|
+
const stateTexts = model.states.map((state, i) => {
|
|
924
|
+
const cy = tops[i] + nodeHeights[i] / 2;
|
|
925
|
+
return stateLines[i].map((line, k) => {
|
|
926
|
+
const y = cy + 5 - (G.identityLine / 2) * (stateLines[i].length - 1) + G.identityLine * k;
|
|
927
|
+
texts.push({ rect: textRect(nodeCenter, y, line, G.identitySize, 'middle'), box: i });
|
|
928
|
+
return { x: nodeCenter, y, text: line };
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const portY = (i, side, e, end) => {
|
|
933
|
+
const list = ports[i][side];
|
|
934
|
+
const j = list.findIndex((p) => p.e === e && p.end === end);
|
|
935
|
+
return tops[i] + nodeHeights[i] / 2 + G.portPitch * (j - (list.length - 1) / 2);
|
|
936
|
+
};
|
|
937
|
+
for (const e of edges) {
|
|
938
|
+
if (e.side === 'direct') {
|
|
939
|
+
const sy = tops[e.from] + nodeHeights[e.from];
|
|
940
|
+
const ty = tops[e.to];
|
|
941
|
+
e.points = [[nodeCenter, sy], [nodeCenter, ty]];
|
|
942
|
+
e.number = { x: nodeCenter + 12, y: (sy + ty) / 2 - 6 };
|
|
943
|
+
} else {
|
|
944
|
+
const x0 = e.side === 'left' ? nodeX : nodeRight;
|
|
945
|
+
const lx = laneX(e.side, e.lane);
|
|
946
|
+
const sy = portY(e.from, e.side, e, 'from');
|
|
947
|
+
const ty = portY(e.to, e.side, e, 'to');
|
|
948
|
+
e.points = [[x0, sy], [lx, sy], [lx, ty], [x0, ty]];
|
|
949
|
+
e.number = { x: e.side === 'left' ? nodeX - 32 : nodeRight + 8, y: sy - 6 };
|
|
950
|
+
}
|
|
951
|
+
texts.push({ rect: textRect(e.number.x, e.number.y, String(e.t.n), G.smallSize) });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const crossing = crossRoutes(edges);
|
|
955
|
+
if (crossing.issue) {
|
|
956
|
+
return crossing;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const descriptions = [];
|
|
960
|
+
for (let i = 0; i < model.states.length; i++) {
|
|
961
|
+
let descTop = tops[i];
|
|
962
|
+
for (const e of bySource[i]) {
|
|
963
|
+
const d = { e, number: { x: numberX, y: descTop + 14 }, trigger: [], guard: [], tag: null };
|
|
964
|
+
texts.push({ rect: textRect(numberX, descTop + 14, String(e.t.n), G.smallSize) });
|
|
965
|
+
e.trigger.forEach((line, k) => {
|
|
966
|
+
const y = descTop + 14 + G.primaryLine * k;
|
|
967
|
+
d.trigger.push({ x: textX, y, text: line });
|
|
968
|
+
texts.push({ rect: textRect(textX, y, line, G.primarySize) });
|
|
969
|
+
});
|
|
970
|
+
const guardTop = descTop + G.primaryLine * e.trigger.length;
|
|
971
|
+
e.guard.forEach((line, k) => {
|
|
972
|
+
const y = guardTop + 13 + G.secondaryLine * k;
|
|
973
|
+
d.guard.push({ x: textX + G.guardKeyWidth, y, text: line });
|
|
974
|
+
texts.push({ rect: textRect(textX + G.guardKeyWidth, y, line, G.secondarySize) });
|
|
975
|
+
});
|
|
976
|
+
if (e.guard.length) {
|
|
977
|
+
d.guardKey = { x: textX, y: guardTop + 13 };
|
|
978
|
+
texts.push({ rect: textRect(textX, guardTop + 13, 'Guard:', G.smallSize) });
|
|
979
|
+
}
|
|
980
|
+
if (e.tag) {
|
|
981
|
+
const tagY = guardTop + G.secondaryLine * e.guard.length;
|
|
982
|
+
d.tag = { x: textX, y: tagY, text: e.tag };
|
|
983
|
+
texts.push({ rect: { x: textX, y: tagY, w: G.tagWidth, h: G.tagHeight } });
|
|
984
|
+
}
|
|
985
|
+
descriptions.push(d);
|
|
986
|
+
descTop += describe(e) + G.descriptionGap;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const routes = edges.map((e) => ({ pieces: e.pieces }));
|
|
991
|
+
if (!auditLayout({ width, height, boxes, texts, routes })) {
|
|
992
|
+
return { issue: '圖上的文字或線條會互相重疊' };
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
layout: {
|
|
996
|
+
width,
|
|
997
|
+
height,
|
|
998
|
+
nodeRadius: G.stateRadius,
|
|
999
|
+
states: model.states.map((state, i) => ({ state, box: boxes[i], lines: stateTexts[i] })),
|
|
1000
|
+
edges,
|
|
1001
|
+
descriptions
|
|
1002
|
+
},
|
|
1003
|
+
truncated: truncated > 0
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Crossings of the lifecycle's orthogonal routes: shared segments and
|
|
1008
|
+
// touching are refused; a proper crossing stays only when few, far from
|
|
1009
|
+
// every endpoint and apart from the next one, and is drawn as a gap in the
|
|
1010
|
+
// vertical segment (the horizontal one stays whole). Sets e.pieces.
|
|
1011
|
+
function crossRoutes(edges) {
|
|
1012
|
+
const L = DIAGRAM_LIMITS;
|
|
1013
|
+
const segs = edges.flatMap((e, ei) => segments(e.points).map(([a, b], k) => ({ ei, k, a, b, vertical: a[0] === b[0] })));
|
|
1014
|
+
const within = (v, p, q) => v >= Math.min(p, q) && v <= Math.max(p, q);
|
|
1015
|
+
const strictly = (v, p, q) => v > Math.min(p, q) && v < Math.max(p, q);
|
|
1016
|
+
const crossings = [];
|
|
1017
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1018
|
+
for (let j = i + 1; j < segs.length; j++) {
|
|
1019
|
+
const s = segs[i];
|
|
1020
|
+
const t = segs[j];
|
|
1021
|
+
if (s.ei === t.ei) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (s.vertical === t.vertical) {
|
|
1025
|
+
const axis = s.vertical ? 0 : 1;
|
|
1026
|
+
const along = s.vertical ? 1 : 0;
|
|
1027
|
+
if (s.a[axis] === t.a[axis] &&
|
|
1028
|
+
Math.max(Math.min(s.a[along], s.b[along]), Math.min(t.a[along], t.b[along])) <=
|
|
1029
|
+
Math.min(Math.max(s.a[along], s.b[along]), Math.max(t.a[along], t.b[along]))) {
|
|
1030
|
+
return { issue: '有兩支箭頭的線段重疊' };
|
|
1031
|
+
}
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const v = s.vertical ? s : t;
|
|
1035
|
+
const h = s.vertical ? t : s;
|
|
1036
|
+
const x = v.a[0];
|
|
1037
|
+
const y = h.a[1];
|
|
1038
|
+
if (!within(x, h.a[0], h.b[0]) || !within(y, v.a[1], v.b[1])) {
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (!strictly(x, h.a[0], h.b[0]) || !strictly(y, v.a[1], v.b[1])) {
|
|
1042
|
+
return { issue: '有兩支箭頭在端點相碰' };
|
|
1043
|
+
}
|
|
1044
|
+
const clearance = Math.min(Math.abs(x - h.a[0]), Math.abs(x - h.b[0]), Math.abs(y - v.a[1]), Math.abs(y - v.b[1]));
|
|
1045
|
+
if (clearance < L.CROSSING_ENDPOINT_CLEARANCE) {
|
|
1046
|
+
return { issue: '箭頭交叉的地方離轉角太近' };
|
|
1047
|
+
}
|
|
1048
|
+
crossings.push({ v, h, x, y });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (crossings.length > L.LC_MAX_CROSSINGS) {
|
|
1052
|
+
return { issue: `箭頭交叉 ${crossings.length} 處,上限 ${L.LC_MAX_CROSSINGS}` };
|
|
1053
|
+
}
|
|
1054
|
+
// Distance along a route to a point on one of its segments.
|
|
1055
|
+
const along = (e, k, point) => {
|
|
1056
|
+
let d = 0;
|
|
1057
|
+
for (let m = 0; m < k; m++) {
|
|
1058
|
+
d += Math.abs(e.points[m + 1][0] - e.points[m][0]) + Math.abs(e.points[m + 1][1] - e.points[m][1]);
|
|
1059
|
+
}
|
|
1060
|
+
return d + Math.abs(point[0] - e.points[k][0]) + Math.abs(point[1] - e.points[k][1]);
|
|
1061
|
+
};
|
|
1062
|
+
for (let ei = 0; ei < edges.length; ei++) {
|
|
1063
|
+
const mine = crossings
|
|
1064
|
+
.flatMap((c) => [c.v, c.h].filter((s) => s.ei === ei).map((s) => along(edges[ei], s.k, [c.x, c.y])))
|
|
1065
|
+
.sort((a, b) => a - b);
|
|
1066
|
+
if (mine.length > L.LC_MAX_CROSSINGS_PER_EDGE) {
|
|
1067
|
+
return { issue: `第 ${edges[ei].t.n} 條轉移交叉 ${mine.length} 處,上限 ${L.LC_MAX_CROSSINGS_PER_EDGE}` };
|
|
1068
|
+
}
|
|
1069
|
+
if (mine.some((d, m) => m > 0 && d - mine[m - 1] < L.CROSSING_SEPARATION)) {
|
|
1070
|
+
return { issue: `第 ${edges[ei].t.n} 條轉移的兩個交叉點太近` };
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// Cut the vertical segment around each crossing.
|
|
1074
|
+
for (let ei = 0; ei < edges.length; ei++) {
|
|
1075
|
+
const e = edges[ei];
|
|
1076
|
+
const cuts = crossings.filter((c) => c.v.ei === ei);
|
|
1077
|
+
const pieces = [[e.points[0]]];
|
|
1078
|
+
for (let k = 1; k < e.points.length; k++) {
|
|
1079
|
+
const [a, b] = [e.points[k - 1], e.points[k]];
|
|
1080
|
+
const here = cuts.filter((c) => c.v.k === k - 1).map((c) => c.y)
|
|
1081
|
+
.sort((p, q) => (b[1] > a[1] ? p - q : q - p));
|
|
1082
|
+
for (const y of here) {
|
|
1083
|
+
const dir = Math.sign(b[1] - a[1]);
|
|
1084
|
+
pieces[pieces.length - 1].push([a[0], y - dir * L.CROSSING_HALF_GAP]);
|
|
1085
|
+
pieces.push([[a[0], y + dir * L.CROSSING_HALF_GAP]]);
|
|
1086
|
+
}
|
|
1087
|
+
pieces[pieces.length - 1].push(b);
|
|
1088
|
+
}
|
|
1089
|
+
e.pieces = pieces;
|
|
1090
|
+
}
|
|
1091
|
+
return {};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ------------------------------------------------------------ flow layout
|
|
1095
|
+
|
|
1096
|
+
function layoutFlow(model) {
|
|
1097
|
+
const G = GEOMETRY;
|
|
1098
|
+
const L = DIAGRAM_LIMITS;
|
|
1099
|
+
const centerOf = new Map(model.participants.map((p, j) => [p, G.firstParticipantX + G.participantPitch * j]));
|
|
1100
|
+
|
|
1101
|
+
const headers = [];
|
|
1102
|
+
const texts = [];
|
|
1103
|
+
const boxes = [];
|
|
1104
|
+
for (const [j, participant] of model.participants.entries()) {
|
|
1105
|
+
const lines = wrapItem(participant, G.headerWrapWidth, G.identitySize);
|
|
1106
|
+
if (lines.length > L.IDENTITY_MAX_LINES) {
|
|
1107
|
+
return { issue: `參與者 ${participant} 的名稱要排成 ${lines.length} 行,上限 ${L.IDENTITY_MAX_LINES} 行` };
|
|
1108
|
+
}
|
|
1109
|
+
const cx = centerOf.get(participant);
|
|
1110
|
+
const box = { x: cx - G.headerWidth / 2, y: G.margin, w: G.headerWidth, h: G.headerHeight };
|
|
1111
|
+
const cy = box.y + box.h / 2;
|
|
1112
|
+
const placed = lines.map((line, k) => {
|
|
1113
|
+
const y = cy + 5 - (G.identityLine / 2) * (lines.length - 1) + G.identityLine * k;
|
|
1114
|
+
texts.push({ rect: textRect(cx, y, line, G.identitySize, 'middle'), box: j });
|
|
1115
|
+
return { x: cx, y, text: line };
|
|
1116
|
+
});
|
|
1117
|
+
boxes.push(box);
|
|
1118
|
+
headers.push({ participant, box, lines: placed });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
let truncated = 0;
|
|
1122
|
+
let bandTop = G.firstBandTop;
|
|
1123
|
+
const steps = [];
|
|
1124
|
+
const exclusions = model.participants.map(() => []);
|
|
1125
|
+
for (const s of model.steps) {
|
|
1126
|
+
const fromX = centerOf.get(s.from);
|
|
1127
|
+
const toX = centerOf.get(s.to);
|
|
1128
|
+
const self = s.from === s.to;
|
|
1129
|
+
const labelX = self ? fromX + 12 : Math.min(fromX, toX) + 12;
|
|
1130
|
+
const labelWidth = self ? G.selfLabelWidth : Math.min(G.maxLabelWidth, Math.abs(toX - fromX) - 24);
|
|
1131
|
+
const main = wrapField(s.handedOver, labelWidth, G.primarySize, L.PRIMARY_MAX_LINES);
|
|
1132
|
+
const change = wrapField(s.stateChange, labelWidth, G.secondarySize, L.SECONDARY_MAX_LINES);
|
|
1133
|
+
truncated += Number(main.truncated) + Number(change.truncated);
|
|
1134
|
+
const tag = DASHED_EVIDENCE.has(s.evidenceType) ? s.evidenceType : null;
|
|
1135
|
+
|
|
1136
|
+
const step = { s, number: { x: G.margin, y: bandTop + 14 }, main: [], change: [], tag: null };
|
|
1137
|
+
const own = [];
|
|
1138
|
+
texts.push({ rect: textRect(G.margin, bandTop + 14, String(s.n), G.smallSize) });
|
|
1139
|
+
main.lines.forEach((line, k) => {
|
|
1140
|
+
const y = bandTop + 14 + G.primaryLine * k;
|
|
1141
|
+
step.main.push({ x: labelX, y, text: line });
|
|
1142
|
+
own.push(textRect(labelX, y, line, G.primarySize));
|
|
1143
|
+
});
|
|
1144
|
+
const tagTop = bandTop + G.primaryLine * main.lines.length;
|
|
1145
|
+
if (tag) {
|
|
1146
|
+
step.tag = { x: labelX, y: tagTop, text: tag };
|
|
1147
|
+
own.push({ x: labelX, y: tagTop, w: G.tagWidth, h: G.tagHeight });
|
|
1148
|
+
}
|
|
1149
|
+
const arrowY = tagTop + (tag ? G.tagRow : 0) + G.labelClearance;
|
|
1150
|
+
const loop = self ? G.selfLoopHeight : 0;
|
|
1151
|
+
step.points = self
|
|
1152
|
+
? [[fromX, arrowY], [fromX + G.selfLoopWidth, arrowY], [fromX + G.selfLoopWidth, arrowY + loop], [fromX, arrowY + loop]]
|
|
1153
|
+
: [[fromX, arrowY], [toX, arrowY]];
|
|
1154
|
+
step.pieces = [step.points];
|
|
1155
|
+
const changeTop = arrowY + loop + G.labelClearance;
|
|
1156
|
+
change.lines.forEach((line, k) => {
|
|
1157
|
+
const y = changeTop + 13 + G.secondaryLine * k;
|
|
1158
|
+
step.change.push({ x: labelX, y, text: line });
|
|
1159
|
+
own.push(textRect(labelX, y, line, G.secondarySize));
|
|
1160
|
+
});
|
|
1161
|
+
texts.push(...own.map((rect) => ({ rect })));
|
|
1162
|
+
// A label spanning intermediate lifelines cuts them, never the arrow.
|
|
1163
|
+
const lo = Math.min(fromX, toX);
|
|
1164
|
+
const hi = Math.max(fromX, toX);
|
|
1165
|
+
model.participants.forEach((p, j) => {
|
|
1166
|
+
const x = centerOf.get(p);
|
|
1167
|
+
if (x > lo && x < hi) {
|
|
1168
|
+
for (const r of own) {
|
|
1169
|
+
if (r.x < x && x < r.x + r.w) {
|
|
1170
|
+
exclusions[j].push([r.y - 4, r.y + r.h + 4]);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
steps.push(step);
|
|
1176
|
+
bandTop = changeTop + G.secondaryLine * change.lines.length + G.stepSpacing;
|
|
1177
|
+
}
|
|
1178
|
+
if (truncated > L.MAX_TRUNCATED_FIELDS) {
|
|
1179
|
+
return { issue: `有 ${truncated} 格文字要節略才放得下,上限 ${L.MAX_TRUNCATED_FIELDS} 格` };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const lifelineEnd = bandTop;
|
|
1183
|
+
const lifelines = model.participants.map((p, j) => {
|
|
1184
|
+
const x = centerOf.get(p);
|
|
1185
|
+
const cuts = exclusions[j].sort((a, b) => a[0] - b[0]);
|
|
1186
|
+
const pieces = [];
|
|
1187
|
+
let y = G.lifelineTop;
|
|
1188
|
+
for (const [a, b] of cuts) {
|
|
1189
|
+
if (a > y) {
|
|
1190
|
+
pieces.push([[x, y], [x, a]]);
|
|
1191
|
+
}
|
|
1192
|
+
y = Math.max(y, b);
|
|
1193
|
+
}
|
|
1194
|
+
if (y < lifelineEnd) {
|
|
1195
|
+
pieces.push([[x, y], [x, lifelineEnd]]);
|
|
1196
|
+
}
|
|
1197
|
+
return pieces;
|
|
1198
|
+
});
|
|
1199
|
+
// No width cap here: the participant cap bounds it, and a flow wider than
|
|
1200
|
+
// the page scrolls sideways on screen (and is replaced by a note in print).
|
|
1201
|
+
const lastCenter = G.firstParticipantX + G.participantPitch * (model.participants.length - 1);
|
|
1202
|
+
const width = Math.max(lastCenter + 96, ...model.steps.filter((s) => s.from === s.to).map((s) => centerOf.get(s.from) + 196));
|
|
1203
|
+
const height = lifelineEnd + G.margin;
|
|
1204
|
+
if (height > L.DIAGRAM_MAX_HEIGHT) {
|
|
1205
|
+
return { issue: `圖高 ${height},上限 ${L.DIAGRAM_MAX_HEIGHT}` };
|
|
1206
|
+
}
|
|
1207
|
+
const routes = steps.map((step) => ({ pieces: step.pieces }));
|
|
1208
|
+
if (!auditLayout({ width, height, boxes, texts, routes, lifelines: lifelines.flat() })) {
|
|
1209
|
+
return { issue: '圖上的文字或線條會互相重疊' };
|
|
1210
|
+
}
|
|
1211
|
+
return { layout: { width, height, headers, lifelines, steps }, truncated: truncated > 0 };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ------------------------------------------------------------ svg output
|
|
1215
|
+
|
|
1216
|
+
function pathData(points) {
|
|
1217
|
+
let d = `M${points[0][0]} ${points[0][1]}`;
|
|
1218
|
+
for (let k = 1; k < points.length; k++) {
|
|
1219
|
+
const [px, py] = points[k - 1];
|
|
1220
|
+
const [x, y] = points[k];
|
|
1221
|
+
d += x === px ? `V${y}` : y === py ? `H${x}` : `L${x} ${y}`;
|
|
1222
|
+
}
|
|
1223
|
+
return d;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function textEl(cls, { x, y, text }) {
|
|
1227
|
+
return `<text class="${cls}" x="${x}" y="${y}">${escapeXml(text)}</text>`;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function routeEls(pieces, dashed, markerId) {
|
|
1231
|
+
const cls = dashed ? 'dg-route dg-uncertain' : 'dg-route';
|
|
1232
|
+
return pieces.map((piece, k) => {
|
|
1233
|
+
const marker = k === pieces.length - 1 ? ` marker-end="url(#${markerId})"` : '';
|
|
1234
|
+
return `<path class="${cls}" d="${pathData(piece)}"${marker}/>`;
|
|
1235
|
+
}).join('');
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function tagEls(tag) {
|
|
1239
|
+
const G = GEOMETRY;
|
|
1240
|
+
return `<rect class="dg-evidence-box" x="${tag.x}" y="${tag.y}" width="${G.tagWidth}" height="${G.tagHeight}" rx="3"/>` +
|
|
1241
|
+
textEl('dg-evidence-text', { x: tag.x + G.tagInset, y: tag.y + 12, text: tag.text });
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function svgOpen(label, width, height, markerId) {
|
|
1245
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" class="dg-svg" role="img" aria-label="${escapeXml(label)}" ` +
|
|
1246
|
+
`width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">\n` +
|
|
1247
|
+
`<defs aria-hidden="true"><marker id="${markerId}" viewBox="0 0 8 8" refX="8" refY="4" markerWidth="8" markerHeight="8" ` +
|
|
1248
|
+
'markerUnits="userSpaceOnUse" orient="auto"><path class="dg-head" d="M0 0L8 4L0 8Z"/></marker></defs>\n' +
|
|
1249
|
+
`<g aria-hidden="true">\n<rect class="dg-canvas" x="0" y="0" width="${width}" height="${height}"/>\n`;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// The legend lists only the arrow styles this diagram uses and states the
|
|
1253
|
+
// Evidence type; it never ranks the types or calls a solid line verified.
|
|
1254
|
+
function legendHtml(tags, truncated) {
|
|
1255
|
+
const key = (dashed) =>
|
|
1256
|
+
`<svg class="dg-key" aria-hidden="true" viewBox="0 0 28 8"><path class="dg-route${dashed ? ' dg-uncertain' : ''}" d="M0 4H28"/></svg>`;
|
|
1257
|
+
const spans = [];
|
|
1258
|
+
if (tags.some((tag) => !tag)) {
|
|
1259
|
+
spans.push(`<span>${key(false)}實線:這一條的 Evidence 類型不是 inferred 或 assumed。</span>`);
|
|
1260
|
+
}
|
|
1261
|
+
const types = ['inferred', 'assumed'].filter((type) => tags.includes(type));
|
|
1262
|
+
if (types.length) {
|
|
1263
|
+
spans.push(`<span>${key(true)}虛線:這一條的 Evidence 類型是 inferred 或 assumed。本圖類型:${types.join('、')}。</span>`);
|
|
1264
|
+
}
|
|
1265
|
+
if (truncated) {
|
|
1266
|
+
spans.push('<span>…:文字已節略,完整內容見下方卡片。</span>');
|
|
1267
|
+
}
|
|
1268
|
+
return `<figcaption class="dg-legend">${spans.join('')}</figcaption>\n`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function evidenceAttr(type) {
|
|
1272
|
+
return type ? ` data-evidence="${escapeXml(type)}"` : '';
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function lifecycleSvg(model, result, ordinal) {
|
|
1276
|
+
const { layout } = result;
|
|
1277
|
+
const markerId = `dflow.dg.${ordinal}.arrow`;
|
|
1278
|
+
const label = `${model.entryId}:${model.states.length} 個狀態、${model.transitions.length} 條轉移;細節見下方卡片`;
|
|
1279
|
+
let out = `<figure class="dflow-dg dg-lc" data-entry="${escapeXml(model.entryId)}">\n<div class="dg-scroll" tabindex="0">\n`;
|
|
1280
|
+
out += svgOpen(label, layout.width, layout.height, markerId);
|
|
1281
|
+
for (const s of layout.states) {
|
|
1282
|
+
out += `<g class="dg-state" data-state="${escapeXml(s.state)}"><rect class="dg-box" x="${s.box.x}" y="${s.box.y}" ` +
|
|
1283
|
+
`width="${s.box.w}" height="${s.box.h}" rx="${layout.nodeRadius}"/>${s.lines.map((line) => textEl('dg-state-label', line)).join('')}</g>\n`;
|
|
1284
|
+
}
|
|
1285
|
+
for (const e of layout.edges) {
|
|
1286
|
+
out += `<g class="dg-edge" data-n="${e.t.n}" data-from="${escapeXml(e.t.from)}" data-to="${escapeXml(e.t.to)}"${evidenceAttr(e.t.evidenceType)}>` +
|
|
1287
|
+
`${routeEls(e.pieces, Boolean(e.tag), markerId)}${textEl('dg-number', { ...e.number, text: String(e.t.n) })}</g>\n`;
|
|
1288
|
+
}
|
|
1289
|
+
for (const d of layout.descriptions) {
|
|
1290
|
+
out += `<g class="dg-label" data-n="${d.e.t.n}">${textEl('dg-number', { ...d.number, text: String(d.e.t.n) })}` +
|
|
1291
|
+
d.trigger.map((line) => textEl('dg-primary', line)).join('') +
|
|
1292
|
+
(d.guardKey ? textEl('dg-guard-key', { ...d.guardKey, text: 'Guard:' }) : '') +
|
|
1293
|
+
d.guard.map((line) => textEl('dg-secondary', line)).join('') +
|
|
1294
|
+
(d.tag ? tagEls(d.tag) : '') + '</g>\n';
|
|
1295
|
+
}
|
|
1296
|
+
out += '</g>\n</svg>\n</div>\n';
|
|
1297
|
+
out += legendHtml(layout.edges.map((e) => e.tag), result.truncated);
|
|
1298
|
+
return `${out}</figure>\n`;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function flowSvg(model, result, ordinal) {
|
|
1302
|
+
const { layout } = result;
|
|
1303
|
+
const markerId = `dflow.dg.${ordinal}.arrow`;
|
|
1304
|
+
const label = `${model.entryId}:${model.participants.length} 個參與者、${model.steps.length} 個步驟;細節見下方卡片`;
|
|
1305
|
+
// More participants than a printed page holds legibly: drawn on screen
|
|
1306
|
+
// (scrolling sideways), replaced by a one-line note in print.
|
|
1307
|
+
const wide = model.participants.length > DIAGRAM_LIMITS.FL_PRINT_MAX_PARTICIPANTS;
|
|
1308
|
+
let out = `<figure class="dflow-dg dg-fl${wide ? ' dg-wide' : ''}" data-entry="${escapeXml(model.entryId)}">\n`;
|
|
1309
|
+
if (wide) {
|
|
1310
|
+
out += `<p class="dg-print-note">${escapeXml(model.entryId)} 有 ${model.participants.length} 個參與者,` +
|
|
1311
|
+
`列印版只印 ${DIAGRAM_LIMITS.FL_PRINT_MAX_PARTICIPANTS} 個以內的流程圖;內容見下方卡片。</p>\n`;
|
|
1312
|
+
}
|
|
1313
|
+
out += '<div class="dg-scroll" tabindex="0">\n';
|
|
1314
|
+
out += svgOpen(label, layout.width, layout.height, markerId);
|
|
1315
|
+
for (const pieces of layout.lifelines) {
|
|
1316
|
+
out += pieces.map((piece) => `<path class="dg-lifeline" d="${pathData(piece)}"/>`).join('') + '\n';
|
|
1317
|
+
}
|
|
1318
|
+
for (const h of layout.headers) {
|
|
1319
|
+
out += `<g class="dg-participant" data-participant="${escapeXml(h.participant)}"><rect class="dg-box" x="${h.box.x}" ` +
|
|
1320
|
+
`y="${h.box.y}" width="${h.box.w}" height="${h.box.h}" rx="${GEOMETRY.stateRadius}"/>` +
|
|
1321
|
+
`${h.lines.map((line) => textEl('dg-participant-label', line)).join('')}</g>\n`;
|
|
1322
|
+
}
|
|
1323
|
+
for (const step of layout.steps) {
|
|
1324
|
+
const s = step.s;
|
|
1325
|
+
out += `<g class="dg-step" data-n="${s.n}" data-from="${escapeXml(s.from)}" data-to="${escapeXml(s.to)}"${evidenceAttr(s.evidenceType)}>` +
|
|
1326
|
+
routeEls(step.pieces, Boolean(step.tag), markerId) +
|
|
1327
|
+
textEl('dg-number', { ...step.number, text: String(s.n) }) +
|
|
1328
|
+
step.main.map((line) => textEl('dg-primary', line)).join('') +
|
|
1329
|
+
(step.tag ? tagEls(step.tag) : '') +
|
|
1330
|
+
step.change.map((line) => textEl('dg-secondary', line)).join('') + '</g>\n';
|
|
1331
|
+
}
|
|
1332
|
+
out += '</g>\n</svg>\n</div>\n';
|
|
1333
|
+
out += legendHtml(layout.steps.map((step) => (step.tag ? step.tag.text : null)), result.truncated);
|
|
1334
|
+
return `${out}</figure>\n`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// A model -> { html } or { issue }. ordinal numbers the page's diagrams.
|
|
1338
|
+
function drawDiagram(model, ordinal) {
|
|
1339
|
+
if (model.kind === 'LC') {
|
|
1340
|
+
const result = layoutLifecycle(model);
|
|
1341
|
+
return result.issue ? result : { html: lifecycleSvg(model, result, ordinal) };
|
|
1342
|
+
}
|
|
1343
|
+
const result = layoutFlow(model);
|
|
1344
|
+
return result.issue ? result : { html: flowSvg(model, result, ordinal) };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ------------------------------------------------------------ page glue
|
|
1348
|
+
|
|
1349
|
+
function noteHtml(entryId, reason) {
|
|
1350
|
+
return `<p class="dflow-dg-notice">${escapeXml(entryId)} 沒有畫成圖:${escapeXml(reason)}。</p>\n`;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// One located subsection -> { at, html } to insert before token `at`, or null
|
|
1354
|
+
// when the subsection gets nothing (no table at all, or its tables are still
|
|
1355
|
+
// the template's, holding only placeholder rows).
|
|
1356
|
+
// Reading its tables, checking them and drawing all run inside one try, so
|
|
1357
|
+
// whatever throws becomes this subsection's note.
|
|
1358
|
+
function subsectionBlock(tokens, located, page) {
|
|
1359
|
+
if (located.tableIndexes.length === 0) {
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
const firstTable = located.tableIndexes[0];
|
|
1363
|
+
let result;
|
|
1364
|
+
try {
|
|
1365
|
+
const sub = readSubsection(tokens, located);
|
|
1366
|
+
result = sub.kind === 'LC' ? lifecycleModel(sub) : flowModel(sub);
|
|
1367
|
+
if (result.skip) {
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
if (result.model) {
|
|
1371
|
+
const drawn = drawDiagram(result.model, page.drawn);
|
|
1372
|
+
if (drawn.html) {
|
|
1373
|
+
page.drawn += 1;
|
|
1374
|
+
return { at: result.insertAt, html: drawn.html };
|
|
1375
|
+
}
|
|
1376
|
+
result = drawn;
|
|
1377
|
+
}
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
result = { issue: `畫圖時發生內部錯誤(${error && error.message ? error.message : error})` };
|
|
1380
|
+
}
|
|
1381
|
+
page.notDrawn += 1;
|
|
1382
|
+
return { at: firstTable, html: noteHtml(located.entryId, result.issue) };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function newPageState() {
|
|
1386
|
+
return { drawn: 0, notDrawn: 0 };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// marked's processAllTokens hook body: insert each subsection's picture or
|
|
1390
|
+
// note as its own html block token, right before the subsection's first
|
|
1391
|
+
// recognised table. Inserting back to front keeps earlier indexes valid.
|
|
1392
|
+
function insertDiagrams(tokens, page) {
|
|
1393
|
+
// Locating subsections reads headings only. Should marked's token shape
|
|
1394
|
+
// ever change under it (marked is exact-pinned, and the template-row test
|
|
1395
|
+
// parses through the same path), the page renders exactly as before rather
|
|
1396
|
+
// than failing the run. Each subsection's own work is isolated in
|
|
1397
|
+
// subsectionBlock.
|
|
1398
|
+
let located;
|
|
1399
|
+
try {
|
|
1400
|
+
located = locateEntrySubsections(tokens);
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
return tokens;
|
|
1403
|
+
}
|
|
1404
|
+
const blocks = located.map((sub) => subsectionBlock(tokens, sub, page)).filter(Boolean);
|
|
1405
|
+
for (const block of blocks.sort((a, b) => b.at - a.at)) {
|
|
1406
|
+
tokens.splice(block.at, 0, { type: 'html', block: true, pre: false, raw: '', text: block.html });
|
|
1407
|
+
}
|
|
1408
|
+
return tokens;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Appended only to pages that carry a diagram or a note. Colours come from
|
|
1412
|
+
// the page's own custom properties, so dark mode and print follow the page.
|
|
1413
|
+
// In print a picture never splits, so one that does not fit moves to the
|
|
1414
|
+
// next page; the heading rules keep its subsection heading moving with it
|
|
1415
|
+
// instead of being left alone at the foot of the previous page.
|
|
1416
|
+
const DIAGRAM_CSS = `
|
|
1417
|
+
.dflow-dg { margin: 16px 0 24px; }
|
|
1418
|
+
.dflow-dg .dg-scroll {
|
|
1419
|
+
max-width: 100%; overflow-x: auto; border: 1px solid var(--line);
|
|
1420
|
+
border-radius: 8px; background: var(--surface);
|
|
1421
|
+
}
|
|
1422
|
+
.dflow-dg .dg-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1423
|
+
.dflow-dg .dg-svg { display: block; max-width: none; font-family: inherit; }
|
|
1424
|
+
.dflow-dg text { fill: var(--ink); font-family: inherit; font-weight: 400; letter-spacing: 0; }
|
|
1425
|
+
.dflow-dg .dg-canvas { fill: var(--surface); }
|
|
1426
|
+
.dflow-dg .dg-box { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 1.5; }
|
|
1427
|
+
.dflow-dg .dg-state-label, .dflow-dg .dg-participant-label { font-size: 14px; font-weight: 600; text-anchor: middle; }
|
|
1428
|
+
.dflow-dg .dg-route { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-linecap: butt; stroke-linejoin: round; }
|
|
1429
|
+
.dflow-dg .dg-uncertain { stroke-dasharray: 6 4; }
|
|
1430
|
+
.dflow-dg .dg-head { fill: var(--ink); stroke: none; stroke-dasharray: none; }
|
|
1431
|
+
.dflow-dg .dg-lifeline { fill: none; stroke: var(--soft); stroke-width: 1; }
|
|
1432
|
+
.dflow-dg .dg-primary { font-size: 14px; }
|
|
1433
|
+
.dflow-dg .dg-secondary { font-size: 13px; fill: var(--soft); }
|
|
1434
|
+
.dflow-dg .dg-guard-key { font-size: 12px; fill: var(--soft); }
|
|
1435
|
+
.dflow-dg .dg-number { font-size: 12px; font-weight: 600; fill: var(--accent); }
|
|
1436
|
+
.dflow-dg .dg-evidence-box { fill: var(--frame); stroke: var(--line); stroke-width: 1; }
|
|
1437
|
+
.dflow-dg .dg-evidence-text { font-size: 12px; fill: var(--soft); }
|
|
1438
|
+
.dflow-dg .dg-legend { margin: 8px 0 0; color: var(--soft); font-size: 12px; line-height: 1.6; }
|
|
1439
|
+
.dflow-dg .dg-legend > span { display: block; }
|
|
1440
|
+
.dflow-dg .dg-key { width: 28px; height: 8px; margin-right: 6px; vertical-align: middle; }
|
|
1441
|
+
.dflow-dg-notice { margin: 12px 0; color: var(--soft); font-size: 14px; }
|
|
1442
|
+
.dflow-dg .dg-print-note { display: none; }
|
|
1443
|
+
@media print {
|
|
1444
|
+
h2, h3, h4 { break-after: avoid; page-break-after: avoid; }
|
|
1445
|
+
.dflow-dg { margin: 8px 0 16px; break-before: avoid; page-break-before: avoid; break-inside: avoid; page-break-inside: avoid; }
|
|
1446
|
+
.dflow-dg .dg-scroll { overflow: visible; border: 0; border-radius: 0; }
|
|
1447
|
+
.dflow-dg .dg-svg { width: 100%; height: auto; max-width: 100%; max-height: 210mm; }
|
|
1448
|
+
.dflow-dg.dg-wide .dg-scroll, .dflow-dg.dg-wide .dg-legend { display: none; }
|
|
1449
|
+
.dflow-dg.dg-wide .dg-print-note { display: block; margin: 0; color: var(--soft); font-size: 14px; }
|
|
1450
|
+
}
|
|
1451
|
+
`;
|
|
1452
|
+
|
|
1453
|
+
module.exports = {
|
|
1454
|
+
newPageState,
|
|
1455
|
+
insertDiagrams,
|
|
1456
|
+
DIAGRAM_CSS,
|
|
1457
|
+
// Exported for tests: the placeholder rows are locked against the shipped
|
|
1458
|
+
// template, the limits and measurements by value, recognition runs on
|
|
1459
|
+
// marked tokens, and the geometry is checked on its own.
|
|
1460
|
+
TEMPLATE_PLACEHOLDER_ROWS,
|
|
1461
|
+
DIAGRAM_LIMITS,
|
|
1462
|
+
GEOMETRY,
|
|
1463
|
+
DASHED_EVIDENCE,
|
|
1464
|
+
findEntrySubsections,
|
|
1465
|
+
lifecycleModel,
|
|
1466
|
+
flowModel,
|
|
1467
|
+
inlineItems,
|
|
1468
|
+
textWidth,
|
|
1469
|
+
wrapItem,
|
|
1470
|
+
wrapField,
|
|
1471
|
+
layoutLifecycle,
|
|
1472
|
+
layoutFlow,
|
|
1473
|
+
drawDiagram
|
|
1474
|
+
};
|