@formicoidea/labre-framework-wardley 0.23.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/package.json +24 -0
- package/src/consts.ts +72 -0
- package/src/descriptor.ts +8 -0
- package/src/effects.ts +17 -0
- package/src/element-renderer.ts +242 -0
- package/src/element-view.ts +143 -0
- package/src/gradient.ts +137 -0
- package/src/index.ts +1 -0
- package/src/label-layout.ts +126 -0
- package/src/legend.ts +438 -0
- package/src/node/consts.ts +109 -0
- package/src/node/node-renderer.ts +142 -0
- package/src/node/node-view.ts +11 -0
- package/src/templates/index.ts +236 -0
- package/src/templates/maps.ts +283 -0
- package/src/toolbar/config.ts +280 -0
- package/src/toolbar/icons.ts +150 -0
- package/src/toolbar/node-config.ts +28 -0
- package/src/toolbar/senior-tool.ts +11 -0
- package/src/toolbar/wardley-menu.ts +552 -0
- package/src/toolbar/wardley-senior-button.ts +154 -0
- package/src/view.ts +39 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
|
|
3
|
+
import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
|
|
4
|
+
import { SignalWatcher } from '@formicoidea/labre-core/global/lit';
|
|
5
|
+
import { css, html, LitElement } from 'lit';
|
|
6
|
+
|
|
7
|
+
import { wardleyToolbarIcon } from './icons';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main toolbar button (colored proposal-B icon) that opens the Wardley toolbox
|
|
11
|
+
* sub-menu above the toolbar. Styled like the other senior tools: the tile fills
|
|
12
|
+
* the 96×64 slot, is anchored to the bottom so it "rises from below", and grows
|
|
13
|
+
* slightly on hover.
|
|
14
|
+
*/
|
|
15
|
+
export class EdgelessWardleySeniorButton extends EdgelessToolbarToolMixin(
|
|
16
|
+
SignalWatcher(LitElement)
|
|
17
|
+
) {
|
|
18
|
+
static override styles = css`
|
|
19
|
+
:host,
|
|
20
|
+
.wardley-button {
|
|
21
|
+
display: block;
|
|
22
|
+
width: 100%;
|
|
23
|
+
height: 100%;
|
|
24
|
+
}
|
|
25
|
+
/* Make this 96px button the containing block of the popup's clip wrapper
|
|
26
|
+
(it is appended to our shadow root) so the sub-menu anchors to THIS
|
|
27
|
+
button — not the whole toolbar — and can be centered over it. */
|
|
28
|
+
:host {
|
|
29
|
+
position: relative;
|
|
30
|
+
}
|
|
31
|
+
.wardley-root {
|
|
32
|
+
width: 100%;
|
|
33
|
+
height: 64px;
|
|
34
|
+
position: relative;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: flex-end;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
}
|
|
41
|
+
.wardley-card {
|
|
42
|
+
--y: -4px;
|
|
43
|
+
--s: 1;
|
|
44
|
+
position: absolute;
|
|
45
|
+
bottom: 0;
|
|
46
|
+
width: 54px;
|
|
47
|
+
height: 54px;
|
|
48
|
+
transform: translateY(var(--y)) scale(var(--s)); /* base */
|
|
49
|
+
translate: var(--active-x, 0) var(--active-y, 0); /* actif */
|
|
50
|
+
rotate: var(--active-r, -2deg);
|
|
51
|
+
scale: var(--active-s, 1);
|
|
52
|
+
transition: transform 0.3s ease, translate 0.3s ease,
|
|
53
|
+
rotate 0.3s ease, scale 0.3s ease;
|
|
54
|
+
}
|
|
55
|
+
.wardley-card svg {
|
|
56
|
+
display: block;
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: 100%;
|
|
59
|
+
}
|
|
60
|
+
.wardley-root:hover .wardley-card {
|
|
61
|
+
--y: -10px;
|
|
62
|
+
--s: 1.07;
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
override enableActiveBackground = true;
|
|
67
|
+
|
|
68
|
+
// `EmptyTool` is only a sentinel for the mixin's abstract `type`; we never
|
|
69
|
+
// activate it. The menu opens as a palette over the default (selection) tool
|
|
70
|
+
// so the canvas stays fully interactive while it is open — mirroring the
|
|
71
|
+
// native Note/Shape sub-menus.
|
|
72
|
+
override type = EmptyTool;
|
|
73
|
+
|
|
74
|
+
private _toggleMenu() {
|
|
75
|
+
// Toggle on popper presence (not tool-active state): the menu stays open on
|
|
76
|
+
// click-outside and only closes on re-click, another senior tool, or Escape.
|
|
77
|
+
if (this.popper) {
|
|
78
|
+
this.popper.dispose();
|
|
79
|
+
this.popper = null;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.setEdgelessTool(DefaultTool);
|
|
83
|
+
const menu = this.createPopper('edgeless-wardley-menu', this);
|
|
84
|
+
menu.element.edgeless = this.edgeless;
|
|
85
|
+
|
|
86
|
+
// Anchor the sub-menu to THIS button (the clip wrapper is now button-
|
|
87
|
+
// relative thanks to `:host{position:relative}`): make the menu an in-flow
|
|
88
|
+
// flex item sized to its content, centered over the button. Now that other
|
|
89
|
+
// senior tools (EDGY, Cynefin/Estuarine) sit to the right of Wardley, there
|
|
90
|
+
// is room on both sides, so the menu no longer needs to be pinned to the
|
|
91
|
+
// right edge. Native sub-menus are untouched.
|
|
92
|
+
const el = menu.element as HTMLElement;
|
|
93
|
+
const wrap = el.parentElement;
|
|
94
|
+
if (wrap) {
|
|
95
|
+
wrap.style.overflow = 'visible';
|
|
96
|
+
wrap.style.justifyContent = 'center';
|
|
97
|
+
}
|
|
98
|
+
Object.assign(el.style, {
|
|
99
|
+
position: 'static',
|
|
100
|
+
width: 'max-content',
|
|
101
|
+
maxWidth: 'calc(100vw - 16px)',
|
|
102
|
+
marginLeft: '0',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The Wardley menu is wide (~13 items). After layout, right-align its right
|
|
106
|
+
// edge to the right edge of the rightmost senior tool (the right end of the
|
|
107
|
+
// senior toolbar), so it fills the space to the right instead of sitting
|
|
108
|
+
// centered with a gap. The menu then extends leftwards.
|
|
109
|
+
requestAnimationFrame(() => {
|
|
110
|
+
const rect = el.getBoundingClientRect();
|
|
111
|
+
|
|
112
|
+
// Right edge of the rightmost senior tool slot (scan across shadow roots).
|
|
113
|
+
let target = 0;
|
|
114
|
+
const seen = new Set<ShadowRoot>();
|
|
115
|
+
const scan = (root: ParentNode) => {
|
|
116
|
+
root.querySelectorAll('*').forEach(node => {
|
|
117
|
+
const cls = (node as HTMLElement).className;
|
|
118
|
+
if (
|
|
119
|
+
typeof cls === 'string' &&
|
|
120
|
+
cls.split(' ').includes('senior-tool-item')
|
|
121
|
+
) {
|
|
122
|
+
const b = node.getBoundingClientRect();
|
|
123
|
+
if (b.width > 0) target = Math.max(target, b.right);
|
|
124
|
+
}
|
|
125
|
+
const sr = (node as HTMLElement).shadowRoot;
|
|
126
|
+
if (sr && !seen.has(sr)) {
|
|
127
|
+
seen.add(sr);
|
|
128
|
+
scan(sr);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
scan(document);
|
|
133
|
+
|
|
134
|
+
if (target > 0) {
|
|
135
|
+
const dx = Math.round(target - rect.right);
|
|
136
|
+
if (dx) el.style.transform = `translateX(${dx}px)`;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
override render() {
|
|
142
|
+
return html`<edgeless-toolbar-button
|
|
143
|
+
class="wardley-button"
|
|
144
|
+
.tooltip=${this.popper ? '' : 'Wardley map'}
|
|
145
|
+
.tooltipOffset=${4}
|
|
146
|
+
.active=${!!this.popper}
|
|
147
|
+
@click=${this._toggleMenu}
|
|
148
|
+
>
|
|
149
|
+
<div class="wardley-root">
|
|
150
|
+
<div class="wardley-card">${wardleyToolbarIcon}</div>
|
|
151
|
+
</div>
|
|
152
|
+
</edgeless-toolbar-button>`;
|
|
153
|
+
}
|
|
154
|
+
}
|