@grainulation/harvest 1.1.1 → 1.1.2
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/package.json +1 -2
- package/lib/templates.js +0 -95
- package/public/index.html +0 -982
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grainulation/harvest",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Analytics and retrospective layer for research sprints -- learn from every decision you've made",
|
|
5
5
|
"main": "lib/analyzer.js",
|
|
6
6
|
"exports": {
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"files": [
|
|
37
37
|
"bin/",
|
|
38
38
|
"lib/",
|
|
39
|
-
"public/",
|
|
40
39
|
"templates/",
|
|
41
40
|
"README.md",
|
|
42
41
|
"LICENSE",
|
package/lib/templates.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* harvest -> barn edge: template discovery for report formatting.
|
|
5
|
-
*
|
|
6
|
-
* If barn is available (filesystem or HTTP), harvest can offer its
|
|
7
|
-
* templates as alternative report formats. Graceful fallback to
|
|
8
|
-
* harvest's built-in formatting when barn is not reachable.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const fs = require("node:fs");
|
|
12
|
-
const path = require("node:path");
|
|
13
|
-
const http = require("node:http");
|
|
14
|
-
|
|
15
|
-
const BARN_PORT = 9093;
|
|
16
|
-
const BARN_SIBLINGS = [
|
|
17
|
-
path.join(__dirname, "..", "..", "barn", "templates"),
|
|
18
|
-
path.join(__dirname, "..", "..", "..", "barn", "templates"),
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Probe barn via filesystem (sibling checkout) or localhost API.
|
|
23
|
-
* Returns { available: true, templates: [...] } or { available: false }.
|
|
24
|
-
*/
|
|
25
|
-
function discoverTemplates() {
|
|
26
|
-
// Strategy 1: filesystem sibling
|
|
27
|
-
for (const dir of BARN_SIBLINGS) {
|
|
28
|
-
if (fs.existsSync(dir)) {
|
|
29
|
-
try {
|
|
30
|
-
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".html"));
|
|
31
|
-
const templates = files.map((f) => {
|
|
32
|
-
const content = fs.readFileSync(path.join(dir, f), "utf8");
|
|
33
|
-
const placeholders = [
|
|
34
|
-
...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || []),
|
|
35
|
-
];
|
|
36
|
-
const commentMatch = content.match(/<!--\s*(.*?)\s*-->/);
|
|
37
|
-
return {
|
|
38
|
-
name: f.replace(".html", ""),
|
|
39
|
-
placeholders,
|
|
40
|
-
description: commentMatch ? commentMatch[1] : "",
|
|
41
|
-
source: "filesystem",
|
|
42
|
-
};
|
|
43
|
-
});
|
|
44
|
-
return { available: true, templates, source: dir };
|
|
45
|
-
} catch {
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return { available: false, templates: [] };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Async probe: try barn's HTTP API for template list.
|
|
55
|
-
* Falls back to filesystem discovery if the server is not running.
|
|
56
|
-
*/
|
|
57
|
-
function discoverTemplatesAsync() {
|
|
58
|
-
return new Promise((resolve) => {
|
|
59
|
-
const req = http.get(
|
|
60
|
-
`http://127.0.0.1:${BARN_PORT}/api/state`,
|
|
61
|
-
{ timeout: 2000 },
|
|
62
|
-
(res) => {
|
|
63
|
-
let body = "";
|
|
64
|
-
res.on("data", (chunk) => {
|
|
65
|
-
body += chunk;
|
|
66
|
-
});
|
|
67
|
-
res.on("end", () => {
|
|
68
|
-
try {
|
|
69
|
-
const state = JSON.parse(body);
|
|
70
|
-
const templates = (state.templates || []).map((t) => ({
|
|
71
|
-
name: t.name,
|
|
72
|
-
placeholders: t.placeholders || [],
|
|
73
|
-
description: t.description || "",
|
|
74
|
-
source: "http",
|
|
75
|
-
}));
|
|
76
|
-
resolve({
|
|
77
|
-
available: true,
|
|
78
|
-
templates,
|
|
79
|
-
source: `http://127.0.0.1:${BARN_PORT}`,
|
|
80
|
-
});
|
|
81
|
-
} catch {
|
|
82
|
-
resolve(discoverTemplates());
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
},
|
|
86
|
-
);
|
|
87
|
-
req.on("error", () => resolve(discoverTemplates()));
|
|
88
|
-
req.on("timeout", () => {
|
|
89
|
-
req.destroy();
|
|
90
|
-
resolve(discoverTemplates());
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
module.exports = { discoverTemplates, discoverTemplatesAsync, BARN_PORT };
|
package/public/index.html
DELETED
|
@@ -1,982 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en" dir="auto" data-tool="harvest">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Harvest</title>
|
|
7
|
-
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%230a0e1a'/><text x='32' y='34' text-anchor='middle' dominant-baseline='central' fill='%23fb923c' font-family='-apple-system,system-ui,sans-serif' font-size='34' font-weight='800'>H</text></svg>">
|
|
8
|
-
<style>
|
|
9
|
-
/* -- grainulation tokens (inline, zero deps) -- */
|
|
10
|
-
:root {
|
|
11
|
-
--bg: #0a0e1a; --bg2: #111827; --bg3: #1e293b; --bg4: #334155;
|
|
12
|
-
--fg: #e2e8f0; --fg2: #94a3b8; --fg3: #64748b;
|
|
13
|
-
--border: #1e293b; --border-subtle: rgba(255,255,255,0.08);
|
|
14
|
-
--green: #34d399; --red: #f87171; --blue: #60a5fa; --purple: #a78bfa; --orange: #fb923c; --cyan: #22d3ee;
|
|
15
|
-
--font-sans: -apple-system,BlinkMacSystemFont,'Segoe UI','Inter',sans-serif;
|
|
16
|
-
--font-mono: 'SF Mono','Cascadia Code','JetBrains Mono','Fira Code',monospace;
|
|
17
|
-
--radius: 8px; --radius-sm: 4px; --radius-lg: 12px;
|
|
18
|
-
--space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 24px; --space-2xl: 32px;
|
|
19
|
-
--transition-fast: 0.1s ease; --transition-base: 0.15s ease;
|
|
20
|
-
--accent: #fb923c; --accent-light: #fdba74; --accent-dim: rgba(251,146,60,0.10); --accent-border: rgba(251,146,60,0.25);
|
|
21
|
-
}
|
|
22
|
-
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23
|
-
html, body { height: 100%; overflow: hidden; }
|
|
24
|
-
body {
|
|
25
|
-
font-family: var(--font-sans); background: var(--bg); color: var(--fg);
|
|
26
|
-
font-size: 13px; line-height: 1.5;
|
|
27
|
-
-webkit-font-smoothing: antialiased;
|
|
28
|
-
overflow-x: hidden;
|
|
29
|
-
}
|
|
30
|
-
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
31
|
-
::-webkit-scrollbar-track { background: transparent; }
|
|
32
|
-
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
|
|
33
|
-
|
|
34
|
-
/* -- layout -- */
|
|
35
|
-
.app { display: flex; flex-direction: column; height: 100vh; }
|
|
36
|
-
.topbar {
|
|
37
|
-
padding: 4px var(--space-xl);
|
|
38
|
-
background: rgba(255,255,255,0.08); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
39
|
-
border-bottom: 1px solid var(--border);
|
|
40
|
-
display: flex; align-items: center; gap: 10px;
|
|
41
|
-
flex-shrink: 0;
|
|
42
|
-
}
|
|
43
|
-
.topbar h1 { font-size: 15px; font-weight: 600; }
|
|
44
|
-
.topbar .tool-badge {
|
|
45
|
-
font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
46
|
-
padding: 2px 6px; border-radius: var(--radius-sm);
|
|
47
|
-
background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent-border);
|
|
48
|
-
}
|
|
49
|
-
.sprint-select {
|
|
50
|
-
padding: 6px 28px 6px 12px;
|
|
51
|
-
border-radius: var(--radius);
|
|
52
|
-
background: var(--bg3);
|
|
53
|
-
border: 1px solid var(--border);
|
|
54
|
-
color: var(--fg);
|
|
55
|
-
font-size: 12px;
|
|
56
|
-
font-family: var(--font-sans);
|
|
57
|
-
outline: none;
|
|
58
|
-
cursor: pointer;
|
|
59
|
-
max-width: 480px;
|
|
60
|
-
text-overflow: ellipsis;
|
|
61
|
-
-webkit-appearance: none;
|
|
62
|
-
appearance: none;
|
|
63
|
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
64
|
-
background-repeat: no-repeat;
|
|
65
|
-
background-position: right 10px center;
|
|
66
|
-
}
|
|
67
|
-
.conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
|
68
|
-
.conn-dot.disconnected { background: var(--red); }
|
|
69
|
-
.reconnect-banner { position:fixed;top:0;left:0;right:0;z-index:9999;padding:8px 16px;background:#92400e;color:#fbbf24;font-size:12px;text-align:center;transform:translateY(-100%);transition:transform .3s;font-family:system-ui,sans-serif }
|
|
70
|
-
.reconnect-banner.visible { transform:translateY(0) }
|
|
71
|
-
.reconnect-banner button { background:none;border:1px solid #fbbf24;color:#fbbf24;padding:2px 10px;border-radius:4px;cursor:pointer;font-size:11px;margin-inline-start:8px }
|
|
72
|
-
|
|
73
|
-
/* -- controls -- */
|
|
74
|
-
.controls { display:flex;align-items:center;gap:var(--space-sm);margin-inline-start:auto;flex-wrap:wrap }
|
|
75
|
-
.controls select,.controls input[type="date"] { padding:6px 10px;border-radius:var(--radius);background:var(--bg3);border:1px solid var(--border);color:var(--fg);font-size:12px;font-family:var(--font-sans);outline:none;cursor:pointer }
|
|
76
|
-
.controls select:focus,.controls input:focus { border-color:var(--accent-border) }
|
|
77
|
-
.controls label { font-size:10px;color:var(--fg3);text-transform:uppercase;letter-spacing:0.3px }
|
|
78
|
-
.refresh-btn { padding:6px 12px;border-radius:var(--radius);background:var(--accent-dim);color:var(--accent);border:1px solid var(--accent-border);cursor:pointer;font-size:11px;transition:background var(--transition-fast) }
|
|
79
|
-
.refresh-btn:hover { background:var(--accent-border) }
|
|
80
|
-
.refresh-btn:focus-visible { outline:2px solid var(--accent);outline-offset:2px }
|
|
81
|
-
|
|
82
|
-
/* -- dashboard -- */
|
|
83
|
-
.dashboard { flex:1;overflow-y:auto;padding:var(--space-xl) }
|
|
84
|
-
.cards { display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:var(--space-lg);margin-bottom:var(--space-xl) }
|
|
85
|
-
.card { padding:var(--space-lg);background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-lg) }
|
|
86
|
-
.card-title { font-size:10px;text-transform:uppercase;letter-spacing:0.5px;color:var(--fg3);margin-bottom:var(--space-md) }
|
|
87
|
-
.card-value { font-size:28px;font-weight:700;color:var(--accent) }
|
|
88
|
-
.card-sub { font-size:11px;color:var(--fg2);margin-top:4px }
|
|
89
|
-
.section { margin-bottom:var(--space-xl);background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-lg);padding:var(--space-lg) }
|
|
90
|
-
.section-title { font-size:13px;font-weight:600;margin-bottom:var(--space-lg);display:flex;align-items:center;gap:var(--space-sm) }
|
|
91
|
-
.section-title::after { content:'';flex:1;height:1px;background:var(--border) }
|
|
92
|
-
|
|
93
|
-
/* -- bar chart -- */
|
|
94
|
-
.bar-chart { display:flex;flex-direction:column;gap:var(--space-sm) }
|
|
95
|
-
.bar-row { display:flex;align-items:center;gap:var(--space-md) }
|
|
96
|
-
.bar-label { width:100px;text-align:end;font-size:11px;color:var(--fg2);flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
|
|
97
|
-
.bar-track { flex:1;height:20px;background:var(--bg);border-radius:var(--radius-sm);overflow:hidden }
|
|
98
|
-
.bar-fill { height:100%;border-radius:var(--radius-sm);transition:width .3s ease;display:flex;align-items:center;padding-inline-start:6px;font-size:10px;color:var(--bg);font-weight:600;min-width:24px }
|
|
99
|
-
.bar-count { font-size:10px;color:var(--fg3);width:36px;text-align:end;flex-shrink:0 }
|
|
100
|
-
|
|
101
|
-
/* bar colors by type */
|
|
102
|
-
.bar-fill.constraint { background: #f87171; }
|
|
103
|
-
.bar-fill.risk { background: #fb923c; }
|
|
104
|
-
.bar-fill.recommendation { background: #60a5fa; }
|
|
105
|
-
.bar-fill.factual { background: #34d399; }
|
|
106
|
-
.bar-fill.estimate { background: #a78bfa; }
|
|
107
|
-
.bar-fill.feedback { background: #fbbf24; }
|
|
108
|
-
.bar-fill.unknown { background: var(--bg4); }
|
|
109
|
-
|
|
110
|
-
/* evidence bars */
|
|
111
|
-
.bar-fill.stated { background: #64748b; }
|
|
112
|
-
.bar-fill.web { background: #94a3b8; }
|
|
113
|
-
.bar-fill.documented { background: #60a5fa; }
|
|
114
|
-
.bar-fill.tested { background: #34d399; }
|
|
115
|
-
.bar-fill.production { background: #22d3ee; }
|
|
116
|
-
|
|
117
|
-
/* -- stacked bar -- */
|
|
118
|
-
.stacked-bar { display:flex;height:28px;border-radius:var(--radius-sm);overflow:hidden;margin-bottom:var(--space-sm) }
|
|
119
|
-
.stacked-segment { height:100%;transition:width .3s ease }
|
|
120
|
-
.stacked-legend { display:flex;flex-wrap:wrap;gap:var(--space-md);margin-top:var(--space-sm) }
|
|
121
|
-
.stacked-legend-item { display:flex;align-items:center;gap:4px;font-size:11px;color:var(--fg2) }
|
|
122
|
-
.stacked-legend-swatch { width:10px;height:10px;border-radius:2px;flex-shrink:0 }
|
|
123
|
-
/* -- velocity -- */
|
|
124
|
-
.velocity-chart-wrap { margin-bottom:var(--space-lg) }
|
|
125
|
-
.velocity-chart-wrap svg { width:100% }
|
|
126
|
-
.velocity-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:var(--space-lg) }
|
|
127
|
-
.velocity-item { text-align:center }
|
|
128
|
-
.velocity-val { font-size:22px;font-weight:700;color:var(--accent) }
|
|
129
|
-
.velocity-label { font-size:10px;color:var(--fg3);text-transform:uppercase;letter-spacing:0.3px }
|
|
130
|
-
/* -- patterns -- */
|
|
131
|
-
.pattern-list { display:flex;flex-direction:column;gap:var(--space-sm) }
|
|
132
|
-
.pattern-item { padding:var(--space-md);background:var(--bg);border-radius:var(--radius);border-inline-start:3px solid var(--green) }
|
|
133
|
-
.pattern-item.anti { border-inline-start-color:var(--red) }
|
|
134
|
-
.pattern-item .pat-name { font-size:12px;font-weight:600;color:var(--fg);margin-bottom:2px }
|
|
135
|
-
.pattern-item .pat-desc { font-size:11px;color:var(--fg2) }
|
|
136
|
-
/* -- calibration -- */
|
|
137
|
-
.cal-table { width:100%;border-collapse:collapse;font-size:12px }
|
|
138
|
-
.cal-table th { text-align:start;padding:var(--space-sm) var(--space-md);font-size:10px;text-transform:uppercase;letter-spacing:0.3px;color:var(--fg3);border-bottom:1px solid var(--border) }
|
|
139
|
-
.cal-table td { padding:var(--space-sm) var(--space-md);border-bottom:1px solid var(--border-subtle);color:var(--fg2) }
|
|
140
|
-
.cal-table tr:last-child td { border-bottom:none }
|
|
141
|
-
.cal-accurate { color:var(--green) } .cal-inaccurate { color:var(--red) } .cal-unknown { color:var(--fg3) }
|
|
142
|
-
.cal-summary-row { display:flex;gap:var(--space-xl);margin-bottom:var(--space-lg);flex-wrap:wrap }
|
|
143
|
-
.cal-summary-item { text-align:center }
|
|
144
|
-
.cal-summary-val { font-size:20px;font-weight:700 }
|
|
145
|
-
.cal-summary-label { font-size:10px;color:var(--fg3);text-transform:uppercase }
|
|
146
|
-
|
|
147
|
-
/* -- decay -- */
|
|
148
|
-
.decay-list { display:flex;flex-direction:column;gap:var(--space-sm) }
|
|
149
|
-
.decay-alert { padding:var(--space-md);background:var(--bg);border-radius:var(--radius);border-inline-start:3px solid var(--red) }
|
|
150
|
-
.decay-alert.stale { border-inline-start-color:var(--orange) }
|
|
151
|
-
.decay-alert.unresolved { border-inline-start-color:var(--purple) }
|
|
152
|
-
.decay-alert .da-header { display:flex;align-items:center;gap:var(--space-sm);font-size:11px;color:var(--fg2);margin-bottom:4px }
|
|
153
|
-
.decay-alert .da-id { font-family:var(--font-mono);font-size:10px;color:var(--fg3) }
|
|
154
|
-
.decay-alert .da-reason { font-size:11px;color:var(--fg3) }
|
|
155
|
-
.type-badge { display:inline-block;padding:1px 6px;border-radius:var(--radius-sm);font-size:10px;font-weight:500 }
|
|
156
|
-
.type-badge.constraint { background:rgba(248,113,113,.15);color:#f87171 }
|
|
157
|
-
.type-badge.risk { background:rgba(251,146,60,.15);color:#fb923c }
|
|
158
|
-
.type-badge.recommendation { background:rgba(96,165,250,.15);color:#60a5fa }
|
|
159
|
-
.type-badge.factual { background:rgba(52,211,153,.15);color:#34d399 }
|
|
160
|
-
.type-badge.estimate { background:rgba(167,139,250,.15);color:#a78bfa }
|
|
161
|
-
.type-badge.feedback { background:rgba(251,191,36,.15);color:#fbbf24 }
|
|
162
|
-
.insight-box { margin-top:var(--space-lg);font-size:12px;color:var(--fg2);padding:var(--space-md);background:var(--bg);border-radius:var(--radius);border-inline-start:2px solid var(--accent-border) }
|
|
163
|
-
.empty-state { text-align:center;padding:60px var(--space-xl);color:var(--fg3) }
|
|
164
|
-
.empty-state h2 { font-size:16px;color:var(--fg2);margin-bottom:var(--space-md) }
|
|
165
|
-
.empty-state p { font-size:12px;max-width:400px;margin:0 auto }
|
|
166
|
-
|
|
167
|
-
/* -- a11y + responsive -- */
|
|
168
|
-
.skip-link{position:absolute;top:-40px;inset-inline-start:0;background:var(--accent);color:#000;padding:8px 16px;z-index:10000;font-size:14px;font-weight:600;transition:top .2s}
|
|
169
|
-
.skip-link:focus{top:0}
|
|
170
|
-
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
|
|
171
|
-
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
|
172
|
-
@media(max-width:768px){
|
|
173
|
-
.topbar{flex-wrap:wrap;padding:var(--space-md);gap:var(--space-sm)}
|
|
174
|
-
.topbar h1{font-size:13px}
|
|
175
|
-
.sprint-select{max-width:100%;font-size:11px}
|
|
176
|
-
.controls{margin-inline-start:0;width:100%;justify-content:flex-start}
|
|
177
|
-
.controls select,.controls input[type="date"]{font-size:11px;padding:5px 8px}
|
|
178
|
-
.dashboard{padding:var(--space-md)}
|
|
179
|
-
.cards{grid-template-columns:repeat(2,1fr);gap:var(--space-md)}
|
|
180
|
-
.card{padding:var(--space-md)}
|
|
181
|
-
.card-value{font-size:22px}
|
|
182
|
-
.section{padding:var(--space-md)}
|
|
183
|
-
.section-title{font-size:12px}
|
|
184
|
-
.bar-label{width:70px;font-size:10px}
|
|
185
|
-
.bar-count{width:28px;font-size:9px}
|
|
186
|
-
.velocity-grid{grid-template-columns:repeat(2,1fr)}
|
|
187
|
-
.velocity-val{font-size:18px}
|
|
188
|
-
.cal-table{font-size:11px;display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
|
189
|
-
.cal-table th,.cal-table td{padding:var(--space-xs) var(--space-sm);white-space:nowrap}
|
|
190
|
-
.cal-summary-row{gap:var(--space-lg)}
|
|
191
|
-
.stacked-legend{gap:var(--space-sm)}
|
|
192
|
-
}
|
|
193
|
-
@media(max-width:480px){
|
|
194
|
-
.topbar{padding:var(--space-sm)}
|
|
195
|
-
.dashboard{padding:var(--space-sm)}
|
|
196
|
-
.cards{grid-template-columns:1fr;gap:var(--space-sm)}
|
|
197
|
-
.bar-label{width:56px;font-size:9px}
|
|
198
|
-
.velocity-grid{grid-template-columns:1fr}
|
|
199
|
-
}
|
|
200
|
-
</style>
|
|
201
|
-
</head>
|
|
202
|
-
<body>
|
|
203
|
-
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
204
|
-
<div id="live-status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
|
|
205
|
-
<div class="reconnect-banner" id="reconnectBanner" role="status" aria-live="polite"></div>
|
|
206
|
-
<div class="app">
|
|
207
|
-
<header class="topbar" role="banner">
|
|
208
|
-
<canvas id="grainLogo" width="256" height="256" style="display:block"></canvas>
|
|
209
|
-
<div style="flex:1"></div>
|
|
210
|
-
<select id="sprintSelect" class="sprint-select" aria-label="Select sprint"><option value="__all">All sprints</option></select>
|
|
211
|
-
<span class="conn-dot" id="connDot" role="status" aria-label="Connection status"></span>
|
|
212
|
-
</header>
|
|
213
|
-
|
|
214
|
-
<main class="dashboard" id="main-content" role="main" aria-label="Harvest analytics workspace">
|
|
215
|
-
<div class="empty-state" id="emptyState">
|
|
216
|
-
<h2>Harvest</h2>
|
|
217
|
-
<p>Point harvest at a directory containing sprint data (claims.json) to begin analysis. Use <code>harvest serve --root /path/to/sprints</code>.</p>
|
|
218
|
-
</div>
|
|
219
|
-
</main>
|
|
220
|
-
</div>
|
|
221
|
-
|
|
222
|
-
<script>
|
|
223
|
-
(function() {
|
|
224
|
-
'use strict';
|
|
225
|
-
|
|
226
|
-
var API = '';
|
|
227
|
-
var sprints = [];
|
|
228
|
-
var selectedSprint = '__all';
|
|
229
|
-
var dateFrom = '';
|
|
230
|
-
var dateTo = '';
|
|
231
|
-
|
|
232
|
-
// -- SSE with exponential backoff --
|
|
233
|
-
var sseRetryCount = 0;
|
|
234
|
-
var sseSource = null;
|
|
235
|
-
|
|
236
|
-
function showBanner(count) {
|
|
237
|
-
var b = document.getElementById('reconnectBanner');
|
|
238
|
-
if (count > 5) {
|
|
239
|
-
b.innerHTML = 'Connection lost. <button onclick="sseRetryCount=0;connectSSE()">Retry now</button>';
|
|
240
|
-
} else if (count > 1) {
|
|
241
|
-
b.textContent = 'Reconnecting (attempt ' + count + ')...';
|
|
242
|
-
} else {
|
|
243
|
-
b.textContent = 'Reconnecting...';
|
|
244
|
-
}
|
|
245
|
-
b.classList.add('visible');
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function hideBanner() { document.getElementById('reconnectBanner').classList.remove('visible'); }
|
|
249
|
-
|
|
250
|
-
function connectSSE() {
|
|
251
|
-
var dot = document.getElementById('connDot');
|
|
252
|
-
sseSource = new EventSource(API + '/events');
|
|
253
|
-
sseSource.onopen = function() {
|
|
254
|
-
sseRetryCount = 0;
|
|
255
|
-
dot.classList.remove('disconnected');
|
|
256
|
-
dot.setAttribute('aria-label', 'Connected');
|
|
257
|
-
if (window._grainSetState) window._grainSetState('idle');
|
|
258
|
-
};
|
|
259
|
-
sseSource.onerror = function() {
|
|
260
|
-
sseSource.close();
|
|
261
|
-
dot.classList.add('disconnected');
|
|
262
|
-
dot.setAttribute('aria-label', 'Disconnected');
|
|
263
|
-
if (window._grainSetState) window._grainSetState('orbit');
|
|
264
|
-
var delay = Math.min(30000, 1000 * Math.pow(2, sseRetryCount)) + Math.random() * 1000;
|
|
265
|
-
sseRetryCount++;
|
|
266
|
-
setTimeout(connectSSE, delay);
|
|
267
|
-
};
|
|
268
|
-
sseSource.onmessage = function(e) {
|
|
269
|
-
try {
|
|
270
|
-
var msg = JSON.parse(e.data);
|
|
271
|
-
if (msg.type === 'state') {
|
|
272
|
-
loadSprints();
|
|
273
|
-
announce('Sprint data refreshed');
|
|
274
|
-
}
|
|
275
|
-
} catch(x) {}
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function announce(text) {
|
|
280
|
-
var ls = document.getElementById('live-status');
|
|
281
|
-
if (ls) ls.textContent = text;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// -- Load sprints --
|
|
285
|
-
function loadSprints() {
|
|
286
|
-
fetch(API + '/api/sprints')
|
|
287
|
-
.then(function(r) { return r.json(); })
|
|
288
|
-
.then(function(data) {
|
|
289
|
-
sprints = data.sprints || [];
|
|
290
|
-
renderSprintSelector();
|
|
291
|
-
loadDashboard();
|
|
292
|
-
})
|
|
293
|
-
.catch(function(err) { console.error('Failed to load sprints:', err); });
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function renderSprintSelector() {
|
|
297
|
-
var sel = document.getElementById('sprintSelect');
|
|
298
|
-
sel.innerHTML = '<option value="__all">All sprints (' + sprints.length + ')</option>' +
|
|
299
|
-
sprints.map(function(s) {
|
|
300
|
-
return '<option value="' + esc(s.name) + '"' + (selectedSprint === s.name ? ' selected' : '') + '>' + esc(s.name) + ' (' + s.claimCount + ' claims)</option>';
|
|
301
|
-
}).join('');
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// -- Load dashboard data --
|
|
305
|
-
function loadDashboard() {
|
|
306
|
-
var db = document.getElementById('main-content');
|
|
307
|
-
if (sprints.length === 0) {
|
|
308
|
-
db.innerHTML = '<div class="empty-state"><h2>No Sprints Found</h2><p>No claims.json files found in the root directory. Start harvest with --root pointing to a sprints directory.</p></div>';
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (selectedSprint === '__all') {
|
|
313
|
-
loadFullDashboard();
|
|
314
|
-
} else {
|
|
315
|
-
loadSprintAnalysis(selectedSprint);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function loadFullDashboard() {
|
|
320
|
-
var db = document.getElementById('main-content');
|
|
321
|
-
db.innerHTML = '<div style="padding:40px;color:var(--fg3);">Analyzing...</div>';
|
|
322
|
-
|
|
323
|
-
Promise.all([
|
|
324
|
-
fetch(API + '/api/dashboard').then(function(r) { return r.json(); }),
|
|
325
|
-
fetch(API + '/api/calibration').then(function(r) { return r.json(); }),
|
|
326
|
-
]).then(function(results) {
|
|
327
|
-
var dash = results[0];
|
|
328
|
-
var calData = results[1];
|
|
329
|
-
renderDashboard(dash, dash.decay, calData.calibration);
|
|
330
|
-
announce('Dashboard loaded: ' + dash.totalClaims + ' claims across ' + dash.sprintCount + ' sprints');
|
|
331
|
-
}).catch(function(err) {
|
|
332
|
-
db.innerHTML = '<div style="padding:40px;color:var(--red);">Error: ' + esc(err.message) + '</div>';
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function loadSprintAnalysis(name) {
|
|
337
|
-
var db = document.getElementById('main-content');
|
|
338
|
-
db.innerHTML = '<div style="padding:40px;color:var(--fg3);">Analyzing...</div>';
|
|
339
|
-
|
|
340
|
-
Promise.all([
|
|
341
|
-
fetch(API + '/api/analysis/' + encodeURIComponent(name)).then(function(r) { return r.json(); }),
|
|
342
|
-
fetch(API + '/api/decay?days=30').then(function(r) { return r.json(); }),
|
|
343
|
-
fetch(API + '/api/calibration').then(function(r) { return r.json(); }),
|
|
344
|
-
]).then(function(results) {
|
|
345
|
-
var data = results[0];
|
|
346
|
-
var decayData = results[1];
|
|
347
|
-
var calData = results[2];
|
|
348
|
-
renderDashboard({
|
|
349
|
-
sprintCount: 1,
|
|
350
|
-
totalClaims: data.analysis.summary.totalClaims,
|
|
351
|
-
analysis: data.analysis,
|
|
352
|
-
velocity: data.velocity,
|
|
353
|
-
}, decayData.decay, calData.calibration);
|
|
354
|
-
announce('Sprint "' + name + '" loaded');
|
|
355
|
-
}).catch(function(err) {
|
|
356
|
-
db.innerHTML = '<div style="padding:40px;color:var(--red);">Error: ' + esc(err.message) + '</div>';
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// -- Render dashboard --
|
|
361
|
-
function renderDashboard(dash, decay, calibration) {
|
|
362
|
-
var db = document.getElementById('main-content');
|
|
363
|
-
var analysis = dash.analysis;
|
|
364
|
-
var velocity = dash.velocity;
|
|
365
|
-
|
|
366
|
-
if (!analysis) {
|
|
367
|
-
db.innerHTML = '<div class="empty-state"><h2>No Data</h2><p>No analysis data available for the selected sprint(s).</p></div>';
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
var summary = analysis.summary || {};
|
|
372
|
-
var velSummary = velocity ? velocity.summary || {} : {};
|
|
373
|
-
var decaySummary = decay ? decay.summary || {} : {};
|
|
374
|
-
var calSummary = calibration ? calibration.summary || {} : {};
|
|
375
|
-
var decayTotal = (decaySummary.staleCount || 0) + (decaySummary.decayingCount || 0) + (decaySummary.unresolvedCount || 0);
|
|
376
|
-
|
|
377
|
-
var html = '';
|
|
378
|
-
|
|
379
|
-
// -- Metric cards --
|
|
380
|
-
html += '<div class="cards" role="region" aria-label="Key metrics">';
|
|
381
|
-
html += metricCard('Total Claims', dash.totalClaims, summary.averageClaimsPerSprint ? (summary.averageClaimsPerSprint + ' avg/sprint') : (dash.sprintCount + ' sprint(s)'));
|
|
382
|
-
html += metricCard('Evidence Quality', evidenceScore(analysis.evidenceDistribution), evidenceScoreLabel(analysis.evidenceDistribution));
|
|
383
|
-
html += metricCard('Velocity', velSummary.avgClaimsPerDay || '--', velSummary.avgDurationDays ? (velSummary.avgDurationDays + 'd avg duration') : 'no timing data');
|
|
384
|
-
html += metricCard('Accuracy', calSummary.accuracyRate !== null && calSummary.accuracyRate !== undefined ? (calSummary.accuracyRate + '%') : '--', calSummary.matched ? (calSummary.matched + ' predictions scored') : 'no calibration data');
|
|
385
|
-
html += metricCard('Conflicts', (analysis.statusDistribution || {}).contested || 0, 'contested claims');
|
|
386
|
-
html += metricCard('Decay Alerts', decayTotal, decaySummary.thresholdDays ? (decaySummary.thresholdDays + 'd threshold') : 'no decay data');
|
|
387
|
-
html += '</div>';
|
|
388
|
-
|
|
389
|
-
// -- Claim type distribution (horizontal bar) --
|
|
390
|
-
html += '<div class="section" role="region" aria-label="Claim type distribution">';
|
|
391
|
-
html += '<div class="section-title">Claim Type Distribution</div>';
|
|
392
|
-
html += renderBarChart(analysis.typeDistribution || {}, dash.totalClaims);
|
|
393
|
-
html += '</div>';
|
|
394
|
-
|
|
395
|
-
// -- Evidence tier breakdown (stacked bar) --
|
|
396
|
-
html += '<div class="section" role="region" aria-label="Evidence tier breakdown">';
|
|
397
|
-
html += '<div class="section-title">Evidence Tier Breakdown</div>';
|
|
398
|
-
html += renderStackedBar(analysis.evidenceDistribution || {}, dash.totalClaims);
|
|
399
|
-
html += renderBarChart(analysis.evidenceDistribution || {}, dash.totalClaims);
|
|
400
|
-
html += '</div>';
|
|
401
|
-
|
|
402
|
-
// -- Sprint velocity over time (SVG line chart) --
|
|
403
|
-
if (velocity && velocity.sprints && velocity.sprints.length > 1) {
|
|
404
|
-
html += '<div class="section" role="region" aria-label="Sprint velocity over time">';
|
|
405
|
-
html += '<div class="section-title">Sprint Velocity Over Time</div>';
|
|
406
|
-
html += renderVelocityLineChart(velocity.sprints);
|
|
407
|
-
html += '<div class="velocity-grid">';
|
|
408
|
-
html += velocityItem(velSummary.avgDurationDays || '--', 'Avg Duration (days)');
|
|
409
|
-
html += velocityItem(velSummary.avgClaimsPerDay || '--', 'Avg Claims/Day');
|
|
410
|
-
html += velocityItem(velSummary.totalStalls || 0, 'Total Stalls');
|
|
411
|
-
html += velocityItem(velSummary.sprintsAnalyzed || 0, 'Sprints Analyzed');
|
|
412
|
-
html += '</div>';
|
|
413
|
-
if (velocity.insight) {
|
|
414
|
-
html += '<div class="insight-box">' + esc(velocity.insight) + '</div>';
|
|
415
|
-
}
|
|
416
|
-
html += '</div>';
|
|
417
|
-
} else if (velocity) {
|
|
418
|
-
html += '<div class="section" role="region" aria-label="Sprint velocity">';
|
|
419
|
-
html += '<div class="section-title">Sprint Velocity</div>';
|
|
420
|
-
html += '<div class="velocity-grid">';
|
|
421
|
-
html += velocityItem(velSummary.avgDurationDays || '--', 'Avg Duration (days)');
|
|
422
|
-
html += velocityItem(velSummary.avgClaimsPerDay || '--', 'Avg Claims/Day');
|
|
423
|
-
html += velocityItem(velSummary.totalStalls || 0, 'Total Stalls');
|
|
424
|
-
html += velocityItem(velSummary.sprintsAnalyzed || 0, 'Sprints Analyzed');
|
|
425
|
-
html += '</div>';
|
|
426
|
-
if (velocity.insight) {
|
|
427
|
-
html += '<div class="insight-box">' + esc(velocity.insight) + '</div>';
|
|
428
|
-
}
|
|
429
|
-
html += '</div>';
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// -- Patterns: recurring themes from tags --
|
|
433
|
-
var tags = analysis.tagFrequency || {};
|
|
434
|
-
var tagEntries = Object.entries(tags).sort(function(a, b) { return b[1] - a[1]; });
|
|
435
|
-
if (tagEntries.length > 0) {
|
|
436
|
-
html += '<div class="section" role="region" aria-label="Detected patterns">';
|
|
437
|
-
html += '<div class="section-title">Recurring Themes (' + tagEntries.length + ')</div>';
|
|
438
|
-
html += '<div class="pattern-list">';
|
|
439
|
-
var shown = tagEntries.slice(0, 15);
|
|
440
|
-
for (var i = 0; i < shown.length; i++) {
|
|
441
|
-
var tag = shown[i];
|
|
442
|
-
html += '<div class="pattern-item">';
|
|
443
|
-
html += '<div class="pat-name">' + esc(tag[0]) + '</div>';
|
|
444
|
-
html += '<div class="pat-desc">' + tag[1] + ' claim(s) tagged with this theme</div>';
|
|
445
|
-
html += '</div>';
|
|
446
|
-
}
|
|
447
|
-
if (tagEntries.length > 15) {
|
|
448
|
-
html += '<div style="padding:var(--space-md);color:var(--fg3);font-size:11px;">...and ' + (tagEntries.length - 15) + ' more themes</div>';
|
|
449
|
-
}
|
|
450
|
-
html += '</div></div>';
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// -- Monocultures (type concentration) --
|
|
454
|
-
if (analysis.monocultures && analysis.monocultures.length > 0) {
|
|
455
|
-
html += '<div class="section" role="region" aria-label="Type monocultures">';
|
|
456
|
-
html += '<div class="section-title">Type Monocultures</div>';
|
|
457
|
-
html += '<div class="pattern-list">';
|
|
458
|
-
for (var m = 0; m < analysis.monocultures.length; m++) {
|
|
459
|
-
var mono = analysis.monocultures[m];
|
|
460
|
-
html += '<div class="pattern-item anti">';
|
|
461
|
-
html += '<div class="pat-name">' + esc(mono.sprint) + '</div>';
|
|
462
|
-
html += '<div class="pat-desc">' + mono.ratio + '% of claims are "' + esc(mono.dominantType) + '" -- diversity may be lacking</div>';
|
|
463
|
-
html += '</div>';
|
|
464
|
-
}
|
|
465
|
-
html += '</div></div>';
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// -- Calibration: accuracy table --
|
|
469
|
-
if (calibration) {
|
|
470
|
-
html += '<div class="section" role="region" aria-label="Calibration accuracy">';
|
|
471
|
-
html += '<div class="section-title">Prediction Calibration</div>';
|
|
472
|
-
html += renderCalibration(calibration);
|
|
473
|
-
html += '</div>';
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// -- Decay alerts --
|
|
477
|
-
if (decay) {
|
|
478
|
-
var alerts = [].concat(
|
|
479
|
-
(decay.decaying || []).map(function(d) { d.severity = 'decaying'; return d; }),
|
|
480
|
-
(decay.stale || []).map(function(d) { d.severity = 'stale'; return d; }),
|
|
481
|
-
(decay.unresolved || []).map(function(d) { d.severity = 'unresolved'; return d; })
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
html += '<div class="section" role="region" aria-label="Decay alerts">';
|
|
485
|
-
html += '<div class="section-title">Decay Alerts (' + alerts.length + ')</div>';
|
|
486
|
-
if (alerts.length === 0) {
|
|
487
|
-
html += '<div style="padding:var(--space-lg);color:var(--fg3);font-size:12px;text-align:center;">No decay detected. Knowledge base looks fresh.</div>';
|
|
488
|
-
} else {
|
|
489
|
-
html += '<div class="decay-list">';
|
|
490
|
-
var maxAlerts = Math.min(alerts.length, 20);
|
|
491
|
-
for (var a = 0; a < maxAlerts; a++) {
|
|
492
|
-
var al = alerts[a];
|
|
493
|
-
html += '<div class="decay-alert ' + esc(al.severity) + '">';
|
|
494
|
-
html += '<div class="da-header">';
|
|
495
|
-
html += '<span class="da-id">' + esc(al.id || '-') + '</span>';
|
|
496
|
-
html += '<span class="type-badge ' + esc(al.type || '') + '">' + esc(al.type || '-') + '</span>';
|
|
497
|
-
if (al.sprint) html += '<span style="font-size:10px;color:var(--fg3);">' + esc(al.sprint) + '</span>';
|
|
498
|
-
if (al.ageDays !== undefined) html += '<span style="font-size:10px;color:var(--fg3);margin-left:auto;">' + al.ageDays + 'd old</span>';
|
|
499
|
-
html += '</div>';
|
|
500
|
-
html += '<div class="da-reason">' + esc(al.reason || '') + '</div>';
|
|
501
|
-
html += '</div>';
|
|
502
|
-
}
|
|
503
|
-
if (alerts.length > 20) {
|
|
504
|
-
html += '<div style="padding:var(--space-md);color:var(--fg3);font-size:11px;">...and ' + (alerts.length - 20) + ' more</div>';
|
|
505
|
-
}
|
|
506
|
-
html += '</div>';
|
|
507
|
-
}
|
|
508
|
-
if (decay.insight) {
|
|
509
|
-
html += '<div class="insight-box">' + esc(decay.insight) + '</div>';
|
|
510
|
-
}
|
|
511
|
-
html += '</div>';
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
db.innerHTML = html;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// -- Render helpers --
|
|
518
|
-
function metricCard(title, value, sub) {
|
|
519
|
-
return '<div class="card" tabindex="0"><div class="card-title">' + esc(title) + '</div><div class="card-value">' + esc(String(value)) + '</div><div class="card-sub">' + esc(sub) + '</div></div>';
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
function velocityItem(val, label) {
|
|
523
|
-
return '<div class="velocity-item"><div class="velocity-val">' + esc(String(val)) + '</div><div class="velocity-label">' + esc(label) + '</div></div>';
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function evidenceScore(dist) {
|
|
527
|
-
if (!dist) return '--';
|
|
528
|
-
var ranks = { stated: 1, web: 2, documented: 3, tested: 4, production: 5 };
|
|
529
|
-
var total = 0, count = 0;
|
|
530
|
-
for (var k in dist) {
|
|
531
|
-
if (ranks[k]) { total += ranks[k] * dist[k]; count += dist[k]; }
|
|
532
|
-
}
|
|
533
|
-
if (count === 0) return '--';
|
|
534
|
-
return (total / count).toFixed(1) + '/5';
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function evidenceScoreLabel(dist) {
|
|
538
|
-
if (!dist) return 'no data';
|
|
539
|
-
var ranks = { stated: 1, web: 2, documented: 3, tested: 4, production: 5 };
|
|
540
|
-
var total = 0, count = 0;
|
|
541
|
-
for (var k in dist) {
|
|
542
|
-
if (ranks[k]) { total += ranks[k] * dist[k]; count += dist[k]; }
|
|
543
|
-
}
|
|
544
|
-
if (count === 0) return 'no data';
|
|
545
|
-
var avg = total / count;
|
|
546
|
-
if (avg >= 4) return 'strong evidence base';
|
|
547
|
-
if (avg >= 3) return 'moderate evidence';
|
|
548
|
-
if (avg >= 2) return 'needs stronger validation';
|
|
549
|
-
return 'weak -- mostly stated/web';
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function renderBarChart(distribution, total) {
|
|
553
|
-
var entries = Object.entries(distribution).sort(function(a, b) { return b[1] - a[1]; });
|
|
554
|
-
if (entries.length === 0) return '<div style="color:var(--fg3);font-size:12px;">No data</div>';
|
|
555
|
-
|
|
556
|
-
var max = Math.max.apply(null, entries.map(function(e) { return e[1]; }));
|
|
557
|
-
var html = '<div class="bar-chart" role="list" aria-label="Bar chart">';
|
|
558
|
-
for (var i = 0; i < entries.length; i++) {
|
|
559
|
-
var label = entries[i][0], count = entries[i][1];
|
|
560
|
-
var pct = max > 0 ? Math.round(count / max * 100) : 0;
|
|
561
|
-
html += '<div class="bar-row" role="listitem" aria-label="' + esc(label) + ': ' + count + '">';
|
|
562
|
-
html += '<span class="bar-label">' + esc(label) + '</span>';
|
|
563
|
-
html += '<div class="bar-track"><div class="bar-fill ' + esc(label) + '" style="width:' + pct + '%">' + count + '</div></div>';
|
|
564
|
-
html += '<span class="bar-count">' + (total > 0 ? Math.round(count / total * 100) : 0) + '%</span>';
|
|
565
|
-
html += '</div>';
|
|
566
|
-
}
|
|
567
|
-
html += '</div>';
|
|
568
|
-
return html;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
var EVIDENCE_COLORS = {
|
|
572
|
-
stated: '#64748b', web: '#94a3b8', documented: '#60a5fa',
|
|
573
|
-
tested: '#34d399', production: '#22d3ee'
|
|
574
|
-
};
|
|
575
|
-
var EVIDENCE_ORDER = ['stated', 'web', 'documented', 'tested', 'production'];
|
|
576
|
-
|
|
577
|
-
function renderStackedBar(distribution, total) {
|
|
578
|
-
if (!distribution || total === 0) return '';
|
|
579
|
-
var html = '<div class="stacked-bar" role="img" aria-label="Evidence tier stacked bar">';
|
|
580
|
-
for (var i = 0; i < EVIDENCE_ORDER.length; i++) {
|
|
581
|
-
var tier = EVIDENCE_ORDER[i];
|
|
582
|
-
var count = distribution[tier] || 0;
|
|
583
|
-
if (count === 0) continue;
|
|
584
|
-
var pct = (count / total * 100).toFixed(1);
|
|
585
|
-
html += '<div class="stacked-segment" style="width:' + pct + '%;background:' + EVIDENCE_COLORS[tier] + ';" title="' + esc(tier) + ': ' + count + ' (' + pct + '%)"></div>';
|
|
586
|
-
}
|
|
587
|
-
html += '</div>';
|
|
588
|
-
html += '<div class="stacked-legend">';
|
|
589
|
-
for (var j = 0; j < EVIDENCE_ORDER.length; j++) {
|
|
590
|
-
var t = EVIDENCE_ORDER[j];
|
|
591
|
-
var c = distribution[t] || 0;
|
|
592
|
-
if (c === 0) continue;
|
|
593
|
-
html += '<div class="stacked-legend-item"><span class="stacked-legend-swatch" style="background:' + EVIDENCE_COLORS[t] + ';"></span>' + esc(t) + ' (' + c + ')</div>';
|
|
594
|
-
}
|
|
595
|
-
html += '</div>';
|
|
596
|
-
return html;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// -- SVG velocity line chart --
|
|
600
|
-
function renderVelocityLineChart(sprintData) {
|
|
601
|
-
var valid = sprintData.filter(function(s) { return s.claimsPerDay !== null; });
|
|
602
|
-
if (valid.length < 2) return '';
|
|
603
|
-
|
|
604
|
-
var W = 600, H = 160, PAD_L = 40, PAD_R = 20, PAD_T = 16, PAD_B = 40;
|
|
605
|
-
var chartW = W - PAD_L - PAD_R;
|
|
606
|
-
var chartH = H - PAD_T - PAD_B;
|
|
607
|
-
var maxVal = Math.max.apply(null, valid.map(function(s) { return s.claimsPerDay; }));
|
|
608
|
-
if (maxVal === 0) maxVal = 1;
|
|
609
|
-
|
|
610
|
-
var points = [];
|
|
611
|
-
for (var i = 0; i < valid.length; i++) {
|
|
612
|
-
var x = PAD_L + (valid.length === 1 ? chartW / 2 : (i / (valid.length - 1)) * chartW);
|
|
613
|
-
var y = PAD_T + chartH - (valid[i].claimsPerDay / maxVal) * chartH;
|
|
614
|
-
points.push({ x: x, y: y, sprint: valid[i].sprint, val: valid[i].claimsPerDay });
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Build SVG path
|
|
618
|
-
var pathD = 'M' + points.map(function(p) { return p.x + ',' + p.y; }).join(' L');
|
|
619
|
-
// Area fill
|
|
620
|
-
var areaD = pathD + ' L' + points[points.length - 1].x + ',' + (PAD_T + chartH) + ' L' + points[0].x + ',' + (PAD_T + chartH) + ' Z';
|
|
621
|
-
|
|
622
|
-
var svg = '<div class="velocity-chart-wrap">';
|
|
623
|
-
svg += '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Sprint velocity line chart" xmlns="http://www.w3.org/2000/svg">';
|
|
624
|
-
|
|
625
|
-
// Y-axis gridlines
|
|
626
|
-
var gridSteps = 4;
|
|
627
|
-
for (var g = 0; g <= gridSteps; g++) {
|
|
628
|
-
var gy = PAD_T + (g / gridSteps) * chartH;
|
|
629
|
-
var gv = ((gridSteps - g) / gridSteps * maxVal).toFixed(1);
|
|
630
|
-
svg += '<line x1="' + PAD_L + '" y1="' + gy + '" x2="' + (W - PAD_R) + '" y2="' + gy + '" stroke="#1e293b" stroke-width="0.5"/>';
|
|
631
|
-
svg += '<text x="' + (PAD_L - 6) + '" y="' + (gy + 3) + '" fill="#64748b" font-size="9" text-anchor="end" font-family="system-ui,sans-serif">' + gv + '</text>';
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Area + line
|
|
635
|
-
svg += '<path d="' + areaD + '" fill="rgba(251,146,60,0.08)" />';
|
|
636
|
-
svg += '<path d="' + pathD + '" fill="none" stroke="#fb923c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
|
637
|
-
|
|
638
|
-
// Data points + labels
|
|
639
|
-
for (var p = 0; p < points.length; p++) {
|
|
640
|
-
svg += '<circle cx="' + points[p].x + '" cy="' + points[p].y + '" r="3.5" fill="#fb923c" stroke="#0a0e1a" stroke-width="1.5"/>';
|
|
641
|
-
// Sprint name on x-axis
|
|
642
|
-
var labelX = points[p].x;
|
|
643
|
-
var truncName = points[p].sprint.length > 12 ? points[p].sprint.slice(0, 11) + '..' : points[p].sprint;
|
|
644
|
-
svg += '<text x="' + labelX + '" y="' + (H - 8) + '" fill="#64748b" font-size="8" text-anchor="middle" font-family="system-ui,sans-serif">' + esc(truncName) + '</text>';
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Y-axis label
|
|
648
|
-
svg += '<text x="8" y="' + (PAD_T + chartH / 2) + '" fill="#64748b" font-size="8" text-anchor="middle" transform="rotate(-90,8,' + (PAD_T + chartH / 2) + ')" font-family="system-ui,sans-serif">claims/day</text>';
|
|
649
|
-
|
|
650
|
-
svg += '</svg></div>';
|
|
651
|
-
return svg;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// -- Calibration section --
|
|
655
|
-
function renderCalibration(cal) {
|
|
656
|
-
var s = cal.summary || {};
|
|
657
|
-
var html = '';
|
|
658
|
-
|
|
659
|
-
// Summary row
|
|
660
|
-
html += '<div class="cal-summary-row">';
|
|
661
|
-
html += calSummaryItem(s.totalEstimates || 0, 'Estimates');
|
|
662
|
-
html += calSummaryItem(s.matched || 0, 'Scored');
|
|
663
|
-
html += calSummaryItem(s.unmatched || 0, 'Unmatched');
|
|
664
|
-
html += calSummaryItem(s.accuracyRate !== null && s.accuracyRate !== undefined ? (s.accuracyRate + '%') : '--', 'Accuracy');
|
|
665
|
-
html += '</div>';
|
|
666
|
-
|
|
667
|
-
// Confidence calibration
|
|
668
|
-
var byConf = cal.calibrationByConfidence || {};
|
|
669
|
-
var confKeys = Object.keys(byConf);
|
|
670
|
-
if (confKeys.length > 0) {
|
|
671
|
-
html += '<div style="margin-bottom:var(--space-lg);">';
|
|
672
|
-
html += '<div style="font-size:11px;color:var(--fg3);text-transform:uppercase;letter-spacing:0.3px;margin-bottom:var(--space-sm);">Accuracy by Confidence Level</div>';
|
|
673
|
-
html += '<div class="bar-chart">';
|
|
674
|
-
for (var i = 0; i < confKeys.length; i++) {
|
|
675
|
-
var k = confKeys[i];
|
|
676
|
-
var v = byConf[k];
|
|
677
|
-
var color = v >= 70 ? 'var(--green)' : v >= 40 ? 'var(--orange)' : 'var(--red)';
|
|
678
|
-
html += '<div class="bar-row">';
|
|
679
|
-
html += '<span class="bar-label">' + esc(k) + '</span>';
|
|
680
|
-
html += '<div class="bar-track"><div style="width:' + v + '%;height:100%;border-radius:var(--radius-sm);background:' + color + ';display:flex;align-items:center;padding-inline-start:6px;font-size:10px;color:var(--bg);font-weight:600;">' + v + '%</div></div>';
|
|
681
|
-
html += '</div>';
|
|
682
|
-
}
|
|
683
|
-
html += '</div></div>';
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Scored predictions table
|
|
687
|
-
var scored = cal.scored || [];
|
|
688
|
-
if (scored.length > 0) {
|
|
689
|
-
html += '<table class="cal-table" aria-label="Scored predictions">';
|
|
690
|
-
html += '<thead><tr><th>Estimate</th><th>Calibration</th><th>Sprint</th><th>Result</th><th>Delta</th></tr></thead>';
|
|
691
|
-
html += '<tbody>';
|
|
692
|
-
var maxRows = Math.min(scored.length, 20);
|
|
693
|
-
for (var j = 0; j < maxRows; j++) {
|
|
694
|
-
var row = scored[j];
|
|
695
|
-
var resultClass = row.accurate === true ? 'cal-accurate' : row.accurate === false ? 'cal-inaccurate' : 'cal-unknown';
|
|
696
|
-
var resultText = row.accurate === true ? 'accurate' : row.accurate === false ? 'inaccurate' : '--';
|
|
697
|
-
html += '<tr>';
|
|
698
|
-
html += '<td><code>' + esc(row.estimateId) + '</code></td>';
|
|
699
|
-
html += '<td><code>' + esc(row.calibrationId) + '</code></td>';
|
|
700
|
-
html += '<td>' + esc(row.sprint) + '</td>';
|
|
701
|
-
html += '<td class="' + resultClass + '">' + resultText + '</td>';
|
|
702
|
-
html += '<td>' + (row.delta !== null && row.delta !== undefined ? row.delta : '--') + '</td>';
|
|
703
|
-
html += '</tr>';
|
|
704
|
-
}
|
|
705
|
-
if (scored.length > 20) {
|
|
706
|
-
html += '<tr><td colspan="5" style="color:var(--fg3);">...and ' + (scored.length - 20) + ' more</td></tr>';
|
|
707
|
-
}
|
|
708
|
-
html += '</tbody></table>';
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Unmatched estimates
|
|
712
|
-
var unmatched = cal.unmatchedEstimates || [];
|
|
713
|
-
if (unmatched.length > 0) {
|
|
714
|
-
html += '<div style="margin-top:var(--space-lg);">';
|
|
715
|
-
html += '<div style="font-size:11px;color:var(--fg3);text-transform:uppercase;letter-spacing:0.3px;margin-bottom:var(--space-sm);">Unmatched Estimates (' + unmatched.length + ')</div>';
|
|
716
|
-
html += '<div class="decay-list">';
|
|
717
|
-
var maxUnmatched = Math.min(unmatched.length, 10);
|
|
718
|
-
for (var u = 0; u < maxUnmatched; u++) {
|
|
719
|
-
var um = unmatched[u];
|
|
720
|
-
html += '<div class="decay-alert stale">';
|
|
721
|
-
html += '<div class="da-header"><span class="da-id">' + esc(um.id) + '</span>';
|
|
722
|
-
if (um.sprint) html += '<span style="font-size:10px;color:var(--fg3);">' + esc(um.sprint) + '</span>';
|
|
723
|
-
if (um.age !== null) html += '<span style="font-size:10px;color:var(--fg3);margin-left:auto;">' + um.age + 'd old</span>';
|
|
724
|
-
html += '</div>';
|
|
725
|
-
html += '<div class="da-reason">' + esc(um.text || 'No description') + '</div>';
|
|
726
|
-
html += '</div>';
|
|
727
|
-
}
|
|
728
|
-
html += '</div></div>';
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
if (cal.insight) {
|
|
732
|
-
html += '<div class="insight-box">' + esc(cal.insight) + '</div>';
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
return html;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
function calSummaryItem(val, label) {
|
|
739
|
-
var color = 'var(--accent)';
|
|
740
|
-
if (label === 'Accuracy') {
|
|
741
|
-
var num = parseInt(val);
|
|
742
|
-
if (!isNaN(num)) color = num >= 70 ? 'var(--green)' : num >= 40 ? 'var(--orange)' : 'var(--red)';
|
|
743
|
-
}
|
|
744
|
-
return '<div class="cal-summary-item"><div class="cal-summary-val" style="color:' + color + ';">' + esc(String(val)) + '</div><div class="cal-summary-label">' + esc(label) + '</div></div>';
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// -- Events --
|
|
748
|
-
document.getElementById('sprintSelect').addEventListener('change', function(e) {
|
|
749
|
-
selectedSprint = e.target.value;
|
|
750
|
-
loadDashboard();
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
// -- Helpers --
|
|
754
|
-
function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
|
755
|
-
|
|
756
|
-
// -- Keyboard navigation --
|
|
757
|
-
document.addEventListener('keydown', function(e) {
|
|
758
|
-
if (e.key === 'Escape') {
|
|
759
|
-
selectedSprint = '__all';
|
|
760
|
-
document.getElementById('sprintSelect').value = '__all';
|
|
761
|
-
loadDashboard();
|
|
762
|
-
}
|
|
763
|
-
// R to refresh
|
|
764
|
-
if (e.key === 'r' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT' && e.target.tagName !== 'SELECT') {
|
|
765
|
-
loadSprints();
|
|
766
|
-
}
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
// -- Init --
|
|
770
|
-
connectSSE();
|
|
771
|
-
loadSprints();
|
|
772
|
-
})();
|
|
773
|
-
</script>
|
|
774
|
-
<script>
|
|
775
|
-
(function() {
|
|
776
|
-
var LW = 0.025;
|
|
777
|
-
var TOOL = { name: 'Harvest', letter: 'H', color: '#fb923c' };
|
|
778
|
-
var _c, _ctx, _s, _cx, _textStart, _restText, _font;
|
|
779
|
-
var _state = 'drawon', _start = null, _raf, _pendingState = null;
|
|
780
|
-
var _openPts = null, _closedPts = null;
|
|
781
|
-
|
|
782
|
-
function _lerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
|
|
783
|
-
function _easeInOut(t){ return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2; }
|
|
784
|
-
|
|
785
|
-
function _bracket(ctx, s, color, alpha) {
|
|
786
|
-
var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
|
|
787
|
-
var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
|
|
788
|
-
if(alpha!==undefined) ctx.globalAlpha=alpha;
|
|
789
|
-
ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
|
|
790
|
-
ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx-fe,topY);
|
|
791
|
-
ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
|
|
792
|
-
ctx.lineTo(cx,botY); ctx.stroke();
|
|
793
|
-
if(alpha!==undefined) ctx.globalAlpha=1;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function _drawBracket(ctx, s, color, progress) {
|
|
797
|
-
var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
|
|
798
|
-
var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
|
|
799
|
-
ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
|
|
800
|
-
var seg1=0.12, seg2=0.72;
|
|
801
|
-
ctx.beginPath();
|
|
802
|
-
if(progress<=seg1){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe*(progress/seg1),topY);}
|
|
803
|
-
else if(progress<=seg1+seg2){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);ctx.stroke();ctx.beginPath();
|
|
804
|
-
var bt=(progress-seg1)/seg2;
|
|
805
|
-
var p0={x:cx-fe,y:topY},p1={x:cx-gw*0.52,y:cy-gh*0.32},p2={x:cx-gw*0.52,y:cy+gh*0.24},p3={x:cx-fe,y:botY};
|
|
806
|
-
var q1=_lerp(p0,p1,bt),q2=_lerp(p1,p2,bt),q3=_lerp(p2,p3,bt);
|
|
807
|
-
var r1=_lerp(q1,q2,bt),r2=_lerp(q2,q3,bt),s1=_lerp(r1,r2,bt);
|
|
808
|
-
ctx.moveTo(p0.x,p0.y);ctx.bezierCurveTo(q1.x,q1.y,r1.x,r1.y,s1.x,s1.y);}
|
|
809
|
-
else{ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);
|
|
810
|
-
ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
|
|
811
|
-
ctx.lineTo((cx-fe)+fe*((progress-seg1-seg2)/(1-seg1-seg2)),botY);}
|
|
812
|
-
ctx.stroke();
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function _drawName(ctx, s, spellP, alpha) {
|
|
816
|
-
var a = alpha !== undefined ? alpha : 1;
|
|
817
|
-
ctx.font = _font; ctx.textBaseline = 'middle';
|
|
818
|
-
var cy = s/2 + s*0.02;
|
|
819
|
-
ctx.globalAlpha = a; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
|
|
820
|
-
ctx.fillText(TOOL.letter, _cx, cy);
|
|
821
|
-
if(_restText.length > 0 && spellP > 0) {
|
|
822
|
-
var n = _restText.length, num = Math.min(n, Math.ceil(spellP * n));
|
|
823
|
-
var rawP = spellP * n, charP = num >= n ? 1 : rawP - Math.floor(rawP);
|
|
824
|
-
var full = charP >= 1 ? num : num - 1;
|
|
825
|
-
ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
|
|
826
|
-
if(full > 0) { ctx.globalAlpha = a; ctx.fillText(_restText.slice(0, full), _textStart, cy); }
|
|
827
|
-
if(full < num) {
|
|
828
|
-
var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
|
|
829
|
-
ctx.globalAlpha = a * (0.3 + 0.7 * charP);
|
|
830
|
-
ctx.fillText(_restText[full], _textStart + prevW, cy);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
ctx.globalAlpha = 1;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function _getOpenPts(s) {
|
|
837
|
-
if(_openPts && _openPts._s === s) return _openPts;
|
|
838
|
-
var cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68, topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
|
|
839
|
-
var pts=[];
|
|
840
|
-
for(var t=0;t<=1;t+=0.05) pts.push({x:cx-fe*t,y:topY});
|
|
841
|
-
var p0={x:cx-fe,y:topY},p1={x:cx-gw*0.52,y:cy-gh*0.32},p2={x:cx-gw*0.52,y:cy+gh*0.24},p3={x:cx-fe,y:botY};
|
|
842
|
-
for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*p0.x+3*u*u*t*p1.x+3*u*t*t*p2.x+t*t*t*p3.x,y:u*u*u*p0.y+3*u*u*t*p1.y+3*u*t*t*p2.y+t*t*t*p3.y});}
|
|
843
|
-
for(var t=0;t<=1;t+=0.05) pts.push({x:(cx-fe)+fe*t,y:botY});
|
|
844
|
-
pts._s=s; _openPts=pts; return pts;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function _getClosedPts(s) {
|
|
848
|
-
if(_closedPts && _closedPts._s === s) return _closedPts;
|
|
849
|
-
var cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68, topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
|
|
850
|
-
var pts=[];
|
|
851
|
-
for(var t=0;t<=1;t+=0.03) pts.push({x:cx-fe*t,y:topY});
|
|
852
|
-
var lp0={x:cx-fe,y:topY},lp1={x:cx-gw*0.52,y:cy-gh*0.32},lp2={x:cx-gw*0.52,y:cy+gh*0.24},lp3={x:cx-fe,y:botY};
|
|
853
|
-
for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*lp0.x+3*u*u*t*lp1.x+3*u*t*t*lp2.x+t*t*t*lp3.x,y:u*u*u*lp0.y+3*u*u*t*lp1.y+3*u*t*t*lp2.y+t*t*t*lp3.y});}
|
|
854
|
-
for(var t=0;t<=1;t+=0.03) pts.push({x:(cx-fe)+2*fe*t,y:botY});
|
|
855
|
-
var rp0={x:cx+fe,y:botY},rp1={x:cx+gw*0.52,y:cy+gh*0.24},rp2={x:cx+gw*0.52,y:cy-gh*0.32},rp3={x:cx+fe,y:topY};
|
|
856
|
-
for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*rp0.x+3*u*u*t*rp1.x+3*u*t*t*rp2.x+t*t*t*rp3.x,y:u*u*u*rp0.y+3*u*u*t*rp1.y+3*u*t*t*rp2.y+t*t*t*rp3.y});}
|
|
857
|
-
for(var t=0;t<=1;t+=0.03) pts.push({x:(cx+fe)-fe*t,y:topY});
|
|
858
|
-
pts._s=s; _closedPts=pts; return pts;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
function _frame(ts) {
|
|
862
|
-
if(!_c) return;
|
|
863
|
-
if(!_start) _start = ts;
|
|
864
|
-
var e = ts - _start, ctx = _ctx, s = _s;
|
|
865
|
-
ctx.clearRect(0, 0, _c.width, s);
|
|
866
|
-
switch(_state) {
|
|
867
|
-
case 'drawon':
|
|
868
|
-
var bp = _easeInOut(Math.min(1, e / 1400));
|
|
869
|
-
_drawBracket(ctx, s, TOOL.color, bp);
|
|
870
|
-
var la = Math.max(0, Math.min(1, (e - 900) / 400));
|
|
871
|
-
if(la > 0) {
|
|
872
|
-
ctx.font = _font; ctx.textBaseline = 'middle';
|
|
873
|
-
ctx.globalAlpha = la; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
|
|
874
|
-
ctx.fillText(TOOL.letter, _cx, s/2 + s*0.02); ctx.globalAlpha = 1;
|
|
875
|
-
}
|
|
876
|
-
if(e > 1100 && _restText.length > 0) {
|
|
877
|
-
var sp = Math.min(1, (e - 1100) / (120 * _restText.length));
|
|
878
|
-
var n = _restText.length, num = Math.min(n, Math.ceil(sp * n));
|
|
879
|
-
if(num > 0) {
|
|
880
|
-
ctx.font = _font; ctx.textBaseline = 'middle';
|
|
881
|
-
var cy = s/2 + s*0.02, rawP = sp * n;
|
|
882
|
-
var charP = num >= n ? 1 : rawP - Math.floor(rawP);
|
|
883
|
-
var full = charP >= 1 ? num : num - 1;
|
|
884
|
-
ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
|
|
885
|
-
if(full > 0) ctx.fillText(_restText.slice(0, full), _textStart, cy);
|
|
886
|
-
if(full < num) {
|
|
887
|
-
var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
|
|
888
|
-
ctx.globalAlpha = 0.3 + 0.7 * charP;
|
|
889
|
-
ctx.fillText(_restText[full], _textStart + prevW, cy); ctx.globalAlpha = 1;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
if(e > 1100 + 120 * _restText.length + 300) { _state = _pendingState || 'idle'; _pendingState = null; _start = ts; }
|
|
894
|
-
break;
|
|
895
|
-
case 'idle':
|
|
896
|
-
var breathe = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(e / 1200));
|
|
897
|
-
_bracket(ctx, s, TOOL.color, breathe);
|
|
898
|
-
var textBreath = 0.88 + 0.12 * (0.5 + 0.5 * Math.sin(e / 1800));
|
|
899
|
-
_drawName(ctx, s, 1, textBreath);
|
|
900
|
-
break;
|
|
901
|
-
case 'shimmer':
|
|
902
|
-
_bracket(ctx, s, TOOL.color, 0.2);
|
|
903
|
-
var spts = _getOpenPts(s), sspeed = 1800;
|
|
904
|
-
var spos = (e % sspeed) / sspeed;
|
|
905
|
-
var sidx = Math.floor(spos * (spts.length - 1));
|
|
906
|
-
var spt = spts[sidx];
|
|
907
|
-
var sgrad = ctx.createRadialGradient(spt.x, spt.y, 0, spt.x, spt.y, s * 0.10);
|
|
908
|
-
sgrad.addColorStop(0, TOOL.color + 'aa'); sgrad.addColorStop(1, 'transparent');
|
|
909
|
-
ctx.fillStyle = sgrad; ctx.fillRect(0, 0, _c.width, s);
|
|
910
|
-
var strailFrac = 0.18;
|
|
911
|
-
var si0 = Math.max(0, Math.floor((spos - strailFrac) * (spts.length - 1)));
|
|
912
|
-
ctx.strokeStyle = TOOL.color; ctx.lineWidth = s * LW; ctx.lineCap = 'round';
|
|
913
|
-
ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(spts[si0].x, spts[si0].y);
|
|
914
|
-
for(var si = si0 + 1; si <= sidx; si++) ctx.lineTo(spts[si].x, spts[si].y);
|
|
915
|
-
ctx.stroke(); ctx.globalAlpha = 1;
|
|
916
|
-
_drawName(ctx, s, 1, undefined);
|
|
917
|
-
break;
|
|
918
|
-
case 'orbit':
|
|
919
|
-
_bracket(ctx, s, TOOL.color, 0.15);
|
|
920
|
-
_drawName(ctx, s, 1, 0.4);
|
|
921
|
-
var pts = _getOpenPts(s), speed = 1200, trailFrac = 0.28;
|
|
922
|
-
var halfCycle = (e % speed) / speed;
|
|
923
|
-
var cycle = (e % (speed * 2)) / (speed * 2);
|
|
924
|
-
var pos = cycle < 0.5 ? halfCycle : 1 - halfCycle;
|
|
925
|
-
var headIdx = Math.floor(pos * (pts.length - 1));
|
|
926
|
-
var trailLen = Math.floor(trailFrac * pts.length);
|
|
927
|
-
var dir = cycle < 0.5 ? 1 : -1;
|
|
928
|
-
ctx.lineWidth = s * LW; ctx.lineCap = 'round';
|
|
929
|
-
for(var i = 0; i < trailLen; i++) {
|
|
930
|
-
var idx = headIdx - dir * (trailLen - i);
|
|
931
|
-
if(idx < 0 || idx >= pts.length) continue;
|
|
932
|
-
var nxt = idx + dir;
|
|
933
|
-
if(nxt < 0 || nxt >= pts.length) continue;
|
|
934
|
-
ctx.globalAlpha = (i / trailLen) * 0.7; ctx.strokeStyle = TOOL.color;
|
|
935
|
-
ctx.beginPath(); ctx.moveTo(pts[idx].x, pts[idx].y); ctx.lineTo(pts[nxt].x, pts[nxt].y); ctx.stroke();
|
|
936
|
-
}
|
|
937
|
-
ctx.globalAlpha = 1;
|
|
938
|
-
break;
|
|
939
|
-
case 'dim':
|
|
940
|
-
var dim = 0.1 + 0.08 * Math.sin(e / 2000);
|
|
941
|
-
_bracket(ctx, s, TOOL.color, dim);
|
|
942
|
-
_drawName(ctx, s, 1, 0.2);
|
|
943
|
-
break;
|
|
944
|
-
}
|
|
945
|
-
_raf = requestAnimationFrame(_frame);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
_c = document.getElementById('grainLogo');
|
|
949
|
-
if(_c) {
|
|
950
|
-
_c.style.width = '0px';
|
|
951
|
-
_s = 256;
|
|
952
|
-
var targetFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
953
|
-
var fontRatio = 0.38;
|
|
954
|
-
var dh = 64;
|
|
955
|
-
_c.height = _s; _c.width = 1024;
|
|
956
|
-
_ctx = _c.getContext('2d');
|
|
957
|
-
_cx = _s / 2;
|
|
958
|
-
_restText = TOOL.name.slice(1);
|
|
959
|
-
_font = '800 ' + (_s * fontRatio) + 'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
|
|
960
|
-
_ctx.font = _font;
|
|
961
|
-
var letterW = _ctx.measureText(TOOL.letter).width;
|
|
962
|
-
var restW = _restText.length > 0 ? _ctx.measureText(_restText).width : 0;
|
|
963
|
-
_textStart = _cx + letterW / 2 + _s * 0.02;
|
|
964
|
-
var totalW = Math.ceil(_textStart + restW + _s * 0.12);
|
|
965
|
-
_c.width = totalW;
|
|
966
|
-
_ctx = _c.getContext('2d');
|
|
967
|
-
_c.style.height = dh + 'px';
|
|
968
|
-
_c.style.width = Math.round(totalW / _s * dh) + 'px';
|
|
969
|
-
_state = 'drawon'; _start = null;
|
|
970
|
-
_raf = requestAnimationFrame(_frame);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
window._grainSetState = function(state) {
|
|
974
|
-
if(_state === state) return;
|
|
975
|
-
if(_state === 'drawon') { _pendingState = state; return; }
|
|
976
|
-
_state = state; _start = null;
|
|
977
|
-
if(!_raf) _raf = requestAnimationFrame(_frame);
|
|
978
|
-
};
|
|
979
|
-
})();
|
|
980
|
-
</script>
|
|
981
|
-
</body>
|
|
982
|
-
</html>
|