@formicoidea/labre-framework-wardley 0.34.2 → 0.36.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 +111 -31
- package/dist/actions.js +184 -98
- package/dist/commands-manifest.js +40 -0
- package/dist/commands.d.ts +18 -1
- package/dist/commands.js +96 -7
- package/dist/export.js +50 -2
- package/dist/import.d.ts +5 -3
- package/dist/import.js +53 -8
- package/dist/index.d.ts +4 -1
- package/dist/index.js +16 -1
- package/dist/legend.d.ts +27 -1
- package/dist/legend.js +277 -3
- package/dist/morph.d.ts +129 -0
- package/dist/morph.js +405 -0
- package/dist/natures.d.ts +31 -0
- package/dist/natures.js +64 -4
- package/dist/node/consts.d.ts +123 -0
- package/dist/node/consts.js +122 -0
- package/dist/node/node-view.d.ts +82 -4
- package/dist/node/node-view.js +108 -4
- package/dist/node/porter-watcher.d.ts +119 -0
- package/dist/node/porter-watcher.js +255 -0
- package/dist/porter-competition.d.ts +36 -0
- package/dist/porter-competition.js +50 -0
- package/dist/presets.d.ts +276 -0
- package/dist/presets.js +554 -0
- package/dist/roles.d.ts +4 -0
- package/dist/roles.js +85 -7
- package/dist/toolbar/icons.d.ts +36 -0
- package/dist/toolbar/icons.js +58 -0
- package/dist/toolbar/node-config.d.ts +20 -0
- package/dist/toolbar/node-config.js +100 -7
- package/dist/view.js +40 -2
- package/package.json +2 -2
package/dist/actions.d.ts
CHANGED
|
@@ -1,42 +1,25 @@
|
|
|
1
1
|
import { WardleyBackgroundElementModel, type WardleyBgVariant } from '@formicoidea/labre-core/model';
|
|
2
|
+
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
2
3
|
import type { BlockStdScope } from '@formicoidea/labre-core/std';
|
|
3
4
|
import { type GfxController } from '@formicoidea/labre-core/std/gfx';
|
|
4
5
|
import { type WardleyExportBoard } from './export.js';
|
|
6
|
+
import { type WardleyAreaShape, type WardleyArtefactKind } from './presets.js';
|
|
5
7
|
/**
|
|
6
8
|
* The single-circle node flavours: one connectable ellipse + a label to its
|
|
7
9
|
* right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
|
|
8
10
|
* method inner circle) is drawn by the node renderer from `kind`.
|
|
11
|
+
*
|
|
12
|
+
* A subset of {@link WardleyArtefactKind}, and named away from
|
|
13
|
+
* `WardleyNodeKind` on purpose: the model declares a type of that name with
|
|
14
|
+
* eleven values, and two homonyms of different cardinality — one of them the
|
|
15
|
+
* source of the semantic vocabulary — is a trap. The market, the pipeline and
|
|
16
|
+
* the Porter's-forces glyph are composites with creation functions of their
|
|
17
|
+
* own; the accelerator and the decelerator are not circles at all and place
|
|
18
|
+
* their label on the side their arrow points from
|
|
19
|
+
* ({@link createWardleyAccelerator}); the area is a zone with no label element
|
|
20
|
+
* at all ({@link createWardleyArea}); the handle is not an artefact.
|
|
9
21
|
*/
|
|
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;
|
|
22
|
+
export type WardleySingleCircleKind = Exclude<WardleyArtefactKind, 'market' | 'pipeline' | 'porter' | 'accelerator' | 'decelerator' | 'area'>;
|
|
40
23
|
/** Create a wardley map background of the given variant, viewport-centered. */
|
|
41
24
|
export declare function createWardleyBackground(gfx: GfxController, variant?: WardleyBgVariant): void;
|
|
42
25
|
/**
|
|
@@ -63,6 +46,104 @@ export declare function createWardleyPipeline(gfx: GfxController): void;
|
|
|
63
46
|
* everything is grouped into one object.
|
|
64
47
|
*/
|
|
65
48
|
export declare function createWardleyMarket(gfx: GfxController): void;
|
|
49
|
+
/**
|
|
50
|
+
* Create a Porter's-forces glyph: a white circle carrying ONE letter, pushed on
|
|
51
|
+
* from the four cardinal directions by solid red arrows, all grouped as one
|
|
52
|
+
* object.
|
|
53
|
+
*
|
|
54
|
+
* It marks an EXTERNAL competition force acting on the map — which is why the
|
|
55
|
+
* circle carries `wardley:porter` and nothing under `wardley:component`: it is
|
|
56
|
+
* not a link in the value chain, and no rule written about the chain has
|
|
57
|
+
* anything to say about a pressure applied from outside it.
|
|
58
|
+
*
|
|
59
|
+
* No label, and that is the notation rather than an omission: the letter says
|
|
60
|
+
* which force this is (R relative competition, L struggle for survival, E
|
|
61
|
+
* struggle to establish) and it is the circle's OWN inner text, so a
|
|
62
|
+
* double-click opens the native shape editor on it (`node/node-view.ts`). The
|
|
63
|
+
* four arrows are the glyph's own wiring — role-less filled red polygons,
|
|
64
|
+
* outside the rim — exactly as the market's triangle is its own.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createWardleyPorter(gfx: GfxController): void;
|
|
67
|
+
/**
|
|
68
|
+
* Create an accelerator or a decelerator: one fat native POLYGON and the words
|
|
69
|
+
* that say what it is, grouped like every other artefact.
|
|
70
|
+
*
|
|
71
|
+
* Not a link in the value chain, and not a pressure on it either: an annotation
|
|
72
|
+
* of the CLIMATE, saying that something is speeding evolution up (an arrow
|
|
73
|
+
* pointing right, towards commodity) or holding it back (the mirror, pointing
|
|
74
|
+
* left). Which way it points IS the notation, so the two are separate kinds
|
|
75
|
+
* with outlines of their own rather than one kind with a `rotate`.
|
|
76
|
+
*
|
|
77
|
+
* ## Where the words go, and why it is not always the right
|
|
78
|
+
*
|
|
79
|
+
* Every other single artefact on this canvas wears its name on its right,
|
|
80
|
+
* because a circle has no direction to disagree with. An arrow does. The
|
|
81
|
+
* reference puts the words on the side the arrow's SHAFT is on — right of an
|
|
82
|
+
* accelerator, left of a decelerator — so the reading runs into the arrow
|
|
83
|
+
* rather than across it, and the label is aligned towards it (left-aligned on
|
|
84
|
+
* the right, right-aligned on the left) so the words end against the shaft
|
|
85
|
+
* whatever their length.
|
|
86
|
+
*
|
|
87
|
+
* SemiBold, which no other Wardley label is: these two are annotations laid
|
|
88
|
+
* over a map that is already full of names, and the weight is what keeps the
|
|
89
|
+
* word readable as a remark ABOUT the map rather than as one more component.
|
|
90
|
+
*/
|
|
91
|
+
export declare function createWardleyAccelerator(gfx: GfxController, kind: 'accelerator' | 'decelerator'): void;
|
|
92
|
+
/**
|
|
93
|
+
* Create an AREA: a translucent zone of the map, drawn as a rectangle or as a
|
|
94
|
+
* polygon whose corners the author then moves.
|
|
95
|
+
*
|
|
96
|
+
* One element and no group, which makes it the plainest creation site in this
|
|
97
|
+
* file — and the difference is the name. Every other artefact wears its name as
|
|
98
|
+
* a text element beside it, so every other artefact is a group of two. A zone's
|
|
99
|
+
* name belongs INSIDE the zone, so it is the shape's own inner text: created
|
|
100
|
+
* empty, opened by a double-click on the zone (`node/node-view.ts`), and set in
|
|
101
|
+
* a fit mode that never resizes the boundary around it.
|
|
102
|
+
*
|
|
103
|
+
* ## Why it is sent to the back
|
|
104
|
+
*
|
|
105
|
+
* A zone is drawn precisely OVER the components it groups, and the surface
|
|
106
|
+
* paints in index order: an area added last would sit on top of everything it
|
|
107
|
+
* surrounds — hiding it under a wash, and worse, intercepting every click meant
|
|
108
|
+
* for a component inside it. So the element is lowered the moment it exists,
|
|
109
|
+
* to the depth {@link wardleyAreaIndexOver} works out.
|
|
110
|
+
*/
|
|
111
|
+
export declare function createWardleyArea(gfx: GfxController, shape: WardleyAreaShape): void;
|
|
112
|
+
/**
|
|
113
|
+
* One already-placed element, as {@link wardleyAreaIndexOver} needs to read it.
|
|
114
|
+
*
|
|
115
|
+
* Three fields and no model, so the placement can be reasoned about — and
|
|
116
|
+
* tested — without a document: what it is, where it is, and how deep.
|
|
117
|
+
*/
|
|
118
|
+
export interface WardleyStackedElement {
|
|
119
|
+
/** Fractional index. Sorts lexicographically, which IS the paint order. */
|
|
120
|
+
index: string;
|
|
121
|
+
/** Serialized box. */
|
|
122
|
+
xywh: string;
|
|
123
|
+
/** Whether this is a framework BACKGROUND — a map, a pool, a C4 board… */
|
|
124
|
+
isBackground: boolean;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How deep a freshly drawn zone goes: just ABOVE the framework backgrounds it
|
|
128
|
+
* covers, and below everything else. `null` means the back of the surface.
|
|
129
|
+
*
|
|
130
|
+
* "The back" alone was the first answer and it was wrong, as the recette of
|
|
131
|
+
* #213 found: a Wardley map is a framework BACKGROUND — an opaque one — so a
|
|
132
|
+
* zone sent behind the whole surface went behind the map and vanished. What a
|
|
133
|
+
* zone must be under is the artefacts it groups; what it must be over is the
|
|
134
|
+
* canvas they are drawn on. Those are two different depths, and only the first
|
|
135
|
+
* one is "the back".
|
|
136
|
+
*
|
|
137
|
+
* So: find the topmost background the zone actually OVERLAPS — the map it was
|
|
138
|
+
* drawn on, and not some other framework's board parked elsewhere on the same
|
|
139
|
+
* canvas — and mint a key between it and whatever sits directly above it. With
|
|
140
|
+
* no background under the zone at all there is nothing to clear, and the back
|
|
141
|
+
* of the surface is right again.
|
|
142
|
+
*
|
|
143
|
+
* `siblings` need not be sorted: fractional indexes sort lexicographically, so
|
|
144
|
+
* this sorts them itself and reads the paint order straight off the strings.
|
|
145
|
+
*/
|
|
146
|
+
export declare function wardleyAreaIndexOver(siblings: readonly WardleyStackedElement[], box: Bound): string | null;
|
|
66
147
|
/**
|
|
67
148
|
* Read an SVG the user picks as a SKETCH, and say what it cost.
|
|
68
149
|
*
|
|
@@ -114,4 +195,3 @@ export declare function exportOwmFile(std: BlockStdScope): void;
|
|
|
114
195
|
* the capability handed to it.
|
|
115
196
|
*/
|
|
116
197
|
export declare function importOwmFile(std: BlockStdScope): Promise<void>;
|
|
117
|
-
export {};
|
package/dist/actions.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { backgroundSize, DefaultTool, runInterchangeImportFile, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
2
|
import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
|
|
3
3
|
import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
|
|
4
|
-
import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
|
|
4
|
+
import { ConnectorMode, FontFamily, FontWeight, FrameworkBackgroundElementModel, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
|
|
5
5
|
import { NotificationProvider, translateKey, } from '@formicoidea/labre-core/shared/services';
|
|
6
6
|
import { downloadBlob } from '@formicoidea/labre-core/shared/utils';
|
|
7
7
|
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
8
|
-
import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
|
|
8
|
+
import { generateKeyBetween, GfxControllerIdentifier, } from '@formicoidea/labre-core/std/gfx';
|
|
9
9
|
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, PORTER_DEFAULT_LETTER, WARDLEY_RED, } from './node/consts.js';
|
|
14
|
+
import { wardleyAreaBox, wardleyAreaProps, wardleyCanonicalBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, WARDLEY_NODE_SIZE, wardleyNodeProps, wardleyPorterArrowProps, wardleyPorterArrows, wardleyPorterLetterProps, } from './presets.js';
|
|
14
15
|
import { WARDLEY_ROLE } from './roles.js';
|
|
15
16
|
/**
|
|
16
17
|
* Standalone creation/activation actions for the Wardley toolbox — the
|
|
@@ -64,19 +65,13 @@ const BACKGROUND_VARIANT_DEFAULTS = {
|
|
|
64
65
|
/** Height of the native free-text labels (Inter, size 18). */
|
|
65
66
|
const LABEL_H = LABEL_FONT_SIZE + 8;
|
|
66
67
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* Width of a label box, whatever it reads.
|
|
69
|
+
*
|
|
70
|
+
* A number rather than a measurement, which is why a RIGHT-aligned label has to
|
|
71
|
+
* subtract it: the box does not shrink to the words, so the only way to make
|
|
72
|
+
* the words end on a given edge is to start the box a full width before it.
|
|
70
73
|
*/
|
|
71
|
-
const
|
|
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
|
-
};
|
|
74
|
+
const LABEL_W = 120;
|
|
80
75
|
function finish(gfx, id) {
|
|
81
76
|
gfx.doc.captureSync();
|
|
82
77
|
gfx.tool.setTool(DefaultTool);
|
|
@@ -91,34 +86,30 @@ function group(gfx, ids) {
|
|
|
91
86
|
});
|
|
92
87
|
return result.groupId || ids[0];
|
|
93
88
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Add a wardley node of one kind, at its canonical size, centred on (cx, cy).
|
|
91
|
+
*
|
|
92
|
+
* The APPEARANCE is {@link wardleyNodeProps}' and nothing else: the palette and
|
|
93
|
+
* the "Change type" dropdown read that one description, so a market drawn here
|
|
94
|
+
* and a component morphed into one are the same element (`presets.ts`).
|
|
95
|
+
*
|
|
96
|
+
* `extra` is for what a preset cannot carry — the Porter's-forces letter, which
|
|
97
|
+
* is CONTENT the author then edits, not a look. `wardleyMorphProps` strips
|
|
98
|
+
* `text` for exactly that reason, so a preset that wrote one would be a preset
|
|
99
|
+
* the morph has to un-write.
|
|
100
|
+
*/
|
|
101
|
+
function addNode(surface, kind, cx, cy, extra = {}) {
|
|
101
102
|
return surface.addElement({
|
|
102
|
-
|
|
103
|
-
|
|
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(),
|
|
103
|
+
...wardleyNodeProps(kind, { xywh: wardleyCanonicalBox(kind, cx, cy) }),
|
|
104
|
+
...extra,
|
|
115
105
|
});
|
|
116
106
|
}
|
|
117
107
|
/** Add a native free-text label (same Inter family as the axis labels). */
|
|
118
|
-
function addLabel(surface, text, x, y, textAlign = 'left') {
|
|
108
|
+
function addLabel(surface, text, x, y, textAlign = 'left', fontWeight = FontWeight.Regular) {
|
|
119
109
|
return surface.addElement({
|
|
120
110
|
type: 'text',
|
|
121
111
|
text,
|
|
112
|
+
fontWeight,
|
|
122
113
|
// Semantic identity (PF1, revised in PF13.4): a Wardley label is a free
|
|
123
114
|
// text element like any other, so its ROLE is the only thing that tells W3
|
|
124
115
|
// it must not land on top of a node. A free text the user typed elsewhere
|
|
@@ -128,7 +119,7 @@ function addLabel(surface, text, x, y, textAlign = 'left') {
|
|
|
128
119
|
fontSize: LABEL_FONT_SIZE,
|
|
129
120
|
color: NODE_STROKE,
|
|
130
121
|
textAlign,
|
|
131
|
-
xywh: new Bound(x, y,
|
|
122
|
+
xywh: new Bound(x, y, LABEL_W, LABEL_H).serialize(),
|
|
132
123
|
});
|
|
133
124
|
}
|
|
134
125
|
/** Create a wardley map background of the given variant, viewport-centered. */
|
|
@@ -171,10 +162,10 @@ export function createWardleyNode(gfx, kind) {
|
|
|
171
162
|
const surface = gfx.surface;
|
|
172
163
|
if (!surface)
|
|
173
164
|
return;
|
|
174
|
-
const {
|
|
165
|
+
const { w } = WARDLEY_NODE_SIZE[kind];
|
|
175
166
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
176
|
-
const nodeId =
|
|
177
|
-
const labelId = addLabel(surface,
|
|
167
|
+
const nodeId = addNode(surface, kind, cx, cy);
|
|
168
|
+
const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind], cx + w / 2 + LABEL_GAP, cy - LABEL_H / 2);
|
|
178
169
|
finish(gfx, group(gfx, [nodeId, labelId]));
|
|
179
170
|
}
|
|
180
171
|
/** Create an inertia bar (filled black rect). */
|
|
@@ -213,43 +204,15 @@ export function createWardleyPipeline(gfx) {
|
|
|
213
204
|
if (!gfx.surface)
|
|
214
205
|
return;
|
|
215
206
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
216
|
-
const W = PIPELINE_WIDTH;
|
|
217
|
-
const H = PIPELINE_HEIGHT;
|
|
218
207
|
const d = HANDLE_SIZE;
|
|
219
|
-
const top = cy -
|
|
208
|
+
const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
|
|
220
209
|
// 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
|
-
});
|
|
210
|
+
const bodyId = addNode(gfx.surface, 'pipeline', cx, cy);
|
|
235
211
|
// Handle: a node-sized WardleyNode square straddling the top edge. Inherits
|
|
236
212
|
// `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
|
-
});
|
|
213
|
+
const handleId = gfx.surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
|
|
251
214
|
// Label centered horizontally on the pipeline, sitting ABOVE the handle.
|
|
252
|
-
const labelId = addLabel(gfx.surface,
|
|
215
|
+
const labelId = addLabel(gfx.surface, WARDLEY_NODE_LABEL.pipeline, cx - 60, top - d / 2 - LABEL_H - LABEL_GAP, 'center');
|
|
253
216
|
// Nested groups: (handle + label), then (body + that group).
|
|
254
217
|
const innerId = group(gfx, [handleId, labelId]);
|
|
255
218
|
finish(gfx, group(gfx, [bodyId, innerId]));
|
|
@@ -266,37 +229,160 @@ export function createWardleyMarket(gfx) {
|
|
|
266
229
|
if (!surface)
|
|
267
230
|
return;
|
|
268
231
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
269
|
-
const R =
|
|
270
|
-
const rho = MARKET_DOT_RING;
|
|
271
|
-
const sin60 = Math.sqrt(3) / 2;
|
|
232
|
+
const R = WARDLEY_NODE_SIZE.market.w / 2;
|
|
272
233
|
// Outer circle = the market node (connectable, center-only).
|
|
273
|
-
const circleId =
|
|
234
|
+
const circleId = addNode(surface, 'market', cx, cy);
|
|
274
235
|
// 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));
|
|
236
|
+
const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
|
|
281
237
|
// 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);
|
|
238
|
+
const connIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
|
|
239
|
+
const labelId = addLabel(surface, WARDLEY_NODE_LABEL.market, cx + R + LABEL_GAP, cy - LABEL_H / 2);
|
|
298
240
|
finish(gfx, group(gfx, [circleId, ...dotIds, ...connIds, labelId]));
|
|
299
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Create a Porter's-forces glyph: a white circle carrying ONE letter, pushed on
|
|
244
|
+
* from the four cardinal directions by solid red arrows, all grouped as one
|
|
245
|
+
* object.
|
|
246
|
+
*
|
|
247
|
+
* It marks an EXTERNAL competition force acting on the map — which is why the
|
|
248
|
+
* circle carries `wardley:porter` and nothing under `wardley:component`: it is
|
|
249
|
+
* not a link in the value chain, and no rule written about the chain has
|
|
250
|
+
* anything to say about a pressure applied from outside it.
|
|
251
|
+
*
|
|
252
|
+
* No label, and that is the notation rather than an omission: the letter says
|
|
253
|
+
* which force this is (R relative competition, L struggle for survival, E
|
|
254
|
+
* struggle to establish) and it is the circle's OWN inner text, so a
|
|
255
|
+
* double-click opens the native shape editor on it (`node/node-view.ts`). The
|
|
256
|
+
* four arrows are the glyph's own wiring — role-less filled red polygons,
|
|
257
|
+
* outside the rim — exactly as the market's triangle is its own.
|
|
258
|
+
*/
|
|
259
|
+
export function createWardleyPorter(gfx) {
|
|
260
|
+
const surface = gfx.surface;
|
|
261
|
+
if (!surface)
|
|
262
|
+
return;
|
|
263
|
+
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
264
|
+
const circleId = addNode(surface, 'porter', cx, cy, wardleyPorterLetterProps(PORTER_DEFAULT_LETTER));
|
|
265
|
+
const arrowIds = wardleyPorterArrows(cx, cy).map(arrow => surface.addElement(wardleyPorterArrowProps(arrow)));
|
|
266
|
+
finish(gfx, group(gfx, [circleId, ...arrowIds]));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Create an accelerator or a decelerator: one fat native POLYGON and the words
|
|
270
|
+
* that say what it is, grouped like every other artefact.
|
|
271
|
+
*
|
|
272
|
+
* Not a link in the value chain, and not a pressure on it either: an annotation
|
|
273
|
+
* of the CLIMATE, saying that something is speeding evolution up (an arrow
|
|
274
|
+
* pointing right, towards commodity) or holding it back (the mirror, pointing
|
|
275
|
+
* left). Which way it points IS the notation, so the two are separate kinds
|
|
276
|
+
* with outlines of their own rather than one kind with a `rotate`.
|
|
277
|
+
*
|
|
278
|
+
* ## Where the words go, and why it is not always the right
|
|
279
|
+
*
|
|
280
|
+
* Every other single artefact on this canvas wears its name on its right,
|
|
281
|
+
* because a circle has no direction to disagree with. An arrow does. The
|
|
282
|
+
* reference puts the words on the side the arrow's SHAFT is on — right of an
|
|
283
|
+
* accelerator, left of a decelerator — so the reading runs into the arrow
|
|
284
|
+
* rather than across it, and the label is aligned towards it (left-aligned on
|
|
285
|
+
* the right, right-aligned on the left) so the words end against the shaft
|
|
286
|
+
* whatever their length.
|
|
287
|
+
*
|
|
288
|
+
* SemiBold, which no other Wardley label is: these two are annotations laid
|
|
289
|
+
* over a map that is already full of names, and the weight is what keeps the
|
|
290
|
+
* word readable as a remark ABOUT the map rather than as one more component.
|
|
291
|
+
*/
|
|
292
|
+
export function createWardleyAccelerator(gfx, kind) {
|
|
293
|
+
const surface = gfx.surface;
|
|
294
|
+
if (!surface)
|
|
295
|
+
return;
|
|
296
|
+
const { w } = WARDLEY_NODE_SIZE[kind];
|
|
297
|
+
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
298
|
+
const nodeId = addNode(surface, kind, cx, cy);
|
|
299
|
+
const rightwards = kind === 'accelerator';
|
|
300
|
+
const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind],
|
|
301
|
+
// A label box is a fixed LABEL_W wide whatever it reads, so a right-aligned
|
|
302
|
+
// one has to start a box-width before the edge the words must end on.
|
|
303
|
+
rightwards ? cx + w / 2 + LABEL_GAP : cx - w / 2 - LABEL_GAP - LABEL_W, cy - LABEL_H / 2, rightwards ? 'left' : 'right', FontWeight.SemiBold);
|
|
304
|
+
finish(gfx, group(gfx, [nodeId, labelId]));
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Create an AREA: a translucent zone of the map, drawn as a rectangle or as a
|
|
308
|
+
* polygon whose corners the author then moves.
|
|
309
|
+
*
|
|
310
|
+
* One element and no group, which makes it the plainest creation site in this
|
|
311
|
+
* file — and the difference is the name. Every other artefact wears its name as
|
|
312
|
+
* a text element beside it, so every other artefact is a group of two. A zone's
|
|
313
|
+
* name belongs INSIDE the zone, so it is the shape's own inner text: created
|
|
314
|
+
* empty, opened by a double-click on the zone (`node/node-view.ts`), and set in
|
|
315
|
+
* a fit mode that never resizes the boundary around it.
|
|
316
|
+
*
|
|
317
|
+
* ## Why it is sent to the back
|
|
318
|
+
*
|
|
319
|
+
* A zone is drawn precisely OVER the components it groups, and the surface
|
|
320
|
+
* paints in index order: an area added last would sit on top of everything it
|
|
321
|
+
* surrounds — hiding it under a wash, and worse, intercepting every click meant
|
|
322
|
+
* for a component inside it. So the element is lowered the moment it exists,
|
|
323
|
+
* to the depth {@link wardleyAreaIndexOver} works out.
|
|
324
|
+
*/
|
|
325
|
+
export function createWardleyArea(gfx, shape) {
|
|
326
|
+
const surface = gfx.surface;
|
|
327
|
+
if (!surface)
|
|
328
|
+
return;
|
|
329
|
+
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
330
|
+
const xywh = wardleyAreaBox(shape, cx, cy);
|
|
331
|
+
const id = surface.addElement(wardleyAreaProps(shape, { xywh }));
|
|
332
|
+
const model = surface.getElementById(id);
|
|
333
|
+
if (model) {
|
|
334
|
+
const over = wardleyAreaIndexOver(stackedElementsOf(surface, id), Bound.deserialize(xywh));
|
|
335
|
+
// `null` is "nothing under it": the back of the surface, minted by the
|
|
336
|
+
// layer itself so an empty board gets the layer's own initial index rather
|
|
337
|
+
// than a key invented here.
|
|
338
|
+
model.index = over ?? gfx.layer.getReorderedIndex(model, 'back');
|
|
339
|
+
}
|
|
340
|
+
finish(gfx, id);
|
|
341
|
+
}
|
|
342
|
+
/** The top-level canvas elements of a surface, minus the one being placed. */
|
|
343
|
+
function stackedElementsOf(surface, exclude) {
|
|
344
|
+
return surface.elementModels
|
|
345
|
+
.filter(model => model.id !== exclude && model.group === null)
|
|
346
|
+
.map(model => ({
|
|
347
|
+
index: model.index,
|
|
348
|
+
xywh: model.xywh,
|
|
349
|
+
isBackground: model instanceof FrameworkBackgroundElementModel,
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* How deep a freshly drawn zone goes: just ABOVE the framework backgrounds it
|
|
354
|
+
* covers, and below everything else. `null` means the back of the surface.
|
|
355
|
+
*
|
|
356
|
+
* "The back" alone was the first answer and it was wrong, as the recette of
|
|
357
|
+
* #213 found: a Wardley map is a framework BACKGROUND — an opaque one — so a
|
|
358
|
+
* zone sent behind the whole surface went behind the map and vanished. What a
|
|
359
|
+
* zone must be under is the artefacts it groups; what it must be over is the
|
|
360
|
+
* canvas they are drawn on. Those are two different depths, and only the first
|
|
361
|
+
* one is "the back".
|
|
362
|
+
*
|
|
363
|
+
* So: find the topmost background the zone actually OVERLAPS — the map it was
|
|
364
|
+
* drawn on, and not some other framework's board parked elsewhere on the same
|
|
365
|
+
* canvas — and mint a key between it and whatever sits directly above it. With
|
|
366
|
+
* no background under the zone at all there is nothing to clear, and the back
|
|
367
|
+
* of the surface is right again.
|
|
368
|
+
*
|
|
369
|
+
* `siblings` need not be sorted: fractional indexes sort lexicographically, so
|
|
370
|
+
* this sorts them itself and reads the paint order straight off the strings.
|
|
371
|
+
*/
|
|
372
|
+
export function wardleyAreaIndexOver(siblings, box) {
|
|
373
|
+
const stack = [...siblings].sort((a, b) => a.index < b.index ? -1 : a.index > b.index ? 1 : 0);
|
|
374
|
+
// The LAST match, which is the topmost: a board with two maps on it gets the
|
|
375
|
+
// zone above the one it is actually drawn over, whichever was drawn first.
|
|
376
|
+
const under = stack.reduce((found, element, at) => element.isBackground &&
|
|
377
|
+
Bound.deserialize(element.xywh).isOverlapWithBound(box)
|
|
378
|
+
? at
|
|
379
|
+
: found, -1);
|
|
380
|
+
if (under < 0)
|
|
381
|
+
return null;
|
|
382
|
+
// `null` for the upper bound when the background is the topmost element
|
|
383
|
+
// there is — `generateKeyBetween` reads that as "append after".
|
|
384
|
+
return generateKeyBetween(stack[under].index, stack[under + 1]?.index ?? null);
|
|
385
|
+
}
|
|
300
386
|
/**
|
|
301
387
|
* Read an SVG the user picks as a SKETCH, and say what it cost.
|
|
302
388
|
*
|
|
@@ -119,6 +119,46 @@ export const wardleyCommandsManifest = [
|
|
|
119
119
|
scope: 'edgeless',
|
|
120
120
|
defaultKeys: { mac: ['w', 'i'], other: ['w', 'i'] },
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
id: 'wardley.addPorter',
|
|
124
|
+
owner: 'wardley',
|
|
125
|
+
labelKey: 'com.labre.commands.wardley.addPorter',
|
|
126
|
+
labelFallback: "Porter's forces",
|
|
127
|
+
scope: 'edgeless',
|
|
128
|
+
defaultKeys: { mac: [], other: [] },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'wardley.addAccelerator',
|
|
132
|
+
owner: 'wardley',
|
|
133
|
+
labelKey: 'com.labre.commands.wardley.addAccelerator',
|
|
134
|
+
labelFallback: 'Accelerator',
|
|
135
|
+
scope: 'edgeless',
|
|
136
|
+
defaultKeys: { mac: [], other: [] },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'wardley.addDecelerator',
|
|
140
|
+
owner: 'wardley',
|
|
141
|
+
labelKey: 'com.labre.commands.wardley.addDecelerator',
|
|
142
|
+
labelFallback: 'Decelerator',
|
|
143
|
+
scope: 'edgeless',
|
|
144
|
+
defaultKeys: { mac: [], other: [] },
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'wardley.addAreaRect',
|
|
148
|
+
owner: 'wardley',
|
|
149
|
+
labelKey: 'com.labre.commands.wardley.addAreaRect',
|
|
150
|
+
labelFallback: 'Area (rectangle)',
|
|
151
|
+
scope: 'edgeless',
|
|
152
|
+
defaultKeys: { mac: [], other: [] },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'wardley.addAreaPolygon',
|
|
156
|
+
owner: 'wardley',
|
|
157
|
+
labelKey: 'com.labre.commands.wardley.addAreaPolygon',
|
|
158
|
+
labelFallback: 'Area (polygon)',
|
|
159
|
+
scope: 'edgeless',
|
|
160
|
+
defaultKeys: { mac: [], other: [] },
|
|
161
|
+
},
|
|
122
162
|
{
|
|
123
163
|
id: 'wardley.importOwm',
|
|
124
164
|
owner: 'wardley',
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import type { CommandDescriptor } from '@formicoidea/labre-core/std';
|
|
2
2
|
import type { TemplateResult } from 'lit';
|
|
3
3
|
/**
|
|
4
|
-
* The Wardley registry: the
|
|
4
|
+
* The Wardley registry: the eighteen toolbox entries, then the two directions
|
|
5
5
|
* of the OWM DSL and the SVG fallback (`docs/adr/0012`).
|
|
6
|
+
*
|
|
7
|
+
* ## Nineteen nominations, and the curation decision behind them
|
|
8
|
+
*
|
|
9
|
+
* `addPorter` spent the last seat of ADR 0014 R4's `SENIOR_MENU_CAP + 1`
|
|
10
|
+
* budget, and `addAccelerator` / `addDecelerator` are the two past it. The PO
|
|
11
|
+
* made the call on 2026-09-03 (Amendment 2026-09-03 under R4): **every Wardley
|
|
12
|
+
* artefact nominates the row**. The row itself is unchanged — thirteen
|
|
13
|
+
* arbitrated seats plus "More artefacts…", the cap of fourteen it has had since
|
|
14
|
+
* the OWM pair tipped this framework over — so what the decision buys is that
|
|
15
|
+
* a newly-drawn artefact is ELIGIBLE for a seat, and recency and frequency
|
|
16
|
+
* decide who gets one. Nothing becomes unreachable: the catalogue lists
|
|
17
|
+
* everything, which is the registry's own invariant.
|
|
18
|
+
*
|
|
19
|
+
* `addAreaRect` and `addAreaPolygon` are the two after those, and they land
|
|
20
|
+
* under that same amendment rather than reopening it: they nominate like every
|
|
21
|
+
* other Wardley artefact, the row is still thirteen arbitrated seats plus "More
|
|
22
|
+
* artefacts…", and the catalogue grows by a section of its own.
|
|
6
23
|
*/
|
|
7
24
|
export declare const wardleyCommands: CommandDescriptor[];
|
|
8
25
|
/** `iconKey` → template. Never travels through either manifest (ADR 0008). */
|