@formicoidea/labre-framework-wardley 0.60.0 → 1.0.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.
@@ -0,0 +1,202 @@
1
+ import { type OwmPlot } from './export.js';
2
+ /**
3
+ * The JSON language of `@formicoidea/wardley-map-renderer`: the vocabulary
4
+ * its reader (`renderer-json-import.ts`) and its writer
5
+ * (`renderer-json-export.ts`) share, a second Wardley exchange language
6
+ * beside OWM (ADR 0012, amendment of 2026-10-28 under P1).
7
+ *
8
+ * Written against the renderer's Zod schemas (`src/schema.ts`) and the JSON
9
+ * Schema it publishes (`schema/wardley-map.schema.json`), **version
10
+ * 1.0.0-beta.5**. The package itself is NOT imported: its PNG path loads a
11
+ * native binary (resvg), which the `./interchange` graph exists to keep out
12
+ * (`interchange-headless.unit.spec.ts`).
13
+ *
14
+ * ## Only what the canvas shows crosses (PO decision, 2026-10-28)
15
+ *
16
+ * Every renderer field becomes something VISIBLE: a toolbox item with its
17
+ * real parameters (kind, nature tag, colours, the name's position), or, where
18
+ * Wardley's toolbox has nothing, a plain shape or a free text grouped with
19
+ * the component. What nothing on the canvas can show is dropped, and the
20
+ * import report says which. No foreign id and no foreign field is stored:
21
+ * ids belong to Labre, and the writer reads back what it sees, recognised by
22
+ * what each element IS (its group, its kind, its place), never by a payload.
23
+ *
24
+ * Every placement below is shared by both directions: the reader puts a
25
+ * piece in its slot and the writer reads the slot.
26
+ */
27
+ /** Rounding of every scalar written, the renderer's own `round3`. */
28
+ export declare function round3(value: number): number;
29
+ /**
30
+ * `(evolution, visibility)` → a surface point on `plot`.
31
+ *
32
+ * The renderer's visibility runs DOWN: `0` is the top of the value chain and
33
+ * `1` the bottom (`visToY = plotTop + visibility × plotHeight`,
34
+ * `src/geometry.ts`), the opposite of OWM's, where `1.0` is the top. The OWM
35
+ * projection is reused on `1 − visibility` rather than restated.
36
+ */
37
+ export declare function rendererPointOf(plot: OwmPlot, evolution: number, visibility: number): [number, number];
38
+ /** A surface point → `(evolution, visibility)`, the exact inverse. */
39
+ export declare function rendererCoordsOf(plot: OwmPlot, x: number, y: number): {
40
+ evolution: number;
41
+ visibility: number;
42
+ };
43
+ /**
44
+ * The evolution of each phase line, read off the map's own zones: the same
45
+ * 0.175 / 0.4 / 0.7 the renderer draws (`EVOLUTION_BOUNDARIES`).
46
+ */
47
+ export declare const PHASE_LINES: readonly number[];
48
+ /** The kinds a renderer component is drawn as, pipeline aside. */
49
+ export type JsonComponentKind = 'anchor' | 'component' | 'method' | 'market' | 'ecosystem';
50
+ /** A box, in surface units. */
51
+ export interface Box {
52
+ x: number;
53
+ y: number;
54
+ w: number;
55
+ h: number;
56
+ }
57
+ /**
58
+ * The canvas a renderer map is drawn on, in its pixels: `label.position` is
59
+ * an offset in these, so it is scaled by the plot of THIS canvas, never of a
60
+ * fixed one (PO, 2026-10-28).
61
+ */
62
+ export interface RendererCanvas {
63
+ w: number;
64
+ h: number;
65
+ }
66
+ /** The renderer's default canvas (its `DEFAULT_RENDER_CONFIG`). */
67
+ export declare const DEFAULT_RENDERER_CANVAS: RendererCanvas;
68
+ /**
69
+ * The canvas a map's `renderConfig` declares, as the renderer resolves it
70
+ * (`build-context.ts`, `render-config-v3.ts`): the output size
71
+ * (`style.view`) wins over the canvas size (`style.background.canvas`),
72
+ * each its `default` merged with its `override`, and the default canvas fills
73
+ * whatever neither gives, axis by axis.
74
+ */
75
+ export declare function rendererCanvasOf(renderConfig: unknown): RendererCanvas;
76
+ /**
77
+ * Where a component's name goes for a renderer `label.position`.
78
+ *
79
+ * The renderer's DEFAULT offset is the toolbox's own slot (right of the
80
+ * node, centred on it), so a file that says nothing gets the toolbar's
81
+ * name; an offset moves the name by its difference from that default,
82
+ * scaled to the plot. A negative `dx` puts it on the left, right-aligned,
83
+ * and `0` centres it, as the renderer's text anchor does.
84
+ */
85
+ export declare function jsonLabelBox(plot: OwmPlot, w: number, cx: number, cy: number, offset: {
86
+ dx: number;
87
+ dy: number;
88
+ }, canvas?: RendererCanvas): {
89
+ x: number;
90
+ y: number;
91
+ textAlign: 'left' | 'right' | 'center';
92
+ };
93
+ /** The inverse of {@link jsonLabelBox}: a name's box → the renderer offset. */
94
+ export declare function jsonLabelOffset(plot: OwmPlot, w: number, cx: number, cy: number, label: Box, textAlign: unknown, canvas?: RendererCanvas): {
95
+ dx: number;
96
+ dy: number;
97
+ };
98
+ /**
99
+ * How far an offset moves a name from the toolbox's slot, in surface units:
100
+ * a pipeline's name, centred above its handle, is moved this way.
101
+ */
102
+ export declare function jsonLabelShift(plot: OwmPlot, offset: {
103
+ dx: number;
104
+ dy: number;
105
+ }, canvas?: RendererCanvas): {
106
+ x: number;
107
+ y: number;
108
+ };
109
+ /** The inverse of {@link jsonLabelShift}. */
110
+ export declare function jsonLabelUnshift(plot: OwmPlot, shift: {
111
+ x: number;
112
+ y: number;
113
+ }, canvas?: RendererCanvas): {
114
+ dx: number;
115
+ dy: number;
116
+ };
117
+ /** Whether an offset is the renderer's default, so nothing need be written. */
118
+ export declare const isDefaultLabelOffset: (offset: {
119
+ dx: number;
120
+ dy: number;
121
+ }) => boolean;
122
+ /**
123
+ * The centre of the accelerator (decelerator) a component's `accelerator`
124
+ * (`deaccelerator`) flag is drawn as: where the RENDERER puts it, beside the
125
+ * node, the accelerator on the right and the decelerator on the left. Its
126
+ * place matters more than the name's, which it may overlap (PO, 2026-10-28).
127
+ */
128
+ export declare function jsonDecoratorCentre(kind: JsonComponentKind, which: 'accelerator' | 'decelerator', cx: number, cy: number): {
129
+ cx: number;
130
+ cy: number;
131
+ };
132
+ /** The numbered `step` dot: a plain ellipse at the node's upper left. */
133
+ export declare const STEP_SIZE = 20;
134
+ export declare function jsonStepCentre(kind: JsonComponentKind, cx: number, cy: number): {
135
+ cx: number;
136
+ cy: number;
137
+ };
138
+ export declare function jsonStepElement(centre: {
139
+ cx: number;
140
+ cy: number;
141
+ }, number: number, colour: string | undefined, id: string): Record<string, unknown> & {
142
+ type: string;
143
+ };
144
+ /** The evolution `range` bar: a thin plain rect at the node's height. */
145
+ export declare const RANGE_HEIGHT = 6;
146
+ export declare function jsonRangeElement(left: number, right: number, cy: number, id: string): Record<string, unknown> & {
147
+ type: string;
148
+ };
149
+ /** Whether a plain shape is a range bar: a thin horizontal rect. */
150
+ export declare const isRangeShape: (shapeType: unknown, box: Box) => boolean;
151
+ export declare function jsonDescriptionElement(text: string, under: {
152
+ x: number;
153
+ y: number;
154
+ }, id: string): Record<string, unknown> & {
155
+ type: string;
156
+ };
157
+ /** Where a component's description is looked for: under its name's box. */
158
+ export declare const descriptionSlot: (label: Box) => {
159
+ x: number;
160
+ y: number;
161
+ };
162
+ export declare function jsonTitleElement(text: string, map: Box, id: string): Record<string, unknown> & {
163
+ type: string;
164
+ };
165
+ /** The map's `context`, under it, as wide as it. */
166
+ export declare function jsonContextElement(text: string, map: Box, id: string): Record<string, unknown> & {
167
+ type: string;
168
+ };
169
+ /**
170
+ * The look of each relation type on the toolbox's dependency link (PO,
171
+ * 2026-10-28): `DependsOn` is the link as the toolbar draws it, `Flow` a BLUE
172
+ * link thicker than it, `Constraint` a BLACK link as thick as a flow. The
173
+ * writer reads the type back from the colour and the width.
174
+ */
175
+ export declare const RELATION_WIDTH: number;
176
+ export declare const FLOW_COLOUR = "#2563eb";
177
+ export declare const CONSTRAINT_COLOUR = "#000000";
178
+ export type RelationType = 'DependsOn' | 'Flow' | 'Constraint';
179
+ export declare function relationLook(type: RelationType, style: unknown): Record<string, unknown>;
180
+ /** A link's type and flow style, read off its colour, width and dash. */
181
+ export declare function relationOf(link: {
182
+ stroke?: unknown;
183
+ strokeWidth?: unknown;
184
+ strokeStyle?: unknown;
185
+ }): {
186
+ type: RelationType;
187
+ style?: 'dashed' | 'bold';
188
+ };
189
+ /** The four renderer evolution types, written as the arrow's label. */
190
+ export declare const EVOLVE_TYPES: readonly ["natural", "ecosystem", "forced", "late"];
191
+ /**
192
+ * The renderer natures this library's `wardley:nature` tag has (`natures.ts`).
193
+ * Its five others (personae, generic, group, natural, anthropic) have no
194
+ * value here and are dropped (PO, 2026-10-28).
195
+ */
196
+ export declare const LABRE_NATURES: readonly ["activity", "data", "practice", "knowledge"];
197
+ /** A renderer `color` as the stroke it paints. */
198
+ export declare function rendererColour(name: string): string;
199
+ /** A stroke as the renderer's word for it: its name if it has one. */
200
+ export declare function rendererColourName(stroke: string): string;
201
+ /** Two CSS colours that are the same paint, whatever their spelling. */
202
+ export declare function sameColour(a: unknown, b: unknown): boolean;
@@ -0,0 +1,366 @@
1
+ import { FontFamily, FontWeight, ShapeStyle, } from '@formicoidea/labre-core/model/headless';
2
+ import { WARDLEY_BACKGROUND } from './background.js';
3
+ import { owmCoordsOf, owmPointOf } from './export.js';
4
+ import { LABEL_GAP, LINK_GREY, LINK_STROKE_WIDTH, NODE_STROKE, } from './node/consts.js';
5
+ import { WARDLEY_LABEL_H, WARDLEY_LABEL_W, WARDLEY_NODE_SIZE, } from './presets.js';
6
+ /**
7
+ * The JSON language of `@formicoidea/wardley-map-renderer`: the vocabulary
8
+ * its reader (`renderer-json-import.ts`) and its writer
9
+ * (`renderer-json-export.ts`) share, a second Wardley exchange language
10
+ * beside OWM (ADR 0012, amendment of 2026-10-28 under P1).
11
+ *
12
+ * Written against the renderer's Zod schemas (`src/schema.ts`) and the JSON
13
+ * Schema it publishes (`schema/wardley-map.schema.json`), **version
14
+ * 1.0.0-beta.5**. The package itself is NOT imported: its PNG path loads a
15
+ * native binary (resvg), which the `./interchange` graph exists to keep out
16
+ * (`interchange-headless.unit.spec.ts`).
17
+ *
18
+ * ## Only what the canvas shows crosses (PO decision, 2026-10-28)
19
+ *
20
+ * Every renderer field becomes something VISIBLE: a toolbox item with its
21
+ * real parameters (kind, nature tag, colours, the name's position), or, where
22
+ * Wardley's toolbox has nothing, a plain shape or a free text grouped with
23
+ * the component. What nothing on the canvas can show is dropped, and the
24
+ * import report says which. No foreign id and no foreign field is stored:
25
+ * ids belong to Labre, and the writer reads back what it sees, recognised by
26
+ * what each element IS (its group, its kind, its place), never by a payload.
27
+ *
28
+ * Every placement below is shared by both directions: the reader puts a
29
+ * piece in its slot and the writer reads the slot.
30
+ */
31
+ /** Rounding of every scalar written, the renderer's own `round3`. */
32
+ export function round3(value) {
33
+ const rounded = Math.round(value * 1000) / 1000;
34
+ return Object.is(rounded, -0) ? 0 : rounded;
35
+ }
36
+ /* ── The two axes ─────────────────────────────────────────────────────── */
37
+ /**
38
+ * `(evolution, visibility)` → a surface point on `plot`.
39
+ *
40
+ * The renderer's visibility runs DOWN: `0` is the top of the value chain and
41
+ * `1` the bottom (`visToY = plotTop + visibility × plotHeight`,
42
+ * `src/geometry.ts`), the opposite of OWM's, where `1.0` is the top. The OWM
43
+ * projection is reused on `1 − visibility` rather than restated.
44
+ */
45
+ export function rendererPointOf(plot, evolution, visibility) {
46
+ return owmPointOf(plot, 1 - visibility, evolution);
47
+ }
48
+ /** A surface point → `(evolution, visibility)`, the exact inverse. */
49
+ export function rendererCoordsOf(plot, x, y) {
50
+ const { evolution, visibility } = owmCoordsOf(plot, x, y);
51
+ return { evolution, visibility: 1 - visibility };
52
+ }
53
+ /**
54
+ * The evolution of each phase line, read off the map's own zones: the same
55
+ * 0.175 / 0.4 / 0.7 the renderer draws (`EVOLUTION_BOUNDARIES`).
56
+ */
57
+ export const PHASE_LINES = (WARDLEY_BACKGROUND.zones ?? [])
58
+ .slice(1)
59
+ .map(zone => zone.rect.x);
60
+ /** The renderer's default canvas (its `DEFAULT_RENDER_CONFIG`). */
61
+ export const DEFAULT_RENDERER_CANVAS = { w: 1600, h: 800 };
62
+ /**
63
+ * The canvas a map's `renderConfig` declares, as the renderer resolves it
64
+ * (`build-context.ts`, `render-config-v3.ts`): the output size
65
+ * (`style.view`) wins over the canvas size (`style.background.canvas`),
66
+ * each its `default` merged with its `override`, and the default canvas fills
67
+ * whatever neither gives, axis by axis.
68
+ */
69
+ export function rendererCanvasOf(renderConfig) {
70
+ const style = renderConfig
71
+ ?.style;
72
+ const merged = (element) => {
73
+ const facets = (element ?? {});
74
+ return {
75
+ ...(typeof facets.default === 'object' ? facets.default : {}),
76
+ ...(typeof facets.override === 'object' ? facets.override : {}),
77
+ };
78
+ };
79
+ const view = merged(style?.view);
80
+ const canvas = merged(style?.background?.canvas);
81
+ const size = (...values) => values.find((value) => typeof value === 'number' && Number.isFinite(value) && value > 0);
82
+ return {
83
+ w: size(view.width, canvas.width) ?? DEFAULT_RENDERER_CANVAS.w,
84
+ h: size(view.height, canvas.height) ?? DEFAULT_RENDERER_CANVAS.h,
85
+ };
86
+ }
87
+ /**
88
+ * The renderer's fixed margins (28 left, 20 right, 28 top, 28 bottom), whose
89
+ * inside is the plot, and its default name offset (`NODE_RADIUS + 4`, `4`),
90
+ * a constant in pixels whatever the canvas.
91
+ */
92
+ const RENDERER_MARGIN = { x: 48, y: 56 };
93
+ const RENDERER_LABEL = { dx: 9, dy: 4 };
94
+ /** Renderer pixels on `canvas` → surface units on `plot`. */
95
+ const scaleOf = (plot, canvas) => ({
96
+ sx: plot.width / (canvas.w - RENDERER_MARGIN.x),
97
+ sy: plot.height / (canvas.h - RENDERER_MARGIN.y),
98
+ });
99
+ /**
100
+ * Where a component's name goes for a renderer `label.position`.
101
+ *
102
+ * The renderer's DEFAULT offset is the toolbox's own slot (right of the
103
+ * node, centred on it), so a file that says nothing gets the toolbar's
104
+ * name; an offset moves the name by its difference from that default,
105
+ * scaled to the plot. A negative `dx` puts it on the left, right-aligned,
106
+ * and `0` centres it, as the renderer's text anchor does.
107
+ */
108
+ export function jsonLabelBox(plot, w, cx, cy, offset, canvas = DEFAULT_RENDERER_CANVAS) {
109
+ const { sx, sy } = scaleOf(plot, canvas);
110
+ const y = cy + (offset.dy - RENDERER_LABEL.dy) * sy - WARDLEY_LABEL_H / 2;
111
+ if (offset.dx > 0) {
112
+ const x = cx + w / 2 + LABEL_GAP + (offset.dx - RENDERER_LABEL.dx) * sx;
113
+ return { x, y, textAlign: 'left' };
114
+ }
115
+ if (offset.dx < 0) {
116
+ const right = cx - w / 2 - LABEL_GAP + (offset.dx + RENDERER_LABEL.dx) * sx;
117
+ return { x: right - WARDLEY_LABEL_W, y, textAlign: 'right' };
118
+ }
119
+ return { x: cx - WARDLEY_LABEL_W / 2, y, textAlign: 'center' };
120
+ }
121
+ /** The inverse of {@link jsonLabelBox}: a name's box → the renderer offset. */
122
+ export function jsonLabelOffset(plot, w, cx, cy, label, textAlign, canvas = DEFAULT_RENDERER_CANVAS) {
123
+ const { sx, sy } = scaleOf(plot, canvas);
124
+ const dy = (label.y + label.h / 2 - cy) / sy + RENDERER_LABEL.dy;
125
+ let dx;
126
+ if (textAlign === 'right') {
127
+ dx =
128
+ (label.x + label.w - (cx - w / 2 - LABEL_GAP)) / sx - RENDERER_LABEL.dx;
129
+ }
130
+ else if (textAlign === 'center') {
131
+ dx = (label.x + label.w / 2 - cx) / sx;
132
+ }
133
+ else {
134
+ dx = (label.x - (cx + w / 2 + LABEL_GAP)) / sx + RENDERER_LABEL.dx;
135
+ }
136
+ return { dx: round2(dx), dy: round2(dy) };
137
+ }
138
+ /**
139
+ * How far an offset moves a name from the toolbox's slot, in surface units:
140
+ * a pipeline's name, centred above its handle, is moved this way.
141
+ */
142
+ export function jsonLabelShift(plot, offset, canvas = DEFAULT_RENDERER_CANVAS) {
143
+ const { sx, sy } = scaleOf(plot, canvas);
144
+ return {
145
+ x: (offset.dx - RENDERER_LABEL.dx) * sx,
146
+ y: (offset.dy - RENDERER_LABEL.dy) * sy,
147
+ };
148
+ }
149
+ /** The inverse of {@link jsonLabelShift}. */
150
+ export function jsonLabelUnshift(plot, shift, canvas = DEFAULT_RENDERER_CANVAS) {
151
+ const { sx, sy } = scaleOf(plot, canvas);
152
+ return {
153
+ dx: round2(shift.x / sx + RENDERER_LABEL.dx),
154
+ dy: round2(shift.y / sy + RENDERER_LABEL.dy),
155
+ };
156
+ }
157
+ /** Whether an offset is the renderer's default, so nothing need be written. */
158
+ export const isDefaultLabelOffset = (offset) => Math.abs(offset.dx - RENDERER_LABEL.dx) < 0.5 &&
159
+ Math.abs(offset.dy - RENDERER_LABEL.dy) < 0.5;
160
+ const round2 = (value) => {
161
+ const rounded = Math.round(value * 100) / 100;
162
+ return Object.is(rounded, -0) ? 0 : rounded;
163
+ };
164
+ /**
165
+ * The centre of the accelerator (decelerator) a component's `accelerator`
166
+ * (`deaccelerator`) flag is drawn as: where the RENDERER puts it, beside the
167
+ * node, the accelerator on the right and the decelerator on the left. Its
168
+ * place matters more than the name's, which it may overlap (PO, 2026-10-28).
169
+ */
170
+ export function jsonDecoratorCentre(kind, which, cx, cy) {
171
+ const dx = WARDLEY_NODE_SIZE[kind].w / 2 +
172
+ LABEL_GAP / 2 +
173
+ WARDLEY_NODE_SIZE[which].w / 2;
174
+ return { cx: which === 'accelerator' ? cx + dx : cx - dx, cy };
175
+ }
176
+ /** The numbered `step` dot: a plain ellipse at the node's upper left. */
177
+ export const STEP_SIZE = 20;
178
+ const STEP_FILL = '#cc0000';
179
+ export function jsonStepCentre(kind, cx, cy) {
180
+ const { w, h } = WARDLEY_NODE_SIZE[kind];
181
+ const overlap = 4;
182
+ return {
183
+ cx: cx - w / 2 - STEP_SIZE / 2 + overlap,
184
+ cy: cy - h / 2 - STEP_SIZE / 2 + overlap,
185
+ };
186
+ }
187
+ export function jsonStepElement(centre, number, colour, id) {
188
+ const fill = colour ?? STEP_FILL;
189
+ return {
190
+ type: 'shape',
191
+ id,
192
+ shapeType: 'ellipse',
193
+ filled: true,
194
+ fillColor: fill,
195
+ strokeColor: fill,
196
+ strokeWidth: 0,
197
+ shapeStyle: ShapeStyle.General,
198
+ roughness: 0,
199
+ text: String(number),
200
+ color: '#ffffff',
201
+ fontFamily: FontFamily.Inter,
202
+ fontSize: 12,
203
+ fontWeight: FontWeight.Bold,
204
+ textAlign: 'center',
205
+ xywh: boxString({
206
+ x: centre.cx - STEP_SIZE / 2,
207
+ y: centre.cy - STEP_SIZE / 2,
208
+ w: STEP_SIZE,
209
+ h: STEP_SIZE,
210
+ }),
211
+ };
212
+ }
213
+ /** The evolution `range` bar: a thin plain rect at the node's height. */
214
+ export const RANGE_HEIGHT = 6;
215
+ const RANGE_FILL = '#c6dbfc';
216
+ export function jsonRangeElement(left, right, cy, id) {
217
+ return {
218
+ type: 'shape',
219
+ id,
220
+ shapeType: 'rect',
221
+ filled: true,
222
+ fillColor: RANGE_FILL,
223
+ strokeColor: RANGE_FILL,
224
+ strokeWidth: 0,
225
+ shapeStyle: ShapeStyle.General,
226
+ roughness: 0,
227
+ radius: 0,
228
+ xywh: boxString({
229
+ x: Math.min(left, right),
230
+ y: cy - RANGE_HEIGHT / 2,
231
+ w: Math.max(Math.abs(right - left), 1),
232
+ h: RANGE_HEIGHT,
233
+ }),
234
+ };
235
+ }
236
+ /** Whether a plain shape is a range bar: a thin horizontal rect. */
237
+ export const isRangeShape = (shapeType, box) => shapeType === 'rect' && box.h <= RANGE_HEIGHT * 2 && box.w >= box.h * 2;
238
+ /** The `description` text: under the component's name, small and grey. */
239
+ const NOTE_WIDTH = 200;
240
+ const NOTE_FONT = 12;
241
+ export function jsonDescriptionElement(text, under, id) {
242
+ return freeText(text, under.x, under.y + 2, NOTE_WIDTH, NOTE_FONT, id, {
243
+ color: LINK_GREY,
244
+ });
245
+ }
246
+ /** Where a component's description is looked for: under its name's box. */
247
+ export const descriptionSlot = (label) => ({
248
+ x: label.x,
249
+ y: label.y + label.h + 2,
250
+ });
251
+ /** The map's `title`, above its top-left corner. */
252
+ const TITLE_FONT = 28;
253
+ export function jsonTitleElement(text, map, id) {
254
+ return freeText(text, map.x, map.y - TITLE_FONT * 2, map.w, TITLE_FONT, id, {
255
+ fontWeight: FontWeight.Bold,
256
+ });
257
+ }
258
+ /** The map's `context`, under it, as wide as it. */
259
+ export function jsonContextElement(text, map, id) {
260
+ return freeText(text, map.x, map.y + map.h + 16, map.w, 14, id, {});
261
+ }
262
+ function freeText(text, x, y, width, fontSize, id, style) {
263
+ // The height a wrapped text needs is the renderer's to measure; this is a
264
+ // first estimate the editor corrects on the first edit.
265
+ const perLine = Math.max(1, Math.floor(width / (fontSize * 0.55)));
266
+ const lines = text
267
+ .split('\n')
268
+ .reduce((n, line) => n + Math.max(1, Math.ceil(line.length / perLine)), 0);
269
+ return {
270
+ type: 'text',
271
+ id,
272
+ text,
273
+ fontFamily: FontFamily.Inter,
274
+ fontSize,
275
+ fontWeight: style.fontWeight ?? FontWeight.Regular,
276
+ color: style.color ?? NODE_STROKE,
277
+ textAlign: 'left',
278
+ hasMaxWidth: true,
279
+ xywh: boxString({ x, y, w: width, h: lines * fontSize * 1.4 }),
280
+ };
281
+ }
282
+ const boxString = (box) => `[${box.x},${box.y},${box.w},${box.h}]`;
283
+ /* ── Relations ────────────────────────────────────────────────────────── */
284
+ /**
285
+ * The look of each relation type on the toolbox's dependency link (PO,
286
+ * 2026-10-28): `DependsOn` is the link as the toolbar draws it, `Flow` a BLUE
287
+ * link thicker than it, `Constraint` a BLACK link as thick as a flow. The
288
+ * writer reads the type back from the colour and the width.
289
+ */
290
+ export const RELATION_WIDTH = LINK_STROKE_WIDTH * 2;
291
+ export const FLOW_COLOUR = '#2563eb';
292
+ export const CONSTRAINT_COLOUR = '#000000';
293
+ export function relationLook(type, style) {
294
+ const base = type === 'Flow'
295
+ ? { stroke: FLOW_COLOUR, strokeWidth: RELATION_WIDTH }
296
+ : type === 'Constraint'
297
+ ? { stroke: CONSTRAINT_COLOUR, strokeWidth: RELATION_WIDTH }
298
+ : { stroke: LINK_GREY, strokeWidth: LINK_STROKE_WIDTH };
299
+ return {
300
+ ...base,
301
+ ...(style === 'bold' ? { strokeWidth: base.strokeWidth * 2 } : {}),
302
+ ...(style === 'dashed' ? { strokeStyle: 'dash' } : {}),
303
+ };
304
+ }
305
+ /** A link's type and flow style, read off its colour, width and dash. */
306
+ export function relationOf(link) {
307
+ const width = typeof link.strokeWidth === 'number' ? link.strokeWidth : LINK_STROKE_WIDTH;
308
+ const thick = width >= RELATION_WIDTH;
309
+ const type = thick && sameColour(link.stroke, FLOW_COLOUR)
310
+ ? 'Flow'
311
+ : thick && sameColour(link.stroke, CONSTRAINT_COLOUR)
312
+ ? 'Constraint'
313
+ : 'DependsOn';
314
+ const base = type === 'DependsOn' ? LINK_STROKE_WIDTH : RELATION_WIDTH;
315
+ const style = link.strokeStyle === 'dash'
316
+ ? 'dashed'
317
+ : width >= base * 2
318
+ ? 'bold'
319
+ : undefined;
320
+ return style === undefined ? { type } : { type, style };
321
+ }
322
+ /** The four renderer evolution types, written as the arrow's label. */
323
+ export const EVOLVE_TYPES = ['natural', 'ecosystem', 'forced', 'late'];
324
+ /* ── Natures ──────────────────────────────────────────────────────────── */
325
+ /**
326
+ * The renderer natures this library's `wardley:nature` tag has (`natures.ts`).
327
+ * Its five others (personae, generic, group, natural, anthropic) have no
328
+ * value here and are dropped (PO, 2026-10-28).
329
+ */
330
+ export const LABRE_NATURES = [
331
+ 'activity',
332
+ 'data',
333
+ 'practice',
334
+ 'knowledge',
335
+ ];
336
+ /* ── Colours ──────────────────────────────────────────────────────────── */
337
+ /**
338
+ * The renderer's colour names, as its `resolveColor` reads them
339
+ * (`src/schema.ts`): six Tailwind names and any `#hex`. Any other name is
340
+ * painted black there, and so it is here.
341
+ */
342
+ const RENDERER_COLOURS = {
343
+ 'red-600': '#dc2626',
344
+ 'blue-600': '#2563eb',
345
+ 'green-600': '#16a34a',
346
+ 'yellow-600': '#ca8a04',
347
+ 'orange-600': '#ea580c',
348
+ 'purple-600': '#9333ea',
349
+ };
350
+ /** A renderer `color` as the stroke it paints. */
351
+ export function rendererColour(name) {
352
+ if (/^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(name))
353
+ return name;
354
+ return RENDERER_COLOURS[name] ?? '#000000';
355
+ }
356
+ /** A stroke as the renderer's word for it: its name if it has one. */
357
+ export function rendererColourName(stroke) {
358
+ const name = Object.keys(RENDERER_COLOURS).find(key => sameColour(RENDERER_COLOURS[key], stroke));
359
+ return name ?? stroke;
360
+ }
361
+ /** Two CSS colours that are the same paint, whatever their spelling. */
362
+ export function sameColour(a, b) {
363
+ return (typeof a === 'string' &&
364
+ typeof b === 'string' &&
365
+ a.toLowerCase() === b.toLowerCase());
366
+ }
package/dist/roles.d.ts CHANGED
@@ -8,8 +8,8 @@ import type { RoleDefs } from '@formicoidea/labre-core/std/gfx';
8
8
  * already picks when choosing the artefact in the toolbox, plus the typed
9
9
  * `dependency` EDGE carried by the Wardley link connector.
10
10
  *
11
- * Hierarchy is DATA (`parent`), never TS inheritance: `market` and `ecosystem`
12
- * specialise `component`, so a rule written on `wardley:component` applies to
11
+ * Hierarchy is DATA (`parent`), never TS inheritance: `market`, `ecosystem`
12
+ * and `method` specialise `component`, so a rule written on `wardley:component` applies to
13
13
  * them for free (see `roleIsA`). The `anchor` (user / need) is a role of its
14
14
  * own — a component with no need of its own — and is deliberately NOT a child
15
15
  * of `component`.
package/dist/roles.js CHANGED
@@ -59,8 +59,21 @@ const DEFS = [
59
59
  labelKey: 'com.labre.wardley.role.ecosystem',
60
60
  labelFallback: 'Ecosystem',
61
61
  },
62
+ /**
63
+ * A component + method: a white component inscribed in a ring that says how
64
+ * it is sourced, run or staffed (`natures.ts`, the `wardley:method` tag).
65
+ *
66
+ * A child of `component` (ADR 0046), and the white circle is why: what sits
67
+ * inside the ring IS a link in the value chain, with a nature, a phase and
68
+ * consumers above it, and the ring is a decision about that link. Before
69
+ * 0.62 the role had no parent, so the nature picker never reached it, the
70
+ * reading panel skipped it and W3 never measured it — a component drawn
71
+ * with a method lost every fact a plain one carried. Through `roleIsA` it
72
+ * now gets them all, exactly as a market and an ecosystem do.
73
+ */
62
74
  {
63
75
  id: WARDLEY_ROLE.method,
76
+ parent: WARDLEY_ROLE.component,
64
77
  kind: 'node',
65
78
  labelKey: 'com.labre.wardley.role.method',
66
79
  labelFallback: 'Method',
@@ -1,6 +1,5 @@
1
1
  import type { FrameworkPalette, NamedPalette } from '@formicoidea/labre-core/components/color-picker';
2
2
  import { type Color, type ShapeElementModel } from '@formicoidea/labre-core/model';
3
- import { type ChromeWording } from '@formicoidea/labre-core/shared/services';
4
3
  /**
5
4
  * What a picked swatch WRITES on a Wardley node.
6
5
  *
@@ -15,28 +14,6 @@ import { type ChromeWording } from '@formicoidea/labre-core/shared/services';
15
14
  * (`--affine-…`) is not a hex at all and must reach the document intact.
16
15
  */
17
16
  export declare function wardleyFillColor(model: ShapeElementModel, value: Color): Color;
18
- /**
19
- * The nine Wardley swatches' own names, resolved by `resolvePaletteLabel`
20
- * (`@labre/affine-components/color-picker`) through each swatch's own
21
- * {@link NamedPalette.labelWording} — the framework-agnostic mechanism a
22
- * gfx module uses to name a palette without touching `Palette`
23
- * (`packages/affine/model`, RED ZONE) or importing another framework.
24
- *
25
- * Exported so `../translations.ts` can list them in the manifest under
26
- * source `chrome`, the same treatment `WARDLEY_TOOLBAR_WORDINGS` already
27
- * gets — a swatch's name is toolbar chrome, re-rendered on every locale
28
- * switch, never seeded into a document.
29
- */
30
- export declare const WARDLEY_PALETTE_WORDING_WONDER: ChromeWording;
31
- export declare const WARDLEY_PALETTE_WORDING_PEACE: ChromeWording;
32
- export declare const WARDLEY_PALETTE_WORDING_WAR: ChromeWording;
33
- export declare const WARDLEY_PALETTE_WORDING_WONDER_LIGHT: ChromeWording;
34
- export declare const WARDLEY_PALETTE_WORDING_PEACE_LIGHT: ChromeWording;
35
- export declare const WARDLEY_PALETTE_WORDING_WAR_LIGHT: ChromeWording;
36
- export declare const WARDLEY_PALETTE_WORDING_RED: ChromeWording;
37
- export declare const WARDLEY_PALETTE_WORDING_INERTIA: ChromeWording;
38
- export declare const WARDLEY_PALETTE_WORDING_METHOD_GREY: ChromeWording;
39
- export declare const WARDLEY_PALETTE_WORDINGS: readonly ChromeWording[];
40
17
  /**
41
18
  * From the default editor palette we keep ONLY the neutrals — the historical
42
19
  * colours are dropped in favour of the Wardley swatches above.