|
|
import polars as pl
|
|
|
import numpy as np
|
|
|
import pandas as pd
|
|
|
import api_scraper
|
|
|
scrape = api_scraper.MLB_Scrape()
|
|
|
from functions import df_update
|
|
|
from functions import pitch_summary_functions
|
|
|
update = df_update.df_update()
|
|
|
from stuff_model import feature_engineering as fe
|
|
|
from stuff_model import stuff_apply
|
|
|
import requests
|
|
|
import joblib
|
|
|
from matplotlib.gridspec import GridSpec
|
|
|
from shiny import App, reactive, ui, render
|
|
|
from shiny.ui import h2, tags
|
|
|
import matplotlib.pyplot as plt
|
|
|
import matplotlib.gridspec as gridspec
|
|
|
import seaborn as sns
|
|
|
from functions.pitch_summary_functions import *
|
|
|
from shiny import App, reactive, ui, render
|
|
|
from shiny.ui import h2, tags
|
|
|
|
|
|
import functions.PitchPlotFunctions as ppf
|
|
|
ploter = ppf.PitchPlotFunctions()
|
|
|
from shiny.plotutils import brushed_points
|
|
|
from pytabulator import TableOptions, Tabulator, output_tabulator, render_tabulator, theme
|
|
|
theme.tabulator_site()
|
|
|
|
|
|
colour_palette = ['#FFB000','#648FFF','#785EF0',
|
|
|
'#DC267F','#FE6100','#3D1EB2','#894D80','#16AA02','#B5592B','#A3C1ED']
|
|
|
cmap_sum = mcolors.LinearSegmentedColormap.from_list("", ['#648FFF', '#FFFFFF', '#FFB000'])
|
|
|
|
|
|
year_list = [2017,2018,2019,2020,2021,2022,2023,2024]
|
|
|
|
|
|
|
|
|
|
|
|
level_dict = {'1':'MLB',
|
|
|
'11':'AAA',
|
|
|
'12':'AA',
|
|
|
'13':'A+',
|
|
|
'14':'A',
|
|
|
'17':'AFL',
|
|
|
'22':'College',
|
|
|
'21':'Prospects',
|
|
|
'51':'International' }
|
|
|
|
|
|
function_dict={
|
|
|
'velocity_kdes':'Velocity Distributions',
|
|
|
'break_plot':'Pitch Movement',
|
|
|
'tj_stuff_roling':'Rolling tjStuff+ by Pitch',
|
|
|
'tj_stuff_roling_game':'Rolling tjStuff+ by Game',
|
|
|
'location_plot_lhb':'Locations vs LHB',
|
|
|
'location_plot_rhb':'Locations vs RHB',
|
|
|
}
|
|
|
|
|
|
|
|
|
split_dict = {'all':'All',
|
|
|
'left':'LHH',
|
|
|
'right':'RHH'}
|
|
|
|
|
|
split_dict_hand = {'all':['L','R'],
|
|
|
'left':['L'],
|
|
|
'right':['R']}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pitch_colours = {
|
|
|
|
|
|
'FF': {'colour': '#FF007D', 'name': '4-Seam Fastball'},
|
|
|
'FA': {'colour': '#FF007D', 'name': 'Fastball'},
|
|
|
'SI': {'colour': '#98165D', 'name': 'Sinker'},
|
|
|
'FC': {'colour': '#BE5FA0', 'name': 'Cutter'},
|
|
|
|
|
|
|
|
|
'CH': {'colour': '#F79E70', 'name': 'Changeup'},
|
|
|
'FS': {'colour': '#FE6100', 'name': 'Splitter'},
|
|
|
'SC': {'colour': '#F08223', 'name': 'Screwball'},
|
|
|
'FO': {'colour': '#FFB000', 'name': 'Forkball'},
|
|
|
|
|
|
|
|
|
'SL': {'colour': '#67E18D', 'name': 'Slider'},
|
|
|
'ST': {'colour': '#1BB999', 'name': 'Sweeper'},
|
|
|
'SV': {'colour': '#376748', 'name': 'Slurve'},
|
|
|
|
|
|
|
|
|
'KC': {'colour': '#311D8B', 'name': 'Knuckle Curve'},
|
|
|
'CU': {'colour': '#3025CE', 'name': 'Curveball'},
|
|
|
'CS': {'colour': '#274BFC', 'name': 'Slow Curve'},
|
|
|
'EP': {'colour': '#648FFF', 'name': 'Eephus'},
|
|
|
|
|
|
|
|
|
'KN': {'colour': '#867A08', 'name': 'Knuckleball'},
|
|
|
'KN': {'colour': '#867A08', 'name': 'Knuckle Ball'},
|
|
|
'PO': {'colour': '#472C30', 'name': 'Pitch Out'},
|
|
|
'UN': {'colour': '#9C8975', 'name': 'Unknown'},
|
|
|
}
|
|
|
|
|
|
|
|
|
dict_colour = {key: value['colour'] for key, value in pitch_colours.items()}
|
|
|
dict_pitch = {key: value['name'] for key, value in pitch_colours.items()}
|
|
|
dict_pitch_desc_type = {value['name']: key for key, value in pitch_colours.items()}
|
|
|
dict_pitch_desc_type.update({'Four-Seam Fastball':'FF'})
|
|
|
dict_pitch_desc_type.update({'All':'All'})
|
|
|
dict_pitch_name = {value['name']: value['colour'] for key, value in pitch_colours.items()}
|
|
|
dict_pitch_name.update({'Four-Seam Fastball':'#FF007D'})
|
|
|
dict_pitch_name.update({'4-Seam':'#FF007D'})
|
|
|
|
|
|
|
|
|
from shiny import App, reactive, ui, render
|
|
|
from shiny.ui import h2, tags
|
|
|
|
|
|
|
|
|
app_ui = ui.page_fluid(
|
|
|
ui.layout_sidebar(
|
|
|
ui.panel_sidebar(
|
|
|
|
|
|
ui.row(
|
|
|
ui.column(6, ui.input_select('year_input', 'Select Season', year_list, selected=2024)),
|
|
|
ui.column(6, ui.input_select('level_input', 'Select Level', level_dict))
|
|
|
),
|
|
|
|
|
|
ui.row(ui.input_action_button("player_button", "Get Player List", class_="btn-primary")),
|
|
|
|
|
|
ui.row(ui.column(12, ui.output_ui('player_select_ui', 'Select Player'))),
|
|
|
|
|
|
ui.row(ui.column(12, ui.output_ui('date_id', 'Select Date'))),
|
|
|
|
|
|
ui.row(
|
|
|
ui.column(6, ui.input_select('split_id', 'Select Split', split_dict, multiple=False)),
|
|
|
),
|
|
|
|
|
|
ui.row(ui.input_action_button("generate_plot", "Generate Plot", class_="btn-primary")),
|
|
|
ui.row(ui.input_action_button("generate_table", "Generate Table", class_="btn-warning")),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
ui.panel_main(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui.card(
|
|
|
{"style": "width: 870px;"},
|
|
|
ui.head_content(
|
|
|
ui.tags.script(src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"),
|
|
|
ui.tags.script("""
|
|
|
async function downloadSVG() {
|
|
|
const content = document.getElementById('capture-section');
|
|
|
|
|
|
// Create a new SVG element
|
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
const bbox = content.getBoundingClientRect();
|
|
|
|
|
|
// Set SVG attributes
|
|
|
svg.setAttribute('width', bbox.width);
|
|
|
svg.setAttribute('height', bbox.height);
|
|
|
svg.setAttribute('viewBox', `0 0 ${bbox.width} ${bbox.height}`);
|
|
|
|
|
|
// Create foreignObject to contain HTML content
|
|
|
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
|
|
foreignObject.setAttribute('width', '100%');
|
|
|
foreignObject.setAttribute('height', '100%');
|
|
|
foreignObject.setAttribute('x', '0');
|
|
|
foreignObject.setAttribute('y', '0');
|
|
|
|
|
|
// Clone the content and its styles
|
|
|
const clonedContent = content.cloneNode(true);
|
|
|
|
|
|
// Add necessary style context
|
|
|
const style = document.createElement('style');
|
|
|
Array.from(document.styleSheets).forEach(sheet => {
|
|
|
try {
|
|
|
Array.from(sheet.cssRules).forEach(rule => {
|
|
|
style.innerHTML += rule.cssText + '\\n';
|
|
|
});
|
|
|
} catch (e) {
|
|
|
console.warn('Could not access stylesheet rules');
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// Create a wrapper div to hold styles and content
|
|
|
const wrapper = document.createElement('div');
|
|
|
wrapper.appendChild(style);
|
|
|
wrapper.appendChild(clonedContent);
|
|
|
|
|
|
foreignObject.appendChild(wrapper);
|
|
|
svg.appendChild(foreignObject);
|
|
|
|
|
|
// Convert to SVG string with XML declaration and DTD
|
|
|
const svgString = new XMLSerializer().serializeToString(svg);
|
|
|
const svgBlob = new Blob([
|
|
|
'<?xml version="1.0" standalone="no"?>\\n',
|
|
|
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\\n',
|
|
|
svgString
|
|
|
], {type: 'image/svg+xml;charset=utf-8'});
|
|
|
|
|
|
// Create and trigger download
|
|
|
const url = URL.createObjectURL(svgBlob);
|
|
|
const link = document.createElement('a');
|
|
|
link.href = url;
|
|
|
link.download = 'plot_and_table.svg';
|
|
|
document.body.appendChild(link);
|
|
|
link.click();
|
|
|
document.body.removeChild(link);
|
|
|
URL.revokeObjectURL(url);
|
|
|
}
|
|
|
|
|
|
$(document).on('click', '#capture_btn', function() {
|
|
|
downloadSVG();
|
|
|
});
|
|
|
""")
|
|
|
),
|
|
|
ui.output_text("status"),
|
|
|
ui.div(
|
|
|
{
|
|
|
"id": "capture-section",
|
|
|
"style": "background-color: white; padding: 0; margin-left: 20px; margin-right: 20px; margin-top: 20px; margin-bottom: 20px;"
|
|
|
},
|
|
|
|
|
|
ui.div(
|
|
|
{"style": "position: relative;"},
|
|
|
ui.output_ui("plot_ui")
|
|
|
),
|
|
|
|
|
|
ui.div(
|
|
|
{"style": "margin-top: 20px;"},
|
|
|
ui.row(ui.tags.b("Pitches in Selection"), ui.output_table("in_brush")),
|
|
|
|
|
|
|
|
|
),
|
|
|
ui.div({"style": "height: 20px;"})
|
|
|
),
|
|
|
ui.input_action_button("capture_btn", "Save as SVG", class_="btn-primary"),
|
|
|
)
|
|
|
|
|
|
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
|
|
|
|
|
|
def server(input, output, session):
|
|
|
|
|
|
@reactive.calc
|
|
|
@reactive.event(input.pitcher_id, input.date_id,input.split_id)
|
|
|
def cached_data():
|
|
|
|
|
|
year_input = int(input.year_input())
|
|
|
sport_id = int(input.level_input())
|
|
|
player_input = int(input.pitcher_id())
|
|
|
start_date = str(input.date_id()[0])
|
|
|
end_date = str(input.date_id()[1])
|
|
|
|
|
|
game_list = scrape.get_player_games_list(sport_id = sport_id,
|
|
|
season = year_input,
|
|
|
player_id = player_input,
|
|
|
start_date = start_date,
|
|
|
end_date = end_date)
|
|
|
|
|
|
data_list = scrape.get_data(game_list_input = game_list[:])
|
|
|
df = (stuff_apply.stuff_apply(fe.feature_engineering(update.update(scrape.get_data_df(data_list = data_list).filter(
|
|
|
(pl.col("pitcher_id") == player_input)&
|
|
|
(pl.col("is_pitch") == True)&
|
|
|
(pl.col('batter_hand').is_in(split_dict_hand[input.split_id()]))
|
|
|
|
|
|
)))).with_columns(
|
|
|
pl.col('pitch_type').count().over('pitch_type').alias('pitch_count')
|
|
|
))
|
|
|
|
|
|
df = df.with_columns(
|
|
|
prop_percent=(pl.col('is_pitch') / pl.col('is_pitch').sum()).over("pitch_type"),
|
|
|
prop=pl.col('is_pitch').sum().over("pitch_type")
|
|
|
)
|
|
|
|
|
|
return df
|
|
|
|
|
|
@render.ui
|
|
|
@reactive.event(input.player_button, input.level_input,input.year_input, ignore_none=False)
|
|
|
def player_select_ui():
|
|
|
|
|
|
df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input())).filter(
|
|
|
pl.col("position").is_in(['P'])).sort("name")
|
|
|
|
|
|
|
|
|
pitcher_dict = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['name']))
|
|
|
|
|
|
|
|
|
return ui.input_select("pitcher_id", "Select Pitcher", pitcher_dict, selectize=True)
|
|
|
|
|
|
@render.ui
|
|
|
@reactive.event(input.player_button,input.pitcher_id,input.year_input, ignore_none=False)
|
|
|
def date_id():
|
|
|
|
|
|
return ui.input_date_range("date_id", "Select Date Range",
|
|
|
start=f"{int(input.year_input())}-01-01",
|
|
|
end=f"{int(input.year_input())}-03-31",
|
|
|
min=f"{int(input.year_input())}-01-01",
|
|
|
max=f"{int(input.year_input())}-12-31")
|
|
|
@output
|
|
|
@render.text
|
|
|
def status():
|
|
|
|
|
|
if input.generate == 0:
|
|
|
return ""
|
|
|
return ""
|
|
|
|
|
|
@render.ui
|
|
|
@reactive.event(input.generate_plot)
|
|
|
def plot_ui():
|
|
|
brush_opts_kwargs = {}
|
|
|
brush_opts_kwargs["direction"] = 'xy'
|
|
|
brush_opts_kwargs["delay"] = 60
|
|
|
brush_opts_kwargs["delay_type"] = "throttle"
|
|
|
|
|
|
|
|
|
return ui.output_plot('plot',
|
|
|
width='800px',
|
|
|
height='800px',
|
|
|
brush=ui.brush_opts(**brush_opts_kwargs))
|
|
|
|
|
|
@render.table
|
|
|
@reactive.event(input.plot_brush, input.generate_table)
|
|
|
def in_brush():
|
|
|
|
|
|
|
|
|
brushed_df = pl.DataFrame(brushed_points(
|
|
|
cached_data().to_pandas(),
|
|
|
input.plot_brush(),
|
|
|
xvar="hb",
|
|
|
yvar="ivb",
|
|
|
all_rows=False
|
|
|
))
|
|
|
|
|
|
|
|
|
brushed_df_final = (((brushed_df.group_by(['pitcher_id', 'pitch_description'])
|
|
|
.agg([
|
|
|
pl.col('is_pitch').drop_nans().count().alias('pitches'),
|
|
|
pl.col('start_speed').drop_nans().mean().round(1).alias('start_speed'),
|
|
|
pl.col('vb').drop_nans().mean().round(1).alias('vb'),
|
|
|
pl.col('ivb').drop_nans().mean().round(1).alias('ivb'),
|
|
|
pl.col('hb').drop_nans().mean().round(1).alias('hb'),
|
|
|
pl.col('spin_rate').drop_nans().mean().round(0).alias('spin_rate'),
|
|
|
pl.col('x0').drop_nans().mean().round(1).alias('x0'),
|
|
|
pl.col('z0').drop_nans().mean().round(1).alias('z0'),
|
|
|
pl.col('tj_stuff_plus').drop_nans().mean().round(0).alias('tj_stuff_plus'),
|
|
|
])
|
|
|
.with_columns(
|
|
|
(pl.col('pitches') / pl.col('pitches').sum().over('pitcher_id'))
|
|
|
|
|
|
|
|
|
.alias('proportion')
|
|
|
)
|
|
|
)).sort('proportion', descending=True).
|
|
|
select(["pitch_description", "pitches", "proportion", "start_speed", "ivb", "hb",
|
|
|
"spin_rate", "x0", "z0",'tj_stuff_plus'])
|
|
|
.with_columns(
|
|
|
pl.when(pl.col("pitch_description") == "Four-Seam Fastball")
|
|
|
.then(pl.lit("4-Seam"))
|
|
|
.otherwise(pl.col("pitch_description"))
|
|
|
.alias("pitch_description")
|
|
|
)
|
|
|
.rename({
|
|
|
'pitch_description': 'Pitch Type',
|
|
|
'pitches': 'Pitches',
|
|
|
'proportion': 'Prop',
|
|
|
'start_speed': 'Velo',
|
|
|
'ivb': 'iVB',
|
|
|
'hb': 'HB',
|
|
|
'spin_rate': 'Spin',
|
|
|
'x0': 'hRel',
|
|
|
'z0': 'vRel',
|
|
|
'tj_stuff_plus': 'tjStuff+'
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def change_font(val):
|
|
|
if val == "Cutter":
|
|
|
return "color: red; font-weight: bold;"
|
|
|
else:
|
|
|
''
|
|
|
return "font-weight: bold;"
|
|
|
df_brush_style = (brushed_df_final.to_pandas().style.set_precision(1)
|
|
|
|
|
|
.set_properties(**{'border': '3 px'},overwrite=False).set_table_styles([{
|
|
|
'selector': 'caption',
|
|
|
'props': [
|
|
|
('color', ''),
|
|
|
('fontname', 'Century Gothic'),
|
|
|
('font-size', '16px'),
|
|
|
('font-style', 'italic'),
|
|
|
('font-weight', ''),
|
|
|
('text-align', 'centre'),
|
|
|
]
|
|
|
|
|
|
},{'selector' :'th', 'props':[('font-size', '16px'),('text-align', 'center'),('Height','px'),('color','black'),('border', '1px black solid !important')]},{'selector' :'td', 'props':[('text-align', 'center'),('font-size', '16px'),('color','black')]}],overwrite=False)
|
|
|
.set_properties(**{'background-color':'White','index':'White','min-width':'72px'},overwrite=False)
|
|
|
.set_table_styles([{'selector': 'th:first-child', 'props': [('background-color', 'white')]}],overwrite=False)
|
|
|
.set_table_styles([{'selector': 'tr:first-child', 'props': [('background-color', 'white')]}],overwrite=False)
|
|
|
.set_table_styles([{'selector': 'tr', 'props': [('line-height', '20px')]}],overwrite=False)
|
|
|
.set_properties(**{'Height': '8px'},**{'text-align': 'center'},overwrite=False)
|
|
|
.hide_index()
|
|
|
.set_properties(**{'border': '1px black solid !important'})
|
|
|
.format('{:.0%}',subset=(brushed_df_final.columns[2]))
|
|
|
.format('{:.0f}',subset=(brushed_df_final.columns[6]))
|
|
|
.format('{:.0f}',subset=(brushed_df_final.columns[-1]))
|
|
|
.set_properties(subset=brushed_df_final.columns, **{'height': '30px'})
|
|
|
.set_table_styles([{'selector': 'thead th', 'props': [('height', '30px')]}], overwrite=False)
|
|
|
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(1)', 'props': [('min-width', '125px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(2)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(3)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(4)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(5)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(6)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(7)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.set_table_styles([{'selector': 'thead th:nth-child(8)', 'props': [('min-width', '40px')]}], overwrite=False)
|
|
|
.background_gradient(cmap=cmap_sum,subset = (brushed_df_final.columns[-1]),vmin=80,vmax=120)
|
|
|
.applymap(lambda x: f'background-color: {dict_pitch_name.get(x, "")}', subset=['Pitch Type'])
|
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
return df_brush_style
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@render.plot
|
|
|
@reactive.event(input.generate_plot)
|
|
|
def plot():
|
|
|
|
|
|
with ui.Progress(min=0, max=1) as p:
|
|
|
p.set(message="Generating plot", detail="This may take a while...")
|
|
|
|
|
|
|
|
|
p.set(0.3, "Gathering data...")
|
|
|
year_input = int(input.year_input())
|
|
|
sport_id = int(input.level_input())
|
|
|
player_input = int(input.pitcher_id())
|
|
|
start_date = str(input.date_id()[0])
|
|
|
end_date = str(input.date_id()[1])
|
|
|
|
|
|
print(year_input, sport_id, player_input, start_date, end_date)
|
|
|
|
|
|
df = cached_data()
|
|
|
df = df.clone()
|
|
|
|
|
|
p.set(0.6, "Creating plot...")
|
|
|
|
|
|
|
|
|
ploter.final_plot(
|
|
|
df=df,
|
|
|
pitcher_id=player_input,
|
|
|
plot_picker='short_form_movement',
|
|
|
sport_id=sport_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = App(app_ui, server) |