Rene Fichtmueller fe81c2d19d feat: visible close button, product name above image, SKU + descriptive name in list
- Panel close button: high-contrast rgba background, 38px, bold ×, shadow
- Detail view: full product name (SKU + description) shown above image
- List view: NAME column shows part_number on line 1, descriptive name line 2
- txDescName() helper builds name from description field or constructs from specs
2026-04-01 20:02:52 +02:00

2745 lines
138 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIP — Transceiver Intelligence Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700;800&family=DM+Serif+Display&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f7f7f7;
--surface: #ffffff;
--surface2: #f0f0f0;
--surface3: #e5e5e5;
--surface-dark: #000000;
--border: #e0e0e0;
--border-hover: #FF8100;
--text: #333333;
--text-bright: #000000;
--text-dim: #888888;
--accent: #FF8100;
--accent-dark: #e07000;
--accent-glow: rgba(255,129,0,0.08);
--accent2: #1a1a1a;
--green: #2d6a4f;
--green-light: #d8f3dc;
--yellow: #FFa000;
--yellow-light: #fff3e0;
--red: #c1121f;
--red-light: #fde8e8;
--purple: #7c5cfc;
--purple-light: #f0ecff;
--orange: #FF8100;
--cyan: #1a1a1a;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-heading: 'DM Sans', system-ui, -apple-system, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06);
--shadow-hover: 0 4px 12px rgba(0,0,0,0.1);
--shadow-glow: 0 2px 8px rgba(255,129,0,0.1);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
}
* { margin:0; padding:0; box-sizing:border-box; }
/* Scrollbar styling to match theme */
html { scrollbar-color: var(--border) var(--bg); }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #cccccc; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
overflow-x: hidden;
}
/* === LAYOUT === */
.app { position: relative; z-index: 1; }
/* === HEADER === */
.header {
background: var(--surface-dark);
padding: 0 2rem;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky; top: 0; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.header-left { display: flex; align-items: center; gap: 2rem; }
.logo { display: flex; align-items: center; gap: 0.6rem; }
.logo-mark {
width: 32px; height: 32px; border-radius: var(--radius-md);
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 0.85rem; color: #fff;
}
.logo-text {
font-family: var(--font-heading);
font-size: 1rem; color: #fff;
letter-spacing: -0.02em;
}
.logo-text span { color: rgba(255,255,255,0.5); font-family: var(--font-body); font-size: 0.85rem; margin-left: 0.25rem; }
.header-stats {
display: flex; gap: 1.5rem; font-size: 0.75rem;
font-family: var(--mono); color: rgba(255,255,255,0.5);
}
.header-stats span[data-goto] {
cursor: pointer; transition: color 0.2s;
}
.header-stats span[data-goto]:hover {
color: rgba(255,255,255,0.85);
}
.header-stats .val {
color: var(--accent); font-weight: 600;
}
.header .status {
display: flex; gap: 1rem; align-items: center; font-size: 0.75rem;
}
.status-pill {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.6rem; border-radius: 20px;
background: rgba(45,106,79,0.15);
border: 1px solid rgba(45,106,79,0.3);
color: #6ee7b7; font-weight: 500;
font-size: 0.7rem; font-family: var(--mono);
}
.status-pill.err { background: rgba(193,18,31,0.15); border-color: rgba(193,18,31,0.3); color: #fca5a5; }
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-ok { background: #6ee7b7; box-shadow: 0 0 6px rgba(110,231,183,0.4); animation: pulse-dot 3s infinite; }
.dot-err { background: #fca5a5; }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.5} }
.version-tag {
font-family: var(--mono); font-size: 0.65rem; color: rgba(255,255,255,0.4);
padding: 0.15rem 0.5rem; border-radius: 4px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
}
/* === TABS === */
.tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 2rem;
background: var(--surface);
}
.tab {
padding: 0.75rem 1.25rem;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-dim);
font-size: 0.8rem; font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.02em;
}
.tab:hover { color: var(--text-bright); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* === MAIN === */
.main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
.grid { display: grid; gap: 1rem; }
.g4 { grid-template-columns: repeat(4, 1fr); }
.g3 { grid-template-columns: repeat(3, 1fr); }
.g2 { grid-template-columns: 1fr 1fr; }
.g2-1 { grid-template-columns: 2fr 1fr; }
/* === CARDS === */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-card);
transition: box-shadow 0.2s, border-color 0.2s;
}
.card-header {
font-size: 1rem; font-weight: 700; color: var(--text-bright);
margin-bottom: 1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.card:hover {
box-shadow: var(--shadow-hover);
}
.card-label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); margin-bottom: 0.5rem;
}
.card-num {
font-size: 2rem; font-weight: 800;
font-family: var(--mono); letter-spacing: -0.03em;
color: var(--accent);
}
.card-num small {
font-size: 0.7rem; font-weight: 400; color: var(--text-dim);
}
/* === STAT CARDS === */
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
position: relative; overflow: hidden;
transition: all 0.2s;
box-shadow: var(--shadow-card);
cursor: pointer;
}
.stat-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.stat-icon {
width: 36px; height: 36px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 1rem; margin-bottom: 0.75rem;
}
.stat-icon.blue { background: rgba(196,112,75,0.08); color: var(--accent); }
.stat-icon.green { background: rgba(45,106,79,0.08); color: var(--green); }
.stat-icon.purple { background: rgba(124,92,252,0.08); color: var(--purple); }
.stat-icon.orange { background: rgba(231,111,81,0.08); color: var(--orange); }
.stat-icon.cyan { background: rgba(38,70,83,0.08); color: var(--cyan); }
.stat-label {
font-size: 0.72rem; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;
}
.stat-val {
font-size: 1.75rem; font-weight: 800; font-family: var(--mono);
color: var(--text-bright); letter-spacing: -0.03em; margin-top: 0.15rem;
}
/* === BADGES === */
.b {
display: inline-block; padding: 2px 8px; border-radius: 100px;
font-size: 0.68rem; font-weight: 600; font-family: var(--mono);
letter-spacing: 0.02em;
}
.b-blue { background: rgba(196,112,75,0.08); color: var(--accent); border: 1px solid rgba(196,112,75,0.2); }
.b-green { background: rgba(45,106,79,0.08); color: var(--green); border: 1px solid rgba(45,106,79,0.2); }
.b-yellow { background: rgba(212,163,115,0.12); color: #b8860b; border: 1px solid rgba(212,163,115,0.3); }
.b-red { background: rgba(193,18,31,0.06); color: var(--red); border: 1px solid rgba(193,18,31,0.2); }
.b-purple { background: rgba(124,92,252,0.06); color: var(--purple); border: 1px solid rgba(124,92,252,0.2); }
.b-orange { background: rgba(231,111,81,0.06); color: var(--orange); border: 1px solid rgba(231,111,81,0.2); }
.b-cyan { background: rgba(38,70,83,0.06); color: var(--cyan); border: 1px solid rgba(38,70,83,0.2); }
.b-neutral { background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }
/* === TABLES === */
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th {
text-align: left; padding: 0.6rem 0.75rem;
border-bottom: 2px solid var(--border);
color: var(--text-dim); font-weight: 700;
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
position: sticky; top: 0; background: var(--surface); z-index: 1;
cursor: pointer; user-select: none; transition: color 0.2s;
white-space: nowrap;
}
th:hover { color: var(--text-bright); }
th .sort-arrow { display: inline-block; margin-left: 4px; font-size: 0.6rem; opacity: 0.3; transition: opacity 0.2s; }
th.sort-asc .sort-arrow, th.sort-desc .sort-arrow { opacity: 1; color: var(--accent); }
th.sort-asc .sort-arrow::after { content: '▲'; }
th.sort-desc .sort-arrow::after { content: '▼'; }
th:not(.sort-asc):not(.sort-desc) .sort-arrow::after { content: '⇅'; }
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text); }
tr.clickable { cursor: pointer; transition: background 0.15s; }
tr.clickable:hover td { background: var(--accent-glow); }
.table-wrap { max-height: 70vh; overflow-y: auto; border-radius: var(--radius-md); }
.table-wrap::-webkit-scrollbar { width: 6px; }
.table-wrap::-webkit-scrollbar-track { background: transparent; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* === SEARCH === */
.search-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
.search-row input, .search-row select {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 0.6rem 1rem;
color: var(--text-bright); font-size: 0.85rem;
font-family: var(--font-body);
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-row input { flex: 1; }
.search-row input:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-row select { min-width: 140px; }
.btn {
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-md); padding: 0.6rem 1.25rem;
font-weight: 700; font-size: 0.8rem; cursor: pointer;
font-family: var(--font-body);
transition: background 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-glow);
}
.btn:hover { background: var(--accent-dark); box-shadow: var(--shadow-hover); }
.btn-ghost {
background: transparent; border: 1px solid var(--border);
color: var(--text-dim); cursor: pointer; border-radius: var(--radius-md);
padding: 0.5rem 0.85rem; font-size: 0.75rem; font-weight: 600;
font-family: var(--font-body);
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
/* === RESULT ITEMS === */
.ri {
border-bottom: 1px solid var(--border);
padding: 1rem 0; cursor: pointer;
transition: all 0.15s;
}
.ri:last-child { border-bottom: none; }
.ri:hover { background: var(--accent-glow); margin: 0 -1.25rem; padding: 1rem 1.25rem; border-radius: var(--radius-md); }
.ri-title { font-weight: 600; font-size: 0.85rem; color: var(--text-bright); }
.ri-body { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.3rem; line-height: 1.6; }
.ri-meta { font-size: 0.7rem; color: var(--text-dim); margin-top: 0.4rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
/* === HYPE CYCLE === */
.hype-wrap {
background: #0a0a0f;
border: 1px solid rgba(255,129,0,0.15);
border-radius: 16px;
padding: 2rem 2rem 1.5rem;
overflow-x: auto;
box-shadow: 0 4px 40px rgba(0,0,0,0.3), 0 0 80px rgba(255,129,0,0.03);
position: relative;
}
.hype-wrap::before {
content: '';
position: absolute; inset: 0;
border-radius: 16px;
background: radial-gradient(ellipse 80% 50% at 20% 30%, rgba(255,129,0,0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 80% 70%, rgba(255,129,0,0.02) 0%, transparent 60%);
pointer-events: none;
}
.hype-wrap svg { display: block; width: 100%; height: auto; position: relative; z-index: 1; }
.hype-dot { cursor: pointer; transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1); }
.hype-dot:hover { filter: brightness(1.3) drop-shadow(0 0 12px currentColor); transform: scale(1.15); }
.hype-label { font-size: 11px; fill: rgba(255,255,255,0.85); pointer-events: none; font-family: 'DM Sans', sans-serif; letter-spacing: 0.02em; }
.hype-phase-label { font-size: 10px; fill: rgba(255,255,255,0.35); text-anchor: middle; font-family: 'DM Sans', sans-serif; text-transform: uppercase; letter-spacing: 0.08em; }
.hype-pulse { animation: hype-pulse-anim 2.5s ease-in-out infinite; }
@keyframes hype-pulse-anim { 0%,100% { opacity: 0.25; r: 16; } 50% { opacity: 0.08; r: 24; } }
.hype-connector { stroke: rgba(255,255,255,0.12); stroke-width: 1; stroke-dasharray: 2,3; }
/* Hype Cycle Hover Tooltip */
.hype-tooltip {
position: fixed; z-index: 1000; pointer-events: none;
background: rgba(10,11,16,0.95); border: 1px solid rgba(255,129,0,0.3);
border-radius: 8px; padding: 10px 14px; min-width: 180px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); backdrop-filter: blur(8px);
font-size: 12px; color: var(--text); opacity: 0; transition: opacity 0.15s;
}
.hype-tooltip.visible { opacity: 1; }
.hype-tooltip .tt-tech { font-weight: 700; font-size: 13px; margin-bottom: 4px; }
.hype-tooltip .tt-phase { font-size: 11px; opacity: 0.7; margin-bottom: 6px; }
.hype-tooltip .tt-row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
.hype-tooltip .tt-label { color: var(--text-dim); }
.hype-tooltip .tt-val { font-family: 'JetBrains Mono', monospace; font-weight: 600; }
/* Phase Legend */
.hype-legend {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
margin-top: 12px; padding: 8px 0;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.hype-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1.5rem; position: relative; z-index: 1;
}
.hype-title {
font-family: var(--font-heading);
font-weight: 700; font-size: 1.3rem; color: #ffffff;
letter-spacing: -0.02em;
}
.hype-title .mono { color: #FF8100; font-weight: 600; }
.hype-sub { font-size: 0.72rem; color: rgba(255,255,255,0.4); margin-top: 0.35rem; letter-spacing: 0.01em; }
.hype-legend { display: flex; gap: 1.2rem; font-size: 0.68rem; color: rgba(255,255,255,0.5); align-items: center; }
.hype-legend-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
margin-right: 0.3rem; vertical-align: middle;
box-shadow: 0 0 6px currentColor;
}
.hype-bar { height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; width: 100%; }
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
/* === TOOLTIPS === */
.tip { position: relative; cursor: help; }
.tip::after {
content: attr(data-tip);
position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
background: var(--surface-dark); color: #e0e0e0;
border: 1px solid rgba(255,255,255,0.1);
padding: 0.6rem 0.85rem; border-radius: var(--radius-md);
font-size: 0.72rem; line-height: 1.5; font-weight: 400;
white-space: normal; width: max-content; max-width: 300px;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
z-index: 500; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}
.tip::before {
content: ''; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%);
border: 6px solid transparent; border-top-color: var(--surface-dark);
opacity: 0; pointer-events: none; transition: opacity 0.2s; z-index: 501;
}
.tip:hover::after, .tip:hover::before { opacity: 1; }
th.tip::after { left: 0; transform: none; }
/* === DETAIL PANEL === */
.panel {
position: fixed; top: 0; right: 0;
width: 560px; height: 100vh;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0,0,0,0.08);
z-index: 1000; overflow-y: auto;
transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
padding: 1.5rem;
}
.panel.open { transform: translateX(0); }
.panel::-webkit-scrollbar { width: 4px; }
.panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.panel-close {
position: absolute; top: 0.75rem; right: 0.75rem;
background: rgba(255,255,255,0.14); border: 1.5px solid rgba(255,255,255,0.28);
color: rgba(255,255,255,0.92); width: 38px; height: 38px; border-radius: 8px;
cursor: pointer; font-size: 1.3rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s; box-shadow: 0 2px 10px rgba(0,0,0,0.35); z-index: 10;
}
.panel-close:hover { background: var(--accent); color: #fff; border-color: var(--accent); box-shadow: 0 2px 12px rgba(255,102,0,0.4); }
.panel-title {
font-family: var(--font-heading);
font-size: 1.3rem; font-weight: 400; color: var(--text-bright);
margin-bottom: 0.2rem; letter-spacing: -0.02em;
padding-right: 2.5rem;
}
.panel-sub { font-size: 0.8rem; color: var(--text-dim); margin-bottom: 1.25rem; }
.panel-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin-bottom: 1.25rem; }
.panel-stat {
background: var(--surface2); border-radius: var(--radius-md); padding: 0.75rem;
border: 1px solid var(--border);
}
.panel-stat-label { font-size: 0.65rem; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.06em; font-weight: 700; }
.panel-stat-val {
font-size: 1.3rem; font-weight: 800; font-family: var(--mono);
margin-top: 0.2rem; color: var(--text-bright);
}
.panel-section {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-dim);
margin: 1.25rem 0 0.6rem;
display: flex; align-items: center; gap: 0.5rem;
}
.panel-section::after {
content: ''; flex: 1; height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
}
.panel-row {
display: flex; justify-content: space-between; align-items: center;
padding: 0.4rem 0; border-bottom: 1px solid var(--border);
font-size: 0.8rem;
}
.panel-row:last-child { border-bottom: none; }
.panel-row-label { color: var(--text-dim); }
.panel-row-val { font-weight: 600; font-family: var(--mono); color: var(--text-bright); }
/* Spec table — Flexoptix-style specification display */
.spec-table {
background: var(--surface2); border-radius: var(--radius-md);
border: 1px solid var(--border); overflow: hidden; margin-bottom: 0.5rem;
}
.spec-row {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border);
font-size: 0.78rem; gap: 1rem;
}
.spec-row:last-child { border-bottom: none; }
.spec-row:nth-child(even) { background: rgba(0,0,0,0.015); }
.spec-label {
color: var(--text-dim); font-weight: 600; text-transform: uppercase;
font-size: 0.68rem; letter-spacing: 0.03em; min-width: 120px; flex-shrink: 0;
}
.spec-val {
font-weight: 600; color: var(--text-bright); text-align: right;
font-family: var(--mono); font-size: 0.78rem; word-break: break-word;
}
.forecast-bar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem;
}
.forecast-bar .yr { width: 40px; color: var(--text-dim); font-family: var(--mono); font-size: 0.75rem; }
.forecast-bar .track { flex: 1; height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; }
.forecast-bar .fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
.forecast-bar .pct { width: 44px; text-align: right; font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); }
/* === USE CASE BADGE === */
.use-case-card {
display: flex; align-items: center; gap: 0.6rem;
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 0.6rem 0.8rem;
margin-bottom: 0.4rem;
}
.use-case-icon { font-size: 1.1rem; }
.use-case-label { font-weight: 600; font-size: 0.8rem; color: var(--text-bright); }
.use-case-desc { font-size: 0.72rem; color: var(--text-dim); }
/* === TRANSCEIVER IMAGE === */
.tx-image-box {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
margin-bottom: 1rem; overflow: hidden;
position: relative;
}
.tx-image-box.has-photo { height: auto; min-height: 140px; max-height: 280px; background: #fff; }
.tx-image-box.has-svg { height: 140px; }
.tx-image-box img {
max-width: 100%; max-height: 280px; object-fit: contain;
padding: 0.75rem; transition: transform 0.3s;
}
.tx-image-box img:hover { transform: scale(1.05); }
.tx-image-box .img-badge {
position: absolute; top: 8px; right: 8px;
font-size: 0.6rem; font-family: var(--mono);
background: rgba(0,0,0,0.6); color: #fff;
padding: 2px 6px; border-radius: 4px;
}
.tx-image-placeholder {
color: var(--text-dim); font-size: 2.5rem; opacity: 0.3;
}
.img-link {
position: absolute; bottom: 8px; right: 8px;
font-size: 0.68rem; font-weight: 600;
color: var(--accent); text-decoration: none;
background: rgba(255,255,255,0.9); padding: 3px 8px;
border-radius: 4px; border: 1px solid var(--border);
transition: background 0.2s;
}
.img-link:hover { background: #fff; }
/* === BLOG CARDS === */
.gen-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.25rem; cursor: pointer;
transition: all 0.2s; position: relative; overflow: hidden;
box-shadow: var(--shadow-card);
}
.gen-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow-hover); }
.gen-card-title {
font-family: var(--font-heading);
font-weight: 400; font-size: 1rem; color: var(--text-bright);
}
.gen-card-sub { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.3rem; }
/* === COPY BUTTON === */
.btn-copy {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); cursor: pointer; border-radius: var(--radius-sm);
padding: 0.3rem 0.7rem; font-size: 0.72rem; font-weight: 600;
font-family: var(--font-body);
transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.3rem;
}
.btn-copy:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
.btn-copy.copied { background: rgba(45,106,79,0.08); color: var(--green); border-color: var(--green); }
/* === COLLECTION ITEM === */
.col-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
}
.col-item:last-child { border-bottom: none; }
.col-name { font-family: var(--mono); font-size: 0.75rem; color: var(--text); }
/* === ENDPOINT LIST === */
.endpoint-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem;
font-family: var(--mono); font-size: 0.72rem; color: var(--text-dim); line-height: 1.8;
}
.endpoint-item {
padding: 0.2rem 0.5rem; border-radius: 4px;
transition: all 0.15s;
}
.endpoint-item:hover { background: var(--accent-glow); color: var(--accent); }
/* === UTILITIES === */
.hidden { display: none !important; }
.loading { text-align: center; padding: 2.5rem; color: var(--text-dim); font-size: 0.85rem; }
.mono { font-family: var(--mono); }
.dim { color: var(--text-dim); }
.mt { margin-top: 1rem; }
.mb { margin-bottom: 1rem; }
/* === TOAST === */
.toast {
position: fixed; top: 1.25rem; right: 1.25rem; z-index: 9999;
background: var(--surface);
border: 1px solid var(--green);
border-left: 3px solid var(--green);
border-radius: var(--radius-md); padding: 0.85rem 1.5rem; max-width: 400px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
transform: translateX(120%); transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast.show { transform: translateX(0); }
.toast.error { border-color: var(--red); border-left-color: var(--red); }
.toast-title { font-weight: 700; font-size: 0.8rem; color: var(--text-bright); }
.toast-body { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.15rem; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.pulse { animation: pulse 1.5s infinite; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeInUp 0.4s ease-out; }
@media (max-width: 1100px) { .g4 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) {
.g2, .g2-1, .g3, .g4 { grid-template-columns: 1fr; }
.panel { width: 100%; }
.header-stats { display: none; }
.main { padding: 1rem; }
.header { padding: 0 1rem; }
.tabs { padding: 0 1rem; }
}
/* Compare overlay */
.compare-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 500; overflow-y: auto;
padding: 2rem;
}
.compare-overlay.visible { display: flex; justify-content: center; align-items: flex-start; }
.compare-panel {
background: var(--surface); border-radius: var(--radius-lg);
padding: 1.5rem; max-width: 95vw; min-width: 600px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.compare-table { overflow-x: auto; }
.compare-table table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.compare-table th, .compare-table td { padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); text-align: left; }
.compare-table th { background: var(--surface2); font-weight: 600; position: sticky; top: 0; }
.compare-diff { background: var(--yellow-light); }
.compare-best { background: var(--green-light); font-weight: 600; }
.compare-cb { width: 16px; height: 16px; cursor: pointer; accent-color: var(--purple); }
</style>
</head>
<body>
<div class="app">
<div id="toast" class="toast"><div class="toast-title"></div><div class="toast-body"></div></div>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="logo">
<div class="logo-mark">TIP</div>
<div class="logo-text">Transceiver Intelligence<span>Platform</span></div>
</div>
<div class="header-stats">
<span data-goto="transceivers"><span class="val" id="stat-transceivers">&mdash;</span> transceivers</span>
<span data-goto="transceivers"><span class="val" id="stat-vendors">&mdash;</span> vendors</span>
<span data-goto="switches"><span class="val" id="stat-switches">&mdash;</span> switches</span>
<span data-goto="hype"><span class="val" id="stat-standards">&mdash;</span> standards</span>
<span data-goto="news"><span class="val" id="stat-news">&mdash;</span> articles</span>
</div>
</div>
<div class="status">
<div class="status-pill" id="api-pill"><span class="dot dot-ok" id="api-status"></span>API</div>
<div class="status-pill" id="db-pill"><span class="dot dot-ok" id="db-status"></span>DB</div>
<div class="status-pill" id="qdrant-pill"><span class="dot dot-ok" id="qdrant-status"></span>Qdrant</div>
<span class="version-tag" id="version-label"></span>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="search">Search</div>
<div class="tab" data-tab="hype">Hype Cycle</div>
<div class="tab" data-tab="transceivers">Transceivers</div>
<div class="tab" data-tab="switches">Switches</div>
<div class="tab" data-tab="news">News</div>
<div class="tab" data-tab="finder">Finder</div>
<div class="tab" data-tab="blog">Blog Engine</div>
</div>
<div class="main">
<!-- OVERVIEW -->
<div id="tab-overview" class="fade-in">
<div class="grid mb" style="grid-template-columns: repeat(5, 1fr);">
<div class="stat-card" data-goto="transceivers">
<div class="stat-icon blue">&#9881;</div>
<div class="stat-label">Transceivers</div>
<div class="stat-val" id="ov-transceivers">&mdash;</div>
</div>
<div class="stat-card" data-goto="transceivers">
<div class="stat-icon green">&#9733;</div>
<div class="stat-label">Vendors</div>
<div class="stat-val" id="ov-vendors">&mdash;</div>
</div>
<div class="stat-card" data-goto="switches">
<div class="stat-icon orange">&#9881;</div>
<div class="stat-label">Switches</div>
<div class="stat-val" id="ov-switches">&mdash;</div>
</div>
<div class="stat-card" data-goto="hype">
<div class="stat-icon purple">&#9879;</div>
<div class="stat-label">Standards</div>
<div class="stat-val" id="ov-standards">&mdash;</div>
</div>
<div class="stat-card" data-goto="news">
<div class="stat-icon cyan">&#9998;</div>
<div class="stat-label">News Articles</div>
<div class="stat-val" id="ov-news">&mdash;</div>
</div>
</div>
<div class="grid g2 mb">
<div class="card">
<div class="card-label">Vector Collections</div>
<div id="collections-list" class="mt"></div>
</div>
<div class="card">
<div class="card-label">Recent Intelligence</div>
<div id="recent-news" class="mt"></div>
</div>
</div>
<div class="card">
<div class="card-label">API Endpoints</div>
<div id="endpoints-list" class="endpoint-grid mt"></div>
</div>
</div>
<!-- SEARCH -->
<div id="tab-search" class="hidden">
<div class="search-row">
<input type="text" id="search-input" placeholder="Search transceivers, datasheets, FAQ, troubleshooting...">
<select id="search-collection">
<option value="product_embeddings">Products</option>
<option value="faq_embeddings">FAQ</option>
<option value="troubleshooting_embeddings">Troubleshooting</option>
<option value="datasheet_chunks">Datasheets</option>
<option value="news_embeddings">News</option>
</select>
<button class="btn" id="search-btn">Search</button>
</div>
<div class="card"><div id="search-results"></div></div>
</div>
<!-- HYPE CYCLE -->
<div id="tab-hype" class="hidden">
<div class="hype-wrap">
<div class="hype-header">
<div>
<div class="hype-title">Optical Transceiver Hype Cycle <span class="mono dim" id="hype-year">2026</span></div>
<div class="hype-sub">Norton-Bass Multigenerational Diffusion Model &mdash; click any technology for details</div>
</div>
<div class="hype-legend">
<span><span class="hype-legend-dot" style="background:#FF8100"></span>Innovation</span>
<span><span class="hype-legend-dot" style="background:#FFa030"></span>Peak</span>
<span><span class="hype-legend-dot" style="background:#c1121f"></span>Trough</span>
<span><span class="hype-legend-dot" style="background:#555555"></span>Slope</span>
<span><span class="hype-legend-dot" style="background:#000000"></span>Plateau</span>
</div>
</div>
<div id="hype-svg-container"></div>
</div>
<div class="card mt">
<div class="table-wrap">
<table>
<thead><tr>
<th>Technology<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Cumulative market adoption based on Norton-Bass diffusion model. 0-100% of total addressable market.">Adoption<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Estimated year of peak hype / maximum attention.">Peak<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Years until mainstream, stable deployment.">To Plateau<span class="sort-arrow"></span></th>
</tr></thead>
<tbody id="hype-table"></tbody>
</table>
</div>
</div>
<div class="card mt" style="border-left:3px solid var(--cyan)">
<div class="panel-section" style="margin-top:0">Methodology: How the Hype Cycle is Calculated</div>
<div style="font-size:0.82rem;color:var(--text-dim);line-height:1.65">
<p style="margin:0.5rem 0">This Hype Cycle uses the <strong>Norton-Bass Multigenerational Diffusion Model</strong> to calculate adoption curves for optical transceiver technologies. The model extends the classic Bass diffusion model to handle multiple generations of technology that compete and cannibalize each other.</p>
<p style="margin:0.5rem 0"><strong>Key Parameters:</strong></p>
<ul style="margin:0.3rem 0;padding-left:1.2rem">
<li><strong>p (innovation coefficient)</strong> &mdash; Rate of adoption driven by external influence (marketing, industry events). Typically 0.01&ndash;0.05 for B2B networking equipment.</li>
<li><strong>q (imitation coefficient)</strong> &mdash; Rate of adoption driven by word-of-mouth and peer influence. Typically 0.3&ndash;0.5 for optical networking.</li>
<li><strong>m (market potential)</strong> &mdash; Total addressable market size, estimated from port shipment forecasts (LightCounting, Dell&rsquo;Oro).</li>
<li><strong>τ (introduction time)</strong> &mdash; Year when the technology became commercially available.</li>
</ul>
<p style="margin:0.5rem 0"><strong>Phase Classification:</strong> Technologies are classified into five phases based on their position on the adoption curve: <span class="b" style="background:#FF810018;color:#FF8100;border:1px solid #FF810033">Innovation Trigger</span> (0&ndash;16%), <span class="b" style="background:#FFa03018;color:#FFa030;border:1px solid #FFa03033">Peak of Inflated Expectations</span> (16&ndash;35%), <span class="b" style="background:#c1121f18;color:#c1121f;border:1px solid #c1121f33">Trough of Disillusionment</span> (35&ndash;55%), <span class="b" style="background:#55555518;color:#555;border:1px solid #55555533">Slope of Enlightenment</span> (55&ndash;80%), and <span class="b" style="background:#00000018;color:#000;border:1px solid #00000033">Plateau of Productivity</span> (80&ndash;100%).</p>
<p style="margin:0.5rem 0"><strong>Composite Score:</strong> A weighted blend of adoption velocity (40%), market momentum from pricing data (30%), and ecosystem maturity from compatibility entries (30%). Score ranges from 0&ndash;100.</p>
<p style="margin:0.5rem 0"><strong>Data Sources:</strong> Adoption timing derived from IEEE/MSA standard ratification dates, market data from aggregated pricing across 60+ vendors, and compatibility data from 29,000+ verified switch-transceiver pairs.</p>
</div>
</div>
</div>
</div>
<!-- TRANSCEIVERS -->
<div id="tab-transceivers" class="hidden">
<div class="search-row">
<input type="text" id="tx-search" placeholder="Filter: 100G LR4, QSFP28, coherent, SMF...">
<select id="tx-ff-filter">
<option value="">All Form Factors</option>
<option value="SFP">SFP</option>
<option value="SFP+">SFP+</option>
<option value="SFP28">SFP28</option>
<option value="QSFP+">QSFP+</option>
<option value="QSFP28">QSFP28</option>
<option value="QSFP-DD">QSFP-DD</option>
<option value="OSFP">OSFP</option>
<option value="CFP">CFP</option>
<option value="CFP2">CFP2</option>
</select>
<select id="tx-vendor-filter">
<option value="">All Vendors</option>
</select>
<button class="btn" id="tx-search-btn">Search</button>
<button class="btn" id="tx-export-btn" style="background:var(--green);color:#fff" title="Export CSV">Export CSV</button>
<button class="btn" id="tx-compare-btn" style="background:var(--purple);color:#fff" title="Compare selected">Compare</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th></tr></thead>
<tbody id="tx-table"></tbody>
</table>
</div>
</div>
</div>
<!-- SWITCHES -->
<div id="tab-switches" class="hidden">
<div class="search-row">
<input type="text" id="sw-search" placeholder="Filter: Nexus 9300, Arista 7060, QFX5130, 400G spine...">
<select id="sw-cat-filter">
<option value="">All Categories</option>
<option value="DataCenter">Data Center</option>
<option value="Campus">Campus</option>
<option value="Edge">Edge</option>
<option value="Core">Core</option>
<option value="SP">Service Provider</option>
<option value="Industrial">Industrial</option>
</select>
<button class="btn" id="sw-search-btn">Search</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Model<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Series<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th><th>Ports<span class="sort-arrow"></span></th><th>Max Speed<span class="sort-arrow"></span></th><th>Capacity<span class="sort-arrow"></span></th><th>ASIC<span class="sort-arrow"></span></th><th>Status<span class="sort-arrow"></span></th></tr></thead>
<tbody id="sw-table"></tbody>
</table>
</div>
</div>
</div>
<!-- NEWS -->
<div id="tab-news" class="hidden">
<div class="card"><div id="news-list"></div></div>
</div>
<!-- FINDER -->
<div id="tab-finder" class="hidden">
<div style="margin-bottom:1.2rem">
<h3 style="font-size:1rem;font-weight:600;margin-bottom:0.3rem">Switch → Transceiver Finder <span style="font-size:0.7rem;color:var(--text-dim);font-weight:400">Find the right Flexoptix transceiver for your switch</span></h3>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
<input id="finder-switch-input" type="text" placeholder="Enter switch model, e.g. N9K-C93180YC-FX3 or Nexus 93180..."
style="flex:1;min-width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.9rem"
onkeydown="if(event.key==='Enter') runFinder()">
<select id="finder-speed-filter" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem">
<option value="">All Speeds</option>
<option value="10">10G</option>
<option value="25">25G</option>
<option value="40">40G</option>
<option value="100">100G</option>
<option value="400">400G</option>
<option value="800">800G</option>
</select>
<button onclick="runFinder()" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:8px;cursor:pointer;font-weight:600;font-size:0.9rem">Find Transceivers</button>
</div>
<!-- Quick examples -->
<div style="margin-top:0.5rem;display:flex;gap:0.4rem;flex-wrap:wrap">
<span style="font-size:0.7rem;color:var(--text-dim)">Quick:</span>
<button onclick="finderQuick('N9K-C93180YC-FX3')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Nexus 93180YC-FX3</button>
<button onclick="finderQuick('N9K-C9332D-GX2B')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Nexus 9332D-GX2B</button>
<button onclick="finderQuick('7280R3A-48D5')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Arista 7280R3A</button>
<button onclick="finderQuick('QFX5120-48Y')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Juniper QFX5120</button>
</div>
</div>
<!-- Results area -->
<div id="finder-results"></div>
</div>
<!-- BLOG -->
<div id="tab-blog" class="hidden">
<div style="margin-bottom:0.8rem;display:flex;justify-content:space-between;align-items:center">
<h3 style="font-size:1rem;font-weight:600">Hot Topics <span id="hot-topics-subtitle" style="font-size:0.7rem;color:var(--text-dim);font-weight:400">auto-discovered from market data + conferences</span></h3>
<button onclick="loadHotTopics()" style="background:var(--accent);color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem">Refresh</button>
</div>
<div id="hot-topics-grid" class="grid g3 mb">
<div class="loading pulse">Loading topics...</div>
</div>
<div id="blog-pipeline-status"></div>
<div style="margin-bottom:0.5rem;text-align:right"><button onclick="deleteAllTemplateDrafts()" style="background:#c1121f;color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.7rem">Delete All Templates</button></div><div class="card"><div id="blog-list"></div></div>
</div>
</div>
</div><!-- .app -->
<!-- DETAIL PANEL -->
<div id="detail-panel" class="panel">
<button class="panel-close" id="panel-close">&times;</button>
<div id="panel-content"></div>
</div>
<!-- COMPARE OVERLAY -->
<div class="compare-overlay" id="compare-overlay">
<div id="compare-content"></div>
</div>
<script>
var API = window.location.origin;
function esc(str) {
if (str == null) return '';
var d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
function el(id) { return document.getElementById(id); }
function api(path) {
return fetch(API + path).then(function(r) {
var ct = r.headers.get('content-type') || '';
if (!r.ok) throw new Error('HTTP ' + r.status);
if (ct.indexOf('application/json') === -1) throw new Error('Server returned non-JSON response');
return r.json();
});
}
function showToast(title, body, isError) {
var t = el('toast');
t.querySelector('.toast-title').textContent = title;
t.querySelector('.toast-body').textContent = body;
t.className = 'toast' + (isError ? ' error' : '');
void t.offsetWidth;
t.classList.add('show');
clearTimeout(showToast._timer);
showToast._timer = setTimeout(function() { t.classList.remove('show'); }, 4000);
}
function buildDOM(parent, html) {
parent.textContent = '';
var t = document.createElement('template');
t.innerHTML = html;
parent.appendChild(t.content.cloneNode(true));
}
// Build a human-readable descriptive product name from available fields
function txDescName(t) {
// Use description field if populated and meaningful (not just the SKU)
if (t.description && t.description.length > 10 && t.description !== t.standard_name && t.description !== t.slug) {
return t.description;
}
// Construct from specs
var parts = [];
if (t.speed) parts.push(t.speed);
if (t.form_factor) parts.push(t.form_factor);
if (t.reach_label) parts.push(t.reach_label);
else if (t.reach_meters) parts.push(t.reach_meters + ' km');
if (t.wavelengths) parts.push('\u03bb' + t.wavelengths); // λ
if (t.connector) parts.push(t.connector);
if (t.fiber_type) parts.push(t.fiber_type);
return parts.join(', ') || '';
}
function openPanel(html) {
var p = el('detail-panel');
buildDOM(el('panel-content'), html);
p.classList.add('open');
}
function closePanel() { el('detail-panel').classList.remove('open'); }
el('panel-close').addEventListener('click', closePanel);
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closePanel(); });
function animateValue(el, target, duration) {
if (!el || isNaN(target)) { if (el) el.textContent = target; return; }
var start = 0, startTime = null;
target = parseInt(target);
function step(ts) {
if (!startTime) startTime = ts;
var p = Math.min((ts - startTime) / duration, 1);
p = 1 - Math.pow(1 - p, 3);
el.textContent = Math.floor(p * target).toLocaleString();
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// Use case classification
function classifyUseCase(t) {
var useCases = [];
var speed = parseFloat(t.speed_gbps) || 0;
var reach = parseInt(t.reach_meters) || 0;
var ff = (t.form_factor || '').toUpperCase();
var fiber = (t.fiber_type || '').toUpperCase();
var cat = (t.category || '').toLowerCase();
var coherent = t.coherent;
var name = (t.standard_name || t.slug || '').toLowerCase();
// Backbone / Long-haul / DCI
if (coherent || reach >= 40000 || name.includes('zr') || name.includes('dwdm') || name.includes('coherent')) {
useCases.push({ icon: '🌐', label: 'Backbone / DCI', desc: 'Long-haul, metro, data center interconnect' });
}
// Spine
if (speed >= 100 && reach <= 2000 && (ff.includes('QSFP') || ff.includes('OSFP'))) {
useCases.push({ icon: '🔀', label: 'Spine Layer', desc: 'High-speed spine-to-spine links in CLOS fabrics' });
}
// Leaf
if (speed >= 25 && speed <= 400 && reach <= 500 && !coherent) {
useCases.push({ icon: '🍃', label: 'Leaf / ToR', desc: 'Top-of-rack to spine connections, server uplinks' });
}
// Edge / Access
if (speed <= 25 || ff === 'SFP' || ff === 'SFP+') {
useCases.push({ icon: '📡', label: 'Edge / Access', desc: 'Campus, access layer, last-mile aggregation' });
}
// Enterprise / Campus
if (fiber.includes('MMF') && reach <= 300) {
useCases.push({ icon: '🏢', label: 'Enterprise Campus', desc: 'In-building fiber connections, short-reach MMF' });
}
// Reseller switch / Compatible
if (speed >= 10 && speed <= 100) {
useCases.push({ icon: '🔄', label: 'Reseller / Compatible', desc: 'Third-party compatible optics for vendor switches' });
}
// Breakout
if (t.breakout_capable) {
useCases.push({ icon: '🔌', label: 'Breakout Cable', desc: 'Splits into ' + (t.breakout_to || 'multiple lower-speed') + ' connections' });
}
// Metro
if (reach >= 10000 && reach < 40000) {
useCases.push({ icon: '🏙️', label: 'Metro / Regional', desc: 'Metropolitan area connections, 10-40km reach' });
}
// Deduplicate and limit to 4
var seen = {};
return useCases.filter(function(u) {
if (seen[u.label]) return false;
seen[u.label] = true;
return true;
}).slice(0, 4);
}
// Reference product photos per form factor (from Flexoptix catalog)
var FF_REFERENCE_IMAGES = {
'SFP': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/S/F/SFP_ZR_LC_Duplex_P.1696.23.xT_A_2.jpg',
'SFP+': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/S/F/SFP_ZR_LC_Duplex_P.1696.23.xT_A_2.jpg',
'SFP28': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/S/F/SFP_ZR_LC_Duplex_P.1696.23.xT_A_2.jpg',
'QSFP+': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'QSFP28': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'QSFP-DD': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'OSFP': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'CFP': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'CFP2': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg'
};
// Get best available image for a transceiver
function getTransceiverImage(t) {
// Real product image from DB
if (t.image_url) {
return '<img src="' + esc(t.image_url) + '" alt="' + esc(t.standard_name || t.slug) + '" style="max-width:100%;border-radius:8px" onerror="this.onerror=null;this.parentElement.innerHTML=getFormFactorImage(\'' + esc(t.form_factor) + '\')">';
}
// Reference image ONLY for FLEXOPTIX products
if (t.vendor_name === 'FLEXOPTIX') {
var ref = FF_REFERENCE_IMAGES[t.form_factor];
if (ref) {
return '<img src="' + esc(ref) + '" alt="' + esc(t.form_factor) + '" style="max-width:100%;border-radius:8px;opacity:0.85" onerror="this.onerror=null;this.parentElement.innerHTML=getFormFactorImage(\'' + esc(t.form_factor) + '\')">';
}
}
// SVG fallback for all other vendors
return getFormFactorImage(t.form_factor);
}
// Form factor image — realistic SVG transceiver diagrams (fallback)
function getFormFactorImage(ff, fiberType) {
var isSFP = /^SFP/i.test(ff);
var isQSFP = /^QSFP/i.test(ff);
var isOSFP = /^OSFP/i.test(ff);
var isCFP = /^CFP/i.test(ff);
var isCopper = (fiberType || '').toLowerCase().includes('copper') || (fiberType || '').toLowerCase().includes('dac');
var c1 = '#333333', c2 = '#1a1a1a', cAccent = '#FF8100';
if (isQSFP) { c1 = '#5e503f'; c2 = '#3a3328'; }
if (isOSFP) { c1 = '#6d4c41'; c2 = '#3e2723'; }
if (isCFP) { c1 = '#37474f'; c2 = '#263238'; }
if (isSFP) {
// SFP: smaller, single LC connector, bail latch on top
return '<svg viewBox="0 0 280 100" style="width:80%;max-height:120px">'
// Body
+ '<rect x="30" y="20" width="180" height="55" rx="4" fill="' + c2 + '"/>'
+ '<rect x="32" y="22" width="176" height="51" rx="3" fill="' + c1 + '"/>'
// Metal housing detail
+ '<rect x="32" y="22" width="176" height="8" rx="2" fill="' + c2 + '" opacity="0.5"/>'
// Bail latch (top handle)
+ '<rect x="50" y="12" width="80" height="10" rx="2" fill="' + c2 + '"/>'
+ '<rect x="55" y="14" width="70" height="6" rx="1" fill="#5a7a9a"/>'
// Label area
+ '<rect x="60" y="38" width="100" height="20" rx="2" fill="rgba(255,255,255,0.08)"/>'
+ '<text x="110" y="52" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="12" font-weight="700" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
// LC connector end (right)
+ (isCopper
? '<rect x="210" y="30" width="40" height="35" rx="2" fill="#8d6e63"/><rect x="218" y="36" width="10" height="8" rx="1" fill="#ffb74d"/><rect x="232" y="36" width="10" height="8" rx="1" fill="#ffb74d"/>'
: '<rect x="210" y="30" width="30" height="35" rx="2" fill="' + c2 + '"/><circle cx="225" cy="42" r="4" fill="#81c784" opacity="0.7"/><circle cx="225" cy="55" r="4" fill="#81c784" opacity="0.7"/>')
// LED indicators
+ '<circle cx="42" y="35" r="2.5" fill="#4caf50" opacity="0.6"/>'
+ '<circle cx="42" y="45" r="2.5" fill="#ff9800" opacity="0.4"/>'
+ '</svg>';
}
if (isQSFP) {
// QSFP: wider body, MPO/MTP connector, pull tab
return '<svg viewBox="0 0 300 110" style="width:85%;max-height:120px">'
// Body
+ '<rect x="20" y="15" width="210" height="75" rx="5" fill="' + c2 + '"/>'
+ '<rect x="22" y="17" width="206" height="71" rx="4" fill="' + c1 + '"/>'
// Metal cage detail
+ '<rect x="22" y="17" width="206" height="10" rx="3" fill="' + c2 + '" opacity="0.6"/>'
// Pull tab
+ '<rect x="40" y="5" width="120" height="14" rx="3" fill="' + cAccent + '"/>'
+ '<rect x="50" y="8" width="100" height="8" rx="2" fill="' + cAccent + '" opacity="0.7"/>'
+ '<text x="100" y="15" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="7" font-weight="700" fill="#fff">PULL</text>'
// Label area
+ '<rect x="50" y="38" width="130" height="28" rx="3" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="115" y="50" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" font-weight="700" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
+ '<text x="115" y="62" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="8" fill="rgba(255,255,255,0.4)">Optical Transceiver</text>'
// MPO/MTP connector (right)
+ (isCopper
? '<rect x="230" y="25" width="45" height="55" rx="3" fill="#6d4c41"/><rect x="238" y="32" width="12" height="12" rx="1" fill="#ffb74d"/><rect x="254" y="32" width="12" height="12" rx="1" fill="#ffb74d"/><rect x="238" y="50" width="12" height="12" rx="1" fill="#ffb74d"/><rect x="254" y="50" width="12" height="12" rx="1" fill="#ffb74d"/>'
: '<rect x="230" y="25" width="40" height="55" rx="3" fill="' + c2 + '"/><rect x="237" y="32" width="26" height="18" rx="2" fill="#263238"/><rect x="240" y="35" width="20" height="12" rx="1" fill="#4db6ac" opacity="0.3"/><rect x="237" y="56" width="26" height="18" rx="2" fill="#263238"/><rect x="240" y="59" width="20" height="12" rx="1" fill="#4db6ac" opacity="0.3"/>')
// Speed label
+ '<rect x="22" y="78" width="206" height="10" rx="2" fill="rgba(0,0,0,0.2)"/>'
+ '</svg>';
}
if (isOSFP) {
// OSFP: largest, wider than QSFP-DD
return '<svg viewBox="0 0 320 120" style="width:90%;max-height:120px">'
+ '<rect x="15" y="10" width="240" height="90" rx="6" fill="' + c2 + '"/>'
+ '<rect x="17" y="12" width="236" height="86" rx="5" fill="' + c1 + '"/>'
+ '<rect x="17" y="12" width="236" height="12" rx="4" fill="' + c2 + '" opacity="0.5"/>'
// Pull mechanism
+ '<rect x="35" y="2" width="140" height="12" rx="3" fill="' + cAccent + '"/>'
+ '<text x="105" y="11" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="7" font-weight="700" fill="#fff">PULL</text>'
// Label
+ '<rect x="45" y="36" width="155" height="35" rx="4" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="122" y="52" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="15" font-weight="800" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
+ '<text x="122" y="66" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="8" fill="rgba(255,255,255,0.35)">High-Speed Optical Module</text>'
// Connector
+ '<rect x="255" y="20" width="45" height="70" rx="4" fill="' + c2 + '"/>'
+ '<rect x="262" y="28" width="30" height="22" rx="2" fill="#263238"/><rect x="265" y="31" width="24" height="16" rx="1" fill="#4db6ac" opacity="0.25"/>'
+ '<rect x="262" y="56" width="30" height="22" rx="2" fill="#263238"/><rect x="265" y="59" width="24" height="16" rx="1" fill="#4db6ac" opacity="0.25"/>'
+ '</svg>';
}
if (isCFP) {
// CFP: very large module
return '<svg viewBox="0 0 340 100" style="width:95%;max-height:120px">'
+ '<rect x="10" y="10" width="270" height="75" rx="5" fill="' + c2 + '"/>'
+ '<rect x="12" y="12" width="266" height="71" rx="4" fill="' + c1 + '"/>'
+ '<rect x="12" y="12" width="266" height="10" rx="3" fill="' + c2 + '" opacity="0.5"/>'
+ '<rect x="40" y="32" width="180" height="30" rx="3" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="130" y="50" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="16" font-weight="800" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
+ '<text x="130" y="60" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="7" fill="rgba(255,255,255,0.35)">Coherent Optical Module</text>'
+ '<rect x="280" y="18" width="40" height="60" rx="3" fill="' + c2 + '"/>'
+ '<circle cx="300" cy="35" r="6" fill="#263238"/><circle cx="300" cy="35" r="3" fill="#4db6ac" opacity="0.3"/>'
+ '<circle cx="300" cy="55" r="6" fill="#263238"/><circle cx="300" cy="55" r="3" fill="#4db6ac" opacity="0.3"/>'
+ '</svg>';
}
// Generic fallback
return '<svg viewBox="0 0 280 100" style="width:75%;max-height:120px">'
+ '<rect x="25" y="18" width="190" height="60" rx="5" fill="' + c2 + '"/>'
+ '<rect x="27" y="20" width="186" height="56" rx="4" fill="' + c1 + '"/>'
+ '<rect x="55" y="36" width="120" height="24" rx="3" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="115" y="52" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" font-weight="700" fill="rgba(255,255,255,0.7)">' + esc(ff || 'Transceiver') + '</text>'
+ '<rect x="215" y="26" width="35" height="44" rx="3" fill="' + c2 + '"/>'
+ '</svg>';
}
// TABS
function goToTab(tabName) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
var tabEl = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (tabEl) tabEl.classList.add('active');
document.querySelectorAll('[id^="tab-"]').forEach(function(p) { p.classList.add('hidden'); });
var target = el('tab-' + tabName);
if (target) {
target.classList.remove('hidden');
target.classList.add('fade-in');
}
if (tabName === 'hype') loadHypeCycle();
if (tabName === 'transceivers') searchTransceivers();
if (tabName === 'switches') searchSwitches();
if (tabName === 'news') loadNews();
if (tabName === 'blog') loadBlogDrafts();
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
}
document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() { goToTab(tab.dataset.tab); });
});
// Clickable header stats and overview cards
document.querySelectorAll('[data-goto]').forEach(function(elem) {
elem.addEventListener('click', function() { goToTab(this.getAttribute('data-goto')); });
});
// OVERVIEW
async function loadOverview() {
try {
var h = await api('/api/health');
animateValue(el('stat-transceivers'), h.database.stats.transceiver_count, 800);
animateValue(el('stat-vendors'), h.database.stats.vendor_count, 600);
animateValue(el('stat-switches'), h.database.stats.switch_count, 500);
animateValue(el('stat-standards'), h.database.stats.standard_count, 500);
animateValue(el('stat-news'), h.database.stats.news_count, 700);
animateValue(el('ov-transceivers'), h.database.stats.transceiver_count, 1000);
animateValue(el('ov-vendors'), h.database.stats.vendor_count, 800);
animateValue(el('ov-switches'), h.database.stats.switch_count, 600);
animateValue(el('ov-standards'), h.database.stats.standard_count, 700);
animateValue(el('ov-news'), h.database.stats.news_count, 900);
el('version-label').textContent = 'v' + h.version;
el('api-status').className = 'dot ' + (h.success ? 'dot-ok' : 'dot-err');
el('db-status').className = 'dot ' + (h.database.connected ? 'dot-ok' : 'dot-err');
if (!h.success) el('api-pill').classList.add('err');
if (!h.database.connected) el('db-pill').classList.add('err');
} catch(e) {
el('api-status').className = 'dot dot-err';
el('api-pill').classList.add('err');
}
try {
var stats = await api('/api/search/stats');
buildDOM(el('collections-list'), stats.collections.map(function(c) {
return '<div class="col-item">'
+ '<span class="col-name">' + esc(c.collection) + '</span>'
+ '<span class="b ' + (c.pointsCount > 0 ? 'b-green' : 'b-yellow') + '">' + esc(c.pointsCount) + ' vectors</span>'
+ '</div>';
}).join(''));
el('qdrant-status').className = 'dot dot-ok';
} catch(e) {
el('qdrant-status').className = 'dot dot-err';
el('qdrant-pill').classList.add('err');
}
try {
var root = await api('/');
buildDOM(el('endpoints-list'), (root.endpoints || []).map(function(e) {
return '<div class="endpoint-item">' + esc(e) + '</div>';
}).join(''));
} catch(e) {}
try {
var news = await api('/api/search?q=transceiver+optics+data+center&collection=news_embeddings&limit=5');
buildDOM(el('recent-news'), (news.results || []).map(function(n) {
return '<div class="ri">'
+ '<div class="ri-title">' + esc(n.title) + '</div>'
+ '<div class="ri-meta"><span class="b b-blue">' + esc(n.source) + '</span> ' + (n.published_at ? new Date(n.published_at).toLocaleDateString() : '') + '</div>'
+ '</div>';
}).join('') || '<div class="loading">No news yet</div>');
} catch(e) {}
}
// SEARCH
function doSearch() {
var q = el('search-input').value;
var col = el('search-collection').value;
if (!q) return;
el('search-results').innerHTML = '<div class="loading pulse">Searching...</div>';
api('/api/search?q=' + encodeURIComponent(q) + '&collection=' + col + '&limit=15').then(function(data) {
buildDOM(el('search-results'), (data.results || []).map(function(r) {
var title = r.standard_name || r.title || r.question || r.symptom || (r.text ? r.text.slice(0,80) : 'Result');
var body = r.answer || r.solution || r.summary || (r.text ? r.text.slice(0,300) : '');
var score = (r.score * 100).toFixed(1);
var scoreColor = score > 70 ? 'var(--green)' : score > 40 ? 'var(--yellow)' : 'var(--text-dim)';
var clickAttr = '';
if (r.id && col === 'product_embeddings') {
clickAttr = ' style="cursor:pointer" onclick="openTxDetail(\'' + esc(r.id) + '\')"';
}
return '<div class="ri"' + clickAttr + '>'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<div class="ri-title">' + esc(title) + '</div>'
+ '<span class="mono" style="font-size:0.72rem;color:' + scoreColor + ';font-weight:600">' + score + '%</span>'
+ '</div>'
+ '<div class="ri-body">' + esc(body) + '</div>'
+ '<div class="ri-meta">'
+ (r.form_factor ? '<span class="b b-blue">' + esc(r.form_factor) + '</span>' : '')
+ (r.speed ? '<span class="b b-purple">' + esc(r.speed) + '</span>' : '')
+ (r.category ? '<span class="b b-yellow">' + esc(r.category) + '</span>' : '')
+ (r.severity ? '<span class="b b-red">' + esc(r.severity) + '</span>' : '')
+ (r.vendor ? '<span class="dim">' + esc(r.vendor) + '</span>' : '')
+ '</div></div>';
}).join('') || '<div class="loading">No results found</div>');
});
}
el('search-btn').addEventListener('click', doSearch);
el('search-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(); });
// HYPE CYCLE
var PC = {
'Innovation Trigger': '#FF8100',
'Peak of Inflated Expectations': '#FFa030',
'Trough of Disillusionment': '#c1121f',
'Slope of Enlightenment': '#555555',
'Plateau of Productivity': '#000000'
};
var PHASE_MAP = {
'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'
};
var PHASE_DESC = {
'Innovation Trigger': 'Early-stage technology breakthrough. First proof-of-concept demos, limited vendor support.',
'Peak of Inflated Expectations': 'Maximum hype and media attention. Vendors announce products, but real-world deployments are rare.',
'Trough of Disillusionment': 'Reality check. Early deployments reveal limitations. Only committed adopters remain.',
'Slope of Enlightenment': 'Practical benefits become clear. Multi-vendor support grows. Best practices emerge.',
'Plateau of Productivity': 'Mainstream adoption. Stable pricing, broad vendor support, proven reliability.'
};
// Hype detail tooltip explanations
var HYPE_TIPS = {
'Adoption': 'Cumulative market adoption percentage based on the Norton-Bass diffusion model. Represents the fraction of total addressable market that has adopted this technology.',
'Position': 'Position on the Gartner-style hype curve (0-100%). 0% = early innovation trigger, 100% = late plateau/decline.',
'Peak Year': 'Estimated year when this technology reaches peak shipment volume, based on the Bass model parameters.',
'To Plateau': 'Estimated years remaining until this technology reaches mainstream, stable deployment (Plateau of Productivity).',
'Revenue Phase': 'Current phase in the revenue lifecycle: growing (pre-peak), peaking (at peak), declining (post-peak), or legacy.',
'Revenue Index': 'Revenue potential score (0-100) based on a bell curve centered on the peak revenue year. Higher = closer to peak revenue.',
'Composite Score': 'Weighted score (0-100) combining shipment share (30%), ASP decline (20%), standards maturity (15%), interop level (15%), vendor trend (10%), and media hype (10%).'
};
function curveY(x, w, h) {
var t = x / w;
if (t < 0.15) return h - (t / 0.15) * h * 0.85;
if (t < 0.22) return h * 0.15 + ((t - 0.15) / 0.07) * h * 0.02;
if (t < 0.42) return h * 0.17 + ((t - 0.22) / 0.20) * h * 0.55;
if (t < 0.48) return h * 0.72 - ((t - 0.42) / 0.06) * h * 0.02;
if (t < 0.80) return h * 0.70 - ((t - 0.48) / 0.32) * h * 0.35;
return h * 0.35 - ((t - 0.80) / 0.20) * h * 0.02;
}
function renderHypeSvg(techs) {
var W = 1400, TOP_LABEL = 30, H = 400, P = 30, BOTTOM_LABEL = 30, PHASE_ZONE = 40;
var curveTop = TOP_LABEL;
var totalH = TOP_LABEL + H + BOTTOM_LABEL + PHASE_ZONE;
var cw = W - P * 2, ch = H;
var pts = [];
for (var i = 0; i <= cw; i += 2) {
var cy = curveY(i, cw, ch) + curveTop;
pts.push((i + P) + ',' + cy);
}
var svg = '<svg viewBox="0 0 ' + W + ' ' + totalH + '" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">';
svg += '<defs>';
// Filters
svg += '<filter id="glow"><feGaussianBlur stdDeviation="4" result="blur"/><feComposite in="SourceGraphic" in2="blur" operator="over"/></filter>';
svg += '<filter id="glow-soft"><feGaussianBlur stdDeviation="10" result="blur"/><feComposite in="SourceGraphic" in2="blur" operator="over"/></filter>';
// Curve gradient
svg += '<linearGradient id="curveGrad" x1="0%" y1="0%" x2="100%" y2="0%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="1"/>';
svg += '<stop offset="20%" stop-color="#FF9530" stop-opacity="1"/>';
svg += '<stop offset="45%" stop-color="#FF6B35" stop-opacity="0.9"/>';
svg += '<stop offset="60%" stop-color="#cc5500" stop-opacity="0.7"/>';
svg += '<stop offset="80%" stop-color="#FF8100" stop-opacity="0.85"/>';
svg += '<stop offset="100%" stop-color="#FFa040" stop-opacity="0.9"/>';
svg += '</linearGradient>';
// Area fill
svg += '<linearGradient id="fillGrad" x1="0%" y1="0%" x2="0%" y2="100%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.15"/>';
svg += '<stop offset="50%" stop-color="#FF8100" stop-opacity="0.04"/>';
svg += '<stop offset="100%" stop-color="#FF8100" stop-opacity="0"/>';
svg += '</linearGradient>';
// Glow line
svg += '<linearGradient id="glowLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.4"/>';
svg += '<stop offset="50%" stop-color="#FF6B35" stop-opacity="0.25"/>';
svg += '<stop offset="100%" stop-color="#FFa040" stop-opacity="0.35"/>';
svg += '</linearGradient>';
// Vertical drop line gradient (fades out downward)
svg += '<linearGradient id="dropGrad" x1="0%" y1="0%" x2="0%" y2="100%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.4"/>';
svg += '<stop offset="70%" stop-color="#FF8100" stop-opacity="0.12"/>';
svg += '<stop offset="100%" stop-color="#FF8100" stop-opacity="0.04"/>';
svg += '</linearGradient>';
svg += '</defs>';
// Subtle grid
for (var gy = 0; gy < 6; gy++) {
var gridY = curveTop + (ch / 5) * gy;
svg += '<line x1="' + P + '" y1="' + gridY + '" x2="' + (cw+P) + '" y2="' + gridY + '" stroke="rgba(255,255,255,0.03)" stroke-width="1" />';
}
// Phase zone separators
var rb = [0, 0.15, 0.28, 0.50, 0.78, 1.0];
for (var r = 1; r < 5; r++) {
var sepX = rb[r] * cw + P;
svg += '<line x1="' + sepX + '" y1="' + curveTop + '" x2="' + sepX + '" y2="' + (curveTop+ch) + '" stroke="rgba(255,129,0,0.06)" stroke-width="1" stroke-dasharray="3,5" />';
}
// Area fill
var areaPoints = pts.join(' ') + ' ' + (cw + P) + ',' + (curveTop + ch) + ' ' + P + ',' + (curveTop + ch);
svg += '<polygon points="' + areaPoints + '" fill="url(#fillGrad)" />';
// Glow behind curve
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="url(#glowLineGrad)" stroke-width="14" stroke-linecap="round" filter="url(#glow-soft)" />';
// Main curve
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="url(#curveGrad)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />';
// Phase labels at very bottom
var phaseY = curveTop + ch + BOTTOM_LABEL;
var pl = [
{l:'Innovation\\nTrigger',x:0.07},
{l:'Peak of Inflated\\nExpectations',x:0.18},
{l:'Trough of\\nDisillusionment',x:0.42},
{l:'Slope of\\nEnlightenment',x:0.64},
{l:'Plateau of\\nProductivity',x:0.90}
];
for (var p = 0; p < pl.length; p++) {
var px = pl[p].x * cw + P;
var ll = pl[p].l.split('\\n');
for (var li = 0; li < ll.length; li++) {
svg += '<text x="' + px + '" y="' + (phaseY + 12 + li * 13) + '" class="hype-phase-label">' + esc(ll[li]) + '</text>';
}
}
// Sort techs by x position
var sortedTechs = techs.slice().sort(function(a,b) { return a.positionPct - b.positionPct; });
// Calculate dot positions and assign top/bottom alternating
var techPositions = [];
for (var ti = 0; ti < sortedTechs.length; ti++) {
var t = sortedTechs[ti];
var dotX = (t.positionPct / 100) * cw + P;
var dotY = curveY((t.positionPct / 100) * cw, cw, ch) + curveTop;
var isTop = (ti % 2 === 0); // alternate: even=top, odd=bottom
techPositions.push({ t: t, dotX: dotX, dotY: dotY, labelX: dotX, isTop: isTop });
}
// Spread labels separately for top and bottom rows
function spreadLabels(positions, minGap) {
for (var i = 1; i < positions.length; i++) {
if (positions[i].labelX - positions[i-1].labelX < minGap) {
positions[i].labelX = positions[i-1].labelX + minGap;
}
}
// Compress if overflow
var maxX = W - P - 50;
if (positions.length > 0 && positions[positions.length - 1].labelX > maxX) {
var over = positions[positions.length - 1].labelX - maxX;
for (var i = 0; i < positions.length; i++) {
var sh = over * ((i + 1) / positions.length);
positions[i].labelX = Math.max(P + 40, positions[i].labelX - sh);
}
}
}
var topLabels = techPositions.filter(function(p) { return p.isTop; });
var bottomLabels = techPositions.filter(function(p) { return !p.isTop; });
spreadLabels(topLabels, 100);
spreadLabels(bottomLabels, 100);
// Render
for (var ti = 0; ti < techPositions.length; ti++) {
var tp = techPositions[ti];
var t = tp.t, color = PC[t.phase] || '#8888a4';
var dotX = tp.dotX, dotY = tp.dotY, labelX = tp.labelX;
var isTop = tp.isTop;
if (isTop) {
// Label at top, line goes from label down to dot
var labelY = 20;
var tickY1 = 28, tickY2 = 34;
var lineY1 = tickY2;
var lineY2 = dotY - 8;
svg += '<line x1="' + labelX + '" y1="' + lineY1 + '" x2="' + dotX + '" y2="' + lineY2 + '" stroke="url(#dropGrad)" stroke-width="1" />';
svg += '<line x1="' + labelX + '" y1="' + tickY1 + '" x2="' + labelX + '" y2="' + tickY2 + '" stroke="rgba(255,129,0,0.5)" stroke-width="2" stroke-linecap="round" />';
svg += '<text x="' + labelX + '" y="' + labelY + '" text-anchor="middle" class="hype-label" font-weight="700" font-size="11">' + esc(t.technology) + '</text>';
} else {
// Label at bottom (below curve area), line goes from dot down to label
var labelY = curveTop + ch + 18;
var tickY1 = labelY - 12, tickY2 = labelY - 6;
var lineY1 = dotY + 8;
var lineY2 = tickY1;
// Bottom drop gradient (fades downward)
svg += '<line x1="' + dotX + '" y1="' + lineY1 + '" x2="' + labelX + '" y2="' + lineY2 + '" stroke="rgba(255,129,0,0.15)" stroke-width="1" />';
svg += '<line x1="' + labelX + '" y1="' + tickY1 + '" x2="' + labelX + '" y2="' + tickY2 + '" stroke="rgba(255,129,0,0.4)" stroke-width="2" stroke-linecap="round" />';
svg += '<text x="' + labelX + '" y="' + labelY + '" text-anchor="middle" class="hype-label" font-weight="700" font-size="11" fill="rgba(255,255,255,0.7)">' + esc(t.technology) + '</text>';
}
// Pulse ring
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="14" fill="' + color + '" class="hype-pulse" style="animation-delay:' + (ti * 0.35) + 's" />';
// Outer ring
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="10" fill="none" stroke="' + color + '" stroke-width="0.8" opacity="0.25" />';
// Main dot (with hover data attributes)
var adoptPct = t.adoptionPct != null ? Math.round(t.adoptionPct) : 0;
var peakYr = t.peakYear || '—';
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="5.5" fill="' + color + '" class="hype-dot" data-tech="' + esc(t.technology) + '" data-phase="' + esc(t.phase) + '" data-adoption="' + adoptPct + '" data-peak="' + peakYr + '" data-score="' + (t.compositeScore||0) + '" filter="url(#glow)" stroke="rgba(255,255,255,0.25)" stroke-width="0.8" style="cursor:pointer" />';
// Inner highlight
svg += '<circle cx="' + (dotX-1.2) + '" cy="' + (dotY-1.2) + '" r="1.5" fill="#fff" opacity="0.5" pointer-events="none" />';
}
svg += '</svg>';
// Phase legend
svg += '<div class="hype-legend">';
var phases = [
{k:'Innovation Trigger',c:PC['Innovation Trigger']||'#4deaff'},
{k:'Peak of Inflated Expectations',c:PC['Peak of Inflated Expectations']||'#fbbf24'},
{k:'Trough of Disillusionment',c:PC['Trough of Disillusionment']||'#f87171'},
{k:'Slope of Enlightenment',c:PC['Slope of Enlightenment']||'#a78bfa'},
{k:'Plateau of Productivity',c:PC['Plateau of Productivity']||'#34d399'},
{k:'Legacy / Decline',c:PC['Legacy / Decline']||'#8888a4'}
];
for (var pi = 0; pi < phases.length; pi++) {
var cnt = techs.filter(function(t){return t.phase===phases[pi].k}).length;
if (cnt > 0) svg += '<span class="legend-item"><span class="legend-dot" style="background:'+phases[pi].c+'"></span>'+phases[pi].k+' ('+cnt+')</span>';
}
svg += '</div>';
return svg;
}
function tipAttr(key) {
return HYPE_TIPS[key] ? ' class="tip" data-tip="' + esc(HYPE_TIPS[key]) + '"' : '';
}
async function openHypeDetail(name) {
openPanel('<div class="loading pulse">Loading ' + esc(name) + '...</div>');
try {
var d = await api('/api/hype-cycle/' + encodeURIComponent(name));
var c = PC[d.phaseLabel] || '#8888a4';
var h = '<div class="panel-title">' + esc(d.technology) + '</div>';
h += '<div class="panel-sub"><span class="b" style="background:' + c + '18;color:' + c + ';border:1px solid ' + c + '33">' + esc(d.phaseLabel) + '</span></div>';
h += '<div class="panel-grid">';
h += '<div class="panel-stat"' + tipAttr('Adoption') + '><div class="panel-stat-label">Adoption</div><div class="panel-stat-val" style="color:' + c + '">' + (d.adoptionPct != null ? d.adoptionPct + '%' : '—') + '</div></div>';
h += '<div class="panel-stat"' + tipAttr('Position') + '><div class="panel-stat-label">Position</div><div class="panel-stat-val">' + (d.positionPct||0) + '%</div></div>';
h += '<div class="panel-stat"' + tipAttr('Peak Year') + '><div class="panel-stat-label">Peak Year</div><div class="panel-stat-val">' + esc(d.forecast && d.forecast.peakShipmentYear || '—') + '</div></div>';
h += '<div class="panel-stat"' + tipAttr('To Plateau') + '><div class="panel-stat-label">To Plateau</div><div class="panel-stat-val">' + (d.forecast && d.forecast.yearsToPlateauFromNow != null ? d.forecast.yearsToPlateauFromNow + 'y' : '—') + '</div></div>';
h += '</div>';
if (d.forecast && d.forecast.fiveYearProjection && d.forecast.fiveYearProjection.length) {
h += '<div class="panel-section">5-Year Forecast</div>';
for (var i = 0; i < d.forecast.fiveYearProjection.length; i++) {
var f = d.forecast.fiveYearProjection[i];
var fc = PC[PHASE_MAP[f.phase] || ''] || '#8888a4';
var pct = Math.min(f.adoptionPct || 0, 100);
h += '<div class="forecast-bar"><span class="yr">' + f.year + '</span><div class="track"><div class="fill" style="width:' + pct + '%;background:' + fc + '"></div></div><span class="pct">' + pct + '%</span></div>';
}
}
if (d.regionalAdoption && d.regionalAdoption.length) {
h += '<div class="panel-section">Regional Adoption</div>';
for (var ri = 0; ri < d.regionalAdoption.length; ri++) {
var ra = d.regionalAdoption[ri];
h += '<div class="panel-row"><span class="panel-row-label">' + esc(ra.region) + '</span><span class="panel-row-val"><span class="b b-cyan" style="margin-right:4px">' + esc(ra.adoptionPhase) + '</span>' + esc(ra.estimatedPeakYear) + '</span></div>';
}
}
if (d.revenueLifecycle) {
var rl = d.revenueLifecycle;
h += '<div class="panel-section">Revenue Lifecycle</div>';
var phaseColors = { growing: 'var(--green)', peaking: 'var(--yellow)', declining: 'var(--orange)', legacy: 'var(--text-dim)' };
h += '<div class="panel-grid">';
h += '<div class="panel-stat"' + tipAttr('Revenue Phase') + '><div class="panel-stat-label">Revenue Phase</div><div class="panel-stat-val" style="color:' + (phaseColors[rl.currentPhase]||'var(--text)') + ';font-size:1rem;text-transform:capitalize">' + esc(rl.currentPhase) + '</div></div>';
h += '<div class="panel-stat"' + tipAttr('Revenue Index') + '><div class="panel-stat-label">Revenue Index</div><div class="panel-stat-val">' + esc(rl.revenueIndex) + '<small>/100</small></div></div>';
h += '</div>';
h += '<div class="panel-row"><span class="panel-row-label">Peak Revenue Year</span><span class="panel-row-val">' + esc(rl.estimatedPeakRevenueYear) + '</span></div>';
h += '<div class="panel-row"><span class="panel-row-label">Decline Start</span><span class="panel-row-val">' + esc(rl.estimatedDeclineStartYear) + '</span></div>';
h += '<div class="panel-row"><span class="panel-row-label">Half-Life</span><span class="panel-row-val">' + esc(rl.revenueHalfLifeYears) + ' years</span></div>';
}
h += '<div class="panel-section">Composite Score</div>';
h += '<div style="padding:0.75rem;background:var(--surface2);border-radius:var(--radius-md);border:1px solid var(--border)"' + tipAttr('Composite Score') + '>';
h += '<span class="mono" style="font-size:1.75rem;font-weight:800;color:' + c + '">' + (d.compositeScore||0) + '</span>';
h += '<span style="font-size:0.8rem;color:var(--text-dim)"> / 100</span>';
h += '</div>';
buildDOM(el('panel-content'), h);
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
// ── Forecast Area Chart (SVG) ──────────────────────────────────────
var TECH_COLORS = ['#FF8100','#4deaff','#a78bfa','#34d399','#fbbf24','#f87171','#818cf8','#fb923c','#22d3ee','#e879f9','#94a3b8'];
function renderForecastChart(techs) {
var withFc = techs.filter(function(t) { return t.fiveYearForecast && t.fiveYearForecast.length > 0; });
if (!withFc.length) return '<div class="dim" style="padding:1rem;text-align:center">No forecast data — use enriched API endpoint</div>';
var W = 900, H = 320, P = 50, PR = 30, PT = 20, PB = 40;
var cw = W - P - PR, ch = H - PT - PB;
var years = withFc[0].fiveYearForecast.map(function(f) { return f.year; });
var numYears = years.length;
var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">';
// Grid + Y axis
for (var y = 0; y <= 100; y += 25) {
var gy = PT + ch - (y / 100) * ch;
svg += '<line x1="' + P + '" y1="' + gy + '" x2="' + (P + cw) + '" y2="' + gy + '" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>';
svg += '<text x="' + (P - 8) + '" y="' + (gy + 4) + '" text-anchor="end" fill="rgba(255,255,255,0.4)" font-size="10" font-family="JetBrains Mono,monospace">' + y + '%</text>';
}
// X axis labels
for (var xi = 0; xi < numYears; xi++) {
var xx = P + (xi / (numYears - 1)) * cw;
svg += '<text x="' + xx + '" y="' + (H - 8) + '" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="11" font-family="DM Sans,sans-serif">' + years[xi] + '</text>';
}
// Lines per technology
for (var ti = 0; ti < withFc.length; ti++) {
var t = withFc[ti];
var color = TECH_COLORS[ti % TECH_COLORS.length];
var pts = [];
for (var fi = 0; fi < t.fiveYearForecast.length; fi++) {
var f = t.fiveYearForecast[fi];
var fx = P + (fi / (numYears - 1)) * cw;
var fy = PT + ch - (Math.min(f.adoptionPct, 100) / 100) * ch;
pts.push(fx + ',' + fy);
}
// Area fill
svg += '<polygon points="' + pts.join(' ') + ' ' + (P + cw) + ',' + (PT + ch) + ' ' + P + ',' + (PT + ch) + '" fill="' + color + '" opacity="0.08"/>';
// Line
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + color + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>';
// Dots
for (var di = 0; di < pts.length; di++) {
var dp = pts[di].split(',');
svg += '<circle cx="' + dp[0] + '" cy="' + dp[1] + '" r="3" fill="' + color + '" stroke="#0a0b10" stroke-width="1.5"/>';
}
// Label at end
var lastPt = pts[pts.length - 1].split(',');
svg += '<text x="' + (parseFloat(lastPt[0]) + 6) + '" y="' + (parseFloat(lastPt[1]) + 3) + '" fill="' + color + '" font-size="9" font-weight="600" font-family="DM Sans,sans-serif">' + esc(t.technology) + '</text>';
}
svg += '</svg>';
return svg;
}
// ── Regional Adoption Heatmap ──────────────────────────────────────
function renderRegionalHeatmap(techs) {
var regions = ['North America (Hyperscale)', 'China (BAT/Hyperscale)', 'APAC (ex-China)', 'Europe', 'Rest of World'];
var shortRegions = ['NA', 'China', 'APAC', 'Europe', 'RoW'];
// We need regional data — fetch per tech
var h = '<table style="width:100%;border-collapse:collapse;font-size:0.8rem">';
h += '<thead><tr><th style="text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:600">Technology</th>';
for (var ri = 0; ri < shortRegions.length; ri++) {
h += '<th style="padding:6px 8px;color:var(--text-dim);font-weight:600;text-align:center">' + shortRegions[ri] + '</th>';
}
h += '<th style="padding:6px 8px;color:var(--text-dim);font-weight:600;text-align:center">Peak Year</th></tr></thead><tbody id="heatmap-body"></tbody></table>';
return h;
}
async function loadRegionalData(techs) {
var body = document.getElementById('heatmap-body');
if (!body) return;
var html = '';
for (var ti = 0; ti < techs.length; ti++) {
var t = techs[ti];
var color = PC[t.phase] || '#8888a4';
try {
var rd = await api('/api/hype-cycle/regional/' + encodeURIComponent(t.technology));
var regions = rd.regions || [];
html += '<tr>';
html += '<td style="padding:6px 8px;font-weight:600;color:' + color + '">' + esc(t.technology) + '</td>';
for (var ri = 0; ri < 5; ri++) {
var r = regions[ri];
if (r) {
var share = r.marketSharePct || 0;
var opacity = Math.max(0.15, share / 45);
html += '<td style="padding:6px 8px;text-align:center;background:rgba(255,129,0,' + opacity.toFixed(2) + ');border-radius:4px"><span class="mono" style="font-size:0.75rem">' + share + '%</span><br><span style="font-size:0.65rem;color:var(--text-dim)">' + esc(r.adoptionPhase) + '</span></td>';
} else {
html += '<td style="padding:6px 8px;text-align:center;color:var(--text-dim)">—</td>';
}
}
html += '<td style="padding:6px 8px;text-align:center" class="mono">' + (rd.globalPeakYear || '—') + '</td>';
html += '</tr>';
} catch(e) {
html += '<tr><td style="padding:6px 8px;color:' + color + '">' + esc(t.technology) + '</td><td colspan="6" style="padding:6px 8px;color:var(--text-dim)">—</td></tr>';
}
}
buildDOM(body, html);
}
async function loadHypeCycle() {
// Use enriched endpoint for forecast data
var data;
try {
data = await api('/api/hype-cycle/enriched');
} catch(e) {
data = await api('/api/hype-cycle');
}
var techs = data.technologies || [];
el('hype-year').textContent = data.year;
var c = el('hype-svg-container');
buildDOM(c, renderHypeSvg(techs));
c.querySelectorAll('.hype-dot').forEach(function(dot) {
dot.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
});
buildDOM(el('hype-table'), techs.map(function(t) {
var color = PC[t.phase] || '#8888a4';
// FIX: adoptionPct is already a percentage (0-100), do NOT multiply by 100
var adoptionDisplay = (t.adoptionPct != null ? t.adoptionPct : 0) + '%';
return '<tr class="clickable" data-tech="' + esc(t.technology) + '">'
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(t.technology) + '</td>'
+ '<td><span class="b tip" data-tip="' + esc(PHASE_DESC[t.phase] || '') + '" style="background:' + color + '18;color:' + color + ';border:1px solid ' + color + '33">' + esc(t.phase) + '</span></td>'
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + t.positionPct + '%;background:' + color + '"></div></div></td>'
+ '<td class="mono tip" data-tip="' + esc(HYPE_TIPS['Adoption']) + '">' + adoptionDisplay + '</td>'
+ '<td class="mono">' + esc(t.peakYear || '—') + '</td>'
+ '<td class="mono">' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>'
+ '</tr>';
}).join(''));
el('hype-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
});
// Render forecast chart (only if enriched data has forecasts)
var fcEl = document.getElementById('forecast-chart');
if (fcEl) buildDOM(fcEl, renderForecastChart(techs));
// Render regional heatmap shell, then load data async
var hmEl = document.getElementById('regional-heatmap');
if (hmEl) {
buildDOM(hmEl, renderRegionalHeatmap(techs));
loadRegionalData(techs);
}
}
// TRANSCEIVERS
var lastTxData = []; // store for export/compare
function searchTransceivers() {
var q = el('tx-search').value;
var ff = el('tx-ff-filter').value;
var vf = el('tx-vendor-filter').value;
var params = [];
if (q) params.push('q=' + encodeURIComponent(q));
if (ff) params.push('form_factor=' + encodeURIComponent(ff));
if (vf) params.push('vendor=' + encodeURIComponent(vf));
params.push('limit=200');
api('/api/transceivers?' + params.join('&')).then(function(data) {
lastTxData = data.data || data.transceivers || [];
buildDOM(el('tx-table'), lastTxData.map(function(t) {
return '<tr class="clickable" data-txid="' + esc(t.id) + '">'
+ '<td onclick="event.stopPropagation()"><input type="checkbox" class="compare-cb" data-id="' + esc(t.id) + '"></td>'
+ '<td><div style="font-weight:600;color:var(--text-bright);line-height:1.2">' + esc(t.part_number || t.standard_name || t.slug) + '</div>'
+ (txDescName(t) && txDescName(t) !== (t.part_number || t.standard_name || t.slug) ? '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.15rem;line-height:1.3;max-width:40ch;white-space:normal">' + esc(txDescName(t)) + '</div>' : '')
+ '</td>'
+ '<td>' + esc(t.vendor_name || '—') + '</td>'
+ '<td><span class="b b-blue">' + esc(t.form_factor) + '</span></td>'
+ '<td class="mono">' + esc(t.speed) + '</td>'
+ '<td>' + esc(t.reach_label) + '</td>'
+ '<td class="mono">' + (t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toLocaleString() : '—') + '</td>'
+ '<td>' + (t.price_tier ? '<span class="b ' + (t.price_tier === 'Premium' ? 'b-purple' : t.price_tier === 'Budget' ? 'b-green' : 'b-neutral') + '">' + esc(t.price_tier) + '</span>' : '—') + '</td>'
+ '<td>' + (t.market_status ? '<span class="b b-green">' + esc(t.market_status) + '</span>' : '—') + '</td>'
+ '<td>' + (t.category ? '<span class="b b-neutral">' + esc(t.category) + '</span>' : '') + '</td>'
+ '</tr>';
}).join(''));
el('tx-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openTxDetail(this.getAttribute('data-txid')); });
});
});
}
async function openTxDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/transceivers/' + id);
var t = data.data || data.transceiver || data;
var h = '';
// Product name header — above the image
var descName = txDescName(t);
var sku = t.part_number || t.standard_name || t.slug;
h += '<div style="margin-bottom:0.9rem">';
h += '<div style="font-size:1.1rem;font-weight:700;color:var(--text-bright);letter-spacing:-0.01em;line-height:1.2">' + esc(sku) + '</div>';
if (descName && descName !== sku) {
h += '<div style="font-size:0.82rem;color:var(--text-dim);margin-top:0.3rem;line-height:1.4">' + esc(descName) + '</div>';
}
h += '</div>';
// Image section
var hasRealImage = t.image_url || (t.vendor_name === 'FLEXOPTIX' && FF_REFERENCE_IMAGES[t.form_factor]);
h += '<div class="tx-image-box ' + (hasRealImage ? 'has-photo' : 'has-svg') + '">';
h += getTransceiverImage(t);
if (t.vendor_name && t.image_url) h += '<span class="img-badge">' + esc(t.vendor_name) + '</span>';
if (t.product_page_url) {
h += '<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" class="img-link">View on ' + esc(t.vendor_name || 'Vendor') + ' &rarr;</a>';
}
h += '</div>';
// Title + Vendor badge
h += '<div class="panel-title">' + esc(t.standard_name || t.slug) + '</div>';
h += '<div class="panel-sub">';
if (t.vendor_name) h += '<span class="b b-blue">' + esc(t.vendor_name) + '</span> ';
if (t.category) h += '<span class="b b-neutral">' + esc(t.category) + '</span> ';
if (t.market_status) h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '">' + esc(t.market_status) + '</span>';
h += '</div>';
if (t.description) h += '<div style="font-size:0.8rem;color:var(--text-dim);margin:0.5rem 0">' + esc(t.description) + '</div>';
// Key specs — hero grid (like Flexoptix top section)
h += '<div class="panel-grid" style="margin-top:1rem">';
h += '<div class="panel-stat"><div class="panel-stat-label">Form Factor</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.form_factor) + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Speed</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.speed) + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Reach</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.reach_label || (t.reach_meters ? t.reach_meters + 'm' : '—')) + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Fiber</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.fiber_type || '—') + '</div></div>';
h += '</div>';
// Helper: render a spec section as a clean table (like Flexoptix spec tables)
function renderSpecTable(title, rows) {
var visible = rows.filter(function(r) { return r[1] != null && r[1] !== '' && r[1] !== false; });
if (visible.length === 0) return '';
var out = '<div class="panel-section">' + title + '</div>';
out += '<div class="spec-table">';
for (var i = 0; i < visible.length; i++) {
out += '<div class="spec-row"><span class="spec-label">' + esc(visible[i][0]) + '</span><span class="spec-val">' + esc(String(visible[i][1])) + '</span></div>';
}
out += '</div>';
return out;
}
// SPECIFICATION — Physical
h += renderSpecTable('Physical', [
['Connector / Polish', t.connector],
['Interface', t.fiber_type],
['Wavelengths', t.wavelengths],
['WDM Type', t.wdm_type],
['Channel Count', t.channel_count],
['Channel Spacing', t.channel_spacing_ghz ? t.channel_spacing_ghz + ' GHz' : null],
['Tunable', t.tunable ? 'Yes' : null],
['ITU Grid', t.itu_grid],
['Coherent', t.coherent ? 'Yes' : null],
['Temperature Range', t.temp_range],
]);
// SPECIFICATION — Performance
h += renderSpecTable('Performance', [
['Lanes', t.lanes],
['Lane Rate', t.lane_rate],
['Modulation', t.modulation],
['Baud Rate', t.baud_rate_gbaud ? t.baud_rate_gbaud + ' GBaud' : null],
['FEC Type', t.fec_type],
['DSP Vendor', t.dsp_vendor],
['Power Consumption', t.power_consumption_w ? t.power_consumption_w + ' W' : null],
['DOM Support', t.dom_support ? 'Yes' : (t.dom_support === false ? 'No' : null)],
['Digital Diagnostics', t.digital_diagnostics],
]);
// SPECIFICATION — Optical Budget
h += renderSpecTable('Optical Budget', [
['Power Budget', t.optical_budget_db ? t.optical_budget_db + ' dB' : null],
['Tx Power Min', t.tx_power_min_dbm ? t.tx_power_min_dbm + ' dBm' : null],
['Tx Power Max', t.tx_power_max_dbm ? t.tx_power_max_dbm + ' dBm' : null],
['Rx Sensitivity', t.rx_sensitivity_dbm ? t.rx_sensitivity_dbm + ' dBm' : null],
]);
// SPECIFICATION — Breakout
if (t.breakout_capable) {
h += renderSpecTable('Breakout', [
['Breakout Capable', 'Yes'],
['Breakout To', t.breakout_to],
]);
}
// SPECIFICATION — Product Info
h += renderSpecTable('Product Info', [
['Vendor', t.vendor_name],
['Part Number', t.part_number],
['Standard', t.standard_full_name || t.ieee_reference],
['Category', t.category],
['Market Status', t.market_status],
['Year Introduced', t.year_introduced],
['Year Mainstream', t.year_mainstream],
]);
// SPECIFICATION — Pricing
h += renderSpecTable('Pricing', [
['MSRP', t.msrp_usd ? '$' + parseFloat(t.msrp_usd).toLocaleString() : null],
['Street Price', t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toLocaleString() : null],
['Price Tier', t.price_tier],
]);
// Notes (scraped extra specs)
if (t.notes) {
h += '<div class="panel-section">Additional Specifications</div>';
var noteItems = t.notes.split('; ');
h += '<div class="spec-table">';
for (var n = 0; n < noteItems.length; n++) {
var parts = noteItems[n].split(': ');
if (parts.length >= 2) {
h += '<div class="spec-row"><span class="spec-label">' + esc(parts[0]) + '</span><span class="spec-val">' + esc(parts.slice(1).join(': ')) + '</span></div>';
}
}
h += '</div>';
}
// Use Cases
var useCases = classifyUseCase(t);
if (useCases.length > 0) {
h += '<div class="panel-section">Typical Use Cases</div>';
for (var u = 0; u < useCases.length; u++) {
h += '<div class="use-case-card">'
+ '<span class="use-case-icon">' + useCases[u].icon + '</span>'
+ '<div><div class="use-case-label">' + esc(useCases[u].label) + '</div>'
+ '<div class="use-case-desc">' + esc(useCases[u].desc) + '</div></div>'
+ '</div>';
}
}
// Documents & Links
var links = [];
if (t.product_page_url) links.push('<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Product Page</a>');
if (t.datasheet_url) links.push('<a href="' + esc(t.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Datasheet (PDF)</a>');
if (t.image_url) links.push('<a href="' + esc(t.image_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Full Image</a>');
if (links.length > 0) {
h += '<div class="panel-section">Documents &amp; Links</div>';
h += '<div style="display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0">' + links.join('') + '</div>';
}
buildDOM(el('panel-content'), h);
// Load compatible switches async
api('/api/transceivers/' + id + '/compatibility').then(function(cdata) {
var swList = cdata.data || [];
if (swList.length === 0) return;
var groups = {};
swList.forEach(function(sw) {
var key = sw.vendor_name || 'Other';
if (!groups[key]) groups[key] = [];
groups[key].push(sw);
});
var ch = '<div class="panel-section">Compatible Switches <span class="b b-green" style="margin-left:0.5rem">' + swList.length + '</span></div>';
ch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Switches verified to work with this transceiver</div>';
Object.keys(groups).sort().forEach(function(vendor) {
var items = groups[vendor];
ch += '<div style="margin:0.6rem 0 0.3rem;font-weight:600;font-size:0.8rem;color:var(--accent)">' + esc(vendor) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem">';
items.slice(0, 12).forEach(function(sw) {
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.7rem" onclick="openSwitchDetail(\'' + esc(sw.id) + '\')">' + esc(sw.model) + '</span>';
});
if (items.length > 12) ch += '<span class="dim" style="font-size:0.7rem">+' + (items.length - 12) + ' more</span>';
ch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
el('tx-search-btn').addEventListener('click', searchTransceivers);
el('tx-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchTransceivers(); });
el('tx-ff-filter').addEventListener('change', searchTransceivers);
el('tx-vendor-filter').addEventListener('change', searchTransceivers);
// Populate vendor dropdown
api('/api/vendors').then(function(data) {
var vendors = (data.data || []).filter(function(v) { return parseInt(v.transceiver_count) > 0; });
vendors.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
var sel = el('tx-vendor-filter');
vendors.forEach(function(v) {
var opt = document.createElement('option');
opt.value = v.name;
opt.textContent = v.name + ' (' + v.transceiver_count + ')';
sel.appendChild(opt);
});
});
// CSV Export
el('tx-export-btn').addEventListener('click', function() {
if (!lastTxData.length) return;
var cols = ['standard_name','vendor_name','form_factor','speed','speed_gbps','reach_label','reach_meters','fiber_type','connector','wdm_type','category','market_status','price_tier','msrp_usd','street_price_usd'];
var csv = cols.join(',') + '\n';
lastTxData.forEach(function(t) {
csv += cols.map(function(c) {
var v = t[c] != null ? String(t[c]).replace(/"/g, '""') : '';
return '"' + v + '"';
}).join(',') + '\n';
});
var blob = new Blob([csv], { type: 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'transceivers-' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
});
// Compare
el('tx-compare-btn').addEventListener('click', openCompare);
function openCompare() {
var checked = document.querySelectorAll('.compare-cb:checked');
if (checked.length < 2) { alert('Select at least 2 transceivers to compare.'); return; }
if (checked.length > 6) { alert('Select at most 6 transceivers to compare.'); return; }
var ids = [];
checked.forEach(function(cb) { ids.push(cb.getAttribute('data-id')); });
var items = lastTxData.filter(function(t) { return ids.indexOf(t.id) !== -1; });
var overlay = el('compare-overlay');
overlay.classList.add('visible');
var fields = [
['Vendor', 'vendor_name'], ['Form Factor', 'form_factor'], ['Speed', 'speed'],
['Speed (Gbps)', 'speed_gbps'], ['Reach', 'reach_label'], ['Reach (m)', 'reach_meters'],
['Fiber', 'fiber_type'], ['Connector', 'connector'], ['WDM', 'wdm_type'],
['Wavelengths', 'wavelengths'], ['Power (W)', 'power_consumption_w'],
['Temp Range', 'temp_range'], ['Category', 'category'], ['Market Status', 'market_status'],
['Price Tier', 'price_tier'], ['MSRP ($)', 'msrp_usd'], ['Street Price ($)', 'street_price_usd'],
];
var h = '<div class="compare-panel">';
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">';
h += '<h3 style="margin:0;font-size:1.1rem">Compare Transceivers (' + items.length + ')</h3>';
h += '<button id="compare-close-btn" class="btn" style="background:var(--red);color:#fff;padding:0.3rem 0.8rem">Close</button>';
h += '</div>';
h += '<div class="compare-table"><table><thead><tr><th>Spec</th>';
items.forEach(function(t) { h += '<th>' + esc(t.standard_name || t.slug) + '</th>'; });
h += '</tr></thead><tbody>';
fields.forEach(function(f) {
var vals = items.map(function(t) { return t[f[1]] != null ? String(t[f[1]]) : '—'; });
var allSame = vals.every(function(v) { return v === vals[0]; });
// Find best price (lowest)
var isBest = [];
if (f[1] === 'msrp_usd' || f[1] === 'street_price_usd') {
var nums = vals.map(function(v) { return parseFloat(v) || Infinity; });
var mn = Math.min.apply(null, nums);
isBest = nums.map(function(n) { return n === mn && n !== Infinity; });
}
h += '<tr><td style="font-weight:600">' + esc(f[0]) + '</td>';
vals.forEach(function(v, i) {
var cls = allSame ? '' : ' class="compare-diff"';
if (isBest.length && isBest[i]) cls = ' class="compare-best"';
h += '<td' + cls + '>' + esc(v) + '</td>';
});
h += '</tr>';
});
h += '</tbody></table></div></div>';
el('compare-content').innerHTML = h;
el('compare-close-btn').addEventListener('click', function() {
overlay.classList.remove('visible');
});
}
// Vendor URL builder
function buildVendorUrl(vendorName, model) {
var v = (vendorName || '').toLowerCase();
if (v.includes('cisco')) {
if (model.startsWith('N9K') || model.startsWith('N3K') || model.startsWith('N5K') || model.startsWith('N7K'))
return 'https://www.cisco.com/c/en/us/products/switches/nexus-' + model.replace(/^N(\d)K.*/, '$1000') + '-series-switches/index.html';
if (model.startsWith('C93') || model.startsWith('C92') || model.startsWith('C95'))
return 'https://www.cisco.com/c/en/us/products/switches/catalyst-' + model.replace(/^C/, '') + '/index.html';
return 'https://www.cisco.com/c/en/us/products/switches/index.html';
}
if (v.includes('arista')) {
var series = model.replace(/^DCS-/, '').replace(/-.*$/, '');
return 'https://www.arista.com/en/products/' + series.toLowerCase() + '-series';
}
if (v.includes('juniper')) {
if (model.startsWith('QFX')) return 'https://www.juniper.net/us/en/products/switches/qfx-series.html';
if (model.startsWith('EX')) return 'https://www.juniper.net/us/en/products/switches/ex-series.html';
return 'https://www.juniper.net/us/en/products/switches.html';
}
if (v.includes('mikrotik')) return 'https://mikrotik.com/product/' + model.replace(/[-\s]+/g, '_');
if (v.includes('fortinet')) return 'https://www.fortinet.com/products/switches';
if (v.includes('ubiquiti') || v.includes('ui.com')) return 'https://store.ui.com/us/en/collections/switching';
if (v.includes('netgear')) return 'https://www.netgear.com/business/wired/switches/' + model.toLowerCase() + '/';
if (v.includes('tp-link')) return 'https://www.tp-link.com/us/business-networking/managed-switch/' + model.toLowerCase() + '/';
if (v.includes('zyxel')) return 'https://www.zyxel.com/products/' + model + '/';
if (v.includes('dell')) return 'https://www.dell.com/en-us/shop/networking/cp/networking-switches';
if (v.includes('hpe') || v.includes('aruba')) return 'https://www.arubanetworks.com/products/switches/';
if (v.includes('mellanox') || v.includes('nvidia')) return 'https://www.nvidia.com/en-us/networking/ethernet-switching/';
if (v.includes('edgecore')) return 'https://www.edge-core.com/product_category-switches.html';
if (v.includes('celestica')) return 'https://www.celestica.com/enterprise-solutions/networking';
if (v.includes('accton')) return 'https://www.edge-core.com/';
return null;
}
// SWITCHES
function searchSwitches() {
var q = el('sw-search').value;
var cat = el('sw-cat-filter').value;
var params = [];
if (q) params.push('q=' + encodeURIComponent(q));
if (cat) params.push('category=' + encodeURIComponent(cat));
params.push('limit=100');
api('/api/switches?' + params.join('&')).then(function(data) {
var items = data.data || data.switches || [];
buildDOM(el('sw-table'), items.map(function(s) {
var catColors = { DataCenter: 'b-blue', Campus: 'b-green', SP: 'b-purple', Core: 'b-orange', Edge: 'b-cyan', Industrial: 'b-yellow' };
var statusColors = { Active: 'b-green', 'EoS_Announced': 'b-yellow', EoL: 'b-red', Legacy: 'b-neutral' };
var maxSpd = s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : s.max_speed_gbps + 'G';
var cap = s.switching_capacity_tbps ? s.switching_capacity_tbps + ' Tbps' : '—';
return '<tr class="clickable" data-swid="' + esc(s.id) + '">'
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(s.model) + '</td>'
+ '<td>' + esc(s.vendor_name || '') + '</td>'
+ '<td class="mono dim">' + esc(s.series || '') + '</td>'
+ '<td><span class="b ' + (catColors[s.category] || 'b-neutral') + '">' + esc(s.category || '') + '</span></td>'
+ '<td class="mono">' + esc(s.total_ports || '—') + '</td>'
+ '<td class="mono">' + esc(maxSpd) + '</td>'
+ '<td class="mono">' + esc(cap) + '</td>'
+ '<td class="dim">' + esc(s.asic_vendor ? s.asic_vendor + (s.asic_model ? ' ' + s.asic_model : '') : '—') + '</td>'
+ '<td><span class="b ' + (statusColors[s.lifecycle_status] || 'b-neutral') + '">' + esc(s.lifecycle_status || 'Active') + '</span></td>'
+ '</tr>';
}).join('') || '<tr><td colspan="9" class="loading">No switches found</td></tr>');
el('sw-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openSwitchDetail(this.getAttribute('data-swid')); });
});
}).catch(function(err) {
buildDOM(el('sw-table'), '<tr><td colspan="9" class="loading">Error loading switches</td></tr>');
});
}
async function openSwitchDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/switches/' + id);
var s = data.data || data;
// Image — real photo or placeholder
var h = '';
if (s.image_url) {
h += '<div class="tx-image-box has-photo">';
h += '<img src="' + esc(s.image_url) + '" alt="' + esc(s.model) + '" style="max-width:100%;border-radius:8px" onerror="this.onerror=null;this.outerHTML=\'<span style=font-size:1.5rem;color:var(--text-dim)>&#9881; ' + esc(s.model) + '</span>\'">';
if (s.vendor_name) h += '<span class="img-badge">' + esc(s.vendor_name) + '</span>';
} else {
h += '<div class="tx-image-box has-svg">';
h += '<span style="font-size:1.5rem;color:var(--text-dim)">&#9881; ' + esc(s.model) + '</span>';
}
if (s.product_page_url) {
h += '<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" class="img-link">View on ' + esc(s.vendor_name || 'Vendor') + ' &rarr;</a>';
}
h += '</div>';
h += '<div class="panel-title">' + esc(s.model) + '</div>';
h += '<div class="panel-sub">' + esc(s.vendor_name || '') + ' &mdash; ' + esc(s.series || '') + '</div>';
h += '<div class="panel-grid">';
h += '<div class="panel-stat"><div class="panel-stat-label">Category</div><div class="panel-stat-val" style="font-size:1rem">' + esc(s.category || '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Total Ports</div><div class="panel-stat-val">' + esc(s.total_ports || '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Switching Capacity</div><div class="panel-stat-val">' + (s.switching_capacity_tbps ? s.switching_capacity_tbps + ' <small>Tbps</small>' : '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Max Speed</div><div class="panel-stat-val">' + (s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : (s.max_speed_gbps || '—') + 'G') + '</div></div>';
h += '</div>';
h += '<div class="panel-section">Specifications</div>';
var specs = [
['Layer', s.layer], ['ASIC', (s.asic_vendor || '') + ' ' + (s.asic_model || '')],
['Forwarding Rate', s.forwarding_rate_mpps ? s.forwarding_rate_mpps + ' Mpps' : null],
['Rack Units', s.rack_units ? s.rack_units + 'U' : null],
['Max Power', s.max_power_w ? s.max_power_w + 'W' : null],
['PoE', s.poe_support !== 'None' ? s.poe_support : null],
['Status', s.lifecycle_status],
];
for (var i = 0; i < specs.length; i++) {
if (specs[i][1] && String(specs[i][1]).trim()) {
h += '<div class="panel-row"><span class="panel-row-label">' + esc(specs[i][0]) + '</span><span class="panel-row-val">' + esc(specs[i][1]) + '</span></div>';
}
}
h += '<div class="panel-section">Features</div>';
var features = [];
if (s.vxlan_support) features.push('VXLAN');
if (s.evpn_support) features.push('EVPN');
if (s.bgp_support) features.push('BGP');
if (s.mpls_support) features.push('MPLS');
if (s.openconfig_support) features.push('OpenConfig');
if (s.sonic_compatible) features.push('SONiC');
if (s.macsec_support) features.push('MACsec');
if (s.stacking_support) features.push('Stacking');
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + features.map(function(f) { return '<span class="b b-cyan">' + esc(f) + '</span>'; }).join('') + (features.length === 0 ? '<span class="dim">None listed</span>' : '') + '</div>';
if (s.ports_config && Object.keys(s.ports_config).length > 0) {
h += '<div class="panel-section">Port Configuration</div>';
Object.keys(s.ports_config).forEach(function(k) {
h += '<div class="panel-row"><span class="panel-row-label">' + esc(k.replace(/_/g, ' ')) + '</span><span class="panel-row-val">' + esc(s.ports_config[k]) + 'x</span></div>';
});
}
var links = [];
if (s.product_page_url) links.push('<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Product Page</a>');
if (s.datasheet_url) links.push('<a href="' + esc(s.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Datasheet</a>');
if (s.catalog_url) links.push('<a href="' + esc(s.catalog_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Catalog</a>');
if (s.image_url) links.push('<a href="' + esc(s.image_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Full Image</a>');
if (!s.product_page_url && s.vendor_name && s.model) {
var vendorUrl = buildVendorUrl(s.vendor_name, s.model);
if (vendorUrl) links.push('<a href="' + esc(vendorUrl) + '" target="_blank" rel="noopener" style="color:var(--yellow);text-decoration:none;font-weight:600;font-size:0.8rem">Vendor Page (estimated)</a>');
}
if (links.length > 0) {
h += '<div class="panel-section">Documents &amp; Links</div>';
h += '<div style="display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0">' + links.join('') + '</div>';
}
if (s.is_whitebox) {
h += '<div class="panel-section">Open Networking</div>';
var nos = [];
if (s.sonic_compatible) nos.push('SONiC');
if (s.onl_compatible) nos.push('ONL');
if (s.dent_compatible) nos.push('DENT');
if (s.cumulus_compatible) nos.push('Cumulus');
if (s.fboss_compatible) nos.push('FBOSS');
if (s.onie_support) nos.push('ONIE');
if (s.supported_nos && s.supported_nos.length) nos = nos.concat(s.supported_nos);
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + nos.map(function(n) { return '<span class="b b-green">' + esc(n) + '</span>'; }).join('') + '</div>';
if (s.sonic_hwsku) h += '<div class="panel-row"><span class="panel-row-label">SONiC HWSKU</span><span class="panel-row-val mono">' + esc(s.sonic_hwsku) + '</span></div>';
if (s.cpu) h += '<div class="panel-row"><span class="panel-row-label">CPU</span><span class="panel-row-val">' + esc(s.cpu) + (s.cpu_cores ? ' (' + s.cpu_cores + ' cores)' : '') + '</span></div>';
if (s.ram_gb) h += '<div class="panel-row"><span class="panel-row-label">RAM</span><span class="panel-row-val">' + esc(s.ram_gb) + ' GB</span></div>';
}
buildDOM(el('panel-content'), h);
api('/api/switches/' + id + '/compatibility').then(function(cdata) {
var txList = cdata.data || cdata.transceivers || [];
if (txList.length === 0) return;
var groups = {};
txList.forEach(function(t) {
var key = (t.form_factor || '?') + ' ' + (t.speed || '?');
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
var ch = '<div class="panel-section">Compatible Transceivers <span class="b b-green" style="margin-left:0.5rem">' + txList.length + '</span></div>';
Object.keys(groups).sort().forEach(function(key) {
var items = groups[key];
ch += '<div style="margin:0.6rem 0 0.3rem;font-weight:600;font-size:0.8rem;color:var(--accent)">' + esc(key) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem">';
items.slice(0, 12).forEach(function(t) {
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.7rem" onclick="openTxDetail(\'' + esc(t.id) + '\')">' + esc(t.standard_name || t.slug || t.part_number) + '</span>';
});
if (items.length > 12) ch += '<span class="dim" style="font-size:0.7rem">+' + (items.length - 12) + ' more</span>';
ch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
el('sw-search-btn').addEventListener('click', searchSwitches);
el('sw-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchSwitches(); });
el('sw-cat-filter').addEventListener('change', searchSwitches);
// NEWS
async function loadNews() {
var data = await api('/api/search?q=optical+transceiver+data+center+networking&collection=news_embeddings&limit=25');
buildDOM(el('news-list'), (data.results || []).map(function(n) {
var urlSafe = (n.url && /^https?:\/\//.test(n.url)) ? n.url : '#';
return '<div class="ri">'
+ '<div class="ri-title">' + esc(n.title) + '</div>'
+ '<div class="ri-body">' + esc(n.summary) + '</div>'
+ '<div class="ri-meta">'
+ '<span class="b b-blue">' + esc(n.source) + '</span>'
+ (n.published_at ? '<span>' + new Date(n.published_at).toLocaleDateString() + '</span>' : '')
+ (urlSafe !== '#' ? '<a href="' + esc(urlSafe) + '" target="_blank" rel="noopener noreferrer" style="color:var(--accent);text-decoration:none;font-size:0.72rem;font-weight:600">Read &rarr;</a>' : '')
+ '</div></div>';
}).join('') || '<div class="loading">No news yet</div>');
}
// Markdown to HTML
function mdToHtml(md) {
if (!md) return '';
return md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h4 style="color:var(--text-bright);margin:1.2rem 0 0.4rem;font-size:0.95rem">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 style="color:var(--accent);margin:1.5rem 0 0.5rem;font-size:1.05rem;font-family:var(--font-heading);border-bottom:1px solid var(--border);padding-bottom:0.3rem">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 style="color:var(--text-bright);margin:0 0 0.8rem;font-size:1.2rem;font-family:var(--font-heading)">$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--text-bright)">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code style="background:var(--surface3);padding:0.15rem 0.4rem;border-radius:4px;font-family:var(--mono);font-size:0.8rem;color:var(--accent)">$1</code>')
.replace(/^```[\s\S]*?\n([\s\S]*?)```/gm, function(m, code) {
return '<pre style="background:var(--surface-dark);color:#d4d4d4;padding:0.8rem;border-radius:var(--radius-md);overflow-x:auto;font-size:0.8rem;line-height:1.5;border:1px solid var(--border);margin:0.5rem 0"><code style="font-family:var(--mono)">' + code.trim() + '</code></pre>';
})
.replace(/^- (.+)$/gm, '<div style="padding-left:1rem;position:relative;margin:0.15rem 0"><span style="position:absolute;left:0;color:var(--accent)">•</span> $1</div>')
.replace(/^\d+\. (.+)$/gm, function(m, text) {
return '<div style="padding-left:1.2rem;margin:0.15rem 0">' + text + '</div>';
})
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>');
}
// Copy to clipboard
function copyBlogContent(id) {
api('/api/blog/' + id).then(function(data) {
var content = data.draft.draft_content || '';
navigator.clipboard.writeText(content).then(function() {
var btn = document.getElementById('copy-btn-' + id);
if (btn) {
btn.classList.add('copied');
btn.innerHTML = '&#10003; Copied';
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = '&#128203; Copy';
}, 2000);
}
showToast('Copied', 'Article text copied to clipboard');
}).catch(function() {
showToast('Error', 'Failed to copy to clipboard', true);
});
});
}
// BLOG
function generateBlog(topic, speed) {
el('blog-list').innerHTML = '<div class="loading pulse">Generating article...</div>';
var body = { topic: topic };
if (speed) body.speed = speed;
fetch(API + '/api/blog/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
showToast('⚙️ Generating…', data.draft.title + ' — pipeline running (~10 min)');
loadBlogDrafts();
// Always poll progress — pipeline runs async
pollBlogLlm(data.draft.id, 0);
} else showToast('Failed', data.error || 'Unknown error', true);
}).catch(function(err) { showToast('Network error', err.message, true); });
}
function pollBlogLlm(id, attempt) {
if (attempt > 60) return; // max 10 min (60 × 10s)
setTimeout(function() {
api('/api/blog/' + id + '/progress').then(function(p) {
if (!p.running) {
// Pipeline done — update badge to "ready" and reload list
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
if (badge) {
badge.className = 'b b-green blog-status-badge';
badge.textContent = 'ready';
}
api('/api/blog/' + id).then(function(data) {
if (data.draft) {
showToast('✅ Blog ready', data.draft.title + ' — ' + data.draft.word_count + ' words');
}
}).catch(function() {});
loadBlogDrafts();
} else {
// Still running — update badge with step info
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
if (badge) {
badge.className = 'b b-yellow blog-status-badge';
badge.textContent = 'step ' + p.step + '/10';
}
pollBlogLlm(id, attempt + 1);
}
}).catch(function() {
pollBlogLlm(id, attempt + 1);
});
}, 8000);
}
// Hot topics loaded dynamically via hot-topics.js
// ══════════════════════════════════════════════════════
// FINDER — Switch → Transceiver
// ══════════════════════════════════════════════════════
function finderQuick(model) {
document.getElementById('finder-switch-input').value = model;
runFinder();
}
async function runFinder() {
var model = (document.getElementById('finder-switch-input').value || '').trim();
var speed = document.getElementById('finder-speed-filter').value;
var results = document.getElementById('finder-results');
if (!model) { results.innerHTML = '<p style="color:var(--text-dim)">Enter a switch model to search.</p>'; return; }
results.innerHTML = '<div class="loading pulse">Searching compatibility database...</div>';
var url = '/api/finder?switch=' + encodeURIComponent(model) + (speed ? '&speed=' + speed : '');
try {
var data = await api(url);
if (data.error) {
results.innerHTML = '<div class="card" style="border-left:3px solid #c1121f"><b>Not found:</b> ' + data.error +
(data.suggestion ? '<br><span style="color:var(--text-dim);font-size:0.85rem">' + data.suggestion + '</span>' : '') + '</div>';
return;
}
var sw = data.switch;
var transceivers = data.compatible_transceivers || [];
var total = data.total || 0;
// Switch info header
var swHtml = '<div class="card mb" style="display:flex;gap:1rem;align-items:center">' +
(sw.image_url ? '<img src="' + sw.image_url + '" style="height:60px;border-radius:6px;object-fit:contain" onerror="this.style.display=\'none\'">' : '') +
'<div style="flex:1">' +
'<div style="font-size:1.1rem;font-weight:700">' + sw.vendor + ' ' + sw.model + '</div>' +
'<div style="color:var(--text-dim);font-size:0.8rem">' +
(sw.series ? sw.series + ' · ' : '') +
'Max speed: ' + (sw.max_speed_gbps || '?') + 'G' +
'</div>' +
'</div>' +
'<div style="text-align:right;font-size:0.8rem;color:var(--text-dim)">' +
'<b style="font-size:1.1rem;color:var(--text)">' + total + '</b> compatible transceivers' +
'</div>' +
'</div>';
if (transceivers.length === 0) {
results.innerHTML = swHtml + '<div class="card" style="color:var(--text-dim)">No compatible transceivers found for this switch. Try removing the speed filter.</div>';
return;
}
// Group by speed class
var bySpeed = {};
for (var t of transceivers) {
var key = t.speed_gbps + 'G ' + t.form_factor;
if (!bySpeed[key]) bySpeed[key] = [];
bySpeed[key].push(t);
}
var speedColors = { 800: '#c1121f', 400: '#FF8100', 100: '#e6a800', 25: '#2d6a4f', 10: '#888' };
var tcvrHtml = Object.entries(bySpeed).sort(function(a, b) {
return parseInt(b[0]) - parseInt(a[0]);
}).map(function(entry) {
var speedClass = entry[0];
var items = entry[1];
var speedGbps = items[0].speed_gbps;
var color = speedColors[speedGbps] || '#888';
var cards = items.slice(0, 12).map(function(t) {
var isFlexoptix = (t.vendor || '').toUpperCase() === 'FLEXOPTIX';
var fullyVerified = t.fully_verified === true;
var priceVerified = t.price_verified === true;
// Price: use verified EUR price if available
var displayPrice = t.price_verified_eur || t.price;
var displayCurrency = t.price_verified_eur ? 'EUR' : (t.currency || 'EUR');
var hasPrice = displayPrice != null;
var priceHtml = hasPrice
? '<span style="color:var(--accent);font-weight:700">' + displayCurrency + ' ' + parseFloat(displayPrice).toFixed(2) + '</span>'
+ (priceVerified ? ' <span title="Price verified from official source" style="color:#2d6a4f;font-size:0.6rem;cursor:help">✓ Verified</span>' : '')
: '<span style="color:var(--text-dim);font-size:0.8rem">see flexoptix.net</span>';
var stockHtml = t.stock === 'in_stock' ? '<span style="color:#2d6a4f;font-size:0.65rem">● In Stock</span>'
: t.stock === 'limited' ? '<span style="color:#e6a800;font-size:0.65rem">● Limited</span>'
: '';
var partNum = t.part_number || t.slug || t.id;
// 100% Verified stamp
var verifiedStamp = fullyVerified
? '<div title="Price, product image and specifications all verified from official sources" style="display:inline-flex;align-items:center;gap:3px;background:linear-gradient(135deg,#1b4332,#2d6a4f);color:white;font-size:0.6rem;font-weight:700;padding:2px 7px;border-radius:10px;margin-bottom:4px;cursor:help;letter-spacing:0.03em">★ 100% VERIFIED</div><br>'
: '';
// Card border: 100% verified = green, Flexoptix = orange, else default
var cardBorder = fullyVerified ? 'border:1px solid #2d6a4f;box-shadow:0 0 0 1px #2d6a4f20'
: isFlexoptix ? 'border-left:3px solid var(--accent)' : '';
return '<div class="card" style="padding:0.8rem;' + cardBorder + '">' +
verifiedStamp +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem">' +
'<div style="flex:1;min-width:0">' +
(isFlexoptix ? '<span style="font-size:0.6rem;background:var(--accent);color:white;padding:1px 5px;border-radius:3px;margin-right:4px">FLEXOPTIX</span>' : '') +
'<span style="font-size:0.7rem;color:var(--text-dim)">' + (t.vendor || '') + '</span><br>' +
'<b style="font-size:0.85rem;word-break:break-all">' + partNum + '</b><br>' +
'<span style="font-size:0.75rem;color:var(--text-dim)">' +
(t.reach || '') + (t.fiber_type ? ' · ' + t.fiber_type : '') +
(t.connector ? ' · ' + t.connector : '') +
'</span>' +
'</div>' +
'<div style="text-align:right;flex-shrink:0">' +
priceHtml + '<br>' + stockHtml +
'</div>' +
'</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem">' +
(t.buy_url ? '<a href="' + t.buy_url + '" target="_blank" style="font-size:0.7rem;color:var(--accent)">Buy at Flexoptix →</a>' : '<span></span>') +
(t.price_verified_url ? '<a href="' + t.price_verified_url + '" target="_blank" title="Price source" style="font-size:0.6rem;color:var(--text-dim)">price source ↗</a>' : '') +
'</div>' +
'</div>';
}).join('');
return '<div style="margin-bottom:1.2rem">' +
'<div style="font-size:0.8rem;font-weight:700;color:' + color + ';margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em">' +
speedClass + ' <span style="color:var(--text-dim);font-weight:400">(' + items.length + ' options)</span>' +
'</div>' +
'<div class="grid g3">' + cards + '</div>' +
'</div>';
}).join('');
// Count verified in results
var verifiedCount = transceivers.filter(function(t) { return t.fully_verified; }).length;
var priceVerCount = transceivers.filter(function(t) { return t.price_verified; }).length;
results.innerHTML = swHtml +
'<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.8rem;display:flex;gap:1rem;flex-wrap:wrap">' +
'<span>Showing ' + Math.min(transceivers.length, total) + ' of ' + total + ' compatible transceivers</span>' +
(verifiedCount > 0 ? '<span style="color:#2d6a4f">★ ' + verifiedCount + ' × 100% Verified</span>' : '') +
(priceVerCount > 0 ? '<span style="color:#2d6a4f">✓ ' + priceVerCount + ' with verified prices</span>' : '') +
'</div>' +
tcvrHtml;
} catch(e) {
results.innerHTML = '<div class="card" style="border-left:3px solid #c1121f">Error: ' + e.message + '</div>';
}
}
async function loadBlogDrafts() {
var data = await api('/api/blog');
// Check which drafts are still generating (in-progress pipelines)
var drafts = data.drafts || [];
// Fetch progress for all drafts in parallel to know which are still running
var progressMap = {};
await Promise.all(drafts.map(function(d) {
return api('/api/blog/' + d.id + '/progress').then(function(p) {
progressMap[d.id] = p;
}).catch(function() {});
}));
buildDOM(el('blog-list'), drafts.map(function(d) {
var p = progressMap[d.id] || {};
var isRunning = p.running === true;
// Status badge: generating → step X/10, done → ready, published → published, review → review
var statusLabel, statusClass;
if (isRunning) {
statusLabel = p.step ? 'step ' + p.step + '/10' : 'generating…';
statusClass = 'b-yellow';
} else if (d.status === 'published') {
statusLabel = 'published';
statusClass = 'b-green';
} else if (d.status === 'review') {
statusLabel = 'review';
statusClass = 'b-yellow';
} else if (d.pipeline_steps_completed >= 10 || (d.generated_by || '').includes('fo-blog')) {
statusLabel = 'ready ✓';
statusClass = 'b-green';
} else {
statusLabel = 'draft';
statusClass = 'b-blue';
}
var gen = (d.generated_by || '').replace('tip-blog-engine-', '');
var gc = gen.includes('fo-blog') ? 'b-green' : gen === 'template-fallback' ? 'b-yellow' : 'b-neutral';
return '<div class="ri" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" onclick="openBlogDetail(\'' + esc(d.id) + '\')">'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<div class="ri-title">' + esc(d.title) + '</div>'
+ '<div style="display:flex;align-items:center;gap:8px">'
+ '<span class="b ' + statusClass + ' blog-status-badge">' + statusLabel + '</span>'
+ '<span class="blog-del-btn" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" title="Delete" style="color:#c1121f;cursor:pointer;font-size:0.9rem;padding:2px 6px;border-radius:4px" onclick="event.stopPropagation();blogDeleteClick(this)">✕</span>'
+ '</div>'
+ '</div>'
+ '<div class="ri-meta">'
+ '<span class="b b-purple">' + esc(d.topic) + '</span>'
+ '<span class="b b-neutral">' + esc(d.target_audience) + '</span>'
+ '<span class="b ' + gc + '">' + esc(gen || 'template') + '</span>'
+ '<span class="mono">' + esc(d.word_count) + ' words</span>'
+ '<span>' + new Date(d.created_at).toLocaleDateString() + '</span>'
+ '</div></div>';
}).join('') || '<div class="loading">No drafts yet &mdash; click a card above to generate</div>');
// Auto-start polling for any drafts currently running
drafts.forEach(function(d) {
if (progressMap[d.id] && progressMap[d.id].running) {
pollBlogLlm(d.id, 0);
}
});
}
async function openBlogDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/blog/' + id);
var d = data.draft;
var outline = d.outline || {};
var genMethod = (d.generated_by || '').replace('tip-blog-engine-', '');
var methodBadge = genMethod === 'llm' ? 'b-green' : genMethod === 'template-fallback' ? 'b-yellow' : 'b-neutral';
var h = '<div class="panel-title">' + esc(d.title) + '</div>';
h += '<div class="panel-sub">';
h += '<span class="b b-purple">' + esc(d.topic) + '</span> ';
h += '<span class="b b-neutral">' + esc(d.target_audience) + '</span> ';
h += '<span class="b ' + methodBadge + '">' + esc(genMethod || 'template') + '</span> ';
h += '<span class="mono dim">' + esc(d.word_count) + ' words</span>';
h += '</div>';
var hasQualityIssues = outline.quality_issues && outline.quality_issues.length > 0;
if (hasQualityIssues) {
h += '<div style="margin:0.5rem 0;padding:0.4rem 0.8rem;background:var(--yellow-light);border:1px solid rgba(212,163,115,0.4);border-radius:var(--radius-md);font-size:0.8rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">';
h += '<div><strong style="color:#b8860b">Quality issues:</strong> ' + outline.quality_issues.map(esc).join(', ') + '</div>';
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="white-space:nowrap;color:#b8860b;border-color:rgba(212,163,115,0.5);font-size:0.75rem;padding:3px 10px">&#x1F504; Regenerate</button>';
h += '</div>';
}
// Copy button + Article section header
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin:1.25rem 0 0.6rem">';
h += '<div class="panel-section" style="margin:0;flex:1">Article</div>';
h += '<button class="btn-copy" id="copy-btn-' + esc(d.id) + '" onclick="event.stopPropagation();copyBlogContent(\'' + esc(d.id) + '\')">&#128203; Copy</button>';
h += '</div>';
h += '<div style="font-size:0.85rem;color:var(--text);line-height:1.8;max-height:65vh;overflow-y:auto;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + mdToHtml(d.draft_content) + '</div>';
h += '<div class="panel-section">SEO Keywords / Hashtags</div>';
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + (d.seo_keywords || []).map(function(k) {
var tag = '#' + k.replace(/\s+/g, '');
return '<span class="b b-neutral" style="cursor:pointer;user-select:all" title="Click to copy hashtag" onclick="navigator.clipboard.writeText(\'' + esc(tag) + '\').then(function(){showToast(\'Copied\',\'' + esc(tag) + '\')})">' + esc(tag) + '</span>';
}).join('') + '</div>';
h += '<div style="margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">';
if (d.status === 'review' || hasQualityIssues) {
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="color:#b8860b;border-color:rgba(212,163,115,0.5)">&#x1F504; Neu generieren</button>';
}
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'review\')" style="color:#b8860b;border-color:rgba(212,163,115,0.4)">Mark Review</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'approved\')" style="color:var(--green);border-color:rgba(45,106,79,0.3)">Approve</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'published\')" style="color:var(--accent);border-color:rgba(196,112,75,0.3)">Publish</button>';
h += '</div>';
buildDOM(el('panel-content'), h);
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
async function updateBlogStatus(id, status) {
try {
var data = await fetch(API + '/api/blog/' + id + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: status })
}).then(function(r) { return r.json(); });
if (data.success) {
showToast('Updated', 'Status: ' + status);
openBlogDetail(id);
loadBlogDrafts();
} else showToast('Failed', data.error, true);
} catch(e) { showToast('Error', e.message, true); }
}
async function regenerateBlog(id) {
showToast('Regenerating…', 'LLM pipeline wird neu gestartet');
try {
var data = await fetch(API + '/api/blog/' + id + '/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}).then(function(r) { return r.json(); });
if (data.success) {
showToast('Gestartet', 'LLM läuft Status wird aktualisiert');
loadBlogDrafts();
// Poll for completion
pollBlogLlm(id, 0);
} else {
showToast('Fehler', data.error || 'Regenerierung fehlgeschlagen', true);
}
} catch(e) { showToast('Error', e.message, true); }
}
// TABLE SORTING
function makeSortable(table) {
if (!table) return;
var headers = table.querySelectorAll('thead th');
headers.forEach(function(th, colIdx) {
th.addEventListener('click', function(e) {
// Don't sort if clicking inside a badge or link
if (e.target.tagName === 'A') return;
var tbody = table.querySelector('tbody');
if (!tbody) return;
// Toggle direction
var isAsc = th.classList.contains('sort-asc');
// Reset all headers in this table
headers.forEach(function(h) { h.classList.remove('sort-asc', 'sort-desc'); });
th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
var dir = isAsc ? -1 : 1;
// Get rows and sort
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function(a, b) {
var cellA = a.children[colIdx];
var cellB = b.children[colIdx];
if (!cellA || !cellB) return 0;
var valA = (cellA.textContent || '').trim();
var valB = (cellB.textContent || '').trim();
// Try numeric comparison (handles "23%", "2026", "3y", "$1,234", "12.5 Tbps" etc.)
var numA = parseFloat(valA.replace(/[^0-9.\-]/g, ''));
var numB = parseFloat(valB.replace(/[^0-9.\-]/g, ''));
if (!isNaN(numA) && !isNaN(numB)) {
return (numA - numB) * dir;
}
// Handle "—" as last
if (valA === '—' && valB !== '—') return 1;
if (valB === '—' && valA !== '—') return -1;
return valA.localeCompare(valB, undefined, { numeric: true, sensitivity: 'base' }) * dir;
});
// Re-append in order
rows.forEach(function(row) { tbody.appendChild(row); });
});
});
}
// Initialize sorting on all tables after DOM ready
function initAllSorting() {
document.querySelectorAll('.table-wrap table').forEach(makeSortable);
}
// Run once now and also after each table rebuild
initAllSorting();
// Observer to re-init sorting when tbody content changes
var sortObserver = new MutationObserver(function() { initAllSorting(); });
document.querySelectorAll('tbody').forEach(function(tb) {
sortObserver.observe(tb, { childList: true });
});
// Close compare overlay on backdrop click
el('compare-overlay').addEventListener('click', function(e) {
if (e.target === this) this.classList.remove('visible');
});
// INIT
loadOverview();
</script>
<script src="/dashboard/hot-topics.js"></script>
</body>
</html>