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