feat: interactive SVG hype cycle visualization with click-through detail panel

This commit is contained in:
Rene Fichtmueller 2026-03-28 00:52:17 +13:00
parent e83711684f
commit 1cc3844822

View File

@ -212,6 +212,91 @@
.hidden { display: none !important; } .hidden { display: none !important; }
.loading { text-align: center; padding: 2rem; color: var(--text-dim); } .loading { text-align: center; padding: 2rem; color: var(--text-dim); }
/* Hype Cycle Visualization */
.hype-viz { position: relative; }
.hype-svg-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
overflow-x: auto;
}
.hype-svg-wrap svg { display: block; margin: 0 auto; }
.hype-dot {
cursor: pointer;
transition: r 0.2s, filter 0.2s;
}
.hype-dot:hover { filter: brightness(1.3); }
.hype-label {
font-size: 11px;
fill: var(--text);
pointer-events: none;
font-family: 'Inter', sans-serif;
}
.hype-phase-label {
font-size: 10px;
fill: var(--text-dim);
text-anchor: middle;
font-family: 'Inter', sans-serif;
}
.hype-detail-panel {
position: fixed;
top: 0; right: 0;
width: 420px;
height: 100vh;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -8px 0 32px rgba(0,0,0,0.5);
z-index: 1000;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease;
padding: 1.5rem;
}
.hype-detail-panel.open { transform: translateX(0); }
.hype-detail-close {
position: absolute;
top: 1rem; right: 1rem;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
width: 32px; height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
}
.hype-detail-close:hover { background: var(--border); }
.hype-detail-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.25rem; }
.hype-detail-phase { font-size: 0.85rem; margin-bottom: 1.5rem; }
.hype-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.hype-stat {
background: var(--surface2);
border-radius: 6px;
padding: 0.75rem;
}
.hype-stat-label { font-size: 0.7rem; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.05em; }
.hype-stat-value { font-size: 1.4rem; font-weight: 700; margin-top: 0.25rem; }
.hype-forecast-title { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; color: var(--text-dim); margin-bottom: 0.75rem; }
.hype-forecast-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.85rem;
}
.hype-forecast-bar .year { width: 40px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.hype-forecast-bar .bar-track { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; }
.hype-forecast-bar .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s; }
.hype-forecast-bar .pct { width: 45px; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-dim); }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } } @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
.animate-pulse { animation: pulse 2s infinite; } .animate-pulse { animation: pulse 2s infinite; }
@ -303,10 +388,25 @@
<!-- HYPE CYCLE TAB --> <!-- HYPE CYCLE TAB -->
<div id="tab-hype" class="hidden"> <div id="tab-hype" class="hidden">
<div class="card"> <div class="hype-svg-wrap">
<div class="card-header"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<div class="card-title">Norton-Bass Hype Cycle — <span id="hype-year">2026</span></div> <div>
<div style="font-size:1.1rem;font-weight:700">Optical Transceiver Hype Cycle <span id="hype-year" style="color:var(--accent)">2026</span></div>
<div style="font-size:0.8rem;color:var(--text-dim);margin-top:0.25rem">Norton-Bass Multigenerational Diffusion Model — Click any technology for details</div>
</div> </div>
<div style="display:flex;gap:1rem;font-size:0.75rem;color:var(--text-dim)">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3b82f6;vertical-align:middle;margin-right:4px"></span>Innovation</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f59e0b;vertical-align:middle;margin-right:4px"></span>Peak</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;vertical-align:middle;margin-right:4px"></span>Trough</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:4px"></span>Slope</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#10b981;vertical-align:middle;margin-right:4px"></span>Plateau</span>
</div>
</div>
<div id="hype-svg-container"></div>
</div>
<div style="margin-top:1rem">
<div class="card">
<div class="card-title" style="margin-bottom:0.75rem">All Technologies</div>
<table> <table>
<thead> <thead>
<tr><th>Technology</th><th>Phase</th><th>Position</th><th>Adoption</th><th>Peak Year</th><th>Years to Plateau</th></tr> <tr><th>Technology</th><th>Phase</th><th>Position</th><th>Adoption</th><th>Peak Year</th><th>Years to Plateau</th></tr>
@ -315,6 +415,13 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<!-- HYPE CYCLE DETAIL PANEL (slides in from right) -->
<div id="hype-detail" class="hype-detail-panel">
<button class="hype-detail-close" id="hype-detail-close">&times;</button>
<div id="hype-detail-content"></div>
</div>
<!-- TRANSCEIVERS TAB --> <!-- TRANSCEIVERS TAB -->
<div id="tab-transceivers" class="hidden"> <div id="tab-transceivers" class="hidden">
@ -493,9 +600,7 @@ el('search-btn').addEventListener('click', doSearch);
el('search-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(); }); el('search-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(); });
// Hype Cycle // Hype Cycle
async function loadHypeCycle() { var PHASE_COLORS = {
var data = await api('/api/hype-cycle');
var phaseColors = {
'Innovation Trigger': '#3b82f6', 'Innovation Trigger': '#3b82f6',
'Peak of Inflated Expectations': '#f59e0b', 'Peak of Inflated Expectations': '#f59e0b',
'Trough of Disillusionment': '#ef4444', 'Trough of Disillusionment': '#ef4444',
@ -503,10 +608,174 @@ async function loadHypeCycle() {
'Plateau of Productivity': '#10b981' 'Plateau of Productivity': '#10b981'
}; };
function hypeCurveY(x, w, h) {
// Classic Gartner hype curve shape: rise, peak, trough, slope, plateau
var t = x / w;
if (t < 0.15) return h - (t / 0.15) * h * 0.85; // rise to peak
if (t < 0.22) return h * 0.15 + ((t - 0.15) / 0.07) * h * 0.02; // peak plateau
if (t < 0.42) return h * 0.17 + ((t - 0.22) / 0.20) * h * 0.55; // drop to trough
if (t < 0.48) return h * 0.72 - ((t - 0.42) / 0.06) * h * 0.02; // trough bottom
if (t < 0.80) return h * 0.70 - ((t - 0.48) / 0.32) * h * 0.35; // slope up
return h * 0.35 - ((t - 0.80) / 0.20) * h * 0.02; // plateau
}
function positionPctToX(pct, w) {
// Map positionPct (0-100) to x on the curve
return (pct / 100) * w;
}
function renderHypeSvg(techs) {
var W = 960, H = 320, PAD = 40;
var cw = W - PAD * 2, ch = H - PAD * 2;
// Build curve path
var pts = [];
for (var i = 0; i <= cw; i += 2) {
pts.push(i + PAD + ',' + (hypeCurveY(i, cw, ch) + PAD));
}
// Phase boundary x positions on the curve
var phases = [
{ label: 'Innovation\\nTrigger', x: 0.07 },
{ label: 'Peak of Inflated\\nExpectations', x: 0.18 },
{ label: 'Trough of\\nDisillusionment', x: 0.42 },
{ label: 'Slope of\\nEnlightenment', x: 0.64 },
{ label: 'Plateau of\\nProductivity', x: 0.90 }
];
var svg = '<svg width="' + W + '" height="' + (H + 50) + '" viewBox="0 0 ' + W + ' ' + (H + 50) + '" xmlns="http://www.w3.org/2000/svg">';
// Phase region backgrounds
var regionBounds = [0, 0.15, 0.28, 0.50, 0.78, 1.0];
var regionColors = ['#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#10b981'];
for (var r = 0; r < 5; r++) {
var rx1 = regionBounds[r] * cw + PAD;
var rx2 = regionBounds[r + 1] * cw + PAD;
svg += '<rect x="' + rx1 + '" y="' + PAD + '" width="' + (rx2 - rx1) + '" height="' + ch + '" fill="' + regionColors[r] + '" opacity="0.04" />';
}
// Phase labels at bottom
for (var p = 0; p < phases.length; p++) {
var px = phases[p].x * cw + PAD;
var lines = phases[p].label.split('\\n');
for (var li = 0; li < lines.length; li++) {
svg += '<text x="' + px + '" y="' + (H + 15 + li * 13) + '" class="hype-phase-label">' + esc(lines[li]) + '</text>';
}
}
// Curve line
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="#4b5563" stroke-width="2.5" stroke-linecap="round" />';
// Technology dots + labels
var placed = [];
for (var ti = 0; ti < techs.length; ti++) {
var t = techs[ti];
var color = PHASE_COLORS[t.phase] || '#6b7280';
var tx = positionPctToX(t.positionPct, cw);
var ty = hypeCurveY(tx, cw, ch);
tx += PAD;
ty += PAD;
// Nudge overlapping labels
var labelY = ty - 14;
var labelX = tx + 8;
var isRight = true;
for (var pi = 0; pi < placed.length; pi++) {
if (Math.abs(placed[pi].x - tx) < 50 && Math.abs(placed[pi].y - labelY) < 16) {
labelY = placed[pi].y + 16;
}
}
if (tx > W - 160) { labelX = tx - 8; isRight = false; }
placed.push({ x: tx, y: labelY });
var dotR = 7;
svg += '<circle cx="' + tx + '" cy="' + ty + '" r="' + dotR + '" fill="' + color + '" class="hype-dot" data-tech="' + esc(t.technology) + '" />';
svg += '<text x="' + labelX + '" y="' + (labelY + 4) + '" class="hype-label" text-anchor="' + (isRight ? 'start' : 'end') + '" font-weight="600">' + esc(t.technology) + '</text>';
}
svg += '</svg>';
return svg;
}
async function openHypeDetail(techName) {
var panel = el('hype-detail');
var content = el('hype-detail-content');
content.innerHTML = '';
content.textContent = 'Loading...';
panel.classList.add('open');
try {
var data = await api('/api/hype-cycle/' + encodeURIComponent(techName));
var color = PHASE_COLORS[data.phaseLabel] || '#6b7280';
var html = '<div class="hype-detail-title">' + esc(data.technology) + '</div>';
html += '<div class="hype-detail-phase"><span class="badge" style="background:' + color + '22;color:' + color + '">' + esc(data.phaseLabel) + '</span></div>';
html += '<div class="hype-stat-grid">';
html += '<div class="hype-stat"><div class="hype-stat-label">Adoption</div><div class="hype-stat-value" style="color:' + color + '">' + (data.adoptionPct != null ? (data.adoptionPct * 100).toFixed(0) + '%' : '—') + '</div></div>';
html += '<div class="hype-stat"><div class="hype-stat-label">Position</div><div class="hype-stat-value">' + (data.positionPct || 0) + '%</div></div>';
html += '<div class="hype-stat"><div class="hype-stat-label">Peak Year</div><div class="hype-stat-value">' + esc(data.forecast && data.forecast.peakShipmentYear ? data.forecast.peakShipmentYear : '—') + '</div></div>';
html += '<div class="hype-stat"><div class="hype-stat-label">Years to Plateau</div><div class="hype-stat-value">' + (data.forecast && data.forecast.yearsToPlateauFromNow != null ? data.forecast.yearsToPlateauFromNow + 'y' : '—') + '</div></div>';
html += '</div>';
// Forecast projection bars
if (data.forecast && data.forecast.fiveYearProjection && data.forecast.fiveYearProjection.length > 0) {
html += '<div class="hype-forecast-title">5-Year Adoption Forecast</div>';
for (var fi = 0; fi < data.forecast.fiveYearProjection.length; fi++) {
var f = data.forecast.fiveYearProjection[fi];
var fColor = PHASE_COLORS[{
'INNOVATION_TRIGGER': 'Innovation Trigger',
'PEAK_OF_INFLATED_EXPECTATIONS': 'Peak of Inflated Expectations',
'TROUGH_OF_DISILLUSIONMENT': 'Trough of Disillusionment',
'SLOPE_OF_ENLIGHTENMENT': 'Slope of Enlightenment',
'PLATEAU_OF_PRODUCTIVITY': 'Plateau of Productivity'
}[f.phase] || ''] || '#6b7280';
html += '<div class="hype-forecast-bar">';
html += '<span class="year">' + f.year + '</span>';
html += '<div class="bar-track"><div class="bar-fill" style="width:' + (f.adoptionPct || 0) + '%;background:' + fColor + '"></div></div>';
html += '<span class="pct">' + (f.adoptionPct || 0) + '%</span>';
html += '</div>';
}
}
// Composite score
html += '<div style="margin-top:1.5rem;padding:1rem;background:var(--surface2);border-radius:6px">';
html += '<div style="font-size:0.75rem;text-transform:uppercase;color:var(--text-dim);letter-spacing:0.05em">Composite Score</div>';
html += '<div style="font-size:2rem;font-weight:700;color:' + color + '">' + (data.compositeScore || 0) + '<span style="font-size:0.9rem;color:var(--text-dim)"> / 100</span></div>';
html += '</div>';
buildDOM(content, html);
} catch (err) {
content.textContent = 'Failed to load: ' + err.message;
}
}
el('hype-detail-close').addEventListener('click', function() {
el('hype-detail').classList.remove('open');
});
async function loadHypeCycle() {
var data = await api('/api/hype-cycle');
var techs = data.technologies || [];
el('hype-year').textContent = data.year; el('hype-year').textContent = data.year;
buildDOM(el('hype-table'), (data.technologies || []).map(function(t) {
var color = phaseColors[t.phase] || '#374151'; // Render SVG
return '<tr>' var container = el('hype-svg-container');
buildDOM(container, renderHypeSvg(techs));
// Attach click handlers to dots
var dots = container.querySelectorAll('.hype-dot');
for (var di = 0; di < dots.length; di++) {
dots[di].addEventListener('click', function() {
openHypeDetail(this.getAttribute('data-tech'));
});
}
// Also render table (rows are clickable too)
buildDOM(el('hype-table'), techs.map(function(t) {
var color = PHASE_COLORS[t.phase] || '#374151';
return '<tr style="cursor:pointer" data-tech="' + esc(t.technology) + '">'
+ '<td style="font-weight:600">' + esc(t.technology) + '</td>' + '<td style="font-weight:600">' + esc(t.technology) + '</td>'
+ '<td><span class="badge" style="background:' + color + '22;color:' + color + '">' + esc(t.phase) + '</span></td>' + '<td><span class="badge" style="background:' + color + '22;color:' + color + '">' + esc(t.phase) + '</span></td>'
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + esc(t.positionPct) + '%;background:' + color + '"></div></div></td>' + '<td><div class="hype-bar"><div class="hype-fill" style="width:' + esc(t.positionPct) + '%;background:' + color + '"></div></div></td>'
@ -515,6 +784,14 @@ async function loadHypeCycle() {
+ '<td>' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>' + '<td>' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>'
+ '</tr>'; + '</tr>';
}).join('')); }).join(''));
// Click handlers on table rows
var rows = el('hype-table').querySelectorAll('tr[data-tech]');
for (var ri = 0; ri < rows.length; ri++) {
rows[ri].addEventListener('click', function() {
openHypeDetail(this.getAttribute('data-tech'));
});
}
} }
// Transceivers // Transceivers