| {% extends "base.html" %} |
|
|
| {% block title %}Expert Discovery | CPI Engine{% endblock %} |
|
|
| {% block content %} |
| <div class="min-h-screen bg-slate-50 p-8"> |
| <div class="max-w-[1800px] mx-auto"> |
| |
| <div class="mb-8"> |
| <h1 class="text-4xl font-black text-slate-900 mb-2 uppercase italic flex items-center"> |
| <i class="fas fa-users-cog text-indigo-600 mr-4"></i>Expert Discovery Engine |
| </h1> |
| <p class="text-slate-500 font-medium mb-2">A programmable scientific truth engine that ranks humans, not papers</p> |
| <p class="text-indigo-600 text-sm italic font-semibold">"Who demonstrably knows how to solve this problem?"</p> |
|
|
| |
| <form action="/discovery" method="GET" class="relative group mt-6"> |
| <input type="text" name="q" value="{{ query }}" |
| placeholder="Enter research topic (e.g., 'T cell exhaustion in CAR-T therapy')..." |
| class="w-full bg-white border-2 border-slate-200 rounded-2xl px-8 py-5 text-lg shadow-sm focus:border-indigo-500 focus:ring-4 focus:ring-indigo-50/50 transition-all outline-none pr-48"> |
| <button type="submit" class="absolute right-4 top-3 bottom-3 bg-indigo-600 text-white px-10 rounded-xl font-bold hover:bg-indigo-700 hover:shadow-lg transition-all active:scale-95"> |
| Discover Experts |
| </button> |
| </form> |
|
|
| |
| <div class="mt-4 flex items-center gap-6 text-[10px] font-bold text-slate-400 uppercase tracking-widest"> |
| <div class="flex items-center gap-2"> |
| <span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> |
| OpenAlex Knowledge Graph |
| </div> |
| <div class="flex items-center gap-2"> |
| <i class="fas fa-brain text-indigo-400"></i> |
| CPI Algorithm |
| </div> |
| {% if metadata and metadata.total_works %} |
| <div class="flex items-center gap-2"> |
| <i class="fas fa-database text-emerald-400"></i> |
| {{ metadata.total_works }} Papers • {{ metadata.topics_discovered }} Topics |
| </div> |
| {% endif %} |
| </div> |
| </div> |
|
|
| |
| {% if error_message %} |
| <div class="bg-red-50 border-2 border-red-200 rounded-2xl p-6 mb-8"> |
| <div class="flex items-center gap-3"> |
| <i class="fas fa-exclamation-triangle text-red-500 text-xl"></i> |
| <p class="text-red-700 font-medium">{{ error_message }}</p> |
| </div> |
| </div> |
| {% endif %} |
|
|
| {% if experts %} |
| |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-6"> |
|
|
| |
| <div class="lg:col-span-5 space-y-4"> |
| <div class="flex items-center justify-between mb-4"> |
| <h2 class="text-xs font-black text-slate-400 uppercase tracking-widest">Ranked Experts (CPI Score)</h2> |
| <span class="text-[10px] font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full">Showing Top 50 of {{ all_experts|length if all_experts else experts|length }}</span> |
| </div> |
|
|
| <div class="space-y-3 max-h-[1200px] overflow-y-auto pr-2"> |
| {% for expert in experts %} |
| <div class="bg-white rounded-xl border border-slate-200 p-5 hover:border-indigo-400 hover:shadow-md transition-all cursor-pointer group" |
| onclick="showExpertModal('{{ expert.openalex_id }}')">> |
|
|
| <div class="flex items-start justify-between mb-3"> |
| <div class="flex-1"> |
| <div class="flex items-center gap-3 mb-1"> |
| <span class="text-lg font-black text-slate-900">{{ loop.index }}</span> |
| <h3 class="font-bold text-slate-900 group-hover:text-indigo-600 transition-colors"> |
| {{ expert.name }} |
| </h3> |
| </div> |
| {% if expert.institutions %} |
| <p class="text-[10px] text-slate-500 italic">{{ expert.institutions[0] }}</p> |
| {% endif %} |
| </div> |
| <div class="text-right"> |
| <div class="text-2xl font-black text-indigo-600">{{ expert.cpi_score }}</div> |
| <div class="text-[8px] text-slate-400 font-bold uppercase">CPI</div> |
| </div> |
| </div> |
|
|
| |
| <div class="grid grid-cols-3 gap-2 text-center mb-3"> |
| <div class="bg-slate-50 rounded-lg py-2"> |
| <div class="text-sm font-black text-slate-900">{{ expert.h_index }}</div> |
| <div class="text-[8px] text-slate-400 font-bold uppercase">h-index</div> |
| </div> |
| <div class="bg-slate-50 rounded-lg py-2"> |
| <div class="text-sm font-black text-slate-900">{{ expert.recent_papers }}</div> |
| <div class="text-[8px] text-slate-400 font-bold uppercase">Recent</div> |
| </div> |
| <div class="bg-slate-50 rounded-lg py-2"> |
| <div class="text-sm font-black text-slate-900">{{ expert.citation_velocity }}%</div> |
| <div class="text-[8px] text-slate-400 font-bold uppercase">Velocity</div> |
| </div> |
| </div> |
|
|
| |
| <div class="grid grid-cols-3 gap-1"> |
| <div> |
| <div class="h-1 bg-slate-100 rounded-full overflow-hidden"> |
| <div class="h-full bg-indigo-500" style="width: {{ expert.contribution_score }}%"></div> |
| </div> |
| <div class="text-[7px] text-slate-400 font-bold mt-1">C: {{ expert.contribution_score }}%</div> |
| </div> |
| <div> |
| <div class="h-1 bg-slate-100 rounded-full overflow-hidden"> |
| <div class="h-full bg-emerald-500" style="width: {{ expert.proof_score }}%"></div> |
| </div> |
| <div class="text-[7px] text-slate-400 font-bold mt-1">P: {{ expert.proof_score }}%</div> |
| </div> |
| <div> |
| <div class="h-1 bg-slate-100 rounded-full overflow-hidden"> |
| <div class="h-full bg-amber-500" style="width: {{ expert.impact_score }}%"></div> |
| </div> |
| <div class="text-[7px] text-slate-400 font-bold mt-1">I: {{ expert.impact_score }}%</div> |
| </div> |
| </div> |
| </div> |
| {% endfor %} |
| </div> |
| </div> |
|
|
| |
| <div class="lg:col-span-7"> |
| <div class="bg-white rounded-2xl border border-slate-200 p-6 sticky top-8"> |
| <div class="flex items-center justify-between mb-4"> |
| <div> |
| <h2 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">Field Topology</h2> |
| <p class="text-[10px] text-slate-500 font-medium">Interactive network showing expert positioning</p> |
| </div> |
| <div class="flex gap-2 text-[9px] font-bold"> |
| <div class="flex items-center gap-1"> |
| <div class="w-3 h-3 rounded-full bg-indigo-600"></div> |
| <span class="text-slate-500">Query</span> |
| </div> |
| <div class="flex items-center gap-1"> |
| <div class="w-3 h-3 rounded-full bg-emerald-500"></div> |
| <span class="text-slate-500">Topics</span> |
| </div> |
| <div class="flex items-center gap-1"> |
| <div class="w-3 h-3 rounded-full bg-blue-400"></div> |
| <span class="text-slate-500">Experts</span> |
| </div> |
| </div> |
| </div> |
|
|
| <div id="graph-container" class="w-full h-[900px] bg-slate-50 rounded-xl relative"> |
| |
| <div id="tour-controls" class="absolute top-4 left-4 z-10 flex gap-2" style="display: none;"> |
| <button onclick="previousExpert()" class="bg-white border-2 border-slate-200 px-4 py-2 rounded-lg font-bold text-slate-700 hover:bg-slate-50 transition-all shadow-sm"> |
| <i class="fas fa-chevron-left"></i> Previous |
| </button> |
| <button onclick="nextExpert()" class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-indigo-700 transition-all shadow-md"> |
| Next <i class="fas fa-chevron-right"></i> |
| </button> |
| <button onclick="toggleAutoTour()" id="auto-tour-btn" class="bg-emerald-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-emerald-700 transition-all shadow-md"> |
| <i class="fas fa-play"></i> Auto-Tour |
| </button> |
| <button onclick="stopTour()" class="bg-slate-200 text-slate-700 px-4 py-2 rounded-lg font-bold hover:bg-slate-300 transition-all"> |
| <i class="fas fa-times"></i> Exit Tour |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="mt-4 flex items-center justify-between text-[10px]"> |
| <div class="text-slate-400 font-medium flex items-center gap-4"> |
| <span><i class="fas fa-mouse-pointer mr-1"></i> Click experts for details</span> |
| <span><i class="fas fa-filter mr-1"></i> Click topics to filter</span> |
| <span><i class="fas fa-hand-rock mr-1"></i> Drag to reposition</span> |
| </div> |
| <div class="flex gap-2"> |
| <button onclick="startTour()" class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-indigo-700 transition-colors"> |
| <i class="fas fa-route mr-1"></i> Start Guided Tour |
| </button> |
| <button onclick="resetGraph()" class="bg-slate-100 px-3 py-2 rounded-lg font-bold text-slate-600 hover:bg-slate-200 transition-colors"> |
| Reset View |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="bg-indigo-600 p-6 rounded-2xl text-white shadow-lg shadow-indigo-200 mt-6"> |
| <p class="text-xs font-bold opacity-80 mb-2 uppercase tracking-widest">Why This Visualization?</p> |
| <h5 class="text-lg font-black italic mb-3">Maps > Lists</h5> |
| <p class="text-xs leading-relaxed opacity-90 mb-4"> |
| Traditional search engines show flat lists. We show <strong>field topology</strong>—where experts sit in the research landscape, their topic clusters, and collaborative networks. |
| </p> |
| <div class="grid grid-cols-2 gap-4 text-xs"> |
| <div> |
| <p class="font-bold mb-1">Node Size</p> |
| <p class="opacity-80">Scaled by CPI score</p> |
| </div> |
| <div> |
| <p class="font-bold mb-1">Connections</p> |
| <p class="opacity-80">Co-authorship + topics</p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| {% elif query %} |
| |
| <div class="bg-white border-2 border-dashed border-slate-200 rounded-[3rem] py-32 text-center"> |
| <div class="max-w-xs mx-auto"> |
| <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300"> |
| <i class="fas fa-search text-2xl"></i> |
| </div> |
| <h3 class="text-xl font-bold text-slate-800 mb-2">No experts found</h3> |
| <p class="text-slate-400 text-sm">No results for "{{ query }}". Try a different research topic.</p> |
| </div> |
| </div> |
|
|
| {% else %} |
| |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> |
| <div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all cursor-pointer group"> |
| <div class="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center mb-4 group-hover:bg-indigo-600 group-hover:text-white transition-colors"> |
| <i class="fas fa-project-diagram text-xl"></i> |
| </div> |
| <h4 class="font-bold text-slate-800 mb-2">Field Topology</h4> |
| <p class="text-sm text-slate-500">See where experts sit in the research landscape—not just who they are.</p> |
| </div> |
|
|
| <div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all cursor-pointer group"> |
| <div class="w-12 h-12 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center mb-4 group-hover:bg-emerald-600 group-hover:text-white transition-colors"> |
| <i class="fas fa-microscope text-xl"></i> |
| </div> |
| <h4 class="font-bold text-slate-800 mb-2">Topic Clustering</h4> |
| <p class="text-sm text-slate-500">Discover which subfields connect experts and reveal collaboration patterns.</p> |
| </div> |
|
|
| <div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all cursor-pointer group"> |
| <div class="w-12 h-12 bg-amber-50 text-amber-600 rounded-xl flex items-center justify-center mb-4 group-hover:bg-amber-600 group-hover:text-white transition-colors"> |
| <i class="fas fa-chart-network text-xl"></i> |
| </div> |
| <h4 class="font-bold text-slate-800 mb-2">Co-Authorship Graph</h4> |
| <p class="text-sm text-slate-500">Navigate expertise through collaborative networks—beyond citations.</p> |
| </div> |
| </div> |
| {% endif %} |
| </div> |
| </div> |
|
|
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script> |
|
|
| <script> |
| {% if graph_data.nodes %} |
| // Graph Data from Backend |
| const graphData = {{ graph_data | tojson }}; |
| |
| // Dimensions |
| const container = document.getElementById('graph-container'); |
| const width = container.clientWidth; |
| const height = container.clientHeight; |
| |
| // Create SVG |
| const svg = d3.select('#graph-container') |
| .append('svg') |
| .attr('width', width) |
| .attr('height', height); |
| |
| const g = svg.append('g'); |
| |
| // Zoom behavior |
| const zoom = d3.zoom() |
| .scaleExtent([0.3, 3]) |
| .on('zoom', (event) => { |
| g.attr('transform', event.transform); |
| }); |
| |
| svg.call(zoom); |
| |
| // Force simulation with MUCH stronger repulsion |
| const simulation = d3.forceSimulation(graphData.nodes) |
| .force('link', d3.forceLink(graphData.links) |
| .id(d => d.id) |
| .distance(d => { |
| // Query to topics: far apart |
| if (d.source.type === 'query' || d.target.type === 'query') return 200; |
| // Topics to authors: medium distance |
| if (d.source.type === 'topic' || d.target.type === 'topic') return 150; |
| // Co-authors: close together |
| return 80; |
| }) |
| .strength(0.3)) |
| .force('charge', d3.forceManyBody() |
| .strength(d => { |
| // Query: strong repulsion |
| if (d.type === 'query') return -3000; |
| // Topics: medium repulsion |
| if (d.type === 'topic') return -1500; |
| // Authors: lighter repulsion |
| return -800; |
| })) |
| .force('center', d3.forceCenter(width / 2, height / 2)) |
| .force('collision', d3.forceCollide().radius(d => d.size + 15)) |
| .force('x', d3.forceX(width / 2).strength(0.05)) |
| .force('y', d3.forceY(height / 2).strength(0.05)); |
| |
| // Draw links |
| const link = g.append('g') |
| .selectAll('line') |
| .data(graphData.links) |
| .join('line') |
| .attr('stroke', d => d.type === 'coauthor' ? '#cbd5e1' : '#e2e8f0') |
| .attr('stroke-width', d => d.type === 'coauthor' ? 2 : 1) |
| .attr('stroke-opacity', 0.6); |
| |
| // Draw nodes |
| const node = g.append('g') |
| .selectAll('circle') |
| .data(graphData.nodes) |
| .join('circle') |
| .attr('r', d => d.size) |
| .attr('fill', d => d.color) |
| .attr('stroke', '#fff') |
| .attr('stroke-width', 2) |
| .attr('id', d => `node-${d.id}`) |
| .style('cursor', 'pointer') |
| .call(drag(simulation)) |
| .on('click', (event, d) => { |
| event.stopPropagation(); |
| if (d.type === 'author') { |
| showExpertModal(d.id); |
| } else if (d.type === 'topic') { |
| filterByTopic(d.name); |
| } |
| }) |
| .on('mouseover', function(event, d) { |
| d3.select(this) |
| .transition() |
| .duration(200) |
| .attr('r', d.size * 1.3) |
| .attr('stroke-width', 4); |
| |
| tooltip.style('display', 'block') |
| .html(getTooltipContent(d)) |
| .style('left', (event.pageX + 10) + 'px') |
| .style('top', (event.pageY - 10) + 'px'); |
| }) |
| .on('mouseout', function(event, d) { |
| d3.select(this) |
| .transition() |
| .duration(200) |
| .attr('r', d.size) |
| .attr('stroke-width', 2); |
| |
| tooltip.style('display', 'none'); |
| }); |
| |
| // Labels for ALL nodes with smart positioning |
| const label = g.append('g') |
| .selectAll('text') |
| .data(graphData.nodes) |
| .join('text') |
| .text(d => { |
| if (d.type === 'author') { |
| // Show rank + name for authors |
| return `#${d.rank} ${d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name}`; |
| } |
| return d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name; |
| }) |
| .attr('font-size', d => { |
| if (d.type === 'query') return 16; |
| if (d.type === 'topic') return 12; |
| return d.rank <= 10 ? 10 : 9; |
| }) |
| .attr('font-weight', d => d.type === 'query' ? 'bold' : d.rank <= 10 ? '600' : 'normal') |
| .attr('fill', d => { |
| if (d.type === 'query') return '#0f172a'; |
| if (d.type === 'topic') return '#059669'; |
| return d.rank <= 10 ? '#1e293b' : '#475569'; |
| }) |
| .attr('text-anchor', 'middle') |
| .attr('dy', d => d.size + 18) |
| .style('pointer-events', 'none') |
| .style('user-select', 'none'); |
| |
| // Tooltip |
| const tooltip = d3.select('body').append('div') |
| .attr('class', 'graph-tooltip') |
| .style('position', 'absolute') |
| .style('display', 'none') |
| .style('background', 'white') |
| .style('border', '2px solid #e2e8f0') |
| .style('border-radius', '8px') |
| .style('padding', '10px') |
| .style('font-size', '12px') |
| .style('box-shadow', '0 4px 12px rgba(0,0,0,0.1)') |
| .style('z-index', '1000') |
| .style('pointer-events', 'none'); |
| |
| function getTooltipContent(d) { |
| if (d.type === 'query') return `<strong>Query:</strong> ${d.name}`; |
| if (d.type === 'topic') return `<strong>Topic:</strong> ${d.name}`; |
| if (d.type === 'author') { |
| return ` |
| <strong>${d.name}</strong><br> |
| <span style="color: #6366f1;">Rank: #${d.rank}</span><br> |
| CPI: ${d.cpi}<br> |
| Papers: ${d.papers} | Citations: ${d.citations} |
| `; |
| } |
| } |
| |
| // Update positions on tick |
| simulation.on('tick', () => { |
| link |
| .attr('x1', d => d.source.x) |
| .attr('y1', d => d.source.y) |
| .attr('x2', d => d.target.x) |
| .attr('y2', d => d.target.y); |
| |
| node |
| .attr('cx', d => d.x) |
| .attr('cy', d => d.y); |
| |
| label |
| .attr('x', d => d.x) |
| .attr('y', d => d.y); |
| }); |
| |
| // Drag behavior |
| function drag(simulation) { |
| function dragstarted(event, d) { |
| if (!event.active) simulation.alphaTarget(0.3).restart(); |
| d.fx = d.x; |
| d.fy = d.y; |
| } |
| |
| function dragged(event, d) { |
| d.fx = event.x; |
| d.fy = event.y; |
| } |
| |
| function dragended(event, d) { |
| if (!event.active) simulation.alphaTarget(0); |
| d.fx = null; |
| d.fy = null; |
| } |
| |
| return d3.drag() |
| .on('start', dragstarted) |
| .on('drag', dragged) |
| .on('end', dragended); |
| } |
| |
| // Highlight expert in both table and graph |
| window.highlightExpert = function(expertId) { |
| // Reset all nodes |
| node.attr('stroke', '#fff').attr('stroke-width', 2); |
| |
| // Highlight selected node |
| d3.select(`#node-${expertId}`) |
| .attr('stroke', '#f59e0b') |
| .attr('stroke-width', 5) |
| .raise(); |
| |
| // Scroll to expert in table |
| const expertCard = document.querySelector(`[onclick="highlightExpert('${expertId}')"]`); |
| if (expertCard) { |
| expertCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| expertCard.style.borderColor = '#f59e0b'; |
| setTimeout(() => { |
| expertCard.style.borderColor = ''; |
| }, 2000); |
| } |
| }; |
| |
| // Reset graph view |
| window.resetGraph = function() { |
| svg.transition().duration(750).call( |
| zoom.transform, |
| d3.zoomIdentity |
| ); |
| node.attr('stroke', '#fff').attr('stroke-width', 2); |
| }; |
| |
| // Show detailed expert modal |
| window.showExpertModal = function(expertId) { |
| // Find expert in data |
| const expert = graphData.nodes.find(n => n.id === expertId); |
| if (!expert) return; |
| |
| // Find expert in full list |
| const experts = {{ experts | tojson }}; |
| const expertData = experts.find(e => e.openalex_id === expertId); |
| |
| if (!expertData) { |
| // Fallback: just highlight |
| highlightExpert(expertId); |
| return; |
| } |
| |
| // Create modal |
| const modal = document.createElement('div'); |
| modal.id = 'expert-modal'; |
| modal.style.cssText = ` |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(0, 0, 0, 0.7); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 9999; |
| backdrop-filter: blur(4px); |
| `; |
| |
| modal.innerHTML = ` |
| <div style=" |
| background: white; |
| border-radius: 24px; |
| max-width: 700px; |
| width: 90%; |
| max-height: 80vh; |
| overflow-y: auto; |
| padding: 40px; |
| position: relative; |
| box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); |
| "> |
| <button onclick="closeExpertModal()" style=" |
| position: absolute; |
| top: 20px; |
| right: 20px; |
| background: #f1f5f9; |
| border: none; |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| cursor: pointer; |
| font-size: 20px; |
| color: #64748b; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: all 0.2s; |
| " onmouseover="this.style.background='#e2e8f0'" onmouseout="this.style.background='#f1f5f9'"> |
| × |
| </button> |
| |
| <h2 style=" |
| font-size: 28px; |
| font-weight: 900; |
| color: #0f172a; |
| margin-bottom: 8px; |
| ">${expertData.name}</h2> |
| |
| ${expertData.institutions && expertData.institutions.length > 0 ? ` |
| <p style=" |
| color: #64748b; |
| font-size: 14px; |
| font-style: italic; |
| margin-bottom: 24px; |
| ">${expertData.institutions.join(' • ')}</p> |
| ` : ''} |
| |
| <div style=" |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| margin-bottom: 32px; |
| padding: 20px; |
| background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); |
| border-radius: 16px; |
| "> |
| <div> |
| <div style=" |
| font-size: 48px; |
| font-weight: 900; |
| color: white; |
| ">${expertData.cpi_score}</div> |
| <div style=" |
| font-size: 11px; |
| font-weight: 700; |
| color: rgba(255, 255, 255, 0.8); |
| text-transform: uppercase; |
| letter-spacing: 1px; |
| ">CPI Score</div> |
| </div> |
| <div style="flex: 1; color: rgba(255, 255, 255, 0.9); font-size: 12px; line-height: 1.6;"> |
| Ranked <strong>#${expertData.cpi_score >= experts[0].cpi_score ? 1 : experts.findIndex(e => e.openalex_id === expertId) + 1}</strong> |
| in this field based on contribution, proof of work, and impact. |
| </div> |
| </div> |
| |
| <div style=" |
| display: grid; |
| grid-template-columns: repeat(3, 1fr); |
| gap: 16px; |
| margin-bottom: 32px; |
| "> |
| <div style=" |
| background: #f8fafc; |
| padding: 16px; |
| border-radius: 12px; |
| text-align: center; |
| "> |
| <div style=" |
| font-size: 32px; |
| font-weight: 900; |
| color: #0f172a; |
| ">${expertData.h_index}</div> |
| <div style=" |
| font-size: 10px; |
| font-weight: 700; |
| color: #94a3b8; |
| text-transform: uppercase; |
| margin-top: 4px; |
| ">h-index</div> |
| </div> |
| <div style=" |
| background: #f8fafc; |
| padding: 16px; |
| border-radius: 12px; |
| text-align: center; |
| "> |
| <div style=" |
| font-size: 32px; |
| font-weight: 900; |
| color: #0f172a; |
| ">${expertData.total_citations}</div> |
| <div style=" |
| font-size: 10px; |
| font-weight: 700; |
| color: #94a3b8; |
| text-transform: uppercase; |
| margin-top: 4px; |
| ">Citations</div> |
| </div> |
| <div style=" |
| background: #f8fafc; |
| padding: 16px; |
| border-radius: 12px; |
| text-align: center; |
| "> |
| <div style=" |
| font-size: 32px; |
| font-weight: 900; |
| color: #0f172a; |
| ">${expertData.total_papers}</div> |
| <div style=" |
| font-size: 10px; |
| font-weight: 700; |
| color: #94a3b8; |
| text-transform: uppercase; |
| margin-top: 4px; |
| ">Papers</div> |
| </div> |
| </div> |
| |
| <div style="margin-bottom: 24px;"> |
| <h3 style=" |
| font-size: 12px; |
| font-weight: 900; |
| color: #94a3b8; |
| text-transform: uppercase; |
| letter-spacing: 1.5px; |
| margin-bottom: 16px; |
| ">CPI Breakdown</h3> |
| |
| <div style="margin-bottom: 12px;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
| <span style="font-size: 12px; font-weight: 600; color: #64748b;">Contribution</span> |
| <span style="font-size: 12px; font-weight: 900; color: #6366f1;">${expertData.contribution_score}%</span> |
| </div> |
| <div style="height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden;"> |
| <div style=" |
| height: 100%; |
| width: ${expertData.contribution_score}%; |
| background: linear-gradient(90deg, #6366f1, #8b5cf6); |
| transition: width 0.5s; |
| "></div> |
| </div> |
| <p style="font-size: 11px; color: #94a3b8; margin-top: 4px;">Recent activity: ${expertData.recent_papers} papers since 2020</p> |
| </div> |
| |
| <div style="margin-bottom: 12px;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
| <span style="font-size: 12px; font-weight: 600; color: #64748b;">Proof</span> |
| <span style="font-size: 12px; font-weight: 900; color: #10b981;">${expertData.proof_score}%</span> |
| </div> |
| <div style="height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden;"> |
| <div style=" |
| height: 100%; |
| width: ${expertData.proof_score}%; |
| background: linear-gradient(90deg, #10b981, #059669); |
| transition: width 0.5s; |
| "></div> |
| </div> |
| <p style="font-size: 11px; color: #94a3b8; margin-top: 4px;">Depth in this topic</p> |
| </div> |
| |
| <div style="margin-bottom: 12px;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
| <span style="font-size: 12px; font-weight: 600; color: #64748b;">Impact</span> |
| <span style="font-size: 12px; font-weight: 900; color: #f59e0b;">${expertData.impact_score}%</span> |
| </div> |
| <div style="height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden;"> |
| <div style=" |
| height: 100%; |
| width: ${expertData.impact_score}%; |
| background: linear-gradient(90deg, #f59e0b, #d97706); |
| transition: width 0.5s; |
| "></div> |
| </div> |
| <p style="font-size: 11px; color: #94a3b8; margin-top: 4px;">Citation velocity: ${expertData.citation_velocity}%</p> |
| </div> |
| </div> |
| |
| ${expertData.topics && expertData.topics.length > 0 ? ` |
| <div style="margin-bottom: 24px;"> |
| <h3 style=" |
| font-size: 12px; |
| font-weight: 900; |
| color: #94a3b8; |
| text-transform: uppercase; |
| letter-spacing: 1.5px; |
| margin-bottom: 12px; |
| ">Research Topics</h3> |
| <div style="display: flex; flex-wrap: wrap; gap: 8px;"> |
| ${expertData.topics.map(topic => ` |
| <span style=" |
| background: #f1f5f9; |
| color: #475569; |
| padding: 6px 12px; |
| border-radius: 8px; |
| font-size: 11px; |
| font-weight: 600; |
| ">${topic}</span> |
| `).join('')} |
| </div> |
| </div> |
| ` : ''} |
| |
| ${expertData.recent_paper_titles && expertData.recent_paper_titles.length > 0 ? ` |
| <div style="margin-bottom: 24px;"> |
| <h3 style=" |
| font-size: 12px; |
| font-weight: 900; |
| color: #94a3b8; |
| text-transform: uppercase; |
| letter-spacing: 1.5px; |
| margin-bottom: 12px; |
| ">Recent Publications</h3> |
| <div style="space-y: 8px;"> |
| ${expertData.recent_paper_titles.slice(0, 3).map((title, idx) => ` |
| <div style=" |
| padding: 12px; |
| background: #f8fafc; |
| border-radius: 8px; |
| margin-bottom: 8px; |
| "> |
| <div style=" |
| font-size: 10px; |
| font-weight: 700; |
| color: #6366f1; |
| margin-bottom: 4px; |
| ">Paper ${idx + 1}</div> |
| <div style=" |
| font-size: 12px; |
| color: #334155; |
| line-height: 1.5; |
| ">${title}</div> |
| </div> |
| `).join('')} |
| </div> |
| </div> |
| ` : ''} |
| |
| |
| <button onclick="showCalculationModal('${expertId}')" style=" |
| width: 100%; |
| background: #f1f5f9; |
| border: 2px solid #e2e8f0; |
| color: #475569; |
| padding: 12px; |
| border-radius: 12px; |
| font-weight: 700; |
| font-size: 12px; |
| cursor: pointer; |
| transition: all 0.2s; |
| margin-bottom: 24px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| " onmouseover="this.style.background='#e2e8f0'" onmouseout="this.style.background='#f1f5f9'"> |
| <i class="fas fa-calculator"></i> |
| How is this CPI score calculated? |
| </button> |
| |
| <div style=" |
| display: flex; |
| gap: 12px; |
| margin-top: 32px; |
| padding-top: 24px; |
| border-top: 2px solid #f1f5f9; |
| "> |
| <a href="${expertData.openalex_id}" target="_blank" style=" |
| flex: 1; |
| background: #6366f1; |
| color: white; |
| padding: 14px 24px; |
| border-radius: 12px; |
| text-align: center; |
| text-decoration: none; |
| font-weight: 700; |
| font-size: 13px; |
| transition: all 0.2s; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| " onmouseover="this.style.background='#4f46e5'" onmouseout="this.style.background='#6366f1'"> |
| <i class="fas fa-external-link-alt"></i> |
| View OpenAlex Profile |
| </a> |
| |
| ${expertData.orcid ? ` |
| <a href="${expertData.orcid}" target="_blank" style=" |
| flex: 1; |
| background: #f1f5f9; |
| color: #475569; |
| padding: 14px 24px; |
| border-radius: 12px; |
| text-align: center; |
| text-decoration: none; |
| font-weight: 700; |
| font-size: 13px; |
| transition: all 0.2s; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| " onmouseover="this.style.background='#e2e8f0'" onmouseout="this.style.background='#f1f5f9'"> |
| <i class="fas fa-id-card"></i> |
| ORCID |
| </a> |
| ` : ''} |
| </div> |
| </div> |
| `; |
| |
| document.body.appendChild(modal); |
| |
| // Close on background click |
| modal.addEventListener('click', (e) => { |
| if (e.target === modal) { |
| closeExpertModal(); |
| } |
| }); |
| |
| // Highlight in graph |
| highlightExpert(expertId); |
| }; |
| |
| window.closeExpertModal = function() { |
| const modal = document.getElementById('expert-modal'); |
| if (modal) { |
| modal.remove(); |
| } |
| }; |
| |
| // Filter graph by topic |
| window.filterByTopic = function(topicName) { |
| // Fade out non-matching nodes |
| node.style('opacity', d => { |
| if (d.type === 'query') return 1; |
| if (d.type === 'topic' && d.name === topicName) return 1; |
| if (d.type === 'author') { |
| // Check if this author has this topic |
| const experts = {{ experts | tojson }}; |
| const expert = experts.find(e => e.openalex_id === d.id); |
| if (expert && expert.topics && expert.topics.includes(topicName)) { |
| return 1; |
| } |
| } |
| return 0.15; |
| }); |
| |
| link.style('opacity', d => { |
| const sourceMatch = d.source.type === 'topic' && d.source.name === topicName; |
| const targetMatch = d.target.type === 'topic' && d.target.name === topicName; |
| return (sourceMatch || targetMatch) ? 0.6 : 0.05; |
| }); |
| |
| // Show reset button notification |
| const notification = document.createElement('div'); |
| notification.style.cssText = ` |
| position: fixed; |
| top: 20px; |
| right: 20px; |
| background: #6366f1; |
| color: white; |
| padding: 12px 20px; |
| border-radius: 12px; |
| font-size: 13px; |
| font-weight: 700; |
| z-index: 1000; |
| box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); |
| `; |
| notification.innerHTML = `Filtered by: ${topicName} <button onclick="resetFilter()" style=" |
| background: rgba(255,255,255,0.2); |
| border: none; |
| color: white; |
| padding: 4px 12px; |
| border-radius: 6px; |
| margin-left: 8px; |
| cursor: pointer; |
| font-weight: 700; |
| font-size: 11px; |
| ">Reset</button>`; |
| document.body.appendChild(notification); |
| |
| setTimeout(() => notification.remove(), 5000); |
| }; |
| |
| window.resetFilter = function() { |
| node.style('opacity', 1); |
| link.style('opacity', 0.6); |
| document.querySelectorAll('[style*="position: fixed"]').forEach(el => { |
| if (el.textContent.includes('Filtered by')) el.remove(); |
| }); |
| }; |
| |
| // Calculation Modal - Shows how CPI is computed |
| window.showCalculationModal = function(expertId) { |
| const experts = {{ experts | tojson }}; |
| const expert = experts.find(e => e.openalex_id === expertId); |
| if (!expert || !expert.cpi_calculation) return; |
| |
| const calc = expert.cpi_calculation; |
| |
| const modal = document.createElement('div'); |
| modal.id = 'calculation-modal'; |
| modal.style.cssText = ` |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(0, 0, 0, 0.8); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 10000; |
| backdrop-filter: blur(4px); |
| `; |
| |
| modal.innerHTML = ` |
| <div style=" |
| background: white; |
| border-radius: 24px; |
| max-width: 800px; |
| width: 90%; |
| padding: 40px; |
| position: relative; |
| "> |
| <button onclick="this.closest('#calculation-modal').remove()" style=" |
| position: absolute; |
| top: 20px; |
| right: 20px; |
| background: #f1f5f9; |
| border: none; |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| cursor: pointer; |
| font-size: 20px; |
| ">×</button> |
| |
| <h2 style="font-size: 24px; font-weight: 900; color: #0f172a; margin-bottom: 8px;"> |
| <i class="fas fa-calculator text-indigo-600 mr-2"></i> |
| CPI Calculation Breakdown |
| </h2> |
| <p style="color: #64748b; font-size: 14px; margin-bottom: 32px;"> |
| For ${expert.name} • Final Score: <strong style="color: #6366f1;">${expert.cpi_score}</strong> |
| </p> |
| |
| |
| <div style=" |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); |
| padding: 24px; |
| border-radius: 16px; |
| margin-bottom: 32px; |
| color: white; |
| "> |
| <div style="font-size: 12px; opacity: 0.8; margin-bottom: 8px; font-weight: 600;">FORMULA</div> |
| <div style="font-size: 20px; font-weight: 900; font-family: monospace;"> |
| CPI = ${calc.formula} |
| </div> |
| </div> |
| |
| |
| <div style="margin-bottom: 24px; padding: 20px; background: #f8fafc; border-radius: 12px;"> |
| <div style="display: flex; justify-content: between; margin-bottom: 12px;"> |
| <h3 style="font-size: 16px; font-weight: 900; color: #6366f1;"> |
| Contribution (C) |
| </h3> |
| <span style="font-size: 24px; font-weight: 900; color: #6366f1;"> |
| ${expert.contribution_score}% |
| </span> |
| </div> |
| <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;"> |
| Measures recent activity relative to career output |
| </p> |
| <div style="background: white; padding: 12px; border-radius: 8px; font-size: 12px; color: #334155;"> |
| <strong>Calculation:</strong> Recent papers (${calc.recent_papers_used}) ÷ Total papers (${calc.total_papers_used})<br> |
| <strong>Result:</strong> ${(calc.contribution_raw * 100).toFixed(1)}% (capped at 100%)<br> |
| <strong>Weight:</strong> × ${calc.contribution_weight} (30% of final score) |
| </div> |
| </div> |
| |
| |
| <div style="margin-bottom: 24px; padding: 20px; background: #f8fafc; border-radius: 12px;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 12px;"> |
| <h3 style="font-size: 16px; font-weight: 900; color: #10b981;"> |
| Proof (P) |
| </h3> |
| <span style="font-size: 24px; font-weight: 900; color: #10b981;"> |
| ${expert.proof_score}% |
| </span> |
| </div> |
| <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;"> |
| Number of works demonstrating expertise in this topic |
| </p> |
| <div style="background: white; padding: 12px; border-radius: 8px; font-size: 12px; color: #334155;"> |
| <strong>Calculation:</strong> Total papers (${calc.total_papers_used}) ÷ 10 papers<br> |
| <strong>Result:</strong> ${(calc.proof_raw * 100).toFixed(1)}% (capped at 100%)<br> |
| <strong>Weight:</strong> × ${calc.proof_weight} (30% of final score) |
| </div> |
| </div> |
| |
| |
| <div style="margin-bottom: 24px; padding: 20px; background: #f8fafc; border-radius: 12px;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 12px;"> |
| <h3 style="font-size: 16px; font-weight: 900; color: #f59e0b;"> |
| Impact (I) |
| </h3> |
| <span style="font-size: 24px; font-weight: 900; color: #f59e0b;"> |
| ${expert.impact_score}% |
| </span> |
| </div> |
| <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;"> |
| Citation count with velocity multiplier (rising vs declining) |
| </p> |
| <div style="background: white; padding: 12px; border-radius: 8px; font-size: 12px; color: #334155;"> |
| <strong>Base:</strong> log₁₀(${calc.citations_used} + 1) ÷ 5<br> |
| <strong>Velocity Boost:</strong> × ${calc.velocity_multiplier.toFixed(2)} (based on ${expert.citation_velocity}% recent citations)<br> |
| <strong>Result:</strong> ${(calc.impact_raw * 100).toFixed(1)}%<br> |
| <strong>Weight:</strong> × ${calc.impact_weight} (40% of final score) |
| </div> |
| </div> |
| |
| |
| <div style=" |
| background: linear-gradient(135deg, #1e293b, #334155); |
| padding: 20px; |
| border-radius: 12px; |
| color: white; |
| "> |
| <div style="font-size: 14px; font-weight: 900; margin-bottom: 12px;">FINAL CALCULATION</div> |
| <div style="font-size: 16px; font-family: monospace; line-height: 1.8;"> |
| (${(calc.contribution_raw * 100).toFixed(1)} × 0.3) + |
| (${(calc.proof_raw * 100).toFixed(1)} × 0.3) + |
| (${(calc.impact_raw * 100).toFixed(1)} × 0.4) = |
| <strong style="color: #fbbf24; font-size: 24px;">${expert.cpi_score}</strong> |
| </div> |
| </div> |
| </div> |
| `; |
| |
| document.body.appendChild(modal); |
| modal.addEventListener('click', (e) => { |
| if (e.target === modal) modal.remove(); |
| }); |
| }; |
| |
| // Guided Tour System - Now uses ALL experts, not just top 20 |
| let tourState = { |
| active: false, |
| currentIndex: 0, |
| autoPlay: false, |
| intervalId: null |
| }; |
| |
| window.startTour = function() { |
| const experts = {{ all_experts | tojson }}; // Use full list |
| if (experts.length === 0) return; |
| |
| tourState.active = true; |
| tourState.currentIndex = 0; |
| |
| document.getElementById('tour-controls').style.display = 'flex'; |
| |
| showExpertBubble(experts[0].openalex_id); |
| zoomToExpert(experts[0].openalex_id); |
| }; |
| |
| window.nextExpert = function() { |
| const experts = {{ all_experts | tojson }}; |
| tourState.currentIndex = (tourState.currentIndex + 1) % experts.length; |
| |
| const expert = experts[tourState.currentIndex]; |
| showExpertBubble(expert.openalex_id); |
| zoomToExpert(expert.openalex_id); |
| }; |
| |
| window.previousExpert = function() { |
| const experts = {{ all_experts | tojson }}; |
| tourState.currentIndex = (tourState.currentIndex - 1 + experts.length) % experts.length; |
| |
| const expert = experts[tourState.currentIndex]; |
| showExpertBubble(expert.openalex_id); |
| zoomToExpert(expert.openalex_id); |
| }; |
| |
| window.toggleAutoTour = function() { |
| tourState.autoPlay = !tourState.autoPlay; |
| const btn = document.getElementById('auto-tour-btn'); |
| |
| if (tourState.autoPlay) { |
| btn.innerHTML = '<i class="fas fa-pause"></i> Pause'; |
| btn.style.background = '#dc2626'; |
| tourState.intervalId = setInterval(nextExpert, 3000); // 3 seconds per expert |
| } else { |
| btn.innerHTML = '<i class="fas fa-play"></i> Auto-Tour'; |
| btn.style.background = '#10b981'; |
| clearInterval(tourState.intervalId); |
| } |
| }; |
| |
| window.stopTour = function() { |
| tourState.active = false; |
| tourState.autoPlay = false; |
| clearInterval(tourState.intervalId); |
| |
| document.getElementById('tour-controls').style.display = 'none'; |
| |
| // Remove bubble |
| const bubble = document.getElementById('expert-bubble'); |
| if (bubble) bubble.remove(); |
| |
| // Reset view |
| resetGraph(); |
| }; |
| |
| // Show info bubble for expert |
| function showExpertBubble(expertId) { |
| // Remove existing bubble |
| const oldBubble = document.getElementById('expert-bubble'); |
| if (oldBubble) oldBubble.remove(); |
| |
| const experts = {{ all_experts | tojson }}; |
| const expert = experts.find(e => e.openalex_id === expertId); |
| if (!expert) return; |
| |
| const rank = experts.indexOf(expert) + 1; |
| |
| const bubble = document.createElement('div'); |
| bubble.id = 'expert-bubble'; |
| bubble.style.cssText = ` |
| position: absolute; |
| top: 80px; |
| left: 50%; |
| transform: translateX(-50%); |
| background: white; |
| border: 3px solid #6366f1; |
| border-radius: 20px; |
| padding: 20px 24px; |
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| z-index: 100; |
| min-width: 450px; |
| animation: bubbleFadeIn 0.3s ease-out; |
| `; |
| |
| bubble.innerHTML = ` |
| <div style="display: flex; align-items: start; gap: 16px;"> |
| <div style=" |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); |
| color: white; |
| width: 48px; |
| height: 48px; |
| border-radius: 12px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 20px; |
| font-weight: 900; |
| flex-shrink: 0; |
| ">#${rank}</div> |
| |
| <div style="flex: 1;"> |
| <h3 style="font-size: 18px; font-weight: 900; color: #0f172a; margin-bottom: 4px;"> |
| ${expert.name} |
| </h3> |
| <p style="font-size: 11px; color: #64748b; font-style: italic; margin-bottom: 12px;"> |
| ${expert.institutions && expert.institutions[0] ? expert.institutions[0] : 'Independent Researcher'} |
| </p> |
| |
| <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px;"> |
| <div> |
| <div style="font-size: 20px; font-weight: 900; color: #6366f1;">${expert.cpi_score}</div> |
| <div style="font-size: 8px; color: #94a3b8; font-weight: 700; text-transform: uppercase;">CPI</div> |
| </div> |
| <div> |
| <div style="font-size: 20px; font-weight: 900; color: #0f172a;">${expert.h_index}</div> |
| <div style="font-size: 8px; color: #94a3b8; font-weight: 700; text-transform: uppercase;">h-index</div> |
| </div> |
| <div> |
| <div style="font-size: 20px; font-weight: 900; color: #0f172a;">${expert.total_papers}</div> |
| <div style="font-size: 8px; color: #94a3b8; font-weight: 700; text-transform: uppercase;">Papers</div> |
| </div> |
| <div> |
| <div style="font-size: 20px; font-weight: 900; color: #10b981;">${expert.recent_papers}</div> |
| <div style="font-size: 8px; color: #94a3b8; font-weight: 700; text-transform: uppercase;">Recent</div> |
| </div> |
| </div> |
| |
| <div style="display: flex; gap: 8px;"> |
| <button onclick="showExpertModal('${expertId}')" style=" |
| background: #6366f1; |
| color: white; |
| border: none; |
| padding: 8px 16px; |
| border-radius: 8px; |
| font-size: 11px; |
| font-weight: 700; |
| cursor: pointer; |
| ">Full Profile</button> |
| <button onclick="showCalculationModal('${expertId}')" style=" |
| background: #f1f5f9; |
| color: #475569; |
| border: none; |
| padding: 8px 16px; |
| border-radius: 8px; |
| font-size: 11px; |
| font-weight: 700; |
| cursor: pointer; |
| "><i class="fas fa-calculator mr-1"></i>How CPI?</button> |
| </div> |
| </div> |
| </div> |
| `; |
| |
| document.getElementById('graph-container').appendChild(bubble); |
| } |
| |
| // Zoom to specific expert in graph with smooth animation |
| function zoomToExpert(expertId) { |
| const expertNode = graphData.nodes.find(n => n.id === expertId); |
| if (!expertNode || !expertNode.x || !expertNode.y) { |
| console.warn(`Node ${expertId} not found or not positioned yet`); |
| return; |
| } |
| |
| // Highlight the node |
| node.attr('stroke', '#fff').attr('stroke-width', 2); |
| d3.select(`#node-${CSS.escape(expertId)}`) |
| .attr('stroke', '#f59e0b') |
| .attr('stroke-width', 6) |
| .raise(); |
| |
| // Zoom to node with smooth transition |
| const scale = 2.0; |
| const x = width / 2 - expertNode.x * scale; |
| const y = height / 2 - expertNode.y * scale; |
| |
| svg.transition() |
| .duration(1000) |
| .ease(d3.easeCubicInOut) |
| .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)); |
| } |
| |
| // Add CSS animation for bubble |
| const style = document.createElement('style'); |
| style.textContent = ` |
| @keyframes bubbleFadeIn { |
| from { |
| opacity: 0; |
| transform: translateX(-50%) translateY(-20px); |
| } |
| to { |
| opacity: 1; |
| transform: translateX(-50%) translateY(0); |
| } |
| } |
| `; |
| document.head.appendChild(style); |
| {% endif %} |
| </script> |
| {% endblock %} |