Diff -- /var/www/vhosts/webstls.com/httpdocs/assets/javascript/graphs.js
Diff

/var/www/vhosts/webstls.com/httpdocs/assets/javascript/graphs.js

added on WebSTLs (webstls.com) at 2026-07-01 22:26:27

Added
+1431
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 390051bd49ff
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1/* =====================================================================
2 WebSTLs -- graphs.js
3 All chart, sparkline and bar rendering. Pages opt in by adding
4 data attributes to the markup; this script renders them on
5 DOMContentLoaded and wires interactive tooltips + click-for-detail
6 on every chart automatically.
7
8 Markup:
9 <svg class="spark" data-points="14,18,16,22,28,24,32"></svg>
10 <div class="bars" data-values="40,80,55,90" data-labels="M,T,W,T"></div>
11 <div class="donut" data-segments='[{"label":"Storefront","value":33,"color":"#3b82f6"},...]'></div>
12 <div class="stack" data-series='[[12,18,22,28],[8,11,9,14]]' data-colors="#3b82f6,#7c3aed"></div>
13
14 Optional interactivity attributes (any chart element):
15 data-eyebrow="Revenue" -- small uppercase label on tip + modal
16 data-prefix="$" -- prepended to numbers ($1,234)
17 data-suffix="%" -- appended to numbers (74%)
18 data-decimals="0" -- digits after the decimal
19 data-fulllabels="Mon,Tue,..." -- richer labels for tip than data-labels
20 data-detail='[ ... ]' -- per-point detail JSON, see buildBarModal
21 data-no-tip="1" -- opt out of interactivity for a chart
22
23 Programmatic API:
24 window.WebSTLsGraphs.renderAll(root);
25 window.WebSTLsGraphs.sparkline(svg, [...numbers], {strokeColor, fillColor});
26 window.WebSTLsGraphs.bars(host, [...values], {labels, max, color});
27 window.WebSTLsGraphs.openModal(eyebrow, title, htmlBody);
28 window.WebSTLsGraphs.closeModal();
29===================================================================== */
30
31(function (global) {
32 'use strict';
33
34 const DEFAULTS = {
35 sparkColor: '#60a5fa',
36 sparkFill: 'rgba(59,130,246,0.18)',
37 barGrad: 'linear-gradient(180deg,#7c3aed 0%,#3b82f6 100%)',
38 donutColors: ['#3b82f6', '#7c3aed', '#06b6d4', '#22c55e', '#f59e0b', '#ec4899'],
39 };
40
41 /* ==================================================================
42 CHROME -- inject tooltip + detail-modal markup and CSS once.
43 Re-uses the same singleton across every chart on the page.
44 ================================================================== */
45 let chromeReady = false;
46 function ensureChrome() {
47 if (chromeReady) return;
48 chromeReady = true;
49
50 const style = document.createElement('style');
51 style.textContent = `
52 #wgTooltip {
53 position: fixed; z-index: 8000; pointer-events: none;
54 background: var(--col-bg-2, #0a0e1c);
55 border: 1px solid var(--col-border-2, rgba(124,58,237,0.35));
56 border-radius: 12px;
57 padding: 12px 16px;
58 min-width: 180px; max-width: 300px;
59 opacity: 0; transition: opacity 0.1s;
60 box-shadow: 0 10px 40px rgba(0,0,0,0.7);
61 backdrop-filter: blur(20px);
62 font-family: inherit;
63 }
64 .wg-tip-eyebrow {
65 font-size: 9px; font-weight: 700;
66 letter-spacing: 1.5px; text-transform: uppercase;
67 color: var(--col-text-3, #64748b);
68 margin-bottom: 5px;
69 }
70 .wg-tip-main {
71 font-size: 22px; font-weight: 700; line-height: 1.1;
72 color: var(--col-text, #f1f5f9);
73 }
74 .wg-tip-sub {
75 font-size: 11px; color: var(--col-text-2, #94a3b8);
76 margin-top: 5px;
77 }
78 .wg-tip-divider {
79 height: 1px; background: var(--col-border, rgba(255,255,255,0.07));
80 margin: 8px 0;
81 }
82 .wg-tip-row {
83 display: flex; justify-content: space-between;
84 gap: 14px;
85 font-size: 11px; color: var(--col-text-2, #94a3b8);
86 padding: 2px 0;
87 }
88 .wg-tip-val {
89 font-weight: 700; color: var(--col-text, #e2e8f0);
90 }
91 .wg-tip-hint {
92 font-size: 10px; color: var(--col-accent-bright, #a78bfa);
93 margin-top: 8px; padding-top: 6px;
94 border-top: 1px solid var(--col-border, rgba(124,58,237,0.18));
95 }
96
97 #wgModalOverlay {
98 position: fixed; inset: 0; z-index: 9000;
99 background: rgba(0,0,0,0);
100 display: none; align-items: center; justify-content: center;
101 padding: 20px;
102 }
103 #wgModalOverlay.open {
104 display: flex;
105 animation: wgOvIn 0.22s ease forwards;
106 }
107 @keyframes wgOvIn { to { background: rgba(0,0,0,0.74); } }
108 @keyframes wgPanIn {
109 from { opacity: 0; transform: scale(0.95) translateY(18px); }
110 to { opacity: 1; transform: scale(1) translateY(0); }
111 }
112 .wg-modal-panel {
113 width: 580px; max-width: 94vw; max-height: 88vh;
114 overflow-y: auto;
115 background: var(--col-bg, #070a1c);
116 border: 1px solid var(--col-border-2, rgba(124,58,237,0.25));
117 border-radius: 16px;
118 box-shadow: 0 28px 90px rgba(0,0,0,0.85);
119 animation: wgPanIn 0.28s cubic-bezier(0.34,1.56,0.64,1) forwards;
120 }
121 .wg-modal-head {
122 display: flex; align-items: center; justify-content: space-between;
123 padding: 20px 24px;
124 border-bottom: 1px solid var(--col-border, rgba(255,255,255,0.07));
125 /* Solid base first, accent gradient layered on top so scrolling
126 body content never bleeds through the sticky header. */
127 background-color: var(--col-bg, #070a1c);
128 background-image: var(--grad-brand-soft, linear-gradient(135deg,rgba(124,58,237,0.10),rgba(59,130,246,0.06)));
129 position: sticky; top: 0; z-index: 5;
130 }
131 .wg-modal-head-left { flex: 1; min-width: 0; }
132 .wg-modal-eye {
133 font-size: 10px; font-weight: 700;
134 letter-spacing: 1.5px; text-transform: uppercase;
135 color: var(--col-text-3, #64748b);
136 margin-bottom: 4px;
137 }
138 .wg-modal-title {
139 font-size: 22px; font-weight: 700;
140 color: var(--col-text, #f1f5f9);
141 }
142 .wg-modal-close {
143 width: 32px; height: 32px; border-radius: 8px;
144 background: var(--col-surface-2, rgba(255,255,255,0.06));
145 border: 1px solid var(--col-border, rgba(255,255,255,0.1));
146 color: var(--col-text-3, #94a3b8);
147 cursor: pointer;
148 display: grid; place-items: center;
149 font-size: 14px;
150 flex-shrink: 0;
151 transition: all 0.15s;
152 }
153 .wg-modal-close:hover {
154 background: rgba(239,68,68,0.15);
155 border-color: rgba(239,68,68,0.3);
156 color: #f87171;
157 }
158 .wg-modal-body { padding: 22px 24px; }
159 .wg-modal-kpis {
160 display: grid;
161 grid-template-columns: repeat(3, 1fr);
162 gap: 12px; margin-bottom: 20px;
163 }
164 .wg-modal-kpi {
165 background: var(--col-surface-1, rgba(255,255,255,0.03));
166 border: 1px solid var(--col-border, rgba(255,255,255,0.07));
167 border-radius: 10px;
168 padding: 14px;
169 }
170 .wg-modal-kpi-lbl {
171 font-size: 9px; font-weight: 700;
172 letter-spacing: 1px; text-transform: uppercase;
173 color: var(--col-text-3, #64748b);
174 margin-bottom: 6px;
175 }
176 .wg-modal-kpi-val {
177 font-size: 21px; font-weight: 700;
178 color: var(--col-text, #f1f5f9);
179 }
180 .wg-modal-kpi-sub {
181 font-size: 11px; color: var(--col-text-2, #94a3b8);
182 margin-top: 3px;
183 }
184 .wg-modal-sec { margin-bottom: 18px; }
185 .wg-modal-sec-title {
186 font-size: 10px; font-weight: 700;
187 letter-spacing: 1.5px; text-transform: uppercase;
188 color: var(--col-text-3, #64748b);
189 margin-bottom: 10px; padding-bottom: 7px;
190 border-bottom: 1px solid var(--col-border, rgba(255,255,255,0.05));
191 }
192 /* Area chart inside the KPI modal. The SVG is rendered with a
193 100x100 viewBox + non-scaling strokes so paths come out crisp
194 at any container width. Hover wiring lives in wireAreaChart. */
195 .wg-area-chart {
196 position: relative;
197 background: linear-gradient(180deg, rgba(15,23,42,0.65), rgba(8,12,28,0.45));
198 border: 1px solid var(--col-border, rgba(255,255,255,0.06));
199 border-radius: 12px;
200 padding: 14px 16px 8px;
201 margin-top: 4px;
202 }
203 .wg-area-svg {
204 display: block;
205 width: 100%;
206 height: 220px;
207 overflow: visible;
208 }
209 .wg-area-grid { stroke: rgba(148,163,184,0.10); stroke-width: 0.2; }
210 .wg-area-marker-line { stroke: rgba(255,255,255,0.40); stroke-width: 0.25; }
211 .wg-area-overlay { cursor: crosshair; }
212 .wg-area-xaxis {
213 position: relative;
214 height: 16px;
215 margin-top: 6px;
216 font-size: 10px;
217 color: var(--col-text-3, #64748b);
218 font-family: var(--font-mono, monospace);
219 letter-spacing: 0.5px;
220 }
221 .wg-area-xlbl {
222 position: absolute;
223 transform: translateX(-50%);
224 white-space: nowrap;
225 }
226 .wg-area-tip {
227 position: absolute;
228 pointer-events: none;
229 background: #0b1023;
230 color: #fff;
231 border: 1px solid var(--col-border, rgba(255,255,255,0.10));
232 border-radius: 8px;
233 padding: 9px 12px;
234 font-size: 11.5px;
235 line-height: 1.45;
236 min-width: 160px;
237 box-shadow: 0 14px 32px rgba(0,0,0,0.55);
238 z-index: 10;
239 }
240 .wg-area-tip-date {
241 font-weight: 700;
242 color: #fff;
243 margin-bottom: 6px;
244 font-size: 12px;
245 }
246 .wg-area-tip-row {
247 display: flex;
248 justify-content: space-between;
249 gap: 12px;
250 font-family: var(--font-mono, monospace);
251 }
252 .wg-area-tip-row span { color: #94a3b8; }
253 .wg-area-tip-row strong { font-weight: 700; }
254 .wg-area-summary {
255 display: flex;
256 gap: 14px;
257 flex-wrap: wrap;
258 font-size: 11px;
259 color: var(--col-text-3, #64748b);
260 margin-bottom: 10px;
261 letter-spacing: 0.5px;
262 }
263 .wg-area-summary strong { color: #fff; font-weight: 700; }
264 .wg-bar-row {
265 display: flex; align-items: center; gap: 10px;
266 margin-bottom: 8px;
267 }
268 .wg-bar-lbl {
269 font-size: 12px; color: var(--col-text-2, #94a3b8);
270 min-width: 100px;
271 }
272 .wg-bar-track {
273 flex: 1; height: 7px;
274 background: var(--col-surface-2, rgba(255,255,255,0.06));
275 border-radius: 4px; overflow: hidden;
276 }
277 .wg-bar-fill { height: 100%; border-radius: 4px; }
278 .wg-bar-pct {
279 font-size: 12px; font-weight: 700;
280 color: var(--col-text, #e2e8f0);
281 min-width: 80px; text-align: right;
282 }
283 .wg-list { display: flex; flex-direction: column; gap: 7px; }
284 .wg-li {
285 display: flex; align-items: center; justify-content: space-between;
286 padding: 10px 14px;
287 background: var(--col-surface-1, rgba(255,255,255,0.03));
288 border: 1px solid var(--col-border, rgba(255,255,255,0.06));
289 border-radius: 9px;
290 }
291 .wg-li-name { font-size: 13px; font-weight: 600; color: var(--col-text, #e2e8f0); }
292 .wg-li-val { font-size: 12px; color: var(--col-text-2, #94a3b8); font-weight: 600; }
293
294 /* "Deep-dive" sections: description / methodology / interpretation
295 render as full-width prose blocks. Each is labelled with a small
296 uppercase eyebrow so the reader can scan for the part they want
297 (what is this? how was it computed? what does it mean?). */
298 .wg-prose {
299 background: var(--col-surface-1, rgba(255,255,255,0.03));
300 border: 1px solid var(--col-border, rgba(255,255,255,0.06));
301 border-radius: 10px;
302 padding: 14px 16px;
303 margin-bottom: 14px;
304 }
305 .wg-prose-lbl {
306 font-size: 9px; font-weight: 700;
307 letter-spacing: 1.5px; text-transform: uppercase;
308 color: var(--col-accent-bright, #a78bfa);
309 margin-bottom: 7px;
310 }
311 .wg-prose-body {
312 font-size: 13px; line-height: 1.6;
313 color: var(--col-text-2, #cbd5e1);
314 }
315 .wg-prose-body strong { color: var(--col-text, #f1f5f9); }
316 .wg-prose-body code, .wg-prose-body .kbd {
317 background: var(--col-surface-2, rgba(255,255,255,0.06));
318 border: 1px solid var(--col-border, rgba(255,255,255,0.07));
319 border-radius: 4px;
320 padding: 1px 5px;
321 font-family: var(--font-mono, monospace);
322 font-size: 11px;
323 color: var(--col-text, #e2e8f0);
324 }
325
326 /* Insight rows: punchy auto-generated observations + any author-
327 provided ones. Each row is a labelled chip + one-line takeaway. */
328 .wg-insights { display: flex; flex-direction: column; gap: 8px; }
329 .wg-insight {
330 display: flex; align-items: flex-start; gap: 12px;
331 padding: 11px 14px;
332 background: var(--col-surface-1, rgba(255,255,255,0.03));
333 border: 1px solid var(--col-border, rgba(255,255,255,0.06));
334 border-left-width: 3px;
335 border-radius: 8px;
336 }
337 .wg-insight.tone-good { border-left-color: #4ade80; }
338 .wg-insight.tone-warn { border-left-color: #fbbf24; }
339 .wg-insight.tone-bad { border-left-color: #f87171; }
340 .wg-insight.tone-info { border-left-color: var(--col-accent-bright, #a78bfa); }
341 .wg-insight-lbl {
342 font-size: 9px; font-weight: 700;
343 letter-spacing: 1.5px; text-transform: uppercase;
344 color: var(--col-text-3, #64748b);
345 min-width: 90px;
346 padding-top: 1px;
347 }
348 .wg-insight.tone-good .wg-insight-lbl { color: #4ade80; }
349 .wg-insight.tone-warn .wg-insight-lbl { color: #fbbf24; }
350 .wg-insight.tone-bad .wg-insight-lbl { color: #f87171; }
351 .wg-insight.tone-info .wg-insight-lbl { color: var(--col-accent-bright, #a78bfa); }
352 .wg-insight-body {
353 font-size: 13px; line-height: 1.5;
354 color: var(--col-text-2, #cbd5e1);
355 flex: 1;
356 }
357
358 /* Drilldown links: navigate to a more detailed view of the data
359 that produced this chart point. Rendered as a row of pill-style
360 link buttons at the bottom of the modal. */
361 .wg-drilldowns {
362 display: flex; flex-wrap: wrap; gap: 8px;
363 padding-top: 6px;
364 }
365 .wg-drilldown {
366 display: inline-flex; align-items: center; gap: 6px;
367 padding: 8px 14px;
368 background: var(--col-surface-2, rgba(255,255,255,0.06));
369 border: 1px solid var(--col-border, rgba(255,255,255,0.07));
370 border-radius: 8px;
371 color: var(--col-text, #e2e8f0);
372 text-decoration: none;
373 font-size: 12px; font-weight: 600;
374 transition: all 0.12s;
375 }
376 .wg-drilldown:hover {
377 border-color: var(--col-accent-bright, #a78bfa);
378 color: var(--col-accent-bright, #a78bfa);
379 background: var(--col-surface-3, rgba(124,58,237,0.10));
380 }
381 .wg-drilldown .arrow { font-size: 14px; }
382
383 .bar { cursor: pointer; transition: filter 0.15s, transform 0.1s; }
384 .bar:hover { filter: brightness(1.25); }
385 .spark { cursor: pointer; }
386
387 [data-kpi-detail] {
388 cursor: pointer;
389 transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
390 border-radius: 8px;
391 }
392 /* Cards and bordered containers: tint the existing border instead
393 of stacking an outline on top of it (avoids the "two-line halo"
394 look around module containers). */
395 [data-kpi-detail]:not(tr):not(td):hover {
396 transform: translateY(-2px);
397 border-color: var(--col-accent, #7c3aed);
398 box-shadow: 0 10px 24px rgba(0,0,0,0.35);
399 }
400 /* Table rows: highlight cells instead of lifting the whole row. */
401 tr[data-kpi-detail]:hover td {
402 background-color: rgba(124,58,237,0.10);
403 }
404 `;
405 document.head.appendChild(style);
406
407 const tip = document.createElement('div');
408 tip.id = 'wgTooltip';
409 document.body.appendChild(tip);
410
411 const overlay = document.createElement('div');
412 overlay.id = 'wgModalOverlay';
413 overlay.innerHTML =
414 '<div class="wg-modal-panel">' +
415 '<div class="wg-modal-head">' +
416 '<div class="wg-modal-head-left">' +
417 '<div class="wg-modal-eye" id="wgModalEye"></div>' +
418 '<div class="wg-modal-title" id="wgModalTitle"></div>' +
419 '</div>' +
420 '<button class="wg-modal-close" type="button" aria-label="Close">&times;</button>' +
421 '</div>' +
422 '<div class="wg-modal-body" id="wgModalBody"></div>' +
423 '</div>';
424 document.body.appendChild(overlay);
425
426 overlay.addEventListener('click', function (e) {
427 if (e.target === overlay) closeModal();
428 });
429 overlay.querySelector('.wg-modal-close').addEventListener('click', closeModal);
430 document.addEventListener('keydown', function (e) {
431 if (e.key === 'Escape') closeModal();
432 });
433
434 document.addEventListener('mousemove', function (e) {
435 if (parseFloat(tip.style.opacity || '0') > 0) {
436 let x = e.clientX + 16, y = e.clientY - 10;
437 const w = tip.offsetWidth || 280, h = tip.offsetHeight || 160;
438 if (x + w > window.innerWidth) x = e.clientX - w - 8;
439 if (y + h > window.innerHeight) y = e.clientY - h - 8;
440 if (x < 8) x = 8;
441 if (y < 8) y = 8;
442 tip.style.left = x + 'px';
443 tip.style.top = y + 'px';
444 }
445 });
446 }
447
448 function showTip(html) {
449 ensureChrome();
450 const tip = document.getElementById('wgTooltip');
451 tip.innerHTML = html;
452 tip.style.opacity = '1';
453 }
454 function hideTip() {
455 const tip = document.getElementById('wgTooltip');
456 if (tip) tip.style.opacity = '0';
457 }
458
459 function openModal(eyebrow, title, html) {
460 ensureChrome();
461 document.getElementById('wgModalEye').textContent = eyebrow || '';
462 document.getElementById('wgModalTitle').textContent = title || '';
463 document.getElementById('wgModalBody').innerHTML = html || '';
464 document.getElementById('wgModalOverlay').classList.add('open');
465 document.body.style.overflow = 'hidden';
466 }
467 function closeModal() {
468 const o = document.getElementById('wgModalOverlay');
469 if (!o) return;
470 o.classList.remove('open');
471 document.body.style.overflow = '';
472 }
473
474 /* ==================================================================
475 FORMATTING + CONTEXT HELPERS
476 ================================================================== */
477 function fmt(v, prefix, suffix, decimals) {
478 if (v === null || v === undefined || isNaN(v)) return '-';
479 const d = (decimals === undefined || decimals === null || decimals === '') ? 0 : Number(decimals);
480 let s;
481 if (d > 0) {
482 s = Number(v).toFixed(d);
483 } else {
484 s = Math.round(Number(v)).toLocaleString();
485 }
486 return (prefix || '') + s + (suffix || '');
487 }
488
489 function nearestTitle(el) {
490 const mod = el.closest('.module, .chart-card, .card, section');
491 if (!mod) return '';
492 const t = mod.querySelector('.module-title, .chart-title, .card-title, h2, h3');
493 return t ? t.textContent.trim() : '';
494 }
495
496 function chartConfig(host) {
497 return {
498 eyebrow: host.dataset.eyebrow || nearestTitle(host) || '',
499 prefix: host.dataset.prefix || '',
500 suffix: host.dataset.suffix || '',
501 decimals: host.dataset.decimals,
502 fullLabels: (host.dataset.fulllabels || '').split(',').filter(function (s) { return s.length; }),
503 detail: (function () {
504 try { return host.dataset.detail ? JSON.parse(host.dataset.detail) : null; }
505 catch (e) { console.warn('graphs.js: bad data-detail JSON', e); return null; }
506 })(),
507 // Chart-level "deep dive" context. JSON object on `data-context`
508 // with any of: description, methodology, interpretation,
509 // insights[], drilldowns[]. Surfaces in every click on this chart
510 // so the modal can explain what the metric IS, how it's computed,
511 // and where to go to investigate further.
512 context: (function () {
513 try { return host.dataset.context ? JSON.parse(host.dataset.context) : null; }
514 catch (e) { console.warn('graphs.js: bad data-context JSON', e); return null; }
515 })(),
516 enabled: host.dataset.noTip !== '1',
517 };
518 }
519
520 /* ==================================================================
521 "DEEP DIVE" HELPERS -- shared between buildModalHtml (per-point on
522 a chart) and buildKpiModalBody (clicking a KPI tile). Each helper
523 returns an HTML string or '' when there's nothing to render, so the
524 caller can just concatenate.
525 ================================================================== */
526
527 // Prose section: description / methodology / interpretation.
528 function buildProseSection(label, body) {
529 if (!body) return '';
530 return '<div class="wg-prose">' +
531 '<div class="wg-prose-lbl">' + escapeHtml(label) + '</div>' +
532 '<div class="wg-prose-body">' + body + '</div>' +
533 '</div>';
534 }
535
536 // Insights list. Each item is either a string (treated as info-tone
537 // body text) or { label, value, tone } where tone is one of
538 // 'good' | 'warn' | 'bad' | 'info'. label is optional.
539 function buildInsightsSection(insights, title) {
540 if (!Array.isArray(insights) || !insights.length) return '';
541 let html = '<div class="wg-modal-sec">' +
542 '<div class="wg-modal-sec-title">' + escapeHtml(title || 'Insights') + '</div>' +
543 '<div class="wg-insights">';
544 insights.forEach(function (ins) {
545 let lbl, body, tone;
546 if (typeof ins === 'string') {
547 lbl = ''; body = ins; tone = 'info';
548 } else {
549 lbl = ins.label || '';
550 body = ins.value || ins.body || '';
551 tone = ins.tone || 'info';
552 }
553 html += '<div class="wg-insight tone-' + escapeAttr(tone) + '">';
554 if (lbl) html += '<div class="wg-insight-lbl">' + escapeHtml(lbl) + '</div>';
555 html += '<div class="wg-insight-body">' + escapeHtml(body) + '</div>';
556 html += '</div>';
557 });
558 html += '</div></div>';
559 return html;
560 }
561
562 // Drill-down link row at the bottom of the modal. Each drilldown is
563 // { label, href, target? }.
564 function buildDrilldownsSection(drilldowns) {
565 if (!Array.isArray(drilldowns) || !drilldowns.length) return '';
566 let html = '<div class="wg-modal-sec">' +
567 '<div class="wg-modal-sec-title">Dive deeper</div>' +
568 '<div class="wg-drilldowns">';
569 drilldowns.forEach(function (d) {
570 const href = d.href || '#';
571 const tgt = d.target ? ' target="' + escapeAttr(d.target) + '"' : '';
572 html += '<a class="wg-drilldown" href="' + escapeAttr(href) + '"' + tgt + '>' +
573 escapeHtml(d.label || 'Open') +
574 ' <span class="arrow">&rarr;</span>' +
575 '</a>';
576 });
577 html += '</div></div>';
578 return html;
579 }
580
581 // Auto-generate insights from a numeric series. Cheap, always-on
582 // analytics for charts that haven't authored bespoke insights.
583 // Returns an array of { label, value, tone } items.
584 function autoInsightsFromSeries(series, idx, cfg) {
585 const out = [];
586 if (!Array.isArray(series) || !series.length || idx == null) return out;
587
588 const value = Number(series[idx]) || 0;
589 const total = series.reduce(function (s, v) { return s + (Number(v) || 0); }, 0);
590 const avg = total / series.length;
591 const max = Math.max.apply(null, series);
592 const min = Math.min.apply(null, series);
593 const pct = total ? (value / total * 100) : 0;
594 const fmtN = function (n) { return fmt(n, cfg && cfg.prefix, cfg && cfg.suffix, cfg && cfg.decimals); };
595
596 // Skip insights entirely if the whole series is zero -- otherwise
597 // we generate noisy "0% of total / 0 below average" rows.
598 if (!total && !max) return out;
599
600 if (value === max && max !== min) {
601 out.push({ label: 'Peak', value: 'This is the highest point in the ' + series.length + '-point window. ' + fmtN(value) + ' is the ceiling to beat.', tone: 'good' });
602 } else if (value === min && max !== min && value === 0) {
603 out.push({ label: 'Zero day', value: 'No activity recorded for this point.', tone: 'warn' });
604 } else if (value === min && max !== min) {
605 out.push({ label: 'Trough', value: 'Lowest point in the window at ' + fmtN(value) + '. Worth checking if anything changed around this time.', tone: 'warn' });
606 }
607
608 if (avg && Math.abs(value - avg) >= 0.0001) {
609 const delta = ((value - avg) / avg * 100);
610 if (Math.abs(delta) >= 10) {
611 out.push({
612 label: delta > 0 ? 'Above average' : 'Below average',
613 value: (delta > 0 ? 'Up ' : 'Down ') + Math.abs(delta).toFixed(1) + '% vs the ' + series.length + '-point average of ' + fmtN(avg) + '.',
614 tone: delta > 0 ? 'good' : 'warn'
615 });
616 }
617 }
618
619 if (pct >= 15 && series.length > 3) {
620 out.push({
621 label: 'Concentrated',
622 value: 'This single point is ' + pct.toFixed(1) + '% of the period total -- a meaningful share.',
623 tone: 'info'
624 });
625 }
626
627 if (idx > 0) {
628 const prev = Number(series[idx - 1]) || 0;
629 if (prev || value) {
630 const dod = prev ? ((value - prev) / prev * 100) : (value ? 100 : 0);
631 if (Math.abs(dod) >= 10) {
632 out.push({
633 label: 'Vs previous',
634 value: (dod > 0 ? 'Up ' : 'Down ') + Math.abs(dod).toFixed(1) + '% from the previous point (' + fmtN(prev) + ').',
635 tone: dod > 0 ? 'good' : 'warn'
636 });
637 }
638 }
639 }
640
641 return out;
642 }
643
644
645 /* ==================================================================
646 TIP + MODAL BUILDERS
647 ================================================================== */
648 function buildTipHtml(cfg, label, value, series, idx) {
649 const total = series.reduce(function (s, v) { return s + (v || 0); }, 0);
650 const pct = total ? (value / total * 100) : 0;
651 const max = Math.max.apply(null, series);
652 const min = Math.min.apply(null, series);
653 const isHigh = (value === max);
654 const isLow = (value === min && min !== max);
655
656 let html = '';
657 if (cfg.eyebrow) {
658 html += '<div class="wg-tip-eyebrow">' + escapeHtml(cfg.eyebrow);
659 if (label) html += ' &middot; ' + escapeHtml(label);
660 html += '</div>';
661 } else if (label) {
662 html += '<div class="wg-tip-eyebrow">' + escapeHtml(label) + '</div>';
663 }
664 html += '<div class="wg-tip-main">' + fmt(value, cfg.prefix, cfg.suffix, cfg.decimals) + '</div>';
665 html += '<div class="wg-tip-sub">' + pct.toFixed(1) + '% of period total</div>';
666 html += '<div class="wg-tip-divider"></div>';
667 html += '<div class="wg-tip-row"><span>Position</span><span class="wg-tip-val">' +
668 (idx + 1) + ' of ' + series.length + '</span></div>';
669 if (isHigh) {
670 html += '<div class="wg-tip-row"><span>Peak</span><span class="wg-tip-val" style="color:#4ade80">highest in series</span></div>';
671 } else if (isLow) {
672 html += '<div class="wg-tip-row"><span>Trough</span><span class="wg-tip-val" style="color:#f87171">lowest in series</span></div>';
673 }
674 html += '<div class="wg-tip-hint">Click for breakdown</div>';
675 return html;
676 }
677
678 function buildModalHtml(cfg, label, value, series, idx) {
679 const total = series.reduce(function (s, v) { return s + (v || 0); }, 0);
680 const avg = total / (series.length || 1);
681 const max = Math.max.apply(null, series);
682 const pct = total ? (value / total * 100) : 0;
683 const vsAvg = value - avg;
684 const ctx = cfg.context || {};
685 const det = cfg.detail && cfg.detail[idx] || {};
686
687 /* ---- Hero KPI row ---------------------------------------------- */
688 let html = '<div class="wg-modal-kpis">';
689 html += '<div class="wg-modal-kpi"><div class="wg-modal-kpi-lbl">Value</div>' +
690 '<div class="wg-modal-kpi-val">' + fmt(value, cfg.prefix, cfg.suffix, cfg.decimals) + '</div>' +
691 '<div class="wg-modal-kpi-sub">' + pct.toFixed(1) + '% of total</div></div>';
692 html += '<div class="wg-modal-kpi"><div class="wg-modal-kpi-lbl">vs Average</div>' +
693 '<div class="wg-modal-kpi-val" style="color:' + (vsAvg >= 0 ? '#4ade80' : '#f87171') + '">' +
694 (vsAvg >= 0 ? '+' : '') + fmt(vsAvg, cfg.prefix, cfg.suffix, cfg.decimals) +
695 '</div>' +
696 '<div class="wg-modal-kpi-sub">Series avg ' + fmt(avg, cfg.prefix, cfg.suffix, cfg.decimals) + '</div></div>';
697 html += '<div class="wg-modal-kpi"><div class="wg-modal-kpi-lbl">Rank</div>' +
698 '<div class="wg-modal-kpi-val">#' + rankInSeries(value, series) + '</div>' +
699 '<div class="wg-modal-kpi-sub">of ' + series.length + ' points</div></div>';
700 html += '</div>';
701
702 /* ---- "What this is" + "How it's calculated" prose -------------- */
703 // Per-point description (from detail) takes priority; chart-level
704 // context fills in when there's no per-point copy.
705 html += buildProseSection('What this is', det.description || ctx.description || '');
706 html += buildProseSection('How it is calculated', det.methodology || ctx.methodology || '');
707
708 /* ---- Trend insights (auto + author-provided) ------------------- */
709 const insights = []
710 .concat(autoInsightsFromSeries(series, idx, cfg))
711 .concat(Array.isArray(det.insights) ? det.insights : [])
712 .concat(Array.isArray(ctx.insights) ? ctx.insights : []);
713 html += buildInsightsSection(insights, 'What the data shows');
714
715 /* ---- Interpretation prose ------------------------------------- */
716 html += buildProseSection('What it means', det.interpretation || ctx.interpretation || '');
717
718 /* ---- Per-point breakdown (splits + lists) --------------------- */
719 if (Array.isArray(det.split) && det.split.length) {
720 html += '<div class="wg-modal-sec"><div class="wg-modal-sec-title">' +
721 escapeHtml(det.splitTitle || 'Breakdown') + '</div>';
722 const splitTotal = det.split.reduce(function (s, x) { return s + (x.value || 0); }, 0) || value || 1;
723 det.split.forEach(function (s, i) {
724 const w = (s.value / splitTotal * 100).toFixed(1);
725 const col = s.color || DEFAULTS.donutColors[i % DEFAULTS.donutColors.length];
726 html += '<div class="wg-bar-row">' +
727 '<span class="wg-bar-lbl">' + escapeHtml(s.label || '') + '</span>' +
728 '<div class="wg-bar-track"><div class="wg-bar-fill" style="width:' + w + '%;background:' + col + '"></div></div>' +
729 '<span class="wg-bar-pct">' + fmt(s.value, cfg.prefix, cfg.suffix, cfg.decimals) + '</span>' +
730 '</div>';
731 });
732 html += '</div>';
733 }
734 if (Array.isArray(det.list) && det.list.length) {
735 html += '<div class="wg-modal-sec"><div class="wg-modal-sec-title">' +
736 escapeHtml(det.listTitle || 'Top contributors') + '</div><div class="wg-list">';
737 det.list.forEach(function (li) {
738 html += '<div class="wg-li"><span class="wg-li-name">' + escapeHtml(li.name || '') + '</span>' +
739 '<span class="wg-li-val">' + escapeHtml(li.value || '') + '</span></div>';
740 });
741 html += '</div></div>';
742 }
743
744 /* ---- Distribution across the whole series --------------------- */
745 // Always shown -- gives the reader a sense of where this point sits
746 // relative to every other point in the window. (Previously this was
747 // a fallback only; now it's a standard section.)
748 html += '<div class="wg-modal-sec"><div class="wg-modal-sec-title">Distribution across series</div>';
749 const baseColor = '#7c3aed';
750 series.forEach(function (v, i) {
751 const w = max ? (v / max * 100).toFixed(1) : 0;
752 const lbl = cfg.fullLabels[i] || ('#' + (i + 1));
753 const isThis = (i === idx);
754 html += '<div class="wg-bar-row">' +
755 '<span class="wg-bar-lbl" style="' + (isThis ? 'color:#a78bfa;font-weight:700' : '') + '">' +
756 escapeHtml(lbl) + (isThis ? ' (this)' : '') + '</span>' +
757 '<div class="wg-bar-track"><div class="wg-bar-fill" style="width:' + w + '%;background:' +
758 (isThis ? '#a78bfa' : baseColor) + ';opacity:' + (isThis ? '1' : '0.45') + '"></div></div>' +
759 '<span class="wg-bar-pct">' + fmt(v, cfg.prefix, cfg.suffix, cfg.decimals) + '</span>' +
760 '</div>';
761 });
762 html += '</div>';
763
764 /* ---- Drill-down links + note ---------------------------------- */
765 html += buildDrilldownsSection(
766 [].concat(Array.isArray(det.drilldowns) ? det.drilldowns : [])
767 .concat(Array.isArray(ctx.drilldowns) ? ctx.drilldowns : [])
768 );
769
770 if (det.note || ctx.note) {
771 html += '<div class="wg-modal-sec"><div class="wg-tip-sub">' + escapeHtml(det.note || ctx.note) + '</div></div>';
772 }
773
774 return html;
775 }
776
777 function rankInSeries(v, series) {
778 const sorted = series.slice().sort(function (a, b) { return b - a; });
779 return sorted.indexOf(v) + 1;
780 }
781
782 function escapeHtml(s) {
783 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
784 return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
785 });
786 }
787
788 /* ==================================================================
789 SPARKLINE
790 ================================================================== */
791 function sparkline(svg, points, opts) {
792 opts = Object.assign({
793 strokeColor: DEFAULTS.sparkColor,
794 fillColor: DEFAULTS.sparkFill,
795 strokeWidth: 2,
796 padTop: 4,
797 padBottom: 4,
798 }, opts || {});
799
800 const w = svg.clientWidth || svg.getAttribute('width') || 280;
801 const h = svg.clientHeight || svg.getAttribute('height') || 80;
802 if (!points || points.length < 2) { svg.innerHTML = ''; return; }
803
804 const min = Math.min.apply(null, points);
805 const max = Math.max.apply(null, points);
806 const span = (max - min) || 1;
807 const stepX = w / (points.length - 1);
808 const usable = h - opts.padTop - opts.padBottom;
809
810 const coords = points.map(function (v, i) {
811 const x = (i * stepX).toFixed(1);
812 const y = (h - opts.padBottom - ((v - min) / span) * usable).toFixed(1);
813 return x + ',' + y;
814 });
815 const linePath = 'M ' + coords.join(' L ');
816 const areaPath = linePath + ' L ' + w + ',' + h + ' L 0,' + h + ' Z';
817
818 svg.innerHTML =
819 '<path d="' + areaPath + '" fill="' + opts.fillColor + '" stroke="none"/>' +
820 '<path d="' + linePath + '" fill="none" stroke="' + opts.strokeColor +
821 '" stroke-width="' + opts.strokeWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
822
823 const cfg = chartConfig(svg);
824 if (!cfg.enabled) return;
825 ensureChrome();
826
827 svg.addEventListener('mousemove', function (e) {
828 const rect = svg.getBoundingClientRect();
829 let i = Math.round(((e.clientX - rect.left) / rect.width) * (points.length - 1));
830 if (i < 0) i = 0; if (i > points.length - 1) i = points.length - 1;
831 const label = cfg.fullLabels[i] || ('Point ' + (i + 1));
832 showTip(buildTipHtml(cfg, label, points[i], points, i));
833 });
834 svg.addEventListener('mouseleave', hideTip);
835 svg.addEventListener('click', function (e) {
836 const rect = svg.getBoundingClientRect();
837 let i = Math.round(((e.clientX - rect.left) / rect.width) * (points.length - 1));
838 if (i < 0) i = 0; if (i > points.length - 1) i = points.length - 1;
839 const label = cfg.fullLabels[i] || ('Point ' + (i + 1));
840 openModal(cfg.eyebrow || 'Trend', label, buildModalHtml(cfg, label, points[i], points, i));
841 });
842 }
843
844 /* ==================================================================
845 BAR CHART
846 ================================================================== */
847 function bars(host, values, opts) {
848 opts = Object.assign({ labels: [], max: null, color: DEFAULTS.barGrad }, opts || {});
849 const max = opts.max || Math.max.apply(null, values);
850 if (!max) { host.innerHTML = ''; return; }
851
852 host.innerHTML = values.map(function (v, i) {
853 const pct = (v / max * 100).toFixed(1);
854 const lbl = opts.labels[i] || '';
855 return '<div class="bar" style="height:' + pct + '%;background:' + opts.color + '"' +
856 ' data-label="' + escapeHtml(lbl) + '" data-value="' + v + '"></div>';
857 }).join('');
858
859 const cfg = chartConfig(host);
860 if (!cfg.enabled) return;
861 ensureChrome();
862
863 host.querySelectorAll('.bar').forEach(function (bar, i) {
864 bar.addEventListener('mouseenter', function () {
865 const label = cfg.fullLabels[i] || opts.labels[i] || ('#' + (i + 1));
866 showTip(buildTipHtml(cfg, label, values[i], values, i));
867 });
868 bar.addEventListener('mouseleave', hideTip);
869 bar.addEventListener('click', function () {
870 const label = cfg.fullLabels[i] || opts.labels[i] || ('#' + (i + 1));
871 openModal(cfg.eyebrow || 'Detail', label, buildModalHtml(cfg, label, values[i], values, i));
872 });
873 });
874 }
875
876 /* ==================================================================
877 DONUT
878 ================================================================== */
879 function donut(host, segments, opts) {
880 opts = Object.assign({ size: 160, thickness: 18, gap: 0.02 }, opts || {});
881 const total = segments.reduce(function (s, x) { return s + (x.value || 0); }, 0) || 1;
882 const r = opts.size / 2 - opts.thickness;
883 const cx = opts.size / 2, cy = opts.size / 2;
884
885 let acc = -Math.PI / 2;
886 const arcs = segments.map(function (seg, i) {
887 const frac = seg.value / total;
888 const angle = frac * Math.PI * 2;
889 const a1 = acc + opts.gap;
890 const a2 = acc + angle - opts.gap;
891 acc += angle;
892 const large = (a2 - a1) > Math.PI ? 1 : 0;
893 const x1 = (cx + r * Math.cos(a1)).toFixed(2);
894 const y1 = (cy + r * Math.sin(a1)).toFixed(2);
895 const x2 = (cx + r * Math.cos(a2)).toFixed(2);
896 const y2 = (cy + r * Math.sin(a2)).toFixed(2);
897 const color = seg.color || DEFAULTS.donutColors[i % DEFAULTS.donutColors.length];
898 return '<path data-i="' + i + '" d="M ' + x1 + ' ' + y1 +
899 ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + x2 + ' ' + y2 + '"' +
900 ' fill="none" stroke="' + color + '" stroke-width="' + opts.thickness +
901 '" stroke-linecap="round" style="cursor:pointer"/>';
902 }).join('');
903
904 host.innerHTML =
905 '<svg viewBox="0 0 ' + opts.size + ' ' + opts.size + '" width="' + opts.size +
906 '" height="' + opts.size + '">' + arcs + '</svg>';
907
908 const cfg = chartConfig(host);
909 if (!cfg.enabled) return;
910 ensureChrome();
911
912 const values = segments.map(function (s) { return s.value || 0; });
913 host.querySelectorAll('path').forEach(function (p, i) {
914 const seg = segments[i];
915 p.addEventListener('mouseenter', function () {
916 showTip(buildTipHtml(cfg, seg.label || ('Segment ' + (i + 1)), seg.value, values, i));
917 p.setAttribute('stroke-width', (opts.thickness + 4));
918 });
919 p.addEventListener('mouseleave', function () {
920 hideTip();
921 p.setAttribute('stroke-width', opts.thickness);
922 });
923 p.addEventListener('click', function () {
924 openModal(cfg.eyebrow || 'Segment', seg.label || ('Segment ' + (i + 1)),
925 buildModalHtml(cfg, seg.label, seg.value, values, i));
926 });
927 });
928 }
929
930 /* ==================================================================
931 STACKED BAR
932 ================================================================== */
933 function stack(host, series, opts) {
934 opts = Object.assign({ labels: [], colors: DEFAULTS.donutColors, seriesLabels: [] }, opts || {});
935 if (!series || !series.length) { host.innerHTML = ''; return; }
936 const n = series[0].length;
937 const totals = [];
938 for (let i = 0; i < n; i++) {
939 let t = 0;
940 for (let s = 0; s < series.length; s++) t += series[s][i] || 0;
941 totals.push(t);
942 }
943 const max = Math.max.apply(null, totals) || 1;
944
945 let html = '';
946 for (let i = 0; i < n; i++) {
947 const colHeightPct = (totals[i] / max * 100).toFixed(1);
948 let inner = '';
949 for (let s = 0; s < series.length; s++) {
950 const v = series[s][i] || 0;
951 const pct = (v / totals[i] * 100).toFixed(1);
952 const color = opts.colors[s % opts.colors.length];
953 inner +=
954 '<div style="flex:0 0 ' + pct + '%;background:' + color + '" data-series="' + s + '" data-v="' + v + '"></div>';
955 }
956 const lbl = opts.labels[i] || '';
957 html +=
958 '<div class="bar" data-i="' + i + '" style="height:' + colHeightPct + '%;background:transparent;display:flex;flex-direction:column-reverse;overflow:hidden"' +
959 ' data-label="' + escapeHtml(lbl) + '" data-value="' + totals[i] + '">' + inner + '</div>';
960 }
961 host.innerHTML = html;
962
963 const cfg = chartConfig(host);
964 if (!cfg.enabled) return;
965 ensureChrome();
966
967 host.querySelectorAll('.bar').forEach(function (bar, i) {
968 bar.addEventListener('mouseenter', function () {
969 const label = cfg.fullLabels[i] || opts.labels[i] || ('#' + (i + 1));
970 let tipHtml = (cfg.eyebrow || label) ?
971 '<div class="wg-tip-eyebrow">' + escapeHtml(cfg.eyebrow) +
972 (label && cfg.eyebrow ? ' &middot; ' : '') + escapeHtml(label) + '</div>' : '';
973 tipHtml += '<div class="wg-tip-main">' + fmt(totals[i], cfg.prefix, cfg.suffix, cfg.decimals) + '</div>';
974 tipHtml += '<div class="wg-tip-divider"></div>';
975 series.forEach(function (s, si) {
976 const v = s[i] || 0;
977 const seriesLabel = opts.seriesLabels[si] || ('Series ' + (si + 1));
978 tipHtml += '<div class="wg-tip-row"><span style="display:inline-flex;align-items:center;gap:6px">' +
979 '<span style="width:8px;height:8px;border-radius:2px;background:' +
980 opts.colors[si % opts.colors.length] + '"></span>' +
981 escapeHtml(seriesLabel) + '</span>' +
982 '<span class="wg-tip-val">' + fmt(v, cfg.prefix, cfg.suffix, cfg.decimals) + '</span></div>';
983 });
984 tipHtml += '<div class="wg-tip-hint">Click for breakdown</div>';
985 showTip(tipHtml);
986 });
987 bar.addEventListener('mouseleave', hideTip);
988 bar.addEventListener('click', function () {
989 const label = cfg.fullLabels[i] || opts.labels[i] || ('#' + (i + 1));
990 let body = '<div class="wg-modal-kpis">' +
991 '<div class="wg-modal-kpi"><div class="wg-modal-kpi-lbl">Total</div>' +
992 '<div class="wg-modal-kpi-val">' + fmt(totals[i], cfg.prefix, cfg.suffix, cfg.decimals) + '</div></div>' +
993 '<div class="wg-modal-kpi"><div class="wg-modal-kpi-lbl">Series</div>' +
994 '<div class="wg-modal-kpi-val">' + series.length + '</div>' +
995 '<div class="wg-modal-kpi-sub">channels combined</div></div>' +
996 '<div class="wg-modal-kpi"><div class="wg-modal-kpi-lbl">Rank</div>' +
997 '<div class="wg-modal-kpi-val">#' + rankInSeries(totals[i], totals) + '</div>' +
998 '<div class="wg-modal-kpi-sub">of ' + n + '</div></div>' +
999 '</div>';
1000
1001 body += '<div class="wg-modal-sec"><div class="wg-modal-sec-title">By channel</div>';
1002 const seriesAtI = series.map(function (s) { return s[i] || 0; });
1003 const denom = totals[i] || 1;
1004 seriesAtI.forEach(function (v, si) {
1005 const w = (v / denom * 100).toFixed(1);
1006 const color = opts.colors[si % opts.colors.length];
1007 const seriesLabel = opts.seriesLabels[si] || ('Series ' + (si + 1));
1008 body += '<div class="wg-bar-row">' +
1009 '<span class="wg-bar-lbl">' + escapeHtml(seriesLabel) + '</span>' +
1010 '<div class="wg-bar-track"><div class="wg-bar-fill" style="width:' + w + '%;background:' + color + '"></div></div>' +
1011 '<span class="wg-bar-pct">' + fmt(v, cfg.prefix, cfg.suffix, cfg.decimals) + '</span>' +
1012 '</div>';
1013 });
1014 body += '</div>';
1015 openModal(cfg.eyebrow || 'Detail', label, body);
1016 });
1017 });
1018 }
1019
1020 /* ==================================================================
1021 KPI CARDS -- elements with data-kpi-detail JSON get the same
1022 hover-tooltip + click-modal treatment as charts. The card itself
1023 is not a chart; the detail JSON drives a rich modal so creators
1024 can drill into "why is gross revenue $24,612?" with one click.
1025
1026 JSON shape (all keys optional):
1027 {
1028 "eyebrow": "Gross revenue . last 30 days",
1029 "title": "$24,612",
1030 "kpis": [ { "label":"...", "value":"...", "sub":"...", "color":"#4ade80" }, ... ],
1031 "splits": [ { "title":"By channel", "rows":[ { "label","value","pct","color" }, ... ] } ],
1032 "lists": [ { "title":"Top earners", "items":[ { "name","value" }, ... ] } ],
1033 "note": "Footnote shown below everything",
1034 "hint": "Click for breakdown" // overrides default tip hint
1035 }
1036 ================================================================== */
1037 function wireKpiCards(root) {
1038 root.querySelectorAll('[data-kpi-detail]').forEach(function (card) {
1039 if (card.dataset.wgKpiWired === '1') return;
1040 card.dataset.wgKpiWired = '1';
1041
1042 let detail;
1043 try { detail = JSON.parse(card.dataset.kpiDetail); }
1044 catch (e) { console.warn('graphs.js: bad data-kpi-detail JSON', e); return; }
1045
1046 const eyeFromCard = (card.querySelector('.stat-label, .kpi-label, .module-title') || {}).textContent;
1047 const titleFromCard = (card.querySelector('.stat-value, .kpi-value, .module-value') || {}).textContent;
1048 const eyebrow = (detail.eyebrow || eyeFromCard || '').trim();
1049 const title = (detail.title || titleFromCard || '').trim();
1050
1051 card.style.cursor = 'pointer';
1052 card.addEventListener('mouseenter', function () {
1053 ensureChrome();
1054 let html = '';
1055 if (eyebrow) html += '<div class="wg-tip-eyebrow">' + escapeHtml(eyebrow) + '</div>';
1056 if (title) html += '<div class="wg-tip-main">' + escapeHtml(title) + '</div>';
1057 if (detail.sub) html += '<div class="wg-tip-sub">' + escapeHtml(detail.sub) + '</div>';
1058 html += '<div class="wg-tip-hint">' + escapeHtml(detail.hint || 'Click for breakdown') + '</div>';
1059 showTip(html);
1060 });
1061 card.addEventListener('mouseleave', hideTip);
1062 card.addEventListener('click', function (e) {
1063 // When the card is a large container (e.g., a dashboard module
1064 // with internal links/buttons), let internal interactive
1065 // children handle their own clicks. Authors can also opt a
1066 // child out explicitly with data-no-kpi-click.
1067 if (e.target.closest('a, button, input, select, textarea, [data-no-kpi-click]')) {
1068 return;
1069 }
1070 hideTip();
1071 openModal(eyebrow, title, buildKpiModalBody(detail));
1072 // After the modal HTML is in the DOM, wire any area charts
1073 // we just emitted (hover overlay + tooltip).
1074 wireAreaCharts(document.getElementById('wgModalBody'));
1075 });
1076 });
1077 }
1078
1079 /* ==================================================================
1080 AREA CHART (inside KPI modal). Pass a series in the kpi-detail
1081 JSON to render a 30-day (or any length) area chart with hover
1082 tooltip + date axis. Data shape:
1083
1084 {
1085 "chartTitle": "Daily total visits, last 30 days",
1086 "chartColor": "#7c3aed",
1087 "chartLabel": "Sessions",
1088 "seriesGlobal":"VISITOR_DAILY_SERIES", // window[name]
1089 "seriesKey": "total", // field on each point
1090 // OR inline:
1091 "series": [ { "date":"2026-05-15","value":12 }, ... ]
1092 }
1093
1094 The SVG renders in a 0..100 viewBox with non-scaling strokes so
1095 it stays crisp at any width. The area gradient fades from the
1096 chart color (top, 0.45 alpha) to transparent at the bottom.
1097 ================================================================== */
1098 function buildAreaChart(d) {
1099 var series = null;
1100 if (Array.isArray(d.series) && d.series.length) {
1101 series = d.series;
1102 } else if (d.seriesGlobal && typeof window !== 'undefined'
1103 && Array.isArray(window[d.seriesGlobal])) {
1104 series = window[d.seriesGlobal];
1105 }
1106 if (!series || !series.length) return '';
1107
1108 var key = d.seriesKey || 'value';
1109 var color = d.chartColor || '#7c3aed';
1110 var title = d.chartTitle || 'Daily activity, last 30 days';
1111 var label = d.chartLabel || 'Value';
1112
1113 // Compute max for normalization + total for the summary chip.
1114 var maxV = 0, sumV = 0, last7 = 0;
1115 series.forEach(function (p, i) {
1116 var v = parseFloat(p[key]) || 0;
1117 if (v > maxV) maxV = v;
1118 sumV += v;
1119 if (i >= series.length - 7) last7 += v;
1120 });
1121 if (maxV === 0) maxV = 1;
1122
1123 // SVG coords. 100x100 viewBox, padding at top so dots near max
1124 // don't get clipped, and at bottom so the baseline line shows.
1125 var padT = 4, padB = 4;
1126 var innerH = 100 - padT - padB;
1127 var stepX = series.length > 1 ? 100 / (series.length - 1) : 0;
1128
1129 var pathLine = '';
1130 var pathArea = '';
1131 var coords = [];
1132
1133 series.forEach(function (p, i) {
1134 var v = parseFloat(p[key]) || 0;
1135 var x = i * stepX;
1136 var y = padT + innerH - (v / maxV) * innerH;
1137 coords.push({ x: x, y: y, value: v, date: p.date || '' });
1138 pathLine += (i === 0 ? 'M ' : ' L ') + x.toFixed(2) + ',' + y.toFixed(2);
1139 if (i === 0) {
1140 pathArea = 'M ' + x.toFixed(2) + ',' + (padT + innerH).toFixed(2);
1141 pathArea += ' L ' + x.toFixed(2) + ',' + y.toFixed(2);
1142 } else {
1143 pathArea += ' L ' + x.toFixed(2) + ',' + y.toFixed(2);
1144 }
1145 });
1146 pathArea += ' L ' + ((series.length - 1) * stepX).toFixed(2)
1147 + ',' + (padT + innerH).toFixed(2) + ' Z';
1148
1149 // X-axis: first, last, and every 5th day. Density designed for
1150 // ~30-day series; for shorter series everything fits naturally.
1151 var xLabels = '';
1152 for (var i = 0; i < series.length; i++) {
1153 if (i === 0 || i === series.length - 1 || i % 5 === 0) {
1154 var x = i * stepX;
1155 var ds = fmtDateShort(series[i].date);
1156 xLabels += '<span class="wg-area-xlbl" style="left:'
1157 + x.toFixed(2) + '%">' + escapeHtml(ds) + '</span>';
1158 }
1159 }
1160
1161 var gradId = 'wgAreaGrad' + Math.floor(Math.random() * 1e9);
1162
1163 var html = '<div class="wg-modal-sec">'
1164 + '<div class="wg-modal-sec-title">' + escapeHtml(title) + '</div>'
1165 + '<div class="wg-area-summary">'
1166 + '<div><span>Total</span> <strong>' + fmtInt(sumV) + '</strong></div>'
1167 + '<div><span>Peak</span> <strong>' + fmtInt(maxV) + '</strong></div>'
1168 + '<div><span>Last 7 days</span> <strong>' + fmtInt(last7) + '</strong></div>'
1169 + '</div>'
1170 + '<div class="wg-area-chart" data-area-chart'
1171 + ' data-area-points=\'' + escapeAttr(JSON.stringify(coords)) + '\''
1172 + ' data-area-color="' + escapeAttr(color) + '"'
1173 + ' data-area-label="' + escapeAttr(label) + '">'
1174 + '<svg class="wg-area-svg" viewBox="0 0 100 100" preserveAspectRatio="none">'
1175 + '<defs><linearGradient id="' + gradId + '" x1="0" y1="0" x2="0" y2="1">'
1176 + '<stop offset="0%" stop-color="' + color + '" stop-opacity="0.50"/>'
1177 + '<stop offset="100%" stop-color="' + color + '" stop-opacity="0"/>'
1178 + '</linearGradient></defs>'
1179 + '<line class="wg-area-grid" x1="0" y1="' + (padT + innerH * 0.25).toFixed(2) + '" x2="100" y2="' + (padT + innerH * 0.25).toFixed(2) + '"/>'
1180 + '<line class="wg-area-grid" x1="0" y1="' + (padT + innerH * 0.50).toFixed(2) + '" x2="100" y2="' + (padT + innerH * 0.50).toFixed(2) + '"/>'
1181 + '<line class="wg-area-grid" x1="0" y1="' + (padT + innerH * 0.75).toFixed(2) + '" x2="100" y2="' + (padT + innerH * 0.75).toFixed(2) + '"/>'
1182 + '<path d="' + pathArea + '" fill="url(#' + gradId + ')"/>'
1183 + '<path d="' + pathLine + '" fill="none" stroke="' + color + '" stroke-width="0.7" vector-effect="non-scaling-stroke" stroke-linejoin="round"/>'
1184 + '<line class="wg-area-marker-line" x1="0" y1="0" x2="0" y2="100" vector-effect="non-scaling-stroke" style="display:none"/>'
1185 + '<circle class="wg-area-marker-dot" cx="0" cy="0" r="1.1" fill="' + color + '" stroke="#fff" stroke-width="0.4" vector-effect="non-scaling-stroke" style="display:none"/>'
1186 + '<rect class="wg-area-overlay" x="0" y="0" width="100" height="100" fill="transparent"/>'
1187 + '</svg>'
1188 + '<div class="wg-area-tip" style="display:none"></div>'
1189 + '<div class="wg-area-xaxis">' + xLabels + '</div>'
1190 + '</div>'
1191 + '</div>';
1192 return html;
1193 }
1194
1195 function fmtInt(n) {
1196 if (n == null || isNaN(n)) return '0';
1197 return Math.round(Number(n)).toLocaleString();
1198 }
1199 function fmtDateShort(d) {
1200 var m = (d || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
1201 if (!m) return d || '';
1202 var mn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1203 return mn[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10);
1204 }
1205 function fmtDateLong(d) {
1206 var m = (d || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
1207 if (!m) return d || '';
1208 var mn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1209 return mn[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10) + ', ' + m[1];
1210 }
1211
1212 function wireAreaChart(container) {
1213 if (!container || container.dataset.areaWired === '1') return;
1214 container.dataset.areaWired = '1';
1215 var pts = [];
1216 try { pts = JSON.parse(container.dataset.areaPoints || '[]'); } catch (e) {}
1217 if (!pts.length) return;
1218 var color = container.dataset.areaColor || '#7c3aed';
1219 var label = container.dataset.areaLabel || 'Value';
1220 var svg = container.querySelector('.wg-area-svg');
1221 var overlay = container.querySelector('.wg-area-overlay');
1222 var marker = container.querySelector('.wg-area-marker-line');
1223 var dot = container.querySelector('.wg-area-marker-dot');
1224 var tip = container.querySelector('.wg-area-tip');
1225
1226 function nearest(xpct) {
1227 var best = pts[0], bestD = Math.abs(pts[0].x - xpct);
1228 for (var i = 1; i < pts.length; i++) {
1229 var d = Math.abs(pts[i].x - xpct);
1230 if (d < bestD) { best = pts[i]; bestD = d; }
1231 }
1232 return best;
1233 }
1234
1235 overlay.addEventListener('mousemove', function (e) {
1236 var rect = svg.getBoundingClientRect();
1237 var xpct = ((e.clientX - rect.left) / rect.width) * 100;
1238 if (xpct < 0) xpct = 0; if (xpct > 100) xpct = 100;
1239 var p = nearest(xpct);
1240 marker.setAttribute('x1', p.x);
1241 marker.setAttribute('x2', p.x);
1242 marker.style.display = '';
1243 dot.setAttribute('cx', p.x);
1244 dot.setAttribute('cy', p.y);
1245 dot.style.display = '';
1246 tip.innerHTML = '<div class="wg-area-tip-date">' + escapeHtml(fmtDateLong(p.date)) + '</div>'
1247 + '<div class="wg-area-tip-row"><span>' + escapeHtml(label) + '</span>'
1248 + '<strong style="color:' + escapeAttr(color) + '">' + fmtInt(p.value) + '</strong></div>';
1249 var crect = container.getBoundingClientRect();
1250 var tipW = 170;
1251 var localX = e.clientX - crect.left;
1252 var localY = e.clientY - crect.top;
1253 var tipLeft = localX + 14;
1254 var tipTop = localY - 50;
1255 if (tipLeft + tipW > crect.width) tipLeft = localX - tipW - 14;
1256 if (tipTop < 4) tipTop = 8;
1257 tip.style.left = tipLeft + 'px';
1258 tip.style.top = tipTop + 'px';
1259 tip.style.display = 'block';
1260 });
1261 overlay.addEventListener('mouseleave', function () {
1262 marker.style.display = 'none';
1263 dot.style.display = 'none';
1264 tip.style.display = 'none';
1265 });
1266 }
1267
1268 function wireAreaCharts(root) {
1269 if (!root) return;
1270 root.querySelectorAll('[data-area-chart]').forEach(wireAreaChart);
1271 }
1272
1273 function buildKpiModalBody(d) {
1274 let html = '';
1275
1276 // Up to N KPI tiles at the top (responsive grid, defaults to 3).
1277 if (Array.isArray(d.kpis) && d.kpis.length) {
1278 const cols = Math.min(d.kpis.length, 4);
1279 html += '<div class="wg-modal-kpis" style="grid-template-columns:repeat(' + cols + ',1fr)">';
1280 d.kpis.forEach(function (k) {
1281 html += '<div class="wg-modal-kpi">';
1282 if (k.label) html += '<div class="wg-modal-kpi-lbl">' + escapeHtml(k.label) + '</div>';
1283 html += '<div class="wg-modal-kpi-val"' + (k.color ? ' style="color:' + escapeAttr(k.color) + '"' : '') + '>' +
1284 escapeHtml(k.value == null ? '' : k.value) + '</div>';
1285 if (k.sub) html += '<div class="wg-modal-kpi-sub">' + escapeHtml(k.sub) + '</div>';
1286 html += '</div>';
1287 });
1288 html += '</div>';
1289 }
1290
1291 // Area chart -- inserted right after the KPI strip so the deep-dive
1292 // visual sits high in the modal where the eye lands first. Driven
1293 // by data.series (inline) or data.seriesGlobal (window var name).
1294 if (d.series || d.seriesGlobal) {
1295 html += buildAreaChart(d);
1296 }
1297
1298 // Deep-dive prose: what this metric is, how it is calculated, what
1299 // it means. Each is optional -- the section only renders when the
1300 // author actually provided copy for it.
1301 html += buildProseSection('What this is', d.description || '');
1302 html += buildProseSection('How it is calculated', d.methodology || '');
1303
1304 // Author-provided insights list (bullet observations).
1305 html += buildInsightsSection(d.insights, d.insightsTitle || 'What the data shows');
1306
1307 // Interpretation block (the "why this matters" / "what to do
1308 // about it" narrative).
1309 html += buildProseSection('What it means', d.interpretation || '');
1310
1311 // Split breakdowns -- horizontal bars with label / fill / value.
1312 (d.splits || []).forEach(function (split) {
1313 const rows = split.rows || [];
1314 // Compute fill widths. Prefer explicit "pct", else relative to max value.
1315 let maxV = 0;
1316 rows.forEach(function (r) { const n = parseFloat(r.value); if (!isNaN(n) && n > maxV) maxV = n; });
1317 html += '<div class="wg-modal-sec">' +
1318 '<div class="wg-modal-sec-title">' + escapeHtml(split.title || 'Breakdown') + '</div>';
1319 rows.forEach(function (r, i) {
1320 let pct;
1321 if (r.pct != null) pct = Number(r.pct);
1322 else if (maxV) {
1323 const n = parseFloat(r.value);
1324 pct = isNaN(n) ? 0 : (n / maxV * 100);
1325 } else pct = 0;
1326 if (pct < 0) pct = 0; if (pct > 100) pct = 100;
1327 const color = r.color || DEFAULTS.donutColors[i % DEFAULTS.donutColors.length];
1328 html += '<div class="wg-bar-row">' +
1329 '<span class="wg-bar-lbl">' + escapeHtml(r.label || '') + '</span>' +
1330 '<div class="wg-bar-track"><div class="wg-bar-fill" style="width:' + pct.toFixed(1) + '%;background:' + escapeAttr(color) + '"></div></div>' +
1331 '<span class="wg-bar-pct">' + escapeHtml(r.value == null ? '' : r.value) + '</span>' +
1332 '</div>';
1333 });
1334 html += '</div>';
1335 });
1336
1337 // List sections (e.g., "Top earning models").
1338 (d.lists || []).forEach(function (list) {
1339 const items = list.items || [];
1340 if (!items.length) return;
1341 html += '<div class="wg-modal-sec">' +
1342 '<div class="wg-modal-sec-title">' + escapeHtml(list.title || 'List') + '</div>' +
1343 '<div class="wg-list">';
1344 items.forEach(function (li) {
1345 html += '<div class="wg-li">' +
1346 '<span class="wg-li-name">' + escapeHtml(li.name || '') + '</span>' +
1347 '<span class="wg-li-val">' + escapeHtml(li.value == null ? '' : li.value) + '</span>' +
1348 '</div>';
1349 });
1350 html += '</div></div>';
1351 });
1352
1353 // Drill-down link row at the bottom (above any footnote).
1354 html += buildDrilldownsSection(d.drilldowns);
1355
1356 if (d.note) {
1357 html += '<div class="wg-modal-sec"><div class="wg-tip-sub">' + escapeHtml(d.note) + '</div></div>';
1358 }
1359 return html;
1360 }
1361
1362 function escapeAttr(s) {
1363 return String(s == null ? '' : s).replace(/[<>"']/g, function (c) {
1364 return ({ '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
1365 });
1366 }
1367
1368 /* ==================================================================
1369 Auto-render: scan a root element for any markup we recognize
1370 ================================================================== */
1371 function renderAll(root) {
1372 root = root || document;
1373
1374 wireKpiCards(root);
1375
1376 root.querySelectorAll('svg.spark[data-points]').forEach(function (svg) {
1377 const pts = svg.dataset.points.split(',').map(Number);
1378 const opts = {};
1379 if (svg.dataset.color) opts.strokeColor = svg.dataset.color;
1380 if (svg.dataset.fillColor) opts.fillColor = svg.dataset.fillColor;
1381 sparkline(svg, pts, opts);
1382 });
1383
1384 root.querySelectorAll('.bars[data-values]').forEach(function (host) {
1385 const values = host.dataset.values.split(',').map(Number);
1386 const labels = (host.dataset.labels || '').split(',');
1387 const opts = { labels };
1388 if (host.dataset.color) opts.color = host.dataset.color;
1389 bars(host, values, opts);
1390 });
1391
1392 root.querySelectorAll('.donut[data-segments]').forEach(function (host) {
1393 try {
1394 const segs = JSON.parse(host.dataset.segments);
1395 donut(host, segs, {
1396 size: Number(host.dataset.size) || 160,
1397 thickness: Number(host.dataset.thickness) || 18,
1398 });
1399 } catch (e) { console.error('WebSTLsGraphs: bad data-segments', e); }
1400 });
1401
1402 root.querySelectorAll('.stack[data-series]').forEach(function (host) {
1403 try {
1404 const series = JSON.parse(host.dataset.series);
1405 const colors = (host.dataset.colors || '').split(',').filter(Boolean);
1406 const labels = (host.dataset.labels || '').split(',');
1407 const seriesLabels = (host.dataset.serieslabels || '').split(',').filter(Boolean);
1408 stack(host, series, {
1409 colors: colors.length ? colors : DEFAULTS.donutColors,
1410 labels: labels,
1411 seriesLabels: seriesLabels,
1412 });
1413 } catch (e) { console.error('WebSTLsGraphs: bad data-series', e); }
1414 });
1415 }
1416
1417 /* ==================================================================
1418 Auto-bootstrap
1419 ================================================================== */
1420 if (document.readyState === 'loading') {
1421 document.addEventListener('DOMContentLoaded', function () { renderAll(document); });
1422 } else {
1423 renderAll(document);
1424 }
1425
1426 global.WebSTLsGraphs = {
1427 sparkline, bars, donut, stack, renderAll,
1428 wireKpiCards,
1429 showTip, hideTip, openModal, closeModal,
1430 };
1431})(window);
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help