added on local at 2026-07-01 21:47:03
| 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">×</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">→</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 += ' · ' + 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ? ' · ' : '') + 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 ({ '<': '<', '>': '>', '"': '"', "'": ''' })[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); |