@formicoidea/labre-framework-wardley 0.32.0 → 0.34.1
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/dist/actions.d.ts +49 -2
- package/dist/actions.js +113 -21
- package/dist/commands-manifest.d.ts +18 -0
- package/dist/commands-manifest.js +146 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +166 -4
- package/dist/element-view.d.ts +22 -0
- package/dist/element-view.js +35 -12
- package/dist/export.d.ts +211 -0
- package/dist/export.js +655 -0
- package/dist/gradient.js +1 -1
- package/dist/import.d.ts +116 -0
- package/dist/import.js +905 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +22 -0
- package/dist/interchange.d.ts +80 -0
- package/dist/interchange.js +138 -0
- package/dist/node/node-renderer.js +1 -1
- package/dist/rules.js +16 -0
- package/dist/templates/index.js +15 -3
- package/dist/templates/maps.js +26 -6
- package/dist/toolbar/config.d.ts +10 -2
- package/dist/toolbar/config.js +12 -5
- package/dist/toolbar/icons.d.ts +20 -0
- package/dist/toolbar/icons.js +34 -0
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/toolbar/wardley-senior-button.js +12 -6
- package/dist/translations.js +8 -2
- package/dist/view.js +13 -2
- package/package.json +6 -2
package/dist/import.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextAlign, } from '@formicoidea/labre-core/model';
|
|
2
|
+
import { WARDLEY_BACKGROUND } from './background.js';
|
|
3
|
+
import { OWM_DEFAULT_MAP_HEIGHT, OWM_DEFAULT_MAP_WIDTH, OWM_LABEL_HEIGHT, OWM_LABEL_WIDTH, OWM_SCOPE, OWM_TAIL_ATTR, OWM_KEYWORDS, OWM_TITLE_ATTR, owmDefaultPlot, owmPointOf, WARDLEY_OWM_FORMAT_ID, } from './export.js';
|
|
4
|
+
import { HANDLE_SIZE, LABEL_GAP, LABEL_FONT_SIZE, LINK_GREY, LINK_STROKE_WIDTH, MARKET_DOT_RING, MARKET_DOT_SIZE, MARKET_DOT_STROKE_WIDTH, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, MARKET_SIZE, ECOSYSTEM_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PIPELINE_HEIGHT, WARDLEY_RED, } from './node/consts.js';
|
|
5
|
+
import { WARDLEY_ROLE } from './roles.js';
|
|
6
|
+
/**
|
|
7
|
+
* An OnlineWardleyMaps (OWM) DSL document, read as a Wardley map — the inverse
|
|
8
|
+
* of `export.ts` on the vocabulary Labre draws, and an honest accounting of
|
|
9
|
+
* everything else (`docs/adr/0012`, D1–D6).
|
|
10
|
+
*
|
|
11
|
+
* ADR 0012 calls this row **the reference Wardley import**, and says why: the
|
|
12
|
+
* OWM DSL is the one Wardley vocabulary that is settled, so it is what a user
|
|
13
|
+
* should be pointed at while mermaid's Wardley diagram type is still
|
|
14
|
+
* experimental upstream.
|
|
15
|
+
*
|
|
16
|
+
* ## Pure by construction, like its mirror
|
|
17
|
+
*
|
|
18
|
+
* A string in, element PROPS out. No `BlockStdScope`, no surface, no DOM, no
|
|
19
|
+
* clock, no randomness — and, unlike `.bpmn`, no parser either: the DSL is
|
|
20
|
+
* line-based, so this reads lines. The caller does the writing (P3).
|
|
21
|
+
*
|
|
22
|
+
* ## What the caller owes, and it is one thing
|
|
23
|
+
*
|
|
24
|
+
* **OWM has no ids: the NAME is the identity.** So every element below carries
|
|
25
|
+
* the name it was declared under, verbatim, in `interchange.owm.id`, and a link
|
|
26
|
+
* arrives with `source` / `target` naming those — which is exactly the map
|
|
27
|
+
* `materializeInterchangeImport` folds the returned array into (D3). Nothing
|
|
28
|
+
* else is needed to finish the import, and the same fold is what makes
|
|
29
|
+
* `boardFromProps` in the spec suite a bridge rather than a mock.
|
|
30
|
+
*
|
|
31
|
+
* A name declared twice is imported twice — nothing is dropped — and every link
|
|
32
|
+
* naming it means the FIRST, which is both the materializer's rule and OWM's
|
|
33
|
+
* own. The report says so by name.
|
|
34
|
+
*
|
|
35
|
+
* ## Where the coordinates come from, and where they do not (D4)
|
|
36
|
+
*
|
|
37
|
+
* A `[visibility, evolution]` pair IS the authoritative position — that is D4's
|
|
38
|
+
* "a format that carries coordinates but no pixels" — so the reader projects it
|
|
39
|
+
* onto the plot of the map it lays down and re-layouts nothing. A statement
|
|
40
|
+
* that carries NO pair inverts the same rule rather than contradicting it:
|
|
41
|
+
* there is nothing to be authoritative, so the reader lays one out and SAYS SO,
|
|
42
|
+
* with an `invented-layout` note naming the artefact. **An invented axis is
|
|
43
|
+
* never presented as read from the file.** That is the whole of D4 for a
|
|
44
|
+
* coordinate format, and it is the case `anchor Client` — one line of the
|
|
45
|
+
* tea-shop corpus, and the shape half the maps in the wild are written in.
|
|
46
|
+
*
|
|
47
|
+
* ## Three states, and the middle one is where the file survives
|
|
48
|
+
*
|
|
49
|
+
* **Mapped** is what the pack draws: `component`, `anchor`, `market`,
|
|
50
|
+
* `ecosystem`, `pipeline`, `note`, `evolve`, `title`, and the `->` links.
|
|
51
|
+
* **Carried** is every other statement — `style`, `annotation`, `attitudes`
|
|
52
|
+
* (`pioneers` / `settlers` / `townplanners`), `submap`, `url`, `size`,
|
|
53
|
+
* `accelerator`, the axis-label overrides, the flow links (`+>`, `+<`, `+<>`,
|
|
54
|
+
* `+'…'>`), a link carrying a `;` context, and every `//` comment — kept
|
|
55
|
+
* verbatim in `interchange.owm.children['@document']` on the map's background
|
|
56
|
+
* element (D6) and written back, in order, at the end of the next export.
|
|
57
|
+
* **Quarantined** is empty, and that is a finding rather than an omission: see
|
|
58
|
+
* the loss table.
|
|
59
|
+
*
|
|
60
|
+
* A mapped line keeps its own tail too — `label [12, -8]`, `(build)`,
|
|
61
|
+
* `inertia`, a trailing comment — under `attrs['@self'].tail` on the artefact
|
|
62
|
+
* it belongs to, so the modifiers this pack does not draw come back on the
|
|
63
|
+
* line they were written on rather than at the bottom of the file.
|
|
64
|
+
*
|
|
65
|
+
* ## The loss table
|
|
66
|
+
*
|
|
67
|
+
* Every semantic capability owes one (ADR 0012), and this is the OWM DSL's.
|
|
68
|
+
* INVISIBLE is not LOST, and the distinction is the deliverable.
|
|
69
|
+
*
|
|
70
|
+
* | what | state | after a round trip |
|
|
71
|
+
* | ---------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
* | `component` / `anchor` / `market` / `ecosystem` and their coordinates | mapped | drawn, and written back from the drawing, to two decimals |
|
|
73
|
+
* | the NAME, which in this format is the identity | mapped | given back verbatim, quoted exactly as it needs to be — the fixed point |
|
|
74
|
+
* | `pipeline Name [m1, m2]` | mapped | drawn as the body + handle composite; `[m1, m2]` written back from the body's two edges |
|
|
75
|
+
* | `note Text [v, e]` | mapped | a free text on the map, written back from its centre |
|
|
76
|
+
* | `evolve X [-> Y] m` | mapped | an evolved twin at the same height plus a change arrow, written back from the arrow |
|
|
77
|
+
* | `A->B` links | mapped | a `wardley:dependency` connector, consumer → what it needs, never inverted |
|
|
78
|
+
* | `title` | mapped | consumed as the board's own name; written back from the board's name (see the two rows below) |
|
|
79
|
+
* | a mapped line's TAIL — `label [x, y]`, `(build)`, `inertia`, comments | carried | invisible on the canvas, re-appended to the very line it came off |
|
|
80
|
+
* | `style`, `annotation(s)`, `attitudes`, `submap`, `url`, `size`, `accelerator`, the axis-label overrides | carried | invisible on the canvas, re-emitted verbatim at the end of the file |
|
|
81
|
+
* | flow links (`+>`, `+<`, `+<>`, `+'…'>`) and a link with a `;` context | carried | not drawn — a flow is not a dependency — and re-emitted verbatim |
|
|
82
|
+
* | a `pipeline` with a `{ … }` body | carried | the block is kept whole and written back whole; the pack draws no pipeline children |
|
|
83
|
+
* | `//` comments | carried | re-emitted verbatim, at the end |
|
|
84
|
+
* | anything else the reader does not recognise | carried | re-emitted verbatim, at the end |
|
|
85
|
+
* | **nothing** | quarantined | every statement this format writes is a standalone sentence, so nothing carried can contradict the drawing (D5) |
|
|
86
|
+
* | a statement with NO coordinates (`anchor Client`) | **lost** | the reader places it at the reference default (0.9, 0.1) and says so; the export then writes the coordinates we invented, not the silence the file had |
|
|
87
|
+
* | a statement with MALFORMED coordinates | **lost** | same, and the report carries a `warning` as well as the `invented-layout` note |
|
|
88
|
+
* | a statement positioned on ONE axis (`[0.5]`, `[nonsense, 0.5]`) | **half mapped, half lost** | the axis the file gave is read and kept exactly; the other takes the reference default and gets its own `invented-layout` note naming WHICH axis was invented. The statement counts as `mapped` — it was positioned — and the export writes a full pair, so the axis the file left silent comes back as a number |
|
|
89
|
+
* | the file's own line ORDER and its blank lines | **lost** | the writer groups statements into sections. Nothing semantic depends on it, and carrying blank lines would make an untouched Labre file report a carried count |
|
|
90
|
+
* | a `pipeline`'s visibility | **lost** | OWM derives it from the component of the same name; the reader places the body under that component and the writer does not write it back |
|
|
91
|
+
* | an `evolve` twin drawn at a different height | **lost** | `evolve` moves along the evolution axis only; the export warns |
|
|
92
|
+
* | a Labre `method` node (build / buy / outsource) | **lost** | written as a plain component — OWM says a method with a decorator this writer cannot tell apart. The export warns |
|
|
93
|
+
* | the file's `title` | **carried**, and it WINS | kept under `attrs['@document'].title` and written back in preference to the name the caller passes — D3's precedence, the same one `interchange.<fmt>.id` has on every element. A board renamed in Labre therefore still exports under the title its file carried, and the export warns that it did; the caller's name is used only when the file carried none (and is still what the DOWNLOAD is called either way) |
|
|
94
|
+
* | surface identity across a re-import | **lost** | a new map beside the old one, never a merge |
|
|
95
|
+
* | a name whose whitespace matters (`Foo Bar`) | **round-trips here, at risk elsewhere** | this reader keeps a name VERBATIM and this writer gives it back verbatim, so Labre → Labre is exact. onlinewardleymaps does not: `normalizeComponentName` collapses whitespace runs before it matches a link end to a component, so a map that goes Labre → OWM → Labre may come back with its links dangling. Not sanitized here, because sanitizing would lose the author's name to protect another tool's matcher |
|
|
96
|
+
* | a top-level `market` / `ecosystem` statement | **round-trips here, refused elsewhere** | Labre reads and writes both. The reference `Converter` registers no strategy for either keyword, so such a line reaches `LinksExtractionStrategy`, has no `->` in it and is recorded as a PARSE ERROR — onlinewardleymaps does not merely fail to draw it. The interoperable spelling is a component carrying the decorator (`component Suppliers [0.3, 0.7] (market)`), which this pair already round-trips through the carried tail |
|
|
97
|
+
*
|
|
98
|
+
* `sourceVersion` reports the DIALECT, because the DSL declares no version: the
|
|
99
|
+
* OnlineWardleyMaps frontend's own extraction strategies, which is what this
|
|
100
|
+
* reader was written against. It reads `DSL (Labre)` for a file holding nothing
|
|
101
|
+
* this library does not itself write — the honest form of "a file we wrote",
|
|
102
|
+
* since the format has no marker to claim one with.
|
|
103
|
+
*/
|
|
104
|
+
/* ── The format, re-exported so both halves agree ─────────────────────── */
|
|
105
|
+
export { WARDLEY_OWM_FORMAT_ID, OWM_SCOPE };
|
|
106
|
+
/** The dialect this reader implements, and how a Labre-shaped file reads. */
|
|
107
|
+
const OWM_DIALECT = 'DSL';
|
|
108
|
+
const OWM_DIALECT_LABRE = 'DSL (Labre)';
|
|
109
|
+
/* ── Reading one line ─────────────────────────────────────────────────── */
|
|
110
|
+
/** A quoted value and what follows it. */
|
|
111
|
+
function readQuoted(raw) {
|
|
112
|
+
let index = 1;
|
|
113
|
+
let value = '';
|
|
114
|
+
while (index < raw.length) {
|
|
115
|
+
const char = raw[index];
|
|
116
|
+
if (char === '\\' && index + 1 < raw.length) {
|
|
117
|
+
value += raw[index] + raw[index + 1];
|
|
118
|
+
index += 2;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (char === '"')
|
|
122
|
+
return { value: unescapeName(value), rest: raw.slice(index + 1) };
|
|
123
|
+
value += char;
|
|
124
|
+
index += 1;
|
|
125
|
+
}
|
|
126
|
+
// No closing quote: OWM recovers rather than refusing, and so does this.
|
|
127
|
+
return { value: unescapeName(value), rest: '' };
|
|
128
|
+
}
|
|
129
|
+
/** OWM's `unescapeComponentNameFromMapText`, minus the quote stripping. */
|
|
130
|
+
function unescapeName(raw) {
|
|
131
|
+
return raw
|
|
132
|
+
.replaceAll('\\n', '\n')
|
|
133
|
+
.replaceAll('\\r', '\r')
|
|
134
|
+
.replaceAll('\\t', '\t')
|
|
135
|
+
.replaceAll('\\"', '"')
|
|
136
|
+
.replaceAll('\\]', ']')
|
|
137
|
+
.replaceAll('\\[', '[')
|
|
138
|
+
.replaceAll('\\\\', '\\');
|
|
139
|
+
}
|
|
140
|
+
/** A name (quoted or bare) and what follows it, in a keyword statement. */
|
|
141
|
+
function readName(raw) {
|
|
142
|
+
const trimmed = raw.trimStart();
|
|
143
|
+
if (trimmed.startsWith('"')) {
|
|
144
|
+
const { value, rest } = readQuoted(trimmed);
|
|
145
|
+
return { name: value, rest };
|
|
146
|
+
}
|
|
147
|
+
// Bare: the name runs up to the coordinate bracket, exactly as OWM's own
|
|
148
|
+
// `setName` splits it. A statement that opens straight on `[` has no name.
|
|
149
|
+
if (trimmed.startsWith('['))
|
|
150
|
+
return { name: '', rest: trimmed };
|
|
151
|
+
const spaced = trimmed.indexOf(' [');
|
|
152
|
+
if (spaced !== -1) {
|
|
153
|
+
return {
|
|
154
|
+
name: trimmed.slice(0, spaced).trim(),
|
|
155
|
+
rest: trimmed.slice(spaced),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// `component Kettle[0.1, 0.2]`, with no space before the bracket. OWM's own
|
|
159
|
+
// `setName` splits on `' ['` and therefore keeps `Kettle[0.1, 0.2]` whole as
|
|
160
|
+
// the name, while `extractLocation` splits on a bare `[` and reads the pair
|
|
161
|
+
// anyway — so the file IS positioned, and only the name comes out wrong.
|
|
162
|
+
// Splitting on the bare bracket maps the statement the way the coordinates
|
|
163
|
+
// say it was meant, which beats both inventing a layout and keeping OWM's
|
|
164
|
+
// own mangled name.
|
|
165
|
+
const tight = trimmed.indexOf('[');
|
|
166
|
+
if (tight !== -1) {
|
|
167
|
+
return { name: trimmed.slice(0, tight).trim(), rest: trimmed.slice(tight) };
|
|
168
|
+
}
|
|
169
|
+
return { name: trimmed.trim(), rest: '' };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* OWM's own defaults for an axis a statement did not give
|
|
173
|
+
* (`extractLocation`, `constants/extractionFunctions.ts`). Used rather than a
|
|
174
|
+
* layout of our own, so an artefact this reader places without coordinates
|
|
175
|
+
* lands where the tool that wrote the file would have drawn it.
|
|
176
|
+
*/
|
|
177
|
+
export const OWM_DEFAULT_VISIBILITY = 0.9;
|
|
178
|
+
export const OWM_DEFAULT_EVOLUTION = 0.1;
|
|
179
|
+
/**
|
|
180
|
+
* Read `[v, e]` the way `extractLocation` reads one — PER AXIS.
|
|
181
|
+
*
|
|
182
|
+
* The per-axis reading is not pedantry, it is what the reference parser does:
|
|
183
|
+
* `[0.5]` is a legal pair whose second member falls back to the default, and so
|
|
184
|
+
* is `[nonsense, 0.5]`. Reading the bracket as all-or-nothing would invent a
|
|
185
|
+
* layout for a statement the file had positioned on one axis, and D4 forbids
|
|
186
|
+
* presenting an invented axis as read from the file — so the two axes are
|
|
187
|
+
* tracked separately all the way to the note.
|
|
188
|
+
*/
|
|
189
|
+
function readBracket(raw) {
|
|
190
|
+
const match = /^\s*\[([^\]]*)\]/.exec(raw);
|
|
191
|
+
if (!match)
|
|
192
|
+
return { present: false, tail: raw };
|
|
193
|
+
const tail = raw.slice(match[0].length);
|
|
194
|
+
const [visibility, evolution] = match[1]
|
|
195
|
+
.split(',')
|
|
196
|
+
.map(part => Number.parseFloat(part.trim()))
|
|
197
|
+
.map(value => (Number.isFinite(value) ? value : undefined));
|
|
198
|
+
return { present: true, visibility, evolution, tail };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A maturity, the way OWM spells one — and it always has a decimal point.
|
|
202
|
+
*
|
|
203
|
+
* The reference regex is `/\s[0-9]?\.[0-9]+[0-9]?/` (`setNameWithMaturity`), so
|
|
204
|
+
* a bare integer is NOT a maturity there. Accepting `0` and `1` here looked
|
|
205
|
+
* harmless and was not: `evolve Tier 1 0.75` would take `1` as the maturity and
|
|
206
|
+
* `Tier` as the name, so the component the line is about is one nobody
|
|
207
|
+
* declared — the twin and its arrow then vanish on the next export. Names
|
|
208
|
+
* ending in a digit are ordinary (`Tier 1`, `Wave 0`, `Region 1`).
|
|
209
|
+
*/
|
|
210
|
+
const MATURITY = String.raw `[0-9]*\.[0-9]+`;
|
|
211
|
+
/**
|
|
212
|
+
* `<anything> <maturity>[ <tail>]`. The `.*?` is lazy, so the FIRST
|
|
213
|
+
* maturity-shaped token ends the name and everything after it is the tail —
|
|
214
|
+
* which is the reference reader's own behaviour (`element.match(...)` returns
|
|
215
|
+
* the first hit).
|
|
216
|
+
*/
|
|
217
|
+
const BEFORE_MATURITY = new RegExp(String.raw `^(.*?)(\s+${MATURITY})(\s.*)?$`);
|
|
218
|
+
const LEADING_MATURITY = new RegExp(String.raw `^\s*(${MATURITY})`);
|
|
219
|
+
/* ── The statements a document is made of ─────────────────────────────── */
|
|
220
|
+
/** The keywords that declare a positioned artefact, and the kind each is. */
|
|
221
|
+
const NODE_KEYWORDS = {
|
|
222
|
+
component: 'component',
|
|
223
|
+
anchor: 'anchor',
|
|
224
|
+
market: 'market',
|
|
225
|
+
ecosystem: 'ecosystem',
|
|
226
|
+
};
|
|
227
|
+
/* ── The reader ───────────────────────────────────────────────────────── */
|
|
228
|
+
/**
|
|
229
|
+
* An OWM document as element props, plus what became of every line.
|
|
230
|
+
*
|
|
231
|
+
* @param source the file, verbatim.
|
|
232
|
+
* @param context the caller's name for it, used only if the file names nothing.
|
|
233
|
+
*/
|
|
234
|
+
export function importWardleyOwm(source, context = {}) {
|
|
235
|
+
const lines = source.split(/\r?\n/);
|
|
236
|
+
const notes = [];
|
|
237
|
+
const carriedLines = [];
|
|
238
|
+
/** How many lines of each construct were carried — one note per kind. */
|
|
239
|
+
const carriedKinds = new Map();
|
|
240
|
+
const nodeStatements = [];
|
|
241
|
+
const pipelines = [];
|
|
242
|
+
const noteStatements = [];
|
|
243
|
+
const evolutions = [];
|
|
244
|
+
const links = [];
|
|
245
|
+
let title;
|
|
246
|
+
/** Whether a LINE of the file put it there — only that one is `mapped`. */
|
|
247
|
+
let titledByFile = false;
|
|
248
|
+
const carry = (line, kind) => {
|
|
249
|
+
carriedLines.push(line);
|
|
250
|
+
carriedKinds.set(kind, (carriedKinds.get(kind) ?? 0) + 1);
|
|
251
|
+
};
|
|
252
|
+
const inventedNote = (element, name, why) => {
|
|
253
|
+
notes.push({
|
|
254
|
+
kind: 'invented-layout',
|
|
255
|
+
sourceId: name,
|
|
256
|
+
element,
|
|
257
|
+
message: why,
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Where one positioned statement goes, and what the report owes for it (D4).
|
|
262
|
+
*
|
|
263
|
+
* Three outcomes, and the middle one is the reason this is per-axis. A
|
|
264
|
+
* statement with NO bracket is laid out whole and declared. One whose bracket
|
|
265
|
+
* this reader cannot make a single number of is the same, plus a `warning`,
|
|
266
|
+
* because the file said something and we could not read it. And one that
|
|
267
|
+
* positioned ONE axis — `[0.5]`, or `[nonsense, 0.5]`, both of which the
|
|
268
|
+
* reference parser accepts and defaults the rest of — is MAPPED at the
|
|
269
|
+
* coordinate it gave, with a note naming the axis it did not. An invented
|
|
270
|
+
* axis is never presented as read from the file, and that is true of half a
|
|
271
|
+
* pair as much as of a whole one.
|
|
272
|
+
*/
|
|
273
|
+
const placeOf = (bracket, element, name, lineIndex) => {
|
|
274
|
+
const { visibility, evolution } = bracket;
|
|
275
|
+
const place = {
|
|
276
|
+
visibility: visibility ?? OWM_DEFAULT_VISIBILITY,
|
|
277
|
+
evolution: evolution ?? OWM_DEFAULT_EVOLUTION,
|
|
278
|
+
};
|
|
279
|
+
if (visibility !== undefined && evolution !== undefined) {
|
|
280
|
+
return { ...place, invented: false };
|
|
281
|
+
}
|
|
282
|
+
if (visibility === undefined && evolution === undefined) {
|
|
283
|
+
if (bracket.present) {
|
|
284
|
+
notes.push({
|
|
285
|
+
kind: 'warning',
|
|
286
|
+
sourceId: name,
|
|
287
|
+
element,
|
|
288
|
+
message: `line ${lineIndex + 1} declares coordinates this reader cannot make a number of, so they were not used.`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
inventedNote(element, name, bracket.present
|
|
292
|
+
? 'its coordinates were unreadable, so it was placed where a map with no coordinates places things. The file did not say where it goes.'
|
|
293
|
+
: 'the file gives it no coordinates, so it was placed where a map with no coordinates places things. The file did not say where it goes.');
|
|
294
|
+
return { ...place, invented: true };
|
|
295
|
+
}
|
|
296
|
+
inventedNote(element, name, visibility === undefined
|
|
297
|
+
? 'the file positions it on the evolution axis only, so its place on the value chain is this reader’s and not the file’s.'
|
|
298
|
+
: 'the file positions it on the value chain only, so its place on the evolution axis is this reader’s and not the file’s.');
|
|
299
|
+
return { ...place, invented: false };
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Inside a `/* … */` block, where every line is a comment.
|
|
303
|
+
*
|
|
304
|
+
* The reference reader strips these in `Converter.stripComments` BEFORE any
|
|
305
|
+
* strategy sees the text, so a commented-out `component` is not a component
|
|
306
|
+
* there. Tracking the state here is what makes that true of this reader too:
|
|
307
|
+
* carrying only the opening line and then parsing the body would put an
|
|
308
|
+
* artefact on the canvas that the file's author had switched off, which is a
|
|
309
|
+
* worse failure than losing it — it is a drawing nobody made.
|
|
310
|
+
*/
|
|
311
|
+
let insideBlockComment = false;
|
|
312
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
313
|
+
const raw = lines[index];
|
|
314
|
+
const line = raw.trim();
|
|
315
|
+
if (insideBlockComment) {
|
|
316
|
+
carry(raw, 'comment');
|
|
317
|
+
if (line.includes('*/'))
|
|
318
|
+
insideBlockComment = false;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (line.length === 0)
|
|
322
|
+
continue;
|
|
323
|
+
if (line.startsWith('//')) {
|
|
324
|
+
carry(raw, 'comment');
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (line.startsWith('/*')) {
|
|
328
|
+
carry(raw, 'comment');
|
|
329
|
+
// A one-line block (`/* … */`) opens and closes on the same line.
|
|
330
|
+
if (!line.includes('*/', 2))
|
|
331
|
+
insideBlockComment = true;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
/* `title` — consumed as the board's own name. */
|
|
335
|
+
if (line.startsWith('title ')) {
|
|
336
|
+
// FIRST wins, matching OWM's own reader, which returns on the first hit.
|
|
337
|
+
if (title === undefined) {
|
|
338
|
+
title = line.slice('title '.length).trim();
|
|
339
|
+
titledByFile = true;
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
carry(raw, 'title');
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
/* The four positioned artefacts. */
|
|
346
|
+
const keyword = Object.keys(NODE_KEYWORDS).find(word => line.startsWith(`${word} `));
|
|
347
|
+
if (keyword !== undefined) {
|
|
348
|
+
const { name, rest } = readName(line.slice(keyword.length + 1));
|
|
349
|
+
const bracket = readBracket(rest);
|
|
350
|
+
const named = name || `${keyword} ${index + 1}`;
|
|
351
|
+
const place = placeOf(bracket, keyword, named, index);
|
|
352
|
+
nodeStatements.push({
|
|
353
|
+
keyword,
|
|
354
|
+
name: named,
|
|
355
|
+
visibility: place.visibility,
|
|
356
|
+
evolution: place.evolution,
|
|
357
|
+
tail: bracket.tail,
|
|
358
|
+
invented: place.invented,
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
/* `pipeline` — the plain form is drawn; a `{ … }` body is carried whole. */
|
|
363
|
+
if (line.startsWith('pipeline ')) {
|
|
364
|
+
const block = blockAfter(lines, index);
|
|
365
|
+
if (block !== undefined) {
|
|
366
|
+
for (let at = index; at <= block; at += 1)
|
|
367
|
+
carriedLines.push(lines[at]);
|
|
368
|
+
carriedKinds.set('pipeline{}', (carriedKinds.get('pipeline{}') ?? 0) + (block - index + 1));
|
|
369
|
+
index = block;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const { name, rest } = readName(line.slice('pipeline '.length));
|
|
373
|
+
const bracket = readBracket(rest);
|
|
374
|
+
// A pipeline's bracket is a SPAN, not a position, so it defaults as a
|
|
375
|
+
// span does (`setPipelineMaturity`) and only a whole one is usable: half
|
|
376
|
+
// a span is not a narrower pipeline.
|
|
377
|
+
const usable = bracket.visibility !== undefined && bracket.evolution !== undefined;
|
|
378
|
+
const named = name || `pipeline ${index + 1}`;
|
|
379
|
+
pipelines.push({
|
|
380
|
+
name: named,
|
|
381
|
+
from: usable ? bracket.visibility : 0.2,
|
|
382
|
+
to: usable ? bracket.evolution : 0.8,
|
|
383
|
+
tail: bracket.tail,
|
|
384
|
+
invented: !usable,
|
|
385
|
+
});
|
|
386
|
+
if (!usable) {
|
|
387
|
+
inventedNote('pipeline', named, 'the file gives it no span, so it was drawn across the default one. The file did not say how wide it is.');
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
/* `note` — a free text at a position. */
|
|
392
|
+
if (line.startsWith('note ')) {
|
|
393
|
+
const { name, rest } = readName(line.slice('note '.length));
|
|
394
|
+
const bracket = readBracket(rest);
|
|
395
|
+
const place = placeOf(bracket, 'note', name, index);
|
|
396
|
+
noteStatements.push({
|
|
397
|
+
text: name,
|
|
398
|
+
visibility: place.visibility,
|
|
399
|
+
evolution: place.evolution,
|
|
400
|
+
tail: bracket.tail,
|
|
401
|
+
invented: place.invented,
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
/* `evolve` — a twin, and the arrow that says it is moving. */
|
|
406
|
+
if (line.startsWith('evolve ')) {
|
|
407
|
+
const parsed = readEvolve(line.slice('evolve '.length));
|
|
408
|
+
evolutions.push(parsed);
|
|
409
|
+
if (parsed.invented) {
|
|
410
|
+
inventedNote('evolve', parsed.name, 'the line names no maturity to evolve to, so the default was used. The file did not say where it is going.');
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
/* A link, or something this reader has no sentence for. */
|
|
415
|
+
const opener = firstWord(line);
|
|
416
|
+
// A statement is never a link, whatever arrows it holds. `evolution
|
|
417
|
+
// Genesis->Custom->Product->Commodity` renames the evolution axis and would
|
|
418
|
+
// otherwise arrive as two dependencies between components nobody declared —
|
|
419
|
+
// which is why OWM's own `LinksExtractionStrategy` carries the same refusal
|
|
420
|
+
// list. The `{` / `}` of a pipeline body get the same treatment.
|
|
421
|
+
const reserved = OWM_KEYWORDS.has(opener) || line.startsWith('{') || line.startsWith('}');
|
|
422
|
+
const flow = ['+>', "+'", '+<'].find(marker => line.includes(marker));
|
|
423
|
+
if (!reserved && flow !== undefined) {
|
|
424
|
+
carry(raw, 'flow link');
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const arrow = reserved ? -1 : line.indexOf('->');
|
|
428
|
+
if (arrow >= 0) {
|
|
429
|
+
if (line.includes(';')) {
|
|
430
|
+
// A link with a context. The context is a sentence on the edge and this
|
|
431
|
+
// pack draws none, so the whole statement is kept rather than half of it.
|
|
432
|
+
carry(raw, 'link with a context');
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
links.push({
|
|
436
|
+
from: readLinkEnd(line.slice(0, arrow)),
|
|
437
|
+
to: readLinkEnd(line.slice(arrow + 2)),
|
|
438
|
+
});
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
carry(raw, opener);
|
|
442
|
+
}
|
|
443
|
+
// The caller's name for what it handed over, used ONLY when the file names
|
|
444
|
+
// nothing itself — a file that carries its own title always wins, because an
|
|
445
|
+
// import states what the file says and never what the caller wished it said.
|
|
446
|
+
// The extension goes: `tea-shop.owm` is what the file is CALLED, and the map
|
|
447
|
+
// it holds is called `tea-shop`.
|
|
448
|
+
if (title === undefined && context.name !== undefined) {
|
|
449
|
+
const named = context.name.replace(/\.(owm|wm)$/i, '').trim();
|
|
450
|
+
if (named.length > 0)
|
|
451
|
+
title = named;
|
|
452
|
+
}
|
|
453
|
+
/* ── One note per carried CONSTRUCT, never one per line ───────────── */
|
|
454
|
+
for (const [kind, count] of carriedKinds) {
|
|
455
|
+
notes.push({
|
|
456
|
+
kind: 'carried',
|
|
457
|
+
element: kind,
|
|
458
|
+
message: `${count} \`${kind}\` line${count === 1 ? '' : 's'} kept verbatim on the map and written back on the next export. Nothing on this canvas draws ${count === 1 ? 'it' : 'them'}.`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/* ── A name declared twice ────────────────────────────────────────── */
|
|
462
|
+
const declared = new Set();
|
|
463
|
+
for (const statement of nodeStatements) {
|
|
464
|
+
if (declared.has(statement.name)) {
|
|
465
|
+
notes.push({
|
|
466
|
+
kind: 'warning',
|
|
467
|
+
sourceId: statement.name,
|
|
468
|
+
element: statement.keyword,
|
|
469
|
+
message: 'this name is declared more than once. Both artefacts are on the map, and every link naming it means the first — OWM identifies a component by its name.',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
declared.add(statement.name);
|
|
473
|
+
}
|
|
474
|
+
/* ── A link naming something nobody declared ──────────────────────── */
|
|
475
|
+
/**
|
|
476
|
+
* D1's third state, applied to the one construct that can dangle.
|
|
477
|
+
*
|
|
478
|
+
* A link is the only statement in this DSL whose meaning depends on OTHER
|
|
479
|
+
* statements: it names two components by name, and a name nothing declares
|
|
480
|
+
* resolves to nothing. `materializeInterchangeImport` leaves such an endpoint
|
|
481
|
+
* exactly as the file wrote it, which keeps the DOCUMENT honest — but the
|
|
482
|
+
* connector then routes to an empty path and is INVISIBLE on the canvas. So a
|
|
483
|
+
* file with a typo in it imported as `mapped: 2, notes: []`: two artefacts
|
|
484
|
+
* drawn, one arrow silently missing, and a report claiming nothing was lost.
|
|
485
|
+
*
|
|
486
|
+
* One note per dangling END rather than per link, because a link with two of
|
|
487
|
+
* them has two problems and an architect fixing the file needs both names.
|
|
488
|
+
*/
|
|
489
|
+
for (const link of links) {
|
|
490
|
+
for (const end of [link.from, link.to]) {
|
|
491
|
+
if (end.length === 0) {
|
|
492
|
+
notes.push({
|
|
493
|
+
kind: 'warning',
|
|
494
|
+
element: 'link',
|
|
495
|
+
message: 'a link names nothing on one of its ends, so the arrow it asks for runs to no artefact and is invisible on the canvas.',
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (declared.has(end))
|
|
500
|
+
continue;
|
|
501
|
+
notes.push({
|
|
502
|
+
kind: 'warning',
|
|
503
|
+
sourceId: end,
|
|
504
|
+
element: 'link',
|
|
505
|
+
message: 'a link names this, and no statement in the file declares it. The arrow is in the document and runs to no artefact, so it is invisible on the canvas.',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/* ── Laying it out ────────────────────────────────────────────────── */
|
|
510
|
+
const plot = owmDefaultPlot();
|
|
511
|
+
const elements = [];
|
|
512
|
+
/** The place a name was declared at, for the statements that reference one. */
|
|
513
|
+
const declaredAt = new Map();
|
|
514
|
+
for (const statement of nodeStatements) {
|
|
515
|
+
// FIRST wins, matching the materializer's own rule for a duplicated name
|
|
516
|
+
// and OWM's: a pipeline or an `evolve` naming it means the first one.
|
|
517
|
+
if (!declaredAt.has(statement.name)) {
|
|
518
|
+
declaredAt.set(statement.name, {
|
|
519
|
+
visibility: statement.visibility,
|
|
520
|
+
evolution: statement.evolution,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Handles the reader mints so a composite's own wiring resolves (D3). */
|
|
525
|
+
const minted = new Set();
|
|
526
|
+
const mintHandle = (stem) => {
|
|
527
|
+
let candidate = stem;
|
|
528
|
+
let suffix = 1;
|
|
529
|
+
while (declared.has(candidate) || minted.has(candidate)) {
|
|
530
|
+
suffix += 1;
|
|
531
|
+
candidate = `${stem} #${suffix}`;
|
|
532
|
+
}
|
|
533
|
+
minted.add(candidate);
|
|
534
|
+
return candidate;
|
|
535
|
+
};
|
|
536
|
+
/* The map itself, always first, and where the document's residue rides. */
|
|
537
|
+
const mapPayload = {};
|
|
538
|
+
if (title !== undefined) {
|
|
539
|
+
mapPayload.attrs = { [OWM_SCOPE.document]: { [OWM_TITLE_ATTR]: title } };
|
|
540
|
+
}
|
|
541
|
+
if (carriedLines.length > 0) {
|
|
542
|
+
mapPayload.children = { [OWM_SCOPE.document]: carriedLines };
|
|
543
|
+
}
|
|
544
|
+
elements.push({
|
|
545
|
+
type: WARDLEY_BACKGROUND.type,
|
|
546
|
+
role: WARDLEY_BACKGROUND.role,
|
|
547
|
+
resizeEnabled: WARDLEY_BACKGROUND.geometry.resizable,
|
|
548
|
+
variant: 'classic',
|
|
549
|
+
xywh: `[0,0,${OWM_DEFAULT_MAP_WIDTH},${OWM_DEFAULT_MAP_HEIGHT}]`,
|
|
550
|
+
...(Object.keys(mapPayload).length > 0
|
|
551
|
+
? { interchange: { [WARDLEY_OWM_FORMAT_ID]: mapPayload } }
|
|
552
|
+
: {}),
|
|
553
|
+
});
|
|
554
|
+
for (const statement of nodeStatements) {
|
|
555
|
+
const [cx, cy] = owmPointOf(plot, statement.visibility, statement.evolution);
|
|
556
|
+
elements.push(...artefact(statement.keyword, statement.name, cx, cy, statement.tail, mintHandle));
|
|
557
|
+
}
|
|
558
|
+
for (const pipeline of pipelines) {
|
|
559
|
+
const at = declaredAt.get(pipeline.name);
|
|
560
|
+
if (at === undefined) {
|
|
561
|
+
inventedNote('pipeline', pipeline.name, 'no component of this name is declared, so the pipeline was drawn halfway up the value chain. OWM takes a pipeline’s height from the component it belongs to.');
|
|
562
|
+
}
|
|
563
|
+
const visibility = at?.visibility ?? 0.5;
|
|
564
|
+
const [left] = owmPointOf(plot, visibility, pipeline.from);
|
|
565
|
+
const [right, y] = owmPointOf(plot, visibility, pipeline.to);
|
|
566
|
+
// Under the component it belongs to, the way OWM draws it.
|
|
567
|
+
const top = y + NODE_SIZE;
|
|
568
|
+
const width = Math.max(right - left, 1);
|
|
569
|
+
const centre = left + width / 2;
|
|
570
|
+
elements.push({
|
|
571
|
+
type: 'wardleyNode',
|
|
572
|
+
kind: 'pipeline',
|
|
573
|
+
role: WARDLEY_ROLE.pipeline,
|
|
574
|
+
shapeType: 'rect',
|
|
575
|
+
filled: true,
|
|
576
|
+
fillColor: PIPELINE_FILL,
|
|
577
|
+
strokeColor: NODE_STROKE,
|
|
578
|
+
strokeWidth: NODE_STROKE_WIDTH,
|
|
579
|
+
shapeStyle: ShapeStyle.General,
|
|
580
|
+
roughness: 0,
|
|
581
|
+
radius: 0,
|
|
582
|
+
xywh: `[${left},${top},${width},${PIPELINE_HEIGHT}]`,
|
|
583
|
+
interchange: payload({ id: pipeline.name, tail: pipeline.tail }),
|
|
584
|
+
});
|
|
585
|
+
elements.push({
|
|
586
|
+
type: 'wardleyNode',
|
|
587
|
+
kind: 'handle',
|
|
588
|
+
role: WARDLEY_ROLE.handle,
|
|
589
|
+
shapeType: 'rect',
|
|
590
|
+
filled: true,
|
|
591
|
+
fillColor: NODE_FILL,
|
|
592
|
+
strokeColor: NODE_STROKE,
|
|
593
|
+
strokeWidth: NODE_STROKE_WIDTH,
|
|
594
|
+
shapeStyle: ShapeStyle.General,
|
|
595
|
+
roughness: 0,
|
|
596
|
+
radius: 0,
|
|
597
|
+
xywh: `[${centre - HANDLE_SIZE / 2},${top - HANDLE_SIZE / 2},${HANDLE_SIZE},${HANDLE_SIZE}]`,
|
|
598
|
+
interchange: payload({
|
|
599
|
+
id: mintHandle(`${pipeline.name} handle`),
|
|
600
|
+
element: 'pipeline',
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
elements.push(label(pipeline.name, centre - OWM_LABEL_WIDTH / 2, top - LABEL_GAP - OWM_LABEL_HEIGHT, TextAlign.Center));
|
|
604
|
+
}
|
|
605
|
+
for (const note of noteStatements) {
|
|
606
|
+
const [cx, cy] = owmPointOf(plot, note.visibility, note.evolution);
|
|
607
|
+
elements.push({
|
|
608
|
+
type: 'text',
|
|
609
|
+
text: note.text,
|
|
610
|
+
fontFamily: FontFamily.Inter,
|
|
611
|
+
fontSize: LABEL_FONT_SIZE,
|
|
612
|
+
color: NODE_STROKE,
|
|
613
|
+
textAlign: TextAlign.Left,
|
|
614
|
+
xywh: `[${cx - OWM_LABEL_WIDTH / 2},${cy - OWM_LABEL_HEIGHT / 2},${OWM_LABEL_WIDTH},${OWM_LABEL_HEIGHT}]`,
|
|
615
|
+
...(note.tail.trim().length > 0
|
|
616
|
+
? { interchange: payload({ tail: note.tail }) }
|
|
617
|
+
: {}),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/** The twin an `evolve` line draws, and the handle its arrow points at. */
|
|
621
|
+
const twinHandles = [];
|
|
622
|
+
for (const evolution of evolutions) {
|
|
623
|
+
const at = declaredAt.get(evolution.name);
|
|
624
|
+
if (at === undefined) {
|
|
625
|
+
inventedNote('evolve', evolution.name, 'no component of this name is declared, so its evolved twin was laid out halfway up the value chain. The file did not say where it sits.');
|
|
626
|
+
}
|
|
627
|
+
const visibility = at?.visibility ?? 0.5;
|
|
628
|
+
const [cx, cy] = owmPointOf(plot, visibility, evolution.evolution);
|
|
629
|
+
const handle = mintHandle(`evolve ${evolution.name}`);
|
|
630
|
+
twinHandles.push(handle);
|
|
631
|
+
elements.push({
|
|
632
|
+
type: 'wardleyNode',
|
|
633
|
+
kind: 'component',
|
|
634
|
+
role: WARDLEY_ROLE.component,
|
|
635
|
+
shapeType: 'ellipse',
|
|
636
|
+
filled: true,
|
|
637
|
+
fillColor: NODE_FILL,
|
|
638
|
+
strokeColor: WARDLEY_RED,
|
|
639
|
+
strokeWidth: 2,
|
|
640
|
+
shapeStyle: ShapeStyle.General,
|
|
641
|
+
roughness: 0,
|
|
642
|
+
xywh: `[${cx - NODE_SIZE / 2},${cy - NODE_SIZE / 2},${NODE_SIZE},${NODE_SIZE}]`,
|
|
643
|
+
// `element: 'evolve'` is what says this id is a HANDLE the reader minted
|
|
644
|
+
// so the arrow below can find its end, and not something the file said —
|
|
645
|
+
// OWM has no id for a twin, and D3 forbids inventing one that pretends
|
|
646
|
+
// otherwise. The writer never reads it: an `evolve` line is written from
|
|
647
|
+
// the arrow and the twin's own name.
|
|
648
|
+
interchange: payload({
|
|
649
|
+
id: handle,
|
|
650
|
+
element: 'evolve',
|
|
651
|
+
tail: evolution.tail,
|
|
652
|
+
}),
|
|
653
|
+
});
|
|
654
|
+
elements.push(label(evolution.becomes, cx + NODE_SIZE / 2 + LABEL_GAP, cy - OWM_LABEL_HEIGHT / 2, TextAlign.Left, WARDLEY_RED));
|
|
655
|
+
}
|
|
656
|
+
evolutions.forEach((evolution, index) => {
|
|
657
|
+
elements.push({
|
|
658
|
+
type: 'connector',
|
|
659
|
+
mode: ConnectorMode.Straight,
|
|
660
|
+
role: WARDLEY_ROLE.changeArrow,
|
|
661
|
+
stroke: WARDLEY_RED,
|
|
662
|
+
strokeStyle: StrokeStyle.Dash,
|
|
663
|
+
strokeWidth: LINK_STROKE_WIDTH,
|
|
664
|
+
frontEndpointStyle: PointStyle.None,
|
|
665
|
+
rearEndpointStyle: PointStyle.Triangle,
|
|
666
|
+
source: { id: evolution.name },
|
|
667
|
+
target: { id: twinHandles[index] },
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
for (const link of links) {
|
|
671
|
+
elements.push({
|
|
672
|
+
type: 'connector',
|
|
673
|
+
mode: ConnectorMode.Straight,
|
|
674
|
+
// The value-chain link. `source` is the CONSUMER and `target` is what it
|
|
675
|
+
// needs — the verb of `wardley:dependency` (ADR 0010), which is exactly
|
|
676
|
+
// what `A->B` says in this DSL. Never inverted.
|
|
677
|
+
role: WARDLEY_ROLE.dependency,
|
|
678
|
+
stroke: LINK_GREY,
|
|
679
|
+
strokeStyle: StrokeStyle.Solid,
|
|
680
|
+
strokeWidth: LINK_STROKE_WIDTH,
|
|
681
|
+
frontEndpointStyle: PointStyle.None,
|
|
682
|
+
rearEndpointStyle: PointStyle.None,
|
|
683
|
+
source: { id: link.from },
|
|
684
|
+
target: { id: link.to },
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
/* ── The report ───────────────────────────────────────────────────── */
|
|
688
|
+
const mapped = (titledByFile ? 1 : 0) +
|
|
689
|
+
nodeStatements.length +
|
|
690
|
+
pipelines.length +
|
|
691
|
+
noteStatements.length +
|
|
692
|
+
evolutions.length +
|
|
693
|
+
links.length;
|
|
694
|
+
return {
|
|
695
|
+
elements,
|
|
696
|
+
report: {
|
|
697
|
+
mapped,
|
|
698
|
+
carried: carriedLines.length,
|
|
699
|
+
// Nothing. Every statement this format writes is a standalone sentence,
|
|
700
|
+
// so a carried one cannot contradict the drawing — which is what D5's
|
|
701
|
+
// quarantine is FOR. Stated here rather than left to be inferred from a
|
|
702
|
+
// zero: a format with no quarantine case is a finding about the format.
|
|
703
|
+
quarantined: 0,
|
|
704
|
+
notes,
|
|
705
|
+
sourceVersion: carriedLines.length === 0 && notes.length === 0
|
|
706
|
+
? OWM_DIALECT_LABRE
|
|
707
|
+
: OWM_DIALECT,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/* ── The pieces ───────────────────────────────────────────────────────── */
|
|
712
|
+
/** The `interchange` value for an element, or `undefined` when it carries none. */
|
|
713
|
+
function payload(parts) {
|
|
714
|
+
const carried = {};
|
|
715
|
+
if (parts.id !== undefined)
|
|
716
|
+
carried.id = parts.id;
|
|
717
|
+
if (parts.element !== undefined)
|
|
718
|
+
carried.element = parts.element;
|
|
719
|
+
if (parts.tail !== undefined && parts.tail.trim().length > 0) {
|
|
720
|
+
carried.attrs = { [OWM_SCOPE.self]: { [OWM_TAIL_ATTR]: parts.tail } };
|
|
721
|
+
}
|
|
722
|
+
if (Object.keys(carried).length === 0)
|
|
723
|
+
return undefined;
|
|
724
|
+
return { [WARDLEY_OWM_FORMAT_ID]: carried };
|
|
725
|
+
}
|
|
726
|
+
/** A name, as the free text element this canvas writes one as. */
|
|
727
|
+
function label(text, x, y, textAlign, color = NODE_STROKE) {
|
|
728
|
+
return {
|
|
729
|
+
type: 'text',
|
|
730
|
+
text,
|
|
731
|
+
// The role is the whole of what tells W3 this text is a NAME and not a
|
|
732
|
+
// remark somebody wrote on the map — and it is what the writer matches on.
|
|
733
|
+
role: WARDLEY_ROLE.label,
|
|
734
|
+
color,
|
|
735
|
+
fontFamily: FontFamily.Inter,
|
|
736
|
+
fontSize: LABEL_FONT_SIZE,
|
|
737
|
+
textAlign,
|
|
738
|
+
xywh: `[${x},${y},${OWM_LABEL_WIDTH},${OWM_LABEL_HEIGHT}]`,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/** The circle (or composite) one positioned statement draws, plus its name. */
|
|
742
|
+
function artefact(keyword, name, cx, cy, tail, mintHandle) {
|
|
743
|
+
const diameter = keyword === 'anchor'
|
|
744
|
+
? OWM_ANCHOR_SIZE
|
|
745
|
+
: keyword === 'market'
|
|
746
|
+
? MARKET_SIZE
|
|
747
|
+
: keyword === 'ecosystem'
|
|
748
|
+
? ECOSYSTEM_SIZE
|
|
749
|
+
: NODE_SIZE;
|
|
750
|
+
const circle = {
|
|
751
|
+
type: 'wardleyNode',
|
|
752
|
+
kind: NODE_KEYWORDS[keyword],
|
|
753
|
+
role: WARDLEY_ROLE[NODE_KEYWORDS[keyword]],
|
|
754
|
+
shapeType: 'ellipse',
|
|
755
|
+
filled: true,
|
|
756
|
+
fillColor: NODE_FILL,
|
|
757
|
+
strokeColor: NODE_STROKE,
|
|
758
|
+
strokeWidth: NODE_STROKE_WIDTH,
|
|
759
|
+
shapeStyle: ShapeStyle.General,
|
|
760
|
+
roughness: 0,
|
|
761
|
+
xywh: `[${cx - diameter / 2},${cy - diameter / 2},${diameter},${diameter}]`,
|
|
762
|
+
interchange: payload({ id: name, tail }),
|
|
763
|
+
};
|
|
764
|
+
const named = label(name, cx + diameter / 2 + LABEL_GAP, cy - OWM_LABEL_HEIGHT / 2, TextAlign.Left);
|
|
765
|
+
if (keyword !== 'market')
|
|
766
|
+
return [circle, named];
|
|
767
|
+
// A market is a COMPOSITE on this canvas: the outer circle plus three inner
|
|
768
|
+
// dots wired into a triangle (`createWardleyMarket`). The dots and their
|
|
769
|
+
// connectors are the glyph's own wiring — they carry no role, so no rule
|
|
770
|
+
// measures them and the writer ignores them — but the connectors still have
|
|
771
|
+
// to find their ends, so each dot gets a minted handle (see `mintHandle`).
|
|
772
|
+
const ring = MARKET_DOT_RING;
|
|
773
|
+
const sin60 = Math.sqrt(3) / 2;
|
|
774
|
+
const vertices = [
|
|
775
|
+
[0, -ring],
|
|
776
|
+
[ring * sin60, ring / 2],
|
|
777
|
+
[-ring * sin60, ring / 2],
|
|
778
|
+
];
|
|
779
|
+
const handles = vertices.map((_, index) => mintHandle(`${name} market ${index + 1}`));
|
|
780
|
+
const dots = vertices.map(([dx, dy], index) => ({
|
|
781
|
+
type: 'wardleyNode',
|
|
782
|
+
kind: 'component',
|
|
783
|
+
shapeType: 'ellipse',
|
|
784
|
+
filled: true,
|
|
785
|
+
fillColor: NODE_FILL,
|
|
786
|
+
strokeColor: NODE_STROKE,
|
|
787
|
+
strokeWidth: MARKET_DOT_STROKE_WIDTH,
|
|
788
|
+
shapeStyle: ShapeStyle.General,
|
|
789
|
+
roughness: 0,
|
|
790
|
+
xywh: `[${cx + dx - MARKET_DOT_SIZE / 2},${cy + dy - MARKET_DOT_SIZE / 2},${MARKET_DOT_SIZE},${MARKET_DOT_SIZE}]`,
|
|
791
|
+
interchange: payload({ id: handles[index], element: 'market' }),
|
|
792
|
+
}));
|
|
793
|
+
const triangle = [
|
|
794
|
+
[0, 1],
|
|
795
|
+
[1, 2],
|
|
796
|
+
[2, 0],
|
|
797
|
+
].map(([from, to]) => ({
|
|
798
|
+
type: 'connector',
|
|
799
|
+
mode: ConnectorMode.Straight,
|
|
800
|
+
source: { id: handles[from] },
|
|
801
|
+
target: { id: handles[to] },
|
|
802
|
+
stroke: MARKET_LINK_COLOR,
|
|
803
|
+
strokeStyle: StrokeStyle.Solid,
|
|
804
|
+
strokeWidth: MARKET_LINK_WIDTH,
|
|
805
|
+
frontEndpointStyle: PointStyle.None,
|
|
806
|
+
rearEndpointStyle: PointStyle.None,
|
|
807
|
+
}));
|
|
808
|
+
return [circle, ...dots, ...triangle, named];
|
|
809
|
+
}
|
|
810
|
+
/** The anchor circle `templates/maps.ts` draws — a person, and larger for it. */
|
|
811
|
+
const OWM_ANCHOR_SIZE = 24;
|
|
812
|
+
/** One end of a link, unquoted the way OWM unquotes one. */
|
|
813
|
+
function readLinkEnd(raw) {
|
|
814
|
+
const trimmed = raw.trim();
|
|
815
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length > 1) {
|
|
816
|
+
return unescapeName(trimmed.slice(1, -1));
|
|
817
|
+
}
|
|
818
|
+
return trimmed;
|
|
819
|
+
}
|
|
820
|
+
/** The first word of a line, which is what names the construct it is. */
|
|
821
|
+
function firstWord(line) {
|
|
822
|
+
const match = /^[A-Za-z_][\w-]*/.exec(line);
|
|
823
|
+
return match ? match[0] : 'unknown';
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* The index of the `}` closing a `{ … }` block that opens after `from`, or
|
|
827
|
+
* `undefined` when the statement has no block.
|
|
828
|
+
*
|
|
829
|
+
* OWM's own scan is this one: from the statement, forward, until either a `{`
|
|
830
|
+
* (there is a block) or the next statement of the same keyword (there is not).
|
|
831
|
+
*/
|
|
832
|
+
function blockAfter(lines, from) {
|
|
833
|
+
for (let index = from + 1; index < lines.length; index += 1) {
|
|
834
|
+
const line = lines[index].trim();
|
|
835
|
+
if (line.length === 0)
|
|
836
|
+
continue;
|
|
837
|
+
if (!line.startsWith('{'))
|
|
838
|
+
return undefined;
|
|
839
|
+
for (let close = index; close < lines.length; close += 1) {
|
|
840
|
+
if (lines[close].trim().includes('}'))
|
|
841
|
+
return close;
|
|
842
|
+
}
|
|
843
|
+
// An unterminated block: everything after it belongs to it.
|
|
844
|
+
return lines.length - 1;
|
|
845
|
+
}
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
848
|
+
/** `evolve X [-> Y] 0.85 [tail]`, as OWM's `setNameWithMaturity` reads one. */
|
|
849
|
+
function readEvolve(raw) {
|
|
850
|
+
let rest = raw.trimStart();
|
|
851
|
+
let name;
|
|
852
|
+
if (rest.startsWith('"')) {
|
|
853
|
+
const quoted = readQuoted(rest);
|
|
854
|
+
name = quoted.value;
|
|
855
|
+
rest = quoted.rest;
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
const arrow = rest.indexOf('->');
|
|
859
|
+
if (arrow >= 0) {
|
|
860
|
+
name = rest.slice(0, arrow).trim();
|
|
861
|
+
rest = rest.slice(arrow);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
const match = BEFORE_MATURITY.exec(rest);
|
|
865
|
+
if (match) {
|
|
866
|
+
name = match[1].trim();
|
|
867
|
+
rest = (match[2] ?? '') + (match[3] ?? '');
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
name = rest.trim();
|
|
871
|
+
rest = '';
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
let becomes = name;
|
|
876
|
+
rest = rest.trimStart();
|
|
877
|
+
if (rest.startsWith('->')) {
|
|
878
|
+
rest = rest.slice(2).trimStart();
|
|
879
|
+
if (rest.startsWith('"')) {
|
|
880
|
+
const quoted = readQuoted(rest);
|
|
881
|
+
becomes = quoted.value;
|
|
882
|
+
rest = quoted.rest;
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
const match = BEFORE_MATURITY.exec(rest);
|
|
886
|
+
if (match) {
|
|
887
|
+
becomes = match[1].trim();
|
|
888
|
+
rest = (match[2] ?? '') + (match[3] ?? '');
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
becomes = rest.trim();
|
|
892
|
+
rest = '';
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const match = LEADING_MATURITY.exec(rest);
|
|
897
|
+
return {
|
|
898
|
+
name,
|
|
899
|
+
becomes: becomes || name,
|
|
900
|
+
// OWM's own default for an `evolve` that names no maturity.
|
|
901
|
+
evolution: match ? Number.parseFloat(match[1]) : 0.85,
|
|
902
|
+
tail: match ? rest.slice(match[0].length) : rest,
|
|
903
|
+
invented: !match,
|
|
904
|
+
};
|
|
905
|
+
}
|