@profpowell/vb-project-planning 0.1.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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* quadrant-grid: Generic 2x2 quadrant grid primitive */
|
|
2
|
+
|
|
3
|
+
quadrant-grid:not(:defined) {
|
|
4
|
+
display: block;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
quadrant-grid {
|
|
8
|
+
display: block;
|
|
9
|
+
font-family: var(--font-sans, system-ui);
|
|
10
|
+
color: var(--color-text, #1a1a1a);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@scope (quadrant-grid) {
|
|
14
|
+
.qg-wrapper {
|
|
15
|
+
display: grid;
|
|
16
|
+
grid-template-columns: max-content max-content 1fr;
|
|
17
|
+
grid-template-rows: max-content 1fr max-content max-content;
|
|
18
|
+
grid-template-areas:
|
|
19
|
+
"y-label . ."
|
|
20
|
+
"y-scale grid grid"
|
|
21
|
+
". x-scale x-scale"
|
|
22
|
+
". x-label x-label";
|
|
23
|
+
gap: var(--size-2xs, 0.25rem);
|
|
24
|
+
margin: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.qg-y-label {
|
|
28
|
+
grid-area: y-label;
|
|
29
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
30
|
+
font-weight: 700;
|
|
31
|
+
text-transform: uppercase;
|
|
32
|
+
letter-spacing: 0.06em;
|
|
33
|
+
color: var(--color-text-muted, #666);
|
|
34
|
+
text-align: start;
|
|
35
|
+
padding-inline-end: var(--size-xs, 0.5rem);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.qg-x-label {
|
|
39
|
+
grid-area: x-label;
|
|
40
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
41
|
+
font-weight: 700;
|
|
42
|
+
text-transform: uppercase;
|
|
43
|
+
letter-spacing: 0.06em;
|
|
44
|
+
color: var(--color-text-muted, #666);
|
|
45
|
+
text-align: end;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.qg-y-scale {
|
|
49
|
+
grid-area: y-scale;
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
justify-content: space-between;
|
|
53
|
+
padding-block: var(--size-2xs, 0.25rem);
|
|
54
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
55
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
56
|
+
color: var(--color-text-muted, #666);
|
|
57
|
+
text-align: end;
|
|
58
|
+
padding-inline-end: var(--size-xs, 0.5rem);
|
|
59
|
+
min-inline-size: 3rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.qg-x-scale {
|
|
63
|
+
grid-area: x-scale;
|
|
64
|
+
display: flex;
|
|
65
|
+
justify-content: space-between;
|
|
66
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
67
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
68
|
+
color: var(--color-text-muted, #666);
|
|
69
|
+
padding-block-start: var(--size-2xs, 0.25rem);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.qg-grid {
|
|
73
|
+
grid-area: grid;
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: 1fr 1fr;
|
|
76
|
+
grid-template-rows: 1fr 1fr;
|
|
77
|
+
gap: var(--size-2xs, 0.25rem);
|
|
78
|
+
min-block-size: var(--qg-min-size, 24rem);
|
|
79
|
+
background: var(--color-border, oklch(85% 0 0));
|
|
80
|
+
padding: 1px;
|
|
81
|
+
border-radius: var(--radius-m, 0.5rem);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.qg-quadrant {
|
|
85
|
+
position: relative;
|
|
86
|
+
background: var(--color-surface, #fff);
|
|
87
|
+
padding: var(--size-s, 0.75rem);
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: var(--size-xs, 0.5rem);
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Cartesian placement: Q1 top-right, Q2 top-left, Q3 bottom-left, Q4 bottom-right */
|
|
95
|
+
.qg-quadrant[data-quadrant-zone="0"] { grid-row: 1; grid-column: 2; border-radius: 0 var(--radius-m, 0.5rem) 0 0; }
|
|
96
|
+
.qg-quadrant[data-quadrant-zone="1"] { grid-row: 1; grid-column: 1; border-radius: var(--radius-m, 0.5rem) 0 0 0; }
|
|
97
|
+
.qg-quadrant[data-quadrant-zone="2"] { grid-row: 2; grid-column: 1; border-radius: 0 0 0 var(--radius-m, 0.5rem); }
|
|
98
|
+
.qg-quadrant[data-quadrant-zone="3"] { grid-row: 2; grid-column: 2; border-radius: 0 0 var(--radius-m, 0.5rem) 0; }
|
|
99
|
+
|
|
100
|
+
.qg-quadrant-label {
|
|
101
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
102
|
+
font-weight: 700;
|
|
103
|
+
text-transform: uppercase;
|
|
104
|
+
letter-spacing: 0.06em;
|
|
105
|
+
color: var(--color-text-muted, #666);
|
|
106
|
+
margin: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.qg-items {
|
|
110
|
+
list-style: none;
|
|
111
|
+
margin: 0;
|
|
112
|
+
padding: 0;
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-direction: column;
|
|
115
|
+
gap: var(--size-2xs, 0.25rem);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.qg-items > li {
|
|
119
|
+
background: var(--color-surface-raised, oklch(96% 0 0));
|
|
120
|
+
padding: var(--size-2xs, 0.25rem) var(--size-xs, 0.5rem);
|
|
121
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
122
|
+
font-size: var(--font-size-sm, 0.875rem);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Coordinate-pinned children get absolute placement.
|
|
126
|
+
Section-local coords (0..1) are computed by JS and stored as
|
|
127
|
+
--qg-local-x / --qg-local-y. The component also measures each pin
|
|
128
|
+
after layout and writes --qg-pin-half-w / --qg-pin-half-h so the
|
|
129
|
+
center-anchored position is clamped by the pin's actual half-size,
|
|
130
|
+
keeping the bounding box inside the quadrant at any coordinate. */
|
|
131
|
+
.qg-pinned {
|
|
132
|
+
position: absolute;
|
|
133
|
+
inline-size: max-content;
|
|
134
|
+
max-inline-size: calc(100% - var(--size-xs, 0.5rem));
|
|
135
|
+
padding: var(--size-3xs, 0.125rem) var(--size-xs, 0.5rem);
|
|
136
|
+
background: var(--color-accent, oklch(45% 0.18 260));
|
|
137
|
+
color: var(--color-text-on-primary, #fff);
|
|
138
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
139
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
140
|
+
inset-inline-start: clamp(var(--qg-pin-half-w, 1.5rem), calc(var(--qg-local-x, 0.5) * 100%), calc(100% - var(--qg-pin-half-w, 1.5rem)));
|
|
141
|
+
inset-block-end: clamp(var(--qg-pin-half-h, 0.75rem), calc(var(--qg-local-y, 0.5) * 100%), calc(100% - var(--qg-pin-half-h, 0.75rem)));
|
|
142
|
+
/* --qg-fan-x / --qg-fan-y nudge clustered pins outward on
|
|
143
|
+
hover/focus; default to zero so non-clustered pins stay anchored. */
|
|
144
|
+
transform: translate(calc(-50% + var(--qg-fan-x, 0px)), calc(50% + var(--qg-fan-y, 0px)));
|
|
145
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
146
|
+
/* Per-pin tint: hue-rotate is cheap, leaves white text neutral,
|
|
147
|
+
and lets identical-style pins stay visually distinct at rest. */
|
|
148
|
+
filter: hue-rotate(var(--qg-pin-hue, 0deg));
|
|
149
|
+
transition: transform 180ms ease, box-shadow 180ms ease, z-index 0s;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Cluster fan-out: when any clustered pin in the quadrant is hovered
|
|
153
|
+
or focused, splay every member of the cluster along a circle.
|
|
154
|
+
--qg-cluster-i is each pin's position (0..n-1); --qg-cluster-n is
|
|
155
|
+
the cluster size. Radius scales with measured pin width. */
|
|
156
|
+
.qg-quadrant:hover .qg-pinned.qg-clustered,
|
|
157
|
+
.qg-quadrant:focus-within .qg-pinned.qg-clustered {
|
|
158
|
+
--qg-fan-angle: calc((var(--qg-cluster-i, 0) / var(--qg-cluster-n, 1)) * 360deg);
|
|
159
|
+
--qg-fan-radius: calc(var(--qg-pin-half-w, 1.5rem) * 1.4);
|
|
160
|
+
--qg-fan-x: calc(cos(var(--qg-fan-angle)) * var(--qg-fan-radius));
|
|
161
|
+
--qg-fan-y: calc(sin(var(--qg-fan-angle)) * var(--qg-fan-radius) * -1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.qg-pinned.qg-clustered:hover,
|
|
165
|
+
.qg-pinned.qg-clustered:focus-visible {
|
|
166
|
+
z-index: 2;
|
|
167
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@media (prefers-reduced-motion: reduce) {
|
|
172
|
+
quadrant-grid * {
|
|
173
|
+
transition: none !important;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@media print {
|
|
178
|
+
quadrant-grid .qg-y-label,
|
|
179
|
+
quadrant-grid .qg-x-label,
|
|
180
|
+
quadrant-grid .qg-y-scale,
|
|
181
|
+
quadrant-grid .qg-x-scale {
|
|
182
|
+
color: #000;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers + SVG builder for <quality-target>.
|
|
3
|
+
*
|
|
4
|
+
* Combines the prior _nfr-utils.js (default ilities, default cost
|
|
5
|
+
* weights, validation invariants) with _radar-geometry.js (axis
|
|
6
|
+
* angles, vector/envelope points, SVG builder), with the addition
|
|
7
|
+
* of click-to-edit dialog state helpers.
|
|
8
|
+
*
|
|
9
|
+
* Domain-neutral schema property names are unchanged: vector,
|
|
10
|
+
* rationales, costWeights, capacityPoints, capacitySource,
|
|
11
|
+
* overrunRationale, ironTriangleHash. Only the front-door labels move
|
|
12
|
+
* from "NFR" to "quality".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── Defaults ──────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_ILITIES = Object.freeze([
|
|
18
|
+
'performance',
|
|
19
|
+
'accessibility',
|
|
20
|
+
'security',
|
|
21
|
+
'reliability',
|
|
22
|
+
'maintainability',
|
|
23
|
+
'observability',
|
|
24
|
+
'compatibility',
|
|
25
|
+
'scalability',
|
|
26
|
+
'portability',
|
|
27
|
+
'internationalization',
|
|
28
|
+
'privacy',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const LEVELS = Object.freeze(['critical', 'important', 'acceptable', 'not-relevant']);
|
|
32
|
+
export const LEVEL_LABELS = Object.freeze({
|
|
33
|
+
'critical': 'Critical',
|
|
34
|
+
'important': 'Important',
|
|
35
|
+
'acceptable': 'Acceptable',
|
|
36
|
+
'not-relevant': 'Not relevant',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Default cost weights when picking an ility as Critical. Sum = 40. */
|
|
40
|
+
export const DEFAULT_COST_WEIGHTS = Object.freeze({
|
|
41
|
+
accessibility: 3,
|
|
42
|
+
performance: 5,
|
|
43
|
+
security: 5,
|
|
44
|
+
reliability: 4,
|
|
45
|
+
observability: 3,
|
|
46
|
+
internationalization: 4,
|
|
47
|
+
compatibility: 2,
|
|
48
|
+
portability: 3,
|
|
49
|
+
privacy: 4,
|
|
50
|
+
scalability: 5,
|
|
51
|
+
maintainability: 2,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const ILITY_LABELS = Object.freeze({
|
|
55
|
+
performance: 'Performance',
|
|
56
|
+
accessibility: 'Accessibility',
|
|
57
|
+
security: 'Security',
|
|
58
|
+
reliability: 'Reliability',
|
|
59
|
+
maintainability: 'Maintainability',
|
|
60
|
+
observability: 'Observability',
|
|
61
|
+
compatibility: 'Compatibility',
|
|
62
|
+
scalability: 'Scalability',
|
|
63
|
+
portability: 'Portability',
|
|
64
|
+
internationalization: 'Internationalization',
|
|
65
|
+
privacy: 'Privacy',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const ILITY_ABBR = Object.freeze({
|
|
69
|
+
accessibility: 'a11y',
|
|
70
|
+
internationalization: 'i18n',
|
|
71
|
+
maintainability: 'maint',
|
|
72
|
+
observability: 'obs',
|
|
73
|
+
compatibility: 'compat',
|
|
74
|
+
portability: 'porta',
|
|
75
|
+
performance: 'perf',
|
|
76
|
+
reliability: 'rely',
|
|
77
|
+
scalability: 'scale',
|
|
78
|
+
security: 'sec',
|
|
79
|
+
privacy: 'priv',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const LEVEL_RATIO = Object.freeze({
|
|
83
|
+
critical: 1,
|
|
84
|
+
important: 0.6,
|
|
85
|
+
acceptable: 0.3,
|
|
86
|
+
'not-relevant': 0,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Labels ────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function ilityLabel(ility) {
|
|
92
|
+
if (ILITY_LABELS[ility]) return ILITY_LABELS[ility];
|
|
93
|
+
return String(ility)
|
|
94
|
+
.replace(/[-_]/g, ' ')
|
|
95
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function ilityAbbr(ility) {
|
|
99
|
+
return ILITY_ABBR[ility] || ility;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Cost weights ──────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export function mergeCostWeights(ilities, overrides = {}) {
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const ility of ilities) {
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(overrides, ility) &&
|
|
108
|
+
Number.isFinite(Number(overrides[ility]))) {
|
|
109
|
+
out[ility] = Math.max(0, Math.floor(Number(overrides[ility])));
|
|
110
|
+
} else if (Object.prototype.hasOwnProperty.call(DEFAULT_COST_WEIGHTS, ility)) {
|
|
111
|
+
out[ility] = DEFAULT_COST_WEIGHTS[ility];
|
|
112
|
+
} else {
|
|
113
|
+
out[ility] = 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return Object.freeze(out);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function criticalSum(vector, costWeights) {
|
|
120
|
+
let sum = 0;
|
|
121
|
+
for (const [ility, level] of Object.entries(vector || {})) {
|
|
122
|
+
if (level === 'critical') sum += Number(costWeights?.[ility] ?? 0);
|
|
123
|
+
}
|
|
124
|
+
return sum;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function criticalKeys(vector) {
|
|
128
|
+
return Object.keys(vector || {}).filter(k => vector[k] === 'critical');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Validation ────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Return { valid, errors, criticalSum } for a candidate vector. Used
|
|
135
|
+
* by the component's checkValidity() and (mirror) by uucd-core's
|
|
136
|
+
* validateQualityVector() so both surfaces apply identical rules.
|
|
137
|
+
*/
|
|
138
|
+
export function validateVector(input) {
|
|
139
|
+
const {
|
|
140
|
+
vector = {}, rationales = {}, costWeights = {},
|
|
141
|
+
capacityPoints = Infinity, overrunRationale = '',
|
|
142
|
+
minRationale = 10, minOverrunRationale = 10,
|
|
143
|
+
} = input || {};
|
|
144
|
+
|
|
145
|
+
const errors = [];
|
|
146
|
+
const crits = criticalKeys(vector);
|
|
147
|
+
|
|
148
|
+
for (const k of crits) {
|
|
149
|
+
const r = rationales[k];
|
|
150
|
+
if (typeof r !== 'string' || r.trim().length < minRationale) {
|
|
151
|
+
errors.push(`Critical "${k}" needs a rationale of at least ${minRationale} characters.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sum = criticalSum(vector, costWeights);
|
|
156
|
+
if (Number.isFinite(capacityPoints) && sum > capacityPoints) {
|
|
157
|
+
if (typeof overrunRationale !== 'string' || overrunRationale.trim().length < minOverrunRationale) {
|
|
158
|
+
errors.push(
|
|
159
|
+
`Over budget by ${sum - capacityPoints} points (${sum}/${capacityPoints}); ` +
|
|
160
|
+
`overrunRationale of at least ${minOverrunRationale} characters required.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { valid: errors.length === 0, errors, criticalSum: sum };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Per-axis dialog state machine: can the user save THIS axis's edit?
|
|
170
|
+
* Critical needs a rationale; other levels just need to be picked.
|
|
171
|
+
* The over-budget rationale is a vector-level concern handled by
|
|
172
|
+
* validateVector — not gated here.
|
|
173
|
+
*/
|
|
174
|
+
export function canSaveAxis({ level, rationale, minRationale = 10 }) {
|
|
175
|
+
if (!LEVELS.includes(level)) return { ok: false, reason: 'pick-level' };
|
|
176
|
+
if (level === 'critical') {
|
|
177
|
+
if (typeof rationale !== 'string' || rationale.trim().length < minRationale) {
|
|
178
|
+
return { ok: false, reason: 'rationale-too-short' };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Tooltip text used both in the SVG <title> and aria-label. */
|
|
185
|
+
export function formatAxisTooltip({ ility, level, costWeight }) {
|
|
186
|
+
const name = ilityLabel(ility);
|
|
187
|
+
const lvl = level ? LEVEL_LABELS[level] || level : 'unset';
|
|
188
|
+
const n = Number(costWeight);
|
|
189
|
+
const cost = Number.isFinite(n) ? `${n} pts` : '? pts';
|
|
190
|
+
return `${name} — ${lvl} · ${cost}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── JSON attribute parsing (data-cost-weights) ───────────────────
|
|
194
|
+
|
|
195
|
+
export function parseJsonAttr(raw) {
|
|
196
|
+
if (!raw || typeof raw !== 'string') return {};
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
|
200
|
+
} catch {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Geometry ──────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/** N axis angles, evenly spaced, starting at the top. */
|
|
208
|
+
export function axisAngles(n) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (let i = 0; i < n; i++) {
|
|
211
|
+
out.push(-Math.PI / 2 + (2 * Math.PI * i) / n);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Fraction of `radius` the capacity envelope reaches per axis.
|
|
218
|
+
* Returns null when capacity is unbounded.
|
|
219
|
+
*/
|
|
220
|
+
export function envelopeRatio({ costWeights = {}, capacityPoints, ilities }) {
|
|
221
|
+
if (!Number.isFinite(capacityPoints)) return null;
|
|
222
|
+
const total = ilities.reduce((s, k) => {
|
|
223
|
+
const w = Number(costWeights[k]);
|
|
224
|
+
return s + (Number.isFinite(w) && w > 0 ? w : 0);
|
|
225
|
+
}, 0);
|
|
226
|
+
if (total <= 0) return 0;
|
|
227
|
+
return Math.min(1, Math.max(0, capacityPoints / total));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Per-ility {x, y, ility, level} points for the chosen vector. */
|
|
231
|
+
export function vectorPoints({ ilities, vector = {}, radius }) {
|
|
232
|
+
const angles = axisAngles(ilities.length);
|
|
233
|
+
return ilities.map((k, i) => {
|
|
234
|
+
const level = vector[k] || null;
|
|
235
|
+
const ratio = LEVEL_RATIO[level] ?? 0;
|
|
236
|
+
return {
|
|
237
|
+
ility: k,
|
|
238
|
+
level,
|
|
239
|
+
x: round(Math.cos(angles[i]) * radius * ratio),
|
|
240
|
+
y: round(Math.sin(angles[i]) * radius * ratio),
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Per-ility {x, y, ility} points for the capacity envelope. */
|
|
246
|
+
export function envelopePoints({ ilities, costWeights, capacityPoints, radius }) {
|
|
247
|
+
const ratio = envelopeRatio({ costWeights, capacityPoints, ilities });
|
|
248
|
+
if (ratio == null) return null;
|
|
249
|
+
const angles = axisAngles(ilities.length);
|
|
250
|
+
return ilities.map((k, i) => ({
|
|
251
|
+
ility: k,
|
|
252
|
+
x: round(Math.cos(angles[i]) * radius * ratio),
|
|
253
|
+
y: round(Math.sin(angles[i]) * radius * ratio),
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Outer axis endpoints — used for the spoke + label positions. */
|
|
258
|
+
export function axisOuterPoints({ ilities, radius }) {
|
|
259
|
+
const angles = axisAngles(ilities.length);
|
|
260
|
+
return ilities.map((k, i) => ({
|
|
261
|
+
ility: k,
|
|
262
|
+
x: round(Math.cos(angles[i]) * radius),
|
|
263
|
+
y: round(Math.sin(angles[i]) * radius),
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── SVG builder ───────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build the inline polygon-as-UI <svg>. Each axis marker is a clickable
|
|
273
|
+
* <g class="axis" data-ility="…" tabindex="0"> hit target the component
|
|
274
|
+
* wires to a per-axis editor dialog.
|
|
275
|
+
*
|
|
276
|
+
* Layers (back to front):
|
|
277
|
+
* 1. three reference rings (Critical 1.0 / Important 0.6 / Acceptable 0.3
|
|
278
|
+
* level radii) — the ONLY background; no envelope polygon.
|
|
279
|
+
* 2. spokes from origin to outer ring
|
|
280
|
+
* 3. vector polygon (picked axes only)
|
|
281
|
+
* 4. axis hit groups: invisible hit-rect + dot marker + label + <title>
|
|
282
|
+
* 5. center capacity readout in a small reserved hole
|
|
283
|
+
*/
|
|
284
|
+
const LEVEL_RING_RATIOS = Object.freeze([
|
|
285
|
+
{ ratio: 1.00, label: 'Critical', className: 'ring-critical' },
|
|
286
|
+
{ ratio: 0.60, label: 'Important', className: 'ring-important' },
|
|
287
|
+
{ ratio: 0.30, label: 'Acceptable', className: 'ring-acceptable' },
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const CENTER_HOLE_RADIUS_RATIO = 0.22; // fraction of `radius` reserved for capacity readout
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {{ ilities?: any[], vector?: Record<string, string>, costWeights?: Record<string, number>, capacityPoints?: number, radius?: number }} [opts]
|
|
294
|
+
*/
|
|
295
|
+
export function buildTargetSvg({
|
|
296
|
+
ilities = /** @type {any[]} */ ([]),
|
|
297
|
+
vector = /** @type {Record<string, string>} */ ({}),
|
|
298
|
+
costWeights = /** @type {Record<string, number>} */ ({}),
|
|
299
|
+
capacityPoints = 0,
|
|
300
|
+
radius = 100,
|
|
301
|
+
} = {}) {
|
|
302
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
303
|
+
// Generous viewBox padding so full ility names ("Internationalization"
|
|
304
|
+
// is the longest at 20 chars) never clip. Labels render at radius + 14;
|
|
305
|
+
// longest label ≈ 130px wide at the 0.625rem default font.
|
|
306
|
+
const labelRadius = radius + 14;
|
|
307
|
+
const padX = labelRadius + 140;
|
|
308
|
+
const padY = labelRadius + 30;
|
|
309
|
+
svg.setAttribute('viewBox', `${-padX} ${-padY} ${padX * 2} ${padY * 2}`);
|
|
310
|
+
svg.setAttribute('role', 'group');
|
|
311
|
+
svg.setAttribute('aria-label',
|
|
312
|
+
`Quality target. ${criticalKeys(vector || {}).length} critical of ${ilities.length} ilities. Click any axis to edit.`);
|
|
313
|
+
|
|
314
|
+
// 1. Three concentric reference rings (level ratios).
|
|
315
|
+
for (const r of LEVEL_RING_RATIOS) {
|
|
316
|
+
const ring = document.createElementNS(SVG_NS, 'polygon');
|
|
317
|
+
ring.setAttribute('class', `ring ${r.className}`);
|
|
318
|
+
const points = axisOuterPoints({ ilities, radius: radius * r.ratio });
|
|
319
|
+
ring.setAttribute('points', points.map(p => `${p.x},${p.y}`).join(' '));
|
|
320
|
+
svg.append(ring);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 2. Spokes from origin to outer ring.
|
|
324
|
+
const outer = axisOuterPoints({ ilities, radius });
|
|
325
|
+
for (const p of outer) {
|
|
326
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
327
|
+
line.setAttribute('class', 'spoke');
|
|
328
|
+
line.setAttribute('x1', '0'); line.setAttribute('y1', '0');
|
|
329
|
+
line.setAttribute('x2', String(p.x)); line.setAttribute('y2', String(p.y));
|
|
330
|
+
svg.append(line);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 3. Vector polygon — built from picked axes only so unset ilities
|
|
334
|
+
// don't drag the shape through the centroid.
|
|
335
|
+
const vec = vectorPoints({ ilities, vector, radius });
|
|
336
|
+
const nonZero = vec.filter(p => Math.hypot(p.x, p.y) > 0.5);
|
|
337
|
+
if (nonZero.length >= 3) {
|
|
338
|
+
const poly = document.createElementNS(SVG_NS, 'polygon');
|
|
339
|
+
poly.setAttribute('class', 'vector');
|
|
340
|
+
poly.setAttribute('points', nonZero.map(p => `${p.x},${p.y}`).join(' '));
|
|
341
|
+
svg.append(poly);
|
|
342
|
+
} else if (nonZero.length > 0) {
|
|
343
|
+
for (const p of nonZero) {
|
|
344
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
345
|
+
line.setAttribute('class', 'vector-spoke');
|
|
346
|
+
line.setAttribute('x1', '0'); line.setAttribute('y1', '0');
|
|
347
|
+
line.setAttribute('x2', String(p.x)); line.setAttribute('y2', String(p.y));
|
|
348
|
+
svg.append(line);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 4. Axis hit groups (markers + full labels).
|
|
353
|
+
const labels = axisOuterPoints({ ilities, radius: labelRadius });
|
|
354
|
+
for (let i = 0; i < ilities.length; i++) {
|
|
355
|
+
const ility = ilities[i];
|
|
356
|
+
const dot = vec[i];
|
|
357
|
+
const label = labels[i];
|
|
358
|
+
const cost = Number(costWeights?.[ility] ?? 0);
|
|
359
|
+
const level = vector?.[ility] ?? null;
|
|
360
|
+
|
|
361
|
+
const g = document.createElementNS(SVG_NS, 'g');
|
|
362
|
+
g.setAttribute('class', 'axis');
|
|
363
|
+
g.setAttribute('data-ility', ility);
|
|
364
|
+
if (level) g.setAttribute('data-level', String(level));
|
|
365
|
+
g.setAttribute('tabindex', '0');
|
|
366
|
+
g.setAttribute('role', 'button');
|
|
367
|
+
g.setAttribute('aria-label',
|
|
368
|
+
`${formatAxisTooltip({ ility, level, costWeight: cost })}. Activate to edit.`);
|
|
369
|
+
|
|
370
|
+
// Invisible hit area sized to the label width.
|
|
371
|
+
const hit = document.createElementNS(SVG_NS, 'rect');
|
|
372
|
+
hit.setAttribute('class', 'hit');
|
|
373
|
+
const anchor = anchorFor(label);
|
|
374
|
+
const hitW = 100;
|
|
375
|
+
const hitH = 22;
|
|
376
|
+
const hitX = label.x + (anchor === 'end' ? -hitW : anchor === 'start' ? 0 : -hitW / 2);
|
|
377
|
+
const hitY = label.y - hitH / 2;
|
|
378
|
+
hit.setAttribute('x', String(round(hitX)));
|
|
379
|
+
hit.setAttribute('y', String(round(hitY)));
|
|
380
|
+
hit.setAttribute('width', String(hitW));
|
|
381
|
+
hit.setAttribute('height', String(hitH));
|
|
382
|
+
hit.setAttribute('rx', '4');
|
|
383
|
+
g.append(hit);
|
|
384
|
+
|
|
385
|
+
// Marker dot at the level point (only when the axis is set —
|
|
386
|
+
// otherwise it sits at origin and would obscure the capacity readout).
|
|
387
|
+
if (level) {
|
|
388
|
+
const marker = document.createElementNS(SVG_NS, 'circle');
|
|
389
|
+
marker.setAttribute('class', 'marker');
|
|
390
|
+
marker.setAttribute('cx', String(dot.x));
|
|
391
|
+
marker.setAttribute('cy', String(dot.y));
|
|
392
|
+
marker.setAttribute('r', '4');
|
|
393
|
+
g.append(marker);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Full ility label.
|
|
397
|
+
const text = document.createElementNS(SVG_NS, 'text');
|
|
398
|
+
text.setAttribute('class', 'axis-label');
|
|
399
|
+
text.setAttribute('x', String(label.x));
|
|
400
|
+
text.setAttribute('y', String(label.y));
|
|
401
|
+
text.setAttribute('text-anchor', anchor);
|
|
402
|
+
text.setAttribute('dominant-baseline', 'middle');
|
|
403
|
+
text.textContent = ilityLabel(ility);
|
|
404
|
+
g.append(text);
|
|
405
|
+
|
|
406
|
+
// Tooltip.
|
|
407
|
+
const title = document.createElementNS(SVG_NS, 'title');
|
|
408
|
+
title.textContent = formatAxisTooltip({ ility, level, costWeight: cost });
|
|
409
|
+
g.append(title);
|
|
410
|
+
|
|
411
|
+
svg.append(g);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 5. Center capacity readout — sits in a reserved hole the polygon
|
|
415
|
+
// visually clears.
|
|
416
|
+
const center = document.createElementNS(SVG_NS, 'g');
|
|
417
|
+
center.setAttribute('class', 'center');
|
|
418
|
+
const sum = criticalSum(vector, costWeights);
|
|
419
|
+
const overBudget = Number.isFinite(capacityPoints) && sum > capacityPoints;
|
|
420
|
+
if (overBudget) center.setAttribute('data-over', '');
|
|
421
|
+
const holeRadius = radius * CENTER_HOLE_RADIUS_RATIO;
|
|
422
|
+
|
|
423
|
+
// Backdrop circle so the polygon stroke under the number doesn't bleed in.
|
|
424
|
+
const backdrop = document.createElementNS(SVG_NS, 'circle');
|
|
425
|
+
backdrop.setAttribute('class', 'capacity-backdrop');
|
|
426
|
+
backdrop.setAttribute('cx', '0');
|
|
427
|
+
backdrop.setAttribute('cy', '0');
|
|
428
|
+
backdrop.setAttribute('r', String(round(holeRadius)));
|
|
429
|
+
center.append(backdrop);
|
|
430
|
+
|
|
431
|
+
const sumText = document.createElementNS(SVG_NS, 'text');
|
|
432
|
+
sumText.setAttribute('class', 'capacity-sum');
|
|
433
|
+
sumText.setAttribute('x', '0');
|
|
434
|
+
sumText.setAttribute('y', '-3');
|
|
435
|
+
sumText.setAttribute('text-anchor', 'middle');
|
|
436
|
+
sumText.setAttribute('dominant-baseline', 'middle');
|
|
437
|
+
sumText.textContent = String(sum);
|
|
438
|
+
center.append(sumText);
|
|
439
|
+
|
|
440
|
+
const denom = document.createElementNS(SVG_NS, 'text');
|
|
441
|
+
denom.setAttribute('class', 'capacity-denom');
|
|
442
|
+
denom.setAttribute('x', '0');
|
|
443
|
+
denom.setAttribute('y', '11');
|
|
444
|
+
denom.setAttribute('text-anchor', 'middle');
|
|
445
|
+
denom.textContent = Number.isFinite(capacityPoints) ? `of ${capacityPoints}` : 'unbounded';
|
|
446
|
+
center.append(denom);
|
|
447
|
+
|
|
448
|
+
svg.append(center);
|
|
449
|
+
|
|
450
|
+
return svg;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
function round(n) { return Math.round(n * 100) / 100; }
|
|
456
|
+
|
|
457
|
+
function anchorFor(p) {
|
|
458
|
+
if (Math.abs(p.x) < 4) return 'middle';
|
|
459
|
+
return p.x > 0 ? 'start' : 'end';
|
|
460
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "quality-target",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Polygon-as-UI quality prioritization surface. The radar IS the picker: each axis is a clickable hit-target that opens a per-ility dialog (level + rationale). Pairs with <iron-triangle> via data-bind-to to read capacity. Form-associated; serializes the quality-vector schema.",
|
|
6
|
+
"htmlvalidate": {
|
|
7
|
+
"flow": true,
|
|
8
|
+
"permittedContent": ["@flow"]
|
|
9
|
+
},
|
|
10
|
+
"attributes": [
|
|
11
|
+
{ "name": "name", "kind": "native", "purpose": "config", "type": "string", "default": "quality", "description": "Form-association field name" },
|
|
12
|
+
{ "name": "data-bind-to", "kind": "data", "purpose": "config", "type": "string", "description": "ID of a sibling <iron-triangle> to read capacityPoints from" },
|
|
13
|
+
{ "name": "data-capacity-points", "kind": "data", "purpose": "config", "type": "number", "description": "Literal capacity fallback when no triangle is bound" },
|
|
14
|
+
{ "name": "data-cost-weights", "kind": "data", "purpose": "config", "type": "string", "description": "JSON object overriding default per-ility cost weights" },
|
|
15
|
+
{ "name": "data-radius", "kind": "data", "purpose": "config", "type": "number", "default": "100", "description": "Base radius in SVG units" },
|
|
16
|
+
{ "name": "data-show-envelope", "kind": "data", "purpose": "visual-variant", "type": "boolean", "description": "Show the capacity envelope polygon (default true; set to \"false\" to hide)" },
|
|
17
|
+
{ "name": "data-min-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "10", "description": "Min characters for a Critical row's rationale" },
|
|
18
|
+
{ "name": "data-max-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "200", "description": "Max characters for the rationale textarea" },
|
|
19
|
+
{ "name": "data-min-overrun-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "10", "description": "Min characters for overrunRationale" },
|
|
20
|
+
{ "name": "data-max-overrun-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "400", "description": "Max characters for overrunRationale" },
|
|
21
|
+
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "All inputs disabled" },
|
|
22
|
+
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors" }
|
|
23
|
+
],
|
|
24
|
+
"slots": [],
|
|
25
|
+
"events": [
|
|
26
|
+
{ "name": "quality-target:change", "detail": "{ vector, rationales, costWeights, capacityPoints, criticalSum, overrunRationale, ironTriangleHash, source, field }", "description": "Any pick or rationale changes" },
|
|
27
|
+
{ "name": "quality-target:over-budget", "detail": "{ delta, criticalSum, capacityPoints }", "description": "The selection just crossed into over-budget" },
|
|
28
|
+
{ "name": "quality-target:under-budget", "detail": "{ slack, criticalSum, capacityPoints }", "description": "The selection just returned within budget" }
|
|
29
|
+
]
|
|
30
|
+
}
|