Rene Fichtmueller 3e780ce6b7 feat: add tooltips throughout Procurement Intelligence tab + rename nav
- Rename nav tab and sub-nav from 'Procurement Intel' to 'Procurement Intelligence'
- Add data-tip tooltips to all 8 ABC table column headers
- Add title attributes to signal badges, ABC class badges, supply risk, stock/price/lead trend spans, signal strength bar
- Add hover descriptions to Market Intelligence type icons, buy signal badges, technology tags, impact horizon, source
- Add hover descriptions to Lifecycle Events type icons, buy signal badges, impact level, effective date
- Tooltips explain business meaning of every data point (e.g. ABC classification formula, demand score composition, supply risk levels)
2026-04-01 23:32:01 +02:00

3426 lines
185 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIP — Transceiver Intelligence Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700;800&family=DM+Serif+Display&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f7f7f7;
--surface: #ffffff;
--surface2: #f0f0f0;
--surface3: #e5e5e5;
--surface-dark: #000000;
--border: #e0e0e0;
--border-hover: #FF8100;
--text: #333333;
--text-bright: #000000;
--text-dim: #888888;
--accent: #FF8100;
--accent-dark: #e07000;
--accent-glow: rgba(255,129,0,0.08);
--accent2: #1a1a1a;
--green: #2d6a4f;
--green-light: #d8f3dc;
--yellow: #FFa000;
--yellow-light: #fff3e0;
--red: #c1121f;
--red-light: #fde8e8;
--purple: #7c5cfc;
--purple-light: #f0ecff;
--orange: #FF8100;
--cyan: #1a1a1a;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-heading: 'DM Sans', system-ui, -apple-system, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06);
--shadow-hover: 0 4px 12px rgba(0,0,0,0.1);
--shadow-glow: 0 2px 8px rgba(255,129,0,0.1);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
}
* { margin:0; padding:0; box-sizing:border-box; }
/* Scrollbar styling to match theme */
html { scrollbar-color: var(--border) var(--bg); }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #cccccc; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
overflow-x: hidden;
}
/* === LAYOUT === */
.app { position: relative; z-index: 1; }
/* === HEADER === */
.header {
background: var(--surface-dark);
padding: 0 2rem;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky; top: 0; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.header-left { display: flex; align-items: center; gap: 2rem; }
.logo { display: flex; align-items: center; gap: 0.6rem; }
.logo-mark {
width: 32px; height: 32px; border-radius: var(--radius-md);
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 0.85rem; color: #fff;
}
.logo-text {
font-family: var(--font-heading);
font-size: 1rem; color: #fff;
letter-spacing: -0.02em;
}
.logo-text span { color: rgba(255,255,255,0.5); font-family: var(--font-body); font-size: 0.85rem; margin-left: 0.25rem; }
.header-stats {
display: flex; gap: 1.5rem; font-size: 0.75rem;
font-family: var(--mono); color: rgba(255,255,255,0.5);
}
.header-stats span[data-goto] {
cursor: pointer; transition: color 0.2s;
}
.header-stats span[data-goto]:hover {
color: rgba(255,255,255,0.85);
}
.header-stats .val {
color: var(--accent); font-weight: 600;
}
.header .status {
display: flex; gap: 1rem; align-items: center; font-size: 0.75rem;
}
.status-pill {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.6rem; border-radius: 20px;
background: rgba(45,106,79,0.15);
border: 1px solid rgba(45,106,79,0.3);
color: #6ee7b7; font-weight: 500;
font-size: 0.7rem; font-family: var(--mono);
}
.status-pill.err { background: rgba(193,18,31,0.15); border-color: rgba(193,18,31,0.3); color: #fca5a5; }
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-ok { background: #6ee7b7; box-shadow: 0 0 6px rgba(110,231,183,0.4); animation: pulse-dot 3s infinite; }
.dot-err { background: #fca5a5; }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.5} }
.version-tag {
font-family: var(--mono); font-size: 0.65rem; color: rgba(255,255,255,0.4);
padding: 0.15rem 0.5rem; border-radius: 4px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
}
/* === TABS === */
.tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 2rem;
background: var(--surface);
}
.tab {
padding: 0.75rem 1.25rem;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-dim);
font-size: 0.8rem; font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.02em;
}
.tab:hover { color: var(--text-bright); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* === MAIN === */
.main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
.grid { display: grid; gap: 1rem; }
.g4 { grid-template-columns: repeat(4, 1fr); }
.g3 { grid-template-columns: repeat(3, 1fr); }
.g2 { grid-template-columns: 1fr 1fr; }
.g2-1 { grid-template-columns: 2fr 1fr; }
/* === CARDS === */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-card);
transition: box-shadow 0.2s, border-color 0.2s;
}
.card-header {
font-size: 1rem; font-weight: 700; color: var(--text-bright);
margin-bottom: 1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.card:hover {
box-shadow: var(--shadow-hover);
}
.card-label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); margin-bottom: 0.5rem;
}
.card-num {
font-size: 2rem; font-weight: 800;
font-family: var(--mono); letter-spacing: -0.03em;
color: var(--accent);
}
.card-num small {
font-size: 0.7rem; font-weight: 400; color: var(--text-dim);
}
/* === STAT CARDS === */
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
position: relative; overflow: hidden;
transition: all 0.2s;
box-shadow: var(--shadow-card);
cursor: pointer;
}
.stat-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.stat-icon {
width: 36px; height: 36px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 1rem; margin-bottom: 0.75rem;
}
.stat-icon.blue { background: rgba(196,112,75,0.08); color: var(--accent); }
.stat-icon.green { background: rgba(45,106,79,0.08); color: var(--green); }
.stat-icon.purple { background: rgba(124,92,252,0.08); color: var(--purple); }
.stat-icon.orange { background: rgba(231,111,81,0.08); color: var(--orange); }
.stat-icon.cyan { background: rgba(38,70,83,0.08); color: var(--cyan); }
.stat-label {
font-size: 0.72rem; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;
}
.stat-val {
font-size: 1.75rem; font-weight: 800; font-family: var(--mono);
color: var(--text-bright); letter-spacing: -0.03em; margin-top: 0.15rem;
}
/* === BADGES === */
.b {
display: inline-block; padding: 2px 8px; border-radius: 100px;
font-size: 0.68rem; font-weight: 600; font-family: var(--mono);
letter-spacing: 0.02em;
}
.b-blue { background: rgba(196,112,75,0.08); color: var(--accent); border: 1px solid rgba(196,112,75,0.2); }
.b-green { background: rgba(45,106,79,0.08); color: var(--green); border: 1px solid rgba(45,106,79,0.2); }
.b-yellow { background: rgba(212,163,115,0.12); color: #b8860b; border: 1px solid rgba(212,163,115,0.3); }
.b-red { background: rgba(193,18,31,0.06); color: var(--red); border: 1px solid rgba(193,18,31,0.2); }
.b-purple { background: rgba(124,92,252,0.06); color: var(--purple); border: 1px solid rgba(124,92,252,0.2); }
.b-orange { background: rgba(231,111,81,0.06); color: var(--orange); border: 1px solid rgba(231,111,81,0.2); }
.b-cyan { background: rgba(38,70,83,0.06); color: var(--cyan); border: 1px solid rgba(38,70,83,0.2); }
.b-neutral { background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }
/* === TABLES === */
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th {
text-align: left; padding: 0.6rem 0.75rem;
border-bottom: 2px solid var(--border);
color: var(--text-dim); font-weight: 700;
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
position: sticky; top: 0; background: var(--surface); z-index: 1;
cursor: pointer; user-select: none; transition: color 0.2s;
white-space: nowrap;
}
th:hover { color: var(--text-bright); }
th .sort-arrow { display: inline-block; margin-left: 4px; font-size: 0.6rem; opacity: 0.3; transition: opacity 0.2s; }
th.sort-asc .sort-arrow, th.sort-desc .sort-arrow { opacity: 1; color: var(--accent); }
th.sort-asc .sort-arrow::after { content: '▲'; }
th.sort-desc .sort-arrow::after { content: '▼'; }
th:not(.sort-asc):not(.sort-desc) .sort-arrow::after { content: '⇅'; }
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text); }
tr.clickable { cursor: pointer; transition: background 0.15s; }
tr.clickable:hover td { background: var(--accent-glow); }
.table-wrap { max-height: 70vh; overflow-y: auto; border-radius: var(--radius-md); }
.table-wrap::-webkit-scrollbar { width: 6px; }
.table-wrap::-webkit-scrollbar-track { background: transparent; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* === SEARCH === */
.search-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
.search-row input, .search-row select {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 0.6rem 1rem;
color: var(--text-bright); font-size: 0.85rem;
font-family: var(--font-body);
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-row input { flex: 1; }
.search-row input:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-row select { min-width: 140px; }
.btn {
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-md); padding: 0.6rem 1.25rem;
font-weight: 700; font-size: 0.8rem; cursor: pointer;
font-family: var(--font-body);
transition: background 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-glow);
}
.btn:hover { background: var(--accent-dark); box-shadow: var(--shadow-hover); }
.btn-ghost {
background: transparent; border: 1px solid var(--border);
color: var(--text-dim); cursor: pointer; border-radius: var(--radius-md);
padding: 0.5rem 0.85rem; font-size: 0.75rem; font-weight: 600;
font-family: var(--font-body);
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
/* === RESULT ITEMS === */
.ri {
border-bottom: 1px solid var(--border);
padding: 1rem 0; cursor: pointer;
transition: all 0.15s;
}
.ri:last-child { border-bottom: none; }
.ri:hover { background: var(--accent-glow); margin: 0 -1.25rem; padding: 1rem 1.25rem; border-radius: var(--radius-md); }
.ri-title { font-weight: 600; font-size: 0.85rem; color: var(--text-bright); }
.ri-body { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.3rem; line-height: 1.6; }
.ri-meta { font-size: 0.7rem; color: var(--text-dim); margin-top: 0.4rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
/* === HYPE CYCLE === */
.hype-wrap {
background: #0a0a0f;
border: 1px solid rgba(255,129,0,0.15);
border-radius: 16px;
padding: 2rem 2rem 1.5rem;
overflow-x: auto;
box-shadow: 0 4px 40px rgba(0,0,0,0.3), 0 0 80px rgba(255,129,0,0.03);
position: relative;
}
.hype-wrap::before {
content: '';
position: absolute; inset: 0;
border-radius: 16px;
background: radial-gradient(ellipse 80% 50% at 20% 30%, rgba(255,129,0,0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 80% 70%, rgba(255,129,0,0.02) 0%, transparent 60%);
pointer-events: none;
}
.hype-wrap svg { display: block; width: 100%; height: auto; position: relative; z-index: 1; }
.hype-dot { cursor: pointer; transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1); }
.hype-dot:hover { filter: brightness(1.3) drop-shadow(0 0 12px currentColor); transform: scale(1.15); }
.hype-label { font-size: 11px; fill: rgba(255,255,255,0.85); pointer-events: none; font-family: 'DM Sans', sans-serif; letter-spacing: 0.02em; }
.hype-phase-label { font-size: 10px; fill: rgba(255,255,255,0.35); text-anchor: middle; font-family: 'DM Sans', sans-serif; text-transform: uppercase; letter-spacing: 0.08em; }
.hype-pulse { animation: hype-pulse-anim 2.5s ease-in-out infinite; }
@keyframes hype-pulse-anim { 0%,100% { opacity: 0.25; r: 16; } 50% { opacity: 0.08; r: 24; } }
.hype-connector { stroke: rgba(255,255,255,0.12); stroke-width: 1; stroke-dasharray: 2,3; }
/* Hype Cycle Hover Tooltip */
.hype-tooltip {
position: fixed; z-index: 1000; pointer-events: none;
background: rgba(10,11,16,0.95); border: 1px solid rgba(255,129,0,0.3);
border-radius: 8px; padding: 10px 14px; min-width: 180px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); backdrop-filter: blur(8px);
font-size: 12px; color: var(--text); opacity: 0; transition: opacity 0.15s;
}
.hype-tooltip.visible { opacity: 1; }
.hype-tooltip .tt-tech { font-weight: 700; font-size: 13px; margin-bottom: 4px; }
.hype-tooltip .tt-phase { font-size: 11px; opacity: 0.7; margin-bottom: 6px; }
.hype-tooltip .tt-row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
.hype-tooltip .tt-label { color: var(--text-dim); }
.hype-tooltip .tt-val { font-family: 'JetBrains Mono', monospace; font-weight: 600; }
/* Phase Legend */
.hype-legend {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
margin-top: 12px; padding: 8px 0;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.hype-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1.5rem; position: relative; z-index: 1;
}
.hype-title {
font-family: var(--font-heading);
font-weight: 700; font-size: 1.3rem; color: #ffffff;
letter-spacing: -0.02em;
}
.hype-title .mono { color: #FF8100; font-weight: 600; }
.hype-sub { font-size: 0.72rem; color: rgba(255,255,255,0.4); margin-top: 0.35rem; letter-spacing: 0.01em; }
.hype-legend { display: flex; gap: 1.2rem; font-size: 0.68rem; color: rgba(255,255,255,0.5); align-items: center; }
.hype-legend-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
margin-right: 0.3rem; vertical-align: middle;
box-shadow: 0 0 6px currentColor;
}
.hype-bar { height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; width: 100%; }
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
/* === TOOLTIPS === */
.tip { position: relative; cursor: help; }
.tip::after {
content: attr(data-tip);
position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
background: var(--surface-dark); color: #e0e0e0;
border: 1px solid rgba(255,255,255,0.1);
padding: 0.6rem 0.85rem; border-radius: var(--radius-md);
font-size: 0.72rem; line-height: 1.5; font-weight: 400;
white-space: normal; width: max-content; max-width: 300px;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
z-index: 500; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}
.tip::before {
content: ''; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%);
border: 6px solid transparent; border-top-color: var(--surface-dark);
opacity: 0; pointer-events: none; transition: opacity 0.2s; z-index: 501;
}
.tip:hover::after, .tip:hover::before { opacity: 1; }
th.tip::after { left: 0; transform: none; }
/* === DETAIL PANEL === */
.panel {
position: fixed; top: 0; right: 0;
width: 560px; height: 100vh;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0,0,0,0.08);
z-index: 1000; overflow-y: auto;
transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
padding: 1.5rem;
}
.panel.open { transform: translateX(0); }
.panel::-webkit-scrollbar { width: 4px; }
.panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.panel-close {
position: absolute; top: 0.75rem; right: 0.75rem;
background: #e8e8f0; border: 1.5px solid #c0c0cc;
color: #1a1a2e; 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 8px rgba(0,0,0,0.12); 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); }
/* === CHANGELOG === */
.cl-entry {
display: flex; gap: 0.6rem; align-items: baseline;
padding: 0.4rem 0; border-bottom: 1px solid var(--border);
font-size: 0.78rem;
}
.cl-entry:last-child { border-bottom: none; }
.cl-date { font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
.cl-type {
font-size: 0.62rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.07em; padding: 1px 6px; border-radius: 3px;
white-space: nowrap; flex-shrink: 0;
}
.cl-FEAT { background: rgba(255,129,0,0.12); color: var(--accent); }
.cl-FIX { background: rgba(193,18,31,0.1); color: #c1121f; }
.cl-UI { background: rgba(124,92,252,0.1); color: #7c5cfc; }
.cl-DATA { background: rgba(45,106,79,0.1); color: #2d6a4f; }
.cl-AI { background: rgba(26,26,46,0.1); color: #1a1a2e; }
.cl-INFRA { background: rgba(136,136,136,0.1);color: #666; }
.cl-msg { color: var(--text); line-height: 1.4; }
/* === PROCUREMENT TAB === */
.proc-btn {
background: var(--surface2); border: 1px solid var(--border);
padding: 5px 14px; border-radius: 6px; cursor: pointer;
font-size: 0.78rem; font-weight: 600; color: var(--text-dim);
transition: all 0.15s;
}
.proc-btn:hover { color: var(--text); border-color: var(--accent); }
.proc-btn-active { background: var(--accent); color: #fff !important; border-color: var(--accent) !important; }
.signal-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1rem;
box-shadow: var(--shadow-card); position: relative;
}
.signal-card:hover { box-shadow: var(--shadow-hover); }
.signal-buy { border-left: 3px solid #c1121f; }
.signal-wait { border-left: 3px solid var(--yellow); }
.signal-hold { border-left: 3px solid var(--green); }
.signal-monitor { border-left: 3px solid var(--purple); }
.sig-badge-buy { background:#fde8e8; color:#c1121f; }
.sig-badge-wait { background:var(--yellow-light); color:#a06000; }
.sig-badge-hold { background:var(--green-light); color:#1b4332; }
.sig-badge-monitor { background:var(--purple-light); color:#5a3fcf; }
.intel-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1rem;
box-shadow: var(--shadow-card);
}
.intel-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 0.5rem;
}
.intel-buy { background:#fde8e8; color:#c1121f; }
.intel-wait { background:var(--yellow-light); color:#a06000; }
.intel-hold { background:var(--green-light); color:#1b4332; }
.intel-monitor { background:var(--purple-light); color:#5a3fcf; }
.intel-none { background:var(--surface2); color:var(--text-dim); }
.abc-a { background:#fde8e8; color:#c1121f; font-weight:800; padding:2px 7px; border-radius:4px; }
.abc-b { background:var(--yellow-light); color:#a06000; font-weight:800; padding:2px 7px; border-radius:4px; }
.abc-c { background:var(--surface2); color:var(--text-dim); font-weight:800; padding:2px 7px; border-radius:4px; }
</style>
</head>
<body>
<div class="app">
<div id="toast" class="toast"><div class="toast-title"></div><div class="toast-body"></div></div>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="logo">
<div class="logo-mark">TIP</div>
<div class="logo-text">Transceiver Intelligence<span>Platform</span></div>
</div>
<div class="header-stats">
<span data-goto="transceivers"><span class="val" id="stat-transceivers">&mdash;</span> transceivers</span>
<span data-goto="transceivers"><span class="val" id="stat-vendors">&mdash;</span> vendors</span>
<span data-goto="switches"><span class="val" id="stat-switches">&mdash;</span> switches</span>
<span data-goto="hype"><span class="val" id="stat-standards">&mdash;</span> standards</span>
<span data-goto="news"><span class="val" id="stat-news">&mdash;</span> articles</span>
</div>
</div>
<div class="status">
<div class="status-pill" id="api-pill"><span class="dot dot-ok" id="api-status"></span>API</div>
<div class="status-pill" id="db-pill"><span class="dot dot-ok" id="db-status"></span>DB</div>
<div class="status-pill" id="qdrant-pill"><span class="dot dot-ok" id="qdrant-status"></span>Qdrant</div>
<span class="version-tag" id="version-label"></span>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="search">Search</div>
<div class="tab" data-tab="hype">Hype Cycle</div>
<div class="tab" data-tab="transceivers">Transceivers</div>
<div class="tab" data-tab="switches">Switches</div>
<div class="tab" data-tab="news">News</div>
<div class="tab" data-tab="finder">Finder</div>
<div class="tab" data-tab="blog">Blog Engine</div>
<div class="tab" data-tab="procurement">Procurement Intelligence</div>
</div>
<div class="main">
<!-- OVERVIEW -->
<div id="tab-overview" class="fade-in">
<div class="grid mb" style="grid-template-columns: repeat(5, 1fr);">
<div class="stat-card" data-goto="transceivers">
<div class="stat-icon blue">&#9881;</div>
<div class="stat-label">Transceivers</div>
<div class="stat-val" id="ov-transceivers">&mdash;</div>
</div>
<div class="stat-card" data-goto="transceivers">
<div class="stat-icon green">&#9733;</div>
<div class="stat-label">Vendors</div>
<div class="stat-val" id="ov-vendors">&mdash;</div>
</div>
<div class="stat-card" data-goto="switches">
<div class="stat-icon orange">&#9881;</div>
<div class="stat-label">Switches</div>
<div class="stat-val" id="ov-switches">&mdash;</div>
</div>
<div class="stat-card" data-goto="hype">
<div class="stat-icon purple">&#9879;</div>
<div class="stat-label">Standards</div>
<div class="stat-val" id="ov-standards">&mdash;</div>
</div>
<div class="stat-card" data-goto="news">
<div class="stat-icon cyan">&#9998;</div>
<div class="stat-label">News Articles</div>
<div class="stat-val" id="ov-news">&mdash;</div>
</div>
</div>
<div class="grid g2 mb">
<div class="card">
<div class="card-label">Vector Collections</div>
<div id="collections-list" class="mt"></div>
</div>
<div class="card">
<div class="card-label">Recent Intelligence</div>
<div id="recent-news" class="mt"></div>
</div>
</div>
<div class="card">
<div class="card-label">API Endpoints</div>
<div id="endpoints-list" class="endpoint-grid mt"></div>
</div>
<!-- CHANGELOG -->
<div class="card mt" id="changelog-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<div class="card-label" style="margin-bottom:0">Changelog</div>
<div style="display:flex;gap:0.4rem;align-items:center">
<span id="changelog-total" style="font-size:0.7rem;color:var(--text-dim);font-family:var(--mono)"></span>
<button onclick="toggleChangelog()" id="changelog-toggle-btn" style="background:var(--surface2);border:1px solid var(--border);padding:2px 10px;border-radius:5px;cursor:pointer;font-size:0.72rem;color:var(--text-dim)">Show all</button>
</div>
</div>
<div id="changelog-list"></div>
</div>
</div>
<!-- SEARCH -->
<div id="tab-search" class="hidden">
<div class="search-row">
<input type="text" id="search-input" placeholder="Search transceivers, datasheets, FAQ, troubleshooting...">
<select id="search-collection">
<option value="product_embeddings">Products</option>
<option value="faq_embeddings">FAQ</option>
<option value="troubleshooting_embeddings">Troubleshooting</option>
<option value="datasheet_chunks">Datasheets</option>
<option value="news_embeddings">News</option>
</select>
<button class="btn" id="search-btn">Search</button>
</div>
<div class="card"><div id="search-results"></div></div>
</div>
<!-- HYPE CYCLE -->
<div id="tab-hype" class="hidden">
<div class="hype-wrap">
<div class="hype-header">
<div>
<div class="hype-title">Optical Transceiver Hype Cycle <span class="mono dim" id="hype-year">2026</span></div>
<div class="hype-sub">Norton-Bass Multigenerational Diffusion Model &mdash; click any technology for details</div>
</div>
<div class="hype-legend">
<span><span class="hype-legend-dot" style="background:#FF8100"></span>Innovation</span>
<span><span class="hype-legend-dot" style="background:#FFa030"></span>Peak</span>
<span><span class="hype-legend-dot" style="background:#c1121f"></span>Trough</span>
<span><span class="hype-legend-dot" style="background:#555555"></span>Slope</span>
<span><span class="hype-legend-dot" style="background:#000000"></span>Plateau</span>
</div>
</div>
<div id="hype-svg-container"></div>
</div>
<div class="card mt">
<div class="table-wrap">
<table>
<thead><tr>
<th>Technology<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Cumulative market adoption based on Norton-Bass diffusion model. 0-100% of total addressable market.">Adoption<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Estimated year of peak hype / maximum attention.">Peak<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Years until mainstream, stable deployment.">To Plateau<span class="sort-arrow"></span></th>
</tr></thead>
<tbody id="hype-table"></tbody>
</table>
</div>
</div>
<div class="card mt" style="border-left:3px solid var(--cyan)">
<div class="panel-section" style="margin-top:0">Methodology: How the Hype Cycle is Calculated</div>
<div style="font-size:0.82rem;color:var(--text-dim);line-height:1.65">
<p style="margin:0.5rem 0">This Hype Cycle uses the <strong>Norton-Bass Multigenerational Diffusion Model</strong> to calculate adoption curves for optical transceiver technologies. The model extends the classic Bass diffusion model to handle multiple generations of technology that compete and cannibalize each other.</p>
<p style="margin:0.5rem 0"><strong>Key Parameters:</strong></p>
<ul style="margin:0.3rem 0;padding-left:1.2rem">
<li><strong>p (innovation coefficient)</strong> &mdash; Rate of adoption driven by external influence (marketing, industry events). Typically 0.01&ndash;0.05 for B2B networking equipment.</li>
<li><strong>q (imitation coefficient)</strong> &mdash; Rate of adoption driven by word-of-mouth and peer influence. Typically 0.3&ndash;0.5 for optical networking.</li>
<li><strong>m (market potential)</strong> &mdash; Total addressable market size, estimated from port shipment forecasts (LightCounting, Dell&rsquo;Oro).</li>
<li><strong>τ (introduction time)</strong> &mdash; Year when the technology became commercially available.</li>
</ul>
<p style="margin:0.5rem 0"><strong>Phase Classification:</strong> Technologies are classified into five phases based on their position on the adoption curve: <span class="b" style="background:#FF810018;color:#FF8100;border:1px solid #FF810033">Innovation Trigger</span> (0&ndash;16%), <span class="b" style="background:#FFa03018;color:#FFa030;border:1px solid #FFa03033">Peak of Inflated Expectations</span> (16&ndash;35%), <span class="b" style="background:#c1121f18;color:#c1121f;border:1px solid #c1121f33">Trough of Disillusionment</span> (35&ndash;55%), <span class="b" style="background:#55555518;color:#555;border:1px solid #55555533">Slope of Enlightenment</span> (55&ndash;80%), and <span class="b" style="background:#00000018;color:#000;border:1px solid #00000033">Plateau of Productivity</span> (80&ndash;100%).</p>
<p style="margin:0.5rem 0"><strong>Composite Score:</strong> A weighted blend of adoption velocity (40%), market momentum from pricing data (30%), and ecosystem maturity from compatibility entries (30%). Score ranges from 0&ndash;100.</p>
<p style="margin:0.5rem 0"><strong>Data Sources:</strong> Adoption timing derived from IEEE/MSA standard ratification dates, market data from aggregated pricing across 60+ vendors, and compatibility data from 29,000+ verified switch-transceiver pairs.</p>
</div>
</div>
</div>
</div>
<!-- TRANSCEIVERS -->
<div id="tab-transceivers" class="hidden">
<div class="search-row">
<input type="text" id="tx-search" placeholder="Filter: 100G LR4, QSFP28, coherent, SMF...">
<select id="tx-ff-filter">
<option value="">All Form Factors</option>
<option value="SFP">SFP</option>
<option value="SFP+">SFP+</option>
<option value="SFP28">SFP28</option>
<option value="QSFP+">QSFP+</option>
<option value="QSFP28">QSFP28</option>
<option value="QSFP-DD">QSFP-DD</option>
<option value="OSFP">OSFP</option>
<option value="CFP">CFP</option>
<option value="CFP2">CFP2</option>
</select>
<select id="tx-vendor-filter">
<option value="">All Vendors</option>
</select>
<button class="btn" id="tx-search-btn">Search</button>
<button class="btn" id="tx-export-btn" style="background:var(--green);color:#fff" title="Export CSV">Export CSV</button>
<button class="btn" id="tx-compare-btn" style="background:var(--purple);color:#fff" title="Compare selected">Compare</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th><th>Verified</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>
<!-- PROCUREMENT INTEL TAB -->
<div id="tab-procurement" class="hidden">
<!-- Sub-nav -->
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap;align-items:center">
<button onclick="showProcSection('signals')" id="proc-btn-signals" class="proc-btn proc-btn-active">Reorder Signals</button>
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
<button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button>
<button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button>
<div style="flex:1"></div>
<button onclick="loadProcurement()" style="background:var(--surface2);border:1px solid var(--border);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem;color:var(--text)">↻ Refresh</button>
</div>
<!-- Reorder Signals section -->
<div id="proc-section-signals">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap">
<button onclick="filterSignal('')" id="sig-all" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
<button onclick="filterSignal('buy_now')" style="background:rgba(193,18,31,0.1);border:1px solid rgba(193,18,31,0.3);color:#c1121f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🔴 Buy Now</button>
<button onclick="filterSignal('wait')" style="background:rgba(255,160,0,0.1);border:1px solid rgba(255,160,0,0.3);color:#c07000;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🟡 Wait</button>
<button onclick="filterSignal('hold')" style="background:rgba(45,106,79,0.1);border:1px solid rgba(45,106,79,0.3);color:#2d6a4f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🟢 Hold</button>
<button onclick="filterSignal('monitor')" style="background:rgba(124,92,252,0.1);border:1px solid rgba(124,92,252,0.3);color:#7c5cfc;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🔵 Monitor</button>
</div>
<div id="proc-signals-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))">
<div class="loading pulse">Loading reorder signals...</div>
</div>
</div>
<!-- ABC Classification section -->
<div id="proc-section-abc" style="display:none">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<button onclick="filterAbc('')" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
<button onclick="filterAbc('A')" style="background:rgba(193,18,31,0.1);border:1px solid rgba(193,18,31,0.3);color:#c1121f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">A — High Turnover</button>
<button onclick="filterAbc('B')" style="background:rgba(255,160,0,0.1);border:1px solid rgba(255,160,0,0.3);color:#c07000;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">B — Medium</button>
<button onclick="filterAbc('C')" style="background:rgba(136,136,136,0.12);border:1px solid #ddd;color:var(--text-dim);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">C — Low</button>
</div>
<div class="card" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.8rem" id="abc-table">
<thead><tr style="border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.7rem;font-weight:700;text-transform:uppercase">
<th class="tip" data-tip="ABC inventory classification: A = high turnover / high value (top 20% products, ~80% of revenue). B = medium. C = low turnover / low value." style="text-align:left;padding:8px 6px">Class</th>
<th class="tip" data-tip="Transceiver product name, part number and vendor." style="text-align:left;padding:8px 6px">Product</th>
<th class="tip" data-tip="Physical form factor: SFP, SFP+, QSFP28, QSFP-DD, OSFP, CFP, etc. Determines physical slot compatibility in switches." style="text-align:left;padding:8px 6px">Form Factor</th>
<th class="tip" data-tip="Composite demand score (0100). Combines: price observation frequency, compatibility entry count, vendor count, hype cycle phase, and recent pricing activity." style="text-align:right;padding:8px 6px">Demand Score</th>
<th class="tip" data-tip="Number of compatibility entries — how many switch models support this transceiver. Higher = broader market reach and easier to sell." style="text-align:right;padding:8px 6px">Compat.</th>
<th class="tip" data-tip="Number of vendors offering this transceiver. More vendors = stronger competition = typically lower prices and better availability." style="text-align:right;padding:8px 6px">Vendors</th>
<th class="tip" data-tip="Supply chain risk level. High = single-source or constrained supply. Medium = some alternatives exist. Low = widely available from multiple sources." style="text-align:left;padding:8px 6px">Supply Risk</th>
<th class="tip" data-tip="Procurement recommendation: 🔴 Buy Now = stock up, prices rising or supply tightening. 🟡 Wait = prices expected to drop. 🟢 Hold = stable, no action needed. 🔵 Monitor = watch for changes." style="text-align:left;padding:8px 6px">Signal</th>
</tr></thead>
<tbody id="abc-tbody"><tr><td colspan="8" style="padding:1rem;color:var(--text-dim)">Loading...</td></tr></tbody>
</table>
</div>
</div>
<!-- Market Intelligence section -->
<div id="proc-section-market" style="display:none">
<div id="proc-market-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(400px,1fr))">
<div class="loading pulse">Loading market intelligence...</div>
</div>
</div>
<!-- Lifecycle Events section -->
<div id="proc-section-lifecycle" style="display:none">
<div id="proc-lifecycle-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(400px,1fr))">
<div class="loading pulse">Loading lifecycle events...</div>
</div>
</div>
</div><!-- end tab-procurement -->
</div>
</div><!-- .app -->
<!-- DETAIL PANEL -->
<div id="detail-panel" class="panel">
<button class="panel-close" id="panel-close">&times;</button>
<div id="panel-content"></div>
</div>
<!-- COMPARE OVERLAY -->
<div class="compare-overlay" id="compare-overlay">
<div id="compare-content"></div>
</div>
<script>
var API = window.location.origin;
function esc(str) {
if (str == null) return '';
var d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
function el(id) { return document.getElementById(id); }
function api(path) {
return fetch(API + path).then(function(r) {
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('application/json') === -1) {
if (!r.ok) throw new Error('HTTP ' + r.status);
throw new Error('Server returned non-JSON response');
}
// Parse JSON even on error so callers can read error.message / error.suggestion
return r.json().then(function(body) {
if (!r.ok) {
var err = new Error(body.error || ('HTTP ' + r.status));
err.body = body;
throw err;
}
return body;
});
});
}
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));
}
// Temperature range code → human-readable display
function tempRangeDisplay(code) {
if (!code) return null;
var map = { 'COM': '0 70 °C (COM)', 'IND': '-40 85 °C (IND)', 'EXT': '-40 70 °C (EXT)' };
return map[code] || code;
}
// Format observed date as "DD.MM.YYYY"
function fmtDate(iso) {
if (!iso) return '';
var d = new Date(iso);
return d.getDate().toString().padStart(2,'0') + '.' + (d.getMonth()+1).toString().padStart(2,'0') + '.' + d.getFullYear();
}
// 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();
if (tabName === 'procurement') loadProcurement();
}
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>'
+ '<td>' + (t.fully_verified
? '<span style="background:linear-gradient(135deg,#1b4332,#2d6a4f);color:#fff;font-size:0.62rem;font-weight:700;padding:2px 6px;border-radius:4px;white-space:nowrap">★ 100%</span>'
: t.price_verified ? '<span style="color:#2d6a4f;font-size:0.68rem;font-weight:600">✓ Price</span>' : '')
+ '</td>'
+ '</tr>';
}).join(''));
el('tx-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openTxDetail(this.getAttribute('data-txid')); });
});
});
}
async function openTxDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/transceivers/' + id);
var t = data.data || data.transceiver || data;
var h = '';
// Product name header — above the image
var descName = txDescName(t);
var sku = t.part_number || t.standard_name || t.slug;
h += '<div style="margin-bottom:0.9rem">';
h += '<div style="font-size:1.1rem;font-weight:700;color:var(--text-bright);letter-spacing:-0.01em;line-height:1.2">' + esc(sku) + '</div>';
if (descName && descName !== sku) {
h += '<div style="font-size:0.82rem;color:var(--text-dim);margin-top:0.3rem;line-height:1.4">' + esc(descName) + '</div>';
}
h += '</div>';
// Image section
var hasRealImage = t.image_url || (t.vendor_name === 'FLEXOPTIX' && FF_REFERENCE_IMAGES[t.form_factor]);
h += '<div class="tx-image-box ' + (hasRealImage ? 'has-photo' : 'has-svg') + '">';
h += getTransceiverImage(t);
if (t.vendor_name && t.image_url) h += '<span class="img-badge">' + esc(t.vendor_name) + '</span>';
if (t.product_page_url) {
h += '<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" class="img-link">View on ' + esc(t.vendor_name || 'Vendor') + ' &rarr;</a>';
}
h += '</div>';
// Title below image — proper manufacturer designation only, never garbage/auto-generated names
var isGarbageName = function(s) {
if (!s) return true;
if (s.startsWith('scraped-')) return true; // auto-generated slug
if (/^[a-z0-9-]+$/.test(s)) return true; // pure slug: only lowercase+digits+dash
if (/^all optical/i.test(s)) return true; // GBICS category page garbage
if (/^compatible \d+/i.test(s)) return true; // "Compatible 800GBASE-..." category
if (/^osfp \d+g/i.test(s)) return true; // generic form-factor description
if (/^qsfp.{0,5}\d+g/i.test(s)) return true; // "QSFP 400G Gigabit Ethernet" etc.
if (s.toLowerCase().includes('gigabit ethernet') && s.length > 25) return true;
if (s.toLowerCase().startsWith('sfp') && /^sfp\s*\d+g\s*\w+/i.test(s)) return true;
return false;
};
var titleName = (!isGarbageName(t.standard_name) ? t.standard_name : null)
|| (!isGarbageName(t.part_number) ? t.part_number : null)
|| (t.description && !isGarbageName(t.description) ? t.description : null)
|| txDescName(t)
|| t.slug;
h += '<div class="panel-title">' + esc(titleName) + '</div>';
// Show data quality warning when product is not details-verified
if (!t.details_verified) {
h += '<div style="font-size:0.72rem;color:#c1440e;background:rgba(193,68,14,0.07);border:1px solid rgba(193,68,14,0.2);border-radius:5px;padding:0.35rem 0.6rem;margin:0.4rem 0">⚠ Produktdaten nicht aus offizieller Quelle verifiziert</div>';
}
h += '<div class="panel-sub">';
if (t.vendor_name) h += '<span class="b b-blue" title="Hersteller / Marke dieses Produkts">' + esc(t.vendor_name) + '</span> ';
if (t.category) h += '<span class="b b-neutral" title="Einsatzbereich: ' + esc(t.category) + '">' + esc(t.category) + '</span> ';
if (t.market_status) {
var msTooltip = t.market_status === 'Emerging' ? 'Hype Cycle: Technologie gewinnt erste Akzeptanz wachsendes Ökosystem, noch Premium-Preise'
: t.market_status === 'Mainstream' ? 'Hype Cycle: Technologie ist etabliert breite Verfügbarkeit, kompetitive Preise'
: t.market_status === 'Legacy' ? 'Hype Cycle: Technologie veraltet wird durch neuere Generationen abgelöst'
: 'Marktphase: ' + t.market_status;
h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '" title="' + msTooltip + '">' + 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>';
// Verification summary bar — explicit === true to handle any type coercion
var pVer = t.price_verified === true;
var iVer = t.image_verified === true;
var dVer = t.details_verified === true;
var fVer = t.fully_verified === true;
var verItems = [];
if (pVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Price</span>');
if (iVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Image</span>');
if (dVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Details</span>');
if (fVer) {
// Inside the green bar: all text must be white/light — not #2d6a4f (same as bg)
var fvItems = [];
if (pVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Price</span>');
if (iVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Image</span>');
if (dVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Details</span>');
h += '<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;margin:0.8rem 0;padding:0.55rem 0.85rem;background:linear-gradient(135deg,#1b4332,#2d6a4f);border-radius:8px">'
+ '<span style="color:#fff;font-size:0.82rem;font-weight:700;letter-spacing:0.04em">★ 100% VERIFIED</span>'
+ '<span style="color:rgba(255,255,255,0.4);font-size:0.7rem"></span>'
+ fvItems.join('<span style="color:rgba(255,255,255,0.3);font-size:0.7rem;margin:0 0.1rem">·</span>')
+ (t.fully_verified_at ? '<span style="color:rgba(255,255,255,0.55);font-size:0.68rem;margin-left:auto">seit ' + fmtDate(t.fully_verified_at) + '</span>' : '')
+ '</div>';
} else if (verItems.length > 0) {
// Partial verification on white background: dark green is fine here
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin:0.6rem 0;padding:0.4rem 0.6rem;background:rgba(45,106,79,0.08);border:1px solid rgba(45,106,79,0.2);border-radius:6px">'
+ verItems.join('<span style="color:#aaa;font-size:0.7rem">·</span>')
+ '</div>';
}
// No verification → no badge. Never show unverified data as verified.
// 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', tempRangeDisplay(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 != null ? t.tx_power_min_dbm + ' dBm' : null],
['TX Power (Max)', t.tx_power_max_dbm != null ? t.tx_power_max_dbm + ' dBm' : null],
['RX Sensitivity', t.rx_sensitivity_dbm != null ? 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
// Rule: Only prices with real URL from last 30 days. No estimated/fallback prices.
var allPrices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
var directPrices = allPrices.filter(function(p) { return p.is_same_product !== false; });
var comparPrices = allPrices.filter(function(p) { return p.is_same_product === false; });
if (allPrices.length > 0) {
h += '<div class="panel-section">Current Prices</div>';
h += '<div class="spec-table">';
function renderPriceRow(p) {
var verBadge = p.is_verified === true
? '<span style="color:#2d6a4f;font-size:0.68rem;font-weight:600;margin-left:0.5rem" title="Scraped from official vendor page, max. 7 days old">✓ Verified</span>'
: '';
var priceStr = '<strong style="font-size:0.9rem">' + p.currency + '\u00a0' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + '</strong>';
var dateStr = '<span style="color:#aaa;font-size:0.67rem;margin-left:0.5rem">Stand: ' + fmtDate(p.observed_at) + '</span>';
var urlLink = '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.68rem;text-decoration:none;margin-left:0.5rem">↗</a>';
// Same layout for all rows — comparable_part as tooltip only, never as block
var label = esc(p.vendor_name)
+ (p.is_same_product === false && p.comparable_part
? ' <span style="color:#aaa;font-size:0.65rem;font-weight:400" title="' + esc(p.comparable_part) + '">ⓘ</span>'
: '');
return '<div class="spec-row"><span class="spec-label">' + label + '</span>'
+ '<span class="spec-val" style="display:flex;align-items:center;flex-wrap:wrap">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
}
directPrices.forEach(function(p) { h += renderPriceRow(p); });
if (comparPrices.length > 0) {
h += '<div style="font-size:0.7rem;color:#888;margin:0.5rem 0 0.25rem;padding-top:0.4rem;border-top:1px solid var(--border)">Vergleichbare Produkte anderer Hersteller (gleiche Spezifikation)</div>';
comparPrices.forEach(function(p) { h += renderPriceRow(p); });
}
h += '</div>';
}
// No price_observations → show nothing. Never display estimated prices.
// Notes (scraped extra specs)
if (t.notes) {
h += '<div class="panel-section">Additional Specifications</div>';
var noteItems = t.notes.split('; ');
h += '<div class="spec-table">';
for (var n = 0; n < noteItems.length; n++) {
var parts = noteItems[n].split(': ');
if (parts.length >= 2) {
h += '<div class="spec-row"><span class="spec-label">' + esc(parts[0]) + '</span><span class="spec-val">' + esc(parts.slice(1).join(': ')) + '</span></div>';
}
}
h += '</div>';
}
// Use Cases
var useCases = classifyUseCase(t);
if (useCases.length > 0) {
h += '<div class="panel-section">Typical Use Cases</div>';
for (var u = 0; u < useCases.length; u++) {
h += '<div class="use-case-card">'
+ '<span class="use-case-icon">' + useCases[u].icon + '</span>'
+ '<div><div class="use-case-label">' + esc(useCases[u].label) + '</div>'
+ '<div class="use-case-desc">' + esc(useCases[u].desc) + '</div></div>'
+ '</div>';
}
}
// Documents & Links
var links = [];
if (t.product_page_url) links.push('<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Product Page</a>');
if (t.datasheet_url) links.push('<a href="' + esc(t.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Datasheet (PDF)</a>');
if (t.image_url) links.push('<a href="' + esc(t.image_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Full Image</a>');
if (links.length > 0) {
h += '<div class="panel-section">Documents &amp; Links</div>';
h += '<div style="display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0">' + links.join('') + '</div>';
}
buildDOM(el('panel-content'), h);
// Load compatible switches async
api('/api/transceivers/' + id + '/compatibility').then(function(cdata) {
var swList = cdata.data || [];
if (swList.length === 0) return;
var groups = {};
swList.forEach(function(sw) {
var key = sw.vendor_name || 'Other';
if (!groups[key]) groups[key] = [];
groups[key].push(sw);
});
var ch = '<div class="panel-section">Compatible Switches <span class="b b-green" style="margin-left:0.5rem">' + swList.length + '</span></div>';
ch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Switches verified to work with this transceiver</div>';
Object.keys(groups).sort().forEach(function(vendor) {
var items = groups[vendor];
ch += '<div style="margin:0.6rem 0 0.3rem;font-weight:600;font-size:0.8rem;color:var(--accent)">' + esc(vendor) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem">';
items.slice(0, 12).forEach(function(sw) {
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.7rem" onclick="openSwitchDetail(\'' + esc(sw.id) + '\')">' + esc(sw.model) + '</span>';
});
if (items.length > 12) ch += '<span class="dim" style="font-size:0.7rem">+' + (items.length - 12) + ' more</span>';
ch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
el('tx-search-btn').addEventListener('click', searchTransceivers);
el('tx-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchTransceivers(); });
el('tx-ff-filter').addEventListener('change', searchTransceivers);
el('tx-vendor-filter').addEventListener('change', searchTransceivers);
// Populate vendor dropdown
api('/api/vendors').then(function(data) {
var vendors = (data.data || []).filter(function(v) { return parseInt(v.transceiver_count) > 0; });
vendors.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
var sel = el('tx-vendor-filter');
vendors.forEach(function(v) {
var opt = document.createElement('option');
opt.value = v.name;
opt.textContent = v.name + ' (' + v.transceiver_count + ')';
sel.appendChild(opt);
});
});
// CSV Export
el('tx-export-btn').addEventListener('click', function() {
if (!lastTxData.length) return;
var cols = ['standard_name','vendor_name','form_factor','speed','speed_gbps','reach_label','reach_meters','fiber_type','connector','wdm_type','category','market_status','price_tier','msrp_usd','street_price_usd'];
var csv = cols.join(',') + '\n';
lastTxData.forEach(function(t) {
csv += cols.map(function(c) {
var v = t[c] != null ? String(t[c]).replace(/"/g, '""') : '';
return '"' + v + '"';
}).join(',') + '\n';
});
var blob = new Blob([csv], { type: 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'transceivers-' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
});
// Compare
el('tx-compare-btn').addEventListener('click', openCompare);
function openCompare() {
var checked = document.querySelectorAll('.compare-cb:checked');
if (checked.length < 2) { alert('Select at least 2 transceivers to compare.'); return; }
if (checked.length > 6) { alert('Select at most 6 transceivers to compare.'); return; }
var ids = [];
checked.forEach(function(cb) { ids.push(cb.getAttribute('data-id')); });
var items = lastTxData.filter(function(t) { return ids.indexOf(t.id) !== -1; });
var overlay = el('compare-overlay');
overlay.classList.add('visible');
var fields = [
['Vendor', 'vendor_name'], ['Form Factor', 'form_factor'], ['Speed', 'speed'],
['Speed (Gbps)', 'speed_gbps'], ['Reach', 'reach_label'], ['Reach (m)', 'reach_meters'],
['Fiber', 'fiber_type'], ['Connector', 'connector'], ['WDM', 'wdm_type'],
['Wavelengths', 'wavelengths'], ['Power (W)', 'power_consumption_w'],
['Temp Range', 'temp_range'], ['Category', 'category'], ['Market Status', 'market_status'],
['Price Tier', 'price_tier'], ['MSRP ($)', 'msrp_usd'], ['Street Price ($)', 'street_price_usd'],
];
var h = '<div class="compare-panel">';
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">';
h += '<h3 style="margin:0;font-size:1.1rem">Compare Transceivers (' + items.length + ')</h3>';
h += '<button id="compare-close-btn" class="btn" style="background:var(--red);color:#fff;padding:0.3rem 0.8rem">Close</button>';
h += '</div>';
h += '<div class="compare-table"><table><thead><tr><th>Spec</th>';
items.forEach(function(t) { h += '<th>' + esc(t.standard_name || t.slug) + '</th>'; });
h += '</tr></thead><tbody>';
fields.forEach(function(f) {
var vals = items.map(function(t) { return t[f[1]] != null ? String(t[f[1]]) : '—'; });
var allSame = vals.every(function(v) { return v === vals[0]; });
// Find best price (lowest)
var isBest = [];
if (f[1] === 'msrp_usd' || f[1] === 'street_price_usd') {
var nums = vals.map(function(v) { return parseFloat(v) || Infinity; });
var mn = Math.min.apply(null, nums);
isBest = nums.map(function(n) { return n === mn && n !== Infinity; });
}
h += '<tr><td style="font-weight:600">' + esc(f[0]) + '</td>';
vals.forEach(function(v, i) {
var cls = allSame ? '' : ' class="compare-diff"';
if (isBest.length && isBest[i]) cls = ' class="compare-best"';
h += '<td' + cls + '>' + esc(v) + '</td>';
});
h += '</tr>';
});
h += '</tbody></table></div></div>';
el('compare-content').innerHTML = h;
el('compare-close-btn').addEventListener('click', function() {
overlay.classList.remove('visible');
});
}
// Vendor URL builder
function buildVendorUrl(vendorName, model) {
var v = (vendorName || '').toLowerCase();
if (v.includes('cisco')) {
if (model.startsWith('N9K') || model.startsWith('N3K') || model.startsWith('N5K') || model.startsWith('N7K'))
return 'https://www.cisco.com/c/en/us/products/switches/nexus-' + model.replace(/^N(\d)K.*/, '$1000') + '-series-switches/index.html';
if (model.startsWith('C93') || model.startsWith('C92') || model.startsWith('C95'))
return 'https://www.cisco.com/c/en/us/products/switches/catalyst-' + model.replace(/^C/, '') + '/index.html';
return 'https://www.cisco.com/c/en/us/products/switches/index.html';
}
if (v.includes('arista')) {
var series = model.replace(/^DCS-/, '').replace(/-.*$/, '');
return 'https://www.arista.com/en/products/' + series.toLowerCase() + '-series';
}
if (v.includes('juniper')) {
if (model.startsWith('QFX')) return 'https://www.juniper.net/us/en/products/switches/qfx-series.html';
if (model.startsWith('EX')) return 'https://www.juniper.net/us/en/products/switches/ex-series.html';
return 'https://www.juniper.net/us/en/products/switches.html';
}
if (v.includes('mikrotik')) return 'https://mikrotik.com/product/' + model.replace(/[-\s]+/g, '_');
if (v.includes('fortinet')) return 'https://www.fortinet.com/products/switches';
if (v.includes('ubiquiti') || v.includes('ui.com')) return 'https://store.ui.com/us/en/collections/switching';
if (v.includes('netgear')) return 'https://www.netgear.com/business/wired/switches/' + model.toLowerCase() + '/';
if (v.includes('tp-link')) return 'https://www.tp-link.com/us/business-networking/managed-switch/' + model.toLowerCase() + '/';
if (v.includes('zyxel')) return 'https://www.zyxel.com/products/' + model + '/';
if (v.includes('dell')) return 'https://www.dell.com/en-us/shop/networking/cp/networking-switches';
if (v.includes('hpe') || v.includes('aruba')) return 'https://www.arubanetworks.com/products/switches/';
if (v.includes('mellanox') || v.includes('nvidia')) return 'https://www.nvidia.com/en-us/networking/ethernet-switching/';
if (v.includes('edgecore')) return 'https://www.edge-core.com/product_category-switches.html';
if (v.includes('celestica')) return 'https://www.celestica.com/enterprise-solutions/networking';
if (v.includes('accton')) return 'https://www.edge-core.com/';
return null;
}
// SWITCHES
function searchSwitches() {
var q = el('sw-search').value;
var cat = el('sw-cat-filter').value;
var params = [];
if (q) params.push('q=' + encodeURIComponent(q));
if (cat) params.push('category=' + encodeURIComponent(cat));
params.push('limit=100');
api('/api/switches?' + params.join('&')).then(function(data) {
var items = data.data || data.switches || [];
buildDOM(el('sw-table'), items.map(function(s) {
var catColors = { DataCenter: 'b-blue', Campus: 'b-green', SP: 'b-purple', Core: 'b-orange', Edge: 'b-cyan', Industrial: 'b-yellow' };
var statusColors = { Active: 'b-green', 'EoS_Announced': 'b-yellow', EoL: 'b-red', Legacy: 'b-neutral' };
var maxSpd = s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : s.max_speed_gbps + 'G';
var cap = s.switching_capacity_tbps ? s.switching_capacity_tbps + ' Tbps' : '—';
return '<tr class="clickable" data-swid="' + esc(s.id) + '">'
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(s.model) + '</td>'
+ '<td>' + esc(s.vendor_name || '') + '</td>'
+ '<td class="mono dim">' + esc(s.series || '') + '</td>'
+ '<td><span class="b ' + (catColors[s.category] || 'b-neutral') + '">' + esc(s.category || '') + '</span></td>'
+ '<td class="mono">' + esc(s.total_ports || '—') + '</td>'
+ '<td class="mono">' + esc(maxSpd) + '</td>'
+ '<td class="mono">' + esc(cap) + '</td>'
+ '<td class="dim">' + esc(s.asic_vendor ? s.asic_vendor + (s.asic_model ? ' ' + s.asic_model : '') : '—') + '</td>'
+ '<td><span class="b ' + (statusColors[s.lifecycle_status] || 'b-neutral') + '">' + esc(s.lifecycle_status || 'Active') + '</span></td>'
+ '</tr>';
}).join('') || '<tr><td colspan="9" class="loading">No switches found</td></tr>');
el('sw-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openSwitchDetail(this.getAttribute('data-swid')); });
});
}).catch(function(err) {
buildDOM(el('sw-table'), '<tr><td colspan="9" class="loading">Error loading switches</td></tr>');
});
}
async function openSwitchDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/switches/' + id);
var s = data.data || data;
// Image — real photo or placeholder
var h = '';
if (s.image_url) {
h += '<div class="tx-image-box has-photo">';
h += '<img src="' + esc(s.image_url) + '" alt="' + esc(s.model) + '" style="max-width:100%;border-radius:8px" onerror="this.onerror=null;this.outerHTML=\'<span style=font-size:1.5rem;color:var(--text-dim)>&#9881; ' + esc(s.model) + '</span>\'">';
if (s.vendor_name) h += '<span class="img-badge">' + esc(s.vendor_name) + '</span>';
} else {
h += '<div class="tx-image-box has-svg">';
h += '<span style="font-size:1.5rem;color:var(--text-dim)">&#9881; ' + esc(s.model) + '</span>';
}
if (s.product_page_url) {
h += '<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" class="img-link">View on ' + esc(s.vendor_name || 'Vendor') + ' &rarr;</a>';
}
h += '</div>';
h += '<div class="panel-title">' + esc(s.model) + '</div>';
h += '<div class="panel-sub">' + esc(s.vendor_name || '') + (s.series ? ' &mdash; ' + esc(s.series) : '') + '</div>';
// Data quality indicators for switch
var swQual = [];
if (s.image_url && !s.image_url.includes('placeholder')) swQual.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Image</span>');
if (s.product_page_url) swQual.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Product Page</span>');
else swQual.push('<span style="color:#aaa;font-size:0.75rem">⚠ Estimated URL</span>');
if (s.datasheet_r2_key) swQual.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Datasheet</span>');
if (swQual.length > 0) {
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin:0.6rem 0;padding:0.4rem 0.6rem;background:rgba(45,106,79,0.07);border:1px solid rgba(45,106,79,0.18);border-radius:6px">'
+ swQual.join('<span style="color:#ccc;font-size:0.7rem">·</span>')
+ '</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 = [];
// Use JSONB features array from DB if populated, fall back to boolean flags
if (s.features && Array.isArray(s.features) && s.features.length > 0) {
features = s.features;
} else {
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>';
// Description
if (s.description) {
h += '<div class="panel-section">Description</div>';
h += '<div style="font-size:0.78rem;color:var(--text-dim);line-height:1.6;padding:0.25rem 0">' + esc(s.description) + '</div>';
}
// eBay Refurbished Price
if (s.ebay_refurb_price_usd) {
h += '<div class="panel-section">Market Pricing</div>';
h += '<div class="panel-row"><span class="panel-row-label">Refurbished (eBay)</span><span class="panel-row-val" style="color:var(--yellow)">€' + parseFloat(s.ebay_refurb_price_usd).toFixed(0) + ' <span style="font-size:0.7rem;color:var(--text-dim)">(incl. warranty, market)</span></span></div>';
if (s.msrp_usd) h += '<div class="panel-row"><span class="panel-row-label">List Price (MSRP)</span><span class="panel-row-val">$' + parseFloat(s.msrp_usd).toFixed(0) + '</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>';
});
}
// Known Issues placeholder (loaded async)
h += '<div class="panel-section" id="sw-issues-hdr-' + id + '" style="display:none">Known Issues <span id="sw-issues-cnt-' + id + '" style="background:#ff4d4d18;color:#ff4d4d;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-issues-body-' + id + '"></div>';
// Documents placeholder (loaded async)
h += '<div class="panel-section" id="sw-docs-hdr-' + id + '" style="display:none">Datasheets &amp; Manuals <span id="sw-docs-cnt-' + id + '" style="background:#2563eb18;color:#4287f5;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-docs-body-' + id + '"></div>';
var links = [];
if (s.product_page_url) links.push('<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Product Page</a>');
if (s.datasheet_url) links.push('<a href="' + esc(s.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Datasheet</a>');
if (s.catalog_url) links.push('<a href="' + esc(s.catalog_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Catalog</a>');
if (s.image_url) links.push('<a href="' + esc(s.image_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Full Image</a>');
if (!s.product_page_url && s.vendor_name && s.model) {
var vendorUrl = buildVendorUrl(s.vendor_name, s.model);
if (vendorUrl) links.push('<a href="' + esc(vendorUrl) + '" target="_blank" rel="noopener" style="color:var(--yellow);text-decoration:none;font-weight:600;font-size:0.8rem">Vendor Page (estimated)</a>');
}
if (links.length > 0) {
h += '<div class="panel-section">Documents &amp; Links</div>';
h += '<div style="display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0">' + links.join('') + '</div>';
}
if (s.is_whitebox) {
h += '<div class="panel-section">Open Networking</div>';
var nos = [];
if (s.sonic_compatible) nos.push('SONiC');
if (s.onl_compatible) nos.push('ONL');
if (s.dent_compatible) nos.push('DENT');
if (s.cumulus_compatible) nos.push('Cumulus');
if (s.fboss_compatible) nos.push('FBOSS');
if (s.onie_support) nos.push('ONIE');
if (s.supported_nos && s.supported_nos.length) nos = nos.concat(s.supported_nos);
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + nos.map(function(n) { return '<span class="b b-green">' + esc(n) + '</span>'; }).join('') + '</div>';
if (s.sonic_hwsku) h += '<div class="panel-row"><span class="panel-row-label">SONiC HWSKU</span><span class="panel-row-val mono">' + esc(s.sonic_hwsku) + '</span></div>';
if (s.cpu) h += '<div class="panel-row"><span class="panel-row-label">CPU</span><span class="panel-row-val">' + esc(s.cpu) + (s.cpu_cores ? ' (' + s.cpu_cores + ' cores)' : '') + '</span></div>';
if (s.ram_gb) h += '<div class="panel-row"><span class="panel-row-label">RAM</span><span class="panel-row-val">' + esc(s.ram_gb) + ' GB</span></div>';
}
buildDOM(el('panel-content'), h);
// Async: load known issues
api('/api/switches/' + id + '/issues').then(function(idata) {
var issues = idata.data || [];
if (issues.length === 0) return;
var hdr = document.getElementById('sw-issues-hdr-' + id);
var cnt = document.getElementById('sw-issues-cnt-' + id);
var body = document.getElementById('sw-issues-body-' + id);
if (!hdr || !body) return;
hdr.style.display = '';
if (cnt) cnt.textContent = issues.length;
var ih = '';
var severityColors = { critical: '#ff4d4d', warning: '#f59e0b', info: '#6b7280' };
var severityIcons = { critical: '🔴', warning: '⚠️', info: '' };
issues.forEach(function(issue) {
var col = severityColors[issue.severity] || '#6b7280';
var icon = severityIcons[issue.severity] || '';
ih += '<div style="border-left:3px solid ' + col + ';padding:0.5rem 0.75rem;margin:0.4rem 0;background:' + col + '10;border-radius:0 4px 4px 0">';
ih += '<div style="font-size:0.78rem;font-weight:600;color:' + col + '">' + icon + ' ' + esc(issue.title) + '</div>';
if (issue.summary) ih += '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.25rem;line-height:1.5">' + esc(issue.summary) + '</div>';
var meta = [];
if (issue.source_name) meta.push('<a href="' + esc(issue.source_url || '#') + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.68rem;text-decoration:none">' + esc(issue.source_name) + ' ↗</a>');
if (issue.affected_firmware) meta.push('<span style="font-size:0.68rem;color:var(--text-dim)">Affects: ' + esc(issue.affected_firmware) + '</span>');
if (issue.fix_firmware) meta.push('<span style="font-size:0.68rem;color:#22c55e">Fixed in: ' + esc(issue.fix_firmware) + '</span>');
if (issue.is_resolved) meta.push('<span style="font-size:0.68rem;color:#22c55e">✓ Resolved</span>');
if (issue.issue_tags && issue.issue_tags.length) {
issue.issue_tags.forEach(function(tag) { meta.push('<span style="background:#ffffff10;color:var(--text-dim);font-size:0.65rem;padding:1px 5px;border-radius:8px">' + esc(tag) + '</span>'); });
}
if (meta.length) ih += '<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.3rem;align-items:center">' + meta.join('') + '</div>';
ih += '</div>';
});
buildDOM(body, ih);
}).catch(function() {});
// Async: load datasheets & manuals
api('/api/switches/' + id + '/documents').then(function(ddata) {
var docs = ddata.data || [];
if (docs.length === 0) return;
var hdr = document.getElementById('sw-docs-hdr-' + id);
var cnt = document.getElementById('sw-docs-cnt-' + id);
var body = document.getElementById('sw-docs-body-' + id);
if (!hdr || !body) return;
hdr.style.display = '';
if (cnt) cnt.textContent = docs.length;
var docTypeLabels = { datasheet: '📄 Datasheet', user_guide: '📖 Manual', release_notes: '📋 Release Notes', app_note: '📝 App Note', product_page: '🌐 Product Page', config_guide: '⚙️ Config Guide' };
var docTypeColors = { datasheet: '#ff6600', user_guide: '#4287f5', release_notes: '#22c55e', app_note: '#f59e0b', product_page: '#8b5cf6', config_guide: '#06b6d4' };
var dh = '<div style="display:flex;flex-direction:column;gap:0.35rem;padding:0.25rem 0">';
docs.forEach(function(doc) {
var url = doc.download_url || doc.source_url;
var label = docTypeLabels[doc.doc_type] || doc.doc_type;
var col = docTypeColors[doc.doc_type] || 'var(--accent)';
var officialBadge = doc.is_official ? '<span style="background:#22c55e18;color:#22c55e;font-size:0.65rem;padding:1px 5px;border-radius:8px;margin-left:0.4rem">Official</span>' : '';
var langBadge = doc.language && doc.language !== 'en' ? '<span style="background:#ffffff10;color:var(--text-dim);font-size:0.65rem;padding:1px 5px;border-radius:8px;margin-left:0.3rem">' + esc(doc.language.toUpperCase()) + '</span>' : '';
dh += '<div style="display:flex;align-items:center;gap:0.5rem">';
if (url) {
dh += '<a href="' + esc(url) + '" target="_blank" rel="noopener" style="color:' + col + ';font-size:0.78rem;font-weight:600;text-decoration:none">' + label + '</a>';
} else {
dh += '<span style="color:' + col + ';font-size:0.78rem;font-weight:600">' + label + '</span>';
}
dh += '<span style="font-size:0.72rem;color:var(--text-dim)">' + esc(doc.title) + '</span>';
dh += officialBadge + langBadge;
dh += '</div>';
});
dh += '</div>';
buildDOM(body, dh);
}).catch(function() {});
api('/api/switches/' + id + '/compatibility').then(function(cdata) {
var txList = cdata.data || cdata.transceivers || [];
if (txList.length === 0) return;
// Split: Flexoptix vs others
var foList = txList.filter(function(t) { return (t.vendor_name || '').toLowerCase() === 'flexoptix'; });
var otherList = txList.filter(function(t) { return (t.vendor_name || '').toLowerCase() !== 'flexoptix'; });
var ch = '';
// ── FLEXOPTIX RECOMMENDED ──────────────────────────────────────────────
if (foList.length > 0) {
ch += '<div class="panel-section" style="color:#ff6600;margin-top:1rem">Flexoptix Recommended <span style="background:#ff660018;color:#ff6600;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem">' + foList.length + '</span></div>';
ch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Directly available from Flexoptix — FlexBox coding supported</div>';
// Group Flexoptix by speed class
var foGroups = {};
foList.forEach(function(t) {
var key = (t.speed || '?') + ' ' + (t.form_factor || '?');
if (!foGroups[key]) foGroups[key] = [];
foGroups[key].push(t);
});
Object.keys(foGroups).sort().forEach(function(key) {
var items = foGroups[key];
ch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.78rem;color:var(--text-bright)">' + esc(key) + ' <span class="dim" style="font-weight:400;font-size:0.72rem">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-direction:column;gap:0.3rem">';
items.slice(0, 8).forEach(function(t) {
var priceStr = t.latest_price ? ' — ' + (t.latest_currency || 'EUR') + ' ' + parseFloat(t.latest_price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) : '';
var verBadge = (t.price_verified === true)
? '<span style="color:#2d6a4f;font-size:0.64rem;font-weight:600;margin-left:0.3rem">✓ Verified</span>' : '';
var fullyBadge = (t.fully_verified === true)
? '<span style="background:linear-gradient(135deg,#1b4332,#2d6a4f);color:#fff;font-size:0.6rem;font-weight:700;padding:1px 5px;border-radius:4px;margin-left:0.3rem">★ 100%</span>' : '';
var foUrl = t.product_page_url
? '<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.65rem;margin-left:0.4rem;text-decoration:none">Shop ↗</a>' : '';
ch += '<div style="display:flex;align-items:center;padding:0.3rem 0.5rem;background:rgba(255,102,0,0.05);border:1px solid rgba(255,102,0,0.15);border-radius:6px;cursor:pointer;gap:0.3rem" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<span style="font-weight:600;font-size:0.78rem;color:var(--text-bright)">' + esc(t.part_number || t.standard_name || t.slug) + '</span>'
+ '<span style="color:var(--text-dim);font-size:0.7rem">' + esc(t.reach_label || '') + priceStr + '</span>'
+ fullyBadge + verBadge + foUrl
+ '</div>';
});
if (items.length > 8) ch += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0">+' + (items.length - 8) + ' more Flexoptix options</div>';
ch += '</div>';
});
}
// ── ALL COMPATIBLE (other vendors) ────────────────────────────────────
ch += '<div class="panel-section">Compatible Transceivers <span class="b b-green" style="margin-left:0.5rem">' + txList.length + '</span></div>';
var groups = {};
otherList.forEach(function(t) {
var key = (t.form_factor || '?') + ' ' + (t.speed || '?');
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
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) {
var fullyBadge = (t.fully_verified === true) ? '★ ' : '';
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.7rem" onclick="openTxDetail(\'' + esc(t.id) + '\')" title="' + esc(t.vendor_name || '') + (t.reach_label ? ' · ' + t.reach_label : '') + '">'
+ fullyBadge + esc(t.standard_name || t.slug || t.part_number) + '</span>';
});
if (items.length > 12) ch += '<span class="dim" style="font-size:0.7rem">+' + (items.length - 12) + ' more</span>';
ch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
el('sw-search-btn').addEventListener('click', searchSwitches);
el('sw-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchSwitches(); });
el('sw-cat-filter').addEventListener('change', searchSwitches);
// NEWS
async function loadNews() {
var data = await api('/api/search?q=optical+transceiver+data+center+networking&collection=news_embeddings&limit=25');
buildDOM(el('news-list'), (data.results || []).map(function(n) {
var urlSafe = (n.url && /^https?:\/\//.test(n.url)) ? n.url : '#';
return '<div class="ri">'
+ '<div class="ri-title">' + esc(n.title) + '</div>'
+ '<div class="ri-body">' + esc(n.summary) + '</div>'
+ '<div class="ri-meta">'
+ '<span class="b b-blue">' + esc(n.source) + '</span>'
+ (n.published_at ? '<span>' + new Date(n.published_at).toLocaleDateString() + '</span>' : '')
+ (urlSafe !== '#' ? '<a href="' + esc(urlSafe) + '" target="_blank" rel="noopener noreferrer" style="color:var(--accent);text-decoration:none;font-size:0.72rem;font-weight:600">Read &rarr;</a>' : '')
+ '</div></div>';
}).join('') || '<div class="loading">No news yet</div>');
}
// Markdown to HTML
function mdToHtml(md) {
if (!md) return '';
return md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h4 style="color:var(--text-bright);margin:1.2rem 0 0.4rem;font-size:0.95rem">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 style="color:var(--accent);margin:1.5rem 0 0.5rem;font-size:1.05rem;font-family:var(--font-heading);border-bottom:1px solid var(--border);padding-bottom:0.3rem">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 style="color:var(--text-bright);margin:0 0 0.8rem;font-size:1.2rem;font-family:var(--font-heading)">$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--text-bright)">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code style="background:var(--surface3);padding:0.15rem 0.4rem;border-radius:4px;font-family:var(--mono);font-size:0.8rem;color:var(--accent)">$1</code>')
.replace(/^```[\s\S]*?\n([\s\S]*?)```/gm, function(m, code) {
return '<pre style="background:var(--surface-dark);color:#d4d4d4;padding:0.8rem;border-radius:var(--radius-md);overflow-x:auto;font-size:0.8rem;line-height:1.5;border:1px solid var(--border);margin:0.5rem 0"><code style="font-family:var(--mono)">' + code.trim() + '</code></pre>';
})
.replace(/^- (.+)$/gm, '<div style="padding-left:1rem;position:relative;margin:0.15rem 0"><span style="position:absolute;left:0;color:var(--accent)">•</span> $1</div>')
.replace(/^\d+\. (.+)$/gm, function(m, text) {
return '<div style="padding-left:1.2rem;margin:0.15rem 0">' + text + '</div>';
})
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>');
}
// Copy to clipboard
function copyBlogContent(id) {
api('/api/blog/' + id).then(function(data) {
var content = data.draft.draft_content || '';
navigator.clipboard.writeText(content).then(function() {
var btn = document.getElementById('copy-btn-' + id);
if (btn) {
btn.classList.add('copied');
btn.innerHTML = '&#10003; Copied';
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = '&#128203; Copy';
}, 2000);
}
showToast('Copied', 'Article text copied to clipboard');
}).catch(function() {
showToast('Error', 'Failed to copy to clipboard', true);
});
});
}
// BLOG
function generateBlog(topic, speed) {
el('blog-list').innerHTML = '<div class="loading pulse">Generating article...</div>';
var body = { topic: topic };
if (speed) body.speed = speed;
fetch(API + '/api/blog/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
showToast('⚙️ Generating…', data.draft.title + ' — pipeline running (~10 min)');
loadBlogDrafts();
// Always poll progress — pipeline runs async
pollBlogLlm(data.draft.id, 0);
} else showToast('Failed', data.error || 'Unknown error', true);
}).catch(function(err) { showToast('Network error', err.message, true); });
}
function pollBlogLlm(id, attempt) {
if (attempt > 60) return; // max 10 min (60 × 10s)
setTimeout(function() {
api('/api/blog/' + id + '/progress').then(function(p) {
if (!p.running) {
// Pipeline done — update badge to "ready" and reload list
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
if (badge) {
badge.className = 'b b-green blog-status-badge';
badge.textContent = 'ready';
}
api('/api/blog/' + id).then(function(data) {
if (data.draft) {
showToast('✅ Blog ready', data.draft.title + ' — ' + data.draft.word_count + ' words');
}
}).catch(function() {});
loadBlogDrafts();
} else {
// Still running — update badge with step info
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
if (badge) {
badge.className = 'b b-yellow blog-status-badge';
badge.textContent = 'step ' + p.step + '/10';
}
pollBlogLlm(id, attempt + 1);
}
}).catch(function() {
pollBlogLlm(id, attempt + 1);
});
}, 8000);
}
// Hot topics loaded dynamically via hot-topics.js
// ══════════════════════════════════════════════════════
// FINDER — Switch → Transceiver
// ══════════════════════════════════════════════════════
function finderQuick(model) {
document.getElementById('finder-switch-input').value = model;
runFinder();
}
async function runFinder() {
var model = (document.getElementById('finder-switch-input').value || '').trim();
var speed = document.getElementById('finder-speed-filter').value;
var results = document.getElementById('finder-results');
if (!model) { results.innerHTML = '<p style="color:var(--text-dim)">Enter a switch model to search.</p>'; return; }
results.innerHTML = '<div class="loading pulse">Searching compatibility database...</div>';
var url = '/api/finder?switch=' + encodeURIComponent(model) + (speed ? '&speed=' + speed : '');
try {
var data = await api(url);
if (data.error) {
results.innerHTML = '<div class="card" style="border-left:3px solid #c1121f"><b>Not found:</b> ' + data.error +
(data.suggestion ? '<br><span style="color:var(--text-dim);font-size:0.85rem">' + data.suggestion + '</span>' : '') + '</div>';
return;
}
var sw = data.switch;
var transceivers = data.compatible_transceivers || [];
var total = data.total || 0;
// Switch info header
var swHtml = '<div class="card mb" style="display:flex;gap:1rem;align-items:center">' +
(sw.image_url ? '<img src="' + sw.image_url + '" style="height:60px;border-radius:6px;object-fit:contain" onerror="this.style.display=\'none\'">' : '') +
'<div style="flex:1">' +
'<div style="font-size:1.1rem;font-weight:700">' + sw.vendor + ' ' + sw.model + '</div>' +
'<div style="color:var(--text-dim);font-size:0.8rem">' +
(sw.series ? sw.series + ' · ' : '') +
'Max speed: ' + (sw.max_speed_gbps || '?') + 'G' +
'</div>' +
'</div>' +
'<div style="text-align:right;font-size:0.8rem;color:var(--text-dim)">' +
'<b style="font-size:1.1rem;color:var(--text)">' + total + '</b> compatible transceivers' +
'</div>' +
'</div>';
if (transceivers.length === 0) {
results.innerHTML = swHtml + '<div class="card" style="color:var(--text-dim)">No compatible transceivers found for this switch. Try removing the speed filter.</div>';
return;
}
// Group by speed class
var bySpeed = {};
for (var t of transceivers) {
var key = t.speed_gbps + 'G ' + t.form_factor;
if (!bySpeed[key]) bySpeed[key] = [];
bySpeed[key].push(t);
}
var speedColors = { 800: '#c1121f', 400: '#FF8100', 100: '#e6a800', 25: '#2d6a4f', 10: '#888' };
var tcvrHtml = Object.entries(bySpeed).sort(function(a, b) {
return parseInt(b[0]) - parseInt(a[0]);
}).map(function(entry) {
var speedClass = entry[0];
var items = entry[1];
var speedGbps = items[0].speed_gbps;
var color = speedColors[speedGbps] || '#888';
var cards = items.slice(0, 12).map(function(t) {
var isFlexoptix = (t.vendor || '').toUpperCase() === 'FLEXOPTIX';
var fullyVerified = t.fully_verified === true;
var priceVerified = t.price_verified === true;
// Price: use verified EUR price if available
var displayPrice = t.price_verified_eur || t.price;
var displayCurrency = t.price_verified_eur ? 'EUR' : (t.currency || 'EUR');
var hasPrice = displayPrice != null;
var priceHtml = hasPrice
? '<span style="color:var(--accent);font-weight:700">' + displayCurrency + ' ' + parseFloat(displayPrice).toFixed(2) + '</span>'
+ (priceVerified ? ' <span title="Price verified from official source" style="color:#2d6a4f;font-size:0.6rem;cursor:help">✓ Verified</span>' : '')
: '<span style="color:var(--text-dim);font-size:0.8rem">see flexoptix.net</span>';
var stockHtml = t.stock === 'in_stock' ? '<span style="color:#2d6a4f;font-size:0.65rem">● In Stock</span>'
: t.stock === 'limited' ? '<span style="color:#e6a800;font-size:0.65rem">● Limited</span>'
: '';
var partNum = t.part_number || t.slug || t.id;
// 100% Verified stamp
var verifiedStamp = fullyVerified
? '<div title="Price, product image and specifications all verified from official sources" style="display:inline-flex;align-items:center;gap:3px;background:linear-gradient(135deg,#1b4332,#2d6a4f);color:white;font-size:0.6rem;font-weight:700;padding:2px 7px;border-radius:10px;margin-bottom:4px;cursor:help;letter-spacing:0.03em">★ 100% VERIFIED</div><br>'
: '';
// Card border: 100% verified = green, Flexoptix = orange, else default
var cardBorder = fullyVerified ? 'border:1px solid #2d6a4f;box-shadow:0 0 0 1px #2d6a4f20'
: isFlexoptix ? 'border-left:3px solid var(--accent)' : '';
return '<div class="card" style="padding:0.8rem;' + cardBorder + '">' +
verifiedStamp +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem">' +
'<div style="flex:1;min-width:0">' +
(isFlexoptix ? '<span style="font-size:0.6rem;background:var(--accent);color:white;padding:1px 5px;border-radius:3px;margin-right:4px">FLEXOPTIX</span>' : '') +
'<span style="font-size:0.7rem;color:var(--text-dim)">' + (t.vendor || '') + '</span><br>' +
'<b style="font-size:0.85rem;word-break:break-all">' + partNum + '</b><br>' +
'<span style="font-size:0.75rem;color:var(--text-dim)">' +
(t.reach || '') + (t.fiber_type ? ' · ' + t.fiber_type : '') +
(t.connector ? ' · ' + t.connector : '') +
'</span>' +
'</div>' +
'<div style="text-align:right;flex-shrink:0">' +
priceHtml + '<br>' + stockHtml +
'</div>' +
'</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem">' +
(t.buy_url ? '<a href="' + t.buy_url + '" target="_blank" style="font-size:0.7rem;color:var(--accent)">Buy at Flexoptix →</a>' : '<span></span>') +
(t.price_verified_url ? '<a href="' + t.price_verified_url + '" target="_blank" title="Price source" style="font-size:0.6rem;color:var(--text-dim)">price source ↗</a>' : '') +
'</div>' +
'</div>';
}).join('');
return '<div style="margin-bottom:1.2rem">' +
'<div style="font-size:0.8rem;font-weight:700;color:' + color + ';margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em">' +
speedClass + ' <span style="color:var(--text-dim);font-weight:400">(' + items.length + ' options)</span>' +
'</div>' +
'<div class="grid g3">' + cards + '</div>' +
'</div>';
}).join('');
// Count verified in results
var verifiedCount = transceivers.filter(function(t) { return t.fully_verified; }).length;
var priceVerCount = transceivers.filter(function(t) { return t.price_verified; }).length;
results.innerHTML = swHtml +
'<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.8rem;display:flex;gap:1rem;flex-wrap:wrap">' +
'<span>Showing ' + Math.min(transceivers.length, total) + ' of ' + total + ' compatible transceivers</span>' +
(verifiedCount > 0 ? '<span style="color:#2d6a4f">★ ' + verifiedCount + ' × 100% Verified</span>' : '') +
(priceVerCount > 0 ? '<span style="color:#2d6a4f">✓ ' + priceVerCount + ' with verified prices</span>' : '') +
'</div>' +
tcvrHtml;
} catch(e) {
var body = e.body || {};
var msg = body.error || e.message || 'Unknown error';
var suggestion = body.suggestion || '';
results.innerHTML = '<div class="card" style="border-left:3px solid #c1121f;padding:1rem">'
+ '<div style="font-weight:700;margin-bottom:0.3rem">Switch not found</div>'
+ '<div style="color:var(--text-dim);font-size:0.85rem">' + esc(msg) + '</div>'
+ (suggestion ? '<div style="color:var(--text-dim);font-size:0.8rem;margin-top:0.4rem">💡 ' + esc(suggestion) + '</div>' : '')
+ '</div>';
}
}
async function loadBlogDrafts() {
var data = await api('/api/blog');
// Check which drafts are still generating (in-progress pipelines)
var drafts = data.drafts || [];
// Fetch progress for all drafts in parallel to know which are still running
var progressMap = {};
await Promise.all(drafts.map(function(d) {
return api('/api/blog/' + d.id + '/progress').then(function(p) {
progressMap[d.id] = p;
}).catch(function() {});
}));
buildDOM(el('blog-list'), drafts.map(function(d) {
var p = progressMap[d.id] || {};
var isRunning = p.running === true;
// Status badge: generating → step X/10, done → ready, published → published, review → review
var statusLabel, statusClass;
if (isRunning) {
statusLabel = p.step ? 'step ' + p.step + '/10' : 'generating…';
statusClass = 'b-yellow';
} else if (d.status === 'published') {
statusLabel = 'published';
statusClass = 'b-green';
} else if (d.status === 'review') {
statusLabel = 'review';
statusClass = 'b-yellow';
} else if (d.pipeline_steps_completed >= 10 || (d.generated_by || '').includes('fo-blog')) {
statusLabel = 'ready ✓';
statusClass = 'b-green';
} else {
statusLabel = 'draft';
statusClass = 'b-blue';
}
var gen = (d.generated_by || '').replace('tip-blog-engine-', '');
var gc = gen.includes('fo-blog') ? 'b-green' : gen === 'template-fallback' ? 'b-yellow' : 'b-neutral';
return '<div class="ri" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" onclick="openBlogDetail(\'' + esc(d.id) + '\')">'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<div class="ri-title">' + esc(d.title) + '</div>'
+ '<div style="display:flex;align-items:center;gap:8px">'
+ '<span class="b ' + statusClass + ' blog-status-badge">' + statusLabel + '</span>'
+ '<span class="blog-del-btn" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" title="Delete" style="color:#c1121f;cursor:pointer;font-size:0.9rem;padding:2px 6px;border-radius:4px" onclick="event.stopPropagation();blogDeleteClick(this)">✕</span>'
+ '</div>'
+ '</div>'
+ '<div class="ri-meta">'
+ '<span class="b b-purple">' + esc(d.topic) + '</span>'
+ '<span class="b b-neutral">' + esc(d.target_audience) + '</span>'
+ '<span class="b ' + gc + '">' + esc(gen || 'template') + '</span>'
+ '<span class="mono">' + esc(d.word_count) + ' words</span>'
+ '<span>' + new Date(d.created_at).toLocaleDateString() + '</span>'
+ '</div></div>';
}).join('') || '<div class="loading">No drafts yet &mdash; click a card above to generate</div>');
// Auto-start polling for any drafts currently running
drafts.forEach(function(d) {
if (progressMap[d.id] && progressMap[d.id].running) {
pollBlogLlm(d.id, 0);
}
});
}
async function openBlogDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/blog/' + id);
var d = data.draft;
var outline = d.outline || {};
var genMethod = (d.generated_by || '').replace('tip-blog-engine-', '');
var methodBadge = genMethod === 'llm' ? 'b-green' : genMethod === 'template-fallback' ? 'b-yellow' : 'b-neutral';
var h = '<div class="panel-title">' + esc(d.title) + '</div>';
h += '<div class="panel-sub">';
h += '<span class="b b-purple">' + esc(d.topic) + '</span> ';
h += '<span class="b b-neutral">' + esc(d.target_audience) + '</span> ';
h += '<span class="b ' + methodBadge + '">' + esc(genMethod || 'template') + '</span> ';
h += '<span class="mono dim">' + esc(d.word_count) + ' words</span>';
h += '</div>';
var hasQualityIssues = outline.quality_issues && outline.quality_issues.length > 0;
if (hasQualityIssues) {
h += '<div style="margin:0.5rem 0;padding:0.4rem 0.8rem;background:var(--yellow-light);border:1px solid rgba(212,163,115,0.4);border-radius:var(--radius-md);font-size:0.8rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">';
h += '<div><strong style="color:#b8860b">Quality issues:</strong> ' + outline.quality_issues.map(esc).join(', ') + '</div>';
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="white-space:nowrap;color:#b8860b;border-color:rgba(212,163,115,0.5);font-size:0.75rem;padding:3px 10px">&#x1F504; Regenerate</button>';
h += '</div>';
}
// Copy button + Article section header
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin:1.25rem 0 0.6rem">';
h += '<div class="panel-section" style="margin:0;flex:1">Article</div>';
h += '<button class="btn-copy" id="copy-btn-' + esc(d.id) + '" onclick="event.stopPropagation();copyBlogContent(\'' + esc(d.id) + '\')">&#128203; Copy</button>';
h += '</div>';
h += '<div style="font-size:0.85rem;color:var(--text);line-height:1.8;max-height:65vh;overflow-y:auto;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + mdToHtml(d.draft_content) + '</div>';
h += '<div class="panel-section">SEO Keywords / Hashtags</div>';
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + (d.seo_keywords || []).map(function(k) {
var tag = '#' + k.replace(/\s+/g, '');
return '<span class="b b-neutral" style="cursor:pointer;user-select:all" title="Click to copy hashtag" onclick="navigator.clipboard.writeText(\'' + esc(tag) + '\').then(function(){showToast(\'Copied\',\'' + esc(tag) + '\')})">' + esc(tag) + '</span>';
}).join('') + '</div>';
h += '<div style="margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">';
if (d.status === 'review' || hasQualityIssues) {
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="color:#b8860b;border-color:rgba(212,163,115,0.5)">&#x1F504; Neu generieren</button>';
}
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'review\')" style="color:#b8860b;border-color:rgba(212,163,115,0.4)">Mark Review</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'approved\')" style="color:var(--green);border-color:rgba(45,106,79,0.3)">Approve</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'published\')" style="color:var(--accent);border-color:rgba(196,112,75,0.3)">Publish</button>';
h += '</div>';
buildDOM(el('panel-content'), h);
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
async function updateBlogStatus(id, status) {
try {
var data = await fetch(API + '/api/blog/' + id + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: status })
}).then(function(r) { return r.json(); });
if (data.success) {
showToast('Updated', 'Status: ' + status);
openBlogDetail(id);
loadBlogDrafts();
} else showToast('Failed', data.error, true);
} catch(e) { showToast('Error', e.message, true); }
}
async function regenerateBlog(id) {
showToast('Regenerating…', 'LLM pipeline wird neu gestartet');
try {
var data = await fetch(API + '/api/blog/' + id + '/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}).then(function(r) { return r.json(); });
if (data.success) {
showToast('Gestartet', 'LLM läuft Status wird aktualisiert');
loadBlogDrafts();
// Poll for completion
pollBlogLlm(id, 0);
} else {
showToast('Fehler', data.error || 'Regenerierung fehlgeschlagen', true);
}
} catch(e) { showToast('Error', e.message, true); }
}
// TABLE SORTING
function makeSortable(table) {
if (!table) return;
var headers = table.querySelectorAll('thead th');
headers.forEach(function(th, colIdx) {
th.addEventListener('click', function(e) {
// Don't sort if clicking inside a badge or link
if (e.target.tagName === 'A') return;
var tbody = table.querySelector('tbody');
if (!tbody) return;
// Toggle direction
var isAsc = th.classList.contains('sort-asc');
// Reset all headers in this table
headers.forEach(function(h) { h.classList.remove('sort-asc', 'sort-desc'); });
th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
var dir = isAsc ? -1 : 1;
// Get rows and sort
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function(a, b) {
var cellA = a.children[colIdx];
var cellB = b.children[colIdx];
if (!cellA || !cellB) return 0;
var valA = (cellA.textContent || '').trim();
var valB = (cellB.textContent || '').trim();
// Try numeric comparison (handles "23%", "2026", "3y", "$1,234", "12.5 Tbps" etc.)
var numA = parseFloat(valA.replace(/[^0-9.\-]/g, ''));
var numB = parseFloat(valB.replace(/[^0-9.\-]/g, ''));
if (!isNaN(numA) && !isNaN(numB)) {
return (numA - numB) * dir;
}
// Handle "—" as last
if (valA === '—' && valB !== '—') return 1;
if (valB === '—' && valA !== '—') return -1;
return valA.localeCompare(valB, undefined, { numeric: true, sensitivity: 'base' }) * dir;
});
// Re-append in order
rows.forEach(function(row) { tbody.appendChild(row); });
});
});
}
// Initialize sorting on all tables after DOM ready
function initAllSorting() {
document.querySelectorAll('.table-wrap table').forEach(makeSortable);
}
// Run once now and also after each table rebuild
initAllSorting();
// Observer to re-init sorting when tbody content changes
var sortObserver = new MutationObserver(function() { initAllSorting(); });
document.querySelectorAll('tbody').forEach(function(tb) {
sortObserver.observe(tb, { childList: true });
});
// Close compare overlay on backdrop click
el('compare-overlay').addEventListener('click', function(e) {
if (e.target === this) this.classList.remove('visible');
});
// ─── CHANGELOG ───────────────────────────────────────────────────────────────
var changelogEntries = [];
var changelogExpanded = false;
async function loadChangelog() {
try {
var d = await api('/api/changelog');
changelogEntries = d.entries || [];
el('changelog-total').textContent = changelogEntries.length + ' entries';
renderChangelog();
} catch(e) {
el('changelog-list').innerHTML = '<div style="color:var(--text-dim);font-size:0.78rem">Not available in preview — runs on production server.</div>';
}
}
function toggleChangelog() {
changelogExpanded = !changelogExpanded;
el('changelog-toggle-btn').textContent = changelogExpanded ? 'Show recent' : 'Show all';
renderChangelog();
}
function renderChangelog() {
var entries = changelogExpanded ? changelogEntries : changelogEntries.slice(0, 8);
el('changelog-list').innerHTML = entries.map(function(e) {
return '<div class="cl-entry">'
+ '<span class="cl-date">' + esc(e.d) + '</span>'
+ '<span class="cl-type cl-' + esc(e.t) + '">' + esc(e.t) + '</span>'
+ '<span class="cl-msg">' + esc(e.m) + '</span>'
+ '</div>';
}).join('');
}
// ─── PROCUREMENT INTEL ───────────────────────────────────────────────────────
var procCurrentSignalFilter = '';
var procCurrentAbcFilter = '';
var procSignalsData = [];
var procAbcData = [];
function showProcSection(name) {
['signals','abc','market','lifecycle'].forEach(function(s) {
var sec = el('proc-section-' + s);
var btn = el('proc-btn-' + s);
if (sec) sec.style.display = s === name ? '' : 'none';
if (btn) { btn.classList.toggle('proc-btn-active', s === name); }
});
}
async function loadProcurement() {
await Promise.all([
loadProcSignals(),
loadProcAbc(),
loadProcMarketIntel(),
loadProcLifecycle(),
]);
}
async function loadProcSignals() {
var container = el('proc-signals-grid');
container.innerHTML = '<div class="loading pulse">Loading signals...</div>';
try {
var d = await api('/api/procurement/signals?limit=100');
procSignalsData = d.data || [];
renderSignals(procCurrentSignalFilter);
} catch(e) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem">No reorder signals yet — run the scraper to populate.</div>';
}
}
function filterSignal(sig) {
procCurrentSignalFilter = sig;
renderSignals(sig);
}
function renderSignals(filterSig) {
var data = filterSig ? procSignalsData.filter(function(r) { return r.signal === filterSig; }) : procSignalsData;
var container = el('proc-signals-grid');
if (!data.length) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No signals for this filter.</div>';
return;
}
var signalIcon = { buy_now:'🔴', wait:'🟡', hold:'🟢', monitor:'🔵' };
var signalLabel = { buy_now:'Buy Now', wait:'Wait', hold:'Hold', monitor:'Monitor' };
container.innerHTML = data.map(function(r) {
var reasons = [];
try { reasons = JSON.parse(r.reasons || '[]'); } catch(e) {}
var sigClass = 'signal-' + (r.signal || 'monitor').replace('_','-');
var badgeClass = 'sig-badge-' + (r.signal || 'monitor').replace('_now','').replace('_','');
var abcTitles = { A:'Class A — high turnover product, top 20% by value. Prioritize stock availability.', B:'Class B — medium turnover. Standard replenishment cycle.', C:'Class C — low turnover. Order on demand only.' };
var abcBadge = r.abc_class ? '<span class="abc-' + r.abc_class.toLowerCase() + '" title="' + (abcTitles[r.abc_class] || '') + '">' + r.abc_class + '</span>' : '';
var strengthPct = Math.round((r.signal_strength || 0) * 100);
var productName = r.standard_name || r.part_number || r.slug || '—';
var imgHtml = '';
if (r.image_r2_key) {
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
}
return '<div class="signal-card ' + sigClass + '">'
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
+ imgHtml
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-weight:700;font-size:0.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(productName) + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(r.form_factor || '') + (r.speed_gbps ? ' · ' + r.speed_gbps + 'G' : '') + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div>'
+ '</div>'
+ '</div>'
+ '<div style="display:flex;gap:0.4rem;align-items:center;margin-bottom:0.6rem;flex-wrap:wrap">'
+ '<span class="intel-badge ' + badgeClass + '" title="Procurement signal: Buy Now = act immediately (supply tightening or price rising). Wait = better prices expected. Hold = no action needed. Monitor = track closely.">' + (signalIcon[r.signal] || '') + ' ' + (signalLabel[r.signal] || r.signal) + '</span>'
+ abcBadge
+ (r.supply_risk ? '<span title="Supply chain risk: low = widely available, medium = some constraints, high = single-source or shortage risk" style="font-size:0.65rem;padding:2px 6px;border-radius:3px;background:var(--surface2);color:var(--text-dim)">' + esc(r.supply_risk) + ' risk</span>' : '')
+ '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.5rem">'
+ (reasons.length ? reasons.map(function(r2) { return '→ ' + esc(r2); }).join('<br>') : 'Insufficient data')
+ '</div>'
+ '<div style="display:flex;gap:1rem;font-size:0.7rem;color:var(--text-dim)">'
+ (r.stock_trend ? '<span title="Stock trend based on price observation frequency and vendor listing changes">Stock: <b style="color:var(--text)">' + r.stock_trend + '</b></span>' : '')
+ (r.price_trend ? '<span title="Price trend over last 30 days: rising/falling/stable">Price: <b style="color:var(--text)">' + r.price_trend + '</b></span>' : '')
+ (r.lead_time_weeks ? '<span title="Estimated supplier lead time in weeks until delivery">Lead: <b style="color:var(--text)">' + r.lead_time_weeks + 'w</b></span>' : '')
+ '</div>'
+ '<div title="Signal strength (0100%): confidence in the procurement recommendation, based on data volume, price history consistency, and compatibility coverage." style="margin-top:0.6rem;background:var(--surface2);border-radius:3px;height:4px">'
+ '<div style="height:4px;border-radius:3px;width:' + strengthPct + '%;background:var(--accent)"></div>'
+ '</div>'
+ '<div style="font-size:0.65rem;color:var(--text-dim);text-align:right;margin-top:2px">Signal strength: ' + strengthPct + '%</div>'
+ '</div>';
}).join('');
}
async function loadProcAbc() {
try {
var d = await api('/api/procurement/abc?limit=200');
procAbcData = d.data || [];
renderAbcTable(procCurrentAbcFilter);
} catch(e) {
el('abc-tbody').innerHTML = '<tr><td colspan="8" style="padding:1rem;color:var(--text-dim)">No ABC data yet — run compute:abc job.</td></tr>';
}
}
function filterAbc(cls) {
procCurrentAbcFilter = cls;
renderAbcTable(cls);
}
function renderAbcTable(filterCls) {
var data = filterCls ? procAbcData.filter(function(r) { return r.abc_class === filterCls; }) : procAbcData;
var sigIcon = { buy_now:'🔴', wait:'🟡', hold:'🟢', monitor:'🔵' };
el('abc-tbody').innerHTML = data.map(function(r) {
var abcEl = '<span class="abc-' + (r.abc_class || 'c').toLowerCase() + '">' + (r.abc_class || '—') + '</span>';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:7px 6px">' + abcEl + '</td>'
+ '<td style="padding:7px 6px"><div style="font-weight:600">' + esc(r.standard_name || r.part_number || '—') + '</div><div style="font-size:0.68rem;color:var(--text-dim)">' + esc(r.vendor_name || '') + '</div></td>'
+ '<td style="padding:7px 6px;font-family:var(--mono);font-size:0.75rem">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:var(--mono)">' + (r.demand_score ? parseFloat(r.demand_score).toFixed(0) : '—') + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:var(--mono)">' + (r.compat_count || 0) + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:var(--mono)">' + (r.vendor_count || 0) + '</td>'
+ '<td style="padding:7px 6px;font-size:0.75rem;color:' + (r.supply_risk === 'high' ? 'var(--red)' : r.supply_risk === 'medium' ? 'var(--yellow)' : 'var(--green)') + '">' + esc(r.supply_risk || '—') + '</td>'
+ '<td style="padding:7px 6px">' + (r.signal ? (sigIcon[r.signal] || '') + ' ' + r.signal.replace('_',' ') : '—') + '</td>'
+ '</tr>';
}).join('') || '<tr><td colspan="8" style="padding:1rem;color:var(--text-dim)">No data for this filter.</td></tr>';
}
async function loadProcMarketIntel() {
var container = el('proc-market-grid');
try {
var d = await api('/api/procurement/market-intel?days=180&limit=50');
var items = d.data || [];
if (!items.length) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No market intelligence yet.</div>';
return;
}
var typeIcon = {
capex_cycle:'💰', trade_show:'🎪', standard_ratified:'📋',
standard_draft:'📝', distributor_lead_time:'🚚', supply_chain:'🏭', tender:'📑'
};
var typeDesc = {
capex_cycle:'Capital expenditure cycle event — customer budget release, fiscal year start, major infrastructure spend',
trade_show:'Trade show or conference (OFC, ECOC, MWC, IEEE) — often signals new product launches and technology shifts',
standard_ratified:'IEEE/MSA standard officially ratified — technology is production-ready, adoption typically accelerates',
standard_draft:'Standard in draft phase — technology is emerging, early adopters phase',
distributor_lead_time:'Distributor lead time change — indicates supply chain pressure or inventory build-up',
supply_chain:'Supply chain event — factory capacity, shortage, logistics disruption',
tender:'Public or enterprise tender/RFP published — indicates near-term procurement demand'
};
container.innerHTML = items.map(function(item) {
var sig = item.buy_signal_implication || 'none';
var badgeClass = 'intel-' + sig.replace('_now','').replace('_','');
var sigLabel = { buy_now:'🔴 Buy Now', wait:'🟡 Wait', hold:'🟢 Hold', monitor:'🔵 Monitor', none:'—' };
var sigDesc = { buy_now:'Buy Now: this market event suggests immediate procurement — prices or availability will worsen', wait:'Wait: conditions suggest holding off — better pricing or availability expected soon', hold:'Hold: market stable, no urgency to act', monitor:'Monitor: track this development, not yet actionable', none:'No specific procurement implication' };
var techs = (item.technologies || []).map(function(t) {
return '<span title="Technology segment this intelligence applies to" style="font-size:0.65rem;padding:1px 6px;border-radius:3px;background:var(--surface2);color:var(--text-dim)">' + esc(t) + '</span>';
}).join(' ');
return '<div class="intel-card">'
+ '<div style="display:flex;gap:0.5rem;align-items:flex-start;margin-bottom:0.4rem">'
+ '<span title="' + esc(typeDesc[item.intel_type] || item.intel_type || '') + '" style="font-size:1.2rem;cursor:default">' + (typeIcon[item.intel_type] || '📊') + '</span>'
+ '<div style="flex:1">'
+ '<span class="intel-badge ' + badgeClass + '" title="' + esc(sigDesc[sig] || sig) + '">' + (sigLabel[sig] || sig) + '</span>'
+ '<div style="font-weight:700;font-size:0.82rem;line-height:1.3;margin-top:0.2rem">' + esc(item.title) + '</div>'
+ '</div></div>'
+ '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.6rem;line-height:1.5">' + esc(item.summary || '') + '</div>'
+ (techs ? '<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-bottom:0.5rem">' + techs + '</div>' : '')
+ '<div style="display:flex;justify-content:space-between;font-size:0.68rem;color:var(--text-dim)">'
+ '<span title="Intelligence source">' + esc(item.source_name) + '</span>'
+ (item.impact_horizon_months ? '<span title="Estimated months until this event has measurable market impact on pricing or availability">Impact: ~' + item.impact_horizon_months + ' months</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
} catch(e) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">Could not load market intelligence.</div>';
}
}
async function loadProcLifecycle() {
var container = el('proc-lifecycle-grid');
try {
var d = await api('/api/procurement/lifecycle?days=365&limit=50');
var items = d.data || [];
if (!items.length) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No lifecycle events yet.</div>';
return;
}
var typeIcon = {
eol_announced:'⛔', eol_effective:'🚫', standard_ratified:'✅',
standard_draft:'📝', capex_peak:'💰', trade_show:'🎪',
supply_risk:'⚠️', tender:'📑', price_floor:'📉'
};
var typeDesc = {
eol_announced:'End-of-Life announced — vendor has confirmed a product or standard will be discontinued. Start planning migration.',
eol_effective:'End-of-Life effective — product is no longer manufactured or supported. Immediately find replacements.',
standard_ratified:'Standard officially ratified by IEEE or MSA — technology is mature, safe to deploy at scale.',
standard_draft:'Standard in draft — technology is emerging. Early adopters phase, compatibility not yet guaranteed.',
capex_peak:'Capital expenditure peak — major procurement wave expected. May affect availability and pricing.',
trade_show:'Trade show event (OFC, ECOC, MWC) — often triggers new product launches and price adjustments.',
supply_risk:'Supply chain risk identified — potential shortage, capacity constraint, or geopolitical factor.',
tender:'Public or enterprise tender published — indicates confirmed near-term demand from large buyer.',
price_floor:'Price floor reached — technology has hit bottom pricing. Unlikely to drop further; good time to stock up.'
};
var impactColor = { critical:'#c1121f', high:'#c1121f', medium:'var(--yellow)', low:'var(--green)' };
var impactDesc = { critical:'Critical impact — immediate action required', high:'High impact — plan response within weeks', medium:'Medium impact — monitor and prepare response', low:'Low impact — informational' };
var sigLabel = { buy_now:'🔴 Buy Now', wait:'🟡 Wait', hold:'🟢 Hold', monitor:'🔵 Monitor' };
var sigDesc = { buy_now:'Buy Now: this event signals immediate procurement urgency', wait:'Wait: better conditions expected after this event resolves', hold:'Hold: no change to current procurement strategy', monitor:'Monitor: track how this event develops before acting' };
container.innerHTML = items.map(function(item) {
var ic = impactColor[item.impact_level] || 'var(--text-dim)';
var productInfo = item.part_number ? esc(item.part_number) + (item.form_factor ? ' · ' + esc(item.form_factor) : '') : '';
var dateStr = item.effective_date ? new Date(item.effective_date).toLocaleDateString('de-DE') : '';
return '<div class="intel-card" style="border-left:3px solid ' + ic + '" title="' + esc(impactDesc[item.impact_level] || '') + '">'
+ '<div style="display:flex;gap:0.5rem;align-items:flex-start;margin-bottom:0.4rem">'
+ '<span title="' + esc(typeDesc[item.event_type] || item.event_type || '') + '" style="font-size:1.2rem;cursor:default">' + (typeIcon[item.event_type] || '📌') + '</span>'
+ '<div style="flex:1">'
+ (item.buy_signal ? '<span class="intel-badge intel-' + item.buy_signal.replace('_now','').replace('_','') + '" title="' + esc(sigDesc[item.buy_signal] || item.buy_signal) + '">' + (sigLabel[item.buy_signal] || item.buy_signal) + '</span>' : '')
+ '<div style="font-weight:700;font-size:0.82rem;line-height:1.3;margin-top:0.2rem">' + esc(item.title) + '</div>'
+ '</div></div>'
+ (item.description ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem;line-height:1.5">' + esc(item.description.substring(0, 200)) + (item.description.length > 200 ? '…' : '') + '</div>' : '')
+ '<div style="display:flex;justify-content:space-between;font-size:0.68rem;color:var(--text-dim)">'
+ '<span>' + esc(item.source_name || '') + (productInfo ? ' · ' + productInfo : '') + '</span>'
+ (dateStr ? '<span title="Effective date of this lifecycle event" style="color:' + ic + ';font-weight:600">' + dateStr + '</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
} catch(e) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">Could not load lifecycle events.</div>';
}
}
// INIT
loadOverview();
loadChangelog();
</script>
<script src="/dashboard/hot-topics.js"></script>
</body>
</html>