added on local at 2026-07-01 21:47:03
| 1 | /*====================================================================== | |
| 2 | * WebSTLs - admin_visitors hover wiring. | |
| 3 | * | |
| 4 | * Two IIFEs: | |
| 5 | * 1. Daily line chart hover -- snap a vertical marker + colored | |
| 6 | * dots on each series to the nearest day; floating tooltip | |
| 7 | * shows the date and all three counts. | |
| 8 | * 2. Section-row hover -- floating popover with the section's | |
| 9 | * full path, view count, unique sessions, and bar share. | |
| 10 | * | |
| 11 | * Lives as a static file (not inline in the template) so the | |
| 12 | * MODS::Template engine never touches it -- $/ regex anchors, \d | |
| 13 | * shorthand, $-prefixed JS values etc. all work without escape | |
| 14 | * acrobatics. | |
| 15 | *====================================================================*/ | |
| 16 | (function () { | |
| 17 | 'use strict'; | |
| 18 | ||
| 19 | // ---- Daily line chart hover ---------------------------------------- | |
| 20 | function wireLineChart() { | |
| 21 | var card = document.querySelector('[data-main-chart-data]'); | |
| 22 | var svg = document.querySelector('[data-main-chart-svg]'); | |
| 23 | var overlay = document.querySelector('[data-main-chart-overlay]'); | |
| 24 | var line = document.querySelector('[data-main-chart-marker-line]'); | |
| 25 | var dotT = document.querySelector('[data-main-chart-dot-total]'); | |
| 26 | var dotU = document.querySelector('[data-main-chart-dot-unique]'); | |
| 27 | var dotS = document.querySelector('[data-main-chart-dot-users]'); | |
| 28 | var tip = document.querySelector('[data-main-chart-tip]'); | |
| 29 | if (!card || !svg || !overlay || !line || !dotT || !dotU || !dotS || !tip) return; | |
| 30 | ||
| 31 | var pts; | |
| 32 | try { pts = JSON.parse(card.textContent || '[]'); } catch (e) { pts = []; } | |
| 33 | if (!pts.length) return; | |
| 34 | ||
| 35 | var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; | |
| 36 | function fmtDate(ymd) { | |
| 37 | if (!ymd) return ''; | |
| 38 | var m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/); | |
| 39 | if (!m) return ymd; | |
| 40 | return MONTHS[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10) + ', ' + m[1]; | |
| 41 | } | |
| 42 | function nearest(sx) { | |
| 43 | var best = 0, bestD = Infinity; | |
| 44 | for (var i = 0; i < pts.length; i++) { | |
| 45 | var d = Math.abs(pts[i].x - sx); | |
| 46 | if (d < bestD) { bestD = d; best = i; } | |
| 47 | } | |
| 48 | return pts[best]; | |
| 49 | } | |
| 50 | ||
| 51 | overlay.addEventListener('mousemove', function (e) { | |
| 52 | var rect = svg.getBoundingClientRect(); | |
| 53 | var vbW = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.width : rect.width; | |
| 54 | var sx = ((e.clientX - rect.left) / rect.width) * vbW; | |
| 55 | var p = nearest(sx); | |
| 56 | if (!p) return; | |
| 57 | line.setAttribute('x1', p.x); line.setAttribute('x2', p.x); | |
| 58 | line.style.display = ''; | |
| 59 | dotT.setAttribute('cx', p.x); dotT.setAttribute('cy', p.y_total); dotT.style.display = ''; | |
| 60 | dotU.setAttribute('cx', p.x); dotU.setAttribute('cy', p.y_unique); dotU.style.display = ''; | |
| 61 | dotS.setAttribute('cx', p.x); dotS.setAttribute('cy', p.y_users); dotS.style.display = ''; | |
| 62 | tip.innerHTML = | |
| 63 | '<div style="color:#a78bfa;font-size:11px;letter-spacing:1px;text-transform:uppercase;font-weight:700;margin-bottom:6px">' + fmtDate(p.date) + '</div>' + | |
| 64 | '<div style="display:grid;grid-template-columns:auto auto;gap:6px 14px">' + | |
| 65 | '<span style="color:#7c3aed">● Total visits</span><span style="text-align:right;font-family:ui-monospace,SFMono-Regular,monospace">' + p.total + '</span>' + | |
| 66 | '<span style="color:#60a5fa">● Unique</span>' + '<span style="text-align:right;font-family:ui-monospace,SFMono-Regular,monospace">' + p.unique + '</span>' + | |
| 67 | '<span style="color:#4ade80">● Logged-in</span>' + '<span style="text-align:right;font-family:ui-monospace,SFMono-Regular,monospace">' + p.users + '</span>' + | |
| 68 | '</div>'; | |
| 69 | var wrapRect = tip.parentNode.getBoundingClientRect(); | |
| 70 | tip.style.left = (e.clientX - wrapRect.left) + 'px'; | |
| 71 | tip.style.top = (e.clientY - wrapRect.top) + 'px'; | |
| 72 | tip.style.display = 'block'; | |
| 73 | }); | |
| 74 | overlay.addEventListener('mouseleave', function () { | |
| 75 | line.style.display = 'none'; | |
| 76 | dotT.style.display = 'none'; | |
| 77 | dotU.style.display = 'none'; | |
| 78 | dotS.style.display = 'none'; | |
| 79 | tip.style.display = 'none'; | |
| 80 | }); | |
| 81 | } | |
| 82 | ||
| 83 | // ---- Section-row hover popover ------------------------------------- | |
| 84 | function wireSectionRows() { | |
| 85 | var rows = document.querySelectorAll('.sec-row'); | |
| 86 | var stip = document.querySelector('[data-sec-tip]'); | |
| 87 | if (!rows.length || !stip) return; | |
| 88 | ||
| 89 | function show(e) { | |
| 90 | var row = e.currentTarget; | |
| 91 | var section = row.getAttribute('data-section') || ''; | |
| 92 | var views = row.getAttribute('data-views') || '0'; | |
| 93 | var uniques = row.getAttribute('data-uniques') || '0'; | |
| 94 | var pct = row.getAttribute('data-pct') || '0'; | |
| 95 | stip.innerHTML = | |
| 96 | '<div style="color:#a78bfa;font-size:11px;letter-spacing:1px;text-transform:uppercase;font-weight:700;margin-bottom:6px">Site section</div>' + | |
| 97 | '<div style="color:#fff;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;margin-bottom:8px;word-break:break-all;white-space:normal">' + section + '</div>' + | |
| 98 | '<div style="display:grid;grid-template-columns:auto auto;gap:4px 14px;font-size:12px">' + | |
| 99 | '<span style="color:var(--col-text-3)">Page views</span>' + '<span style="text-align:right;color:#fff;font-family:ui-monospace,SFMono-Regular,monospace;font-weight:700">' + views + '</span>' + | |
| 100 | '<span style="color:var(--col-text-3)">Unique sessions</span>' + '<span style="text-align:right;color:#fff;font-family:ui-monospace,SFMono-Regular,monospace;font-weight:700">' + uniques + '</span>' + | |
| 101 | '<span style="color:var(--col-text-3)">Bar share</span>' + '<span style="text-align:right;color:#a78bfa;font-family:ui-monospace,SFMono-Regular,monospace;font-weight:700">' + pct + '%</span>' + | |
| 102 | '</div>'; | |
| 103 | stip.style.display = 'block'; | |
| 104 | move(e); | |
| 105 | } | |
| 106 | function move(e) { | |
| 107 | // position:fixed -- coords are viewport pixels. | |
| 108 | stip.style.left = e.clientX + 'px'; | |
| 109 | stip.style.top = e.clientY + 'px'; | |
| 110 | } | |
| 111 | function hide() { stip.style.display = 'none'; } | |
| 112 | ||
| 113 | for (var i = 0; i < rows.length; i++) { | |
| 114 | rows[i].addEventListener('mouseenter', show); | |
| 115 | rows[i].addEventListener('mousemove', move); | |
| 116 | rows[i].addEventListener('mouseleave', hide); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | // Wait for DOM if needed, then wire both handlers. | |
| 121 | function init() { | |
| 122 | wireLineChart(); | |
| 123 | wireSectionRows(); | |
| 124 | } | |
| 125 | if (document.readyState === 'loading') { | |
| 126 | document.addEventListener('DOMContentLoaded', init); | |
| 127 | } else { | |
| 128 | init(); | |
| 129 | } | |
| 130 | })(); |