ai-agent-session-center 1.0.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/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- package/server/wsManager.js +83 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { drawBarChart, drawLineChart, drawHeatmapGrid, formatNumber, showTooltip, hideTooltip } from './chartUtils.js';
|
|
2
|
+
import * as db from './browserDb.js';
|
|
3
|
+
|
|
4
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
5
|
+
|
|
6
|
+
let initialized = false;
|
|
7
|
+
|
|
8
|
+
export async function init() {
|
|
9
|
+
if (initialized) return;
|
|
10
|
+
initialized = true;
|
|
11
|
+
await loadAll();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function refresh() {
|
|
15
|
+
await init();
|
|
16
|
+
await loadAll();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function loadAll() {
|
|
20
|
+
const [summary, tools, trends, projects, heatmap] = await Promise.all([
|
|
21
|
+
db.getSummaryStats(),
|
|
22
|
+
db.getToolBreakdown(),
|
|
23
|
+
db.getDurationTrends({ period: 'day' }),
|
|
24
|
+
db.getActiveProjects(),
|
|
25
|
+
db.getHeatmap(),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
renderSummary(summary);
|
|
29
|
+
renderToolUsage(tools);
|
|
30
|
+
renderDurationTrends(trends);
|
|
31
|
+
renderActiveProjects(projects);
|
|
32
|
+
renderHeatmap(heatmap);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// -- 1. Summary Stats --
|
|
36
|
+
|
|
37
|
+
function renderSummary(data) {
|
|
38
|
+
const container = document.getElementById('analytics-summary');
|
|
39
|
+
container.innerHTML = '';
|
|
40
|
+
|
|
41
|
+
const mostTool = data.most_used_tool;
|
|
42
|
+
const busiestProj = data.busiest_project;
|
|
43
|
+
|
|
44
|
+
const stats = [
|
|
45
|
+
{ label: 'Total Sessions', value: formatNumber(data.total_sessions || 0), detail: 'all time' },
|
|
46
|
+
{ label: 'Total Prompts', value: formatNumber(data.total_prompts || 0), detail: 'all time' },
|
|
47
|
+
{ label: 'Total Tool Calls', value: formatNumber(data.total_tool_calls || 0), detail: 'all time' },
|
|
48
|
+
{ label: 'Avg Duration', value: formatDuration(data.avg_duration || data.avg_session_duration_ms || 0), detail: 'per session' },
|
|
49
|
+
{
|
|
50
|
+
label: 'Most Used Tool',
|
|
51
|
+
value: mostTool ? (mostTool.tool_name || mostTool.name) : 'N/A',
|
|
52
|
+
detail: mostTool ? formatNumber(mostTool.count) + ' calls' : '',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: 'Busiest Project',
|
|
56
|
+
value: busiestProj ? (busiestProj.name || busiestProj.project_path) : 'N/A',
|
|
57
|
+
detail: busiestProj ? formatNumber(busiestProj.count || busiestProj.sessions) + ' sessions' : '',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
stats.forEach(s => {
|
|
62
|
+
const card = document.createElement('div');
|
|
63
|
+
card.className = 'summary-stat';
|
|
64
|
+
card.innerHTML =
|
|
65
|
+
'<div class="stat-label">' + escapeHtml(s.label) + '</div>' +
|
|
66
|
+
'<div class="stat-value">' + escapeHtml(s.value) + '</div>' +
|
|
67
|
+
'<div class="stat-detail">' + escapeHtml(s.detail) + '</div>';
|
|
68
|
+
container.appendChild(card);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// -- 2. Tool Usage Breakdown --
|
|
73
|
+
|
|
74
|
+
function renderToolUsage(data) {
|
|
75
|
+
const container = document.getElementById('tool-usage-chart');
|
|
76
|
+
container.innerHTML = '';
|
|
77
|
+
|
|
78
|
+
const tools = (Array.isArray(data) ? data : data.tools || []).slice(0, 15);
|
|
79
|
+
if (tools.length === 0) {
|
|
80
|
+
container.insertAdjacentHTML('beforeend', '<div class="tab-empty">No tool data</div>');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const totalCalls = tools.reduce((sum, t) => sum + (t.count || 0), 0) || 1;
|
|
85
|
+
|
|
86
|
+
const chartDiv = document.createElement('div');
|
|
87
|
+
container.appendChild(chartDiv);
|
|
88
|
+
|
|
89
|
+
const barHeight = 20;
|
|
90
|
+
const gap = 4;
|
|
91
|
+
const labelWidth = 120;
|
|
92
|
+
const valueWidth = 90;
|
|
93
|
+
const totalHeight = tools.length * (barHeight + gap) - gap;
|
|
94
|
+
const svgWidth = chartDiv.clientWidth || container.clientWidth || 500;
|
|
95
|
+
const barAreaWidth = Math.max(50, svgWidth - labelWidth - valueWidth - 10);
|
|
96
|
+
|
|
97
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
98
|
+
svg.setAttribute('width', '100%');
|
|
99
|
+
svg.setAttribute('height', totalHeight);
|
|
100
|
+
svg.setAttribute('viewBox', '0 0 ' + svgWidth + ' ' + totalHeight);
|
|
101
|
+
|
|
102
|
+
const maxVal = Math.max(...tools.map(t => t.count || 0), 1);
|
|
103
|
+
|
|
104
|
+
tools.forEach((t, i) => {
|
|
105
|
+
const y = i * (barHeight + gap);
|
|
106
|
+
const count = t.count || 0;
|
|
107
|
+
const barW = Math.max(1, (count / maxVal) * barAreaWidth);
|
|
108
|
+
const pct = ((count / totalCalls) * 100).toFixed(1);
|
|
109
|
+
const name = t.tool_name || t.name || '';
|
|
110
|
+
|
|
111
|
+
// Label
|
|
112
|
+
const text = createSvgText(labelWidth - 6, y + barHeight / 2 + 4, name, {
|
|
113
|
+
fill: '#8892b0', 'font-size': '11', 'text-anchor': 'end',
|
|
114
|
+
});
|
|
115
|
+
svg.appendChild(text);
|
|
116
|
+
|
|
117
|
+
// Bar
|
|
118
|
+
const rect = document.createElementNS(SVG_NS, 'rect');
|
|
119
|
+
setAttrs(rect, {
|
|
120
|
+
x: labelWidth, y: y,
|
|
121
|
+
width: barW, height: barHeight,
|
|
122
|
+
rx: 3, fill: '#00e5ff', opacity: 0.85,
|
|
123
|
+
});
|
|
124
|
+
rect.addEventListener('mouseenter', (e) => {
|
|
125
|
+
rect.setAttribute('opacity', '1');
|
|
126
|
+
showTooltip(name + ': ' + formatNumber(count) + ' (' + pct + '%)', e.pageX, e.pageY);
|
|
127
|
+
});
|
|
128
|
+
rect.addEventListener('mouseleave', () => {
|
|
129
|
+
rect.setAttribute('opacity', '0.85');
|
|
130
|
+
hideTooltip();
|
|
131
|
+
});
|
|
132
|
+
svg.appendChild(rect);
|
|
133
|
+
|
|
134
|
+
// Value + percentage
|
|
135
|
+
const valText = createSvgText(labelWidth + barW + 6, y + barHeight / 2 + 4,
|
|
136
|
+
formatNumber(count) + ' (' + pct + '%)', {
|
|
137
|
+
fill: '#ccd6f6', 'font-size': '11',
|
|
138
|
+
});
|
|
139
|
+
svg.appendChild(valText);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
chartDiv.appendChild(svg);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -- 3. Duration Trends --
|
|
146
|
+
|
|
147
|
+
function renderDurationTrends(data) {
|
|
148
|
+
const container = document.getElementById('duration-trends-chart');
|
|
149
|
+
container.innerHTML = '';
|
|
150
|
+
|
|
151
|
+
const points = Array.isArray(data) ? data : (data.buckets || data.trends || []);
|
|
152
|
+
if (points.length === 0) {
|
|
153
|
+
container.insertAdjacentHTML('beforeend', '<div class="tab-empty">No duration data</div>');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const chartDiv = document.createElement('div');
|
|
158
|
+
container.appendChild(chartDiv);
|
|
159
|
+
|
|
160
|
+
const svgWidth = chartDiv.clientWidth || container.clientWidth || 500;
|
|
161
|
+
const height = 250;
|
|
162
|
+
const paddingLeft = 55;
|
|
163
|
+
const paddingRight = 15;
|
|
164
|
+
const paddingTop = 15;
|
|
165
|
+
const paddingBottom = 30;
|
|
166
|
+
const chartW = svgWidth - paddingLeft - paddingRight;
|
|
167
|
+
const chartH = height - paddingTop - paddingBottom;
|
|
168
|
+
|
|
169
|
+
const lineData = points.map(p => {
|
|
170
|
+
const raw = p.period || p.timestamp || p.date || p.label;
|
|
171
|
+
let label = String(raw);
|
|
172
|
+
// Try to parse date strings like "2024-01-15" into "Jan 15"
|
|
173
|
+
if (typeof raw === 'string' && /^\d{4}-\d{2}-\d{2}/.test(raw)) {
|
|
174
|
+
const date = new Date(raw + 'T00:00:00');
|
|
175
|
+
if (!isNaN(date.getTime())) {
|
|
176
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
177
|
+
label = months[date.getMonth()] + ' ' + date.getDate();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { label, value: p.avg_duration || p.avg_duration_ms || 0 };
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const maxVal = Math.max(...lineData.map(d => d.value), 1);
|
|
184
|
+
const color = '#00e5ff';
|
|
185
|
+
|
|
186
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
187
|
+
svg.setAttribute('width', '100%');
|
|
188
|
+
svg.setAttribute('height', height);
|
|
189
|
+
svg.setAttribute('viewBox', '0 0 ' + svgWidth + ' ' + height);
|
|
190
|
+
|
|
191
|
+
// Y-axis with duration formatting
|
|
192
|
+
for (let i = 0; i <= 4; i++) {
|
|
193
|
+
const val = (maxVal / 4) * i;
|
|
194
|
+
const y = paddingTop + chartH - (i / 4) * chartH;
|
|
195
|
+
|
|
196
|
+
const text = createSvgText(paddingLeft - 6, y + 4, formatDuration(val), {
|
|
197
|
+
fill: '#8892b0', 'font-size': '10', 'text-anchor': 'end',
|
|
198
|
+
});
|
|
199
|
+
svg.appendChild(text);
|
|
200
|
+
|
|
201
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
202
|
+
setAttrs(line, {
|
|
203
|
+
x1: paddingLeft, y1: y,
|
|
204
|
+
x2: svgWidth - paddingRight, y2: y,
|
|
205
|
+
stroke: '#1e2a4a', 'stroke-width': 1,
|
|
206
|
+
});
|
|
207
|
+
svg.appendChild(line);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Build coordinate points
|
|
211
|
+
const pts = lineData.map((d, i) => {
|
|
212
|
+
const x = paddingLeft + (i / Math.max(lineData.length - 1, 1)) * chartW;
|
|
213
|
+
const y = paddingTop + chartH - (d.value / maxVal) * chartH;
|
|
214
|
+
return { x: x, y: y, label: d.label, value: d.value };
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Area fill
|
|
218
|
+
if (pts.length > 1) {
|
|
219
|
+
const areaPoints = [
|
|
220
|
+
pts[0].x + ',' + (paddingTop + chartH),
|
|
221
|
+
...pts.map(p => p.x + ',' + p.y),
|
|
222
|
+
pts[pts.length - 1].x + ',' + (paddingTop + chartH),
|
|
223
|
+
].join(' ');
|
|
224
|
+
const polygon = document.createElementNS(SVG_NS, 'polygon');
|
|
225
|
+
setAttrs(polygon, { points: areaPoints, fill: color, opacity: 0.1 });
|
|
226
|
+
svg.appendChild(polygon);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Line
|
|
230
|
+
if (pts.length > 1) {
|
|
231
|
+
const polyline = document.createElementNS(SVG_NS, 'polyline');
|
|
232
|
+
setAttrs(polyline, {
|
|
233
|
+
points: pts.map(p => p.x + ',' + p.y).join(' '),
|
|
234
|
+
fill: 'none', stroke: color,
|
|
235
|
+
'stroke-width': 2, 'stroke-linejoin': 'round',
|
|
236
|
+
});
|
|
237
|
+
svg.appendChild(polyline);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Dots with duration tooltip
|
|
241
|
+
pts.forEach(p => {
|
|
242
|
+
const circle = document.createElementNS(SVG_NS, 'circle');
|
|
243
|
+
setAttrs(circle, { cx: p.x, cy: p.y, r: 3, fill: color });
|
|
244
|
+
circle.addEventListener('mouseenter', (e) => showTooltip(p.label + ': ' + formatDuration(p.value), e.pageX, e.pageY));
|
|
245
|
+
circle.addEventListener('mouseleave', hideTooltip);
|
|
246
|
+
svg.appendChild(circle);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// X-axis labels
|
|
250
|
+
const labelStep = Math.max(1, Math.floor(lineData.length / 10));
|
|
251
|
+
pts.forEach((p, i) => {
|
|
252
|
+
if (i % labelStep !== 0 && i !== pts.length - 1) return;
|
|
253
|
+
const text = createSvgText(p.x, height - 6, p.label, {
|
|
254
|
+
fill: '#8892b0', 'font-size': '9', 'text-anchor': 'middle',
|
|
255
|
+
});
|
|
256
|
+
svg.appendChild(text);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
chartDiv.appendChild(svg);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -- 4. Active Projects --
|
|
263
|
+
|
|
264
|
+
function renderActiveProjects(data) {
|
|
265
|
+
const container = document.getElementById('active-projects-chart');
|
|
266
|
+
container.innerHTML = '';
|
|
267
|
+
|
|
268
|
+
const projects = (Array.isArray(data) ? data : data.projects || [])
|
|
269
|
+
.sort((a, b) => (b.session_count || 0) - (a.session_count || 0));
|
|
270
|
+
|
|
271
|
+
if (projects.length === 0) {
|
|
272
|
+
container.insertAdjacentHTML('beforeend', '<div class="tab-empty">No project data</div>');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const chartDiv = document.createElement('div');
|
|
277
|
+
container.appendChild(chartDiv);
|
|
278
|
+
|
|
279
|
+
const barHeight = 22;
|
|
280
|
+
const gap = 4;
|
|
281
|
+
const labelWidth = 130;
|
|
282
|
+
const valueWidth = 160;
|
|
283
|
+
const totalHeight = projects.length * (barHeight + gap) - gap;
|
|
284
|
+
const svgWidth = chartDiv.clientWidth || container.clientWidth || 500;
|
|
285
|
+
const barAreaWidth = Math.max(50, svgWidth - labelWidth - valueWidth - 10);
|
|
286
|
+
const maxVal = Math.max(...projects.map(p => p.session_count || 0), 1);
|
|
287
|
+
|
|
288
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
289
|
+
svg.setAttribute('width', '100%');
|
|
290
|
+
svg.setAttribute('height', totalHeight);
|
|
291
|
+
svg.setAttribute('viewBox', '0 0 ' + svgWidth + ' ' + totalHeight);
|
|
292
|
+
|
|
293
|
+
projects.forEach((p, i) => {
|
|
294
|
+
const y = i * (barHeight + gap);
|
|
295
|
+
const count = p.session_count || 0;
|
|
296
|
+
const barW = Math.max(1, (count / maxVal) * barAreaWidth);
|
|
297
|
+
const name = p.project_name || p.name || p.project_path || '';
|
|
298
|
+
const lastActive = (p.last_activity || p.last_active_at) ? formatDate(p.last_activity || p.last_active_at) : '';
|
|
299
|
+
|
|
300
|
+
// Project name
|
|
301
|
+
const text = createSvgText(labelWidth - 6, y + barHeight / 2 + 4, name, {
|
|
302
|
+
fill: '#8892b0', 'font-size': '11', 'text-anchor': 'end',
|
|
303
|
+
});
|
|
304
|
+
svg.appendChild(text);
|
|
305
|
+
|
|
306
|
+
// Bar
|
|
307
|
+
const rect = document.createElementNS(SVG_NS, 'rect');
|
|
308
|
+
setAttrs(rect, {
|
|
309
|
+
x: labelWidth, y: y,
|
|
310
|
+
width: barW, height: barHeight,
|
|
311
|
+
rx: 3, fill: '#00e5ff', opacity: 0.85,
|
|
312
|
+
});
|
|
313
|
+
rect.addEventListener('mouseenter', (e) => {
|
|
314
|
+
rect.setAttribute('opacity', '1');
|
|
315
|
+
showTooltip(name + ': ' + formatNumber(count) + ' sessions, ' + formatNumber(p.total_prompts || 0) + ' prompts, ' + formatNumber(p.total_tools || 0) + ' tools', e.pageX, e.pageY);
|
|
316
|
+
});
|
|
317
|
+
rect.addEventListener('mouseleave', () => {
|
|
318
|
+
rect.setAttribute('opacity', '0.85');
|
|
319
|
+
hideTooltip();
|
|
320
|
+
});
|
|
321
|
+
svg.appendChild(rect);
|
|
322
|
+
|
|
323
|
+
// Session count and last active
|
|
324
|
+
const detail = formatNumber(count) + ' sessions' + (lastActive ? ' | ' + lastActive : '');
|
|
325
|
+
const valText = createSvgText(labelWidth + barW + 6, y + barHeight / 2 + 4, detail, {
|
|
326
|
+
fill: '#ccd6f6', 'font-size': '11',
|
|
327
|
+
});
|
|
328
|
+
svg.appendChild(valText);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
chartDiv.appendChild(svg);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// -- 5. Daily Heatmap --
|
|
335
|
+
|
|
336
|
+
function renderHeatmap(data) {
|
|
337
|
+
const container = document.getElementById('daily-heatmap-chart');
|
|
338
|
+
container.innerHTML = '';
|
|
339
|
+
|
|
340
|
+
const rawData = Array.isArray(data) ? data : (data.cells || data.heatmap || []);
|
|
341
|
+
if (rawData.length === 0) {
|
|
342
|
+
container.insertAdjacentHTML('beforeend', '<div class="tab-empty">No heatmap data</div>');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const dayLabelsFull = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
347
|
+
const dayLabelsShort = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
348
|
+
|
|
349
|
+
const gridData = rawData.map(d => ({
|
|
350
|
+
row: d.day_of_week != null ? d.day_of_week : (d.day != null ? d.day : d.row),
|
|
351
|
+
col: d.hour != null ? d.hour : d.col,
|
|
352
|
+
value: d.count || d.value || 0,
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
const cellSize = 14;
|
|
356
|
+
const gapSize = 2;
|
|
357
|
+
const colorMin = '#12122a';
|
|
358
|
+
const colorMax = '#00ff88';
|
|
359
|
+
|
|
360
|
+
const maxVal = Math.max(...gridData.map(d => d.value), 1);
|
|
361
|
+
const valueMap = new Map();
|
|
362
|
+
gridData.forEach(d => valueMap.set(d.row + '-' + d.col, d.value));
|
|
363
|
+
|
|
364
|
+
const chartDiv = document.createElement('div');
|
|
365
|
+
container.appendChild(chartDiv);
|
|
366
|
+
|
|
367
|
+
const grid = document.createElement('div');
|
|
368
|
+
grid.style.display = 'grid';
|
|
369
|
+
grid.style.gridTemplateColumns = '40px repeat(24, ' + cellSize + 'px)';
|
|
370
|
+
grid.style.gridTemplateRows = cellSize + 'px repeat(7, ' + cellSize + 'px)';
|
|
371
|
+
grid.style.gap = gapSize + 'px';
|
|
372
|
+
grid.style.alignItems = 'center';
|
|
373
|
+
|
|
374
|
+
// Top-left empty corner
|
|
375
|
+
grid.appendChild(document.createElement('div'));
|
|
376
|
+
|
|
377
|
+
// Hour labels (top row)
|
|
378
|
+
for (let h = 0; h < 24; h++) {
|
|
379
|
+
const lbl = document.createElement('div');
|
|
380
|
+
lbl.textContent = h;
|
|
381
|
+
lbl.style.fontSize = '9px';
|
|
382
|
+
lbl.style.color = '#8892b0';
|
|
383
|
+
lbl.style.textAlign = 'center';
|
|
384
|
+
grid.appendChild(lbl);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Rows
|
|
388
|
+
for (let r = 0; r < 7; r++) {
|
|
389
|
+
// Day label
|
|
390
|
+
const dayLbl = document.createElement('div');
|
|
391
|
+
dayLbl.textContent = dayLabelsShort[r];
|
|
392
|
+
dayLbl.style.fontSize = '10px';
|
|
393
|
+
dayLbl.style.color = '#8892b0';
|
|
394
|
+
dayLbl.style.textAlign = 'right';
|
|
395
|
+
dayLbl.style.paddingRight = '4px';
|
|
396
|
+
grid.appendChild(dayLbl);
|
|
397
|
+
|
|
398
|
+
for (let c = 0; c < 24; c++) {
|
|
399
|
+
const val = valueMap.get(r + '-' + c) || 0;
|
|
400
|
+
const cell = document.createElement('div');
|
|
401
|
+
cell.style.width = cellSize + 'px';
|
|
402
|
+
cell.style.height = cellSize + 'px';
|
|
403
|
+
cell.style.borderRadius = '2px';
|
|
404
|
+
cell.style.backgroundColor = interpolateColor(val, 0, maxVal, colorMin, colorMax);
|
|
405
|
+
cell.style.cursor = 'pointer';
|
|
406
|
+
const tipText = dayLabelsFull[r] + ' ' + c.toString().padStart(2, '0') + ':00 - ' + val + ' events';
|
|
407
|
+
cell.addEventListener('mouseenter', (e) => showTooltip(tipText, e.pageX, e.pageY));
|
|
408
|
+
cell.addEventListener('mouseleave', hideTooltip);
|
|
409
|
+
grid.appendChild(cell);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
chartDiv.appendChild(grid);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// -- Helpers --
|
|
417
|
+
|
|
418
|
+
function formatDuration(ms) {
|
|
419
|
+
const s = Math.floor(ms / 1000);
|
|
420
|
+
const m = Math.floor(s / 60);
|
|
421
|
+
const h = Math.floor(m / 60);
|
|
422
|
+
if (h > 0) return h + 'h ' + (m % 60) + 'm';
|
|
423
|
+
if (m > 0) return m + 'm ' + (s % 60) + 's';
|
|
424
|
+
return s + 's';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function formatDate(ts) {
|
|
428
|
+
const d = new Date(ts);
|
|
429
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
430
|
+
return months[d.getMonth()] + ' ' + d.getDate();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function interpolateColor(value, min, max, colorStart, colorEnd) {
|
|
434
|
+
const t = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));
|
|
435
|
+
const r1 = parseInt(colorStart.slice(1, 3), 16);
|
|
436
|
+
const g1 = parseInt(colorStart.slice(3, 5), 16);
|
|
437
|
+
const b1 = parseInt(colorStart.slice(5, 7), 16);
|
|
438
|
+
const r2 = parseInt(colorEnd.slice(1, 3), 16);
|
|
439
|
+
const g2 = parseInt(colorEnd.slice(3, 5), 16);
|
|
440
|
+
const b2 = parseInt(colorEnd.slice(5, 7), 16);
|
|
441
|
+
const r = Math.round(r1 + (r2 - r1) * t);
|
|
442
|
+
const g = Math.round(g1 + (g2 - g1) * t);
|
|
443
|
+
const b = Math.round(b1 + (b2 - b1) * t);
|
|
444
|
+
return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function escapeHtml(str) {
|
|
448
|
+
if (!str) return '';
|
|
449
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function createSvgText(x, y, content, attrs) {
|
|
453
|
+
const text = document.createElementNS(SVG_NS, 'text');
|
|
454
|
+
text.setAttribute('x', x);
|
|
455
|
+
text.setAttribute('y', y);
|
|
456
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
457
|
+
text.setAttribute(k, String(v));
|
|
458
|
+
}
|
|
459
|
+
text.textContent = content;
|
|
460
|
+
return text;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function setAttrs(el, attrs) {
|
|
464
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
465
|
+
el.setAttribute(k, String(v));
|
|
466
|
+
}
|
|
467
|
+
}
|