@itsthecsguy/linework 0.1.1 → 0.1.3
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/README.md +46 -3
- package/dist/drawio/theme.js +79 -52
- package/dist/drawio/write.js +152 -22
- package/dist/layout/layout.js +225 -17
- package/dist/layout/polish.js +281 -0
- package/dist/layout/sequence.js +7 -4
- package/dist/lint/check.js +6 -1
- package/dist/spec/model.js +3 -1
- package/package.json +1 -1
- package/skills/linework/SKILL.md +9 -2
- package/skills/linework/examples/auth-seq.yaml +35 -0
- package/skills/linework/examples/deploy-flow.yaml +32 -0
- package/skills/linework/examples/gcp-ingest.yaml +21 -0
- package/skills/linework/examples/job-state.yaml +25 -0
- package/skills/linework/examples/platform.yaml +45 -0
- package/skills/linework/examples/shop-er.yaml +26 -0
- package/skills/linework/references/spec.md +3 -3
- package/skills/linework/references/style-guide.md +9 -1
package/README.md
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
<h1>
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/logo-dark.svg">
|
|
4
|
+
<img alt="Linework" src="docs/brand/logo-light.svg" height="64">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
2
7
|
|
|
3
8
|
Software diagrams your AI agent writes and you can edit by hand, in Claude Code, Codex, Cursor, Antigravity, Copilot, Grok Build or any other agent.
|
|
4
9
|
|
|
@@ -97,6 +102,29 @@ Linework lays it out with ELK and draws it with draw.io's own renderer. It draws
|
|
|
97
102
|
|
|
98
103
|
To change a diagram, the agent runs `linework show` to get the spec back, hand edits included, edits it and rebuilds. The rebuild merges into your layout. Your boxes stay where you put them, new ones go next to their neighbours, and only the edges that need it get rerouted (with draw.io's libavoid router).
|
|
99
104
|
|
|
105
|
+
## Examples
|
|
106
|
+
|
|
107
|
+
These are first builds of the specs in [`examples/`](examples), with no hand edits. Each one is a `.drawio.svg`, so the image you see here is also the file you'd open in draw.io. Click one to see its spec.
|
|
108
|
+
|
|
109
|
+
<table>
|
|
110
|
+
<tr>
|
|
111
|
+
<td width="50%" valign="top">Architecture, with AWS icons<br><br><a href="examples/serverless-aws.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/serverless-aws-dark.drawio.svg"><img alt="Architecture diagram: an uploader posts to an API Gateway, a Lambda presigns an S3 upload, and S3 events queue resize jobs for a second Lambda." src="docs/gallery/serverless-aws-light.drawio.svg" width="100%"></picture></a></td>
|
|
112
|
+
<td width="50%" valign="top">Flow<br><br><a href="examples/refund-flow.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/refund-flow-dark.drawio.svg"><img alt="Flowchart of a refund: look up the order, check the 30-day window and the amount, then auto-approve, queue for review or deny, and email the customer." src="docs/gallery/refund-flow-light.drawio.svg" width="100%"></picture></a></td>
|
|
113
|
+
</tr>
|
|
114
|
+
</table>
|
|
115
|
+
|
|
116
|
+
State
|
|
117
|
+
|
|
118
|
+
<a href="examples/job-state.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/job-state-dark.drawio.svg"><img alt="State diagram of a background job: queued, running, paused, waiting to retry, succeeded, failed and cancelled, with the transitions between them." src="docs/gallery/job-state-light.drawio.svg"></picture></a>
|
|
119
|
+
|
|
120
|
+
Sequence
|
|
121
|
+
|
|
122
|
+
<a href="examples/pay-sequence.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/pay-sequence-dark.drawio.svg"><img alt="Sequence diagram of a card payment: the web app posts to the checkout API, which writes the order and creates a Stripe PaymentIntent, with an alt frame for success and decline." src="docs/gallery/pay-sequence-light.drawio.svg"></picture></a>
|
|
123
|
+
|
|
124
|
+
ER
|
|
125
|
+
|
|
126
|
+
<a href="examples/shop-er.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/shop-er-dark.drawio.svg"><img alt="Entity relationship diagram of a shop: customers, addresses, orders, order items, products, inventory and payments, with keys and crow's-foot cardinality." src="docs/gallery/shop-er-light.drawio.svg"></picture></a>
|
|
127
|
+
|
|
100
128
|
## Styles
|
|
101
129
|
|
|
102
130
|
Set `theme:` in the spec.
|
|
@@ -107,8 +135,23 @@ Set `theme:` in the spec.
|
|
|
107
135
|
| `soft` | slides. Borderless pastel cards, tinted by kind. |
|
|
108
136
|
| `dark` | dark-mode pages |
|
|
109
137
|
| `sketch` | early drafts. Hand-drawn lines and a handwriting font. |
|
|
110
|
-
| `blueprint` | technical deep dives.
|
|
111
|
-
| `c4` | C4-model diagrams |
|
|
138
|
+
| `blueprint` | technical deep dives. Blue ink on pale drafting paper, monospace. |
|
|
139
|
+
| `c4` | C4-model diagrams, in quiet blues and grays |
|
|
140
|
+
|
|
141
|
+
The same spec in each style:
|
|
142
|
+
|
|
143
|
+
<table>
|
|
144
|
+
<tr>
|
|
145
|
+
<td width="33%" align="center" valign="top"><code>clean</code><br><br><img alt="A web app, checkout API and orders database in the clean style" src="docs/gallery/style-clean.drawio.svg"></td>
|
|
146
|
+
<td width="33%" align="center" valign="top"><code>soft</code><br><br><img alt="A web app, checkout API and orders database in the soft style" src="docs/gallery/style-soft.drawio.svg"></td>
|
|
147
|
+
<td width="33%" align="center" valign="top"><code>dark</code><br><br><img alt="A web app, checkout API and orders database in the dark style" src="docs/gallery/style-dark.drawio.svg"></td>
|
|
148
|
+
</tr>
|
|
149
|
+
<tr>
|
|
150
|
+
<td width="33%" align="center" valign="top"><code>sketch</code><br><br><img alt="A web app, checkout API and orders database in the sketch style" src="docs/gallery/style-sketch.drawio.svg"></td>
|
|
151
|
+
<td width="33%" align="center" valign="top"><code>blueprint</code><br><br><img alt="A web app, checkout API and orders database in the blueprint style" src="docs/gallery/style-blueprint.drawio.svg"></td>
|
|
152
|
+
<td width="33%" align="center" valign="top"><code>c4</code><br><br><img alt="A web app, checkout API and orders database in the c4 style" src="docs/gallery/style-c4.drawio.svg"></td>
|
|
153
|
+
</tr>
|
|
154
|
+
</table>
|
|
112
155
|
|
|
113
156
|
Color is for meaning. Nodes stay neutral by default, and errors and successes are red and green in every style. Mark the part the doc is about with `highlight: true` on a node or `==>` on an edge, and it's drawn in the style's accent color. Use `color: red` (or blue, violet, pink, orange, amber, green, teal, gray) for categories.
|
|
114
157
|
|
package/dist/drawio/theme.js
CHANGED
|
@@ -31,7 +31,8 @@ const LIGHT_SW = {
|
|
|
31
31
|
pink: { tint: '#FDF2F8', line: '#F9A8D4', ink: '#9D174D', solid: '#DB2777', edge: '#DB2777' },
|
|
32
32
|
red: { tint: '#FEF2F2', line: '#FCA5A5', ink: '#991B1B', solid: '#DC2626', edge: '#DC2626' },
|
|
33
33
|
orange: { tint: '#FFF7ED', line: '#FDBA74', ink: '#9A3412', solid: '#C2410C', edge: '#EA580C' },
|
|
34
|
-
|
|
34
|
+
// Ochre, not orange: the two read as one color at line weight otherwise
|
|
35
|
+
amber: { tint: '#FFFBEB', line: '#FCD34D', ink: '#92400E', solid: '#B45309', edge: '#A16207' },
|
|
35
36
|
green: { tint: '#F0FDF4', line: '#86EFAC', ink: '#166534', solid: '#15803D', edge: '#16A34A' },
|
|
36
37
|
teal: { tint: '#F0FDFA', line: '#5EEAD4', ink: '#115E59', solid: '#0F766E', edge: '#0D9488' },
|
|
37
38
|
gray: { tint: '#F4F4F5', line: '#D4D4D8', ink: '#3F3F46', solid: '#52525B', edge: '#71717A' },
|
|
@@ -59,26 +60,26 @@ const DARK_SW = {
|
|
|
59
60
|
teal: { tint: '#042F2E', line: '#115E59', ink: '#99F6E4', solid: '#0F766E', edge: '#2DD4BF' },
|
|
60
61
|
gray: { tint: '#27272A', line: '#52525B', ink: '#E4E4E7', solid: '#52525B', edge: '#A1A1AA' },
|
|
61
62
|
};
|
|
62
|
-
// Blueprint:
|
|
63
|
+
// Blueprint: a drafting sheet. Blue ink on pale paper; color only changes the line.
|
|
63
64
|
const BP_LINE = {
|
|
64
|
-
blue: '#
|
|
65
|
-
violet: '#
|
|
66
|
-
pink: '#
|
|
67
|
-
red: '#
|
|
68
|
-
orange: '#
|
|
69
|
-
amber: '#
|
|
70
|
-
green: '#
|
|
71
|
-
teal: '#
|
|
72
|
-
gray: '#
|
|
65
|
+
blue: '#1D4ED8',
|
|
66
|
+
violet: '#6D28D9',
|
|
67
|
+
pink: '#BE185D',
|
|
68
|
+
red: '#B91C1C',
|
|
69
|
+
orange: '#C2410C',
|
|
70
|
+
amber: '#A16207',
|
|
71
|
+
green: '#15803D',
|
|
72
|
+
teal: '#0F766E',
|
|
73
|
+
gray: '#64748B',
|
|
73
74
|
};
|
|
74
75
|
function lightPaint(sw, neutral, text, muted, accent) {
|
|
75
76
|
return (color, highlight) => {
|
|
76
77
|
if (highlight) {
|
|
77
78
|
const s = sw[color === 'neutral' || color === 'ink' ? accent : color];
|
|
78
|
-
return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.
|
|
79
|
+
return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.9)', strokeWidth: 1 };
|
|
79
80
|
}
|
|
80
81
|
if (color === 'ink')
|
|
81
|
-
return { fill: text, stroke: text, text: '#FFFFFF', sub:
|
|
82
|
+
return { fill: text, stroke: text, text: '#FFFFFF', sub: 'rgba(255,255,255,0.72)', strokeWidth: 1 };
|
|
82
83
|
if (color === 'neutral')
|
|
83
84
|
return { ...neutral, text, sub: muted, strokeWidth: 1 };
|
|
84
85
|
const s = sw[color];
|
|
@@ -91,16 +92,16 @@ const CLEAN = {
|
|
|
91
92
|
canvas: '#FFFFFF',
|
|
92
93
|
text: '#17171C',
|
|
93
94
|
muted: '#6B6B76',
|
|
94
|
-
edge: '#
|
|
95
|
+
edge: '#868691',
|
|
95
96
|
edgeText: '#52525C',
|
|
96
97
|
edgeWidth: 1.25,
|
|
97
98
|
accent: 'blue',
|
|
98
99
|
kindColors: false,
|
|
99
100
|
radius: 10,
|
|
100
|
-
group: { fill: '#F5F5F7', nested: '#EBEBEF', stroke: 'none', dashed: false, text: '#
|
|
101
|
+
group: { fill: '#F5F5F7', nested: '#EBEBEF', stroke: 'none', dashed: false, text: '#61616C' },
|
|
101
102
|
note: { fill: '#FFF4CC', stroke: 'none', text: '#5C4300' },
|
|
102
103
|
well: '#F3F3F5',
|
|
103
|
-
paint: lightPaint(LIGHT_SW, { fill: '#FFFFFF', stroke: '#
|
|
104
|
+
paint: lightPaint(LIGHT_SW, { fill: '#FFFFFF', stroke: '#C4C4CC' }, '#17171C', '#6B6B76', 'blue'),
|
|
104
105
|
edgePaint: (color, hl) => {
|
|
105
106
|
const s = LIGHT_SW[color ?? 'blue'];
|
|
106
107
|
if (hl)
|
|
@@ -114,12 +115,12 @@ const SOFT = {
|
|
|
114
115
|
name: 'soft',
|
|
115
116
|
kindColors: true,
|
|
116
117
|
radius: 12,
|
|
117
|
-
group: { fill: '#F7F7F9', nested: '#EFEFF3', stroke: 'none', dashed: false, text: '#
|
|
118
|
+
group: { fill: '#F7F7F9', nested: '#EFEFF3', stroke: 'none', dashed: false, text: '#61616C' },
|
|
118
119
|
paint: (color, hl) => {
|
|
119
120
|
if (hl || color === 'ink')
|
|
120
121
|
return CLEAN.paint(color, hl);
|
|
121
122
|
const c = color === 'neutral' ? 'gray' : color;
|
|
122
|
-
return { fill: SOFT_TINT[c], stroke: 'none', text: c === 'gray' ? '#17171C' : LIGHT_SW[c].ink, sub: c === 'gray' ? '#6B6B76' : LIGHT_SW[c].ink + '
|
|
123
|
+
return { fill: SOFT_TINT[c], stroke: 'none', text: c === 'gray' ? '#17171C' : LIGHT_SW[c].ink, sub: c === 'gray' ? '#6B6B76' : LIGHT_SW[c].ink + 'E0', strokeWidth: 1 };
|
|
123
124
|
},
|
|
124
125
|
};
|
|
125
126
|
const DARK = {
|
|
@@ -140,7 +141,7 @@ const DARK = {
|
|
|
140
141
|
paint: (color, hl) => {
|
|
141
142
|
if (hl) {
|
|
142
143
|
const s = DARK_SW[color === 'neutral' || color === 'ink' ? 'blue' : color];
|
|
143
|
-
return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.
|
|
144
|
+
return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.9)', strokeWidth: 1 };
|
|
144
145
|
}
|
|
145
146
|
if (color === 'ink')
|
|
146
147
|
return { fill: '#E6EDF3', stroke: '#E6EDF3', text: '#0D1117', sub: '#59636E', strokeWidth: 1 };
|
|
@@ -167,7 +168,7 @@ const SKETCH = {
|
|
|
167
168
|
edge: '#3F3F46',
|
|
168
169
|
edgeText: '#3F3F46',
|
|
169
170
|
edgeWidth: 1.5,
|
|
170
|
-
accent: '
|
|
171
|
+
accent: 'blue',
|
|
171
172
|
kindColors: true,
|
|
172
173
|
radius: 8,
|
|
173
174
|
group: { fill: 'none', stroke: '#71717A', dashed: true, text: '#3F3F46' },
|
|
@@ -179,8 +180,9 @@ const SKETCH = {
|
|
|
179
180
|
if (color === 'ink')
|
|
180
181
|
return { fill: SKETCH_INK, stroke: SKETCH_INK, text: '#FFFFFF', sub: '#5C5C66', strokeWidth: 1.5, extra: solid };
|
|
181
182
|
if (hl) {
|
|
182
|
-
const
|
|
183
|
-
|
|
183
|
+
const c = color === 'neutral' ? 'blue' : color;
|
|
184
|
+
// A marker pass: a stronger tint than any kind's, under the accent's line
|
|
185
|
+
return { fill: c === 'blue' ? '#B9C6FF' : LIGHT_SW[c].line, stroke: LIGHT_SW[c].solid, text: SKETCH_INK, sub: LIGHT_SW[c].ink, strokeWidth: 2.5, extra: solid };
|
|
184
186
|
}
|
|
185
187
|
if (color === 'neutral')
|
|
186
188
|
return { fill: '#FFFFFF', stroke: SKETCH_INK, text: SKETCH_INK, sub: '#5C5C66', strokeWidth: 1.5, extra: solid };
|
|
@@ -189,37 +191,38 @@ const SKETCH = {
|
|
|
189
191
|
edgePaint: (color, hl) => {
|
|
190
192
|
if (!color && !hl)
|
|
191
193
|
return { stroke: SKETCH.edge, text: SKETCH.edgeText, width: 1.5 };
|
|
192
|
-
const s = LIGHT_SW[color ?? '
|
|
194
|
+
const s = LIGHT_SW[color ?? 'blue'];
|
|
193
195
|
return { stroke: s.solid, text: s.ink, width: hl ? 2.5 : 1.5 };
|
|
194
196
|
},
|
|
195
197
|
extra: 'sketch=1;curveFitting=1;jiggle=2;',
|
|
196
198
|
};
|
|
197
|
-
const
|
|
198
|
-
const BP_INK = '#
|
|
199
|
-
const
|
|
199
|
+
const BP_PAPER = '#F6F8FC';
|
|
200
|
+
const BP_INK = '#1B2F55';
|
|
201
|
+
const BP_LINE_INK = '#3F5F96';
|
|
202
|
+
const BP_HI = '#1D4ED8';
|
|
200
203
|
const BLUEPRINT = {
|
|
201
204
|
name: 'blueprint',
|
|
202
205
|
type: MONO,
|
|
203
|
-
canvas:
|
|
206
|
+
canvas: BP_PAPER,
|
|
204
207
|
text: BP_INK,
|
|
205
|
-
muted: '#
|
|
206
|
-
edge: '#
|
|
207
|
-
edgeText: '#
|
|
208
|
+
muted: '#5A6F92',
|
|
209
|
+
edge: '#6F86AD',
|
|
210
|
+
edgeText: '#4A5F84',
|
|
208
211
|
edgeWidth: 1.25,
|
|
209
|
-
accent: '
|
|
212
|
+
accent: 'blue',
|
|
210
213
|
kindColors: false,
|
|
211
214
|
radius: 4,
|
|
212
|
-
group: { fill: 'none', stroke: '#
|
|
213
|
-
note: { fill: '#
|
|
214
|
-
well: '#
|
|
215
|
+
group: { fill: 'none', stroke: '#7489AF', dashed: true, text: '#5A6F92' },
|
|
216
|
+
note: { fill: '#EAF0FA', stroke: '#9DB0D0', text: BP_INK },
|
|
217
|
+
well: '#EAF0FA',
|
|
215
218
|
paint: (color, hl) => {
|
|
216
219
|
if (hl)
|
|
217
|
-
return { fill: BP_HI, stroke: BP_HI, text:
|
|
220
|
+
return { fill: BP_HI, stroke: BP_HI, text: '#FFFFFF', sub: 'rgba(255,255,255,0.8)', strokeWidth: 1 };
|
|
218
221
|
if (color === 'ink')
|
|
219
|
-
return { fill: BP_INK, stroke: BP_INK, text:
|
|
222
|
+
return { fill: BP_INK, stroke: BP_INK, text: '#FFFFFF', sub: '#C9D5EA', strokeWidth: 1 };
|
|
220
223
|
if (color === 'neutral')
|
|
221
|
-
return { fill:
|
|
222
|
-
return { fill:
|
|
224
|
+
return { fill: '#FFFFFF', stroke: BP_LINE_INK, text: BP_INK, sub: '#5A6F92', strokeWidth: 1.25 };
|
|
225
|
+
return { fill: '#FFFFFF', stroke: BP_LINE[color], text: BP_INK, sub: BP_LINE[color], strokeWidth: 1.25 };
|
|
223
226
|
},
|
|
224
227
|
edgePaint: (color, hl) => {
|
|
225
228
|
if (hl)
|
|
@@ -228,12 +231,14 @@ const BLUEPRINT = {
|
|
|
228
231
|
},
|
|
229
232
|
extra: '',
|
|
230
233
|
};
|
|
231
|
-
// C4
|
|
234
|
+
// C4's convention (a dark person, blue containers, gray external systems) in
|
|
235
|
+
// quieter tones than C4-PlantUML's, so a highlight still stands out, with white text at 4.5:1 or better on each.
|
|
236
|
+
// Other diagram types use `clean`.
|
|
232
237
|
const C4_KIND = {
|
|
233
|
-
person: { fill: '#
|
|
234
|
-
external: { fill: '#
|
|
238
|
+
person: { fill: '#1E3466', stroke: '#1E3466', text: '#FFFFFF', type: 'Person' },
|
|
239
|
+
external: { fill: '#626A7A', stroke: '#626A7A', text: '#FFFFFF', type: 'External system' },
|
|
235
240
|
};
|
|
236
|
-
const C4_CONTAINER = { fill: '#
|
|
241
|
+
const C4_CONTAINER = { fill: '#456A9E', stroke: '#456A9E', text: '#FFFFFF', type: 'Container' };
|
|
237
242
|
const C4 = { ...CLEAN, name: 'c4', group: { fill: 'none', stroke: '#444444', dashed: true, text: '#444444' }, edge: '#707070', edgeText: '#444444' };
|
|
238
243
|
const THEMES_BY_NAME = { clean: CLEAN, soft: SOFT, dark: DARK, sketch: SKETCH, blueprint: BLUEPRINT, c4: C4 };
|
|
239
244
|
export function palette(theme = 'clean') {
|
|
@@ -245,7 +250,7 @@ export function paintFor(type, kind, theme, look = {}) {
|
|
|
245
250
|
const t = palette(theme);
|
|
246
251
|
if (theme === 'c4' && type === 'architecture' && !look.highlight && !look.color) {
|
|
247
252
|
const c = C4_KIND[kind] ?? C4_CONTAINER;
|
|
248
|
-
return { fill: c.fill, stroke: c.stroke, text: c.text, sub: 'rgba(255,255,255,0.
|
|
253
|
+
return { fill: c.fill, stroke: c.stroke, text: c.text, sub: 'rgba(255,255,255,0.92)', strokeWidth: 1 };
|
|
249
254
|
}
|
|
250
255
|
const k = kindDef(type, kind);
|
|
251
256
|
// A highlight is the style's accent unless the spec names a color.
|
|
@@ -269,7 +274,9 @@ export function nodeStyle(type, kind, theme = 'clean', look = {}) {
|
|
|
269
274
|
const r = t.radius;
|
|
270
275
|
switch (k.shape) {
|
|
271
276
|
case 'cylinder':
|
|
272
|
-
|
|
277
|
+
// With an outline the lid is drawn, and the text centres on the face below
|
|
278
|
+
// it. Without one (soft) the lid is invisible: centre on the whole shape.
|
|
279
|
+
return `shape=cylinder3;boundedLbl=${stroke === 'none' ? 0 : 1};backgroundOutline=1;size=${CYL_SIZE};${base}`;
|
|
273
280
|
case 'pill':
|
|
274
281
|
return `rounded=1;arcSize=50;${base}`;
|
|
275
282
|
case 'diamond':
|
|
@@ -293,6 +300,17 @@ export function nodeStyle(type, kind, theme = 'clean', look = {}) {
|
|
|
293
300
|
return `rounded=1;absoluteArcSize=1;arcSize=${r * 2};${base}`;
|
|
294
301
|
}
|
|
295
302
|
}
|
|
303
|
+
/** Depth of a cylinder's lid and base curve. */
|
|
304
|
+
export const CYL_SIZE = 7;
|
|
305
|
+
/**
|
|
306
|
+
* Vertical centre of a node's text, from the top of its box. draw.io bounds a
|
|
307
|
+
* cylinder's label between its lid (2 x size) and 0.3 x size above the base.
|
|
308
|
+
*/
|
|
309
|
+
export function textMiddle(style, h) {
|
|
310
|
+
if (!/shape=cylinder3/.test(style) || !/boundedLbl=1/.test(style))
|
|
311
|
+
return h / 2;
|
|
312
|
+
return (2 * CYL_SIZE + h - 0.3 * CYL_SIZE) / 2;
|
|
313
|
+
}
|
|
296
314
|
/** Horizontal text padding inside a node, each side. */
|
|
297
315
|
export const PAD_X = 16;
|
|
298
316
|
/** Vertical text padding inside a node, each side. */
|
|
@@ -304,6 +322,8 @@ export function fieldStyle(theme = 'clean') {
|
|
|
304
322
|
return ('text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=0;spacingRight=0;overflow=fill;' +
|
|
305
323
|
`rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;${fontPart(t, t.type.size.field, t.text)}`);
|
|
306
324
|
}
|
|
325
|
+
/** Left inset of a group's title. */
|
|
326
|
+
export const GROUP_INSET = 14;
|
|
307
327
|
export function groupStyle(theme = 'clean', look = {}, depth = 0) {
|
|
308
328
|
const t = palette(theme);
|
|
309
329
|
let { fill, stroke, text } = t.group;
|
|
@@ -319,7 +339,7 @@ export function groupStyle(theme = 'clean', look = {}, depth = 0) {
|
|
|
319
339
|
return (`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2 + 4};html=1;whiteSpace=wrap;container=1;collapsible=0;` +
|
|
320
340
|
`fillColor=${fill};strokeColor=${stroke};strokeWidth=${look.highlight ? 1.5 : 1};` +
|
|
321
341
|
(t.group.dashed ? dashes(t) : '') +
|
|
322
|
-
|
|
342
|
+
`verticalAlign=top;align=left;spacingLeft=${GROUP_INSET};spacingTop=9;` +
|
|
323
343
|
`${fontPart(t, t.type.size.group, text)}fontStyle=1;${t.extra}`);
|
|
324
344
|
}
|
|
325
345
|
/** Group title HTML: sentence case, except blueprint's drafting capitals. */
|
|
@@ -353,8 +373,10 @@ export function lifelineStyle(kind, theme = 'clean', headH = 48, look = {}) {
|
|
|
353
373
|
const t = palette(theme);
|
|
354
374
|
const c = paintFor('sequence', kind, theme, look);
|
|
355
375
|
const k = kindDef('sequence', kind);
|
|
356
|
-
|
|
357
|
-
//
|
|
376
|
+
// The dashed stem takes the stroke color, so a highlighted head keeps the plain
|
|
377
|
+
// participant line: the accent stays on the head and the highlighted messages.
|
|
378
|
+
const quiet = t.paint('neutral', false).stroke;
|
|
379
|
+
const stroke = look.highlight ? (quiet === 'none' ? t.edge : quiet) : c.stroke === 'none' ? (k.dashed ? '#A1A1AA' : c.fill) : c.stroke;
|
|
358
380
|
return (`shape=umlLifeline;perimeter=lifelinePerimeter;container=1;collapsible=0;recursiveResize=0;outlineConnect=0;size=${headH};` +
|
|
359
381
|
`html=1;whiteSpace=wrap;rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};fillColor=${c.fill};strokeColor=${stroke};strokeWidth=${Math.min(1.5, c.strokeWidth)};` +
|
|
360
382
|
`spacingLeft=${PAD_X - 4};spacingRight=${PAD_X - 4};` +
|
|
@@ -379,12 +401,17 @@ export function messageStyle(style, theme = 'clean', self = false, look = {}) {
|
|
|
379
401
|
(self ? 'edgeStyle=orthogonalEdgeStyle;rounded=1;align=left;spacingLeft=6;verticalAlign=middle;' : '') +
|
|
380
402
|
`${fontPart(t, t.type.size.edge, p.text)}${look.highlight ? 'fontStyle=1;' : ''}labelBackgroundColor=${t.canvas};labelPadding=1 5;${t.extra}`);
|
|
381
403
|
}
|
|
404
|
+
/** Padding of a sequence frame's operator tab, and its inset from the frame's corner. */
|
|
405
|
+
export const FRAME_TAB_X = 9, FRAME_TAB_Y = 3, FRAME_TAB_INSET = 4;
|
|
382
406
|
export function frameStyle(theme = 'clean') {
|
|
383
407
|
const t = palette(theme);
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
408
|
+
// The operator (alt, loop, opt) sits in a small tab in the frame's corner: UML's
|
|
409
|
+
// pentagon, drawn as a chip, inset the same from both sides so its square corner
|
|
410
|
+
// stays inside the frame's rounded one. draw.io draws an HTML label's background
|
|
411
|
+
// 1px left of and 3.5px below where its spacing puts it; the +1 and -3.5 cancel that.
|
|
412
|
+
return (`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};whiteSpace=wrap;html=1;container=0;verticalAlign=top;align=left;spacing=0;spacingLeft=${FRAME_TAB_X + FRAME_TAB_INSET + 1};spacingTop=${FRAME_TAB_Y + FRAME_TAB_INSET - 3.5};` +
|
|
413
|
+
`labelBackgroundColor=${t.well};labelPadding=${FRAME_TAB_Y} ${FRAME_TAB_X};` +
|
|
414
|
+
`fillColor=none;strokeColor=${t.edge};strokeWidth=1;${fontPart(t, t.type.size.group, t.text)}fontStyle=1;${t.extra}`);
|
|
388
415
|
}
|
|
389
416
|
export function guardStyle(theme = 'clean') {
|
|
390
417
|
const t = palette(theme);
|
|
@@ -392,7 +419,7 @@ export function guardStyle(theme = 'clean') {
|
|
|
392
419
|
}
|
|
393
420
|
export function separatorStyle(theme = 'clean') {
|
|
394
421
|
const t = palette(theme);
|
|
395
|
-
return `endArrow=none;dashed=1;dashPattern=4 4;html=1;strokeColor=${t.
|
|
422
|
+
return `endArrow=none;dashed=1;dashPattern=4 4;html=1;strokeColor=${t.edge};`;
|
|
396
423
|
}
|
|
397
424
|
export function noteStyle(theme = 'clean') {
|
|
398
425
|
const t = palette(theme);
|
package/dist/drawio/write.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { kindDef } from '../spec/model.js';
|
|
2
2
|
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
3
3
|
import { ICON_PAD, ICON_SIZE, resolveIcon } from './icons.js';
|
|
4
|
-
import { ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette } from './theme.js';
|
|
4
|
+
import { CYL_SIZE, ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette, textMiddle, typeface } from './theme.js';
|
|
5
5
|
export { ER_HEAD, ER_ROW };
|
|
6
6
|
export const FORMAT_VERSION = '1';
|
|
7
7
|
export const xmlEsc = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, ' ');
|
|
@@ -72,7 +72,8 @@ export function nodeChildren(d, n, box) {
|
|
|
72
72
|
parent: n.id,
|
|
73
73
|
attrs: { label: '', aiviz_type: 'icon' },
|
|
74
74
|
style: r.style,
|
|
75
|
-
|
|
75
|
+
// Level with the text, which on a cylinder sits below the lid.
|
|
76
|
+
box: { x: (ICON_PAD - ICON_SIZE) / 2 + 2, y: Math.round(textMiddle(styleFor(d, n), box.h) - ICON_SIZE / 2), w: ICON_SIZE, h: ICON_SIZE },
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
return out;
|
|
@@ -115,12 +116,38 @@ export const groupStyleFor = (d, g) => groupStyle(d.theme, g, groupDepth(d, g));
|
|
|
115
116
|
export function groupAttrs(g, theme = 'clean') {
|
|
116
117
|
return { label: groupLabel(g.label, theme), aiviz_type: 'group', aiviz_label: g.label, ...lookAttrs(g) };
|
|
117
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Layout sizes a decision's diamond from its text wrapped narrow (see nodeSizes),
|
|
121
|
+
* but draw.io wraps at the diamond's full width, so a long question runs out over
|
|
122
|
+
* the slanted sides. Inset the label to the width layout measured.
|
|
123
|
+
*/
|
|
124
|
+
function diamondInset(d, n, box) {
|
|
125
|
+
if (kindDef(d.type, n.kind).shape !== 'diamond')
|
|
126
|
+
return '';
|
|
127
|
+
const text = (box.w - 40) / 1.5;
|
|
128
|
+
const side = Math.max(0, Math.floor((box.w - text - 10) / 2));
|
|
129
|
+
return `spacingLeft=${side};spacingRight=${side};`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Where a vertical edge at `x` meets a cylinder's curved top or base, as a
|
|
133
|
+
* fraction of its height. Off centre the curve sits inside the bounding box, and
|
|
134
|
+
* an arrow stopped at the box would float short of it.
|
|
135
|
+
*/
|
|
136
|
+
function rimY(p, b) {
|
|
137
|
+
const top = Math.abs(p.y - b.y) < 0.5, base = Math.abs(p.y - (b.y + b.h)) < 0.5;
|
|
138
|
+
if (!top && !base)
|
|
139
|
+
return undefined;
|
|
140
|
+
const u = Math.min(1, Math.abs(p.x - (b.x + b.w / 2)) / (b.w / 2));
|
|
141
|
+
const dip = CYL_SIZE * (1 - Math.sqrt(1 - u * u));
|
|
142
|
+
return top ? dip / b.h : (b.h - dip) / b.h;
|
|
143
|
+
}
|
|
118
144
|
/** Exit/entry constraints so draw.io leaves and enters nodes where ELK did. */
|
|
119
|
-
export function portStyle(points, s, t) {
|
|
145
|
+
export function portStyle(points, s, t, cyl = {}) {
|
|
120
146
|
const start = points[0], end = points[points.length - 1];
|
|
121
147
|
const c01 = (v) => Math.min(1, Math.max(0, Math.round(v * 1000) / 1000));
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
const sr = cyl.from ? rimY(start, s) : undefined, tr = cyl.to ? rimY(end, t) : undefined;
|
|
149
|
+
return (`exitX=${c01((start.x - s.x) / s.w)};exitY=${c01(sr ?? (start.y - s.y) / s.h)};exitDx=0;exitDy=0;${sr !== undefined ? 'exitPerimeter=0;' : ''}` +
|
|
150
|
+
`entryX=${c01((end.x - t.x) / t.w)};entryY=${c01(tr ?? (end.y - t.y) / t.h)};entryDx=0;entryDy=0;${tr !== undefined ? 'entryPerimeter=0;' : ''}`);
|
|
124
151
|
}
|
|
125
152
|
export function sceneFromLayout(d, l) {
|
|
126
153
|
const parentOf = new Map();
|
|
@@ -139,43 +166,140 @@ export function sceneFromLayout(d, l) {
|
|
|
139
166
|
const vertices = [];
|
|
140
167
|
for (const g of d.groups) {
|
|
141
168
|
const box = rel(g.id);
|
|
142
|
-
|
|
169
|
+
const inset = l.titleInset?.get(g.id);
|
|
170
|
+
vertices.push({ id: g.id, parent: g.parent ?? '1', attrs: { ...groupAttrs(g, d.theme), aiviz_auto: autoAttr(box) }, style: groupStyleFor(d, g) + (inset ? `spacingLeft=${inset};` : ''), box });
|
|
143
171
|
}
|
|
144
172
|
for (const n of d.nodes) {
|
|
145
173
|
const box = rel(n.id);
|
|
146
|
-
vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n), box });
|
|
174
|
+
vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n) + diamondInset(d, n, box), box });
|
|
147
175
|
vertices.push(...nodeChildren(d, n, box));
|
|
148
176
|
}
|
|
177
|
+
const isCyl = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'cylinder'; };
|
|
149
178
|
const edges = d.edges.map((e) => {
|
|
150
179
|
const le = l.edges.get(e.id);
|
|
180
|
+
const cyl = { from: isCyl(e.from), to: isCyl(e.to) };
|
|
151
181
|
const base = { id: edgeCellId(e.id), parent: '1', attrs: edgeAttrs(e), style: edgeStyleFor(d, e, labelBg(d, l, le?.label)), source: e.from, target: e.to };
|
|
152
182
|
if (!le)
|
|
153
183
|
return base;
|
|
184
|
+
if (e.from === e.to && le.label && e.label && le.points.length === 4) {
|
|
185
|
+
// A self-loop: the label sits beside the loop's outer run, not on it.
|
|
186
|
+
const [, a, b] = le.points;
|
|
187
|
+
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
188
|
+
return {
|
|
189
|
+
...base,
|
|
190
|
+
style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
|
|
191
|
+
points: le.points.slice(1, -1),
|
|
192
|
+
labelX: 0.001,
|
|
193
|
+
labelOffset: { x: le.label.x + le.label.w / 2 - mid.x, y: le.label.y + le.label.h / 2 - mid.y },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
154
196
|
return {
|
|
155
197
|
...base,
|
|
156
|
-
style: base.style + portStyle(le.points, abs(e.from), abs(e.to)),
|
|
198
|
+
style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
|
|
157
199
|
points: le.points.slice(1, -1),
|
|
158
|
-
labelX: le.label && e.label ? (labelSpot(le.points, le.label) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
|
|
200
|
+
labelX: le.label && e.label ? (labelSpot(le.points, le.label, l.direction) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
|
|
159
201
|
};
|
|
160
202
|
});
|
|
203
|
+
alignLabels(d, l, edges);
|
|
161
204
|
return { name: d.title ?? 'Diagram', background: palette(d.theme).canvas, meta: diagramMeta(d), vertices, edges, raw: [] };
|
|
162
205
|
}
|
|
206
|
+
/** The point at a draw.io label fraction (-1..1) along a route, and the run it sits on. */
|
|
207
|
+
function spotAt(pts, frac) {
|
|
208
|
+
const lens = pts.slice(1).map((b, i) => Math.hypot(b.x - pts[i].x, b.y - pts[i].y));
|
|
209
|
+
let at = ((frac + 1) / 2) * lens.reduce((x, y) => x + y, 0);
|
|
210
|
+
for (let i = 0; i < lens.length; i++) {
|
|
211
|
+
const a = pts[i], b = pts[i + 1], len = lens[i];
|
|
212
|
+
if (at <= len + 1e-6 || i === lens.length - 1) {
|
|
213
|
+
const t = len ? Math.min(1, at / len) : 0;
|
|
214
|
+
return { p: { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }, a, b };
|
|
215
|
+
}
|
|
216
|
+
at -= len;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Labels on neighbouring parallel runs (two edges dropping side by side) sit at
|
|
222
|
+
* whatever height each run's midpoint gives, often a few px apart. Put them on
|
|
223
|
+
* one line when each can move there and stay on its own run.
|
|
224
|
+
*/
|
|
225
|
+
function alignLabels(d, l, edges) {
|
|
226
|
+
const h = Math.ceil(typeface(d.theme).size.edge * 1.2) + 4;
|
|
227
|
+
const spots = edges.flatMap((se) => {
|
|
228
|
+
const e = d.edges.find((x) => edgeCellId(x.id) === se.id);
|
|
229
|
+
const le = e && l.edges.get(e.id);
|
|
230
|
+
if (!le || se.labelX === undefined || se.labelOffset || !e.label)
|
|
231
|
+
return [];
|
|
232
|
+
const s = spotAt(le.points, se.labelX);
|
|
233
|
+
if (!s)
|
|
234
|
+
return [];
|
|
235
|
+
const vertical = Math.abs(s.a.x - s.b.x) < 0.5;
|
|
236
|
+
if (!vertical && Math.abs(s.a.y - s.b.y) >= 0.5)
|
|
237
|
+
return [];
|
|
238
|
+
return [{ se, pts: le.points, ...s, vertical }];
|
|
239
|
+
});
|
|
240
|
+
for (const vertical of [true, false]) {
|
|
241
|
+
const along = (p) => (vertical ? p.y : p.x), across = (p) => (vertical ? p.x : p.y);
|
|
242
|
+
const group = spots.filter((s) => s.vertical === vertical).sort((a, b) => along(a.p) - along(b.p));
|
|
243
|
+
for (let i = 0; i < group.length;) {
|
|
244
|
+
let j = i + 1;
|
|
245
|
+
while (j < group.length && along(group[j].p) - along(group[i].p) <= 8)
|
|
246
|
+
j++;
|
|
247
|
+
const set = group.slice(i, j);
|
|
248
|
+
i = j;
|
|
249
|
+
if (set.length < 2)
|
|
250
|
+
continue;
|
|
251
|
+
// Side by side on different runs, not one run's labels stacked.
|
|
252
|
+
const xs = set.map((s) => across(s.p));
|
|
253
|
+
if (Math.max(...xs) - Math.min(...xs) < 20)
|
|
254
|
+
continue;
|
|
255
|
+
const target = Math.round(set.reduce((m, s) => m + along(s.p), 0) / set.length);
|
|
256
|
+
const fits = set.every((s) => {
|
|
257
|
+
const lo = Math.min(along(s.a), along(s.b)) + h / 2 + 4, hi = Math.max(along(s.a), along(s.b)) - h / 2 - 4;
|
|
258
|
+
return target >= lo && target <= hi;
|
|
259
|
+
});
|
|
260
|
+
if (!fits)
|
|
261
|
+
continue;
|
|
262
|
+
for (const s of set) {
|
|
263
|
+
const c = vertical ? { x: s.p.x, y: target } : { x: target, y: s.p.y };
|
|
264
|
+
s.se.labelX = labelFraction(s.pts, c);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
163
269
|
const attrString = (attrs) => Object.entries(attrs)
|
|
164
270
|
.filter(([k]) => k !== 'id')
|
|
165
271
|
.map(([k, v]) => ` ${k}="${xmlEsc(v)}"`)
|
|
166
272
|
.join('');
|
|
167
273
|
export function serialize(scene) {
|
|
168
|
-
// Parents before children.
|
|
169
274
|
const parentOf = new Map(scene.vertices.map((v) => [v.id, v.parent]));
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const
|
|
275
|
+
// Every cell after its parent, otherwise in the scene's order, which is spec
|
|
276
|
+
// order: show() reads it back and ELK uses it as a placement hint. Groups go
|
|
277
|
+
// first so nodes can sit in them; a node's own children (icon, table rows)
|
|
278
|
+
// already follow it.
|
|
279
|
+
const groups = scene.vertices.filter((v) => v.attrs.aiviz_type === 'group');
|
|
280
|
+
const rest = scene.vertices.filter((v) => v.attrs.aiviz_type !== 'group');
|
|
281
|
+
const placed = new Set(['1']);
|
|
282
|
+
const vertices = [];
|
|
283
|
+
for (const list of [groups, rest]) {
|
|
284
|
+
let todo = [...list];
|
|
285
|
+
while (todo.length) {
|
|
286
|
+
const left = [];
|
|
287
|
+
for (const v of todo) {
|
|
288
|
+
if (placed.has(v.parent) || !parentOf.has(v.parent)) {
|
|
289
|
+
vertices.push(v);
|
|
290
|
+
placed.add(v.id);
|
|
291
|
+
}
|
|
292
|
+
else
|
|
293
|
+
left.push(v);
|
|
294
|
+
}
|
|
295
|
+
// A cycle or a dangling parent: write the rest as they are rather than drop them.
|
|
296
|
+
if (left.length === todo.length) {
|
|
297
|
+
vertices.push(...left);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
todo = left;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
179
303
|
const cells = [];
|
|
180
304
|
for (const v of vertices) {
|
|
181
305
|
const b = v.box;
|
|
@@ -188,6 +312,7 @@ export function serialize(scene) {
|
|
|
188
312
|
const geom = e.geomRaw ??
|
|
189
313
|
`<mxGeometry ${e.labelX ? `x="${e.labelX.toFixed(3)}" ` : ''}relative="1" as="geometry">` +
|
|
190
314
|
(e.points?.length ? `<Array as="points">${e.points.map((p) => `<mxPoint x="${r(p.x)}" y="${r(p.y)}"/>`).join('')}</Array>` : '') +
|
|
315
|
+
(e.labelOffset ? `<mxPoint x="${r(e.labelOffset.x)}" y="${r(e.labelOffset.y)}" as="offset"/>` : '') +
|
|
191
316
|
`</mxGeometry>`;
|
|
192
317
|
cells.push(`<object id="${xmlEsc(e.id)}"${attrString({ label: '', ...e.attrs })}>` +
|
|
193
318
|
`<mxCell style="${xmlEsc(e.style)}" edge="1" parent="${xmlEsc(e.parent)}"` +
|
|
@@ -209,16 +334,21 @@ export function toMxfile(d, l) {
|
|
|
209
334
|
* source node, which reads as a caption of that node. Returns draw.io's -1..1
|
|
210
335
|
* fraction, or undefined when no run is long enough.
|
|
211
336
|
*/
|
|
212
|
-
function labelSpot(pts, label) {
|
|
337
|
+
function labelSpot(pts, label, dir) {
|
|
213
338
|
let total = 0;
|
|
214
339
|
let best;
|
|
340
|
+
// Runs along the flow belong to one edge; runs across it are often shared
|
|
341
|
+
// corridors where several edges' labels would stack up. Prefer the former.
|
|
342
|
+
const flowVertical = dir === 'TB' || dir === 'BT';
|
|
215
343
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
216
344
|
const a = pts[i], b = pts[i + 1];
|
|
217
345
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
218
346
|
const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
|
|
219
347
|
const room = len - (vertical ? label.h : label.w) - 20;
|
|
220
|
-
|
|
221
|
-
|
|
348
|
+
const along = dir ? vertical === flowVertical : true;
|
|
349
|
+
const better = !best || (along && !best.along) || (along === best.along && room > best.room);
|
|
350
|
+
if (room >= 0 && better)
|
|
351
|
+
best = { room, at: total + len / 2, along };
|
|
222
352
|
total += len;
|
|
223
353
|
}
|
|
224
354
|
return best && total ? (best.at / total) * 2 - 1 : undefined;
|