import pandas as pd import plotly.express as px import plotly.graph_objects as go from dash import Dash, dcc, html, Input, Output, State, ALL, MATCH import numpy as np import random import math from collections import defaultdict import colorsys from fastapi import HTTPException from pydantic import BaseModel from dash import Dash import dash_bootstrap_components as dbc from fastapi import HTTPException, APIRouter, Request router = APIRouter() # MongoDB connection and data loader function async def load_data_from_mongodb(userId, topic, year, request: Request): query = { "userId": userId, "topic": topic, "year": year } collection = request.app.state.collection2 document = await collection.find_one(query) if not document: raise ValueError(f"No data found for userId={userId}, topic={topic}, year={year}") # Extract metadata and convert to DataFrame metadata = document.get("metadata", []) df = pd.DataFrame(metadata) df['publication_date'] = pd.to_datetime(df['publication_date']) return df # Common functions (unchanged) def filter_by_date_range(dataframe, start_idx, end_idx): start_date = date_range[start_idx] end_date = date_range[end_idx] return dataframe[(dataframe['publication_date'] >= start_date) & (dataframe['publication_date'] <= end_date)] def generate_vibrant_colors(n): base_colors = [] for i in range(n): hue = (i / n) % 1.0 saturation = random.uniform(0.7, 0.9) value = random.uniform(0.7, 0.9) r, g, b = colorsys.hsv_to_rgb(hue, saturation, value) vibrant_color = '#{:02x}{:02x}{:02x}'.format( int(r * 255), int(g * 255), int(b * 255) ) end_color_r = min(255, int(r * 255 * 1.1)) end_color_g = min(255, int(g * 255 * 1.1)) end_color_b = min(255, int(b * 255 * 1.1)) gradient_end = '#{:02x}{:02x}{:02x}'.format(end_color_r, end_color_g, end_color_b) base_colors.append({ 'start': vibrant_color, 'end': gradient_end }) extended_colors = base_colors * math.ceil(n/10) final_colors = [] for i in range(n): color = extended_colors[i] jitter = random.uniform(0.9, 1.1) def jitter_color(hex_color): r, g, b = [min(255, max(0, int(int(hex_color[j:j+2], 16) * jitter))) for j in (1, 3, 5)] return f'rgba({r}, {g}, {b}, 0.9)' final_colors.append({ 'start': jitter_color(color['start']), 'end': jitter_color(color['end']).replace('0.9', '0.8') }) return final_colors # Knowledge map creator function (unchanged) def create_knowledge_map(filtered_df, view_type='host'): color_palette = { 'background': '#1E1E1E', 'card_bg': '#1A2238', 'accent1': '#FF6A3D', 'accent2': '#4ECCA3', 'accent3': '#9D84B7', 'text_light': '#FFFFFF', 'text_dark': '#E0E0E0', } if view_type == 'host': group_col = 'host_organization_name' id_col = 'host_organization_id' title = "Host Organization Clusters" else: group_col = 'venue' id_col = 'venue_id' title = "Publication Venue Clusters" summary = filtered_df.groupby(group_col).agg( paper_count=('id', 'count'), is_oa=('is_oa', 'mean'), oa_status=('oa_status', lambda x: x.mode()[0] if not x.mode().empty else None), entity_id=(id_col, 'first') ).reset_index() paper_count_groups = defaultdict(list) for _, row in summary.iterrows(): paper_count_groups[row['paper_count']].append(row) knowledge_map_fig = go.Figure() sorted_counts = sorted(paper_count_groups.keys(), reverse=True) vibrant_colors = generate_vibrant_colors(len(sorted_counts)) golden_angle = np.pi * (3 - np.sqrt(5)) spiral_coef = 150 cluster_metadata = {} max_x, max_y = 500, 500 for i, count in enumerate(sorted_counts): radius = np.sqrt(i) * spiral_coef theta = golden_angle * i cluster_x, cluster_y = radius * np.cos(theta), radius * np.sin(theta) label_offset_angle = theta + np.pi/4 label_offset_distance = 80 + 4 * np.sqrt(len(paper_count_groups[count])) label_x = cluster_x + label_offset_distance * np.cos(label_offset_angle) label_y = cluster_y + label_offset_distance * np.sin(label_offset_angle) cluster_metadata[count] = { 'center_x': cluster_x, 'center_y': cluster_y, 'entities': paper_count_groups[count], 'color': vibrant_colors[i] } entities = paper_count_groups[count] num_entities = len(entities) cluster_size = min(200, max(80, 40 + 8 * np.sqrt(num_entities))) color = vibrant_colors[i] knowledge_map_fig.add_shape( type="circle", x0=cluster_x - cluster_size/2, y0=cluster_y - cluster_size/2, x1=cluster_x + cluster_size/2, y1=cluster_y + cluster_size/2, fillcolor=color['end'].replace("0.8", "0.15"), line=dict(color=color['start'], width=1.5), opacity=0.7 ) knowledge_map_fig.add_trace(go.Scatter( x=[cluster_x], y=[cluster_y], mode='markers', marker=dict(size=cluster_size, color=color['start'], opacity=0.3), customdata=[[count, "cluster"]], hoverinfo='skip' )) knowledge_map_fig.add_trace(go.Scatter( x=[cluster_x, label_x], y=[cluster_y, label_y], mode='lines', line=dict(color=color['start'], width=1, dash='dot'), hoverinfo='skip' )) knowledge_map_fig.add_annotation( x=label_x, y=label_y, text=f"{count} papers
{num_entities} {'orgs' if view_type == 'host' else 'venues'}", showarrow=False, font=dict(size=11, color='white'), bgcolor=color['start'], bordercolor='white', borderwidth=1, opacity=0.9 ) entities_sorted = sorted(entities, key=lambda x: x[group_col]) inner_spiral_coef = 0.4 for j, entity_data in enumerate(entities_sorted): spiral_radius = np.sqrt(j) * cluster_size * inner_spiral_coef / np.sqrt(num_entities + 1) spiral_angle = golden_angle * j jitter_radius = random.uniform(0.9, 1.1) * spiral_radius jitter_angle = spiral_angle + random.uniform(-0.1, 0.1) entity_x = cluster_x + jitter_radius * np.cos(jitter_angle) entity_y = cluster_y + jitter_radius * np.sin(jitter_angle) node_size = min(18, max(8, np.sqrt(entity_data['paper_count']) * 1.5)) knowledge_map_fig.add_trace(go.Scatter( x=[entity_x], y=[entity_y], mode='markers', marker=dict( size=node_size, color=color['start'], line=dict(color='rgba(255, 255, 255, 0.9)', width=1.5) ), customdata=[[ entity_data[group_col], entity_data['paper_count'], entity_data['is_oa'], entity_data['entity_id'], count, "entity" ]], hovertemplate=( f"{entity_data[group_col]}
" f"Papers: {entity_data['paper_count']}
" f"Open Access: {entity_data['is_oa']:.1%}" ) )) max_x = max([abs(cluster['center_x']) for cluster in cluster_metadata.values()]) + 150 if cluster_metadata else 500 max_y = max([abs(cluster['center_y']) for cluster in cluster_metadata.values()]) + 150 if cluster_metadata else 500 knowledge_map_fig.update_layout( title=dict( text=title, font=dict(size=22, family='"Poppins", sans-serif', color=color_palette['accent1']) ), plot_bgcolor='rgba(26, 34, 56, 1)', paper_bgcolor='rgba(26, 34, 56, 0.7)', xaxis=dict(range=[-max(700, max_x), max(700, max_x)], showticklabels=False, showgrid=False), yaxis=dict(range=[-max(500, max_y), max(500, max_y)], showticklabels=False, showgrid=False), margin=dict(l=10, r=10, t=60, b=10), height=700, hovermode='closest', showlegend=False, font=dict(family='"Poppins", sans-serif', color=color_palette['text_light']), ) return knowledge_map_fig, cluster_metadata # Other chart functions (unchanged) def create_oa_pie_fig(filtered_df): color_palette = { 'background': '#1A2238', 'card_bg': '#1A2238', 'accent1': '#FF6A3D', 'accent2': '#4ECCA3', 'accent3': '#9D84B7', 'text_light': '#FFFFFF', 'text_dark': '#FFFFFF', } fig = px.pie( filtered_df, names='is_oa', title="Overall Open Access Status", labels={True: "Open Access", False: "Not Open Access"}, color_discrete_sequence=[color_palette['accent2'], color_palette['accent1']] ) fig.update_traces( textinfo='label+percent', textfont=dict(size=14, family='"Poppins", sans-serif'), marker=dict(line=dict(color='#1A2238', width=2)) ) fig.update_layout( title=dict( text="Overall Open Access Status", font=dict(size=18, family='"Poppins", sans-serif', color=color_palette['accent1']) ), font=dict(family='"Poppins", sans-serif', color=color_palette['text_light']), paper_bgcolor=color_palette['background'], plot_bgcolor=color_palette['background'], margin=dict(t=50, b=20, l=20, r=20), legend=dict( orientation="h", yanchor="bottom", y=-0.2, xanchor="center", x=0.5, font=dict(size=12, color=color_palette['text_light']) ) ) return fig def create_oa_status_pie_fig(filtered_df): custom_colors = [ "#9D84B7", '#4DADFF', '#FFD166', '#06D6A0', '#EF476F' ] fig = px.pie( filtered_df, names='oa_status', title="Open Access Status Distribution", color_discrete_sequence=custom_colors ) fig.update_traces( textinfo='label+percent', insidetextorientation='radial', textfont=dict(size=14, family='"Poppins", sans-serif'), marker=dict(line=dict(color='#FFFFFF', width=2)) ) fig.update_layout( title=dict( text="Open Access Status Distribution", font=dict(size=18, family='"Poppins", sans-serif', color="#FF6A3D") ), font=dict(family='"Poppins", sans-serif', color='#FFFFFF'), paper_bgcolor='#1A2238', plot_bgcolor='#1A2238', margin=dict(t=50, b=20, l=20, r=20), legend=dict( orientation="h", yanchor="bottom", y=-0.2, xanchor="center", x=0.5, font=dict(size=12, color='#FFFFFF') ) ) return fig def create_type_bar_fig(filtered_df): type_counts = filtered_df['type'].value_counts() vibrant_colors = [ '#4361EE', '#3A0CA3', '#4CC9F0', '#F72585', '#7209B7', '#B5179E', '#480CA8', '#560BAD', '#F77F00' ] fig = px.bar( type_counts, title="Publication Types", labels={'value': 'Count', 'index': 'Type'}, color=type_counts.index, color_discrete_sequence=vibrant_colors[:len(type_counts)] ) fig.update_traces( marker_line_width=1, marker_line_color='rgba(0, 0, 0, 0.5)', opacity=0.9, hovertemplate='%{y} publications', texttemplate='%{y}', textposition='outside', textfont=dict(size=14, color='white') ) fig.update_layout( title=dict( text="Publication Types", font=dict(size=20, family='"Poppins", sans-serif', color="#FF6A3D") ), xaxis_title="Type", yaxis_title="Count", font=dict(family='"Poppins", sans-serif', color="#FFFFFF", size=14), paper_bgcolor='#1A2238', plot_bgcolor='#1A2238', margin=dict(t=70, b=60, l=60, r=40), xaxis=dict( tickfont=dict(size=14, color="#FFFFFF"), tickangle=-45, gridcolor='rgba(255, 255, 255, 0.1)' ), yaxis=dict( tickfont=dict(size=14, color="#FFFFFF"), gridcolor='rgba(255, 255, 255, 0.1)' ), bargap=0.3, ) return fig # Pydantic model for request validation class DashboardRequest(BaseModel): userId: str topic: str year: int @router.post("/load_and_display_dashboard/") async def load_and_display_dashboard(request: DashboardRequest, req: Request): try: # Load data from MongoDB df = await load_data_from_mongodb(request.userId, request.topic, request.year, req) # Get date range for the slider global min_date, max_date, date_range, date_marks min_date = df['publication_date'].min() max_date = df['publication_date'].max() date_range = pd.date_range(start=min_date, end=max_date, freq='MS') date_marks = {i: date.strftime('%b %Y') for i, date in enumerate(date_range)} # Create and run dashboard create_and_run_dashboard(df, request.topic) base_url = str(req.base_url) venue_redirect_url = f"{base_url}venue_redirect/{request.userId}/{request.topic}/{request.year}" # Return response with redirect info - but DON'T open browser here return { "status": "success", "message": "Dashboard ready at /venues/", "redirect": "/venues/", "open_url": venue_redirect_url } except Exception as e: raise HTTPException(status_code=400, detail=str(e)) venue_dash_app = None def create_and_run_dashboard(df, topic): global venue_dash_app from app import get_or_create_venue_dash_app venue_dash_app = get_or_create_venue_dash_app() # Clear previous cluster metadata if hasattr(venue_dash_app, 'cluster_metadata'): venue_dash_app.cluster_metadata.clear() # Define color palette and styles color_palette = { 'background': '#1A2238', 'card_bg': '#F8F8FF', 'accent1': '#FF6A3D', 'accent2': '#4ECCA3', 'accent3': '#9D84B7', 'text_light': '#FFFFFF', 'text_dark': '#2D3748', } container_style = { 'padding': '5px', 'backgroundColor': color_palette['text_dark'], 'borderRadius': '12px', 'boxShadow': '0 4px 12px rgba(0, 0, 0, 0.15)', 'marginBottom': '25px', 'border': f'1px solid rgba(255, 255, 255, 0.2)', } hidden_style = {**container_style, 'display': 'none'} visible_style = {**container_style} # Create the layout venue_dash_app.layout = html.Div([ html.Div([ html.H1(topic.capitalize() + " Analytics Dashboard", style={ 'textAlign': 'center', 'marginBottom': '10px', 'color': color_palette['accent1'], 'fontSize': '2.5rem', 'fontWeight': '700', 'letterSpacing': '0.5px', }), html.Div([ html.P("Research Publication Analysis & Knowledge Mapping", style={ 'textAlign': 'center', 'color': color_palette['text_light'], 'opacity': '0.8', 'fontSize': '1.2rem', 'marginTop': '0', }) ]) ], style={ 'background': f'linear-gradient(135deg, {color_palette["background"]}, #364156)', 'padding': '30px 20px', 'borderRadius': '12px', 'marginBottom': '25px', 'boxShadow': '0 4px 20px rgba(0, 0, 0, 0.2)', }), # Controls section html.Div([ html.Div([ html.Button( id='view-toggle', children='Switch to Venue View', style={ 'padding': '12px 20px', 'fontSize': '1rem', 'borderRadius': '8px', 'border': 'none', 'backgroundColor': color_palette['accent1'], 'color': 'white', 'cursor': 'pointer', 'boxShadow': '0 2px 5px rgba(0, 0, 0, 0.1)', 'transition': 'all 0.3s ease', 'marginRight': '20px', 'fontWeight': '500', } ), html.H3("Filter by Publication Date", style={ 'marginBottom': '15px', 'color': color_palette['text_dark'], 'fontSize': '1.3rem', 'fontWeight': '600', }), ], style={'display': 'flex', 'alignItems': 'center', 'marginBottom': '15px'}), dcc.RangeSlider( id='date-slider', min=0, max=len(date_range) - 1, value=[0, len(date_range) - 1], marks=date_marks if len(date_marks) <= 12 else { i: date_marks[i] for i in range(0, len(date_range), max(1, len(date_range) // 12)) }, step=1, tooltip={"placement": "bottom", "always_visible": True}, updatemode='mouseup' ), html.Div(id='date-range-display', style={ 'textAlign': 'center', 'marginTop': '12px', 'fontSize': '1.1rem', 'fontWeight': '500', 'color': color_palette['accent1'], }) ], style={**container_style, 'marginBottom': '25px'}), # Knowledge map html.Div([ dcc.Graph( id='knowledge-map', style={'width': '100%', 'height': '700px'}, config={'scrollZoom': True, 'displayModeBar': True, 'responsive': True} ) ], style={ **container_style, 'height': '750px', 'marginBottom': '25px', 'background': f'linear-gradient(to bottom right, {color_palette["card_bg"]}, #F0F0F8)', }), # Details container html.Div([ html.H3(id='details-title', style={ 'marginBottom': '15px', 'color': color_palette['accent1'], 'fontSize': '1.4rem', 'fontWeight': '600', }), html.Div(id='details-content', style={ 'maxHeight': '350px', 'overflowY': 'auto', 'padding': '10px', 'borderRadius': '8px', 'backgroundColor': 'rgba(255, 255, 255, 0.7)', }) ], id='details-container', style=hidden_style), # Charts in flex container html.Div([ html.Div([ dcc.Graph( id='oa-pie-chart', style={'width': '100%', 'height': '350px'}, config={'displayModeBar': False, 'responsive': True} ) ], style={ 'flex': 1, **container_style, 'margin': '0 10px', 'height': '400px', 'transition': 'transform 0.3s ease', ':hover': {'transform': 'translateY(-5px)'}, }), html.Div([ dcc.Graph( id='oa-status-pie-chart', style={'width': '100%', 'height': '350px'}, config={'displayModeBar': False, 'responsive': True} ) ], style={ 'flex': 1, **container_style, 'margin': '0 10px', 'height': '400px', 'transition': 'transform 0.3s ease', ':hover': {'transform': 'translateY(-5px)'}, }) ], style={'display': 'flex', 'marginBottom': '25px', 'height': '420px'}), # Bar chart html.Div([ dcc.Graph( id='type-bar-chart', style={'width': '100%', 'height': '50vh'}, config={'displayModeBar': False, 'responsive': True} ) ], style={ **container_style, 'height': '500px', 'background': 'rgba(26, 34, 56, 1)', 'marginBottom': '10px', }), # Store components dcc.Store(id='filtered-df-info'), dcc.Store(id='current-view', data='host'), html.Div(id='load-trigger', children=f"trigger-{pd.Timestamp.now().timestamp()}", style={'display': 'none'}) ], style={ 'fontFamily': '"Poppins", "Segoe UI", Arial, sans-serif', 'backgroundColor': '#121212', 'padding': '30px', 'maxWidth': '1800px', 'margin': '0 auto', 'minHeight': '100vh', 'color': color_palette['text_light'], 'paddingBottom': '10px', }) # Callbacks @venue_dash_app.callback( [Output('current-view', 'data'), Output('view-toggle', 'children')], [Input('view-toggle', 'n_clicks')], [State('current-view', 'data')] ) def toggle_view(n_clicks, current_view): if not n_clicks: return current_view, 'Switch to Venue View' if current_view == 'host' else 'Switch to Host View' new_view = 'venue' if current_view == 'host' else 'host' new_button_text = 'Switch to Host View' if new_view == 'venue' else 'Switch to Venue View' return new_view, new_button_text @venue_dash_app.callback( Output('date-range-display', 'children'), [Input('date-slider', 'value')] ) def update_date_range_display(date_range_indices): start_date = date_range[date_range_indices[0]] end_date = date_range[date_range_indices[1]] return f"Selected period: {start_date.strftime('%b %Y')} to {end_date.strftime('%b %Y')}" @venue_dash_app.callback( [Output('knowledge-map', 'figure'), Output('oa-pie-chart', 'figure'), Output('oa-status-pie-chart', 'figure'), Output('type-bar-chart', 'figure'), Output('filtered-df-info', 'data'), Output('details-container', 'style')], [Input('date-slider', 'value'), Input('current-view', 'data'), Input('load-trigger', 'children')] # Trigger updates ) def update_visualizations(date_range_indices, current_view, _): # Filter data based on date range filtered_df = filter_by_date_range(df, date_range_indices[0], date_range_indices[1]) # Generate knowledge map knowledge_map_fig, cluster_metadata = create_knowledge_map(filtered_df, current_view) venue_dash_app.cluster_metadata = cluster_metadata # Prepare metadata for storage filtered_info = { 'start_idx': date_range_indices[0], 'end_idx': date_range_indices[1], 'start_date': date_range[date_range_indices[0]].strftime('%Y-%m-%d'), 'end_date': date_range[date_range_indices[1]].strftime('%Y-%m-%d'), 'record_count': len(filtered_df), 'view_type': current_view } # Return updated figures and metadata return ( knowledge_map_fig, create_oa_pie_fig(filtered_df), create_oa_status_pie_fig(filtered_df), create_type_bar_fig(filtered_df), filtered_info, hidden_style ) @venue_dash_app.callback( [Output('details-container', 'style', allow_duplicate=True), Output('details-title', 'children'), Output('details-content', 'children')], [Input('knowledge-map', 'clickData')], [State('filtered-df-info', 'data')], prevent_initial_call=True ) def display_details(clickData, filtered_info): if not clickData or not filtered_info: return hidden_style, "", [] customdata = clickData['points'][0]['customdata'] view_type = filtered_info['view_type'] entity_type = "Organization" if view_type == 'host' else "Venue" if len(customdata) >= 2 and customdata[-1] == "cluster": count = customdata[0] if count not in venue_dash_app.cluster_metadata: return hidden_style, "", [] entities = venue_dash_app.cluster_metadata[count]['entities'] color = venue_dash_app.cluster_metadata[count]['color']['start'] table_header = [ html.Thead(html.Tr([ html.Th(f"{entity_type} Name", style={'padding': '8px'}), html.Th(f"{entity_type} ID", style={'padding': '8px'}), html.Th("Papers", style={'padding': '8px', 'textAlign': 'center'}), html.Th("Open Access %", style={'padding': '8px', 'textAlign': 'center'}) ], style={'backgroundColor': color_palette['accent1'], 'color': 'white'})) ] rows = [] for entity in sorted(entities, key=lambda x: x['paper_count'], reverse=True): entity_name_link = html.A( entity[f"{view_type}_organization_name" if view_type == 'host' else "venue"], href=entity['entity_id'], target="_blank", style={'color': color, 'textDecoration': 'underline'} ) entity_id_link = html.A( entity['entity_id'].split('/')[-1], href=entity['entity_id'], target="_blank", style={'color': color, 'textDecoration': 'underline'} ) rows.append(html.Tr([ html.Td(entity_name_link, style={'padding': '8px'}), html.Td(entity_id_link, style={'padding': '8px'}), html.Td(entity['paper_count'], style={'padding': '8px', 'textAlign': 'center'}), html.Td(f"{entity['is_oa']:.1%}", style={'padding': '8px', 'textAlign': 'center'}) ])) table = html.Table(table_header + [html.Tbody(rows)], style={ 'width': '100%', 'borderCollapse': 'collapse', 'boxShadow': '0 1px 3px rgba(0,0,0,0.1)' }) return ( visible_style, f"{entity_type}s with {count} papers", [html.P(f"Showing {len(entities)} {entity_type.lower()}s during selected period"), table] ) elif len(customdata) >= 6 and customdata[-1] == "entity": entity_name = customdata[0] entity_id = customdata[3] cluster_count = customdata[4] color = venue_dash_app.cluster_metadata[cluster_count]['color']['start'] if view_type == 'host': entity_papers = df[df['host_organization_name'] == entity_name].copy() else: entity_papers = df[df['venue'] == entity_name].copy() entity_papers = entity_papers[ (entity_papers['publication_date'] >= pd.to_datetime(filtered_info['start_date'])) & (entity_papers['publication_date'] <= pd.to_datetime(filtered_info['end_date'])) ] entity_name_link = html.A( entity_name, href=entity_id, target="_blank", style={'color': color, 'textDecoration': 'underline', 'fontSize': '1.2em'} ) entity_id_link = html.A( entity_id.split('/')[-1], href=entity_id, target="_blank", style={'color': color, 'textDecoration': 'underline'} ) header = [ html.Div([ html.Span("Name: ", style={'fontWeight': 'bold'}), entity_name_link ], style={'marginBottom': '10px'}), html.Div([ html.Span("ID: ", style={'fontWeight': 'bold'}), entity_id_link ], style={'marginBottom': '10px'}), html.Div([ html.Span(f"Papers: {len(entity_papers)}", style={'marginRight': '20px'}), ], style={'marginBottom': '20px'}) ] table_header = [ html.Thead(html.Tr([ html.Th("Paper ID", style={'padding': '8px'}), html.Th("Type", style={'padding': '8px'}), html.Th("OA Status", style={'padding': '8px', 'textAlign': 'center'}), html.Th("Publication Date", style={'padding': '8px', 'textAlign': 'center'}) ], style={'backgroundColor': color, 'color': 'white'})) ] rows = [] for _, paper in entity_papers.sort_values('publication_date', ascending=False).iterrows(): paper_link = html.A( paper['id'], href=paper['id'], target="_blank", style={'color': color, 'textDecoration': 'underline'} ) rows.append(html.Tr([ html.Td(paper_link, style={'padding': '8px'}), html.Td(paper['type'], style={'padding': '8px'}), html.Td(paper['oa_status'], style={'padding': '8px', 'textAlign': 'center'}), html.Td(paper['publication_date'].strftime('%Y-%m-%d'), style={'padding': '8px', 'textAlign': 'center'}) ])) table = html.Table(table_header + [html.Tbody(rows)], style={ 'width': '100%', 'borderCollapse': 'collapse', 'boxShadow': '0 1px 3px rgba(0,0,0,0.1)' }) return visible_style, f"{entity_type} Papers", header + [table] return hidden_style, "", [] return None