@formicoidea/labre-framework-edgy 0.32.0 → 0.33.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.
@@ -1,6 +1,9 @@
1
1
  import { makeTemplateSnapshot, surfaceText, } from '@formicoidea/labre-core/gfx/template';
2
2
  import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextAlign, } from '@formicoidea/labre-core/model';
3
3
  import { CROP, CROP_LABELED } from '../consts.js';
4
+ import { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, EDGY_ZONE_FILL, edgyElementLabel, } from '../metamodel.js';
5
+ import { EDGY_RELATION_LABEL_DISTANCE, edgyVerbLabelXYWH } from '../relation.js';
6
+ import { EDGY_ROLE, EDGY_VERB_ROLE } from '../roles.js';
4
7
  import { ACTIVITY_VERTICES, INNER_FONT_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, OUTCOME_RADIUS, } from '../node/consts.js';
5
8
  // EDGY facet palette (header / pale sub-card).
6
9
  const C = {
@@ -35,7 +38,15 @@ function rect(x, y, w, h, opts = {}) {
35
38
  }
36
39
  return el;
37
40
  }
38
- /** An EDGY node (kind drives the native shape): outcome/object box, people, activity chevron. */
41
+ /**
42
+ * An EDGY node (kind drives the native shape): outcome/object box, people,
43
+ * activity chevron.
44
+ *
45
+ * `role` is optional and writes NOTHING when absent: the illustrative templates
46
+ * (journey, blueprint, org chart) stay neutral drawings the engine never looks
47
+ * at, exactly as they were before roles existed. Only the EDGY dynamic template
48
+ * — the one that IS the metamodel — stamps its elements.
49
+ */
39
50
  function enode(kind, x, y, w, h, opts = {}) {
40
51
  const el = {
41
52
  type: 'edgyNode',
@@ -52,6 +63,9 @@ function enode(kind, x, y, w, h, opts = {}) {
52
63
  };
53
64
  if (kind === 'activity')
54
65
  el.vertices = ACTIVITY_VERTICES;
66
+ // `undefined` writes nothing: a neutral node keeps no `role` key.
67
+ if (opts.role !== undefined)
68
+ el.role = opts.role;
55
69
  if (opts.text != null) {
56
70
  el.text = surfaceText(opts.text);
57
71
  el.color = opts.textColor ?? NODE_STROKE;
@@ -108,17 +122,53 @@ function facetsOverview() {
108
122
  const out = {};
109
123
  const tall = (key, x, name, cards) => {
110
124
  const [hdr, sub] = C[key];
111
- out[`${key}F`] = rect(x, 140, 192, 460, { fill: hdr, stroke: hdr, radius: 16 });
112
- out[`${key}N`] = label(x, 168, 192, 28, name, { color: '#ffffff', fontSize: 18 });
113
- out[`${key}1`] = rect(x + 16, 208, 160, 80, { fill: sub, stroke: sub, radius: 6, text: cards[0], fontSize: 16 });
114
- out[`${key}2`] = rect(x + 16, 300, 160, 80, { fill: sub, stroke: sub, radius: 6, text: cards[1], fontSize: 16 });
115
- out[`${key}3`] = enode('activity', x + 16, 392, 160, 84, { fill: sub, text: cards[2], fontSize: 16 });
125
+ out[`${key}F`] = rect(x, 140, 192, 460, {
126
+ fill: hdr,
127
+ stroke: hdr,
128
+ radius: 16,
129
+ });
130
+ out[`${key}N`] = label(x, 168, 192, 28, name, {
131
+ color: '#ffffff',
132
+ fontSize: 18,
133
+ });
134
+ out[`${key}1`] = rect(x + 16, 208, 160, 80, {
135
+ fill: sub,
136
+ stroke: sub,
137
+ radius: 6,
138
+ text: cards[0],
139
+ fontSize: 16,
140
+ });
141
+ out[`${key}2`] = rect(x + 16, 300, 160, 80, {
142
+ fill: sub,
143
+ stroke: sub,
144
+ radius: 6,
145
+ text: cards[1],
146
+ fontSize: 16,
147
+ });
148
+ out[`${key}3`] = enode('activity', x + 16, 392, 160, 84, {
149
+ fill: sub,
150
+ text: cards[2],
151
+ fontSize: 16,
152
+ });
116
153
  };
117
154
  const low = (key, x, name) => {
118
155
  const [hdr, sub] = C[key];
119
- out[`${key}F`] = rect(x, 300, 192, 424, { fill: hdr, stroke: hdr, radius: 16 });
120
- out[`${key}1`] = rect(x + 16, 360, 160, 92, { fill: sub, stroke: sub, radius: 6, text: name, fontSize: 16 });
121
- out[`${key}N`] = label(x, 686, 192, 28, name, { color: '#ffffff', fontSize: 18 });
156
+ out[`${key}F`] = rect(x, 300, 192, 424, {
157
+ fill: hdr,
158
+ stroke: hdr,
159
+ radius: 16,
160
+ });
161
+ out[`${key}1`] = rect(x + 16, 360, 160, 92, {
162
+ fill: sub,
163
+ stroke: sub,
164
+ radius: 6,
165
+ text: name,
166
+ fontSize: 16,
167
+ });
168
+ out[`${key}N`] = label(x, 686, 192, 28, name, {
169
+ color: '#ffffff',
170
+ fontSize: 18,
171
+ });
122
172
  };
123
173
  // lower facets first (drawn behind the tall ones at the overlaps)
124
174
  low('organisation', 240, 'Organisation');
@@ -135,39 +185,114 @@ function facetsOverview() {
135
185
  }
136
186
  // ── Customer journey ──────────────────────────────────────────────────
137
187
  function journey() {
138
- const step = (i, x) => enode('activity', x, 132, 224, 116, { fill: JOURNEY_PINK, text: `Journey step ${i}`, textColor: '#ffffff', fontSize: 18 });
139
- const ch = (x, n) => enode('object', x, 392, 184, 96, { fill: JOURNEY_PINK, text: `Channel ${n}`, fontSize: 16 });
140
- const tk = (x, n) => enode('object', x, 540, 168, 92, { fill: JOURNEY_PINK, text: `Task ${n}`, fontSize: 16 });
188
+ const step = (i, x) => enode('activity', x, 132, 224, 116, {
189
+ fill: JOURNEY_PINK,
190
+ text: `Journey step ${i}`,
191
+ textColor: '#ffffff',
192
+ fontSize: 18,
193
+ });
194
+ const ch = (x, n) => enode('object', x, 392, 184, 96, {
195
+ fill: JOURNEY_PINK,
196
+ text: `Channel ${n}`,
197
+ fontSize: 16,
198
+ });
199
+ const tk = (x, n) => enode('object', x, 540, 168, 92, {
200
+ fill: JOURNEY_PINK,
201
+ text: `Task ${n}`,
202
+ fontSize: 16,
203
+ });
141
204
  return {
142
205
  cust: enode('people', 40, 120, 64, 64, { fill: '#ffffff' }),
143
206
  custL: label(20, 192, 104, 24, 'Customer', { fontSize: 14 }),
144
- band: { type: 'shape', shapeType: 'polygon', vertices: [[0, 0], [0.86, 0], [1, 0.5], [0.86, 1], [0, 1]], filled: true, fillColor: JOURNEY_PINK, strokeColor: JOURNEY_PINK, strokeWidth: 1, shapeStyle: ShapeStyle.General, roughness: 0, xywh: '[150,108,860,164]' },
145
- bandL: label(170, 116, 120, 24, 'Journey', { color: '#ffffff', fontSize: 16, align: TextAlign.Left }),
207
+ band: {
208
+ type: 'shape',
209
+ shapeType: 'polygon',
210
+ vertices: [
211
+ [0, 0],
212
+ [0.86, 0],
213
+ [1, 0.5],
214
+ [0.86, 1],
215
+ [0, 1],
216
+ ],
217
+ filled: true,
218
+ fillColor: JOURNEY_PINK,
219
+ strokeColor: JOURNEY_PINK,
220
+ strokeWidth: 1,
221
+ shapeStyle: ShapeStyle.General,
222
+ roughness: 0,
223
+ xywh: '[150,108,860,164]',
224
+ },
225
+ bandL: label(170, 116, 120, 24, 'Journey', {
226
+ color: '#ffffff',
227
+ fontSize: 16,
228
+ align: TextAlign.Left,
229
+ }),
146
230
  s1: step(1, 240),
147
231
  s2: step(2, 500),
148
232
  s3: step(3, 760),
149
- rightTask: enode('object', 1060, 132, 168, 92, { text: 'Task', fontSize: 16 }),
150
- c1: ch(258, 'X'), c2: ch(518, 'Y'), c3: ch(778, 'Z'),
151
- t1: tk(266, 'A'), t2: tk(526, 'B'), t3: tk(786, 'C'),
152
- trav1: label(258, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
153
- trav2: label(518, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
154
- trav3: label(778, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
233
+ rightTask: enode('object', 1060, 132, 168, 92, {
234
+ text: 'Task',
235
+ fontSize: 16,
236
+ }),
237
+ c1: ch(258, 'X'),
238
+ c2: ch(518, 'Y'),
239
+ c3: ch(778, 'Z'),
240
+ t1: tk(266, 'A'),
241
+ t2: tk(526, 'B'),
242
+ t3: tk(786, 'C'),
243
+ trav1: label(258, 350, 184, 20, 'traverses', {
244
+ fontSize: 13,
245
+ color: '#5f6368',
246
+ }),
247
+ trav2: label(518, 350, 184, 20, 'traverses', {
248
+ fontSize: 13,
249
+ color: '#5f6368',
250
+ }),
251
+ trav3: label(778, 350, 184, 20, 'traverses', {
252
+ fontSize: 13,
253
+ color: '#5f6368',
254
+ }),
155
255
  use1: label(258, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
156
256
  use2: label(518, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
157
257
  use3: label(778, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
158
- l1: attach('s1', 'c1'), l2: attach('s2', 'c2'), l3: attach('s3', 'c3'),
159
- l4: attach('c1', 't1'), l5: attach('c2', 't2'), l6: attach('c3', 't3'),
258
+ l1: attach('s1', 'c1'),
259
+ l2: attach('s2', 'c2'),
260
+ l3: attach('s3', 'c3'),
261
+ l4: attach('c1', 't1'),
262
+ l5: attach('c2', 't2'),
263
+ l6: attach('c3', 't3'),
160
264
  };
161
265
  }
162
266
  // ── Service blueprint (six swimlanes) ─────────────────────────────────
163
267
  function blueprint() {
164
- const lanes = ['Physical Evidence', 'Customer Actions', 'On-stage Actions', 'Back-stage Actions', 'Support Processes', 'Support Systems'];
165
- const laneFill = ['#fdeef2', '#fbd5e0', '#dff0fb', '#d4e9f8', '#d4e9f8', '#d4e9f8'];
268
+ const lanes = [
269
+ 'Physical Evidence',
270
+ 'Customer Actions',
271
+ 'On-stage Actions',
272
+ 'Back-stage Actions',
273
+ 'Support Processes',
274
+ 'Support Systems',
275
+ ];
276
+ const laneFill = [
277
+ '#fdeef2',
278
+ '#fbd5e0',
279
+ '#dff0fb',
280
+ '#d4e9f8',
281
+ '#d4e9f8',
282
+ '#d4e9f8',
283
+ ];
166
284
  const out = {};
167
285
  lanes.forEach((name, i) => {
168
286
  const y = 40 + i * 150;
169
- out[`lane${i}`] = rect(36, y, 1280, 150, { fill: laneFill[i], stroke: laneFill[i], sw: 0 });
170
- out[`laneL${i}`] = label(52, y + 12, 260, 22, name, { fontSize: 15, align: TextAlign.Left });
287
+ out[`lane${i}`] = rect(36, y, 1280, 150, {
288
+ fill: laneFill[i],
289
+ stroke: laneFill[i],
290
+ sw: 0,
291
+ });
292
+ out[`laneL${i}`] = label(52, y + 12, 260, 22, name, {
293
+ fontSize: 15,
294
+ align: TextAlign.Left,
295
+ });
171
296
  });
172
297
  const chev = (x, y, fill, t) => enode('activity', x, y, 200, 60, { fill, text: t, fontSize: 14 });
173
298
  const box = (x, y, fill, t) => enode('object', x, y, 200, 60, { fill, text: t, fontSize: 14 });
@@ -175,11 +300,19 @@ function blueprint() {
175
300
  Object.assign(out, {
176
301
  af: rect(280, 64, 220, 70, { text: 'Admission Form' }),
177
302
  cf: rect(1010, 64, 220, 70, { text: 'Confirmation' }),
178
- sf: chev(280, 214, P, 'Send Form'), fs: chev(600, 214, P, 'Follow status'), rd: chev(1010, 214, P, 'Receive decision'),
179
- fh: chev(280, 364, B, 'Form handling'), cs: chev(600, 364, B, 'Customer support'), ct: chev(1010, 364, B, 'Contact'),
180
- csv: chev(280, 514, B, 'Customer service'), cfu: box(1010, 514, B, 'Customer follow-up'),
181
- pf: chev(600, 664, B, 'Process Form'), dec: chev(900, 664, B, 'Decision [Accept|Reject]'),
182
- crm: box(280, 814, B, 'CRM Application'), cms: box(600, 814, B, 'Case Management'), pay: box(900, 814, B, 'Payments System'),
303
+ sf: chev(280, 214, P, 'Send Form'),
304
+ fs: chev(600, 214, P, 'Follow status'),
305
+ rd: chev(1010, 214, P, 'Receive decision'),
306
+ fh: chev(280, 364, B, 'Form handling'),
307
+ cs: chev(600, 364, B, 'Customer support'),
308
+ ct: chev(1010, 364, B, 'Contact'),
309
+ csv: chev(280, 514, B, 'Customer service'),
310
+ cfu: box(1010, 514, B, 'Customer follow-up'),
311
+ pf: chev(600, 664, B, 'Process Form'),
312
+ dec: chev(900, 664, B, 'Decision [Accept|Reject]'),
313
+ crm: box(280, 814, B, 'CRM Application'),
314
+ cms: box(600, 814, B, 'Case Management'),
315
+ pay: box(900, 814, B, 'Payments System'),
183
316
  a1: attach('af', 'sf', { arrow: true }),
184
317
  a2: attach('sf', 'fs', { arrow: true }),
185
318
  a3: attach('fs', 'rd', { arrow: true }),
@@ -197,8 +330,12 @@ function orgChart() {
197
330
  const u = (x, y, w, t) => enode('object', x, y, w, 64, { fill: cyan, text: t, fontSize: 16 });
198
331
  return {
199
332
  org: u(420, 40, 180, 'Organisation'),
200
- a: u(120, 200, 200, 'Business Unit A'), b: u(410, 200, 200, 'Business Unit B'), c: u(700, 200, 200, 'Business Unit C'),
201
- a1: u(60, 360, 170, 'Group A-1'), a2: u(260, 360, 170, 'Group A-2'), c1: u(715, 360, 170, 'Group C-1'),
333
+ a: u(120, 200, 200, 'Business Unit A'),
334
+ b: u(410, 200, 200, 'Business Unit B'),
335
+ c: u(700, 200, 200, 'Business Unit C'),
336
+ a1: u(60, 360, 170, 'Group A-1'),
337
+ a2: u(260, 360, 170, 'Group A-2'),
338
+ c1: u(715, 360, 170, 'Group C-1'),
202
339
  // Attached orthogonal connectors: the router draws the org-chart elbows
203
340
  // and the links follow when units are moved around.
204
341
  e1: attach('org', 'a', { mode: ConnectorMode.Orthogonal }),
@@ -217,72 +354,12 @@ function orgChart() {
217
354
  */
218
355
  export const DYN_SCALE = 4.8;
219
356
  /**
220
- * The 24 canonical EDGY relations (source, target, verb, label position along
221
- * the link) — exported for the unit tests. 7 per facet + 3 between the
222
- * intersections. The optional 4th member mirrors the reference diagram's
223
- * placements: verbs of intersection-outgoing links sit near the far element
224
- * (`labelOffset.distance` ≈ .75), short peer links keep the middle.
225
- */
226
- export const EDGY_DYNAMIC_RELATIONS = [
227
- // Identity
228
- ['content', 'purpose', 'expresses'],
229
- ['content', 'story', 'conveys', 0.75],
230
- ['story', 'purpose', 'contextualises', 0.8],
231
- ['organisation', 'purpose', 'pursues', 0.8],
232
- ['organisation', 'story', 'authors', 0.6],
233
- ['brand', 'purpose', 'represents', 0.8],
234
- ['brand', 'story', 'evokes', 0.65],
235
- // Architecture
236
- ['organisation', 'process', 'performs', 0.8],
237
- ['process', 'capability', 'realises', 0.75],
238
- ['process', 'asset', 'requires'],
239
- ['capability', 'asset', 'requires', 0.75],
240
- ['organisation', 'capability', 'has', 0.6],
241
- ['product', 'capability', 'requires', 0.75],
242
- ['process', 'product', 'creates', 0.65],
243
- // Experience
244
- ['task', 'journey', 'is part of', 0.6],
245
- ['task', 'channel', 'uses', 0.6],
246
- ['journey', 'channel', 'traverses', 0.6],
247
- ['product', 'task', 'serves', 0.9],
248
- ['product', 'journey', 'features in', 0.8],
249
- ['brand', 'task', 'supports', 0.8],
250
- ['brand', 'journey', 'appears in', 0.9],
251
- // Intersections
252
- ['organisation', 'brand', 'builds', 0.85],
253
- ['organisation', 'product', 'makes', 0.65],
254
- ['product', 'brand', 'embodies', 0.8],
255
- ];
256
- /**
257
- * The 12 elements, centred coordinates in REFERENCE coords (the fixed space
258
- * of consts.ts — the same space as `VENN`), laid out like the reference
259
- * "elements & relations" diagram: aligned top row, Story/Capability flanks,
260
- * Brand/Product astride the white centre, Task/Journey/Channel triangle.
261
- * Exported (with {@link dynToModel}) for the containment test.
357
+ * The metamodel itself the 12 elements and the 24 relations — now lives in
358
+ * `../metamodel.ts`, so the role vocabulary can DERIVE from it without this
359
+ * module and that one importing each other. Re-exported here under the names
360
+ * they have always had: nothing that reads them had to change.
262
361
  */
263
- /** Official pastel fills per facet (the `pictograms/Shape-*.svg` colors). */
264
- const PASTEL = {
265
- identity: '#80ffb7',
266
- architecture: '#a6c0ff',
267
- experience: '#ff99bd',
268
- organisation: '#80eaff',
269
- brand: '#ffd580',
270
- product: '#e599ff',
271
- };
272
- export const EDGY_DYNAMIC_NODES = {
273
- content: { kind: 'object', cx: 237.5, cy: 100, fill: PASTEL.identity },
274
- purpose: { kind: 'outcome', cx: 282.5, cy: 100, fill: PASTEL.identity },
275
- organisation: { kind: 'object', cx: 340, cy: 100, w: 175, fill: PASTEL.organisation },
276
- process: { kind: 'activity', cx: 397.5, cy: 100, fill: PASTEL.architecture },
277
- asset: { kind: 'object', cx: 442.5, cy: 100, fill: PASTEL.architecture },
278
- story: { kind: 'activity', cx: 255, cy: 152.5, fill: PASTEL.identity },
279
- capability: { kind: 'outcome', cx: 425, cy: 152.5, w: 150, fill: PASTEL.architecture },
280
- brand: { kind: 'object', cx: 280, cy: 195, fill: PASTEL.brand },
281
- product: { kind: 'object', cx: 400, cy: 195, fill: PASTEL.product },
282
- task: { kind: 'outcome', cx: 310, cy: 257.5, fill: PASTEL.experience },
283
- journey: { kind: 'activity', cx: 370, cy: 257.5, fill: PASTEL.experience },
284
- channel: { kind: 'object', cx: 340, cy: 297.5, fill: PASTEL.experience },
285
- };
362
+ export { EDGY_DYNAMIC_NODES, EDGY_DYNAMIC_RELATIONS, } from '../metamodel.js';
286
363
  /**
287
364
  * Reference coords → template model coords: the background element sits at
288
365
  * (0,0) and renders the cropped circles box, so a reference point maps to
@@ -298,17 +375,23 @@ function dynamic() {
298
375
  showLabels: false,
299
376
  showPictos: false,
300
377
  cropToCircles: true,
378
+ // The frame a finding is attributed to. Stamped here and nowhere else in
379
+ // this template: the background is what makes the board an EDGY board.
380
+ role: EDGY_ROLE.facets,
301
381
  xywh: `[0,0,${CROP.w * DYN_SCALE},${CROP.h * DYN_SCALE}]`,
302
382
  },
303
383
  };
304
- for (const [key, { kind, cx, cy, w, fill }] of Object.entries(EDGY_DYNAMIC_NODES)) {
384
+ for (const [key, { kind, cx, cy, w, zone }] of Object.entries(EDGY_DYNAMIC_NODES)) {
305
385
  const nw = w ?? NODE_SIZE[kind].w;
306
386
  const nh = NODE_SIZE[kind].h;
307
387
  const [mx, my] = dynToModel(cx, cy);
308
- const name = key.charAt(0).toUpperCase() + key.slice(1);
388
+ const name = edgyElementLabel(key);
309
389
  out[key] = enode(kind, mx - nw / 2, my - nh / 2, nw, nh, {
310
390
  text: name,
311
- fill,
391
+ fill: EDGY_ZONE_FILL[zone],
392
+ // The OFFICIAL element, not just its kind: this template IS the
393
+ // metamodel, so its Purpose is a Purpose and not merely an outcome.
394
+ role: EDGY_ROLE[key],
312
395
  });
313
396
  }
314
397
  EDGY_DYNAMIC_RELATIONS.forEach(([src, dst, verb, t], i) => {
@@ -322,21 +405,33 @@ function dynamic() {
322
405
  rearEndpointStyle: PointStyle.None,
323
406
  source: { id: src },
324
407
  target: { id: dst },
408
+ // The verb, as a ROLE — one per canonical verb, derived from this very
409
+ // table (`../roles.ts`). Source is the subject and target the object
410
+ // (`docs/adr/0010` tier 1), which is exactly how the row above reads, so
411
+ // `edgy.non-canonical-link` finds all 24 of these sentences legal.
412
+ role: EDGY_VERB_ROLE[verb],
325
413
  // Native connector label: the verb travels with the link. The x/y are
326
414
  // re-centred on the path at the first layout, but the w/h ARE the label
327
415
  // box — size it to the verb so the text lays out on one line. The
328
416
  // distance slides the verb along the link like the reference diagram.
329
417
  text: surfaceText(verb),
330
- labelXYWH: [0, 0, verb.length * 9 + 24, 26],
331
- labelOffset: { distance: t ?? 0.5 },
418
+ labelXYWH: edgyVerbLabelXYWH(verb),
419
+ labelOffset: { distance: t ?? EDGY_RELATION_LABEL_DISTANCE },
332
420
  };
333
421
  });
334
422
  return out;
335
423
  }
336
- const single = (el) => ({ a: el });
424
+ const single = (el) => ({
425
+ a: el,
426
+ });
337
427
  const ATTRS = 'width="100%" height="100%" viewBox="0 0 135 80" xmlns="http://www.w3.org/2000/svg"';
338
428
  function tpl(name, preview, elements) {
339
- return { name, type: 'template', preview, content: makeTemplateSnapshot(elements, name) };
429
+ return {
430
+ name,
431
+ type: 'template',
432
+ preview,
433
+ content: makeTemplateSnapshot(elements, name),
434
+ };
340
435
  }
341
436
  /** Exported for direct insertion from the EDGY senior toolbar menu. */
342
437
  export const edgyDynamicTemplate = tpl('EDGY dynamic', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/><path d="M55 30 H80 M56 31 L66 52 M79 31 L69 52" stroke="#fff" stroke-width="2"/><rect x="51" y="26" width="8" height="8" fill="#fff"/><circle cx="80" cy="30" r="4" fill="#fff"/><path d="M62 49 h6 l3 3 -3 3 h-6 z" fill="#fff"/></svg>`, dynamic());
@@ -347,9 +442,17 @@ export const edgyTemplateCategory = {
347
442
  tpl('Customer journey', `<svg ${ATTRS} fill="none"><path d="M20 24 H110 L122 40 L110 56 H20 Z" fill="#f3a3c0"/><rect x="26" y="30" width="22" height="20" fill="none" stroke="#fff"/><rect x="54" y="30" width="22" height="20" fill="none" stroke="#fff"/><rect x="82" y="30" width="22" height="20" fill="none" stroke="#fff"/></svg>`, journey()),
348
443
  tpl('Service blueprint', `<svg ${ATTRS} fill="none"><rect x="8" y="14" width="119" height="16" fill="#fbd5e0"/><rect x="8" y="32" width="119" height="34" fill="#d4e9f8"/><rect x="20" y="18" width="22" height="9" fill="#f5246e" opacity="0.5"/><rect x="20" y="40" width="22" height="9" fill="#2f6ff0" opacity="0.4"/><rect x="60" y="40" width="22" height="9" fill="#2f6ff0" opacity="0.4"/></svg>`, blueprint()),
349
444
  tpl('Organisation chart', `<svg ${ATTRS} fill="none"><rect x="52" y="12" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="14" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="52" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="90" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><path d="M68 26 V32 M30 32 H106 M30 32 V38 M68 32 V38 M106 32 V38" stroke="#262626"/></svg>`, orgChart()),
350
- tpl('Facets diagram', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/></svg>`, single({ type: 'edgy', cropToCircles: true, xywh: `[0,0,${CROP_LABELED.w * 1.5},${CROP_LABELED.h * 1.5}]` })),
445
+ tpl('Facets diagram', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/></svg>`, single({
446
+ type: 'edgy',
447
+ cropToCircles: true,
448
+ role: EDGY_ROLE.facets,
449
+ xywh: `[0,0,${CROP_LABELED.w * 1.5},${CROP_LABELED.h * 1.5}]`,
450
+ })),
351
451
  edgyDynamicTemplate,
352
- tpl('People', `<svg ${ATTRS} fill="#262626"><circle cx="67" cy="32" r="9" fill="none" stroke="#262626" stroke-width="2.4"/><path d="M50 60 a17 17 0 0 1 34 0" fill="none" stroke="#262626" stroke-width="2.4"/></svg>`, { n: enode('people', 0, 0, 64, 64), l: label(-28, 70, 120, 24, 'People', { fontSize: 16 }) }),
452
+ tpl('People', `<svg ${ATTRS} fill="#262626"><circle cx="67" cy="32" r="9" fill="none" stroke="#262626" stroke-width="2.4"/><path d="M50 60 a17 17 0 0 1 34 0" fill="none" stroke="#262626" stroke-width="2.4"/></svg>`, {
453
+ n: enode('people', 0, 0, 64, 64),
454
+ l: label(-28, 70, 120, 24, 'People', { fontSize: 16 }),
455
+ }),
353
456
  tpl('Outcome', `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" rx="6" stroke="#262626" stroke-width="2"/></svg>`, single(enode('outcome', 0, 0, 130, 80, { text: 'Outcome' }))),
354
457
  tpl('Object', `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" stroke="#262626" stroke-width="2"/></svg>`, single(enode('object', 0, 0, 130, 80, { text: 'Object' }))),
355
458
  tpl('Activity', `<svg ${ATTRS} fill="none"><path d="M20 24 H98 L116 41 H116 L98 58 H20 Z" stroke="#262626" stroke-width="2" stroke-linejoin="round"/></svg>`, single(enode('activity', 0, 0, 140, 80, { text: 'Activity' }))),
@@ -19,6 +19,11 @@ export declare const edgyToolbarConfig: {
19
19
  icon: TemplateResult;
20
20
  active(ctx: ToolbarContext): boolean;
21
21
  run(ctx: ToolbarContext): void;
22
+ }, {
23
+ id: string;
24
+ tooltip: string;
25
+ icon: TemplateResult<1>;
26
+ run(ctx: ToolbarContext): void;
22
27
  }];
23
28
  readonly when: (ctx: ToolbarContext) => boolean;
24
29
  };
@@ -36,6 +41,11 @@ export declare const edgyBoardToolbarConfig: {
36
41
  icon: TemplateResult;
37
42
  active(ctx: ToolbarContext): boolean;
38
43
  run(ctx: ToolbarContext): void;
44
+ }, {
45
+ id: string;
46
+ tooltip: string;
47
+ icon: TemplateResult<1>;
48
+ run(ctx: ToolbarContext): void;
39
49
  }];
40
50
  readonly when: (ctx: ToolbarContext) => boolean;
41
51
  };
@@ -1,8 +1,10 @@
1
1
  import { EdgelessCRUDIdentifier } from '@formicoidea/labre-core/blocks/surface';
2
+ import { createAutoLegend, dddLegendIcon } from '@formicoidea/labre-ddd-shared';
2
3
  import { EdgyBoardElementModel, EdgyFacetsElementModel, } from '@formicoidea/labre-core/model';
3
- import { ToolbarModuleExtension, } from '@formicoidea/labre-core/shared/services';
4
+ import { TelemetryProvider, ToolbarModuleExtension, } from '@formicoidea/labre-core/shared/services';
4
5
  import { BlockFlavourIdentifier } from '@formicoidea/labre-core/std';
5
6
  import { html } from 'lit';
7
+ import { EDGY_AUTO_LEGEND } from '../legend.js';
6
8
  const ResizeIcon = html `<svg
7
9
  width="24"
8
10
  height="24"
@@ -75,11 +77,45 @@ function booleanToggle(Model, id, tooltip, icon, prop) {
75
77
  },
76
78
  };
77
79
  }
80
+ /**
81
+ * The legend action, shared by the two EDGY backgrounds: both frame the same
82
+ * notation, so both answer the same question — "what is actually drawn here" —
83
+ * with the same table ({@link EDGY_AUTO_LEGEND}) and the same box.
84
+ *
85
+ * Registered ALWAYS-ON (`EdgyRenderViewExtension`) with the toggles it sits
86
+ * next to: a legend is real, editable elements written into the document, so
87
+ * generating one is authoring, not tooling a flag may take away
88
+ * (`docs/adr/0009`).
89
+ */
90
+ function legendAction(Model, id) {
91
+ return {
92
+ id,
93
+ tooltip: 'Generate the legend (notation present)',
94
+ icon: dddLegendIcon,
95
+ run(ctx) {
96
+ const background = ctx.getSurfaceModelsByType(Model)[0];
97
+ if (!background)
98
+ return;
99
+ createAutoLegend(ctx.std, background, EDGY_AUTO_LEGEND);
100
+ ctx.std.getOptional(TelemetryProvider)?.track('FrameworkLegendCreated', {
101
+ // The WIRE value from `frameworks.ts` (`telemetryKey`), which for EDGY
102
+ // happens to be the module id itself. Same field set as Wardley's and
103
+ // the three DDD legend buttons, so the four are comparable.
104
+ framework: 'edgy',
105
+ element: 'legend',
106
+ page: 'whiteboard editor',
107
+ segment: 'element toolbar',
108
+ module: 'edgy toolbar',
109
+ });
110
+ },
111
+ };
112
+ }
78
113
  export const edgyToolbarConfig = {
79
114
  actions: [
80
115
  booleanToggle(EdgyFacetsElementModel, 'a.toggle-resize', 'Enable / lock resizing', ResizeIcon, 'resizeEnabled'),
81
116
  booleanToggle(EdgyFacetsElementModel, 'b.toggle-labels', 'Show / hide facet labels', LabelsIcon, 'showLabels'),
82
117
  booleanToggle(EdgyFacetsElementModel, 'c.toggle-spotlight', 'Enable / disable hover spotlight', SpotlightIcon, 'spotlightEnabled'),
118
+ legendAction(EdgyFacetsElementModel, 'd.legend'),
83
119
  ],
84
120
  when: ctx => ctx.getSurfaceModelsByType(EdgyFacetsElementModel).length > 0,
85
121
  };
@@ -91,6 +127,7 @@ export const edgyBoardToolbarConfig = {
91
127
  actions: [
92
128
  booleanToggle(EdgyBoardElementModel, 'a.toggle-resize', 'Enable / lock resizing', ResizeIcon, 'resizeEnabled'),
93
129
  booleanToggle(EdgyBoardElementModel, 'b.toggle-spotlight', 'Enable / disable hover spotlight', SpotlightIcon, 'spotlightEnabled'),
130
+ legendAction(EdgyBoardElementModel, 'c.legend'),
94
131
  ],
95
132
  when: ctx => ctx.getSurfaceModelsByType(EdgyBoardElementModel).length > 0,
96
133
  };
@@ -1,5 +1,6 @@
1
1
  import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
3
+ import { translateKey } from '@formicoidea/labre-core/shared/services';
3
4
  import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
4
5
  import { SignalWatcher } from '@formicoidea/labre-core/global/lit';
5
6
  import { css, html, LitElement } from 'lit';
@@ -44,7 +45,10 @@ export class EdgelessEdgySeniorButton extends EdgelessToolbarToolMixin(SignalWat
44
45
  translate: var(--active-x, 0) var(--active-y, 0);
45
46
  rotate: var(--active-r, -2deg);
46
47
  scale: var(--active-s, 1);
47
- transition: transform 0.3s ease, translate 0.3s ease, rotate 0.3s ease,
48
+ transition:
49
+ transform 0.3s ease,
50
+ translate 0.3s ease,
51
+ rotate 0.3s ease,
48
52
  scale 0.3s ease;
49
53
  }
50
54
  .edgy-card svg {
@@ -71,7 +75,9 @@ export class EdgelessEdgySeniorButton extends EdgelessToolbarToolMixin(SignalWat
71
75
  render() {
72
76
  return html `<edgeless-toolbar-button
73
77
  class="edgy-button"
74
- .tooltip=${this.popper ? '' : 'EDGY'}
78
+ .tooltip=${this.popper
79
+ ? ''
80
+ : translateKey(this.edgeless.std, 'com.labre.framework.edgy', 'EDGY')}
75
81
  .tooltipOffset=${4}
76
82
  .active=${!!this.popper}
77
83
  @click=${this._toggleMenu}
@@ -12,5 +12,13 @@ export declare const edgyPeopleIcon: import("lit-html").TemplateResult<2>;
12
12
  export declare const edgyOutcomeIcon: import("lit-html").TemplateResult<2>;
13
13
  /** Object — plain rectangle. */
14
14
  export declare const edgyObjectIcon: import("lit-html").TemplateResult<2>;
15
+ /**
16
+ * Relation — a bare line between two ends, with the label riding on it.
17
+ *
18
+ * No arrowhead, exactly like the 24 relations of the metamodel template: EDGY
19
+ * draws its links as plain lines and lets the verb say which way the sentence
20
+ * runs. The little box in the middle IS the verb the tool writes there.
21
+ */
22
+ export declare const edgyRelationIcon: import("lit-html").TemplateResult<2>;
15
23
  /** Activity — right-pointing chevron. */
16
24
  export declare const edgyActivityIcon: import("lit-html").TemplateResult<2>;
@@ -44,6 +44,19 @@ export const edgyOutcomeIcon = svg `<svg width="24" height="24" viewBox="0 0 24
44
44
  export const edgyObjectIcon = svg `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
45
45
  <rect x="3.5" y="6.5" width="17" height="11" stroke="currentColor" stroke-width="1.6"/>
46
46
  </svg>`;
47
+ /**
48
+ * Relation — a bare line between two ends, with the label riding on it.
49
+ *
50
+ * No arrowhead, exactly like the 24 relations of the metamodel template: EDGY
51
+ * draws its links as plain lines and lets the verb say which way the sentence
52
+ * runs. The little box in the middle IS the verb the tool writes there.
53
+ */
54
+ export const edgyRelationIcon = svg `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
55
+ <path d="M3.5 12 H20.5" stroke="currentColor" stroke-width="1.6"/>
56
+ <circle cx="3.8" cy="12" r="1.6" fill="currentColor"/>
57
+ <circle cx="20.2" cy="12" r="1.6" fill="currentColor"/>
58
+ <rect x="8" y="8.6" width="8" height="6.8" rx="1.4" fill="var(--affine-background-primary-color, #fff)" stroke="currentColor" stroke-width="1.4"/>
59
+ </svg>`;
47
60
  /** Activity — right-pointing chevron. */
48
61
  export const edgyActivityIcon = svg `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
49
62
  <path d="M3.5 6.5 H15 L20.5 12 L15 17.5 H3.5 Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
@@ -3,6 +3,7 @@ import { html } from 'lit';
3
3
  export const edgySeniorTool = SeniorToolExtension('edgy', ({ block }) => {
4
4
  return {
5
5
  name: 'EDGY',
6
+ labelKey: 'com.labre.framework.edgy',
6
7
  content: html `<edgeless-edgy-senior-button
7
8
  .edgeless=${block}
8
9
  ></edgeless-edgy-senior-button>`,
@@ -1,14 +1,24 @@
1
1
  import { type TranslationKeyManifestEntry } from '@formicoidea/labre-core/std';
2
2
  /**
3
- * THIS framework's contribution to the translation-key manifest.
3
+ * THIS framework's contribution to the translation-key manifest — every
4
+ * `com.labre.*` key EDGY can hand to `TranslationProvider.t`, derived from the
5
+ * very declarations the editor registers.
4
6
  *
5
7
  * Its command labels and descriptions are built from a TEMPLATE, so the
6
- * concrete keys exist nowhere but in the declarations themselves and the
7
- * core manifest could not restate them even if it wanted to. The
8
- * contribution therefore ships WITH the framework: in the bundled
8
+ * concrete keys exist nowhere but in the declarations themselves and the core
9
+ * manifest could not restate them even if it wanted to. The same now holds for
10
+ * the roles: one key per canonical verb, derived from the metamodel, so a
11
+ * relation added there contributes its keys without anybody editing this file.
12
+ *
13
+ * That derivation is why the hand-drawn relation tool needed no line here: its
14
+ * `com.labre.commands.edgy.addRelation` and the `.description` key that carries
15
+ * its gesture sentence — the first DESCRIPTION any EDGY command has declared —
16
+ * are collected off `edgyCommands` with their English fallbacks, like every
17
+ * label before them.
18
+ *
19
+ * The contribution therefore ships WITH the framework: in the bundled
9
20
  * distribution `@formicoidea/labre-framework-edgy` carries it, and a host
10
- * composes it into its catalogue exactly as it already composes
11
- * `edgyCommands` into the command registry. See
12
- * `packages/affine/all/src/translations.ts`.
21
+ * composes it into its catalogue exactly as it already composes `edgyCommands`
22
+ * into the command registry. See `packages/affine/all/src/translations.ts`.
13
23
  */
14
24
  export declare const edgyTranslationEntries: TranslationKeyManifestEntry[];