@formicoidea/labre-framework-wardley 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,552 +1,552 @@
1
- import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
2
- import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
3
- import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
4
- import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
5
- import {
6
- ConnectorMode,
7
- FontFamily,
8
- PointStyle,
9
- ShapeStyle,
10
- StrokeStyle,
11
- type WardleyBgVariant,
12
- } from '@formicoidea/labre-core/model';
13
- import {
14
- EditPropsStore,
15
- TelemetryProvider,
16
- } from '@formicoidea/labre-core/shared/services';
17
- import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
18
- import { Bound } from '@formicoidea/labre-core/global/gfx';
19
- import type { GfxController } from '@formicoidea/labre-core/std/gfx';
20
- import { css, html, LitElement } from 'lit';
21
-
22
- import { REF_WIDTH } from '../consts';
23
- import {
24
- ECOSYSTEM_LABEL,
25
- ECOSYSTEM_SIZE,
26
- HANDLE_SIZE,
27
- INERTIA_COLOR,
28
- INERTIA_SIZE,
29
- LABEL_DEFAULT,
30
- LABEL_FONT_SIZE,
31
- LABEL_GAP,
32
- LINK_GREY,
33
- LINK_STROKE_WIDTH,
34
- MARKET_DOT_RING,
35
- MARKET_DOT_SIZE,
36
- MARKET_DOT_STROKE_WIDTH,
37
- MARKET_LABEL,
38
- MARKET_LINK_COLOR,
39
- MARKET_LINK_WIDTH,
40
- MARKET_SIZE,
41
- METHOD_FILL,
42
- METHOD_LABEL,
43
- METHOD_SIZE,
44
- NODE_FILL,
45
- NODE_SIZE,
46
- NODE_STROKE,
47
- NODE_STROKE_WIDTH,
48
- PIPELINE_FILL,
49
- PIPELINE_HEIGHT,
50
- PIPELINE_LABEL,
51
- PIPELINE_WIDTH,
52
- WARDLEY_RED,
53
- } from '../node/consts';
54
- import {
55
- wardleyAnchorIcon,
56
- wardleyArrowIcon,
57
- wardleyBackgroundIcon,
58
- wardleyBenefitIcon,
59
- wardleyComponentIcon,
60
- wardleyEcosystemIcon,
61
- wardleyInertiaIcon,
62
- wardleyLinkIcon,
63
- wardleyMarketIcon,
64
- wardleyMethodIcon,
65
- wardleyEvolutionGradientIcon,
66
- wardleyOpportunityIcon,
67
- wardleyPipelineIcon,
68
- } from './icons';
69
-
70
- /**
71
- * Per-variant default label overrides applied at creation (all remain editable
72
- * afterwards via the inline editor / toggles). The gradient itself is driven by
73
- * `variant` in the renderer.
74
- */
75
- const BACKGROUND_VARIANT_DEFAULTS: Record<
76
- WardleyBgVariant,
77
- Record<string, unknown>
78
- > = {
79
- classic: {},
80
- // The Y axis becomes "Opportunity"; phase labels keep the classic defaults.
81
- opportunity: {
82
- yAxisTitle: 'Opportunity',
83
- showVisibilityLabels: false,
84
- showCornerLabels: false,
85
- },
86
- // The Y axis splits into Benefit (top) / Investment (bottom) around a zero
87
- // line drawn by the renderer.
88
- benefit: {
89
- yAxisTitle: '',
90
- visibilityHigh: 'Benefit',
91
- visibilityLow: 'Investment',
92
- showCornerLabels: false,
93
- },
94
- // Keeps the classic labels (Value Chain / Uncharted / Industrialized…); only
95
- // the grey gradient differs.
96
- 'evolution-gradient': {},
97
- };
98
-
99
- type Surface = NonNullable<GfxController['surface']>;
100
-
101
- /** Height of the native free-text labels (Inter, size 18). */
102
- const LABEL_H = LABEL_FONT_SIZE + 8;
103
-
104
- /**
105
- * The single-circle node flavours: one connectable ellipse + a label to its
106
- * right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
107
- * method inner circle) is drawn by the node renderer from `kind`.
108
- */
109
- const NODE_PRESETS = {
110
- component: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.component },
111
- anchor: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.anchor },
112
- // Ecosystem: glyph = double border + hatched donut; connectors attach to
113
- // this outer circle's center.
114
- ecosystem: { d: ECOSYSTEM_SIZE, fill: NODE_FILL, label: ECOSYSTEM_LABEL },
115
- // Method: the FILL color encodes the chosen method (editable).
116
- method: { d: METHOD_SIZE, fill: METHOD_FILL, label: METHOD_LABEL },
117
- } as const;
118
-
119
- /**
120
- * The popover that opens above the toolbar for the Wardley toolbox. Each item
121
- * creates a pre-formatted Wardley object. Nodes (component / anchor) are a
122
- * native ellipse + a native text label, grouped together.
123
- */
124
- export class EdgelessWardleyMenu extends EdgelessToolbarToolMixin(LitElement) {
125
- static override styles = css`
126
- :host {
127
- position: absolute;
128
- display: flex;
129
- z-index: -1;
130
- }
131
- .menu-content {
132
- display: flex;
133
- align-items: center;
134
- justify-content: center;
135
- }
136
- .button-group-container {
137
- display: flex;
138
- align-items: center;
139
- gap: 14px;
140
- fill: var(--affine-icon-color);
141
- }
142
- .button-group-container svg {
143
- width: 24px;
144
- height: 24px;
145
- }
146
- `;
147
-
148
- override type = EmptyTool;
149
-
150
- private _createBackground(variant: WardleyBgVariant = 'classic') {
151
- const { gfx } = this;
152
- if (!gfx.surface) return;
153
-
154
- let width = REF_WIDTH;
155
- for (const el of gfx.surface.getElementsByType('wardley')) {
156
- const [, , ew, eh] = el.deserializedXYWH;
157
- width = Math.max(width, ew, (eh * 16) / 9);
158
- }
159
- const height = (width * 9) / 16;
160
-
161
- const { centerX, centerY } = gfx.viewport;
162
- const id = gfx.surface.addElement({
163
- type: 'wardley',
164
- variant,
165
- ...BACKGROUND_VARIANT_DEFAULTS[variant],
166
- xywh: new Bound(
167
- centerX - width / 2,
168
- centerY - height / 2,
169
- width,
170
- height
171
- ).serialize(),
172
- });
173
- this._track('FrameworkElementAdded', `background:${variant}`);
174
- this._finish(id);
175
- }
176
-
177
- /** Add a native ellipse wardley node centred on (cx, cy). */
178
- private _addEllipseNode(
179
- surface: Surface,
180
- kind: keyof typeof NODE_PRESETS | 'market',
181
- cx: number,
182
- cy: number,
183
- d: number,
184
- fillColor: string,
185
- strokeWidth = NODE_STROKE_WIDTH
186
- ) {
187
- return surface.addElement({
188
- type: 'wardleyNode',
189
- kind,
190
- shapeType: 'ellipse',
191
- filled: true,
192
- fillColor,
193
- strokeColor: NODE_STROKE,
194
- strokeWidth,
195
- shapeStyle: ShapeStyle.General,
196
- roughness: 0,
197
- xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
198
- });
199
- }
200
-
201
- /** Add a native free-text label (same Inter family as the axis labels). */
202
- private _addLabel(
203
- surface: Surface,
204
- text: string,
205
- x: number,
206
- y: number,
207
- textAlign: 'left' | 'center' = 'left'
208
- ) {
209
- return surface.addElement({
210
- type: 'text',
211
- text,
212
- fontFamily: FontFamily.Inter,
213
- fontSize: LABEL_FONT_SIZE,
214
- color: NODE_STROKE,
215
- textAlign,
216
- xywh: new Bound(x, y, 120, LABEL_H).serialize(),
217
- });
218
- }
219
-
220
- /** Group elements; returns the group id (or the first id if grouping failed). */
221
- private _group(ids: string[]) {
222
- const [, result] = this.edgeless.std.command.exec(createGroupCommand, {
223
- elements: ids,
224
- });
225
- return result.groupId || ids[0];
226
- }
227
-
228
- /**
229
- * Create a single-circle node (component / anchor / ecosystem / method):
230
- * one connectable native ellipse + a label to its right, grouped so they
231
- * move together (enter the group to reposition / edit the label).
232
- */
233
- private _createNode(kind: keyof typeof NODE_PRESETS) {
234
- const surface = this.gfx.surface;
235
- if (!surface) return;
236
-
237
- const { d, fill, label } = NODE_PRESETS[kind];
238
- const { centerX: cx, centerY: cy } = this.gfx.viewport;
239
-
240
- const nodeId = this._addEllipseNode(surface, kind, cx, cy, d, fill);
241
- const labelId = this._addLabel(
242
- surface,
243
- label,
244
- cx + d / 2 + LABEL_GAP,
245
- cy - LABEL_H / 2
246
- );
247
-
248
- this._track('FrameworkElementAdded', `node:${kind}`);
249
- this._finish(this._group([nodeId, labelId]));
250
- }
251
-
252
- private _createInertia() {
253
- const { gfx } = this;
254
- if (!gfx.surface) return;
255
-
256
- const { w, h } = INERTIA_SIZE;
257
- const { centerX, centerY } = gfx.viewport;
258
- const id = gfx.surface.addElement({
259
- type: 'shape',
260
- shapeType: 'rect',
261
- filled: true,
262
- fillColor: INERTIA_COLOR,
263
- strokeColor: INERTIA_COLOR,
264
- strokeWidth: 0,
265
- shapeStyle: ShapeStyle.General,
266
- roughness: 0,
267
- radius: 0,
268
- xywh: new Bound(centerX - w / 2, centerY - h / 2, w, h).serialize(),
269
- });
270
- this._track('FrameworkElementAdded', 'node:inertia');
271
- this._finish(id);
272
- }
273
-
274
- /**
275
- * Create a pipeline: a wide thin native rect body (white semi-transparent,
276
- * NON-connectable) + a node-sized square handle straddling its top edge (the
277
- * only connection point, center anchor) + a native text label. The handle and
278
- * label are grouped, then grouped again with the body so the whole pipeline
279
- * moves as one. Pure composition of native elements — no custom type / view.
280
- */
281
- private _createPipeline() {
282
- const { gfx } = this;
283
- if (!gfx.surface) return;
284
-
285
- const { centerX: cx, centerY: cy } = gfx.viewport;
286
- const W = PIPELINE_WIDTH;
287
- const H = PIPELINE_HEIGHT;
288
- const d = HANDLE_SIZE;
289
- const top = cy - H / 2;
290
-
291
- // Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
292
- const bodyId = gfx.surface.addElement({
293
- type: 'wardleyNode',
294
- kind: 'pipeline',
295
- shapeType: 'rect',
296
- filled: true,
297
- fillColor: PIPELINE_FILL,
298
- strokeColor: NODE_STROKE,
299
- strokeWidth: NODE_STROKE_WIDTH,
300
- shapeStyle: ShapeStyle.General,
301
- roughness: 0,
302
- radius: 0,
303
- xywh: new Bound(cx - W / 2, top, W, H).serialize(),
304
- });
305
-
306
- // Handle: a node-sized WardleyNode square straddling the top edge. Inherits
307
- // `centerAnchorOnly` so connectors attach to its center only.
308
- const handleId = gfx.surface.addElement({
309
- type: 'wardleyNode',
310
- kind: 'handle',
311
- shapeType: 'rect',
312
- filled: true,
313
- fillColor: NODE_FILL,
314
- strokeColor: NODE_STROKE,
315
- strokeWidth: NODE_STROKE_WIDTH,
316
- shapeStyle: ShapeStyle.General,
317
- roughness: 0,
318
- radius: 0,
319
- xywh: new Bound(cx - d / 2, top - d / 2, d, d).serialize(),
320
- });
321
-
322
- // Label centered horizontally on the pipeline, sitting ABOVE the handle.
323
- const labelId = this._addLabel(
324
- gfx.surface,
325
- PIPELINE_LABEL,
326
- cx - 60,
327
- top - d / 2 - LABEL_H - LABEL_GAP,
328
- 'center'
329
- );
330
-
331
- // Nested groups: (handle + label), then (body + that group).
332
- const innerId = this._group([handleId, labelId]);
333
- this._track('FrameworkElementAdded', 'node:pipeline');
334
- this._finish(this._group([bodyId, innerId]));
335
- }
336
-
337
- /**
338
- * Create a market: a large thin-bordered circle (the connectable market node)
339
- * containing 3 small thick-bordered component nodes wired into a triangle by
340
- * native attached connectors (thin, dark, no arrows — they auto-route between
341
- * the node centers and follow on move/resize). A label sits to the right and
342
- * everything is grouped into one object.
343
- */
344
- private _createMarket() {
345
- const surface = this.gfx.surface;
346
- if (!surface) return;
347
-
348
- const { centerX: cx, centerY: cy } = this.gfx.viewport;
349
- const R = MARKET_SIZE / 2;
350
- const rho = MARKET_DOT_RING;
351
- const sin60 = Math.sqrt(3) / 2;
352
-
353
- // Outer circle = the market node (connectable, center-only).
354
- const circleId = this._addEllipseNode(
355
- surface,
356
- 'market',
357
- cx,
358
- cy,
359
- MARKET_SIZE,
360
- NODE_FILL
361
- );
362
-
363
- // 3 inner component nodes (thick border, no label) at the triangle vertices.
364
- const verts = [
365
- [0, -rho],
366
- [rho * sin60, rho / 2],
367
- [-rho * sin60, rho / 2],
368
- ];
369
- const dotIds = verts.map(([vx, vy]) =>
370
- this._addEllipseNode(
371
- surface,
372
- 'component',
373
- cx + vx,
374
- cy + vy,
375
- MARKET_DOT_SIZE,
376
- NODE_FILL,
377
- MARKET_DOT_STROKE_WIDTH
378
- )
379
- );
380
-
381
- // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
382
- const connIds = [
383
- [dotIds[0], dotIds[1]],
384
- [dotIds[1], dotIds[2]],
385
- [dotIds[2], dotIds[0]],
386
- ].map(([a, b]) =>
387
- surface.addElement({
388
- type: 'connector',
389
- mode: ConnectorMode.Straight,
390
- source: { id: a },
391
- target: { id: b },
392
- stroke: MARKET_LINK_COLOR,
393
- strokeStyle: StrokeStyle.Solid,
394
- strokeWidth: MARKET_LINK_WIDTH,
395
- frontEndpointStyle: PointStyle.None,
396
- rearEndpointStyle: PointStyle.None,
397
- })
398
- );
399
-
400
- const labelId = this._addLabel(
401
- surface,
402
- MARKET_LABEL,
403
- cx + R + LABEL_GAP,
404
- cy - LABEL_H / 2
405
- );
406
-
407
- this._track('FrameworkElementAdded', 'node:market');
408
- this._finish(this._group([circleId, ...dotIds, ...connIds, labelId]));
409
- }
410
-
411
- /**
412
- * Activate the native connector tool, pre-styled for a Wardley link (grey,
413
- * solid, no arrow) or evolution arrow (red, dashed, FILLED triangle). The
414
- * user then draws from one node to another (endpoints attach to centers).
415
- */
416
- private _activateConnector(kind: 'link' | 'arrow') {
417
- const props =
418
- kind === 'arrow'
419
- ? {
420
- mode: ConnectorMode.Straight,
421
- stroke: WARDLEY_RED,
422
- strokeStyle: StrokeStyle.Dash,
423
- strokeWidth: LINK_STROKE_WIDTH,
424
- frontEndpointStyle: PointStyle.None,
425
- rearEndpointStyle: PointStyle.Triangle,
426
- }
427
- : {
428
- mode: ConnectorMode.Straight,
429
- stroke: LINK_GREY,
430
- strokeStyle: StrokeStyle.Solid,
431
- strokeWidth: LINK_STROKE_WIDTH,
432
- frontEndpointStyle: PointStyle.None,
433
- rearEndpointStyle: PointStyle.None,
434
- };
435
- this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
436
- this._track('FrameworkToolPicked', `connector:${kind}`);
437
- this.gfx.tool.setTool(ConnectorTool, { mode: ConnectorMode.Straight });
438
- // Keep the palette open (native sub-menu behaviour): it only closes on
439
- // re-click of the senior button, another senior tool, or Escape.
440
- }
441
-
442
- private _finish(id: string) {
443
- const { gfx } = this;
444
- gfx.doc.captureSync();
445
- gfx.tool.setTool(DefaultTool);
446
- gfx.selection.set({ elements: [id], editing: false });
447
- // Keep the palette open (native sub-menu behaviour) so several Wardley
448
- // objects can be added in a row; the canvas stays selectable meanwhile.
449
- }
450
-
451
- private _track(
452
- event: 'FrameworkElementAdded' | 'FrameworkToolPicked',
453
- element: string
454
- ) {
455
- this.edgeless.std.getOptional(TelemetryProvider)?.track(event, {
456
- framework: 'wardley',
457
- element,
458
- page: 'whiteboard editor',
459
- segment: 'wardley toolbox',
460
- module: 'wardley menu',
461
- });
462
- }
463
-
464
- override render() {
465
- return html`
466
- <edgeless-slide-menu>
467
- <div class="menu-content">
468
- <div class="button-group-container">
469
- <edgeless-tool-icon-button
470
- .tooltip=${'Wardley map background'}
471
- @click=${() => this._createBackground('classic')}
472
- >
473
- ${wardleyBackgroundIcon}
474
- </edgeless-tool-icon-button>
475
- <edgeless-tool-icon-button
476
- .tooltip=${'Opportunity background (gradient)'}
477
- @click=${() => this._createBackground('opportunity')}
478
- >
479
- ${wardleyOpportunityIcon}
480
- </edgeless-tool-icon-button>
481
- <edgeless-tool-icon-button
482
- .tooltip=${'Benefit / Investment background (gradient)'}
483
- @click=${() => this._createBackground('benefit')}
484
- >
485
- ${wardleyBenefitIcon}
486
- </edgeless-tool-icon-button>
487
- <edgeless-tool-icon-button
488
- .tooltip=${'Evolution background (Wardley presentation)'}
489
- @click=${() => this._createBackground('evolution-gradient')}
490
- >
491
- ${wardleyEvolutionGradientIcon}
492
- </edgeless-tool-icon-button>
493
- <edgeless-tool-icon-button
494
- .tooltip=${'Component'}
495
- @click=${() => this._createNode('component')}
496
- >
497
- ${wardleyComponentIcon}
498
- </edgeless-tool-icon-button>
499
- <edgeless-tool-icon-button
500
- .tooltip=${'Component + method'}
501
- @click=${() => this._createNode('method')}
502
- >
503
- ${wardleyMethodIcon}
504
- </edgeless-tool-icon-button>
505
- <edgeless-tool-icon-button
506
- .tooltip=${'Market'}
507
- @click=${this._createMarket}
508
- >
509
- ${wardleyMarketIcon}
510
- </edgeless-tool-icon-button>
511
- <edgeless-tool-icon-button
512
- .tooltip=${'Ecosystem'}
513
- @click=${() => this._createNode('ecosystem')}
514
- >
515
- ${wardleyEcosystemIcon}
516
- </edgeless-tool-icon-button>
517
- <edgeless-tool-icon-button
518
- .tooltip=${'Anchor'}
519
- @click=${() => this._createNode('anchor')}
520
- >
521
- ${wardleyAnchorIcon}
522
- </edgeless-tool-icon-button>
523
- <edgeless-tool-icon-button
524
- .tooltip=${'Pipeline'}
525
- @click=${this._createPipeline}
526
- >
527
- ${wardleyPipelineIcon}
528
- </edgeless-tool-icon-button>
529
- <edgeless-tool-icon-button
530
- .tooltip=${'Link'}
531
- @click=${() => this._activateConnector('link')}
532
- >
533
- ${wardleyLinkIcon}
534
- </edgeless-tool-icon-button>
535
- <edgeless-tool-icon-button
536
- .tooltip=${'Arrow (evolution)'}
537
- @click=${() => this._activateConnector('arrow')}
538
- >
539
- ${wardleyArrowIcon}
540
- </edgeless-tool-icon-button>
541
- <edgeless-tool-icon-button
542
- .tooltip=${'Inertia'}
543
- @click=${this._createInertia}
544
- >
545
- ${wardleyInertiaIcon}
546
- </edgeless-tool-icon-button>
547
- </div>
548
- </div>
549
- </edgeless-slide-menu>
550
- `;
551
- }
552
- }
1
+ import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
2
+ import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
3
+ import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
4
+ import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
5
+ import {
6
+ ConnectorMode,
7
+ FontFamily,
8
+ PointStyle,
9
+ ShapeStyle,
10
+ StrokeStyle,
11
+ type WardleyBgVariant,
12
+ } from '@formicoidea/labre-core/model';
13
+ import {
14
+ EditPropsStore,
15
+ TelemetryProvider,
16
+ } from '@formicoidea/labre-core/shared/services';
17
+ import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
18
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
19
+ import type { GfxController } from '@formicoidea/labre-core/std/gfx';
20
+ import { css, html, LitElement } from 'lit';
21
+
22
+ import { REF_WIDTH } from '../consts';
23
+ import {
24
+ ECOSYSTEM_LABEL,
25
+ ECOSYSTEM_SIZE,
26
+ HANDLE_SIZE,
27
+ INERTIA_COLOR,
28
+ INERTIA_SIZE,
29
+ LABEL_DEFAULT,
30
+ LABEL_FONT_SIZE,
31
+ LABEL_GAP,
32
+ LINK_GREY,
33
+ LINK_STROKE_WIDTH,
34
+ MARKET_DOT_RING,
35
+ MARKET_DOT_SIZE,
36
+ MARKET_DOT_STROKE_WIDTH,
37
+ MARKET_LABEL,
38
+ MARKET_LINK_COLOR,
39
+ MARKET_LINK_WIDTH,
40
+ MARKET_SIZE,
41
+ METHOD_FILL,
42
+ METHOD_LABEL,
43
+ METHOD_SIZE,
44
+ NODE_FILL,
45
+ NODE_SIZE,
46
+ NODE_STROKE,
47
+ NODE_STROKE_WIDTH,
48
+ PIPELINE_FILL,
49
+ PIPELINE_HEIGHT,
50
+ PIPELINE_LABEL,
51
+ PIPELINE_WIDTH,
52
+ WARDLEY_RED,
53
+ } from '../node/consts';
54
+ import {
55
+ wardleyAnchorIcon,
56
+ wardleyArrowIcon,
57
+ wardleyBackgroundIcon,
58
+ wardleyBenefitIcon,
59
+ wardleyComponentIcon,
60
+ wardleyEcosystemIcon,
61
+ wardleyInertiaIcon,
62
+ wardleyLinkIcon,
63
+ wardleyMarketIcon,
64
+ wardleyMethodIcon,
65
+ wardleyEvolutionGradientIcon,
66
+ wardleyOpportunityIcon,
67
+ wardleyPipelineIcon,
68
+ } from './icons';
69
+
70
+ /**
71
+ * Per-variant default label overrides applied at creation (all remain editable
72
+ * afterwards via the inline editor / toggles). The gradient itself is driven by
73
+ * `variant` in the renderer.
74
+ */
75
+ const BACKGROUND_VARIANT_DEFAULTS: Record<
76
+ WardleyBgVariant,
77
+ Record<string, unknown>
78
+ > = {
79
+ classic: {},
80
+ // The Y axis becomes "Opportunity"; phase labels keep the classic defaults.
81
+ opportunity: {
82
+ yAxisTitle: 'Opportunity',
83
+ showVisibilityLabels: false,
84
+ showCornerLabels: false,
85
+ },
86
+ // The Y axis splits into Benefit (top) / Investment (bottom) around a zero
87
+ // line drawn by the renderer.
88
+ benefit: {
89
+ yAxisTitle: '',
90
+ visibilityHigh: 'Benefit',
91
+ visibilityLow: 'Investment',
92
+ showCornerLabels: false,
93
+ },
94
+ // Keeps the classic labels (Value Chain / Uncharted / Industrialized…); only
95
+ // the grey gradient differs.
96
+ 'evolution-gradient': {},
97
+ };
98
+
99
+ type Surface = NonNullable<GfxController['surface']>;
100
+
101
+ /** Height of the native free-text labels (Inter, size 18). */
102
+ const LABEL_H = LABEL_FONT_SIZE + 8;
103
+
104
+ /**
105
+ * The single-circle node flavours: one connectable ellipse + a label to its
106
+ * right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
107
+ * method inner circle) is drawn by the node renderer from `kind`.
108
+ */
109
+ const NODE_PRESETS = {
110
+ component: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.component },
111
+ anchor: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.anchor },
112
+ // Ecosystem: glyph = double border + hatched donut; connectors attach to
113
+ // this outer circle's center.
114
+ ecosystem: { d: ECOSYSTEM_SIZE, fill: NODE_FILL, label: ECOSYSTEM_LABEL },
115
+ // Method: the FILL color encodes the chosen method (editable).
116
+ method: { d: METHOD_SIZE, fill: METHOD_FILL, label: METHOD_LABEL },
117
+ } as const;
118
+
119
+ /**
120
+ * The popover that opens above the toolbar for the Wardley toolbox. Each item
121
+ * creates a pre-formatted Wardley object. Nodes (component / anchor) are a
122
+ * native ellipse + a native text label, grouped together.
123
+ */
124
+ export class EdgelessWardleyMenu extends EdgelessToolbarToolMixin(LitElement) {
125
+ static override styles = css`
126
+ :host {
127
+ position: absolute;
128
+ display: flex;
129
+ z-index: -1;
130
+ }
131
+ .menu-content {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ }
136
+ .button-group-container {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 14px;
140
+ fill: var(--affine-icon-color);
141
+ }
142
+ .button-group-container svg {
143
+ width: 24px;
144
+ height: 24px;
145
+ }
146
+ `;
147
+
148
+ override type = EmptyTool;
149
+
150
+ private _createBackground(variant: WardleyBgVariant = 'classic') {
151
+ const { gfx } = this;
152
+ if (!gfx.surface) return;
153
+
154
+ let width = REF_WIDTH;
155
+ for (const el of gfx.surface.getElementsByType('wardley')) {
156
+ const [, , ew, eh] = el.deserializedXYWH;
157
+ width = Math.max(width, ew, (eh * 16) / 9);
158
+ }
159
+ const height = (width * 9) / 16;
160
+
161
+ const { centerX, centerY } = gfx.viewport;
162
+ const id = gfx.surface.addElement({
163
+ type: 'wardley',
164
+ variant,
165
+ ...BACKGROUND_VARIANT_DEFAULTS[variant],
166
+ xywh: new Bound(
167
+ centerX - width / 2,
168
+ centerY - height / 2,
169
+ width,
170
+ height
171
+ ).serialize(),
172
+ });
173
+ this._track('FrameworkElementAdded', `background:${variant}`);
174
+ this._finish(id);
175
+ }
176
+
177
+ /** Add a native ellipse wardley node centred on (cx, cy). */
178
+ private _addEllipseNode(
179
+ surface: Surface,
180
+ kind: keyof typeof NODE_PRESETS | 'market',
181
+ cx: number,
182
+ cy: number,
183
+ d: number,
184
+ fillColor: string,
185
+ strokeWidth = NODE_STROKE_WIDTH
186
+ ) {
187
+ return surface.addElement({
188
+ type: 'wardleyNode',
189
+ kind,
190
+ shapeType: 'ellipse',
191
+ filled: true,
192
+ fillColor,
193
+ strokeColor: NODE_STROKE,
194
+ strokeWidth,
195
+ shapeStyle: ShapeStyle.General,
196
+ roughness: 0,
197
+ xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
198
+ });
199
+ }
200
+
201
+ /** Add a native free-text label (same Inter family as the axis labels). */
202
+ private _addLabel(
203
+ surface: Surface,
204
+ text: string,
205
+ x: number,
206
+ y: number,
207
+ textAlign: 'left' | 'center' = 'left'
208
+ ) {
209
+ return surface.addElement({
210
+ type: 'text',
211
+ text,
212
+ fontFamily: FontFamily.Inter,
213
+ fontSize: LABEL_FONT_SIZE,
214
+ color: NODE_STROKE,
215
+ textAlign,
216
+ xywh: new Bound(x, y, 120, LABEL_H).serialize(),
217
+ });
218
+ }
219
+
220
+ /** Group elements; returns the group id (or the first id if grouping failed). */
221
+ private _group(ids: string[]) {
222
+ const [, result] = this.edgeless.std.command.exec(createGroupCommand, {
223
+ elements: ids,
224
+ });
225
+ return result.groupId || ids[0];
226
+ }
227
+
228
+ /**
229
+ * Create a single-circle node (component / anchor / ecosystem / method):
230
+ * one connectable native ellipse + a label to its right, grouped so they
231
+ * move together (enter the group to reposition / edit the label).
232
+ */
233
+ private _createNode(kind: keyof typeof NODE_PRESETS) {
234
+ const surface = this.gfx.surface;
235
+ if (!surface) return;
236
+
237
+ const { d, fill, label } = NODE_PRESETS[kind];
238
+ const { centerX: cx, centerY: cy } = this.gfx.viewport;
239
+
240
+ const nodeId = this._addEllipseNode(surface, kind, cx, cy, d, fill);
241
+ const labelId = this._addLabel(
242
+ surface,
243
+ label,
244
+ cx + d / 2 + LABEL_GAP,
245
+ cy - LABEL_H / 2
246
+ );
247
+
248
+ this._track('FrameworkElementAdded', `node:${kind}`);
249
+ this._finish(this._group([nodeId, labelId]));
250
+ }
251
+
252
+ private _createInertia() {
253
+ const { gfx } = this;
254
+ if (!gfx.surface) return;
255
+
256
+ const { w, h } = INERTIA_SIZE;
257
+ const { centerX, centerY } = gfx.viewport;
258
+ const id = gfx.surface.addElement({
259
+ type: 'shape',
260
+ shapeType: 'rect',
261
+ filled: true,
262
+ fillColor: INERTIA_COLOR,
263
+ strokeColor: INERTIA_COLOR,
264
+ strokeWidth: 0,
265
+ shapeStyle: ShapeStyle.General,
266
+ roughness: 0,
267
+ radius: 0,
268
+ xywh: new Bound(centerX - w / 2, centerY - h / 2, w, h).serialize(),
269
+ });
270
+ this._track('FrameworkElementAdded', 'node:inertia');
271
+ this._finish(id);
272
+ }
273
+
274
+ /**
275
+ * Create a pipeline: a wide thin native rect body (white semi-transparent,
276
+ * NON-connectable) + a node-sized square handle straddling its top edge (the
277
+ * only connection point, center anchor) + a native text label. The handle and
278
+ * label are grouped, then grouped again with the body so the whole pipeline
279
+ * moves as one. Pure composition of native elements — no custom type / view.
280
+ */
281
+ private _createPipeline() {
282
+ const { gfx } = this;
283
+ if (!gfx.surface) return;
284
+
285
+ const { centerX: cx, centerY: cy } = gfx.viewport;
286
+ const W = PIPELINE_WIDTH;
287
+ const H = PIPELINE_HEIGHT;
288
+ const d = HANDLE_SIZE;
289
+ const top = cy - H / 2;
290
+
291
+ // Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
292
+ const bodyId = gfx.surface.addElement({
293
+ type: 'wardleyNode',
294
+ kind: 'pipeline',
295
+ shapeType: 'rect',
296
+ filled: true,
297
+ fillColor: PIPELINE_FILL,
298
+ strokeColor: NODE_STROKE,
299
+ strokeWidth: NODE_STROKE_WIDTH,
300
+ shapeStyle: ShapeStyle.General,
301
+ roughness: 0,
302
+ radius: 0,
303
+ xywh: new Bound(cx - W / 2, top, W, H).serialize(),
304
+ });
305
+
306
+ // Handle: a node-sized WardleyNode square straddling the top edge. Inherits
307
+ // `centerAnchorOnly` so connectors attach to its center only.
308
+ const handleId = gfx.surface.addElement({
309
+ type: 'wardleyNode',
310
+ kind: 'handle',
311
+ shapeType: 'rect',
312
+ filled: true,
313
+ fillColor: NODE_FILL,
314
+ strokeColor: NODE_STROKE,
315
+ strokeWidth: NODE_STROKE_WIDTH,
316
+ shapeStyle: ShapeStyle.General,
317
+ roughness: 0,
318
+ radius: 0,
319
+ xywh: new Bound(cx - d / 2, top - d / 2, d, d).serialize(),
320
+ });
321
+
322
+ // Label centered horizontally on the pipeline, sitting ABOVE the handle.
323
+ const labelId = this._addLabel(
324
+ gfx.surface,
325
+ PIPELINE_LABEL,
326
+ cx - 60,
327
+ top - d / 2 - LABEL_H - LABEL_GAP,
328
+ 'center'
329
+ );
330
+
331
+ // Nested groups: (handle + label), then (body + that group).
332
+ const innerId = this._group([handleId, labelId]);
333
+ this._track('FrameworkElementAdded', 'node:pipeline');
334
+ this._finish(this._group([bodyId, innerId]));
335
+ }
336
+
337
+ /**
338
+ * Create a market: a large thin-bordered circle (the connectable market node)
339
+ * containing 3 small thick-bordered component nodes wired into a triangle by
340
+ * native attached connectors (thin, dark, no arrows — they auto-route between
341
+ * the node centers and follow on move/resize). A label sits to the right and
342
+ * everything is grouped into one object.
343
+ */
344
+ private _createMarket() {
345
+ const surface = this.gfx.surface;
346
+ if (!surface) return;
347
+
348
+ const { centerX: cx, centerY: cy } = this.gfx.viewport;
349
+ const R = MARKET_SIZE / 2;
350
+ const rho = MARKET_DOT_RING;
351
+ const sin60 = Math.sqrt(3) / 2;
352
+
353
+ // Outer circle = the market node (connectable, center-only).
354
+ const circleId = this._addEllipseNode(
355
+ surface,
356
+ 'market',
357
+ cx,
358
+ cy,
359
+ MARKET_SIZE,
360
+ NODE_FILL
361
+ );
362
+
363
+ // 3 inner component nodes (thick border, no label) at the triangle vertices.
364
+ const verts = [
365
+ [0, -rho],
366
+ [rho * sin60, rho / 2],
367
+ [-rho * sin60, rho / 2],
368
+ ];
369
+ const dotIds = verts.map(([vx, vy]) =>
370
+ this._addEllipseNode(
371
+ surface,
372
+ 'component',
373
+ cx + vx,
374
+ cy + vy,
375
+ MARKET_DOT_SIZE,
376
+ NODE_FILL,
377
+ MARKET_DOT_STROKE_WIDTH
378
+ )
379
+ );
380
+
381
+ // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
382
+ const connIds = [
383
+ [dotIds[0], dotIds[1]],
384
+ [dotIds[1], dotIds[2]],
385
+ [dotIds[2], dotIds[0]],
386
+ ].map(([a, b]) =>
387
+ surface.addElement({
388
+ type: 'connector',
389
+ mode: ConnectorMode.Straight,
390
+ source: { id: a },
391
+ target: { id: b },
392
+ stroke: MARKET_LINK_COLOR,
393
+ strokeStyle: StrokeStyle.Solid,
394
+ strokeWidth: MARKET_LINK_WIDTH,
395
+ frontEndpointStyle: PointStyle.None,
396
+ rearEndpointStyle: PointStyle.None,
397
+ })
398
+ );
399
+
400
+ const labelId = this._addLabel(
401
+ surface,
402
+ MARKET_LABEL,
403
+ cx + R + LABEL_GAP,
404
+ cy - LABEL_H / 2
405
+ );
406
+
407
+ this._track('FrameworkElementAdded', 'node:market');
408
+ this._finish(this._group([circleId, ...dotIds, ...connIds, labelId]));
409
+ }
410
+
411
+ /**
412
+ * Activate the native connector tool, pre-styled for a Wardley link (grey,
413
+ * solid, no arrow) or evolution arrow (red, dashed, FILLED triangle). The
414
+ * user then draws from one node to another (endpoints attach to centers).
415
+ */
416
+ private _activateConnector(kind: 'link' | 'arrow') {
417
+ const props =
418
+ kind === 'arrow'
419
+ ? {
420
+ mode: ConnectorMode.Straight,
421
+ stroke: WARDLEY_RED,
422
+ strokeStyle: StrokeStyle.Dash,
423
+ strokeWidth: LINK_STROKE_WIDTH,
424
+ frontEndpointStyle: PointStyle.None,
425
+ rearEndpointStyle: PointStyle.Triangle,
426
+ }
427
+ : {
428
+ mode: ConnectorMode.Straight,
429
+ stroke: LINK_GREY,
430
+ strokeStyle: StrokeStyle.Solid,
431
+ strokeWidth: LINK_STROKE_WIDTH,
432
+ frontEndpointStyle: PointStyle.None,
433
+ rearEndpointStyle: PointStyle.None,
434
+ };
435
+ this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
436
+ this._track('FrameworkToolPicked', `connector:${kind}`);
437
+ this.gfx.tool.setTool(ConnectorTool, { mode: ConnectorMode.Straight });
438
+ // Keep the palette open (native sub-menu behaviour): it only closes on
439
+ // re-click of the senior button, another senior tool, or Escape.
440
+ }
441
+
442
+ private _finish(id: string) {
443
+ const { gfx } = this;
444
+ gfx.doc.captureSync();
445
+ gfx.tool.setTool(DefaultTool);
446
+ gfx.selection.set({ elements: [id], editing: false });
447
+ // Keep the palette open (native sub-menu behaviour) so several Wardley
448
+ // objects can be added in a row; the canvas stays selectable meanwhile.
449
+ }
450
+
451
+ private _track(
452
+ event: 'FrameworkElementAdded' | 'FrameworkToolPicked',
453
+ element: string
454
+ ) {
455
+ this.edgeless.std.getOptional(TelemetryProvider)?.track(event, {
456
+ framework: 'wardley',
457
+ element,
458
+ page: 'whiteboard editor',
459
+ segment: 'wardley toolbox',
460
+ module: 'wardley menu',
461
+ });
462
+ }
463
+
464
+ override render() {
465
+ return html`
466
+ <edgeless-slide-menu>
467
+ <div class="menu-content">
468
+ <div class="button-group-container">
469
+ <edgeless-tool-icon-button
470
+ .tooltip=${'Wardley map background'}
471
+ @click=${() => this._createBackground('classic')}
472
+ >
473
+ ${wardleyBackgroundIcon}
474
+ </edgeless-tool-icon-button>
475
+ <edgeless-tool-icon-button
476
+ .tooltip=${'Opportunity background (gradient)'}
477
+ @click=${() => this._createBackground('opportunity')}
478
+ >
479
+ ${wardleyOpportunityIcon}
480
+ </edgeless-tool-icon-button>
481
+ <edgeless-tool-icon-button
482
+ .tooltip=${'Benefit / Investment background (gradient)'}
483
+ @click=${() => this._createBackground('benefit')}
484
+ >
485
+ ${wardleyBenefitIcon}
486
+ </edgeless-tool-icon-button>
487
+ <edgeless-tool-icon-button
488
+ .tooltip=${'Evolution background (Wardley presentation)'}
489
+ @click=${() => this._createBackground('evolution-gradient')}
490
+ >
491
+ ${wardleyEvolutionGradientIcon}
492
+ </edgeless-tool-icon-button>
493
+ <edgeless-tool-icon-button
494
+ .tooltip=${'Component'}
495
+ @click=${() => this._createNode('component')}
496
+ >
497
+ ${wardleyComponentIcon}
498
+ </edgeless-tool-icon-button>
499
+ <edgeless-tool-icon-button
500
+ .tooltip=${'Component + method'}
501
+ @click=${() => this._createNode('method')}
502
+ >
503
+ ${wardleyMethodIcon}
504
+ </edgeless-tool-icon-button>
505
+ <edgeless-tool-icon-button
506
+ .tooltip=${'Market'}
507
+ @click=${this._createMarket}
508
+ >
509
+ ${wardleyMarketIcon}
510
+ </edgeless-tool-icon-button>
511
+ <edgeless-tool-icon-button
512
+ .tooltip=${'Ecosystem'}
513
+ @click=${() => this._createNode('ecosystem')}
514
+ >
515
+ ${wardleyEcosystemIcon}
516
+ </edgeless-tool-icon-button>
517
+ <edgeless-tool-icon-button
518
+ .tooltip=${'Anchor'}
519
+ @click=${() => this._createNode('anchor')}
520
+ >
521
+ ${wardleyAnchorIcon}
522
+ </edgeless-tool-icon-button>
523
+ <edgeless-tool-icon-button
524
+ .tooltip=${'Pipeline'}
525
+ @click=${this._createPipeline}
526
+ >
527
+ ${wardleyPipelineIcon}
528
+ </edgeless-tool-icon-button>
529
+ <edgeless-tool-icon-button
530
+ .tooltip=${'Link'}
531
+ @click=${() => this._activateConnector('link')}
532
+ >
533
+ ${wardleyLinkIcon}
534
+ </edgeless-tool-icon-button>
535
+ <edgeless-tool-icon-button
536
+ .tooltip=${'Arrow (evolution)'}
537
+ @click=${() => this._activateConnector('arrow')}
538
+ >
539
+ ${wardleyArrowIcon}
540
+ </edgeless-tool-icon-button>
541
+ <edgeless-tool-icon-button
542
+ .tooltip=${'Inertia'}
543
+ @click=${this._createInertia}
544
+ >
545
+ ${wardleyInertiaIcon}
546
+ </edgeless-tool-icon-button>
547
+ </div>
548
+ </div>
549
+ </edgeless-slide-menu>
550
+ `;
551
+ }
552
+ }