@formicoidea/labre-framework-c4 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.
- package/dist/actions.d.ts +179 -0
- package/dist/actions.js +375 -0
- package/dist/background.d.ts +77 -0
- package/dist/background.js +223 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +221 -0
- package/dist/component.d.ts +192 -0
- package/dist/component.js +188 -0
- package/dist/consts.d.ts +331 -0
- package/dist/consts.js +384 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +18 -0
- package/dist/element-renderer.js +14 -0
- package/dist/element-view.d.ts +51 -0
- package/dist/element-view.js +146 -0
- package/dist/export.d.ts +184 -0
- package/dist/export.js +454 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +52 -0
- package/dist/interchange.d.ts +74 -0
- package/dist/interchange.js +143 -0
- package/dist/legend.d.ts +37 -0
- package/dist/legend.js +123 -0
- package/dist/levels.d.ts +70 -0
- package/dist/levels.js +46 -0
- package/dist/morph.d.ts +89 -0
- package/dist/morph.js +229 -0
- package/dist/node/node-renderer.d.ts +6 -0
- package/dist/node/node-renderer.js +304 -0
- package/dist/node/node-view.d.ts +45 -0
- package/dist/node/node-view.js +80 -0
- package/dist/node/type-line-watcher.d.ts +70 -0
- package/dist/node/type-line-watcher.js +142 -0
- package/dist/presets.d.ts +84 -0
- package/dist/presets.js +149 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +177 -0
- package/dist/roles.d.ts +116 -0
- package/dist/roles.js +303 -0
- package/dist/rules.d.ts +95 -0
- package/dist/rules.js +1261 -0
- package/dist/toolbar/c4-menu.d.ts +11 -0
- package/dist/toolbar/c4-menu.js +14 -0
- package/dist/toolbar/c4-senior-button.d.ts +19 -0
- package/dist/toolbar/c4-senior-button.js +23 -0
- package/dist/toolbar/config.d.ts +150 -0
- package/dist/toolbar/config.js +436 -0
- package/dist/toolbar/icons.d.ts +90 -0
- package/dist/toolbar/icons.js +157 -0
- package/dist/toolbar/senior-tool.d.ts +1 -0
- package/dist/toolbar/senior-tool.js +11 -0
- package/dist/translations.d.ts +18 -0
- package/dist/translations.js +42 -0
- package/dist/type-line.d.ts +175 -0
- package/dist/type-line.js +244 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +148 -0
- package/package.json +34 -0
package/dist/morph.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { morphLabel, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { C4NodeElementModel, GroupElementModel, TextElementModel, } from '@formicoidea/labre-core/model';
|
|
3
|
+
import { c4CommandIcons, c4Commands } from './commands.js';
|
|
4
|
+
import { NODE_LABEL } from './consts.js';
|
|
5
|
+
import { c4MorphClears, c4MorphProps } from './presets.js';
|
|
6
|
+
import { C4_ROLE, C4_ROLE_OF_KIND } from './roles.js';
|
|
7
|
+
import { c4MorphedTypeLine } from './type-line.js';
|
|
8
|
+
/**
|
|
9
|
+
* What a C4 artefact may BECOME — the declaration behind the "Change type"
|
|
10
|
+
* dropdown on a selected component's contextual toolbar.
|
|
11
|
+
*
|
|
12
|
+
* ## Families, and the one thing that makes them safe here
|
|
13
|
+
*
|
|
14
|
+
* A person and an external person are the same actor, said with a different
|
|
15
|
+
* ownership; a container, a database, a mobile app and a browser app are the
|
|
16
|
+
* same level of the model, drawn with different silhouettes. Realising halfway
|
|
17
|
+
* through a container diagram that the box should have been the cylinder is
|
|
18
|
+
* modelling, not a mistake — and the only way through it today is delete,
|
|
19
|
+
* re-draw, re-connect, and retype three tiers of words.
|
|
20
|
+
*
|
|
21
|
+
* The three families below are DATA. Nothing derives them, and the role tree
|
|
22
|
+
* would have been the wrong source in the same way it is for BPMN: `roleIsA`
|
|
23
|
+
* makes `c4:database` a `c4:container`, so a derivation would reach in one
|
|
24
|
+
* direction and not the other, and it says nothing at all about
|
|
25
|
+
* `person`/`person-ext`, which are ONE role and still two artefacts.
|
|
26
|
+
*
|
|
27
|
+
* What makes these three families cheap is a property they happen to have and
|
|
28
|
+
* a later one may not: **every member of a family lays its words out
|
|
29
|
+
* identically**. `c4TierBoxes` differs on exactly one thing — the person's head
|
|
30
|
+
* clearance, which both people share — and `NODE_SIZE` on exactly one, the
|
|
31
|
+
* person's taller silhouette, which both people also share. So a morph inside
|
|
32
|
+
* a family needs no re-layout at all: the three tiers stay where they are, the
|
|
33
|
+
* group's derived box stays what it was, and the geometry a morph promises not
|
|
34
|
+
* to touch is genuinely untouched. `component` is deliberately in NO family
|
|
35
|
+
* partly for that reason and mostly for a better one: a component is a part of
|
|
36
|
+
* a container, not another drawing of one, and offering the swap would invite
|
|
37
|
+
* a diagram that mixes two levels of the model — the one thing C4 exists to
|
|
38
|
+
* stop. The two boundaries are frames rather than artefacts and are excluded
|
|
39
|
+
* for the reason `roles.ts` gives.
|
|
40
|
+
*/
|
|
41
|
+
export const C4_MORPH_FAMILIES = [
|
|
42
|
+
// Declaration order is menu order, and each family opens on the INTERNAL,
|
|
43
|
+
// undecorated member: the plain artefact is the honest first draft and the
|
|
44
|
+
// variant is the refinement — the same call `commands.ts` makes about the
|
|
45
|
+
// order of the senior sub-menu.
|
|
46
|
+
['person', 'person-ext'],
|
|
47
|
+
['system', 'system-ext'],
|
|
48
|
+
['container', 'database', 'mobile', 'browser'],
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* The creation command that draws each kind, keyed BY that kind.
|
|
52
|
+
*
|
|
53
|
+
* Derived from `telemetry.element`, which is where the kind is already written
|
|
54
|
+
* down (`node:database`) and is documented as a historical value that must not
|
|
55
|
+
* be renamed. Deriving rather than restating is what stops a second table of
|
|
56
|
+
* labels and icons drifting from the one the sub-menu and the catalogue read.
|
|
57
|
+
*/
|
|
58
|
+
const NODE_COMMANDS = new Map(c4Commands.flatMap(command => {
|
|
59
|
+
const element = command.telemetry?.element;
|
|
60
|
+
return element?.startsWith('node:')
|
|
61
|
+
? [[element.slice('node:'.length), command]]
|
|
62
|
+
: [];
|
|
63
|
+
}));
|
|
64
|
+
/** A kind's wording: the creation command's own key and English. */
|
|
65
|
+
function labelOf(kind) {
|
|
66
|
+
const command = NODE_COMMANDS.get(kind);
|
|
67
|
+
return {
|
|
68
|
+
key: command?.labelKey,
|
|
69
|
+
fallback: command?.labelFallback ?? kind,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A kind's icon: the creation command's own, reused rather than redrawn. */
|
|
73
|
+
function iconOf(kind) {
|
|
74
|
+
const iconKey = NODE_COMMANDS.get(kind)?.iconKey;
|
|
75
|
+
return (iconKey && c4CommandIcons[iconKey]) || c4CommandIcons['c4.container'];
|
|
76
|
+
}
|
|
77
|
+
/* ── Resolving the composite ───────────────────────────────────────────── */
|
|
78
|
+
/**
|
|
79
|
+
* The `c4Node` shape a selected GROUP is the component of — `undefined` when
|
|
80
|
+
* the group is not one.
|
|
81
|
+
*
|
|
82
|
+
* A C4 component is a native `group` holding the shape and its three lines of
|
|
83
|
+
* words (PO recette, 28/08/2026), so what a click selects is the group and what
|
|
84
|
+
* carries `kind` is the shape. This is the whole of the indirection, and it is
|
|
85
|
+
* also the gate: a plain group somebody lassoed round three rectangles, a
|
|
86
|
+
* Wardley component (a group of a circle and its label) and a group of two C4
|
|
87
|
+
* components all answer `undefined`, so none of them is ever offered the menu.
|
|
88
|
+
*
|
|
89
|
+
* TWO shapes is a refusal rather than a first-wins pick. A group holding two
|
|
90
|
+
* components is a group somebody made of two components — morphing "it" would
|
|
91
|
+
* mean picking one of them by document order, and the honest answer to an
|
|
92
|
+
* ambiguous selection is the same one the generic module gives to a mixed
|
|
93
|
+
* family: nothing.
|
|
94
|
+
*/
|
|
95
|
+
export function c4NodeOfComponent(model) {
|
|
96
|
+
if (!(model instanceof GroupElementModel))
|
|
97
|
+
return undefined;
|
|
98
|
+
let found;
|
|
99
|
+
for (const child of model.childElements) {
|
|
100
|
+
if (!(child instanceof C4NodeElementModel))
|
|
101
|
+
continue;
|
|
102
|
+
if (found)
|
|
103
|
+
return undefined;
|
|
104
|
+
found = child;
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
/** One tier of a component, by the role it carries. */
|
|
109
|
+
function tierOf(model, role) {
|
|
110
|
+
if (!(model instanceof GroupElementModel))
|
|
111
|
+
return undefined;
|
|
112
|
+
return model.childElements.find((child) => child instanceof TextElementModel && child.role === role);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Write one tier, in place, if the decision says to.
|
|
116
|
+
*
|
|
117
|
+
* `decide` reads the tier as it is STORED and answers with the whole line to
|
|
118
|
+
* write, or `null` for "this text is the author's, leave it". The comparison
|
|
119
|
+
* that decides whether anything happens is against the TRIMMED text — the same
|
|
120
|
+
* string `decide` was given — because a decision made on `'[Container] '`
|
|
121
|
+
* returns `'[Person]'`, and comparing that against the untrimmed original
|
|
122
|
+
* would call a no-op a change and spend a transaction rewriting the padding
|
|
123
|
+
* away.
|
|
124
|
+
*
|
|
125
|
+
* In place, in one transaction, and inside the caller's `captureSync`: the
|
|
126
|
+
* `Y.Text` instance is what any bound editor holds, so it is mutated rather
|
|
127
|
+
* than replaced, and the rewrite is part of the same single ctrl+z as the kind
|
|
128
|
+
* that made it necessary.
|
|
129
|
+
*/
|
|
130
|
+
function rewriteTier(model, role, decide) {
|
|
131
|
+
const tier = tierOf(model, role);
|
|
132
|
+
if (!tier || tier.isLocked())
|
|
133
|
+
return;
|
|
134
|
+
const text = tier.text.toString().trim();
|
|
135
|
+
const next = decide(text);
|
|
136
|
+
if (next === null || next === text)
|
|
137
|
+
return;
|
|
138
|
+
tier.surface.store.transact(() => {
|
|
139
|
+
tier.text.delete(0, tier.text.length);
|
|
140
|
+
tier.text.insert(0, next);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Keep the component's own words saying what the shape now is — the
|
|
145
|
+
* `afterMorph` half of the C4 spec.
|
|
146
|
+
*
|
|
147
|
+
* ## The two tiers a morph may touch, and the one rule they share
|
|
148
|
+
*
|
|
149
|
+
* The rule is the same for both and it is deliberately timid: rewrite ONLY
|
|
150
|
+
* what the notation itself wrote, and never what an author typed. A morph is
|
|
151
|
+
* not allowed to take away words somebody put on the picture.
|
|
152
|
+
*
|
|
153
|
+
* - **The type line.** `[Container: Java]` is two statements in one: the
|
|
154
|
+
* bracketed word is derived from `kind`, the technology is the author's.
|
|
155
|
+
* The watcher that keeps the halves apart (`node/type-line-watcher.ts`) only
|
|
156
|
+
* runs when somebody finishes TYPING into the tier, so a morph — which
|
|
157
|
+
* changes the kind with nobody touching the words — has to make the same
|
|
158
|
+
* statement for itself. {@link c4MorphedTypeLine} owns that decision.
|
|
159
|
+
* - **The title.** This is the one a user actually sees go wrong, and it is
|
|
160
|
+
* the one the type line does NOT cover: `NODE_LABEL` differs inside the
|
|
161
|
+
* container family (`Container` / `Database` / `Mobile app` / `Web app`), so
|
|
162
|
+
* an untouched component morphed from a container to a database keeps a
|
|
163
|
+
* cylinder captioned "Container" — a picture flatly contradicting its own
|
|
164
|
+
* name. The rule is placeholder→placeholder and nothing else: the title is
|
|
165
|
+
* rewritten only when it is EXACTLY the source kind's own prompt, which is
|
|
166
|
+
* what a component nobody has named still carries. A component called
|
|
167
|
+
* "Customer database" keeps that name whatever it becomes, which is the PO's
|
|
168
|
+
* "the label is intact" for every title that is real content.
|
|
169
|
+
*/
|
|
170
|
+
function rewriteTiers(model, from, to) {
|
|
171
|
+
rewriteTier(model, C4_ROLE['type-line'], text => c4MorphedTypeLine(from, to, text));
|
|
172
|
+
rewriteTier(model, C4_ROLE.title, text => c4MorphedTitle(from, to, text));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The name a component should carry once its shape has morphed — or `null`
|
|
176
|
+
* when the name is the AUTHOR's and must not be touched.
|
|
177
|
+
*
|
|
178
|
+
* Exactly one case rewrites: the title is the source kind's own creation
|
|
179
|
+
* prompt, letter for letter, which is what a component nobody has named still
|
|
180
|
+
* says. Everything else — a name typed over it, a prompt with a word added, a
|
|
181
|
+
* cleared title, another kind's prompt — is content, and content survives a
|
|
182
|
+
* morph untouched.
|
|
183
|
+
*
|
|
184
|
+
* This is the tier the type line does NOT cover, and the only one where a user
|
|
185
|
+
* sees the contradiction: {@link NODE_LABEL} differs across the container
|
|
186
|
+
* family where `C4_TYPE_WORD` does not, so a fresh container morphed to a
|
|
187
|
+
* database would otherwise be a cylinder captioned "Container".
|
|
188
|
+
*
|
|
189
|
+
* Pure and total over every string, like its sibling in `type-line.ts`: the
|
|
190
|
+
* input is a canvas text element somebody may have typed anything into.
|
|
191
|
+
*/
|
|
192
|
+
export function c4MorphedTitle(from, to, rawText) {
|
|
193
|
+
return (rawText ?? '').trim() === NODE_LABEL[from] ? NODE_LABEL[to] : null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* C4's morph declaration, handed to the generic `morphToolbarConfig`.
|
|
197
|
+
*
|
|
198
|
+
* `modelType` is the native `GroupElementModel` and not `C4NodeElementModel`,
|
|
199
|
+
* because the group is what a click on a component selects. Everything that
|
|
200
|
+
* makes that safe is in {@link c4NodeOfComponent}: the toolbar's own
|
|
201
|
+
* homogeneity test only proves that every selected element is A group, and the
|
|
202
|
+
* resolution proves that every one of them is one of OURS.
|
|
203
|
+
*
|
|
204
|
+
* `propsOf` is the shipped creation builder minus `type` / `xywh` / `text`
|
|
205
|
+
* ({@link c4MorphProps}), so the palette and the morph cannot disagree about
|
|
206
|
+
* what a database looks like; `clearOf` is empty on today's table and derived
|
|
207
|
+
* rather than assumed so (`presets.ts`).
|
|
208
|
+
*/
|
|
209
|
+
export const C4_MORPH_SPEC = {
|
|
210
|
+
framework: 'c4',
|
|
211
|
+
families: C4_MORPH_FAMILIES,
|
|
212
|
+
modelType: GroupElementModel,
|
|
213
|
+
resolveTarget: c4NodeOfComponent,
|
|
214
|
+
kindOf: (model) => model instanceof C4NodeElementModel ? model.kind : undefined,
|
|
215
|
+
// The SHAPE's role, which is the only role a C4 artefact has: the group
|
|
216
|
+
// carries none by design, and the three tiers carry roles of their own
|
|
217
|
+
// (`c4:title`, `c4:type-line`, `c4:description`) that say which LINE they
|
|
218
|
+
// are and not what kind of box they belong to — so a morph never rewrites
|
|
219
|
+
// them. Note that this table collapses: `person` and `person-ext` are both
|
|
220
|
+
// `c4:person`, so a morph inside those two families reports the same role
|
|
221
|
+
// twice, which is the truth — what changed is the ownership, not the meaning.
|
|
222
|
+
roleOf: kind => C4_ROLE_OF_KIND[kind],
|
|
223
|
+
propsOf: c4MorphProps,
|
|
224
|
+
clearOf: c4MorphClears,
|
|
225
|
+
afterMorph: rewriteTiers,
|
|
226
|
+
labelOf,
|
|
227
|
+
iconOf,
|
|
228
|
+
label: morphLabel('com.labre.morph.toolbar.label', 'Change type'),
|
|
229
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ElementRenderer } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { type C4NodeElementModel } from '@formicoidea/labre-core/model';
|
|
3
|
+
export declare const c4Node: ElementRenderer<C4NodeElementModel>;
|
|
4
|
+
export declare const C4NodeRendererExtension: import("@formicoidea/labre-core/store").ExtensionType & {
|
|
5
|
+
identifier: import("@formicoidea/labre-core/_pkgs/global/di").ServiceIdentifier<ElementRenderer<C4NodeElementModel>>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { ElementRendererExtension, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { shape as shapeRenderer } from '@formicoidea/labre-core/gfx/shape';
|
|
3
|
+
import { DefaultTheme, } from '@formicoidea/labre-core/model';
|
|
4
|
+
import { NODE_RADIUS } from '../consts.js';
|
|
5
|
+
/**
|
|
6
|
+
* Renderer for a C4 node — the glyph layer, and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* Every path, radius and offset below is read off the PO's reference model
|
|
9
|
+
* (`C4Model_default.svg`, a Visio export of the official stencil) and expressed
|
|
10
|
+
* as a FRACTION of the node box, so a resized element keeps the stencil's own
|
|
11
|
+
* proportions instead of the stencil's own pixels.
|
|
12
|
+
*
|
|
13
|
+
* ## What the native shape does, and what this file does
|
|
14
|
+
*
|
|
15
|
+
* The native shape renderer runs FIRST and owns the fill, the stroke, the theme
|
|
16
|
+
* and the TITLE — the element's inner text, editable in place exactly like any
|
|
17
|
+
* other shape's. This file then paints, in the element-local frame, only the
|
|
18
|
+
* silhouettes a rectangle cannot be: `person` / `person-ext` (a circular head
|
|
19
|
+
* fused into a strongly rounded body), `database` (a cylinder), `mobile` (a
|
|
20
|
+
* phone: a dark bezel with a lighter screen inset in it, a speaker slot and a
|
|
21
|
+
* button) and `browser` (a window: a dark frame, a chrome band with three dots
|
|
22
|
+
* and an address bar, and a lighter screen under it). For all five the native
|
|
23
|
+
* rect is created unfilled and unstroked — the glyph IS the body.
|
|
24
|
+
*
|
|
25
|
+
* ## The two tiers this file no longer paints
|
|
26
|
+
*
|
|
27
|
+
* It used to paint the type line and the description under the title, off two
|
|
28
|
+
* fields on the model. It does not any more, and the difference is the PO's
|
|
29
|
+
* recette of 28/08/2026: a C4 component is the shape and its own words, GROUPED
|
|
30
|
+
* — the type line and the description are real canvas TEXT elements, created
|
|
31
|
+
* beside the shape and edited in place like any other text. Painted tiers could
|
|
32
|
+
* only ever be read, and a picture an architect cannot type on is a form waiting
|
|
33
|
+
* to be invented. `actions.ts` places them; the text renderer draws them.
|
|
34
|
+
*
|
|
35
|
+
* `system`, `system-ext`, `container` and `component` have no glyph at all: the
|
|
36
|
+
* stencil draws them as plain SQUARE-cornered rectangles, and what tells them
|
|
37
|
+
* apart is the COLOUR they are created in (`NODE_PALETTE`) plus the role stamped
|
|
38
|
+
* on them. That is C4's own answer, and it is why this pack has nine kinds and
|
|
39
|
+
* five element roles rather than nine glyphs.
|
|
40
|
+
*
|
|
41
|
+
* Both fill and stroke are read off the MODEL, never off the palette table: a
|
|
42
|
+
* node's colours are editable from the shape toolbar like any other shape's, and
|
|
43
|
+
* a glyph that painted the table's blue would silently ignore the user's own
|
|
44
|
+
* choice. The palette is what the creation site SEEDS them with.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* The kinds C4 draws BARE — a plain native shape with nothing on it.
|
|
48
|
+
*
|
|
49
|
+
* Written as the short list rather than the long one, so that the glyph kinds
|
|
50
|
+
* are DERIVED from the model's union instead of restated beside it: a kind added
|
|
51
|
+
* to `C4NodeKind` is a glyph kind by default, and the exhaustiveness check at the
|
|
52
|
+
* bottom of {@link paintGlyph} then refuses to compile until it is drawn.
|
|
53
|
+
*/
|
|
54
|
+
const BARE_KINDS = {
|
|
55
|
+
system: true,
|
|
56
|
+
'system-ext': true,
|
|
57
|
+
container: true,
|
|
58
|
+
component: true,
|
|
59
|
+
};
|
|
60
|
+
const isBare = (kind) => Object.hasOwn(BARE_KINDS, kind);
|
|
61
|
+
const TAU = Math.PI * 2;
|
|
62
|
+
/* ── The stencil's geometry, as fractions of the node box ──────────────── */
|
|
63
|
+
/**
|
|
64
|
+
* The person (`mID 1`), measured off its single path.
|
|
65
|
+
*
|
|
66
|
+
* The head arc runs between `x=68.89` and `x=37.41` on the body's top edge with
|
|
67
|
+
* `rx=26.362`, `ry=26.504` and `large-arc-flag=1`; solving the SVG centre
|
|
68
|
+
* equation puts its centre 21.263 units ABOVE that edge, so the head stands
|
|
69
|
+
* `26.504 + 21.263 = 47.767` clear of a body 74.409 tall — a silhouette 122.18
|
|
70
|
+
* tall in total, which is what {@link PERSON_BOX} is twice.
|
|
71
|
+
*/
|
|
72
|
+
const PERSON = {
|
|
73
|
+
/** Head radius, as a fraction of the box width, and of its height. */
|
|
74
|
+
headOfWidth: 26.362 / 106.3,
|
|
75
|
+
headOfHeight: 26.504 / 122.18,
|
|
76
|
+
/** Where the body's top edge sits, as a multiple of the head radius. */
|
|
77
|
+
bodyTopPerHead: 47.767 / 26.433,
|
|
78
|
+
/** The body's corner radius (`19.842`), as a fraction of the box width. */
|
|
79
|
+
radiusOfWidth: 19.842 / 106.3,
|
|
80
|
+
};
|
|
81
|
+
/** The cylinder's lid and floor: `ry ≈ 9.97` of a 74.409-tall box. */
|
|
82
|
+
const DATABASE_RY = 9.97 / 74.409;
|
|
83
|
+
/** The phone (`mID 6`): a screen inset in a bezel, a speaker slot, a button. */
|
|
84
|
+
const MOBILE = {
|
|
85
|
+
screen: {
|
|
86
|
+
x: 9.2126 / 106.3,
|
|
87
|
+
y: 2.766 / 74.409,
|
|
88
|
+
w: 87.874 / 106.3,
|
|
89
|
+
h: 68.882 / 74.409,
|
|
90
|
+
},
|
|
91
|
+
screenRadius: 1.4173 / 106.3,
|
|
92
|
+
/** The home button, centred in the LEFT bezel column. */
|
|
93
|
+
button: { x: 4.985 / 106.3, y: 38.039 / 74.409, r: 3.0301 / 106.3 },
|
|
94
|
+
/** The speaker slot, a vertical line down the RIGHT bezel column. */
|
|
95
|
+
speaker: { x: 101.411 / 106.3, y0: 29.851 / 74.409, y1: 44.561 / 74.409 },
|
|
96
|
+
};
|
|
97
|
+
/** The browser window (`mID 11`): a chrome band over a screen. */
|
|
98
|
+
const BROWSER = {
|
|
99
|
+
screen: {
|
|
100
|
+
x: 2.126 / 106.3,
|
|
101
|
+
y: 9.214 / 74.409,
|
|
102
|
+
w: 102.05 / 106.3,
|
|
103
|
+
h: 63.071 / 74.409,
|
|
104
|
+
},
|
|
105
|
+
screenRadius: 1.4173 / 106.3,
|
|
106
|
+
dot: { y: 4.766 / 74.409, r: 3.0301 / 106.3 },
|
|
107
|
+
dotsX: [6.5001 / 106.3, 13.978 / 106.3, 21.455 / 106.3],
|
|
108
|
+
bar: {
|
|
109
|
+
x: 26.169 / 106.3,
|
|
110
|
+
y: 1.731 / 74.409,
|
|
111
|
+
w: 77.862 / 106.3,
|
|
112
|
+
h: 6.07 / 74.409,
|
|
113
|
+
r: 2.8346 / 106.3,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
/* ── Paths ─────────────────────────────────────────────────────────────── */
|
|
117
|
+
/**
|
|
118
|
+
* Trace a rounded rectangle, and say whether there was one to trace.
|
|
119
|
+
*
|
|
120
|
+
* An element can be dragged to nothing: the resize manager takes the absolute
|
|
121
|
+
* value of the dragged extents but sets no minimum size, and every dimension
|
|
122
|
+
* here has the stroke width subtracted from it first — so a 2-unit border on a
|
|
123
|
+
* 1-unit box gives -0.5. `arcTo` throws `IndexSizeError` on a negative radius
|
|
124
|
+
* rather than clamping, and the surface render loop wraps no renderer in a
|
|
125
|
+
* `try`, so one such throw aborts the rest of the frame with an unbalanced save
|
|
126
|
+
* stack. Returning `false` lets the caller skip the fill and the stroke too,
|
|
127
|
+
* rather than painting a degenerate path.
|
|
128
|
+
*/
|
|
129
|
+
function roundedRectPath(ctx, x, y, w, h, r) {
|
|
130
|
+
if (!(w > 0) || !(h > 0))
|
|
131
|
+
return false;
|
|
132
|
+
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
|
|
133
|
+
ctx.beginPath();
|
|
134
|
+
ctx.moveTo(x + rr, y);
|
|
135
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
136
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
137
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
138
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
139
|
+
ctx.closePath();
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/** Fill and outline a rounded rectangle, skipping a degenerate one entirely. */
|
|
143
|
+
function fillStrokeRect(ctx, x, y, w, h, r) {
|
|
144
|
+
if (!roundedRectPath(ctx, x, y, w, h, r))
|
|
145
|
+
return;
|
|
146
|
+
ctx.fill();
|
|
147
|
+
ctx.stroke();
|
|
148
|
+
}
|
|
149
|
+
/* ── The glyphs ────────────────────────────────────────────────────────── */
|
|
150
|
+
/**
|
|
151
|
+
* Paint one glyph kind, in the element-local frame, with `fillStyle` /
|
|
152
|
+
* `strokeStyle` / `lineWidth` already set from the model.
|
|
153
|
+
*
|
|
154
|
+
* @param inset half the stroke width — the body box is drawn inside it, so the
|
|
155
|
+
* outline lands within the element's bounds rather than straddling them.
|
|
156
|
+
*/
|
|
157
|
+
function paintGlyph(kind, ctx, fill, stroke, w, h, inset) {
|
|
158
|
+
const cx = w / 2;
|
|
159
|
+
const x0 = inset;
|
|
160
|
+
const y0 = inset;
|
|
161
|
+
const x1 = w - inset;
|
|
162
|
+
const y1 = h - inset;
|
|
163
|
+
const bw = x1 - x0;
|
|
164
|
+
const bh = y1 - y0;
|
|
165
|
+
// ── The person: a head fused into a strongly rounded body ────────────
|
|
166
|
+
if (kind === 'person' || kind === 'person-ext') {
|
|
167
|
+
// A single radius from whichever dimension is the tighter, so the head stays
|
|
168
|
+
// CIRCULAR at any aspect ratio the author drags the element to. At the
|
|
169
|
+
// stencil's own ratio the two agree to within half a unit, which is the
|
|
170
|
+
// whole reason the person keeps a box of its own.
|
|
171
|
+
const headR = Math.max(0, Math.min(w * PERSON.headOfWidth, h * PERSON.headOfHeight));
|
|
172
|
+
const headY = y0 + headR;
|
|
173
|
+
const bodyTop = y0 + headR * PERSON.bodyTopPerHead;
|
|
174
|
+
// The head goes FIRST so the body covers its lower arc: what is left is a
|
|
175
|
+
// head standing clear of a pair of shoulders, which is the stencil's own
|
|
176
|
+
// person — its path draws exactly this, the body's top edge running into the
|
|
177
|
+
// head arc and back out of it.
|
|
178
|
+
if (headR > 0) {
|
|
179
|
+
ctx.beginPath();
|
|
180
|
+
ctx.arc(cx, headY, headR, 0, TAU);
|
|
181
|
+
ctx.fill();
|
|
182
|
+
ctx.stroke();
|
|
183
|
+
}
|
|
184
|
+
fillStrokeRect(ctx, x0, bodyTop, bw, y1 - bodyTop, w * PERSON.radiusOfWidth);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// ── The database: a cylinder ─────────────────────────────────────────
|
|
188
|
+
if (kind === 'database') {
|
|
189
|
+
// Both radii subtract the stroke first, so both go NEGATIVE on an element
|
|
190
|
+
// dragged narrower (or shorter) than its own border — and a negative radius
|
|
191
|
+
// is the one thing `ellipse` throws on. Clamped where they are computed,
|
|
192
|
+
// exactly as the BPMN data store's are.
|
|
193
|
+
const rx = Math.max(0, bw / 2);
|
|
194
|
+
const ry = Math.max(0, bh * DATABASE_RY);
|
|
195
|
+
const mx = (x0 + x1) / 2;
|
|
196
|
+
const top = y0 + ry;
|
|
197
|
+
const bottom = y1 - ry;
|
|
198
|
+
ctx.beginPath();
|
|
199
|
+
ctx.moveTo(x0, top);
|
|
200
|
+
ctx.lineTo(x0, bottom);
|
|
201
|
+
// Floor, left to right through the lowest point.
|
|
202
|
+
ctx.ellipse(mx, bottom, rx, ry, 0, Math.PI, 0, true);
|
|
203
|
+
ctx.lineTo(x1, top);
|
|
204
|
+
// Back up the front of the lid, right to left.
|
|
205
|
+
ctx.ellipse(mx, top, rx, ry, 0, 0, Math.PI, false);
|
|
206
|
+
ctx.closePath();
|
|
207
|
+
ctx.fill();
|
|
208
|
+
ctx.stroke();
|
|
209
|
+
// The lid's own far edge, which the body path does not include — the rim the
|
|
210
|
+
// stencil draws as two extra `.st10` arcs.
|
|
211
|
+
ctx.beginPath();
|
|
212
|
+
ctx.ellipse(mx, top, rx, ry, 0, 0, TAU);
|
|
213
|
+
ctx.stroke();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// ── The two devices: a dark frame with a lighter screen inset in it ──
|
|
217
|
+
// The stencil paints the OUTER rectangle in the darker of the node's two
|
|
218
|
+
// colours and the screen in the lighter, which is the opposite of a band
|
|
219
|
+
// painted over a body — so both are drawn here in full, and the native rect
|
|
220
|
+
// under them paints nothing.
|
|
221
|
+
ctx.fillStyle = stroke;
|
|
222
|
+
ctx.strokeStyle = stroke;
|
|
223
|
+
// Every INNER feature below is placed against the element box (`0…w`, `0…h`)
|
|
224
|
+
// rather than against the stroke-inset one the outer rectangle uses. The
|
|
225
|
+
// stencil states them that way — its screen has equal bezel columns left and
|
|
226
|
+
// right — and shifting them by half a border would make those columns unequal
|
|
227
|
+
// by exactly that half, which is visible at the sizes these are drawn at.
|
|
228
|
+
if (kind === 'mobile') {
|
|
229
|
+
fillStrokeRect(ctx, x0, y0, bw, bh, NODE_RADIUS.mobile);
|
|
230
|
+
ctx.fillStyle = fill;
|
|
231
|
+
fillStrokeRect(ctx, w * MOBILE.screen.x, h * MOBILE.screen.y, w * MOBILE.screen.w, h * MOBILE.screen.h, w * MOBILE.screenRadius);
|
|
232
|
+
// The button, in the left bezel column…
|
|
233
|
+
const buttonR = Math.max(0, w * MOBILE.button.r);
|
|
234
|
+
if (buttonR > 0) {
|
|
235
|
+
ctx.beginPath();
|
|
236
|
+
ctx.arc(w * MOBILE.button.x, h * MOBILE.button.y, buttonR, 0, TAU);
|
|
237
|
+
ctx.fill();
|
|
238
|
+
}
|
|
239
|
+
// …and the speaker slot down the right one.
|
|
240
|
+
ctx.strokeStyle = fill;
|
|
241
|
+
ctx.beginPath();
|
|
242
|
+
ctx.moveTo(w * MOBILE.speaker.x, h * MOBILE.speaker.y0);
|
|
243
|
+
ctx.lineTo(w * MOBILE.speaker.x, h * MOBILE.speaker.y1);
|
|
244
|
+
ctx.stroke();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (kind === 'browser') {
|
|
248
|
+
fillStrokeRect(ctx, x0, y0, bw, bh, NODE_RADIUS.browser);
|
|
249
|
+
ctx.fillStyle = fill;
|
|
250
|
+
fillStrokeRect(ctx, w * BROWSER.screen.x, h * BROWSER.screen.y, w * BROWSER.screen.w, h * BROWSER.screen.h, w * BROWSER.screenRadius);
|
|
251
|
+
// The three window dots, and the address bar beside them.
|
|
252
|
+
const dotR = Math.max(0, w * BROWSER.dot.r);
|
|
253
|
+
const dotY = h * BROWSER.dot.y;
|
|
254
|
+
if (dotR > 0) {
|
|
255
|
+
for (const dotX of BROWSER.dotsX) {
|
|
256
|
+
ctx.beginPath();
|
|
257
|
+
ctx.arc(w * dotX, dotY, dotR, 0, TAU);
|
|
258
|
+
ctx.fill();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
fillStrokeRect(ctx, w * BROWSER.bar.x, h * BROWSER.bar.y, w * BROWSER.bar.w, h * BROWSER.bar.h, w * BROWSER.bar.r);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Every glyph kind is drawn above, and this is what keeps that true: `kind` is
|
|
266
|
+
* narrowed to `never` here only if the branches are exhaustive over
|
|
267
|
+
* {@link C4GlyphKind}, so a kind added to the model's union without a picture
|
|
268
|
+
* of its own stops the build.
|
|
269
|
+
*
|
|
270
|
+
* Which is the whole point of closing the last branch rather than letting it
|
|
271
|
+
* fall through. A renderer that silently paints a browser band on somebody's
|
|
272
|
+
* new artefact is worse than one that paints nothing: the first is a wrong
|
|
273
|
+
* picture nobody is told about, the second is a missing one everybody sees.
|
|
274
|
+
*/
|
|
275
|
+
const unhandled = kind;
|
|
276
|
+
void unhandled;
|
|
277
|
+
}
|
|
278
|
+
export const c4Node = (model, ctx, matrix, renderer, rc, bound) => {
|
|
279
|
+
const [, , w, h] = model.deserializedXYWH;
|
|
280
|
+
const cx = w / 2;
|
|
281
|
+
const cy = h / 2;
|
|
282
|
+
// Capture the element-local transform BEFORE the shape renderer mutates the
|
|
283
|
+
// matrix, so the glyph can be drawn in the same space afterwards.
|
|
284
|
+
const glyphMatrix = DOMMatrix.fromMatrix(matrix)
|
|
285
|
+
.translateSelf(cx, cy)
|
|
286
|
+
.rotateSelf(model.rotate)
|
|
287
|
+
.translateSelf(-cx, -cy);
|
|
288
|
+
// Native shape: fill, stroke, theme and the TITLE, all handled natively.
|
|
289
|
+
shapeRenderer(model, ctx, matrix, renderer, rc, bound);
|
|
290
|
+
const stroke = renderer.getColorValue(model.strokeColor, DefaultTheme.shapeStrokeColor, true);
|
|
291
|
+
const fill = renderer.getColorValue(model.fillColor, DefaultTheme.shapeFillColor, true);
|
|
292
|
+
const strokeWidth = model.strokeWidth || 1;
|
|
293
|
+
ctx.setTransform(glyphMatrix);
|
|
294
|
+
ctx.strokeStyle = stroke;
|
|
295
|
+
ctx.fillStyle = fill;
|
|
296
|
+
ctx.lineWidth = strokeWidth;
|
|
297
|
+
ctx.lineCap = 'round';
|
|
298
|
+
ctx.lineJoin = 'round';
|
|
299
|
+
const kind = model.kind;
|
|
300
|
+
if (!isBare(kind)) {
|
|
301
|
+
paintGlyph(kind, ctx, fill, stroke, w, h, strokeWidth / 2);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
export const C4NodeRendererExtension = ElementRendererExtension('c4Node', c4Node);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type C4NodeElementModel } from '@formicoidea/labre-core/model';
|
|
2
|
+
import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
|
|
3
|
+
/**
|
|
4
|
+
* View for a C4 node. Registering it ensures `gfx.view.get(model)` returns a
|
|
5
|
+
* view (required so move / select / connector interactions work).
|
|
6
|
+
*
|
|
7
|
+
* ## Double-clicking the body edits the NAME — which is not on this element
|
|
8
|
+
*
|
|
9
|
+
* Since the PO's follow-up to the recette of 28/08/2026 a C4 element's name is a
|
|
10
|
+
* canvas `text` child stamped `c4:title`, grouped with the shape, and the shape
|
|
11
|
+
* itself carries no text at all. Which leaves a gesture with nowhere obvious to
|
|
12
|
+
* go: the body is the biggest target in the component, double-clicking a shape
|
|
13
|
+
* is how everybody in this editor starts typing, and the shape's own inline
|
|
14
|
+
* editor would write into the one field that must stay empty. A "shadow" name,
|
|
15
|
+
* invisible under the real one, disagreeing with it, exported by neither.
|
|
16
|
+
*
|
|
17
|
+
* So the gesture is ROUTED: a double-click on the body opens the title child's
|
|
18
|
+
* own editor, `mountTextElementEditor` — the very same call `TextElementView`
|
|
19
|
+
* makes when you double-click those words directly. The author gets one editor
|
|
20
|
+
* for one name whichever half of the component they aimed at, and the shape's
|
|
21
|
+
* text path is never entered.
|
|
22
|
+
*
|
|
23
|
+
* The seam is cheap because the platform already had both halves: the title is
|
|
24
|
+
* found through the group (`c4ComponentSiblings`, the same pure resolution the
|
|
25
|
+
* exporter uses) and opened through the text package's own public mount. No new
|
|
26
|
+
* editor, no new selection handling, no reimplementation of anything.
|
|
27
|
+
*
|
|
28
|
+
* ## …except on an element that has no title child
|
|
29
|
+
*
|
|
30
|
+
* One drawn before this change keeps its name in the shape's inner text, which
|
|
31
|
+
* is where that iteration put it, and `mountShapeTextEditor` is then exactly
|
|
32
|
+
* right — it is still that element's name. The same test the exporter makes
|
|
33
|
+
* (`c4StatedName`), so the words a double-click edits are the words the file
|
|
34
|
+
* comes out with. A node whose group was released and whose title was deleted
|
|
35
|
+
* lands here too, and gets the old behaviour rather than nothing at all.
|
|
36
|
+
*
|
|
37
|
+
* Hit testing is `C4NodeElementModel.includesPoint`'s doing: an empty unfilled
|
|
38
|
+
* shape would normally be a target only near its border, and the override forces
|
|
39
|
+
* the interior test, so the whole body answers this gesture on all nine kinds.
|
|
40
|
+
*/
|
|
41
|
+
export declare class C4NodeView extends GfxElementModelView<C4NodeElementModel> {
|
|
42
|
+
#private;
|
|
43
|
+
static type: string;
|
|
44
|
+
onCreated(): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mountTextElementEditor } from '@formicoidea/labre-core/gfx/text';
|
|
2
|
+
import { GroupElementModel, ShapeElementModel, TextElementModel, } from '@formicoidea/labre-core/model';
|
|
3
|
+
import { mountShapeTextEditor } from '@formicoidea/labre-core/gfx/shape';
|
|
4
|
+
import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
|
|
5
|
+
import { c4ComponentSiblings } from '../component.js';
|
|
6
|
+
import { C4_ROLE } from '../roles.js';
|
|
7
|
+
/**
|
|
8
|
+
* View for a C4 node. Registering it ensures `gfx.view.get(model)` returns a
|
|
9
|
+
* view (required so move / select / connector interactions work).
|
|
10
|
+
*
|
|
11
|
+
* ## Double-clicking the body edits the NAME — which is not on this element
|
|
12
|
+
*
|
|
13
|
+
* Since the PO's follow-up to the recette of 28/08/2026 a C4 element's name is a
|
|
14
|
+
* canvas `text` child stamped `c4:title`, grouped with the shape, and the shape
|
|
15
|
+
* itself carries no text at all. Which leaves a gesture with nowhere obvious to
|
|
16
|
+
* go: the body is the biggest target in the component, double-clicking a shape
|
|
17
|
+
* is how everybody in this editor starts typing, and the shape's own inline
|
|
18
|
+
* editor would write into the one field that must stay empty. A "shadow" name,
|
|
19
|
+
* invisible under the real one, disagreeing with it, exported by neither.
|
|
20
|
+
*
|
|
21
|
+
* So the gesture is ROUTED: a double-click on the body opens the title child's
|
|
22
|
+
* own editor, `mountTextElementEditor` — the very same call `TextElementView`
|
|
23
|
+
* makes when you double-click those words directly. The author gets one editor
|
|
24
|
+
* for one name whichever half of the component they aimed at, and the shape's
|
|
25
|
+
* text path is never entered.
|
|
26
|
+
*
|
|
27
|
+
* The seam is cheap because the platform already had both halves: the title is
|
|
28
|
+
* found through the group (`c4ComponentSiblings`, the same pure resolution the
|
|
29
|
+
* exporter uses) and opened through the text package's own public mount. No new
|
|
30
|
+
* editor, no new selection handling, no reimplementation of anything.
|
|
31
|
+
*
|
|
32
|
+
* ## …except on an element that has no title child
|
|
33
|
+
*
|
|
34
|
+
* One drawn before this change keeps its name in the shape's inner text, which
|
|
35
|
+
* is where that iteration put it, and `mountShapeTextEditor` is then exactly
|
|
36
|
+
* right — it is still that element's name. The same test the exporter makes
|
|
37
|
+
* (`c4StatedName`), so the words a double-click edits are the words the file
|
|
38
|
+
* comes out with. A node whose group was released and whose title was deleted
|
|
39
|
+
* lands here too, and gets the old behaviour rather than nothing at all.
|
|
40
|
+
*
|
|
41
|
+
* Hit testing is `C4NodeElementModel.includesPoint`'s doing: an empty unfilled
|
|
42
|
+
* shape would normally be a target only near its border, and the override forces
|
|
43
|
+
* the interior test, so the whole body answers this gesture on all nine kinds.
|
|
44
|
+
*/
|
|
45
|
+
export class C4NodeView extends GfxElementModelView {
|
|
46
|
+
static { this.type = 'c4Node'; }
|
|
47
|
+
onCreated() {
|
|
48
|
+
super.onCreated();
|
|
49
|
+
this.on('dblclick', () => {
|
|
50
|
+
const edgeless = this.std.view.getBlock(this.std.store.root.id);
|
|
51
|
+
if (!edgeless || this.model.isLocked())
|
|
52
|
+
return;
|
|
53
|
+
const title = this.#title();
|
|
54
|
+
if (title) {
|
|
55
|
+
if (!title.isLocked())
|
|
56
|
+
mountTextElementEditor(title, edgeless);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// No title child: the name really is the shape's own text.
|
|
60
|
+
if (this.model instanceof ShapeElementModel) {
|
|
61
|
+
mountShapeTextEditor(this.model, edgeless);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** The `c4:title` text grouped with this shape, if there is one. */
|
|
66
|
+
#title() {
|
|
67
|
+
const surface = this.gfx.surface;
|
|
68
|
+
if (!surface)
|
|
69
|
+
return null;
|
|
70
|
+
const groups = surface.elementModels.filter((element) => element instanceof GroupElementModel);
|
|
71
|
+
for (const siblingId of c4ComponentSiblings(this.model.id, groups)) {
|
|
72
|
+
const sibling = surface.getElementById(siblingId);
|
|
73
|
+
if (sibling instanceof TextElementModel &&
|
|
74
|
+
sibling.role === C4_ROLE.title) {
|
|
75
|
+
return sibling;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|