Skip to content

Commit

Permalink
Refactor. Split out functions into separate files
Browse files Browse the repository at this point in the history
  • Loading branch information
perfectly-preserved-pie committed May 25, 2024
1 parent 8280b12 commit 52f66bb
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 143 deletions.
151 changes: 8 additions & 143 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,89 +1,22 @@
from dash import Dash, dcc, html, no_update
from components.app_config import create_app, external_stylesheets
from components.html_components import title_card, modal
from dash import Dash, dcc, html, no_update, callback_context
from dash.dependencies import Input, Output, State
from dash.exceptions import PreventUpdate
from loguru import logger
import dash
from utils.dataframes import ep1_df, ep2_df, ep3_df
from utils.functions import generate_column_defs
import dash_ag_grid as dag
import dash_bootstrap_components as dbc
import pandas as pd

external_stylesheets = [
dbc.icons.BOOTSTRAP,
dbc.icons.FONT_AWESOME,
dbc.themes.DARKLY,
"https://cdn.jsdelivr.net/gh/AnnMarieW/[email protected]/dbc.min.css", # https://github.com/AnnMarieW/dash-bootstrap-templates#dbccss--stylesheet

]

ep1_df = pd.read_json('assets/json/episode1.json', lines=True) # Read the JSON files into dataframes
ep2_df = pd.read_json('assets/json/episode2.json', lines=True)
ep3_df = pd.read_json('assets/json/episode3.json', lines=True)

app = Dash(
__name__,
external_stylesheets=external_stylesheets,
external_scripts=[
# Plausible analytics
{
'src': "https://plausible.automateordie.io/js/plausible.js",
'data-domain': "enemies.xenosaga.games",
'defer': True,
'type': 'application/javascript'
},
],
use_pages=False,
# Because we're displaying tab content dynamically, we need to suppress callback exceptions
# https://dash.plotly.com/callback-gotchas#callbacks-require-all-inputs-and-states-to-be-rendered-on-the-page
suppress_callback_exceptions=True,
# Add meta tags for mobile devices
# https://community.plotly.com/t/reorder-website-for-mobile-view/33669/5?
meta_tags = [
{"name": "viewport", "content": "width=device-width, initial-scale=1"}
],
)
# Create the Dash app
app = create_app(external_stylesheets, [])

# Set the page title
app.title = "Xenosaga Enemy Database"
app.description = "A searchable and sortable table of all enemies in the Xenosaga series, organized by game."

title_card = dbc.Card(
[
html.H3("Xenosaga Enemy Database", className="card-title"),
html.I("Mystic powers, grant me a miracle! ✨", style={"margin-bottom": "10px"}),
html.P(
"This is a mobile-friendly searchable, sortable, and filterable table of all enemies in the Xenosaga series, organized by game.",
style = {"margin-bottom": "0px"}
),
html.P(
"Clicking on anywhere on a row will display the selected enemy's stats in a popup.",
style = {"margin-bottom": "0px"}
),
html.I( # use a GitHub icon for my repo
className="bi bi-github",
style = {
"margin-right": "5px",
"margin-left": "0px"
},
),
html.A("GitHub", href='https://github.com/perfectly-preserved-pie/xenosaga', target='_blank'),
],
body = True
)

# Create a modal to display the selected enemy stats
# The modal will be populated by the callback below
modal = dbc.Modal(
[
dbc.ModalHeader("Selected Enemy Stats"),
dbc.ModalBody(id="modal-content"),
dbc.ModalFooter(
dbc.Button("Close", id="close", className="ml-auto", n_clicks=0)
),
],
id="modal",
is_open=False,
)

# For Gunicorn
server = app.server

Expand Down Expand Up @@ -123,74 +56,6 @@
},
)

# Create a function to generate the column definitions based on the dataframe
def generate_column_defs(df):
# Determine if a column is numeric based on a sampling of 100 values from the column
# I did this way because I'm too lazy to properly cast dtypes for the 30+ columns across all 3 episode dataframes
def is_numeric_col(df, column_name):
# If dtype is already numeric, return True
if pd.api.types.is_numeric_dtype(df[column_name].dtype):
return True
# If dtype is object, sample some rows and test if they can be converted to numbers
non_na_values = df[column_name].dropna() # Drop NA values
sample_values = non_na_values.sample(min(100, len(non_na_values))).tolist()
try:
# Try converting the sample values to numbers
[float(x) for x in sample_values]
return True
except ValueError:
# If conversion fails, it's not a numeric column
return False

# Extracts the starting number from a cell's content, especially if the content represents a range like "100-200"
# This is used to sort the numeric columns properly
def get_value_getter(column_name):
if is_numeric_col(df, column_name):
return {"function": f"return params.data.{column_name} && params.data.{column_name}.split('-')[0] ? Number(params.data.{column_name}.split('-')[0]) : null"}
else:
return None

# Create the column definitions
# The Name column is special because it's the only column that's pinned to the left
column_defs = [
{
"field": "Name", # Set the field to "Name" for the Name column
"minWidth": 150, # Set a minimum width for the Name column
"pinned": "left", # Pin the Name column to the left
"resizable": True,
"sortable": True,
"type": "textColumn",
"filter": "agTextColumnFilter",
"floatingFilter": True,
"floatingFilterComponentParams": {"filterPlaceholder": "Search..."},
"suppressMenu": True
}
]
# Add other columns except the "Name" or "uuid" column
for i in df.columns:
if i not in ["Name", "uuid"]:
column_def = {
"field": i,
"filter": "agNumberColumnFilter" if is_numeric_col(df, i) else "agTextColumnFilter",
"floatingFilter": True,
"floatingFilterComponentParams": {"suppressFilterButton": False} if is_numeric_col(df, i) else {"filterPlaceholder": "Search..."},
"minWidth": 120,
"resizable": True,
"sortable": True,
"suppressMenu": True,
"tooltipField": i, # Set the tooltip field to the column name
"type": "numericColumn" if is_numeric_col(df, i) else "textColumn",
"valueFormatter": {"function": "d3.format(',.0f')(params.value)"} if is_numeric_col(df, i) else None,
"valueGetter": get_value_getter(i),
}
# Only add tooltipComponent for string columns
if not is_numeric_col(df, i):
column_def["tooltipComponent"] = "CustomTooltip"

column_defs.append(column_def)

return column_defs

# A callback to generate the grid (lazy load) and the column definitions based on the selected tab
@app.callback(
[Output('grid', 'rowData'), Output('grid', 'columnDefs'), Output('active-tab-data', 'data')],
Expand Down Expand Up @@ -235,7 +100,7 @@ def update_column_size(_):
]
)
def open_and_populate_modal(cell_clicked_data, close_btn_clicks, modal_open, grid_data):
ctx = dash.callback_context
ctx = callback_context

if not ctx.triggered:
return no_update, no_update
Expand Down
Empty file added components/__init__.py
Empty file.
36 changes: 36 additions & 0 deletions components/app_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from dash import Dash
from typing import List, Dict
import dash_bootstrap_components as dbc

external_stylesheets = [
dbc.icons.BOOTSTRAP,
dbc.icons.FONT_AWESOME,
dbc.themes.DARKLY,
"https://cdn.jsdelivr.net/gh/AnnMarieW/[email protected]/dbc.min.css", # https://github.com/AnnMarieW/dash-bootstrap-templates#dbccss--stylesheet

]

# Create the Dash app using a factory function
# This lets me use the same app configuration in multiple files without causing circular imports
def create_app(external_stylesheets: List[str], external_scripts: List[Dict[str, str]]) -> Dash:
"""
Create and configure the Dash app.
Args:
external_stylesheets (List[str]): List of external stylesheets to be used.
external_scripts (List[Dict[str, str]]): List of external scripts to be used. Each script is represented as a dictionary.
Returns:
Dash: The configured Dash app.
"""
app = Dash(
__name__,
external_stylesheets=external_stylesheets,
external_scripts=external_scripts,
use_pages=False,
suppress_callback_exceptions=True,
meta_tags = [
{"name": "viewport", "content": "width=device-width, initial-scale=1"}
],
)
return app
40 changes: 40 additions & 0 deletions components/html_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from dash import html
import dash_bootstrap_components as dbc

title_card = dbc.Card(
[
html.H3("Xenosaga Enemy Database", className="card-title"),
html.I("Mystic powers, grant me a miracle! ✨", style={"margin-bottom": "10px"}),
html.P(
"This is a mobile-friendly searchable, sortable, and filterable table of all enemies in the Xenosaga series, organized by game.",
style = {"margin-bottom": "0px"}
),
html.P(
"Clicking on anywhere on a row will display the selected enemy's stats in a popup.",
style = {"margin-bottom": "0px"}
),
html.I( # use a GitHub icon for my repo
className="bi bi-github",
style = {
"margin-right": "5px",
"margin-left": "0px"
},
),
html.A("GitHub", href='https://github.com/perfectly-preserved-pie/xenosaga', target='_blank'),
],
body = True
)

# Create a modal to display the selected enemy stats
# The modal will be populated by the callback below
modal = dbc.Modal(
[
dbc.ModalHeader("Selected Enemy Stats"),
dbc.ModalBody(id="modal-content"),
dbc.ModalFooter(
dbc.Button("Close", id="close", className="ml-auto", n_clicks=0)
),
],
id="modal",
is_open=False,
)
6 changes: 6 additions & 0 deletions utils/dataframes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import pandas as pd

# Read the JSON files into dataframes for each episode
ep1_df = pd.read_json('assets/json/episode1.json', lines=True)
ep2_df = pd.read_json('assets/json/episode2.json', lines=True)
ep3_df = pd.read_json('assets/json/episode3.json', lines=True)
69 changes: 69 additions & 0 deletions utils/functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pandas as pd

# Create a function to generate the column definitions based on the dataframe
def generate_column_defs(df):
# Determine if a column is numeric based on a sampling of 100 values from the column
# I did this way because I'm too lazy to properly cast dtypes for the 30+ columns across all 3 episode dataframes
def is_numeric_col(df, column_name):
# If dtype is already numeric, return True
if pd.api.types.is_numeric_dtype(df[column_name].dtype):
return True
# If dtype is object, sample some rows and test if they can be converted to numbers
non_na_values = df[column_name].dropna() # Drop NA values
sample_values = non_na_values.sample(min(100, len(non_na_values))).tolist()
try:
# Try converting the sample values to numbers
[float(x) for x in sample_values]
return True
except ValueError:
# If conversion fails, it's not a numeric column
return False

# Extracts the starting number from a cell's content, especially if the content represents a range like "100-200"
# This is used to sort the numeric columns properly
def get_value_getter(column_name):
if is_numeric_col(df, column_name):
return {"function": f"return params.data.{column_name} && params.data.{column_name}.split('-')[0] ? Number(params.data.{column_name}.split('-')[0]) : null"}
else:
return None

# Create the column definitions
# The Name column is special because it's the only column that's pinned to the left
column_defs = [
{
"field": "Name", # Set the field to "Name" for the Name column
"minWidth": 150, # Set a minimum width for the Name column
"pinned": "left", # Pin the Name column to the left
"resizable": True,
"sortable": True,
"type": "textColumn",
"filter": "agTextColumnFilter",
"floatingFilter": True,
"floatingFilterComponentParams": {"filterPlaceholder": "Search..."},
"suppressMenu": True
}
]
# Add other columns except the "Name" or "uuid" column
for i in df.columns:
if i not in ["Name", "uuid"]:
column_def = {
"field": i,
"filter": "agNumberColumnFilter" if is_numeric_col(df, i) else "agTextColumnFilter",
"floatingFilter": True,
"floatingFilterComponentParams": {"suppressFilterButton": False} if is_numeric_col(df, i) else {"filterPlaceholder": "Search..."},
"minWidth": 120,
"resizable": True,
"sortable": True,
"suppressMenu": True,
"tooltipField": i, # Set the tooltip field to the column name
"type": "numericColumn" if is_numeric_col(df, i) else "textColumn",
"valueFormatter": {"function": "d3.format(',.0f')(params.value)"} if is_numeric_col(df, i) else None,
"valueGetter": get_value_getter(i),
}
# Only add tooltipComponent for string columns
if not is_numeric_col(df, i):
column_def["tooltipComponent"] = "CustomTooltip"

column_defs.append(column_def)

return column_defs

0 comments on commit 52f66bb

Please sign in to comment.