Add auto-heal pipeline command and fix multi-source draft processing

- Add `ietf auto` command: fetches, analyzes, embeds, extracts ideas,
  and refreshes gaps across all sources with cost-based auto-approval
- Fix SourceDocument→Draft conversion in auto fetch step
- Fix gap_analysis method name in auto command
- Process all 270 unrated ETSI/ISO/ITU/NIST drafts (761 total, all rated)
- Update web UI templates and data layer for multi-source support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 18:41:42 +01:00
parent 1ec1f69bee
commit a46a01bd8c
15 changed files with 991 additions and 381 deletions

View File

@@ -116,34 +116,72 @@
<p class="text-xs text-slate-500 mb-4">Clusters are formed by connected-component analysis of the co-authorship graph: authors who share 2+ drafts are linked, and all authors reachable through such links form a cluster. This reveals research teams and institutional collaboration patterns — a cluster of 20 authors from 3 organizations means those groups actively co-author across org boundaries. Click a cluster to highlight it in the graph above.</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="clusterGrid">
{% for c in network.clusters[:12] %}
<div class="cluster-card bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 cursor-pointer" data-cluster-id="{{ c.id }}" onclick="highlightCluster({{ c.id }})">
<div class="flex items-center justify-between mb-2">
<div class="cluster-card bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 cursor-pointer" data-cluster-id="{{ c.id }}">
<!-- Header — click to highlight in graph -->
<div class="flex items-center justify-between mb-2" onclick="highlightCluster({{ c.id }})">
<span class="text-sm font-semibold text-white">Cluster #{{ c.id + 1 }}</span>
<div class="flex gap-1.5">
<div class="flex gap-1.5 items-center">
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">{{ c.size }} authors</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400">{{ c.draft_count }} drafts</span>
<svg class="w-4 h-4 text-slate-500 transition-transform cluster-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<!-- Org mix -->
<div class="flex flex-wrap gap-1 mb-2">
{% for org, count in c.org_mix.items() %}
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300">{{ org }} ({{ count }})</span>
{% endfor %}
</div>
<!-- Preview: first 3 members -->
<div class="text-xs text-slate-500 mb-2 truncate" title="{{ c.members | join(', ') }}">
{{ c.members[:5] | join(', ') }}{% if c.members | length > 5 %} +{{ c.members | length - 5 }} more{% endif %}
{{ c.members[:3] | join(', ') }}{% if c.members | length > 3 %} +{{ c.members | length - 3 }} more{% endif %}
</div>
<!-- Preview: first 3 drafts -->
{% if c.drafts %}
<div class="border-t border-slate-700/50 pt-2 mt-2">
{% for d in c.drafts[:5] %}
{% for d in c.drafts[:3] %}
<div class="text-xs truncate mb-0.5" title="{{ d.name }}: {{ d.title }}">
<a href="/drafts/{{ d.name }}" class="text-blue-400/70 hover:text-blue-300 transition" onclick="event.stopPropagation()">{{ d.title }}</a>
</div>
{% endfor %}
{% if c.draft_count > 5 %}
<div class="text-xs text-slate-600 mt-1">+{{ c.draft_count - 5 }} more drafts</div>
{% if c.draft_count > 3 %}
<div class="text-xs text-slate-600 mt-1">+{{ c.draft_count - 3 }} more drafts</div>
{% endif %}
</div>
{% endif %}
<!-- Expanded detail (hidden by default) -->
<div class="cluster-detail hidden mt-3 border-t border-slate-700/50 pt-3" id="authorCluster-{{ c.id }}">
<!-- All members with org -->
<h4 class="text-xs font-semibold text-slate-300 mb-2 uppercase tracking-wide">All {{ c.size }} Authors</h4>
<div class="max-h-48 overflow-y-auto mb-3 space-y-1">
{% for member in c.members %}
<div class="text-xs flex items-center justify-between gap-2">
<a href="/drafts?q={{ member | urlencode }}" class="text-slate-300 hover:text-blue-400 transition truncate" onclick="event.stopPropagation()">{{ member }}</a>
{% set member_org = c.member_orgs[member] if c.member_orgs is defined and member in c.member_orgs else '' %}
{% if member_org %}
<span class="text-slate-600 text-[10px] truncate max-w-[120px] flex-shrink-0">{{ member_org }}</span>
{% endif %}
</div>
{% endfor %}
</div>
<!-- All drafts -->
{% if c.drafts %}
<h4 class="text-xs font-semibold text-slate-300 mb-2 uppercase tracking-wide">All {{ c.draft_count }} Drafts</h4>
<div class="max-h-48 overflow-y-auto space-y-1.5">
{% for d in c.drafts %}
<div class="text-xs" title="{{ d.name }}">
<a href="/drafts/{{ d.name }}" class="text-blue-400/70 hover:text-blue-300 transition" onclick="event.stopPropagation()">{{ d.title }}</a>
<span class="text-slate-600 font-mono text-[10px] ml-1">{{ d.name | replace('draft-', '') | truncate(25) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
@@ -478,6 +516,20 @@ const network = {{ network | tojson }};
});
});
// Toggle expand/collapse on cluster card chevron click
document.querySelectorAll('.cluster-card').forEach(card => {
card.addEventListener('click', function(e) {
// Don't toggle if clicking a link or the highlight header
if (e.target.closest('a') || e.target.closest('[onclick*="highlightCluster"]')) return;
const detail = card.querySelector('.cluster-detail');
const chevron = card.querySelector('.cluster-chevron');
if (detail) {
detail.classList.toggle('hidden');
chevron.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(180deg)';
}
});
});
// Expose cluster highlighting globally
window.highlightCluster = function(clusterId) {
const cluster = (network.clusters || []).find(c => c.id === clusterId);

View File

@@ -71,7 +71,7 @@
{{ draft.title }}
</a>
<div class="text-xs text-slate-600 font-mono mt-1">{{ draft.name }}</div>
<div class="text-xs text-slate-500 mt-2 line-clamp-3">{{ draft.abstract[:200] }}</div>
<div class="text-xs text-slate-500 mt-2 line-clamp-3">{{ (draft.abstract | striptags)[:200] }}</div>
{% if draft.rating %}
<!-- Rating radar -->
@@ -79,9 +79,15 @@
{% for dim, label in [('novelty', 'Nov'), ('maturity', 'Mat'), ('relevance', 'Rel'), ('momentum', 'Mom'), ('overlap', 'Ovl')] %}
<div>
<div class="text-xs text-slate-500">{{ label }}</div>
{% if dim == 'overlap' %}
<div class="text-sm font-semibold {% if draft.rating[dim] <= 2 %}text-green-400{% elif draft.rating[dim] <= 3 %}text-yellow-400{% else %}text-red-400{% endif %}">
{{ draft.rating[dim] }}
</div>
{% else %}
<div class="text-sm font-semibold {% if draft.rating[dim] >= 4 %}text-green-400{% elif draft.rating[dim] >= 3 %}text-yellow-400{% else %}text-red-400{% endif %}">
{{ draft.rating[dim] }}
</div>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -91,7 +91,7 @@
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
Abstract
</h2>
<p class="text-sm text-slate-400 leading-relaxed">{{ draft.abstract or "No abstract available." }}</p>
<p class="text-sm text-slate-400 leading-relaxed">{{ (draft.abstract | striptags) or "No abstract available." }}</p>
</div>
<!-- Rating Analysis -->
@@ -120,10 +120,18 @@
<svg class="w-3.5 h-3.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon }}"/></svg>
<span class="text-xs font-semibold text-slate-300 uppercase tracking-wide">{{ label }}</span>
</div>
{% if dim == "overlap" %}
<span class="text-lg font-bold {% if val <= 2 %}text-green-400{% elif val <= 3 %}text-amber-400{% else %}text-red-400{% endif %}">{{ val }}<span class="text-xs text-slate-600 font-normal">/5</span></span>
{% else %}
<span class="text-lg font-bold {% if val >= 4 %}text-green-400{% elif val >= 3 %}text-amber-400{% else %}text-red-400{% endif %}">{{ val }}<span class="text-xs text-slate-600 font-normal">/5</span></span>
{% endif %}
</div>
<div class="dim-progress mb-2">
{% if dim == "overlap" %}
<div class="dim-progress-fill {% if val <= 2 %}dim-high{% elif val <= 3 %}dim-mid{% else %}dim-low{% endif %}" style="width: {{ val * 20 }}%"></div>
{% else %}
<div class="dim-progress-fill {% if val >= 4 %}dim-high{% elif val >= 3 %}dim-mid{% else %}dim-low{% endif %}" style="width: {{ val * 20 }}%"></div>
{% endif %}
</div>
{% if draft.rating[dim + '_note'] %}
<p class="text-xs text-slate-500 leading-relaxed">{{ draft.rating[dim + '_note'] }}</p>
@@ -231,7 +239,11 @@
{% for dim, abbr in [("novelty","N"), ("maturity","M"), ("overlap","O"), ("momentum","Mo"), ("relevance","R")] %}
{% set v = draft.rating[dim] %}
<div>
{% if dim == "overlap" %}
<div class="text-xs font-bold {% if v <= 2 %}text-green-400{% elif v <= 3 %}text-amber-400{% else %}text-red-400{% endif %}">{{ v }}</div>
{% else %}
<div class="text-xs font-bold {% if v >= 4 %}text-green-400{% elif v >= 3 %}text-amber-400{% else %}text-red-400{% endif %}">{{ v }}</div>
{% endif %}
<div class="text-[9px] text-slate-600 uppercase">{{ abbr }}</div>
</div>
{% endfor %}

View File

@@ -68,6 +68,11 @@
color: #c084fc;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.source-nist {
background: rgba(6, 182, 212, 0.15);
color: #22d3ee;
border: 1px solid rgba(6, 182, 212, 0.3);
}
.source-generated {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
@@ -180,6 +185,7 @@
<option value="etsi" {% if current_source == 'etsi' %}selected{% endif %}>ETSI</option>
<option value="itu" {% if current_source == 'itu' %}selected{% endif %}>ITU-T</option>
<option value="iso" {% if current_source == 'iso' %}selected{% endif %}>ISO/IEC</option>
<option value="nist" {% if current_source == 'nist' %}selected{% endif %}>NIST</option>
</select>
</div>
<!-- Sort -->
@@ -426,7 +432,7 @@
<td class="px-4 py-3 hidden xl:table-cell">
<div class="flex items-center gap-1.5">
<span class="dim-bar-bg">
<span class="dim-bar-fill {% if d.overlap >= 4 %}dim-fill-high{% elif d.overlap >= 3 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
<span class="dim-bar-fill {% if d.overlap <= 2 %}dim-fill-high{% elif d.overlap <= 3 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
style="width: {{ (d.overlap / 5 * 100)|int }}%"></span>
</span>
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ d.overlap }}</span>

View File

@@ -63,6 +63,20 @@
<div id="treemapPlot" style="height: 450px;"></div>
</div>
<!-- Cluster relationship network -->
<div id="networkSection" class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6 hidden">
<h2 class="text-sm font-semibold text-slate-300 mb-1">Cluster Relationships</h2>
<p class="text-xs text-slate-500 mb-3">Network showing how idea clusters relate to each other. Thicker lines = stronger semantic similarity. Click a link to see the connecting ideas.</p>
<div id="networkPlot" style="height: 560px;"></div>
<div id="linkDetail" class="hidden mt-4 bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-white" id="linkTitle"></h3>
<button onclick="document.getElementById('linkDetail').classList.add('hidden')" class="text-slate-500 hover:text-white text-xs"></button>
</div>
<div id="linkContent" class="text-xs text-slate-400 space-y-2"></div>
</div>
</div>
<!-- Cluster cards grid -->
<h2 class="text-lg font-semibold text-white mb-4">Cluster Details</h2>
<div id="clusterGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
@@ -180,6 +194,135 @@ if (data.empty) {
}, CFG);
}
// --- Cluster Relationship Network ---
const links = data.links || [];
if (links.length > 0) {
document.getElementById('networkSection').classList.remove('hidden');
// Build node set from clusters that have links
const linkedIds = new Set();
links.forEach(l => { linkedIds.add(l.source); linkedIds.add(l.target); });
const nodes = data.clusters.filter(c => linkedIds.has(c.id));
const nodeMap = {};
nodes.forEach((n, i) => { nodeMap[n.id] = i; });
// Force-directed layout using Plotly scatter + annotations for edges
// Position nodes in a circle, then use link structure
const n = nodes.length;
const nodeX = nodes.map((_, i) => Math.cos(2 * Math.PI * i / n) * 4);
const nodeY = nodes.map((_, i) => Math.sin(2 * Math.PI * i / n) * 4);
// Simple force-directed: pull linked nodes closer
for (let iter = 0; iter < 80; iter++) {
for (const link of links) {
const si = nodeMap[link.source];
const ti = nodeMap[link.target];
if (si === undefined || ti === undefined) continue;
const dx = nodeX[ti] - nodeX[si];
const dy = nodeY[ti] - nodeY[si];
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const force = (link.best_pair_sim - 0.5) * 0.15;
nodeX[si] += dx/dist * force;
nodeY[si] += dy/dist * force;
nodeX[ti] -= dx/dist * force;
nodeY[ti] -= dy/dist * force;
}
// Repulsion between all nodes
for (let i = 0; i < n; i++) {
for (let j = i+1; j < n; j++) {
const dx = nodeX[j] - nodeX[i];
const dy = nodeY[j] - nodeY[i];
const dist = Math.sqrt(dx*dx + dy*dy) || 0.1;
if (dist < 1.5) {
const repel = 0.3 / (dist * dist);
nodeX[i] -= dx/dist * repel;
nodeY[i] -= dy/dist * repel;
nodeX[j] += dx/dist * repel;
nodeY[j] += dy/dist * repel;
}
}
}
}
// Edge traces (one per link for click handling)
const edgeTraces = links.map((link, li) => {
const si = nodeMap[link.source];
const ti = nodeMap[link.target];
if (si === undefined || ti === undefined) return null;
const width = 1 + (link.best_pair_sim - 0.5) * 8;
const opacity = 0.3 + (link.best_pair_sim - 0.5) * 1.2;
return {
x: [nodeX[si], nodeX[ti], null],
y: [nodeY[si], nodeY[ti], null],
mode: 'lines',
line: { width: width, color: `rgba(100,116,139,${opacity})` },
hoverinfo: 'text',
text: `${link.source_theme}${link.target_theme}<br>Similarity: ${(link.best_pair_sim * 100).toFixed(0)}%`,
customdata: [li, li, null],
showlegend: false,
};
}).filter(Boolean);
// Node trace
const nodeTrace = {
x: nodeX, y: nodeY,
mode: 'markers+text',
type: 'scatter',
marker: {
size: nodes.map(n => 12 + Math.sqrt(n.size) * 3),
color: nodes.map((_, i) => PALETTE[nodes[i].id % PALETTE.length]),
line: { width: 2, color: 'rgba(15,23,42,0.8)' },
},
text: nodes.map(n => n.theme.length > 25 ? n.theme.substring(0, 22) + '...' : n.theme),
textposition: 'top center',
textfont: { size: 10, color: '#cbd5e1' },
hovertext: nodes.map(n =>
`<b>${n.theme}</b><br>${n.size} ideas, ${n.drafts.length} drafts`
),
hoverinfo: 'text',
showlegend: false,
};
Plotly.newPlot('networkPlot', [...edgeTraces, nodeTrace], {
...PLOTLY_LAYOUT,
xaxis: { visible: false, showgrid: false, zeroline: false },
yaxis: { visible: false, showgrid: false, zeroline: false },
hovermode: 'closest',
margin: { t: 10, r: 20, b: 10, l: 20 },
}, CFG);
// Click handler for edges — show link detail
document.getElementById('networkPlot').on('plotly_click', function(ev) {
const pt = ev.points[0];
if (pt.data.customdata && pt.data.customdata[pt.pointNumber] !== null) {
const link = links[pt.data.customdata[pt.pointNumber]];
if (!link) return;
const detail = document.getElementById('linkDetail');
const simPct = (link.best_pair_sim * 100).toFixed(0);
document.getElementById('linkTitle').innerHTML =
`<span style="color:${PALETTE[link.source % PALETTE.length]}">${link.source_theme}</span>` +
` <span class="text-slate-500">↔</span> ` +
`<span style="color:${PALETTE[link.target % PALETTE.length]}">${link.target_theme}</span>` +
` <span class="text-slate-500 text-xs font-normal ml-2">${simPct}% similar</span>`;
document.getElementById('linkContent').innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-900/50 rounded p-3 border border-slate-700/30">
<div class="text-slate-300 font-medium mb-1">${link.idea_a}</div>
<a href="/drafts/${link.idea_a_draft}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${link.idea_a_draft}</a>
</div>
<div class="bg-slate-900/50 rounded p-3 border border-slate-700/30">
<div class="text-slate-300 font-medium mb-1">${link.idea_b}</div>
<a href="/drafts/${link.idea_b_draft}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${link.idea_b_draft}</a>
</div>
</div>
<p class="text-slate-500 text-[10px] mt-1">These two ideas from different clusters have the strongest cross-cluster similarity.</p>
`;
detail.classList.remove('hidden');
}
});
}
// --- Cluster Cards ---
const grid = document.getElementById('clusterGrid');
@@ -190,15 +333,42 @@ if (data.empty) {
if (filter === 'large' && cluster.size < 10) return;
const color = PALETTE[i % PALETTE.length];
const topIdeas = cluster.ideas.slice(0, 5);
const ideaListHtml = topIdeas.map(idea =>
`<li class="text-xs text-slate-400 truncate" title="${idea.description || idea.title}">
<span class="text-slate-300">${idea.title}</span>
</li>`
).join('');
const extraCount = cluster.size - topIdeas.length;
const extraHtml = extraCount > 0
? `<li class="text-xs text-slate-600">+${extraCount} more</li>` : '';
const cardId = `cluster-${i}`;
const topIdeas = cluster.ideas.slice(0, 3);
// Deduplicate ideas by title, track which drafts have each
const ideaByTitle = {};
cluster.ideas.forEach(idea => {
if (!ideaByTitle[idea.title]) {
ideaByTitle[idea.title] = { ...idea, drafts: [] };
}
ideaByTitle[idea.title].drafts.push(idea.draft_name);
});
const uniqueIdeas = Object.values(ideaByTitle);
// Preview: first 3 unique ideas
const previewHtml = uniqueIdeas.slice(0, 3).map(idea => {
const draftTag = idea.drafts.length > 1
? `<span class="text-slate-600">(${idea.drafts.length} drafts)</span>`
: `<span class="text-slate-600">${idea.drafts[0].replace('draft-', '').substring(0, 20)}</span>`;
return `<li class="text-xs text-slate-400 truncate" title="${idea.description || idea.title}">
<span class="text-slate-300">${idea.title}</span> ${draftTag}
</li>`;
}).join('');
const previewExtra = uniqueIdeas.length > 3
? `<li class="text-xs text-slate-600">+${uniqueIdeas.length - 3} more unique ideas</li>` : '';
// Full idea list (shown on expand)
const fullIdeasHtml = uniqueIdeas.map(idea => {
const draftLinks = idea.drafts.map(d =>
`<a href="/drafts/${d}" class="text-blue-400/70 hover:text-blue-300 transition">${d.replace('draft-', '').substring(0, 28)}</a>`
).join(', ');
return `<div class="py-2 border-b border-slate-800/50 last:border-0">
<div class="text-xs text-slate-200 font-medium">${idea.title}</div>
${idea.description ? `<div class="text-xs text-slate-500 mt-0.5 leading-relaxed">${idea.description.substring(0, 200)}</div>` : ''}
<div class="text-[10px] text-slate-600 mt-1 font-mono">${draftLinks}</div>
</div>`;
}).join('');
// WG badges
const wgBadges = (cluster.wgs || []).filter(w => w.wg !== 'none').map(w =>
@@ -224,22 +394,39 @@ if (data.empty) {
? `<span class="text-xs bg-amber-900/30 text-amber-400 px-1.5 py-0.5 rounded">cross-WG</span>` : '';
const card = document.createElement('div');
card.className = 'bg-slate-900 rounded-xl border p-5 ' +
card.className = 'bg-slate-900 rounded-xl border p-5 cursor-pointer hover:border-slate-600 transition ' +
(cluster.cross_wg ? 'border-amber-800/40' : 'border-slate-800');
card.onclick = () => {
const detail = document.getElementById(cardId);
const chevron = document.getElementById(`chevron-${i}`);
if (detail.classList.contains('hidden')) {
detail.classList.remove('hidden');
chevron.style.transform = 'rotate(180deg)';
} else {
detail.classList.add('hidden');
chevron.style.transform = '';
}
};
card.innerHTML = `
<div class="flex items-center gap-2 mb-3">
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background: ${color}"></div>
<h3 class="text-sm font-semibold text-white truncate">${cluster.theme}</h3>
${crossBadge}
<span class="ml-auto text-xs text-slate-500 flex-shrink-0">${cluster.size} ideas</span>
<svg id="chevron-${i}" class="w-4 h-4 text-slate-500 flex-shrink-0 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<ul class="space-y-1 mb-3">${ideaListHtml}${extraHtml}</ul>
<ul class="space-y-1 mb-3">${previewHtml}${previewExtra}</ul>
${(wgBadges || noneHtml) ? `<div class="mb-2"><p class="text-xs text-slate-500 mb-1">Working Groups</p><div class="flex flex-wrap gap-1">${wgBadges} ${noneHtml}</div></div>` : ''}
${catBadges ? `<div class="mb-2"><p class="text-xs text-slate-500 mb-1">Categories</p><div class="flex flex-wrap gap-1">${catBadges}</div></div>` : ''}
<div class="border-t border-slate-800 pt-3">
<p class="text-xs text-slate-500 mb-1">${cluster.drafts.length} source draft${cluster.drafts.length !== 1 ? 's' : ''}</p>
<div class="flex flex-wrap gap-1">${draftBadges}${extraDrafts}</div>
</div>
<!-- Expanded detail (hidden by default) -->
<div id="${cardId}" class="hidden mt-4 border-t border-slate-700 pt-4">
<h4 class="text-xs font-semibold text-slate-300 mb-2 uppercase tracking-wide">All ${uniqueIdeas.length} unique ideas</h4>
<div class="max-h-80 overflow-y-auto pr-1">${fullIdeasHtml}</div>
</div>
`;
grid.appendChild(card);
});

View File

@@ -185,8 +185,13 @@ document.getElementById('scatter').on('plotly_click', function(data) {
return 'score-low';
}
function dimBadge(val) {
const cls = val >= 4 ? 'text-green-400' : val >= 3 ? 'text-yellow-400' : 'text-slate-500';
function dimBadge(val, inverted = false) {
let cls;
if (inverted) {
cls = val <= 2 ? 'text-green-400' : val <= 3 ? 'text-yellow-400' : 'text-red-400';
} else {
cls = val >= 4 ? 'text-green-400' : val >= 3 ? 'text-yellow-400' : 'text-slate-500';
}
return `<span class="${cls}">${val}</span>`;
}
@@ -207,7 +212,7 @@ document.getElementById('scatter').on('plotly_click', function(data) {
<td class="px-4 py-3 text-center">${dimBadge(d.maturity)}</td>
<td class="px-4 py-3 text-center">${dimBadge(d.relevance)}</td>
<td class="px-4 py-3 text-center">${dimBadge(d.momentum)}</td>
<td class="px-4 py-3 text-center">${dimBadge(d.overlap)}</td>
<td class="px-4 py-3 text-center">${dimBadge(d.overlap, true)}</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${d.category}</span>
</td>