@formicoidea/labre-framework-wardley 0.34.1 → 0.35.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/dist/actions.d.ts +9 -31
- package/dist/actions.js +23 -98
- package/dist/commands-manifest.js +2 -2
- package/dist/commands.js +2 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -0
- package/dist/morph.d.ts +129 -0
- package/dist/morph.js +405 -0
- package/dist/presets.d.ts +130 -0
- package/dist/presets.js +230 -0
- package/dist/roles.js +18 -7
- package/dist/view.js +30 -2
- package/package.json +2 -2
package/dist/actions.d.ts
CHANGED
|
@@ -2,41 +2,20 @@ import { WardleyBackgroundElementModel, type WardleyBgVariant } from '@formicoid
|
|
|
2
2
|
import type { BlockStdScope } from '@formicoidea/labre-core/std';
|
|
3
3
|
import { type GfxController } from '@formicoidea/labre-core/std/gfx';
|
|
4
4
|
import { type WardleyExportBoard } from './export.js';
|
|
5
|
+
import { type WardleyArtefactKind } from './presets.js';
|
|
5
6
|
/**
|
|
6
7
|
* The single-circle node flavours: one connectable ellipse + a label to its
|
|
7
8
|
* right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
|
|
8
9
|
* method inner circle) is drawn by the node renderer from `kind`.
|
|
10
|
+
*
|
|
11
|
+
* A subset of {@link WardleyArtefactKind}, and named away from
|
|
12
|
+
* `WardleyNodeKind` on purpose: the model declares a type of that name with
|
|
13
|
+
* SEVEN values, and two homonyms of different cardinality — one of them the
|
|
14
|
+
* source of the semantic vocabulary — is a trap. The market and the pipeline
|
|
15
|
+
* are composites with creation functions of their own; the handle is not an
|
|
16
|
+
* artefact at all.
|
|
9
17
|
*/
|
|
10
|
-
|
|
11
|
-
readonly component: {
|
|
12
|
-
readonly d: 18;
|
|
13
|
-
readonly fill: "#ffffff";
|
|
14
|
-
readonly label: string;
|
|
15
|
-
};
|
|
16
|
-
readonly anchor: {
|
|
17
|
-
readonly d: 18;
|
|
18
|
-
readonly fill: "#ffffff";
|
|
19
|
-
readonly label: string;
|
|
20
|
-
};
|
|
21
|
-
readonly ecosystem: {
|
|
22
|
-
readonly d: 40;
|
|
23
|
-
readonly fill: "#ffffff";
|
|
24
|
-
readonly label: "Ecosystem";
|
|
25
|
-
};
|
|
26
|
-
readonly method: {
|
|
27
|
-
readonly d: 35;
|
|
28
|
-
readonly fill: "#d9d9d9";
|
|
29
|
-
readonly label: "Component";
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
|
-
/**
|
|
33
|
-
* The subset of {@link WardleyNodeKind} that the single-circle presets above
|
|
34
|
-
* cover. Renamed away from `WardleyNodeKind` on purpose: the model declares a
|
|
35
|
-
* type of that name with SEVEN values, and two homonyms of different
|
|
36
|
-
* cardinality — one of them now the source of the semantic vocabulary — is a
|
|
37
|
-
* trap. `WARDLEY_ROLE[kind]` below only compiles while this stays a subset.
|
|
38
|
-
*/
|
|
39
|
-
export type WardleySingleCircleKind = keyof typeof NODE_PRESETS;
|
|
18
|
+
export type WardleySingleCircleKind = Exclude<WardleyArtefactKind, 'market' | 'pipeline'>;
|
|
40
19
|
/** Create a wardley map background of the given variant, viewport-centered. */
|
|
41
20
|
export declare function createWardleyBackground(gfx: GfxController, variant?: WardleyBgVariant): void;
|
|
42
21
|
/**
|
|
@@ -114,4 +93,3 @@ export declare function exportOwmFile(std: BlockStdScope): void;
|
|
|
114
93
|
* the capability handed to it.
|
|
115
94
|
*/
|
|
116
95
|
export declare function importOwmFile(std: BlockStdScope): Promise<void>;
|
|
117
|
-
export {};
|
package/dist/actions.js
CHANGED
|
@@ -10,7 +10,8 @@ import { WARDLEY_BACKGROUND } from './background.js';
|
|
|
10
10
|
import { WARDLEY_SVG_IMPORT } from './interchange.js';
|
|
11
11
|
import { wardleyBoardFrom, wardleySafeFilename, } from './export.js';
|
|
12
12
|
import { WARDLEY_OWM_EXPORT, WARDLEY_OWM_IMPORT } from './interchange.js';
|
|
13
|
-
import {
|
|
13
|
+
import { HANDLE_SIZE, INERTIA_COLOR, INERTIA_SIZE, LABEL_FONT_SIZE, LABEL_GAP, LINK_GREY, LINK_STROKE_WIDTH, NODE_STROKE, WARDLEY_RED, } from './node/consts.js';
|
|
14
|
+
import { wardleyCanonicalBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, WARDLEY_NODE_SIZE, wardleyNodeProps, } from './presets.js';
|
|
14
15
|
import { WARDLEY_ROLE } from './roles.js';
|
|
15
16
|
/**
|
|
16
17
|
* Standalone creation/activation actions for the Wardley toolbox — the
|
|
@@ -63,20 +64,6 @@ const BACKGROUND_VARIANT_DEFAULTS = {
|
|
|
63
64
|
};
|
|
64
65
|
/** Height of the native free-text labels (Inter, size 18). */
|
|
65
66
|
const LABEL_H = LABEL_FONT_SIZE + 8;
|
|
66
|
-
/**
|
|
67
|
-
* The single-circle node flavours: one connectable ellipse + a label to its
|
|
68
|
-
* right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
|
|
69
|
-
* method inner circle) is drawn by the node renderer from `kind`.
|
|
70
|
-
*/
|
|
71
|
-
const NODE_PRESETS = {
|
|
72
|
-
component: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.component },
|
|
73
|
-
anchor: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.anchor },
|
|
74
|
-
// Ecosystem: glyph = double border + hatched donut; connectors attach to
|
|
75
|
-
// this outer circle's center.
|
|
76
|
-
ecosystem: { d: ECOSYSTEM_SIZE, fill: NODE_FILL, label: ECOSYSTEM_LABEL },
|
|
77
|
-
// Method: the FILL color encodes the chosen method (editable).
|
|
78
|
-
method: { d: METHOD_SIZE, fill: METHOD_FILL, label: METHOD_LABEL },
|
|
79
|
-
};
|
|
80
67
|
function finish(gfx, id) {
|
|
81
68
|
gfx.doc.captureSync();
|
|
82
69
|
gfx.tool.setTool(DefaultTool);
|
|
@@ -91,28 +78,15 @@ function group(gfx, ids) {
|
|
|
91
78
|
});
|
|
92
79
|
return result.groupId || ids[0];
|
|
93
80
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
kind,
|
|
104
|
-
// Semantic identity (PF1): posted next to `kind`, which stays untouched
|
|
105
|
-
// and keeps driving the rendering.
|
|
106
|
-
role: neutral ? undefined : WARDLEY_ROLE[kind],
|
|
107
|
-
shapeType: 'ellipse',
|
|
108
|
-
filled: true,
|
|
109
|
-
fillColor,
|
|
110
|
-
strokeColor: NODE_STROKE,
|
|
111
|
-
strokeWidth,
|
|
112
|
-
shapeStyle: ShapeStyle.General,
|
|
113
|
-
roughness: 0,
|
|
114
|
-
xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
|
|
115
|
-
});
|
|
81
|
+
/**
|
|
82
|
+
* Add a wardley node of one kind, at its canonical size, centred on (cx, cy).
|
|
83
|
+
*
|
|
84
|
+
* The props are {@link wardleyNodeProps}' and nothing else: the palette and the
|
|
85
|
+
* "Change type" dropdown read that one description, so a market drawn here and
|
|
86
|
+
* a component morphed into one are the same element (`presets.ts`).
|
|
87
|
+
*/
|
|
88
|
+
function addNode(surface, kind, cx, cy) {
|
|
89
|
+
return surface.addElement(wardleyNodeProps(kind, { xywh: wardleyCanonicalBox(kind, cx, cy) }));
|
|
116
90
|
}
|
|
117
91
|
/** Add a native free-text label (same Inter family as the axis labels). */
|
|
118
92
|
function addLabel(surface, text, x, y, textAlign = 'left') {
|
|
@@ -171,10 +145,10 @@ export function createWardleyNode(gfx, kind) {
|
|
|
171
145
|
const surface = gfx.surface;
|
|
172
146
|
if (!surface)
|
|
173
147
|
return;
|
|
174
|
-
const {
|
|
148
|
+
const { w } = WARDLEY_NODE_SIZE[kind];
|
|
175
149
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
176
|
-
const nodeId =
|
|
177
|
-
const labelId = addLabel(surface,
|
|
150
|
+
const nodeId = addNode(surface, kind, cx, cy);
|
|
151
|
+
const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind], cx + w / 2 + LABEL_GAP, cy - LABEL_H / 2);
|
|
178
152
|
finish(gfx, group(gfx, [nodeId, labelId]));
|
|
179
153
|
}
|
|
180
154
|
/** Create an inertia bar (filled black rect). */
|
|
@@ -213,43 +187,15 @@ export function createWardleyPipeline(gfx) {
|
|
|
213
187
|
if (!gfx.surface)
|
|
214
188
|
return;
|
|
215
189
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
216
|
-
const W = PIPELINE_WIDTH;
|
|
217
|
-
const H = PIPELINE_HEIGHT;
|
|
218
190
|
const d = HANDLE_SIZE;
|
|
219
|
-
const top = cy -
|
|
191
|
+
const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
|
|
220
192
|
// Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
|
|
221
|
-
const bodyId = gfx.surface
|
|
222
|
-
type: 'wardleyNode',
|
|
223
|
-
kind: 'pipeline',
|
|
224
|
-
role: WARDLEY_ROLE.pipeline,
|
|
225
|
-
shapeType: 'rect',
|
|
226
|
-
filled: true,
|
|
227
|
-
fillColor: PIPELINE_FILL,
|
|
228
|
-
strokeColor: NODE_STROKE,
|
|
229
|
-
strokeWidth: NODE_STROKE_WIDTH,
|
|
230
|
-
shapeStyle: ShapeStyle.General,
|
|
231
|
-
roughness: 0,
|
|
232
|
-
radius: 0,
|
|
233
|
-
xywh: new Bound(cx - W / 2, top, W, H).serialize(),
|
|
234
|
-
});
|
|
193
|
+
const bodyId = addNode(gfx.surface, 'pipeline', cx, cy);
|
|
235
194
|
// Handle: a node-sized WardleyNode square straddling the top edge. Inherits
|
|
236
195
|
// `centerAnchorOnly` so connectors attach to its center only.
|
|
237
|
-
const handleId = gfx.surface.addElement({
|
|
238
|
-
type: 'wardleyNode',
|
|
239
|
-
kind: 'handle',
|
|
240
|
-
role: WARDLEY_ROLE.handle,
|
|
241
|
-
shapeType: 'rect',
|
|
242
|
-
filled: true,
|
|
243
|
-
fillColor: NODE_FILL,
|
|
244
|
-
strokeColor: NODE_STROKE,
|
|
245
|
-
strokeWidth: NODE_STROKE_WIDTH,
|
|
246
|
-
shapeStyle: ShapeStyle.General,
|
|
247
|
-
roughness: 0,
|
|
248
|
-
radius: 0,
|
|
249
|
-
xywh: new Bound(cx - d / 2, top - d / 2, d, d).serialize(),
|
|
250
|
-
});
|
|
196
|
+
const handleId = gfx.surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
|
|
251
197
|
// Label centered horizontally on the pipeline, sitting ABOVE the handle.
|
|
252
|
-
const labelId = addLabel(gfx.surface,
|
|
198
|
+
const labelId = addLabel(gfx.surface, WARDLEY_NODE_LABEL.pipeline, cx - 60, top - d / 2 - LABEL_H - LABEL_GAP, 'center');
|
|
253
199
|
// Nested groups: (handle + label), then (body + that group).
|
|
254
200
|
const innerId = group(gfx, [handleId, labelId]);
|
|
255
201
|
finish(gfx, group(gfx, [bodyId, innerId]));
|
|
@@ -266,35 +212,14 @@ export function createWardleyMarket(gfx) {
|
|
|
266
212
|
if (!surface)
|
|
267
213
|
return;
|
|
268
214
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
269
|
-
const R =
|
|
270
|
-
const rho = MARKET_DOT_RING;
|
|
271
|
-
const sin60 = Math.sqrt(3) / 2;
|
|
215
|
+
const R = WARDLEY_NODE_SIZE.market.w / 2;
|
|
272
216
|
// Outer circle = the market node (connectable, center-only).
|
|
273
|
-
const circleId =
|
|
217
|
+
const circleId = addNode(surface, 'market', cx, cy);
|
|
274
218
|
// 3 inner component nodes (thick border, no label) at the triangle vertices.
|
|
275
|
-
const
|
|
276
|
-
[0, -rho],
|
|
277
|
-
[rho * sin60, rho / 2],
|
|
278
|
-
[-rho * sin60, rho / 2],
|
|
279
|
-
];
|
|
280
|
-
const dotIds = verts.map(([vx, vy]) => addEllipseNode(surface, 'component', cx + vx, cy + vy, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH, true));
|
|
219
|
+
const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
|
|
281
220
|
// Triangle: 3 attached connectors (auto-route center-to-center, clipped).
|
|
282
|
-
const connIds = [
|
|
283
|
-
|
|
284
|
-
[dotIds[1], dotIds[2]],
|
|
285
|
-
[dotIds[2], dotIds[0]],
|
|
286
|
-
].map(([a, b]) => surface.addElement({
|
|
287
|
-
type: 'connector',
|
|
288
|
-
mode: ConnectorMode.Straight,
|
|
289
|
-
source: { id: a },
|
|
290
|
-
target: { id: b },
|
|
291
|
-
stroke: MARKET_LINK_COLOR,
|
|
292
|
-
strokeStyle: StrokeStyle.Solid,
|
|
293
|
-
strokeWidth: MARKET_LINK_WIDTH,
|
|
294
|
-
frontEndpointStyle: PointStyle.None,
|
|
295
|
-
rearEndpointStyle: PointStyle.None,
|
|
296
|
-
}));
|
|
297
|
-
const labelId = addLabel(surface, MARKET_LABEL, cx + R + LABEL_GAP, cy - LABEL_H / 2);
|
|
221
|
+
const connIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
|
|
222
|
+
const labelId = addLabel(surface, WARDLEY_NODE_LABEL.market, cx + R + LABEL_GAP, cy - LABEL_H / 2);
|
|
298
223
|
finish(gfx, group(gfx, [circleId, ...dotIds, ...connIds, labelId]));
|
|
299
224
|
}
|
|
300
225
|
/**
|
|
@@ -85,7 +85,7 @@ export const wardleyCommandsManifest = [
|
|
|
85
85
|
labelKey: 'com.labre.commands.wardley.addAnchor',
|
|
86
86
|
labelFallback: 'Anchor',
|
|
87
87
|
scope: 'edgeless',
|
|
88
|
-
defaultKeys: { mac: [], other: [] },
|
|
88
|
+
defaultKeys: { mac: ['w', 'a'], other: ['w', 'a'] },
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
id: 'wardley.addPipeline',
|
|
@@ -109,7 +109,7 @@ export const wardleyCommandsManifest = [
|
|
|
109
109
|
labelKey: 'com.labre.keyboardShortcuts.wardley.evolutionArrow',
|
|
110
110
|
labelFallback: 'Arrow (evolution)',
|
|
111
111
|
scope: 'edgeless',
|
|
112
|
-
defaultKeys: { mac: ['w', '
|
|
112
|
+
defaultKeys: { mac: ['w', 'e'], other: ['w', 'e'] },
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
id: 'wardley.addInertia',
|
package/dist/commands.js
CHANGED
|
@@ -84,6 +84,7 @@ const SPECS = [
|
|
|
84
84
|
{
|
|
85
85
|
id: 'addAnchor',
|
|
86
86
|
label: 'Anchor',
|
|
87
|
+
key: 'a',
|
|
87
88
|
iconKey: 'wardley.anchor',
|
|
88
89
|
category: 'nodes',
|
|
89
90
|
kind: 'artefact',
|
|
@@ -117,7 +118,7 @@ const SPECS = [
|
|
|
117
118
|
id: 'evolutionArrow',
|
|
118
119
|
label: 'Arrow (evolution)',
|
|
119
120
|
labelKey: 'com.labre.keyboardShortcuts.wardley.evolutionArrow',
|
|
120
|
-
key: '
|
|
121
|
+
key: 'e',
|
|
121
122
|
iconKey: 'wardley.arrow',
|
|
122
123
|
category: 'connectors',
|
|
123
124
|
kind: 'tool',
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { WARDLEY_PROFILES } from './profiles.js';
|
|
|
7
7
|
export { WARDLEY_NUDGES } from './nudges.js';
|
|
8
8
|
export { WARDLEY_NAMING_CONVENTIONS, WARDLEY_READING } from './reading.js';
|
|
9
9
|
export { WARDLEY_RULES } from './rules.js';
|
|
10
|
+
export { type WardleyArtefactKind, wardleyCanonicalBox, wardleyCenteredBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, WARDLEY_NODE_SIZE, wardleyMorphClears, wardleyMorphProps, wardleyNodeProps, } from './presets.js';
|
|
11
|
+
export { WARDLEY_MORPH_FAMILIES, WARDLEY_MORPH_SPEC, type WardleyMorphKind, wardleyMorphComposite, wardleyMorphedLabel, wardleyNodeOfComponent, } from './morph.js';
|
|
10
12
|
/**
|
|
11
13
|
* The map's own frame, declared. Public because a host that lays elements out
|
|
12
14
|
* against the plot — labre-mcp does, and so does anything that generates a map
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,16 @@ export { WARDLEY_PROFILES } from './profiles.js';
|
|
|
7
7
|
export { WARDLEY_NUDGES } from './nudges.js';
|
|
8
8
|
export { WARDLEY_NAMING_CONVENTIONS, WARDLEY_READING } from './reading.js';
|
|
9
9
|
export { WARDLEY_RULES } from './rules.js';
|
|
10
|
+
// What a Wardley artefact is BORN as, shared by the palette and — since the
|
|
11
|
+
// morph — by the toolbar that says one artefact differently, so the two can
|
|
12
|
+
// never disagree about what a market looks like. The sizes are the notation
|
|
13
|
+
// itself, which is why a host laying out a map without an editor needs them.
|
|
14
|
+
export { wardleyCanonicalBox, wardleyCenteredBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, WARDLEY_NODE_SIZE, wardleyMorphClears, wardleyMorphProps, wardleyNodeProps, } from './presets.js';
|
|
15
|
+
// What an artefact may BECOME: the declared family, the spec the generic morph
|
|
16
|
+
// module is registered with, the resolution from a selected group to the node
|
|
17
|
+
// inside it, and the two rules a composite morph owes its own parts. Data a
|
|
18
|
+
// host can read without an editor.
|
|
19
|
+
export { WARDLEY_MORPH_FAMILIES, WARDLEY_MORPH_SPEC, wardleyMorphComposite, wardleyMorphedLabel, wardleyNodeOfComponent, } from './morph.js';
|
|
10
20
|
/**
|
|
11
21
|
* The map's own frame, declared. Public because a host that lays elements out
|
|
12
22
|
* against the plot — labre-mcp does, and so does anything that generates a map
|
package/dist/morph.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type MorphSpec } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { WardleyNodeElementModel } from '@formicoidea/labre-core/model';
|
|
3
|
+
import type { GfxPrimitiveElementModel } from '@formicoidea/labre-core/std/gfx';
|
|
4
|
+
/**
|
|
5
|
+
* What a Wardley artefact may BECOME — the declaration behind the "Change type"
|
|
6
|
+
* dropdown on a selected component's contextual toolbar.
|
|
7
|
+
*
|
|
8
|
+
* ## One family, and why it is exactly these four
|
|
9
|
+
*
|
|
10
|
+
* A component, a market, an ecosystem and a pipeline are four ways of saying
|
|
11
|
+
* the SAME thing: something the value chain depends on. The map's own reading
|
|
12
|
+
* agrees — `roles.ts` makes `market` and `ecosystem` children of `component` in
|
|
13
|
+
* the role tree — and the modelling gesture the morph exists for is the one
|
|
14
|
+
* every mapper makes: you draw a component, you look at the map, and you
|
|
15
|
+
* realise that what you drew is a market of many suppliers, or a whole
|
|
16
|
+
* ecosystem, or a pipeline of choices. Today that costs a delete, a re-draw,
|
|
17
|
+
* every dependency re-attached and the name typed again.
|
|
18
|
+
*
|
|
19
|
+
* The family is DATA and nothing derives it — the same call BPMN and C4 make.
|
|
20
|
+
* Deriving it from `roleIsA` would have been wrong in both directions here: it
|
|
21
|
+
* would reach from market to component and not back, and it would say nothing
|
|
22
|
+
* at all about the pipeline, which is not a specialisation of anything and is
|
|
23
|
+
* still the fourth way of drawing the same claim.
|
|
24
|
+
*
|
|
25
|
+
* ## The two kinds deliberately left out
|
|
26
|
+
*
|
|
27
|
+
* - **`anchor`** is the user, and the need they have. It is a role of its own
|
|
28
|
+
* precisely because it is NOT a component (`roles.ts`): it is what the value
|
|
29
|
+
* chain hangs from, not a link in it. A menu offering "turn this user into a
|
|
30
|
+
* pipeline" would invite a map with no anchor at all — the one thing a
|
|
31
|
+
* Wardley map cannot be read without.
|
|
32
|
+
* - **`method`** is not an artefact but an annotation ON one: the coloured ring
|
|
33
|
+
* says how a component is being BUILT (buy / build / outsource), and its fill
|
|
34
|
+
* encodes the choice. Morphing it away would silently discard that decision,
|
|
35
|
+
* and morphing into it would have to invent one.
|
|
36
|
+
*
|
|
37
|
+
* `handle` is in no family either, and does not need excluding by name: it is
|
|
38
|
+
* not an artefact at all, so nothing selects it and {@link wardleyNodeOfComponent}
|
|
39
|
+
* never resolves to one.
|
|
40
|
+
*/
|
|
41
|
+
export declare const WARDLEY_MORPH_FAMILIES: readonly (readonly WardleyMorphKind[])[];
|
|
42
|
+
/** The kinds this framework offers as morph targets. */
|
|
43
|
+
export type WardleyMorphKind = 'component' | 'market' | 'ecosystem' | 'pipeline';
|
|
44
|
+
/**
|
|
45
|
+
* The `wardleyNode` a selected GROUP is the artefact of — `undefined` when the
|
|
46
|
+
* group is not one.
|
|
47
|
+
*
|
|
48
|
+
* Every Wardley artefact in this family is a composite: a component is a circle
|
|
49
|
+
* and its label, a market adds three dots and a triangle, and a pipeline is a
|
|
50
|
+
* body plus a handle plus a label in a nested group. So what a click selects is
|
|
51
|
+
* the outer group, and what carries `kind` is one node inside it — RECURSIVELY
|
|
52
|
+
* inside it, because the pipeline's own creation nests.
|
|
53
|
+
*
|
|
54
|
+
* Three conditions, and each one excludes a piece of the notation rather than
|
|
55
|
+
* an accident:
|
|
56
|
+
*
|
|
57
|
+
* - a `WardleyNodeElementModel`, which leaves out the labels and the connectors;
|
|
58
|
+
* - with a ROLE, which leaves out the market's three neutral dots — they are the
|
|
59
|
+
* glyph's wiring and carry none by construction (`presets.ts`);
|
|
60
|
+
* - whose kind is in the family, which leaves out the pipeline's `handle` and
|
|
61
|
+
* any `anchor` or `method` somebody grouped in beside the artefact.
|
|
62
|
+
*
|
|
63
|
+
* TWO candidates is a REFUSAL rather than a first-wins pick, exactly as C4's
|
|
64
|
+
* resolution refuses a group of two components: morphing "it" would mean
|
|
65
|
+
* picking one by document order, and the honest answer to an ambiguous
|
|
66
|
+
* selection is nothing at all. A plain lasso round three shapes, a C4 component
|
|
67
|
+
* and a group of two Wardley components all answer `undefined`, so none of them
|
|
68
|
+
* is ever offered the menu.
|
|
69
|
+
*/
|
|
70
|
+
export declare function wardleyNodeOfComponent(model: GfxPrimitiveElementModel): WardleyNodeElementModel | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The name an artefact should carry once it has morphed — or `null` when the
|
|
73
|
+
* name is the AUTHOR's and must not be touched.
|
|
74
|
+
*
|
|
75
|
+
* Exactly one case rewrites: the label is the source kind's own creation
|
|
76
|
+
* prompt, letter for letter, which is what an artefact nobody has named still
|
|
77
|
+
* says. Everything else — a name typed over it, the prompt with a word added, a
|
|
78
|
+
* cleared label, another kind's prompt — is content, and content survives a
|
|
79
|
+
* morph untouched. The same timid rule `c4MorphedTitle` states, and for the same
|
|
80
|
+
* reason: a morph may not take away words somebody put on the picture.
|
|
81
|
+
*
|
|
82
|
+
* Pure and total over every string: the input is a canvas text element somebody
|
|
83
|
+
* may have typed anything into.
|
|
84
|
+
*/
|
|
85
|
+
export declare function wardleyMorphedLabel(from: WardleyMorphKind, to: WardleyMorphKind, rawText: string | null | undefined): string | null;
|
|
86
|
+
/**
|
|
87
|
+
* Everything the morph owes a Wardley composite beyond the props patch — the
|
|
88
|
+
* `afterMorph` half of the spec, run inside the generic module's one
|
|
89
|
+
* `captureSync` so all of it is a single ctrl+z.
|
|
90
|
+
*
|
|
91
|
+
* ## The geometry, and why this framework writes it
|
|
92
|
+
*
|
|
93
|
+
* A DEVIATION from BPMN and C4, decided by the PO: those two keep the box the
|
|
94
|
+
* user gave the element, because there a size is a preference. Here it is the
|
|
95
|
+
* NOTATION. A market is drawn bigger than a component so a reader can tell them
|
|
96
|
+
* apart at a glance, an ecosystem bigger again, and a pipeline is a 120-wide bar
|
|
97
|
+
* — a market left at a component's 18 pixels is an unreadable smudge with three
|
|
98
|
+
* dots crammed into it. So the target's canonical size is applied, CENTRED on
|
|
99
|
+
* where the artefact already stands: the thing does not move, it becomes the
|
|
100
|
+
* size its notation says it is. The label keeps its own place, which is the
|
|
101
|
+
* user's.
|
|
102
|
+
*
|
|
103
|
+
* ## And why the rest is here rather than in the patch
|
|
104
|
+
*
|
|
105
|
+
* Because a market IS its three dots and a pipeline IS its handle. A patch can
|
|
106
|
+
* write `kind: 'market'` onto a circle and the result is a circle that claims to
|
|
107
|
+
* be a market and does not look like one; a patch cannot create an element, nor
|
|
108
|
+
* move a connector somebody drew. That is exactly what `afterMorph` is for, and
|
|
109
|
+
* the ORDER below is the whole of the care it needs: the glyph the artefact is
|
|
110
|
+
* LEAVING comes apart before the one it is arriving at is built, so a
|
|
111
|
+
* market → pipeline never has three dots and a handle at once.
|
|
112
|
+
*/
|
|
113
|
+
export declare function wardleyMorphComposite(selected: GfxPrimitiveElementModel, from: WardleyMorphKind, to: WardleyMorphKind): void;
|
|
114
|
+
/**
|
|
115
|
+
* Wardley's morph declaration, handed to the generic `morphToolbarConfig`.
|
|
116
|
+
*
|
|
117
|
+
* `modelType` is the native `GroupElementModel` and not
|
|
118
|
+
* `WardleyNodeElementModel`, because every artefact in this family is a
|
|
119
|
+
* composite and the group is what a click on one selects. Everything that makes
|
|
120
|
+
* that safe is in {@link wardleyNodeOfComponent}: the toolbar's own homogeneity
|
|
121
|
+
* test only proves that each selected element is A group, and the resolution
|
|
122
|
+
* proves that each one is one of OURS.
|
|
123
|
+
*
|
|
124
|
+
* `propsOf` is the shipped creation builder minus `type` / `xywh` / `text`
|
|
125
|
+
* ({@link wardleyMorphProps}), so the palette and the morph cannot disagree
|
|
126
|
+
* about what a pipeline looks like, and `clearOf` removes the keys the target
|
|
127
|
+
* does not write — `radius`, which only the pipeline has.
|
|
128
|
+
*/
|
|
129
|
+
export declare const WARDLEY_MORPH_SPEC: MorphSpec<WardleyMorphKind>;
|
package/dist/morph.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { morphLabel, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { ConnectorElementModel, GroupElementModel, TextElementModel, WardleyNodeElementModel, } from '@formicoidea/labre-core/model';
|
|
3
|
+
import { wardleyCommandIcons, wardleyCommands } from './commands.js';
|
|
4
|
+
import { wardleyCanonicalBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, wardleyMorphClears, wardleyMorphProps, } from './presets.js';
|
|
5
|
+
import { WARDLEY_ROLE } from './roles.js';
|
|
6
|
+
/**
|
|
7
|
+
* What a Wardley artefact may BECOME — the declaration behind the "Change type"
|
|
8
|
+
* dropdown on a selected component's contextual toolbar.
|
|
9
|
+
*
|
|
10
|
+
* ## One family, and why it is exactly these four
|
|
11
|
+
*
|
|
12
|
+
* A component, a market, an ecosystem and a pipeline are four ways of saying
|
|
13
|
+
* the SAME thing: something the value chain depends on. The map's own reading
|
|
14
|
+
* agrees — `roles.ts` makes `market` and `ecosystem` children of `component` in
|
|
15
|
+
* the role tree — and the modelling gesture the morph exists for is the one
|
|
16
|
+
* every mapper makes: you draw a component, you look at the map, and you
|
|
17
|
+
* realise that what you drew is a market of many suppliers, or a whole
|
|
18
|
+
* ecosystem, or a pipeline of choices. Today that costs a delete, a re-draw,
|
|
19
|
+
* every dependency re-attached and the name typed again.
|
|
20
|
+
*
|
|
21
|
+
* The family is DATA and nothing derives it — the same call BPMN and C4 make.
|
|
22
|
+
* Deriving it from `roleIsA` would have been wrong in both directions here: it
|
|
23
|
+
* would reach from market to component and not back, and it would say nothing
|
|
24
|
+
* at all about the pipeline, which is not a specialisation of anything and is
|
|
25
|
+
* still the fourth way of drawing the same claim.
|
|
26
|
+
*
|
|
27
|
+
* ## The two kinds deliberately left out
|
|
28
|
+
*
|
|
29
|
+
* - **`anchor`** is the user, and the need they have. It is a role of its own
|
|
30
|
+
* precisely because it is NOT a component (`roles.ts`): it is what the value
|
|
31
|
+
* chain hangs from, not a link in it. A menu offering "turn this user into a
|
|
32
|
+
* pipeline" would invite a map with no anchor at all — the one thing a
|
|
33
|
+
* Wardley map cannot be read without.
|
|
34
|
+
* - **`method`** is not an artefact but an annotation ON one: the coloured ring
|
|
35
|
+
* says how a component is being BUILT (buy / build / outsource), and its fill
|
|
36
|
+
* encodes the choice. Morphing it away would silently discard that decision,
|
|
37
|
+
* and morphing into it would have to invent one.
|
|
38
|
+
*
|
|
39
|
+
* `handle` is in no family either, and does not need excluding by name: it is
|
|
40
|
+
* not an artefact at all, so nothing selects it and {@link wardleyNodeOfComponent}
|
|
41
|
+
* never resolves to one.
|
|
42
|
+
*/
|
|
43
|
+
export const WARDLEY_MORPH_FAMILIES = [
|
|
44
|
+
// Declaration order is menu order, and it opens on the plain component: the
|
|
45
|
+
// undecorated artefact is the honest first draft, and the three others are
|
|
46
|
+
// refinements of it — the same call `commands.ts` makes about the senior row.
|
|
47
|
+
['component', 'market', 'ecosystem', 'pipeline'],
|
|
48
|
+
];
|
|
49
|
+
/** The same set, as a lookup — the gate every resolution below runs through. */
|
|
50
|
+
const MORPH_KINDS = new Set(WARDLEY_MORPH_FAMILIES.flat());
|
|
51
|
+
/**
|
|
52
|
+
* The creation command that draws each kind, keyed BY that kind.
|
|
53
|
+
*
|
|
54
|
+
* Derived from `telemetry.element`, which is where the kind is already written
|
|
55
|
+
* down (`node:market`) and is documented as a historical value that must not be
|
|
56
|
+
* renamed. Deriving rather than restating is what stops a second table of
|
|
57
|
+
* labels and icons drifting from the one the sub-menu and the catalogue read.
|
|
58
|
+
*/
|
|
59
|
+
const NODE_COMMANDS = new Map(wardleyCommands.flatMap(command => {
|
|
60
|
+
const element = command.telemetry?.element;
|
|
61
|
+
return element?.startsWith('node:')
|
|
62
|
+
? [[element.slice('node:'.length), command]]
|
|
63
|
+
: [];
|
|
64
|
+
}));
|
|
65
|
+
/** A kind's wording: the creation command's own key and English. */
|
|
66
|
+
function labelOf(kind) {
|
|
67
|
+
const command = NODE_COMMANDS.get(kind);
|
|
68
|
+
return {
|
|
69
|
+
key: command?.labelKey,
|
|
70
|
+
fallback: command?.labelFallback ?? kind,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** A kind's icon: the creation command's own, reused rather than redrawn. */
|
|
74
|
+
function iconOf(kind) {
|
|
75
|
+
const iconKey = NODE_COMMANDS.get(kind)?.iconKey;
|
|
76
|
+
return ((iconKey && wardleyCommandIcons[iconKey]) ||
|
|
77
|
+
wardleyCommandIcons['wardley.component']);
|
|
78
|
+
}
|
|
79
|
+
/* ── Resolving the composite ───────────────────────────────────────────── */
|
|
80
|
+
/**
|
|
81
|
+
* Every element under a group, however deeply nested.
|
|
82
|
+
*
|
|
83
|
+
* Recursive because the pipeline's own creation site nests — (handle + label),
|
|
84
|
+
* then (body + that) — so a flat pass over `childElements` would miss half of
|
|
85
|
+
* one of the four artefacts this file is about.
|
|
86
|
+
*/
|
|
87
|
+
function* descendants(group) {
|
|
88
|
+
for (const child of group.childElements) {
|
|
89
|
+
yield child;
|
|
90
|
+
if (child instanceof GroupElementModel)
|
|
91
|
+
yield* descendants(child);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The `wardleyNode` a selected GROUP is the artefact of — `undefined` when the
|
|
96
|
+
* group is not one.
|
|
97
|
+
*
|
|
98
|
+
* Every Wardley artefact in this family is a composite: a component is a circle
|
|
99
|
+
* and its label, a market adds three dots and a triangle, and a pipeline is a
|
|
100
|
+
* body plus a handle plus a label in a nested group. So what a click selects is
|
|
101
|
+
* the outer group, and what carries `kind` is one node inside it — RECURSIVELY
|
|
102
|
+
* inside it, because the pipeline's own creation nests.
|
|
103
|
+
*
|
|
104
|
+
* Three conditions, and each one excludes a piece of the notation rather than
|
|
105
|
+
* an accident:
|
|
106
|
+
*
|
|
107
|
+
* - a `WardleyNodeElementModel`, which leaves out the labels and the connectors;
|
|
108
|
+
* - with a ROLE, which leaves out the market's three neutral dots — they are the
|
|
109
|
+
* glyph's wiring and carry none by construction (`presets.ts`);
|
|
110
|
+
* - whose kind is in the family, which leaves out the pipeline's `handle` and
|
|
111
|
+
* any `anchor` or `method` somebody grouped in beside the artefact.
|
|
112
|
+
*
|
|
113
|
+
* TWO candidates is a REFUSAL rather than a first-wins pick, exactly as C4's
|
|
114
|
+
* resolution refuses a group of two components: morphing "it" would mean
|
|
115
|
+
* picking one by document order, and the honest answer to an ambiguous
|
|
116
|
+
* selection is nothing at all. A plain lasso round three shapes, a C4 component
|
|
117
|
+
* and a group of two Wardley components all answer `undefined`, so none of them
|
|
118
|
+
* is ever offered the menu.
|
|
119
|
+
*/
|
|
120
|
+
export function wardleyNodeOfComponent(model) {
|
|
121
|
+
if (!(model instanceof GroupElementModel))
|
|
122
|
+
return undefined;
|
|
123
|
+
let found;
|
|
124
|
+
for (const child of descendants(model)) {
|
|
125
|
+
if (!(child instanceof WardleyNodeElementModel))
|
|
126
|
+
continue;
|
|
127
|
+
if (child.role === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
if (!MORPH_KINDS.has(child.kind))
|
|
130
|
+
continue;
|
|
131
|
+
if (found)
|
|
132
|
+
return undefined;
|
|
133
|
+
found = child;
|
|
134
|
+
}
|
|
135
|
+
return found;
|
|
136
|
+
}
|
|
137
|
+
/* ── The structural half of the morph ──────────────────────────────────── */
|
|
138
|
+
/**
|
|
139
|
+
* The name an artefact should carry once it has morphed — or `null` when the
|
|
140
|
+
* name is the AUTHOR's and must not be touched.
|
|
141
|
+
*
|
|
142
|
+
* Exactly one case rewrites: the label is the source kind's own creation
|
|
143
|
+
* prompt, letter for letter, which is what an artefact nobody has named still
|
|
144
|
+
* says. Everything else — a name typed over it, the prompt with a word added, a
|
|
145
|
+
* cleared label, another kind's prompt — is content, and content survives a
|
|
146
|
+
* morph untouched. The same timid rule `c4MorphedTitle` states, and for the same
|
|
147
|
+
* reason: a morph may not take away words somebody put on the picture.
|
|
148
|
+
*
|
|
149
|
+
* Pure and total over every string: the input is a canvas text element somebody
|
|
150
|
+
* may have typed anything into.
|
|
151
|
+
*/
|
|
152
|
+
export function wardleyMorphedLabel(from, to, rawText) {
|
|
153
|
+
return (rawText ?? '').trim() === WARDLEY_NODE_LABEL[from]
|
|
154
|
+
? WARDLEY_NODE_LABEL[to]
|
|
155
|
+
: null;
|
|
156
|
+
}
|
|
157
|
+
/** The centre of an element, from the box it currently occupies. */
|
|
158
|
+
function centreOf(model) {
|
|
159
|
+
const [x, y, w, h] = model.deserializedXYWH;
|
|
160
|
+
return [x + w / 2, y + h / 2];
|
|
161
|
+
}
|
|
162
|
+
/** The label of a composite — `undefined` when there is not exactly one. */
|
|
163
|
+
function labelOfComposite(group) {
|
|
164
|
+
let found;
|
|
165
|
+
for (const child of descendants(group)) {
|
|
166
|
+
if (!(child instanceof TextElementModel))
|
|
167
|
+
continue;
|
|
168
|
+
if (child.role !== WARDLEY_ROLE.label)
|
|
169
|
+
continue;
|
|
170
|
+
if (found)
|
|
171
|
+
return undefined;
|
|
172
|
+
found = child;
|
|
173
|
+
}
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Point every connector that named `fromId` at `toId` instead.
|
|
178
|
+
*
|
|
179
|
+
* The whole point of morphing rather than re-drawing: a component wired into
|
|
180
|
+
* half a value chain becomes a pipeline and keeps every dependency it had. The
|
|
181
|
+
* endpoint is rewritten to the bare `{ id }` and the `position` is dropped on
|
|
182
|
+
* purpose — a position is stored RELATIVE to the element it names, so carrying
|
|
183
|
+
* one across to a different box would land the arrow somewhere nobody chose.
|
|
184
|
+
* Every Wardley node is `centerAnchorOnly` anyway, so a connector this framework
|
|
185
|
+
* drew never had one.
|
|
186
|
+
*/
|
|
187
|
+
function reanchor(surface, fromId, toId) {
|
|
188
|
+
for (const element of surface.elementModels) {
|
|
189
|
+
if (!(element instanceof ConnectorElementModel))
|
|
190
|
+
continue;
|
|
191
|
+
if (element.isLocked())
|
|
192
|
+
continue;
|
|
193
|
+
if (element.source?.id === fromId) {
|
|
194
|
+
surface.updateElement(element.id, { source: { id: toId } });
|
|
195
|
+
}
|
|
196
|
+
if (element.target?.id === fromId) {
|
|
197
|
+
surface.updateElement(element.id, { target: { id: toId } });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Take the market's glyph apart: the three neutral dots, and the triangle
|
|
203
|
+
* wiring them.
|
|
204
|
+
*
|
|
205
|
+
* Identified the way {@link wardleyNodeOfComponent} identifies what it must NOT
|
|
206
|
+
* resolve to — a `wardleyNode` with no role — and the connectors by the fact
|
|
207
|
+
* that both their ends name one of those dots. Never by document order, and
|
|
208
|
+
* never by "the connectors in this group": a dependency the user drew from
|
|
209
|
+
* their market to something else is a child of nothing and must survive, and one
|
|
210
|
+
* they drew INTO the composite still has one end outside it.
|
|
211
|
+
*/
|
|
212
|
+
function removeMarketGlyph(group) {
|
|
213
|
+
const surface = group.surface;
|
|
214
|
+
const dots = [...descendants(group)].filter((child) => child instanceof WardleyNodeElementModel && child.role === undefined);
|
|
215
|
+
const dotIds = new Set(dots.map(dot => dot.id));
|
|
216
|
+
for (const child of [...descendants(group)]) {
|
|
217
|
+
if (!(child instanceof ConnectorElementModel))
|
|
218
|
+
continue;
|
|
219
|
+
const { id: sourceId } = child.source ?? {};
|
|
220
|
+
const { id: targetId } = child.target ?? {};
|
|
221
|
+
if (sourceId === undefined || targetId === undefined)
|
|
222
|
+
continue;
|
|
223
|
+
if (!dotIds.has(sourceId) || !dotIds.has(targetId))
|
|
224
|
+
continue;
|
|
225
|
+
surface.deleteElement(child.id);
|
|
226
|
+
}
|
|
227
|
+
for (const dot of dots)
|
|
228
|
+
surface.deleteElement(dot.id);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Build the market's glyph round a centre: three dots and the triangle between
|
|
232
|
+
* them, from the very same constants `createWardleyMarket` uses.
|
|
233
|
+
*
|
|
234
|
+
* Added to the group AFTER the circle they sit in, which is also the painting
|
|
235
|
+
* order the creation site produces — a market drawn from the sub-menu and one
|
|
236
|
+
* morphed into being are the same stack of elements.
|
|
237
|
+
*/
|
|
238
|
+
function addMarketGlyph(group, cx, cy) {
|
|
239
|
+
const surface = group.surface;
|
|
240
|
+
const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
|
|
241
|
+
const linkIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
|
|
242
|
+
for (const id of [...dotIds, ...linkIds]) {
|
|
243
|
+
const element = surface.getElementById(id);
|
|
244
|
+
if (element)
|
|
245
|
+
group.addChild(element);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Give a pipeline the handle it is connected through, and move the user's own
|
|
250
|
+
* links onto it.
|
|
251
|
+
*
|
|
252
|
+
* ## The handle lands FLAT in the outer group — a documented simplification
|
|
253
|
+
*
|
|
254
|
+
* `createWardleyPipeline` nests: it groups (handle + label), then groups that
|
|
255
|
+
* with the body. Nothing depends on the nesting — the handle is found by kind
|
|
256
|
+
* and the label by role, both recursively — and a morph that rebuilt it would
|
|
257
|
+
* have to move the user's label into a group it never asked for. So the morph
|
|
258
|
+
* adds the handle as a sibling of the body. A pipeline morphed into being is
|
|
259
|
+
* therefore ONE group of three elements where a drawn one is a group of two;
|
|
260
|
+
* they behave identically on the canvas, in the exporter and under every rule,
|
|
261
|
+
* and the reverse morph flattens a drawn one the same way.
|
|
262
|
+
*/
|
|
263
|
+
function addHandle(group, carrier, cx, cy) {
|
|
264
|
+
const surface = group.surface;
|
|
265
|
+
const handleId = surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
|
|
266
|
+
const handle = surface.getElementById(handleId);
|
|
267
|
+
if (handle)
|
|
268
|
+
group.addChild(handle);
|
|
269
|
+
// A pipeline body declares `connectable === false`, so every link the user
|
|
270
|
+
// drew to the component has to move to the handle or it would point at
|
|
271
|
+
// something that no longer accepts it.
|
|
272
|
+
reanchor(surface, carrier.id, handleId);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Take the handle away, and give the user's links back to the body.
|
|
276
|
+
*
|
|
277
|
+
* The mirror of {@link addHandle}, plus the one piece of tidying the nesting
|
|
278
|
+
* makes necessary: a pipeline DRAWN from the sub-menu keeps its label in an
|
|
279
|
+
* inner group with the handle, and removing the handle would leave a group of
|
|
280
|
+
* one behind. The label is promoted into the outer group and the empty wrapper
|
|
281
|
+
* deleted, which is what leaves a morphed artefact looking exactly like a drawn
|
|
282
|
+
* one of its new kind.
|
|
283
|
+
*/
|
|
284
|
+
function removeHandle(group, carrier) {
|
|
285
|
+
const surface = group.surface;
|
|
286
|
+
const handle = [...descendants(group)].find((child) => child instanceof WardleyNodeElementModel && child.kind === 'handle');
|
|
287
|
+
if (!handle)
|
|
288
|
+
return;
|
|
289
|
+
// Before the deletion, never after: the links have to name something.
|
|
290
|
+
reanchor(surface, handle.id, carrier.id);
|
|
291
|
+
const inner = handle.group;
|
|
292
|
+
surface.deleteElement(handle.id);
|
|
293
|
+
if (!(inner instanceof GroupElementModel) || inner === group)
|
|
294
|
+
return;
|
|
295
|
+
// Emptied first, so `deleteElement` — which deletes a group's children with
|
|
296
|
+
// it — has nothing left to take.
|
|
297
|
+
for (const orphan of [...inner.childElements]) {
|
|
298
|
+
inner.removeChild(orphan);
|
|
299
|
+
group.addChild(orphan);
|
|
300
|
+
}
|
|
301
|
+
surface.deleteElement(inner.id);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Keep the artefact's own name saying what the artefact now is.
|
|
305
|
+
*
|
|
306
|
+
* The one exception to "the words and the placement are the user's": the
|
|
307
|
+
* placeholder rule of {@link wardleyMorphedLabel}. In place and in one
|
|
308
|
+
* transaction, because the `Y.Text` instance is what a bound editor holds, and
|
|
309
|
+
* inside the caller's `captureSync`, so the rewrite is part of the same single
|
|
310
|
+
* ctrl+z as the kind that made it necessary.
|
|
311
|
+
*/
|
|
312
|
+
function rewriteLabel(group, from, to) {
|
|
313
|
+
const label = labelOfComposite(group);
|
|
314
|
+
if (!label || label.isLocked())
|
|
315
|
+
return;
|
|
316
|
+
const text = label.text.toString().trim();
|
|
317
|
+
const next = wardleyMorphedLabel(from, to, text);
|
|
318
|
+
if (next === null || next === text)
|
|
319
|
+
return;
|
|
320
|
+
label.surface.store.transact(() => {
|
|
321
|
+
label.text.delete(0, label.text.length);
|
|
322
|
+
label.text.insert(0, next);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Everything the morph owes a Wardley composite beyond the props patch — the
|
|
327
|
+
* `afterMorph` half of the spec, run inside the generic module's one
|
|
328
|
+
* `captureSync` so all of it is a single ctrl+z.
|
|
329
|
+
*
|
|
330
|
+
* ## The geometry, and why this framework writes it
|
|
331
|
+
*
|
|
332
|
+
* A DEVIATION from BPMN and C4, decided by the PO: those two keep the box the
|
|
333
|
+
* user gave the element, because there a size is a preference. Here it is the
|
|
334
|
+
* NOTATION. A market is drawn bigger than a component so a reader can tell them
|
|
335
|
+
* apart at a glance, an ecosystem bigger again, and a pipeline is a 120-wide bar
|
|
336
|
+
* — a market left at a component's 18 pixels is an unreadable smudge with three
|
|
337
|
+
* dots crammed into it. So the target's canonical size is applied, CENTRED on
|
|
338
|
+
* where the artefact already stands: the thing does not move, it becomes the
|
|
339
|
+
* size its notation says it is. The label keeps its own place, which is the
|
|
340
|
+
* user's.
|
|
341
|
+
*
|
|
342
|
+
* ## And why the rest is here rather than in the patch
|
|
343
|
+
*
|
|
344
|
+
* Because a market IS its three dots and a pipeline IS its handle. A patch can
|
|
345
|
+
* write `kind: 'market'` onto a circle and the result is a circle that claims to
|
|
346
|
+
* be a market and does not look like one; a patch cannot create an element, nor
|
|
347
|
+
* move a connector somebody drew. That is exactly what `afterMorph` is for, and
|
|
348
|
+
* the ORDER below is the whole of the care it needs: the glyph the artefact is
|
|
349
|
+
* LEAVING comes apart before the one it is arriving at is built, so a
|
|
350
|
+
* market → pipeline never has three dots and a handle at once.
|
|
351
|
+
*/
|
|
352
|
+
export function wardleyMorphComposite(selected, from, to) {
|
|
353
|
+
if (!(selected instanceof GroupElementModel))
|
|
354
|
+
return;
|
|
355
|
+
const carrier = wardleyNodeOfComponent(selected);
|
|
356
|
+
if (!carrier)
|
|
357
|
+
return;
|
|
358
|
+
const [cx, cy] = centreOf(carrier);
|
|
359
|
+
carrier.surface.updateElement(carrier.id, {
|
|
360
|
+
xywh: wardleyCanonicalBox(to, cx, cy),
|
|
361
|
+
});
|
|
362
|
+
if (from === 'market')
|
|
363
|
+
removeMarketGlyph(selected);
|
|
364
|
+
if (from === 'pipeline')
|
|
365
|
+
removeHandle(selected, carrier);
|
|
366
|
+
if (to === 'market')
|
|
367
|
+
addMarketGlyph(selected, cx, cy);
|
|
368
|
+
if (to === 'pipeline')
|
|
369
|
+
addHandle(selected, carrier, cx, cy);
|
|
370
|
+
rewriteLabel(selected, from, to);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Wardley's morph declaration, handed to the generic `morphToolbarConfig`.
|
|
374
|
+
*
|
|
375
|
+
* `modelType` is the native `GroupElementModel` and not
|
|
376
|
+
* `WardleyNodeElementModel`, because every artefact in this family is a
|
|
377
|
+
* composite and the group is what a click on one selects. Everything that makes
|
|
378
|
+
* that safe is in {@link wardleyNodeOfComponent}: the toolbar's own homogeneity
|
|
379
|
+
* test only proves that each selected element is A group, and the resolution
|
|
380
|
+
* proves that each one is one of OURS.
|
|
381
|
+
*
|
|
382
|
+
* `propsOf` is the shipped creation builder minus `type` / `xywh` / `text`
|
|
383
|
+
* ({@link wardleyMorphProps}), so the palette and the morph cannot disagree
|
|
384
|
+
* about what a pipeline looks like, and `clearOf` removes the keys the target
|
|
385
|
+
* does not write — `radius`, which only the pipeline has.
|
|
386
|
+
*/
|
|
387
|
+
export const WARDLEY_MORPH_SPEC = {
|
|
388
|
+
framework: 'wardley',
|
|
389
|
+
families: WARDLEY_MORPH_FAMILIES,
|
|
390
|
+
modelType: GroupElementModel,
|
|
391
|
+
resolveTarget: wardleyNodeOfComponent,
|
|
392
|
+
kindOf: (model) => model instanceof WardleyNodeElementModel && MORPH_KINDS.has(model.kind)
|
|
393
|
+
? model.kind
|
|
394
|
+
: undefined,
|
|
395
|
+
// The NODE's role, which is the only role a Wardley artefact has: the group
|
|
396
|
+
// carries none, the label carries `wardley:label` (which says it is a name,
|
|
397
|
+
// not what it names) and the market's dots carry none at all.
|
|
398
|
+
roleOf: kind => WARDLEY_ROLE[kind],
|
|
399
|
+
propsOf: wardleyMorphProps,
|
|
400
|
+
clearOf: wardleyMorphClears,
|
|
401
|
+
afterMorph: wardleyMorphComposite,
|
|
402
|
+
labelOf,
|
|
403
|
+
iconOf,
|
|
404
|
+
label: morphLabel('com.labre.morph.toolbar.label', 'Change type'),
|
|
405
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type WardleyNodeKind } from '@formicoidea/labre-core/model';
|
|
2
|
+
/**
|
|
3
|
+
* What a Wardley artefact IS, as props — the ONE description the creation sites
|
|
4
|
+
* and the morph both read.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `actions.ts` when the morph landed, for the reason C4's and
|
|
7
|
+
* BPMN's `presets.ts` give: a kind's appearance is written by the preset of the
|
|
8
|
+
* kind it was CREATED as and nothing else ever rewrites it, so a morph that
|
|
9
|
+
* restated the table would agree with the palette the day it was written and
|
|
10
|
+
* drift on the first restyle. Derived, they cannot — a component morphed into a
|
|
11
|
+
* market and one drawn from the sub-menu are the same element.
|
|
12
|
+
*
|
|
13
|
+
* The three pieces that are not artefacts live here too, and for the same
|
|
14
|
+
* reason: the pipeline's HANDLE, the market's three neutral DOTS and the
|
|
15
|
+
* triangle that wires them are glyph parts, and the morph has to be able to
|
|
16
|
+
* build and unbuild them exactly as `createWardleyMarket` /
|
|
17
|
+
* `createWardleyPipeline` build them once.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Every kind a user can draw — {@link WardleyNodeKind} minus the `handle`.
|
|
21
|
+
*
|
|
22
|
+
* The handle is not an artefact: it is the square that straddles a pipeline's
|
|
23
|
+
* top edge so connectors have somewhere to land (the body itself declares
|
|
24
|
+
* `connectable === false`). Nobody picks one from the palette, nothing may morph
|
|
25
|
+
* into one, and {@link wardleyHandleProps} is how the two composites that need
|
|
26
|
+
* one ask for it.
|
|
27
|
+
*/
|
|
28
|
+
export type WardleyArtefactKind = Exclude<WardleyNodeKind, 'handle'>;
|
|
29
|
+
/**
|
|
30
|
+
* The size a kind is BORN at, which for Wardley is the notation rather than a
|
|
31
|
+
* default.
|
|
32
|
+
*
|
|
33
|
+
* A market is 30 across and a component is 18 because that is what tells a
|
|
34
|
+
* reader which is which — the two are the same white circle otherwise. Same for
|
|
35
|
+
* the ecosystem's 40 and the pipeline's 120×25 bar. This is the table the morph
|
|
36
|
+
* re-centres an artefact on, and the one the palette draws from.
|
|
37
|
+
*/
|
|
38
|
+
export declare const WARDLEY_NODE_SIZE: Record<WardleyArtefactKind, {
|
|
39
|
+
w: number;
|
|
40
|
+
h: number;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* The words a kind is created NEXT TO — the prompt an artefact nobody has named
|
|
44
|
+
* still carries, and the only string a morph is ever allowed to rewrite.
|
|
45
|
+
*/
|
|
46
|
+
export declare const WARDLEY_NODE_LABEL: Record<WardleyArtefactKind, string>;
|
|
47
|
+
/** A box centred on a point — how every Wardley creation site places a node. */
|
|
48
|
+
export declare function wardleyCenteredBox(cx: number, cy: number, w: number, h: number): string;
|
|
49
|
+
/** The canonical box of one kind, centred where the artefact already stands. */
|
|
50
|
+
export declare function wardleyCanonicalBox(kind: WardleyArtefactKind, cx: number, cy: number): string;
|
|
51
|
+
/**
|
|
52
|
+
* Every prop a Wardley node is created with, for one kind and one box.
|
|
53
|
+
*
|
|
54
|
+
* Five of the six are a native ELLIPSE and differ only in fill and size: the
|
|
55
|
+
* glyph — the anchor's silhouette, the ecosystem's hatched donut, the method's
|
|
56
|
+
* inner circle — is drawn by the renderer from `kind`, off this same model, so
|
|
57
|
+
* stroke and fill stay editable from the shape toolbar exactly like any other
|
|
58
|
+
* shape's. The pipeline is the exception and the reason `clearOf` exists on this
|
|
59
|
+
* pack: it is a RECT, and it is the only kind that writes `radius`.
|
|
60
|
+
*
|
|
61
|
+
* No `text`: a Wardley label is a separate free-text element grouped with the
|
|
62
|
+
* node (see `addLabel` in `actions.ts`), never words stored on the shape.
|
|
63
|
+
*/
|
|
64
|
+
export declare function wardleyNodeProps(kind: WardleyArtefactKind, box: {
|
|
65
|
+
xywh: string;
|
|
66
|
+
}): Record<string, unknown> & {
|
|
67
|
+
type: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The pipeline's handle: a node-sized square straddling the body's top edge, and
|
|
71
|
+
* the only place a connector may land on a pipeline.
|
|
72
|
+
*
|
|
73
|
+
* It inherits `centerAnchorOnly` from the model like every other Wardley node,
|
|
74
|
+
* and it carries `wardley:handle` — a role of its own, so nothing that judges
|
|
75
|
+
* components ever judges it.
|
|
76
|
+
*/
|
|
77
|
+
export declare function wardleyHandleProps(box: {
|
|
78
|
+
xywh: string;
|
|
79
|
+
}): Record<string, unknown> & {
|
|
80
|
+
type: string;
|
|
81
|
+
};
|
|
82
|
+
/** Where a handle sits, given the body's centre: astride the top edge. */
|
|
83
|
+
export declare function wardleyHandleBox(cx: number, cy: number): string;
|
|
84
|
+
/**
|
|
85
|
+
* One of the market's three inner dots — thicker-bordered, and deliberately
|
|
86
|
+
* ROLE-LESS.
|
|
87
|
+
*
|
|
88
|
+
* They are the GLYPH's own wiring rather than artefacts the user placed, the
|
|
89
|
+
* same reason the triangle between them carries no role: they sit inside the
|
|
90
|
+
* market circle by construction, so a role would make every market composite
|
|
91
|
+
* report an overlap with itself (W3).
|
|
92
|
+
*/
|
|
93
|
+
export declare function wardleyMarketDotProps(box: {
|
|
94
|
+
xywh: string;
|
|
95
|
+
}): Record<string, unknown> & {
|
|
96
|
+
type: string;
|
|
97
|
+
};
|
|
98
|
+
/** The three dot boxes of a market centred on (cx, cy) — an upright triangle. */
|
|
99
|
+
export declare function wardleyMarketDotBoxes(cx: number, cy: number): string[];
|
|
100
|
+
/**
|
|
101
|
+
* One side of the market's triangle: an ATTACHED connector, so it auto-routes
|
|
102
|
+
* centre to centre and follows the dots on move and resize.
|
|
103
|
+
*/
|
|
104
|
+
export declare function wardleyMarketLinkProps(sourceId: string, targetId: string): Record<string, unknown> & {
|
|
105
|
+
type: string;
|
|
106
|
+
};
|
|
107
|
+
/** The pairs the triangle wires, given the three dots in creation order. */
|
|
108
|
+
export declare function wardleyMarketLinkPairs<T>(dots: readonly T[]): [T, T][];
|
|
109
|
+
/**
|
|
110
|
+
* What a kind is worth to a node that ALREADY EXISTS — {@link wardleyNodeProps}
|
|
111
|
+
* minus the three things a morph has no business touching.
|
|
112
|
+
*
|
|
113
|
+
* The whole preset and not `{kind, role}`, and on this pack it is visibly not a
|
|
114
|
+
* formality: a pipeline is a rect with square corners and a semi-transparent
|
|
115
|
+
* fill, and the other three members of the family are opaque white ellipses. A
|
|
116
|
+
* two-key patch would turn a component into a "pipeline" still drawn as a
|
|
117
|
+
* circle.
|
|
118
|
+
*/
|
|
119
|
+
export declare function wardleyMorphProps(kind: WardleyArtefactKind): Record<string, unknown>;
|
|
120
|
+
/**
|
|
121
|
+
* The fields to DELETE after morphing to `kind` — the keys some other kind
|
|
122
|
+
* writes and this one does not.
|
|
123
|
+
*
|
|
124
|
+
* `radius` for every kind but the pipeline, and that is not a formality either:
|
|
125
|
+
* a patch cannot express absence, so a pipeline morphed back to a component
|
|
126
|
+
* would keep `radius: 0` sitting in the Y.Map, silently in force on an ellipse.
|
|
127
|
+
* Derived rather than listed, so the day a preset gains a conditional key it is
|
|
128
|
+
* covered without anybody remembering to come here.
|
|
129
|
+
*/
|
|
130
|
+
export declare function wardleyMorphClears(kind: WardleyArtefactKind): readonly string[];
|
package/dist/presets.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { ConnectorMode, PointStyle, ShapeStyle, StrokeStyle, } from '@formicoidea/labre-core/model';
|
|
2
|
+
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
3
|
+
import { ECOSYSTEM_LABEL, ECOSYSTEM_SIZE, HANDLE_SIZE, LABEL_DEFAULT, MARKET_DOT_RING, MARKET_DOT_SIZE, MARKET_DOT_STROKE_WIDTH, MARKET_LABEL, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, MARKET_SIZE, METHOD_FILL, METHOD_LABEL, METHOD_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PIPELINE_HEIGHT, PIPELINE_LABEL, PIPELINE_WIDTH, } from './node/consts.js';
|
|
4
|
+
import { WARDLEY_ROLE } from './roles.js';
|
|
5
|
+
/**
|
|
6
|
+
* The size a kind is BORN at, which for Wardley is the notation rather than a
|
|
7
|
+
* default.
|
|
8
|
+
*
|
|
9
|
+
* A market is 30 across and a component is 18 because that is what tells a
|
|
10
|
+
* reader which is which — the two are the same white circle otherwise. Same for
|
|
11
|
+
* the ecosystem's 40 and the pipeline's 120×25 bar. This is the table the morph
|
|
12
|
+
* re-centres an artefact on, and the one the palette draws from.
|
|
13
|
+
*/
|
|
14
|
+
export const WARDLEY_NODE_SIZE = {
|
|
15
|
+
component: { w: NODE_SIZE, h: NODE_SIZE },
|
|
16
|
+
anchor: { w: NODE_SIZE, h: NODE_SIZE },
|
|
17
|
+
ecosystem: { w: ECOSYSTEM_SIZE, h: ECOSYSTEM_SIZE },
|
|
18
|
+
method: { w: METHOD_SIZE, h: METHOD_SIZE },
|
|
19
|
+
market: { w: MARKET_SIZE, h: MARKET_SIZE },
|
|
20
|
+
pipeline: { w: PIPELINE_WIDTH, h: PIPELINE_HEIGHT },
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The words a kind is created NEXT TO — the prompt an artefact nobody has named
|
|
24
|
+
* still carries, and the only string a morph is ever allowed to rewrite.
|
|
25
|
+
*/
|
|
26
|
+
export const WARDLEY_NODE_LABEL = {
|
|
27
|
+
component: LABEL_DEFAULT.component,
|
|
28
|
+
anchor: LABEL_DEFAULT.anchor,
|
|
29
|
+
ecosystem: ECOSYSTEM_LABEL,
|
|
30
|
+
method: METHOD_LABEL,
|
|
31
|
+
market: MARKET_LABEL,
|
|
32
|
+
pipeline: PIPELINE_LABEL,
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The fill a kind is born with. White for every circle that means "a thing on
|
|
36
|
+
* the value chain"; the method's grey is the one that carries meaning — it
|
|
37
|
+
* ENCODES the chosen method and stays editable from the toolbar.
|
|
38
|
+
*/
|
|
39
|
+
const NODE_FILL_OF = {
|
|
40
|
+
component: NODE_FILL,
|
|
41
|
+
anchor: NODE_FILL,
|
|
42
|
+
ecosystem: NODE_FILL,
|
|
43
|
+
method: METHOD_FILL,
|
|
44
|
+
market: NODE_FILL,
|
|
45
|
+
// White at ~60% opacity: a pipeline is a bar you place components ON, so the
|
|
46
|
+
// map has to stay visible through it. The 1px border stays opaque.
|
|
47
|
+
pipeline: PIPELINE_FILL,
|
|
48
|
+
};
|
|
49
|
+
/** A box centred on a point — how every Wardley creation site places a node. */
|
|
50
|
+
export function wardleyCenteredBox(cx, cy, w, h) {
|
|
51
|
+
return new Bound(cx - w / 2, cy - h / 2, w, h).serialize();
|
|
52
|
+
}
|
|
53
|
+
/** The canonical box of one kind, centred where the artefact already stands. */
|
|
54
|
+
export function wardleyCanonicalBox(kind, cx, cy) {
|
|
55
|
+
const { w, h } = WARDLEY_NODE_SIZE[kind];
|
|
56
|
+
return wardleyCenteredBox(cx, cy, w, h);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Every prop a Wardley node is created with, for one kind and one box.
|
|
60
|
+
*
|
|
61
|
+
* Five of the six are a native ELLIPSE and differ only in fill and size: the
|
|
62
|
+
* glyph — the anchor's silhouette, the ecosystem's hatched donut, the method's
|
|
63
|
+
* inner circle — is drawn by the renderer from `kind`, off this same model, so
|
|
64
|
+
* stroke and fill stay editable from the shape toolbar exactly like any other
|
|
65
|
+
* shape's. The pipeline is the exception and the reason `clearOf` exists on this
|
|
66
|
+
* pack: it is a RECT, and it is the only kind that writes `radius`.
|
|
67
|
+
*
|
|
68
|
+
* No `text`: a Wardley label is a separate free-text element grouped with the
|
|
69
|
+
* node (see `addLabel` in `actions.ts`), never words stored on the shape.
|
|
70
|
+
*/
|
|
71
|
+
export function wardleyNodeProps(kind, box) {
|
|
72
|
+
const rect = kind === 'pipeline';
|
|
73
|
+
return {
|
|
74
|
+
type: 'wardleyNode',
|
|
75
|
+
kind,
|
|
76
|
+
// Semantic identity (PF1): posted next to `kind`, which stays untouched and
|
|
77
|
+
// keeps driving the rendering. The role is what every rule reads — no rule
|
|
78
|
+
// will ever look at a shape type.
|
|
79
|
+
role: WARDLEY_ROLE[kind],
|
|
80
|
+
shapeType: rect ? 'rect' : 'ellipse',
|
|
81
|
+
filled: true,
|
|
82
|
+
fillColor: NODE_FILL_OF[kind],
|
|
83
|
+
strokeColor: NODE_STROKE,
|
|
84
|
+
strokeWidth: NODE_STROKE_WIDTH,
|
|
85
|
+
shapeStyle: ShapeStyle.General,
|
|
86
|
+
roughness: 0,
|
|
87
|
+
// Square corners, and ONLY on the kind that has corners at all. Spread
|
|
88
|
+
// conditionally rather than written as 0 everywhere, because that is the
|
|
89
|
+
// truth about the ellipses — and because a key some kinds write and others
|
|
90
|
+
// do not is precisely what `wardleyMorphClears` is for.
|
|
91
|
+
...(rect ? { radius: 0 } : {}),
|
|
92
|
+
xywh: box.xywh,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The pipeline's handle: a node-sized square straddling the body's top edge, and
|
|
97
|
+
* the only place a connector may land on a pipeline.
|
|
98
|
+
*
|
|
99
|
+
* It inherits `centerAnchorOnly` from the model like every other Wardley node,
|
|
100
|
+
* and it carries `wardley:handle` — a role of its own, so nothing that judges
|
|
101
|
+
* components ever judges it.
|
|
102
|
+
*/
|
|
103
|
+
export function wardleyHandleProps(box) {
|
|
104
|
+
return {
|
|
105
|
+
type: 'wardleyNode',
|
|
106
|
+
kind: 'handle',
|
|
107
|
+
role: WARDLEY_ROLE.handle,
|
|
108
|
+
shapeType: 'rect',
|
|
109
|
+
filled: true,
|
|
110
|
+
fillColor: NODE_FILL,
|
|
111
|
+
strokeColor: NODE_STROKE,
|
|
112
|
+
strokeWidth: NODE_STROKE_WIDTH,
|
|
113
|
+
shapeStyle: ShapeStyle.General,
|
|
114
|
+
roughness: 0,
|
|
115
|
+
radius: 0,
|
|
116
|
+
xywh: box.xywh,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Where a handle sits, given the body's centre: astride the top edge. */
|
|
120
|
+
export function wardleyHandleBox(cx, cy) {
|
|
121
|
+
const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
|
|
122
|
+
return wardleyCenteredBox(cx, top, HANDLE_SIZE, HANDLE_SIZE);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* One of the market's three inner dots — thicker-bordered, and deliberately
|
|
126
|
+
* ROLE-LESS.
|
|
127
|
+
*
|
|
128
|
+
* They are the GLYPH's own wiring rather than artefacts the user placed, the
|
|
129
|
+
* same reason the triangle between them carries no role: they sit inside the
|
|
130
|
+
* market circle by construction, so a role would make every market composite
|
|
131
|
+
* report an overlap with itself (W3).
|
|
132
|
+
*/
|
|
133
|
+
export function wardleyMarketDotProps(box) {
|
|
134
|
+
return {
|
|
135
|
+
type: 'wardleyNode',
|
|
136
|
+
kind: 'component',
|
|
137
|
+
role: undefined,
|
|
138
|
+
shapeType: 'ellipse',
|
|
139
|
+
filled: true,
|
|
140
|
+
fillColor: NODE_FILL,
|
|
141
|
+
strokeColor: NODE_STROKE,
|
|
142
|
+
strokeWidth: MARKET_DOT_STROKE_WIDTH,
|
|
143
|
+
shapeStyle: ShapeStyle.General,
|
|
144
|
+
roughness: 0,
|
|
145
|
+
xywh: box.xywh,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** The three dot boxes of a market centred on (cx, cy) — an upright triangle. */
|
|
149
|
+
export function wardleyMarketDotBoxes(cx, cy) {
|
|
150
|
+
const rho = MARKET_DOT_RING;
|
|
151
|
+
const sin60 = Math.sqrt(3) / 2;
|
|
152
|
+
return [
|
|
153
|
+
[0, -rho],
|
|
154
|
+
[rho * sin60, rho / 2],
|
|
155
|
+
[-rho * sin60, rho / 2],
|
|
156
|
+
].map(([vx, vy]) => wardleyCenteredBox(cx + vx, cy + vy, MARKET_DOT_SIZE, MARKET_DOT_SIZE));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One side of the market's triangle: an ATTACHED connector, so it auto-routes
|
|
160
|
+
* centre to centre and follows the dots on move and resize.
|
|
161
|
+
*/
|
|
162
|
+
export function wardleyMarketLinkProps(sourceId, targetId) {
|
|
163
|
+
return {
|
|
164
|
+
type: 'connector',
|
|
165
|
+
mode: ConnectorMode.Straight,
|
|
166
|
+
source: { id: sourceId },
|
|
167
|
+
target: { id: targetId },
|
|
168
|
+
stroke: MARKET_LINK_COLOR,
|
|
169
|
+
strokeStyle: StrokeStyle.Solid,
|
|
170
|
+
strokeWidth: MARKET_LINK_WIDTH,
|
|
171
|
+
frontEndpointStyle: PointStyle.None,
|
|
172
|
+
rearEndpointStyle: PointStyle.None,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** The pairs the triangle wires, given the three dots in creation order. */
|
|
176
|
+
export function wardleyMarketLinkPairs(dots) {
|
|
177
|
+
return [
|
|
178
|
+
[dots[0], dots[1]],
|
|
179
|
+
[dots[1], dots[2]],
|
|
180
|
+
[dots[2], dots[0]],
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The box {@link wardleyMorphProps} hands {@link wardleyNodeProps} and then
|
|
185
|
+
* throws away. Never written to a document: the morph places the element
|
|
186
|
+
* itself, from {@link wardleyCanonicalBox}, in `afterMorph`.
|
|
187
|
+
*/
|
|
188
|
+
const DISCARDED_BOX = '[0,0,0,0]';
|
|
189
|
+
/** What a morph must never rewrite: identity, geometry, and the user's words. */
|
|
190
|
+
const NOT_A_MORPH = ['type', 'xywh', 'text'];
|
|
191
|
+
/**
|
|
192
|
+
* What a kind is worth to a node that ALREADY EXISTS — {@link wardleyNodeProps}
|
|
193
|
+
* minus the three things a morph has no business touching.
|
|
194
|
+
*
|
|
195
|
+
* The whole preset and not `{kind, role}`, and on this pack it is visibly not a
|
|
196
|
+
* formality: a pipeline is a rect with square corners and a semi-transparent
|
|
197
|
+
* fill, and the other three members of the family are opaque white ellipses. A
|
|
198
|
+
* two-key patch would turn a component into a "pipeline" still drawn as a
|
|
199
|
+
* circle.
|
|
200
|
+
*/
|
|
201
|
+
export function wardleyMorphProps(kind) {
|
|
202
|
+
const props = {
|
|
203
|
+
...wardleyNodeProps(kind, { xywh: DISCARDED_BOX }),
|
|
204
|
+
};
|
|
205
|
+
for (const key of NOT_A_MORPH)
|
|
206
|
+
delete props[key];
|
|
207
|
+
return props;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Every key ANY kind's props may carry — the union over the whole pack.
|
|
211
|
+
*
|
|
212
|
+
* Computed rather than listed, so a preset that starts spreading a key
|
|
213
|
+
* conditionally is covered on the day it is added rather than on the day
|
|
214
|
+
* somebody notices.
|
|
215
|
+
*/
|
|
216
|
+
const EVERY_MORPH_KEY = new Set(Object.keys(WARDLEY_NODE_SIZE).flatMap(kind => Object.keys(wardleyMorphProps(kind))));
|
|
217
|
+
/**
|
|
218
|
+
* The fields to DELETE after morphing to `kind` — the keys some other kind
|
|
219
|
+
* writes and this one does not.
|
|
220
|
+
*
|
|
221
|
+
* `radius` for every kind but the pipeline, and that is not a formality either:
|
|
222
|
+
* a patch cannot express absence, so a pipeline morphed back to a component
|
|
223
|
+
* would keep `radius: 0` sitting in the Y.Map, silently in force on an ellipse.
|
|
224
|
+
* Derived rather than listed, so the day a preset gains a conditional key it is
|
|
225
|
+
* covered without anybody remembering to come here.
|
|
226
|
+
*/
|
|
227
|
+
export function wardleyMorphClears(kind) {
|
|
228
|
+
const present = new Set(Object.keys(wardleyMorphProps(kind)));
|
|
229
|
+
return [...EVERY_MORPH_KEY].filter(key => !present.has(key));
|
|
230
|
+
}
|
package/dist/roles.js
CHANGED
|
@@ -55,16 +55,21 @@ const DEFS = [
|
|
|
55
55
|
labelKey: 'com.labre.wardley.role.method',
|
|
56
56
|
},
|
|
57
57
|
/**
|
|
58
|
-
* The value-chain link: "A
|
|
58
|
+
* The value-chain link: "A needs B".
|
|
59
59
|
*
|
|
60
60
|
* The comment above was the whole of the convention until `docs/adr/0010`
|
|
61
61
|
* made it DATA. Tier 1 of that ADR is generic — `source` is the subject of
|
|
62
62
|
* the role's verb, `target` its object — and tier 2 is this `direction`
|
|
63
|
-
* block: the verb of THIS role is "
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
63
|
+
* block: the verb of THIS role is "needs", so its source is the consumer and
|
|
64
|
+
* its target is what the consumer needs. Needs descend from source to target;
|
|
65
|
+
* value flows back up. W4 evaluates exactly that reading, and the tool hint
|
|
66
|
+
* below is what makes it a statement the user made rather than a by-product
|
|
67
|
+
* of which end their finger landed on first.
|
|
68
|
+
*
|
|
69
|
+
* The verb read "depends on" until the PO recette of 02/09/2026: one word, in
|
|
70
|
+
* the user's own vocabulary, on a chip that is laid ALONG the link and must
|
|
71
|
+
* not outgrow it. Only the FALLBACK moved — `verbKey` is stable, and the
|
|
72
|
+
* French wording ("A besoin") comes from the host's catalogue.
|
|
68
73
|
*/
|
|
69
74
|
{
|
|
70
75
|
id: WARDLEY_ROLE.dependency,
|
|
@@ -73,9 +78,15 @@ const DEFS = [
|
|
|
73
78
|
labelFallback: 'Dependency',
|
|
74
79
|
direction: {
|
|
75
80
|
verbKey: 'com.labre.wardley.role.dependency.verb',
|
|
76
|
-
verbFallback: '
|
|
81
|
+
verbFallback: 'needs',
|
|
77
82
|
gestureHintKey: 'com.labre.wardley.role.dependency.gesture',
|
|
78
83
|
gestureHintFallback: 'Drag from the component that has the need to what it needs.',
|
|
84
|
+
// The house primary, on THIS chip and no other (same recette). The value
|
|
85
|
+
// chain is what a Wardley map is read along, and the PO asked for the
|
|
86
|
+
// relation that carries it to be unmistakable — a decision about one
|
|
87
|
+
// relation, so it is declared on that relation rather than on the
|
|
88
|
+
// mechanism, and every other framework's reveal keeps the colour it had.
|
|
89
|
+
chipColor: '#2563eb',
|
|
79
90
|
},
|
|
80
91
|
},
|
|
81
92
|
// The map itself: the axes frame the other roles are positioned against.
|
package/dist/view.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { InterchangeExtension, ReadingProfileExtension, tagsToolbarConfig, validationToolbarConfig, QualityNudgeExtension, ValidationProfileExtension, ValidationRuleExtension, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
-
import { AuditCriterionExtension, ToolbarModuleExtension, UniverseTagDefsExtension, } from '@formicoidea/labre-core/shared/services';
|
|
1
|
+
import { InterchangeExtension, morphToolbarConfig, ReadingProfileExtension, tagsToolbarConfig, validationToolbarConfig, QualityNudgeExtension, ValidationProfileExtension, ValidationRuleExtension, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { AuditCriterionExtension, ToolbarModuleExtension, toolbarModuleKey, UniverseTagDefsExtension, } from '@formicoidea/labre-core/shared/services';
|
|
3
3
|
import { ViewExtensionProvider, } from '@formicoidea/labre-core/ext-loader';
|
|
4
4
|
import { extendTemplateCategory } from '@formicoidea/labre-core/gfx/template';
|
|
5
5
|
import { BlockFlavourIdentifier, CommandExtension } from '@formicoidea/labre-core/std';
|
|
@@ -8,6 +8,7 @@ import { WARDLEY_AUDIT_CRITERIA } from './audit-criteria.js';
|
|
|
8
8
|
import { wardleyCommandIcons, wardleyCommands } from './commands.js';
|
|
9
9
|
import { effects } from './effects.js';
|
|
10
10
|
import { WARDLEY_INTERCHANGE } from './interchange.js';
|
|
11
|
+
import { WARDLEY_MORPH_SPEC } from './morph.js';
|
|
11
12
|
import { WARDLEY_TAG_DEFS } from './natures.js';
|
|
12
13
|
import { WARDLEY_PROFILES } from './profiles.js';
|
|
13
14
|
import { WARDLEY_NUDGES } from './nudges.js';
|
|
@@ -133,6 +134,33 @@ export class WardleyViewExtension extends ViewExtensionProvider {
|
|
|
133
134
|
config: tagsToolbarConfig(WARDLEY_ROLES),
|
|
134
135
|
}));
|
|
135
136
|
}
|
|
137
|
+
// The "Change type" dropdown on a selected COMPONENT's contextual
|
|
138
|
+
// toolbar — the generic module, parameterized by Wardley's own family.
|
|
139
|
+
//
|
|
140
|
+
// ## Why the key carries an owner
|
|
141
|
+
//
|
|
142
|
+
// A Wardley artefact is a native `group`, so the row the toolbar draws
|
|
143
|
+
// for it is the GROUP's row — and both plain group keys are already
|
|
144
|
+
// claimed: `affine:surface:group` by the native group operations, and
|
|
145
|
+
// `custom:affine:surface:group` by the qualification dropdown twenty
|
|
146
|
+
// lines above, which is on the group for the very same reason this is.
|
|
147
|
+
// Registering a second module on either would throw
|
|
148
|
+
// `DuplicateServiceDefinitionError` before the editor finished setting
|
|
149
|
+
// up. `toolbarModuleKey` is what lifts that ceiling — C4's morph already
|
|
150
|
+
// uses it on this flavour — so the module is registered under the
|
|
151
|
+
// DISTINCT variant `custom:affine:surface:group#wardley-morph` and the
|
|
152
|
+
// registry hands it to the same row.
|
|
153
|
+
//
|
|
154
|
+
// ## Why here
|
|
155
|
+
//
|
|
156
|
+
// In the flag-gated half, because a morph is TOOLING: a market drawn
|
|
157
|
+
// while the flag was on keeps its circle, its dots, its role and its
|
|
158
|
+
// place in every rule when the flag goes off — it just stops being
|
|
159
|
+
// something the toolbar offers to say differently (`docs/adr/0009`).
|
|
160
|
+
context.register(ToolbarModuleExtension({
|
|
161
|
+
id: BlockFlavourIdentifier(toolbarModuleKey('custom:affine:surface:group', 'wardley-morph')),
|
|
162
|
+
config: morphToolbarConfig(WARDLEY_MORPH_SPEC),
|
|
163
|
+
}));
|
|
136
164
|
// The reversed reading (MF3): what the map says about a component, on
|
|
137
165
|
// demand. One declaration — the roles, the nature tag, the dependency
|
|
138
166
|
// edge and the map's own zones, all of them already stated elsewhere in
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formicoidea/labre-framework-wardley",
|
|
3
3
|
"description": "Labre wardley framework for @formicoidea/labre-core.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.35.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"author": "lajola",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dist"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@formicoidea/labre-core": "0.
|
|
34
|
+
"@formicoidea/labre-core": "0.35.0",
|
|
35
35
|
"lit": "^3.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|