@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,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "quadrant-grid",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Generic 2x2 quadrant grid primitive (SWOT, stakeholder-map, custom)",
|
|
6
|
+
"attributes": [
|
|
7
|
+
{
|
|
8
|
+
"name": "x-label",
|
|
9
|
+
"kind": "host-api",
|
|
10
|
+
"purpose": "config",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "X-axis label"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "y-label",
|
|
16
|
+
"kind": "host-api",
|
|
17
|
+
"purpose": "config",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Y-axis label"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "x-low",
|
|
23
|
+
"kind": "host-api",
|
|
24
|
+
"purpose": "config",
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "X-axis low-end marker (default: \"Low\")"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "x-high",
|
|
30
|
+
"kind": "host-api",
|
|
31
|
+
"purpose": "config",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "X-axis high-end marker (default: \"High\")"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "y-low",
|
|
37
|
+
"kind": "host-api",
|
|
38
|
+
"purpose": "config",
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Y-axis low-end marker (default: \"Low\")"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "y-high",
|
|
44
|
+
"kind": "host-api",
|
|
45
|
+
"purpose": "config",
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Y-axis high-end marker (default: \"High\")"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "q1-label",
|
|
51
|
+
"kind": "host-api",
|
|
52
|
+
"purpose": "config",
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "Top-right quadrant title"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "q2-label",
|
|
58
|
+
"kind": "host-api",
|
|
59
|
+
"purpose": "config",
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "Top-left quadrant title"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "q3-label",
|
|
65
|
+
"kind": "host-api",
|
|
66
|
+
"purpose": "config",
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "Bottom-left quadrant title"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "q4-label",
|
|
72
|
+
"kind": "host-api",
|
|
73
|
+
"purpose": "config",
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Bottom-right quadrant title"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "draggable",
|
|
79
|
+
"kind": "host-api",
|
|
80
|
+
"purpose": "config",
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"description": "Enable drag-and-drop between quadrants (composes <drag-surface>)"
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"childAttributes": [
|
|
86
|
+
{
|
|
87
|
+
"name": "data-quadrant",
|
|
88
|
+
"on": "*",
|
|
89
|
+
"type": "number",
|
|
90
|
+
"description": "Explicit quadrant index 0..3 (Cartesian Q1..Q4)"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "data-x",
|
|
94
|
+
"on": "*",
|
|
95
|
+
"type": "number",
|
|
96
|
+
"description": "Normalized x coordinate 0..1; quadrant is computed from coords"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"name": "data-y",
|
|
100
|
+
"on": "*",
|
|
101
|
+
"type": "number",
|
|
102
|
+
"description": "Normalized y coordinate 0..1; quadrant is computed from coords"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "data-id",
|
|
106
|
+
"on": "*",
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "Stable identifier emitted in move events"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"events": [
|
|
112
|
+
{
|
|
113
|
+
"name": "quadrant-grid:move",
|
|
114
|
+
"detail": "{ item, itemId, from, to }",
|
|
115
|
+
"description": "Fired when a draggable child moves between quadrants"
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
"structure": [],
|
|
119
|
+
"htmlvalidate": {
|
|
120
|
+
"flow": true,
|
|
121
|
+
"permittedContent": [
|
|
122
|
+
"@flow"
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quadrant-grid: Generic 2x2 quadrant grid primitive
|
|
3
|
+
*
|
|
4
|
+
* Backs SWOT, stakeholder-map, custom 2x2 visualizations. Sister to
|
|
5
|
+
* <impact-effort> (which keeps its hardcoded quick-wins/big-bets API
|
|
6
|
+
* for backward compatibility).
|
|
7
|
+
*
|
|
8
|
+
* Children are placed by either:
|
|
9
|
+
* - data-quadrant="0..3" — explicit bucket (Cartesian: Q1=top-right,
|
|
10
|
+
* Q2=top-left, Q3=bottom-left, Q4=bottom-right; numbers 0..3 map to
|
|
11
|
+
* Q1..Q4)
|
|
12
|
+
* - data-x="0..1" data-y="0..1" — normalized coordinates; component
|
|
13
|
+
* computes the quadrant AND positions the child as a labelled dot
|
|
14
|
+
* inside the cell
|
|
15
|
+
*
|
|
16
|
+
* Optional drag-and-drop via the `draggable` host attribute; composes
|
|
17
|
+
* <drag-surface> the same way <impact-effort> does. Children become
|
|
18
|
+
* draggable cards that can be moved between quadrants.
|
|
19
|
+
*
|
|
20
|
+
* Progressive enhancement:
|
|
21
|
+
* 1. HTML — children with data-quadrant remain visible without JS;
|
|
22
|
+
* a <dl> static fallback enumerates each quadrant.
|
|
23
|
+
* 2. CSS — :not(:defined) selector keeps the layout sensible
|
|
24
|
+
* pre-upgrade; the grid layout is applied once the component
|
|
25
|
+
* defines.
|
|
26
|
+
* 3. JS — adds axis labels, computes quadrants for data-x/data-y
|
|
27
|
+
* children, wires up drag-and-drop when requested, fires
|
|
28
|
+
* `quadrant-grid:move`.
|
|
29
|
+
*
|
|
30
|
+
* @attr {string} x-label - X-axis label (default: "")
|
|
31
|
+
* @attr {string} y-label - Y-axis label (default: "")
|
|
32
|
+
* @attr {string} x-low - X-axis low-end marker (default: "Low")
|
|
33
|
+
* @attr {string} x-high - X-axis high-end marker (default: "High")
|
|
34
|
+
* @attr {string} y-low - Y-axis low-end marker (default: "Low")
|
|
35
|
+
* @attr {string} y-high - Y-axis high-end marker (default: "High")
|
|
36
|
+
* @attr {string} q1-label - Top-right quadrant title
|
|
37
|
+
* @attr {string} q2-label - Top-left quadrant title
|
|
38
|
+
* @attr {string} q3-label - Bottom-left quadrant title
|
|
39
|
+
* @attr {string} q4-label - Bottom-right quadrant title
|
|
40
|
+
* @attr {boolean} draggable - Enable DnD between quadrants
|
|
41
|
+
*
|
|
42
|
+
* Children attributes:
|
|
43
|
+
* @attr {number} data-quadrant - 0..3 mapping to Q1..Q4
|
|
44
|
+
* @attr {number} data-x - 0..1 normalized x coordinate
|
|
45
|
+
* @attr {number} data-y - 0..1 normalized y coordinate
|
|
46
|
+
* @attr {string} data-id - Stable identifier for moves
|
|
47
|
+
*
|
|
48
|
+
* @fires quadrant-grid:move - { item, itemId, from, to, x?, y? }
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
52
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
53
|
+
|
|
54
|
+
class QuadrantGrid extends VBElement {
|
|
55
|
+
static #instanceCount = 0;
|
|
56
|
+
|
|
57
|
+
setup() {
|
|
58
|
+
this.#groupId = `qg-${++QuadrantGrid.#instanceCount}`;
|
|
59
|
+
|
|
60
|
+
const xLabel = this.getAttribute('x-label') || '';
|
|
61
|
+
const yLabel = this.getAttribute('y-label') || '';
|
|
62
|
+
const xLow = this.getAttribute('x-low') || 'Low';
|
|
63
|
+
const xHigh = this.getAttribute('x-high') || 'High';
|
|
64
|
+
const yLow = this.getAttribute('y-low') || 'Low';
|
|
65
|
+
const yHigh = this.getAttribute('y-high') || 'High';
|
|
66
|
+
const qLabels = [
|
|
67
|
+
this.getAttribute('q1-label') || 'Q1',
|
|
68
|
+
this.getAttribute('q2-label') || 'Q2',
|
|
69
|
+
this.getAttribute('q3-label') || 'Q3',
|
|
70
|
+
this.getAttribute('q4-label') || 'Q4',
|
|
71
|
+
];
|
|
72
|
+
const draggable = this.hasAttribute('draggable');
|
|
73
|
+
|
|
74
|
+
const children = [...this.querySelectorAll(':scope > [data-quadrant], :scope > [data-x][data-y]')];
|
|
75
|
+
|
|
76
|
+
const wrapper = document.createElement('section');
|
|
77
|
+
wrapper.className = 'qg-wrapper';
|
|
78
|
+
wrapper.setAttribute('role', 'region');
|
|
79
|
+
wrapper.setAttribute('aria-label', `${yLabel} × ${xLabel} quadrant grid`);
|
|
80
|
+
|
|
81
|
+
const yLabelEl = document.createElement('header');
|
|
82
|
+
yLabelEl.className = 'qg-y-label';
|
|
83
|
+
yLabelEl.setAttribute('aria-hidden', 'true');
|
|
84
|
+
yLabelEl.textContent = `↑ ${yLabel}`;
|
|
85
|
+
|
|
86
|
+
const yScale = document.createElement('aside');
|
|
87
|
+
yScale.className = 'qg-y-scale';
|
|
88
|
+
yScale.setAttribute('aria-hidden', 'true');
|
|
89
|
+
yScale.innerHTML = `<span>${yHigh}</span><span>${yLow}</span>`;
|
|
90
|
+
|
|
91
|
+
const grid = document.createElement('div');
|
|
92
|
+
grid.className = 'qg-grid';
|
|
93
|
+
|
|
94
|
+
const xScale = document.createElement('aside');
|
|
95
|
+
xScale.className = 'qg-x-scale';
|
|
96
|
+
xScale.setAttribute('aria-hidden', 'true');
|
|
97
|
+
xScale.innerHTML = `<span>${xLow}</span><span>${xHigh}</span>`;
|
|
98
|
+
|
|
99
|
+
const xLabelEl = document.createElement('footer');
|
|
100
|
+
xLabelEl.className = 'qg-x-label';
|
|
101
|
+
xLabelEl.setAttribute('aria-hidden', 'true');
|
|
102
|
+
xLabelEl.textContent = `${xLabel} →`;
|
|
103
|
+
|
|
104
|
+
/* Cartesian DOM order: place sections in the visual grid by index.
|
|
105
|
+
Indices 0..3 correspond to Q1..Q4. CSS grid-area positions them. */
|
|
106
|
+
for (let q = 0; q < 4; q++) {
|
|
107
|
+
const section = document.createElement('section');
|
|
108
|
+
section.className = 'qg-quadrant';
|
|
109
|
+
section.dataset.quadrantZone = String(q);
|
|
110
|
+
section.setAttribute('aria-label', qLabels[q]);
|
|
111
|
+
|
|
112
|
+
const header = document.createElement('header');
|
|
113
|
+
header.className = 'qg-quadrant-label';
|
|
114
|
+
header.textContent = qLabels[q];
|
|
115
|
+
|
|
116
|
+
section.appendChild(header);
|
|
117
|
+
|
|
118
|
+
if (draggable) {
|
|
119
|
+
const surface = document.createElement('drag-surface');
|
|
120
|
+
surface.setAttribute('group', this.#groupId);
|
|
121
|
+
surface.setAttribute('aria-label', qLabels[q]);
|
|
122
|
+
surface.setAttribute('data-layout', 'stack');
|
|
123
|
+
surface.setAttribute('data-layout-gap', 'xs');
|
|
124
|
+
section.appendChild(surface);
|
|
125
|
+
this.#surfaces[q] = surface;
|
|
126
|
+
} else {
|
|
127
|
+
const list = document.createElement('ul');
|
|
128
|
+
list.className = 'qg-items';
|
|
129
|
+
section.appendChild(list);
|
|
130
|
+
this.#lists[q] = list;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
grid.appendChild(section);
|
|
134
|
+
this.#sections[q] = section;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Distribute captured children into matching quadrants. */
|
|
138
|
+
children.forEach((/** @type {Element} */ rawChild, i) => {
|
|
139
|
+
const child = /** @type {HTMLElement} */ (rawChild);
|
|
140
|
+
const q = this.#resolveQuadrant(child);
|
|
141
|
+
const target = draggable ? this.#surfaces[q] : this.#lists[q];
|
|
142
|
+
|
|
143
|
+
if (draggable) {
|
|
144
|
+
if (!child.hasAttribute('draggable')) child.setAttribute('draggable', 'true');
|
|
145
|
+
}
|
|
146
|
+
if (!child.hasAttribute('data-id')) child.dataset.id = `qg-item-${i}`;
|
|
147
|
+
|
|
148
|
+
// Coordinate-positioned children: compute section-local coords
|
|
149
|
+
// (each quadrant spans half the grid; convert document 0..1 to
|
|
150
|
+
// section 0..1) and pin via --qg-local-x / --qg-local-y.
|
|
151
|
+
if (child.hasAttribute('data-x') && child.hasAttribute('data-y')) {
|
|
152
|
+
const x = parseFloat(child.getAttribute('data-x') ?? '');
|
|
153
|
+
const y = parseFloat(child.getAttribute('data-y') ?? '');
|
|
154
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
155
|
+
const localX = q === 0 || q === 3 ? (x - 0.5) * 2 : x * 2;
|
|
156
|
+
const localY = q === 0 || q === 1 ? (y - 0.5) * 2 : y * 2;
|
|
157
|
+
child.style.setProperty('--qg-local-x', localX.toFixed(4));
|
|
158
|
+
child.style.setProperty('--qg-local-y', localY.toFixed(4));
|
|
159
|
+
child.classList.add('qg-pinned');
|
|
160
|
+
// Deterministic per-pin hue so identical-style pins remain
|
|
161
|
+
// distinguishable even when stacked. Hash data-id (or text)
|
|
162
|
+
// to a 0..359 hue offset that CSS applies via hue-rotate.
|
|
163
|
+
if (!child.style.getPropertyValue('--qg-pin-hue')) {
|
|
164
|
+
const key = child.dataset.id || (child.textContent || '').trim();
|
|
165
|
+
child.style.setProperty('--qg-pin-hue', `${QuadrantGrid.#hueFromString(key)}deg`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Wrap non-drag children in <li> so list semantics hold.
|
|
171
|
+
if (draggable) {
|
|
172
|
+
target.appendChild(child);
|
|
173
|
+
} else {
|
|
174
|
+
const li = document.createElement('li');
|
|
175
|
+
li.appendChild(child);
|
|
176
|
+
target.appendChild(li);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/* Live region for screen-reader announcements on move. */
|
|
181
|
+
const liveRegion = document.createElement('div');
|
|
182
|
+
liveRegion.className = 'qg-live-region visually-hidden';
|
|
183
|
+
liveRegion.setAttribute('role', 'status');
|
|
184
|
+
liveRegion.setAttribute('aria-live', 'polite');
|
|
185
|
+
|
|
186
|
+
/* Assembly: 3-col grid (y-scale | grid | spacer) over 3 rows (label | grid | x-scale + label) */
|
|
187
|
+
wrapper.appendChild(yLabelEl);
|
|
188
|
+
wrapper.appendChild(yScale);
|
|
189
|
+
wrapper.appendChild(grid);
|
|
190
|
+
wrapper.appendChild(xScale);
|
|
191
|
+
wrapper.appendChild(xLabelEl);
|
|
192
|
+
this.appendChild(wrapper);
|
|
193
|
+
this.appendChild(liveRegion);
|
|
194
|
+
|
|
195
|
+
this.#grid = grid;
|
|
196
|
+
this.#liveRegion = liveRegion;
|
|
197
|
+
this.#qLabels = qLabels;
|
|
198
|
+
|
|
199
|
+
if (draggable) {
|
|
200
|
+
this.listen(this, 'drag-surface:transfer', this.#onTransfer);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Measure pinned tokens so CSS can clamp their center position by
|
|
204
|
+
half-width / half-height, keeping the bounding box inside the
|
|
205
|
+
quadrant at extreme coordinates. ResizeObserver keeps the
|
|
206
|
+
measurement live across font load, container resize, and content
|
|
207
|
+
changes. */
|
|
208
|
+
this.#observePins();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#observePins() {
|
|
212
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
213
|
+
const measure = (el) => {
|
|
214
|
+
const w = el.offsetWidth;
|
|
215
|
+
const h = el.offsetHeight;
|
|
216
|
+
if (w > 0) el.style.setProperty('--qg-pin-half-w', `${w / 2}px`);
|
|
217
|
+
if (h > 0) el.style.setProperty('--qg-pin-half-h', `${h / 2}px`);
|
|
218
|
+
};
|
|
219
|
+
this.#pinObserver = new ResizeObserver((entries) => {
|
|
220
|
+
for (const entry of entries) measure(entry.target);
|
|
221
|
+
this.#scheduleClusterPass();
|
|
222
|
+
});
|
|
223
|
+
for (const pin of this.querySelectorAll('.qg-pinned')) {
|
|
224
|
+
this.#pinObserver.observe(pin);
|
|
225
|
+
}
|
|
226
|
+
this.#scheduleClusterPass();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Coalesce cluster passes within a frame; ResizeObserver and font
|
|
230
|
+
loads can fire multiple times during initial layout. */
|
|
231
|
+
#scheduleClusterPass() {
|
|
232
|
+
if (this.#clusterRaf) return;
|
|
233
|
+
this.#clusterRaf = requestAnimationFrame(() => {
|
|
234
|
+
this.#clusterRaf = 0;
|
|
235
|
+
this.#detectClusters();
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* Group overlapping pins per quadrant so CSS can fan them on hover
|
|
240
|
+
or focus. Cluster pairs by bounding-rect intersection — robust to
|
|
241
|
+
pin size, padding, and inline-size differences. */
|
|
242
|
+
#detectClusters() {
|
|
243
|
+
for (const rawPin of this.querySelectorAll('.qg-pinned')) {
|
|
244
|
+
const pin = /** @type {HTMLElement} */ (rawPin);
|
|
245
|
+
pin.classList.remove('qg-clustered');
|
|
246
|
+
pin.style.removeProperty('--qg-cluster-i');
|
|
247
|
+
pin.style.removeProperty('--qg-cluster-n');
|
|
248
|
+
delete pin.dataset.cluster;
|
|
249
|
+
if (pin.dataset.qgTabbed !== undefined) {
|
|
250
|
+
pin.removeAttribute('tabindex');
|
|
251
|
+
delete pin.dataset.qgTabbed;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let clusterSeq = 0;
|
|
256
|
+
for (let q = 0; q < 4; q++) {
|
|
257
|
+
const section = this.#sections[q];
|
|
258
|
+
if (!section) continue;
|
|
259
|
+
const pins = /** @type {HTMLElement[]} */ ([...section.querySelectorAll('.qg-pinned')]);
|
|
260
|
+
if (pins.length < 2) continue;
|
|
261
|
+
const rects = pins.map((p) => p.getBoundingClientRect());
|
|
262
|
+
const parent = Array.from({ length: pins.length }, (_, i) => i);
|
|
263
|
+
/** @type {(i: number) => number} */
|
|
264
|
+
const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
|
|
265
|
+
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
|
|
266
|
+
for (let i = 0; i < pins.length; i++) {
|
|
267
|
+
for (let j = i + 1; j < pins.length; j++) {
|
|
268
|
+
if (QuadrantGrid.#rectsIntersect(rects[i], rects[j])) union(i, j);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** @type {Map<number, number[]>} */
|
|
272
|
+
const groups = new Map();
|
|
273
|
+
for (let i = 0; i < pins.length; i++) {
|
|
274
|
+
const root = find(i);
|
|
275
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
276
|
+
groups.get(root)?.push(i);
|
|
277
|
+
}
|
|
278
|
+
for (const members of groups.values()) {
|
|
279
|
+
if (members.length < 2) continue;
|
|
280
|
+
const id = `c${++clusterSeq}`;
|
|
281
|
+
members.forEach((idx, i) => {
|
|
282
|
+
const pin = pins[idx];
|
|
283
|
+
pin.classList.add('qg-clustered');
|
|
284
|
+
pin.dataset.cluster = id;
|
|
285
|
+
pin.style.setProperty('--qg-cluster-i', String(i));
|
|
286
|
+
pin.style.setProperty('--qg-cluster-n', String(members.length));
|
|
287
|
+
if (!pin.hasAttribute('tabindex') && !pin.matches('a, button, [contenteditable]')) {
|
|
288
|
+
pin.setAttribute('tabindex', '0');
|
|
289
|
+
pin.dataset.qgTabbed = '';
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
teardown() {
|
|
297
|
+
this.#pinObserver?.disconnect();
|
|
298
|
+
this.#pinObserver = null;
|
|
299
|
+
if (this.#clusterRaf) cancelAnimationFrame(this.#clusterRaf);
|
|
300
|
+
this.#clusterRaf = 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
static #rectsIntersect(a, b) {
|
|
304
|
+
return !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* Stable string hash → hue degree in [0, 360). djb2-ish; small and
|
|
308
|
+
deterministic across runs so per-pin tints don't flicker on
|
|
309
|
+
re-render. */
|
|
310
|
+
static #hueFromString(s) {
|
|
311
|
+
let h = 5381;
|
|
312
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
313
|
+
return ((h % 360) + 360) % 360;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#onTransfer = (e) => {
|
|
317
|
+
const { item, fromSurface, toSurface } = e.detail;
|
|
318
|
+
const from = this.#findQuadrantForSurface(fromSurface);
|
|
319
|
+
const to = this.#findQuadrantForSurface(toSurface);
|
|
320
|
+
if (from == null || to == null) return;
|
|
321
|
+
|
|
322
|
+
item.setAttribute('data-quadrant', String(to));
|
|
323
|
+
this.#announce(`Moved ${this.#itemLabel(item)} to ${this.#qLabels[to]}`);
|
|
324
|
+
|
|
325
|
+
this.dispatchEvent(new CustomEvent('quadrant-grid:move', {
|
|
326
|
+
bubbles: true,
|
|
327
|
+
detail: {
|
|
328
|
+
item,
|
|
329
|
+
itemId: item.dataset.id,
|
|
330
|
+
from,
|
|
331
|
+
to,
|
|
332
|
+
},
|
|
333
|
+
}));
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
#resolveQuadrant(child) {
|
|
337
|
+
const q = parseInt(child.getAttribute('data-quadrant'), 10);
|
|
338
|
+
if (q >= 0 && q <= 3) return q;
|
|
339
|
+
if (child.hasAttribute('data-x') && child.hasAttribute('data-y')) {
|
|
340
|
+
const x = parseFloat(child.getAttribute('data-x'));
|
|
341
|
+
const y = parseFloat(child.getAttribute('data-y'));
|
|
342
|
+
return QuadrantGrid.quadrantFor(x, y);
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Map normalized (x, y) coordinates in [0..1] to a Cartesian quadrant
|
|
349
|
+
* index 0..3 (Q1=top-right, Q2=top-left, Q3=bottom-left, Q4=bottom-right).
|
|
350
|
+
* The midpoint (0.5) belongs to the higher quadrant.
|
|
351
|
+
*/
|
|
352
|
+
static quadrantFor(x, y) {
|
|
353
|
+
const right = x >= 0.5;
|
|
354
|
+
const top = y >= 0.5;
|
|
355
|
+
if (top && right) return 0;
|
|
356
|
+
if (top && !right) return 1;
|
|
357
|
+
if (!top && !right) return 2;
|
|
358
|
+
return 3;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#findQuadrantForSurface(surface) {
|
|
362
|
+
for (let q = 0; q < 4; q++) {
|
|
363
|
+
if (this.#surfaces[q] === surface) return q;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#itemLabel(item) {
|
|
369
|
+
return (item.textContent || '').trim().split('\n')[0] || 'item';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#announce(message) {
|
|
373
|
+
if (this.#liveRegion) this.#liveRegion.textContent = message;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#groupId = '';
|
|
377
|
+
/** @type {Record<number, HTMLElement>} */
|
|
378
|
+
#surfaces = {};
|
|
379
|
+
/** @type {Record<number, HTMLElement>} */
|
|
380
|
+
#lists = {};
|
|
381
|
+
/** @type {Record<number, HTMLElement>} */
|
|
382
|
+
#sections = {};
|
|
383
|
+
/** @type {HTMLElement | null} */
|
|
384
|
+
#grid = null;
|
|
385
|
+
/** @type {HTMLElement | null} */
|
|
386
|
+
#liveRegion = null;
|
|
387
|
+
/** @type {string[]} */
|
|
388
|
+
#qLabels = [];
|
|
389
|
+
/** @type {ResizeObserver | null} */
|
|
390
|
+
#pinObserver = null;
|
|
391
|
+
/** @type {number} */
|
|
392
|
+
#clusterRaf = 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
registerComponent('quadrant-grid', QuadrantGrid);
|
|
396
|
+
|
|
397
|
+
export { QuadrantGrid };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Quadrant Grid — Static Fallback</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; }
|
|
9
|
+
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; max-width: 36rem; }
|
|
10
|
+
dt { font-weight: 600; }
|
|
11
|
+
dd { margin: 0; color: #555; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<!--
|
|
16
|
+
Static fallback for <quadrant-grid>.
|
|
17
|
+
|
|
18
|
+
Upgrade relationship:
|
|
19
|
+
- Without JS or CSS: the <dl> below remains a readable list of
|
|
20
|
+
quadrant titles and their items. Children with data-quadrant are
|
|
21
|
+
grouped in author order.
|
|
22
|
+
- With CSS but no JS: the :not(:defined) selector keeps the host
|
|
23
|
+
visible; without the grid layout the items appear in source order
|
|
24
|
+
grouped under their data-quadrant heading.
|
|
25
|
+
- With JS: the component upgrades, builds the 2x2 grid with axis
|
|
26
|
+
labels, distributes children into quadrant sections, optionally
|
|
27
|
+
wires <drag-surface> for moves, and fires quadrant-grid:move.
|
|
28
|
+
-->
|
|
29
|
+
|
|
30
|
+
<quadrant-grid
|
|
31
|
+
x-label="Effort"
|
|
32
|
+
y-label="Impact"
|
|
33
|
+
q1-label="Big Bets"
|
|
34
|
+
q2-label="Quick Wins"
|
|
35
|
+
q3-label="Fill-Ins"
|
|
36
|
+
q4-label="Money Pit">
|
|
37
|
+
<dl>
|
|
38
|
+
<dt>Big Bets</dt>
|
|
39
|
+
<dd>High impact, high effort. Long-term initiatives.</dd>
|
|
40
|
+
<dt>Quick Wins</dt>
|
|
41
|
+
<dd>High impact, low effort. Do these first.</dd>
|
|
42
|
+
<dt>Fill-Ins</dt>
|
|
43
|
+
<dd>Low impact, low effort. Background work.</dd>
|
|
44
|
+
<dt>Money Pit</dt>
|
|
45
|
+
<dd>Low impact, high effort. Avoid.</dd>
|
|
46
|
+
</dl>
|
|
47
|
+
</quadrant-grid>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|