2278 lines
111 KiB
HTML

<!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: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-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: 1rem; right: 1rem;
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); width: 32px; height: 32px; border-radius: var(--radius-md);
cursor: pointer; font-size: 1.1rem;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.panel-close:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.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="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>
<!-- 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>
<!-- BLOG -->
<div id="tab-blog" class="hidden">
<div class="grid g3 mb">
<div class="gen-card" id="gen-hype">
<div class="gen-card-title">Hype Cycle Analysis</div>
<div class="gen-card-sub">800G technology position article</div>
</div>
<div class="gen-card" id="gen-comparison">
<div class="gen-card-title">Product Comparison</div>
<div class="gen-card-sub">400G transceiver comparison</div>
</div>
<div class="gen-card" id="gen-tutorial">
<div class="gen-card-title">Tutorial</div>
<div class="gen-card-sub">Transceiver troubleshooting guide</div>
</div>
</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));
}
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();
}
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
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="5.5" fill="' + color + '" class="hype-dot" data-tech="' + esc(t.technology) + '" filter="url(#glow)" stroke="rgba(255,255,255,0.25)" stroke-width="0.8" />';
// 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>';
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; }
}
async function loadHypeCycle() {
var 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')); });
});
}
// 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 style="font-weight:600;color:var(--text-bright)">' + esc(t.standard_name || t.slug) + '</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 = '';
// 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) {
var msg = data.draft.title + ' — ' + data.draft.word_count + ' words';
if (data.draft.llm_enhancing) msg += ' (LLM enhancing in background...)';
showToast('Draft generated', msg);
if (data.draft.llm_enhancing) {
pollBlogLlm(data.draft.id, 0);
}
} else showToast('Failed', data.error || 'Unknown error', true);
loadBlogDrafts();
}).catch(function(err) { showToast('Network error', err.message, true); });
}
function pollBlogLlm(id, attempt) {
if (attempt > 30) return;
setTimeout(function() {
api('/api/blog/' + id).then(function(data) {
if (data.draft && data.draft.generated_by && data.draft.generated_by.includes('llm')) {
showToast('LLM Enhanced', data.draft.title + ' — ' + data.draft.word_count + ' words');
loadBlogDrafts();
} else {
pollBlogLlm(id, attempt + 1);
}
}).catch(function() {});
}, 10000);
}
el('gen-hype').addEventListener('click', function() { generateBlog('hype_cycle', '800G'); });
el('gen-comparison').addEventListener('click', function() { generateBlog('comparison', '400G'); });
el('gen-tutorial').addEventListener('click', function() { generateBlog('tutorial'); });
async function loadBlogDrafts() {
var data = await api('/api/blog');
buildDOM(el('blog-list'), (data.drafts || []).map(function(d) {
var sc = d.status === 'published' ? 'b-green' : d.status === 'review' ? 'b-yellow' : 'b-blue';
var gen = (d.generated_by || '').replace('tip-blog-engine-', '');
var gc = gen === 'llm' ? 'b-green' : gen === 'template-fallback' ? 'b-yellow' : 'b-neutral';
return '<div class="ri" onclick="openBlogDetail(\'' + esc(d.id) + '\')">'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<div class="ri-title">' + esc(d.title) + '</div>'
+ '<span class="b ' + sc + '">' + esc(d.status) + '</span>'
+ '</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>');
}
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>';
if (outline.quality_issues && outline.quality_issues.length > 0) {
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">';
h += '<strong style="color:#b8860b">Quality issues:</strong> ' + outline.quality_issues.map(esc).join(', ');
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</div>';
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + (d.seo_keywords || []).map(function(k) { return '<span class="b b-neutral">' + esc(k) + '</span>'; }).join('') + '</div>';
h += '<div style="margin-top:1rem;display:flex;gap:0.5rem">';
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); }
}
// 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>
</body>
</html>