@formicoidea/labre-framework-wardley 0.23.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/consts.d.ts +72 -0
- package/{src/consts.ts → dist/consts.js} +63 -72
- package/dist/descriptor.d.ts +7 -0
- package/{src/descriptor.ts → dist/descriptor.js} +1 -1
- package/dist/effects.d.ts +10 -0
- package/dist/effects.js +7 -0
- package/dist/element-renderer.d.ts +15 -0
- package/dist/element-renderer.js +160 -0
- package/dist/element-view.d.ts +21 -0
- package/dist/element-view.js +122 -0
- package/dist/gradient.d.ts +18 -0
- package/dist/gradient.js +112 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/label-layout.d.ts +21 -0
- package/dist/label-layout.js +73 -0
- package/dist/legend.d.ts +12 -0
- package/dist/legend.js +333 -0
- package/dist/node/consts.d.ts +107 -0
- package/{src/node/consts.ts → dist/node/consts.js} +12 -20
- package/dist/node/label-editor.d.ts +28 -0
- package/dist/node/label-editor.js +216 -0
- package/dist/node/node-renderer.d.ts +17 -0
- package/dist/node/node-renderer.js +106 -0
- package/{src/node/node-view.ts → dist/node/node-view.d.ts} +3 -3
- package/dist/node/node-view.js +10 -0
- package/dist/templates/index.d.ts +3 -0
- package/dist/templates/index.js +172 -0
- package/dist/templates/maps.d.ts +3 -0
- package/dist/templates/maps.js +247 -0
- package/dist/toolbar/config.d.ts +75 -0
- package/dist/toolbar/config.js +206 -0
- package/dist/toolbar/icons.d.ts +31 -0
- package/{src/toolbar/icons.ts → dist/toolbar/icons.js} +51 -66
- package/dist/toolbar/node-config.d.ts +2 -0
- package/{src/toolbar/node-config.ts → dist/toolbar/node-config.js} +7 -14
- package/dist/toolbar/senior-tool.d.ts +2 -0
- package/{src/toolbar/senior-tool.ts → dist/toolbar/senior-tool.js} +5 -5
- package/dist/toolbar/wardley-menu.d.ts +53 -0
- package/dist/toolbar/wardley-menu.js +408 -0
- package/dist/toolbar/wardley-senior-button.d.ts +18 -0
- package/dist/toolbar/wardley-senior-button.js +146 -0
- package/dist/toolbar/wardley-tool-button.d.ts +10 -0
- package/dist/toolbar/wardley-tool-button.js +123 -0
- package/dist/view.d.ts +7 -0
- package/dist/view.js +36 -0
- package/package.json +15 -6
- package/src/effects.ts +0 -17
- package/src/element-renderer.ts +0 -242
- package/src/element-view.ts +0 -143
- package/src/gradient.ts +0 -137
- package/src/index.ts +0 -1
- package/src/label-layout.ts +0 -126
- package/src/legend.ts +0 -438
- package/src/node/node-renderer.ts +0 -142
- package/src/templates/index.ts +0 -236
- package/src/templates/maps.ts +0 -283
- package/src/toolbar/config.ts +0 -280
- package/src/toolbar/wardley-menu.ts +0 -552
- package/src/toolbar/wardley-senior-button.ts +0 -154
- package/src/view.ts +0 -39
package/src/gradient.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Curve-driven gradient backgrounds (Slice C). Each analytic background is a
|
|
3
|
-
* smooth mathematical curve (piecewise asymmetric Gaussian bells); the gradient
|
|
4
|
-
* opacity at each evolution position X follows that curve, normalised between
|
|
5
|
-
* its own min and max — i.e. the gradient is strongest where the curve peaks and
|
|
6
|
-
* fades to nothing at its minimum. Validated against the reference images at
|
|
7
|
-
* `../wardley-mockups/gradient-backgrounds.html`.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const bell = (x: number, mu: number, s: number) =>
|
|
11
|
-
Math.exp(-0.5 * ((x - mu) / s) ** 2);
|
|
12
|
-
const asym = (x: number, mu: number, sL: number, sR: number) =>
|
|
13
|
-
Math.exp(-0.5 * ((x - mu) / (x < mu ? sL : sR)) ** 2);
|
|
14
|
-
|
|
15
|
-
// Opportunity — differential value (green): early peak + long decay.
|
|
16
|
-
const fDiff = (x: number) => asym(x, 0.175, 0.24, 0.36);
|
|
17
|
-
const DIFF_DOM: readonly [number, number] = [0, 0.86];
|
|
18
|
-
// Opportunity — operational value (red): bump centred on commodity.
|
|
19
|
-
const fOper = (x: number) => asym(x, 0.85, 0.1, 0.075);
|
|
20
|
-
const OPER_DOM: readonly [number, number] = [0.62, 1];
|
|
21
|
-
// Benefit / investment (signed): big positive bell − small negative bell.
|
|
22
|
-
const fBen = (x: number) => asym(x, 0.49, 0.17, 0.24) - 0.42 * bell(x, 0.1, 0.075);
|
|
23
|
-
|
|
24
|
-
// Evolution-gradient — Simon Wardley's classic evolution presentation: a
|
|
25
|
-
// symmetric grey "U", strong at both evolution extremes (uncharted /
|
|
26
|
-
// industrialised), fading to white through the build middle.
|
|
27
|
-
const fGrey = (x: number) => {
|
|
28
|
-
const left = Math.max(0, (0.26 - x) / 0.26);
|
|
29
|
-
const right = Math.max(0, (x - 0.64) / 0.36);
|
|
30
|
-
return Math.max(left, right);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function rangeOf(fn: (x: number) => number, x0: number, x1: number) {
|
|
34
|
-
let lo = Infinity;
|
|
35
|
-
let hi = -Infinity;
|
|
36
|
-
for (let i = 0; i <= 240; i++) {
|
|
37
|
-
const v = fn(x0 + ((x1 - x0) * i) / 240);
|
|
38
|
-
if (v < lo) lo = v;
|
|
39
|
-
if (v > hi) hi = v;
|
|
40
|
-
}
|
|
41
|
-
return { lo, hi };
|
|
42
|
-
}
|
|
43
|
-
const RG = rangeOf(fDiff, DIFF_DOM[0], DIFF_DOM[1]);
|
|
44
|
-
const RR = rangeOf(fOper, OPER_DOM[0], OPER_DOM[1]);
|
|
45
|
-
const RB = rangeOf(fBen, 0, 1);
|
|
46
|
-
|
|
47
|
-
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
|
48
|
-
const norm = (v: number, lo: number, hi: number) => (hi > lo ? (v - lo) / (hi - lo) : 0);
|
|
49
|
-
|
|
50
|
-
export const GRADIENT_GREEN = '#1f9e4d';
|
|
51
|
-
export const GRADIENT_RED = '#d6455d';
|
|
52
|
-
const GRADIENT_GREY = '#7c8389';
|
|
53
|
-
/** Validated peak opacity for the green/red variants. */
|
|
54
|
-
const GRADIENT_MAX_OPACITY = 0.45;
|
|
55
|
-
/** Peak opacity for the grey evolution-gradient variant. */
|
|
56
|
-
const GREY_MAX_OPACITY = 0.38;
|
|
57
|
-
/** Benefit/investment zero-line height (fraction of plot height from bottom). */
|
|
58
|
-
export const BENEFIT_ZERO_FRAC = 0.3;
|
|
59
|
-
|
|
60
|
-
function rgba(hex: string, alpha: number) {
|
|
61
|
-
const r = parseInt(hex.slice(1, 3), 16);
|
|
62
|
-
const g = parseInt(hex.slice(3, 5), 16);
|
|
63
|
-
const b = parseInt(hex.slice(5, 7), 16);
|
|
64
|
-
return `rgba(${r},${g},${b},${alpha})`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Add stops to a horizontal gradient (offset 0..1 spanning the plot width) for
|
|
69
|
-
* a function-driven opacity profile within [x0, x1] (zero outside).
|
|
70
|
-
*/
|
|
71
|
-
function addStops(
|
|
72
|
-
grad: CanvasGradient,
|
|
73
|
-
hex: string,
|
|
74
|
-
opacityFn: (x: number) => number,
|
|
75
|
-
x0: number,
|
|
76
|
-
x1: number,
|
|
77
|
-
maxOp: number = GRADIENT_MAX_OPACITY
|
|
78
|
-
) {
|
|
79
|
-
const eps = 0.001;
|
|
80
|
-
if (x0 > eps) grad.addColorStop(Math.max(0, x0 - eps), rgba(hex, 0));
|
|
81
|
-
const N = 48;
|
|
82
|
-
for (let i = 0; i <= N; i++) {
|
|
83
|
-
const x = x0 + ((x1 - x0) * i) / N;
|
|
84
|
-
grad.addColorStop(clamp01(x), rgba(hex, clamp01(opacityFn(x)) * maxOp));
|
|
85
|
-
}
|
|
86
|
-
if (x1 < 1 - eps) grad.addColorStop(Math.min(1, x1 + eps), rgba(hex, 0));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Paint the curve-driven gradient over the plot rectangle [px0,px1]×[py0,py1]
|
|
91
|
-
* in element-local coordinates. `classic` paints nothing.
|
|
92
|
-
*/
|
|
93
|
-
export function paintGradientBackground(
|
|
94
|
-
ctx: CanvasRenderingContext2D,
|
|
95
|
-
variant: 'opportunity' | 'benefit' | 'evolution-gradient',
|
|
96
|
-
px0: number,
|
|
97
|
-
px1: number,
|
|
98
|
-
py0: number,
|
|
99
|
-
py1: number
|
|
100
|
-
) {
|
|
101
|
-
const w = px1 - px0;
|
|
102
|
-
const h = py1 - py0;
|
|
103
|
-
|
|
104
|
-
if (variant === 'evolution-gradient') {
|
|
105
|
-
const grey = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
106
|
-
addStops(grey, GRADIENT_GREY, fGrey, 0, 1, GREY_MAX_OPACITY);
|
|
107
|
-
ctx.fillStyle = grey;
|
|
108
|
-
ctx.fillRect(px0, py0, w, h);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (variant === 'opportunity') {
|
|
113
|
-
const green = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
114
|
-
addStops(green, GRADIENT_GREEN, x => norm(fDiff(x), RG.lo, RG.hi), DIFF_DOM[0], DIFF_DOM[1]);
|
|
115
|
-
ctx.fillStyle = green;
|
|
116
|
-
ctx.fillRect(px0, py0, w, h);
|
|
117
|
-
|
|
118
|
-
const red = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
119
|
-
addStops(red, GRADIENT_RED, x => norm(fOper(x), RR.lo, RR.hi), OPER_DOM[0], OPER_DOM[1]);
|
|
120
|
-
ctx.fillStyle = red;
|
|
121
|
-
ctx.fillRect(px0, py0, w, h);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// benefit: green where the curve is positive, red where negative.
|
|
126
|
-
const maxPos = RB.hi;
|
|
127
|
-
const maxNeg = -RB.lo;
|
|
128
|
-
const green = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
129
|
-
addStops(green, GRADIENT_GREEN, x => Math.max(0, fBen(x)) / maxPos, 0, 1);
|
|
130
|
-
ctx.fillStyle = green;
|
|
131
|
-
ctx.fillRect(px0, py0, w, h);
|
|
132
|
-
|
|
133
|
-
const red = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
134
|
-
addStops(red, GRADIENT_RED, x => Math.max(0, -fBen(x)) / maxNeg, 0, 1);
|
|
135
|
-
ctx.fillStyle = red;
|
|
136
|
-
ctx.fillRect(px0, py0, w, h);
|
|
137
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/src/label-layout.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import type { WardleyBackgroundElementModel } from '@formicoidea/labre-core/model';
|
|
2
|
-
|
|
3
|
-
import { FONTS, MARGIN, OFFSETS } from './consts';
|
|
4
|
-
|
|
5
|
-
/** The editable label fields of a Wardley background. */
|
|
6
|
-
export type WardleyLabelField =
|
|
7
|
-
| 'xAxisTitle'
|
|
8
|
-
| 'yAxisTitle'
|
|
9
|
-
| 'evolutionStart'
|
|
10
|
-
| 'evolutionEnd'
|
|
11
|
-
| 'visibilityHigh'
|
|
12
|
-
| 'visibilityLow'
|
|
13
|
-
| 'phase0'
|
|
14
|
-
| 'phase1'
|
|
15
|
-
| 'phase2'
|
|
16
|
-
| 'phase3';
|
|
17
|
-
|
|
18
|
-
/** A label's hit box in element-local coordinates (axis-aligned, padded). */
|
|
19
|
-
export interface WardleyLabelHit {
|
|
20
|
-
field: WardleyLabelField;
|
|
21
|
-
minX: number;
|
|
22
|
-
minY: number;
|
|
23
|
-
maxX: number;
|
|
24
|
-
maxY: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Rough per-character advance — only used to size generous hit boxes. */
|
|
28
|
-
const approxTextWidth = (text: string, fontSize: number) =>
|
|
29
|
-
Math.max(fontSize, text.length * fontSize * 0.6);
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Compute the clickable boxes of every *visible* Wardley label, in the
|
|
33
|
-
* element's local space. Positions are derived from the SAME constants the
|
|
34
|
-
* renderer uses (`MARGIN` / `OFFSETS` / `FONTS`), so the hit boxes track the
|
|
35
|
-
* drawn text. Boxes are padded so double-clicking is forgiving.
|
|
36
|
-
*/
|
|
37
|
-
export function getWardleyLabelHits(
|
|
38
|
-
model: WardleyBackgroundElementModel,
|
|
39
|
-
w: number,
|
|
40
|
-
h: number
|
|
41
|
-
): WardleyLabelHit[] {
|
|
42
|
-
const px0 = MARGIN.left;
|
|
43
|
-
const px1 = w - MARGIN.right;
|
|
44
|
-
const py0 = MARGIN.top;
|
|
45
|
-
const py1 = h - MARGIN.bottom;
|
|
46
|
-
const ex = (r: number) => px0 + r * (px1 - px0);
|
|
47
|
-
|
|
48
|
-
const pad = 6;
|
|
49
|
-
const hits: WardleyLabelHit[] = [];
|
|
50
|
-
|
|
51
|
-
// Horizontal label anchored on its text baseline.
|
|
52
|
-
const addH = (
|
|
53
|
-
field: WardleyLabelField,
|
|
54
|
-
text: string,
|
|
55
|
-
fontSize: number,
|
|
56
|
-
ax: number,
|
|
57
|
-
baseline: number,
|
|
58
|
-
align: 'left' | 'right'
|
|
59
|
-
) => {
|
|
60
|
-
const tw = approxTextWidth(text, fontSize);
|
|
61
|
-
const minX = align === 'right' ? ax - tw : ax;
|
|
62
|
-
const maxX = align === 'right' ? ax : ax + tw;
|
|
63
|
-
hits.push({
|
|
64
|
-
field,
|
|
65
|
-
minX: minX - pad,
|
|
66
|
-
maxX: maxX + pad,
|
|
67
|
-
minY: baseline - fontSize - pad,
|
|
68
|
-
maxY: baseline + fontSize * 0.3 + pad,
|
|
69
|
-
});
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// Vertical label (drawn rotated -90°), centered on (ax, ay).
|
|
73
|
-
const addV = (
|
|
74
|
-
field: WardleyLabelField,
|
|
75
|
-
text: string,
|
|
76
|
-
fontSize: number,
|
|
77
|
-
ax: number,
|
|
78
|
-
ay: number
|
|
79
|
-
) => {
|
|
80
|
-
const tw = approxTextWidth(text, fontSize);
|
|
81
|
-
hits.push({
|
|
82
|
-
field,
|
|
83
|
-
minX: ax - fontSize - pad,
|
|
84
|
-
maxX: ax + fontSize * 0.4 + pad,
|
|
85
|
-
minY: ay - tw / 2 - pad,
|
|
86
|
-
maxY: ay + tw / 2 + pad,
|
|
87
|
-
});
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
if (model.showColumnLabels) {
|
|
91
|
-
addH('phase0', model.phase0, FONTS.phase, ex(0) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
92
|
-
addH('phase1', model.phase1, FONTS.phase, ex(0.175) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
93
|
-
addH('phase2', model.phase2, FONTS.phase, ex(0.4) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
94
|
-
addH('phase3', model.phase3, FONTS.phase, ex(0.7) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
95
|
-
}
|
|
96
|
-
if (model.showXAxis) {
|
|
97
|
-
addH('xAxisTitle', model.xAxisTitle, FONTS.axis, px1 - OFFSETS.evolutionPadRight, py1 + OFFSETS.phaseBaseline, 'right');
|
|
98
|
-
}
|
|
99
|
-
if (model.showCornerLabels) {
|
|
100
|
-
addH('evolutionStart', model.evolutionStart, FONTS.direction, px0 + OFFSETS.directionPadLeft, py0 + OFFSETS.directionTop, 'left');
|
|
101
|
-
addH('evolutionEnd', model.evolutionEnd, FONTS.direction, px1 - OFFSETS.directionPadRight, py0 + OFFSETS.directionTop, 'right');
|
|
102
|
-
}
|
|
103
|
-
if (model.showYAxis) {
|
|
104
|
-
addV('yAxisTitle', model.yAxisTitle, FONTS.axis, px0 - OFFSETS.yHug, (py0 + py1) / 2);
|
|
105
|
-
}
|
|
106
|
-
if (model.showVisibilityLabels) {
|
|
107
|
-
addV('visibilityHigh', model.visibilityHigh, FONTS.visibility, px0 - OFFSETS.yHug, py0 + OFFSETS.visibleTop);
|
|
108
|
-
addV('visibilityLow', model.visibilityLow, FONTS.visibility, px0 - OFFSETS.yHug, py1 - OFFSETS.invisibleBottom);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return hits;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** First label whose (padded) box contains the local point, or null. */
|
|
115
|
-
export function hitTestWardleyLabel(
|
|
116
|
-
hits: WardleyLabelHit[],
|
|
117
|
-
lx: number,
|
|
118
|
-
ly: number
|
|
119
|
-
): WardleyLabelHit | null {
|
|
120
|
-
for (const hit of hits) {
|
|
121
|
-
if (lx >= hit.minX && lx <= hit.maxX && ly >= hit.minY && ly <= hit.maxY) {
|
|
122
|
-
return hit;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
}
|
package/src/legend.ts
DELETED
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
|
|
2
|
-
import {
|
|
3
|
-
ConnectorElementModel,
|
|
4
|
-
ConnectorMode,
|
|
5
|
-
FontFamily,
|
|
6
|
-
PointStyle,
|
|
7
|
-
ShapeElementModel,
|
|
8
|
-
ShapeStyle,
|
|
9
|
-
StrokeStyle,
|
|
10
|
-
type WardleyBackgroundElementModel,
|
|
11
|
-
WardleyNodeElementModel,
|
|
12
|
-
} from '@formicoidea/labre-core/model';
|
|
13
|
-
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
14
|
-
import type { BlockStdScope } from '@formicoidea/labre-core/std';
|
|
15
|
-
import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
|
|
16
|
-
|
|
17
|
-
import { GRADIENT_GREEN, GRADIENT_RED } from './gradient';
|
|
18
|
-
import {
|
|
19
|
-
INERTIA_COLOR,
|
|
20
|
-
LINK_GREY,
|
|
21
|
-
LINK_STROKE_WIDTH,
|
|
22
|
-
MARKET_DOT_STROKE_WIDTH,
|
|
23
|
-
MARKET_LINK_COLOR,
|
|
24
|
-
MARKET_LINK_WIDTH,
|
|
25
|
-
METHOD_FILL,
|
|
26
|
-
NODE_FILL,
|
|
27
|
-
NODE_STROKE,
|
|
28
|
-
NODE_STROKE_WIDTH,
|
|
29
|
-
PIPELINE_FILL,
|
|
30
|
-
WARDLEY_RED,
|
|
31
|
-
} from './node/consts';
|
|
32
|
-
|
|
33
|
-
/** Component kinds the legend can describe, in display order. */
|
|
34
|
-
type LegendType =
|
|
35
|
-
| 'component'
|
|
36
|
-
| 'anchor'
|
|
37
|
-
| 'market'
|
|
38
|
-
| 'ecosystem'
|
|
39
|
-
| 'method'
|
|
40
|
-
| 'pipeline'
|
|
41
|
-
| 'link'
|
|
42
|
-
| 'arrow'
|
|
43
|
-
| 'inertia';
|
|
44
|
-
|
|
45
|
-
const LEGEND_ORDER: LegendType[] = [
|
|
46
|
-
'component',
|
|
47
|
-
'anchor',
|
|
48
|
-
'market',
|
|
49
|
-
'ecosystem',
|
|
50
|
-
'method',
|
|
51
|
-
'pipeline',
|
|
52
|
-
'link',
|
|
53
|
-
'arrow',
|
|
54
|
-
'inertia',
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
/** Default (editable) descriptions for each legend row. */
|
|
58
|
-
const LEGEND_DESC: Record<LegendType, string> = {
|
|
59
|
-
component: 'Need / capability (activity, practice, data…)',
|
|
60
|
-
anchor: 'Stakeholder (customer, user…)',
|
|
61
|
-
market: 'Market (set of actors)',
|
|
62
|
-
ecosystem: 'Ecosystem',
|
|
63
|
-
method: 'Component + method (color = phase)',
|
|
64
|
-
pipeline: 'Pipeline (possible choices for a capability)',
|
|
65
|
-
link: 'Need relation (parent → child)',
|
|
66
|
-
arrow: 'Evolution / movement (red = future)',
|
|
67
|
-
inertia: 'Inertia to change',
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
type GradientVariant = Exclude<WardleyBackgroundElementModel['variant'], 'classic'>;
|
|
71
|
-
|
|
72
|
-
/** Gradient-meaning block, keyed by variant (caption + 2-colour swatch). */
|
|
73
|
-
const LEGEND_GRADIENT: Record<
|
|
74
|
-
GradientVariant,
|
|
75
|
-
{ caption: string; swatch: [string, string] }
|
|
76
|
-
> = {
|
|
77
|
-
opportunity: {
|
|
78
|
-
caption:
|
|
79
|
-
'Opportunity gradient: differential value (green) vs operational value (red).',
|
|
80
|
-
swatch: [GRADIENT_GREEN, GRADIENT_RED],
|
|
81
|
-
},
|
|
82
|
-
benefit: {
|
|
83
|
-
caption: 'Gradient: investment (red) then benefit (green).',
|
|
84
|
-
swatch: [GRADIENT_RED, GRADIENT_GREEN],
|
|
85
|
-
},
|
|
86
|
-
'evolution-gradient': {
|
|
87
|
-
caption:
|
|
88
|
-
"Gradient representing the growth of Wardley's evolution function.",
|
|
89
|
-
swatch: ['#9aa0a6', '#cfd2d6'],
|
|
90
|
-
},
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Build a "Legend" group from real, editable elements (white rect frame +
|
|
95
|
-
* "Legend" text + one row of [real component glyph + description text] per
|
|
96
|
-
* Wardley component TYPE present inside the background's perimeter + a
|
|
97
|
-
* gradient-meaning block when the background is a gradient variant). A snapshot
|
|
98
|
-
* is created on each call; everything is grouped so it can be moved / resized /
|
|
99
|
-
* edited and is dropped bottom-left of the background.
|
|
100
|
-
*/
|
|
101
|
-
export function createWardleyLegend(
|
|
102
|
-
std: BlockStdScope,
|
|
103
|
-
bg: WardleyBackgroundElementModel
|
|
104
|
-
) {
|
|
105
|
-
const gfx = std.get(GfxControllerIdentifier);
|
|
106
|
-
const surface = gfx.surface;
|
|
107
|
-
if (!surface) return;
|
|
108
|
-
|
|
109
|
-
const [bx, by, , bh] = bg.deserializedXYWH;
|
|
110
|
-
|
|
111
|
-
// 1. Detect which component types are present inside the perimeter.
|
|
112
|
-
const present = new Set<LegendType>();
|
|
113
|
-
for (const el of gfx.getElementsByBound(Bound.deserialize(bg.xywh), {
|
|
114
|
-
type: 'canvas',
|
|
115
|
-
})) {
|
|
116
|
-
// Note: WardleyNodeElementModel extends ShapeElementModel, so the order of
|
|
117
|
-
// these instanceof checks matters.
|
|
118
|
-
if (el instanceof WardleyNodeElementModel) {
|
|
119
|
-
if (el.kind !== 'handle') present.add(el.kind);
|
|
120
|
-
} else if (el instanceof ConnectorElementModel) {
|
|
121
|
-
if (el.strokeStyle === StrokeStyle.Dash || el.stroke === WARDLEY_RED) {
|
|
122
|
-
present.add('arrow');
|
|
123
|
-
} else if (el.stroke === LINK_GREY) {
|
|
124
|
-
present.add('link');
|
|
125
|
-
}
|
|
126
|
-
// market triangle connectors (NODE_STROKE) are ignored.
|
|
127
|
-
} else if (el instanceof ShapeElementModel) {
|
|
128
|
-
if (el.fillColor === INERTIA_COLOR) present.add('inertia');
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const rows = LEGEND_ORDER.filter(t => present.has(t));
|
|
132
|
-
|
|
133
|
-
// 2. Layout (model units). The text column is wide enough for one-line
|
|
134
|
-
// descriptions; the gradient row is taller as its caption may wrap.
|
|
135
|
-
const PAD = 16;
|
|
136
|
-
const TITLE_H = 28;
|
|
137
|
-
const ROW_H = 30;
|
|
138
|
-
const GLYPH_W = 46;
|
|
139
|
-
const GAP = 12;
|
|
140
|
-
const TEXT_FS = 15;
|
|
141
|
-
const TITLE_FS = 18;
|
|
142
|
-
const TEXT_W = 360;
|
|
143
|
-
const GRAD_ROW_H = 40;
|
|
144
|
-
const W = PAD * 2 + GLYPH_W + GAP + TEXT_W;
|
|
145
|
-
|
|
146
|
-
const variant = bg.variant;
|
|
147
|
-
const grad = variant !== 'classic' ? LEGEND_GRADIENT[variant] : null;
|
|
148
|
-
const gradH = grad ? 12 + GRAD_ROW_H : 0;
|
|
149
|
-
const H = PAD * 2 + TITLE_H + rows.length * ROW_H + gradH;
|
|
150
|
-
|
|
151
|
-
const x0 = bx + 50;
|
|
152
|
-
const y0 = by + bh - 56 - H;
|
|
153
|
-
|
|
154
|
-
const text = (
|
|
155
|
-
t: string,
|
|
156
|
-
x: number,
|
|
157
|
-
y: number,
|
|
158
|
-
w: number,
|
|
159
|
-
h: number,
|
|
160
|
-
fontSize: number,
|
|
161
|
-
align: 'left' | 'center' = 'left'
|
|
162
|
-
) =>
|
|
163
|
-
surface.addElement({
|
|
164
|
-
type: 'text',
|
|
165
|
-
text: t,
|
|
166
|
-
fontFamily: FontFamily.Inter,
|
|
167
|
-
fontSize,
|
|
168
|
-
color: NODE_STROKE,
|
|
169
|
-
textAlign: align,
|
|
170
|
-
xywh: new Bound(x, y, w, h).serialize(),
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// ── glyph builders (real, editable elements), centred on (cx, cy) ─────
|
|
174
|
-
const ellipse = (
|
|
175
|
-
kind: 'component' | 'anchor' | 'ecosystem' | 'method',
|
|
176
|
-
d: number,
|
|
177
|
-
fill: string,
|
|
178
|
-
sw: number,
|
|
179
|
-
cx: number,
|
|
180
|
-
cy: number
|
|
181
|
-
) =>
|
|
182
|
-
surface.addElement({
|
|
183
|
-
type: 'wardleyNode',
|
|
184
|
-
kind,
|
|
185
|
-
shapeType: 'ellipse',
|
|
186
|
-
filled: true,
|
|
187
|
-
fillColor: fill,
|
|
188
|
-
strokeColor: NODE_STROKE,
|
|
189
|
-
strokeWidth: sw,
|
|
190
|
-
shapeStyle: ShapeStyle.General,
|
|
191
|
-
roughness: 0,
|
|
192
|
-
xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
const glyph = (type: LegendType, cx: number, cy: number): string[] => {
|
|
196
|
-
switch (type) {
|
|
197
|
-
case 'component':
|
|
198
|
-
return [ellipse('component', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
|
|
199
|
-
case 'anchor':
|
|
200
|
-
return [ellipse('anchor', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
|
|
201
|
-
case 'ecosystem':
|
|
202
|
-
return [ellipse('ecosystem', 20, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
|
|
203
|
-
case 'method':
|
|
204
|
-
return [ellipse('method', 18, METHOD_FILL, NODE_STROKE_WIDTH, cx, cy)];
|
|
205
|
-
case 'inertia':
|
|
206
|
-
return [
|
|
207
|
-
surface.addElement({
|
|
208
|
-
type: 'shape',
|
|
209
|
-
shapeType: 'rect',
|
|
210
|
-
filled: true,
|
|
211
|
-
fillColor: INERTIA_COLOR,
|
|
212
|
-
strokeColor: INERTIA_COLOR,
|
|
213
|
-
strokeWidth: 0,
|
|
214
|
-
shapeStyle: ShapeStyle.General,
|
|
215
|
-
roughness: 0,
|
|
216
|
-
radius: 0,
|
|
217
|
-
xywh: new Bound(cx - 2.5, cy - 11, 5, 22).serialize(),
|
|
218
|
-
}),
|
|
219
|
-
];
|
|
220
|
-
case 'pipeline': {
|
|
221
|
-
const bw2 = 34;
|
|
222
|
-
const bh2 = 12;
|
|
223
|
-
const hd = 10;
|
|
224
|
-
const top = cy - bh2 / 2;
|
|
225
|
-
return [
|
|
226
|
-
surface.addElement({
|
|
227
|
-
type: 'wardleyNode',
|
|
228
|
-
kind: 'pipeline',
|
|
229
|
-
shapeType: 'rect',
|
|
230
|
-
filled: true,
|
|
231
|
-
fillColor: PIPELINE_FILL,
|
|
232
|
-
strokeColor: NODE_STROKE,
|
|
233
|
-
strokeWidth: NODE_STROKE_WIDTH,
|
|
234
|
-
shapeStyle: ShapeStyle.General,
|
|
235
|
-
roughness: 0,
|
|
236
|
-
radius: 0,
|
|
237
|
-
xywh: new Bound(cx - bw2 / 2, top, bw2, bh2).serialize(),
|
|
238
|
-
}),
|
|
239
|
-
surface.addElement({
|
|
240
|
-
type: 'wardleyNode',
|
|
241
|
-
kind: 'handle',
|
|
242
|
-
shapeType: 'rect',
|
|
243
|
-
filled: true,
|
|
244
|
-
fillColor: NODE_FILL,
|
|
245
|
-
strokeColor: NODE_STROKE,
|
|
246
|
-
strokeWidth: NODE_STROKE_WIDTH,
|
|
247
|
-
shapeStyle: ShapeStyle.General,
|
|
248
|
-
roughness: 0,
|
|
249
|
-
radius: 0,
|
|
250
|
-
xywh: new Bound(cx - hd / 2, top - hd / 2, hd, hd).serialize(),
|
|
251
|
-
}),
|
|
252
|
-
];
|
|
253
|
-
}
|
|
254
|
-
case 'market': {
|
|
255
|
-
const R = 11;
|
|
256
|
-
const dr = 3;
|
|
257
|
-
const rho = 6;
|
|
258
|
-
const sin60 = Math.sqrt(3) / 2;
|
|
259
|
-
const circle = surface.addElement({
|
|
260
|
-
type: 'wardleyNode',
|
|
261
|
-
kind: 'market',
|
|
262
|
-
shapeType: 'ellipse',
|
|
263
|
-
filled: true,
|
|
264
|
-
fillColor: NODE_FILL,
|
|
265
|
-
strokeColor: NODE_STROKE,
|
|
266
|
-
strokeWidth: NODE_STROKE_WIDTH,
|
|
267
|
-
shapeStyle: ShapeStyle.General,
|
|
268
|
-
roughness: 0,
|
|
269
|
-
xywh: new Bound(cx - R, cy - R, R * 2, R * 2).serialize(),
|
|
270
|
-
});
|
|
271
|
-
const verts = [
|
|
272
|
-
[0, -rho],
|
|
273
|
-
[rho * sin60, rho / 2],
|
|
274
|
-
[-rho * sin60, rho / 2],
|
|
275
|
-
];
|
|
276
|
-
const dots = verts.map(([vx, vy]) =>
|
|
277
|
-
surface.addElement({
|
|
278
|
-
type: 'wardleyNode',
|
|
279
|
-
kind: 'component',
|
|
280
|
-
shapeType: 'ellipse',
|
|
281
|
-
filled: true,
|
|
282
|
-
fillColor: NODE_FILL,
|
|
283
|
-
strokeColor: NODE_STROKE,
|
|
284
|
-
strokeWidth: MARKET_DOT_STROKE_WIDTH,
|
|
285
|
-
shapeStyle: ShapeStyle.General,
|
|
286
|
-
roughness: 0,
|
|
287
|
-
xywh: new Bound(cx + vx - dr, cy + vy - dr, dr * 2, dr * 2).serialize(),
|
|
288
|
-
})
|
|
289
|
-
);
|
|
290
|
-
const conns = [
|
|
291
|
-
[dots[0], dots[1]],
|
|
292
|
-
[dots[1], dots[2]],
|
|
293
|
-
[dots[2], dots[0]],
|
|
294
|
-
].map(([a, b]) =>
|
|
295
|
-
surface.addElement({
|
|
296
|
-
type: 'connector',
|
|
297
|
-
mode: ConnectorMode.Straight,
|
|
298
|
-
source: { id: a },
|
|
299
|
-
target: { id: b },
|
|
300
|
-
stroke: MARKET_LINK_COLOR,
|
|
301
|
-
strokeStyle: StrokeStyle.Solid,
|
|
302
|
-
strokeWidth: MARKET_LINK_WIDTH,
|
|
303
|
-
frontEndpointStyle: PointStyle.None,
|
|
304
|
-
rearEndpointStyle: PointStyle.None,
|
|
305
|
-
})
|
|
306
|
-
);
|
|
307
|
-
return [circle, ...dots, ...conns];
|
|
308
|
-
}
|
|
309
|
-
case 'link':
|
|
310
|
-
return [
|
|
311
|
-
surface.addElement({
|
|
312
|
-
type: 'connector',
|
|
313
|
-
mode: ConnectorMode.Straight,
|
|
314
|
-
source: { position: [cx - 18, cy + 6] },
|
|
315
|
-
target: { position: [cx + 18, cy - 6] },
|
|
316
|
-
stroke: LINK_GREY,
|
|
317
|
-
strokeStyle: StrokeStyle.Solid,
|
|
318
|
-
strokeWidth: LINK_STROKE_WIDTH,
|
|
319
|
-
frontEndpointStyle: PointStyle.None,
|
|
320
|
-
rearEndpointStyle: PointStyle.None,
|
|
321
|
-
}),
|
|
322
|
-
];
|
|
323
|
-
case 'arrow':
|
|
324
|
-
return [
|
|
325
|
-
surface.addElement({
|
|
326
|
-
type: 'connector',
|
|
327
|
-
mode: ConnectorMode.Straight,
|
|
328
|
-
source: { position: [cx - 18, cy] },
|
|
329
|
-
target: { position: [cx + 16, cy] },
|
|
330
|
-
stroke: WARDLEY_RED,
|
|
331
|
-
strokeStyle: StrokeStyle.Dash,
|
|
332
|
-
strokeWidth: LINK_STROKE_WIDTH,
|
|
333
|
-
frontEndpointStyle: PointStyle.None,
|
|
334
|
-
rearEndpointStyle: PointStyle.Triangle,
|
|
335
|
-
}),
|
|
336
|
-
];
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
// 3. Create the elements.
|
|
341
|
-
std.store.captureSync();
|
|
342
|
-
const ids: string[] = [];
|
|
343
|
-
|
|
344
|
-
// White frame.
|
|
345
|
-
ids.push(
|
|
346
|
-
surface.addElement({
|
|
347
|
-
type: 'shape',
|
|
348
|
-
shapeType: 'rect',
|
|
349
|
-
filled: true,
|
|
350
|
-
fillColor: '#ffffff',
|
|
351
|
-
strokeColor: '#cfd2d6',
|
|
352
|
-
strokeWidth: 1,
|
|
353
|
-
shapeStyle: ShapeStyle.General,
|
|
354
|
-
roughness: 0,
|
|
355
|
-
radius: 6,
|
|
356
|
-
xywh: new Bound(x0, y0, W, H).serialize(),
|
|
357
|
-
})
|
|
358
|
-
);
|
|
359
|
-
|
|
360
|
-
// Title.
|
|
361
|
-
ids.push(
|
|
362
|
-
text('Legend', x0 + PAD, y0 + PAD, W - PAD * 2, TITLE_FS + 6, TITLE_FS)
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
// Rows.
|
|
366
|
-
let ry = y0 + PAD + TITLE_H;
|
|
367
|
-
for (const t of rows) {
|
|
368
|
-
const cyRow = ry + ROW_H / 2;
|
|
369
|
-
ids.push(...glyph(t, x0 + PAD + GLYPH_W / 2, cyRow));
|
|
370
|
-
ids.push(
|
|
371
|
-
text(
|
|
372
|
-
LEGEND_DESC[t],
|
|
373
|
-
x0 + PAD + GLYPH_W + GAP,
|
|
374
|
-
cyRow - (TEXT_FS + 8) / 2,
|
|
375
|
-
TEXT_W,
|
|
376
|
-
TEXT_FS + 8,
|
|
377
|
-
TEXT_FS
|
|
378
|
-
)
|
|
379
|
-
);
|
|
380
|
-
ry += ROW_H;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Gradient meaning block: a separator, then [2-colour swatch | caption].
|
|
384
|
-
if (grad) {
|
|
385
|
-
const sepY = ry + 4;
|
|
386
|
-
ids.push(
|
|
387
|
-
surface.addElement({
|
|
388
|
-
type: 'shape',
|
|
389
|
-
shapeType: 'rect',
|
|
390
|
-
filled: true,
|
|
391
|
-
fillColor: '#cfd2d6',
|
|
392
|
-
strokeColor: '#cfd2d6',
|
|
393
|
-
strokeWidth: 0,
|
|
394
|
-
shapeStyle: ShapeStyle.General,
|
|
395
|
-
roughness: 0,
|
|
396
|
-
radius: 0,
|
|
397
|
-
xywh: new Bound(x0 + PAD, sepY, W - PAD * 2, 1).serialize(),
|
|
398
|
-
})
|
|
399
|
-
);
|
|
400
|
-
const cyRow = sepY + 8 + GRAD_ROW_H / 2;
|
|
401
|
-
const sw = 14;
|
|
402
|
-
const sgap = 2;
|
|
403
|
-
const sx = x0 + PAD + GLYPH_W / 2 - (sw * 2 + sgap) / 2;
|
|
404
|
-
grad.swatch.forEach((col, i) => {
|
|
405
|
-
ids.push(
|
|
406
|
-
surface.addElement({
|
|
407
|
-
type: 'shape',
|
|
408
|
-
shapeType: 'rect',
|
|
409
|
-
filled: true,
|
|
410
|
-
fillColor: col,
|
|
411
|
-
strokeColor: '#cfd2d6',
|
|
412
|
-
strokeWidth: 0.5,
|
|
413
|
-
shapeStyle: ShapeStyle.General,
|
|
414
|
-
roughness: 0,
|
|
415
|
-
radius: 1,
|
|
416
|
-
xywh: new Bound(sx + i * (sw + sgap), cyRow - sw / 2, sw, sw).serialize(),
|
|
417
|
-
})
|
|
418
|
-
);
|
|
419
|
-
});
|
|
420
|
-
ids.push(
|
|
421
|
-
text(
|
|
422
|
-
grad.caption,
|
|
423
|
-
x0 + PAD + GLYPH_W + GAP,
|
|
424
|
-
cyRow - GRAD_ROW_H / 2,
|
|
425
|
-
TEXT_W,
|
|
426
|
-
GRAD_ROW_H,
|
|
427
|
-
TEXT_FS
|
|
428
|
-
)
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// 4. Group everything and select it.
|
|
433
|
-
const [, result] = std.command.exec(createGroupCommand, { elements: ids });
|
|
434
|
-
gfx.selection.set({
|
|
435
|
-
elements: [result.groupId || ids[0]],
|
|
436
|
-
editing: false,
|
|
437
|
-
});
|
|
438
|
-
}
|