diff --git a/.gitignore b/.gitignore index b525ed32f..52753bec9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .DS_Store __pycache__/ #instance/ -*config.py \ No newline at end of file +#config.py +*.ipynb \ No newline at end of file diff --git a/flaskapp/__init__.py b/flaskapp/__init__.py index d099ba574..6d468a77d 100644 --- a/flaskapp/__init__.py +++ b/flaskapp/__init__.py @@ -8,8 +8,24 @@ # Configuring the SQLite database app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db' +# Add engine options for SQLite timeout +app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'connect_args': {'timeout': 15} # Timeout in seconds +} + # Creating a SQLAlchemy database instance db = SQLAlchemy(app) + app.app_context().push() +# Import routes after app and db are initialized to avoid circular imports from flaskapp import routes + +# Optional: Setup basic logging if you haven't already for app.logger to work well +import logging +if not app.debug: + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.INFO) + app.logger.addHandler(stream_handler) +app.logger.setLevel(logging.INFO) # Set a default logging level +app.logger.info('Flask app initialized with enhanced SQLite settings.') \ No newline at end of file diff --git a/flaskapp/models.py b/flaskapp/models.py index 4e64a954a..2b39ebf23 100644 --- a/flaskapp/models.py +++ b/flaskapp/models.py @@ -1,68 +1,51 @@ from flaskapp import db from datetime import datetime - # Defining a model for users class User(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(20), nullable=False) posts = db.relationship('BlogPost', backref='author', lazy=True) + def __repr__(self): return f"User('{self.name}', '{self.id}'')" - def __repr__(self): - return f"User('{self.name}', '{self.id}'')" - - -# Defining a model for blog posts ('models' are used to represent tables in your database). +# Defining a model for blog posts class BlogPost(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100), nullable=False) content = db.Column(db.Text, nullable=False) - # author = db.Column(db.String(50), nullable=False) date_posted = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - - def __repr__(self): - return f"BlogPost('{self.title}', '{self.date_posted}')" - + def __repr__(self): return f"BlogPost('{self.title}', '{self.date_posted}')" class Day(db.Model): - # __tablename__ = 'day' # if you wanted to, you could change the default table name here id = db.Column(db.Date, primary_key=True) views = db.Column(db.Integer) - - def __repr__(self): - return f"Day('{self.id}', '{self.views}')" - + def __repr__(self): return f"Day('{self.id}', '{self.views}')" class IpView(db.Model): ip = db.Column(db.String(20), primary_key=True) date_id = db.Column(db.Date, db.ForeignKey('day.id'), primary_key=True) + def __repr__(self): return f"IpView('{self.ip}', '{self.date_id}')" - def __repr__(self): - return f"IpView('{self.ip}', '{self.date_id}')" - - -# 2010-2019 BES Constituency Results with Census and Candidate Data -# from: https://www.britishelectionstudy.com/data-objects/linked-data/ -# citation: Fieldhouse, E., J. Green., G. Evans., J. Mellon & C. Prosser (2019) British Election Study 2019 Constituency Results file, version 1.1, DOI: 10.48420/20278599 class UkData(db.Model): - id = db.Column(db.String(9), primary_key=True) # UK parliamentary constituency ID - constituency_name = db.Column(db.Text, nullable=False) # UK parliamentary constituency - country = db.Column(db.String(8), nullable=False) # England, Scotland, Wales - region = db.Column(db.String(24), nullable=False) # UK Region - Turnout19 = db.Column(db.Float, nullable=False) # General Election 2019 Turnout (pct of electorate) - ConVote19 = db.Column(db.Float, nullable=False) # General Election 2019 Conservative votes - LabVote19 = db.Column(db.Float, nullable=False) # General Election 2019 Labour Party votes - LDVote19 = db.Column(db.Float, nullable=False) # General Election 2019 Liberal Democrat votes - SNPVote19 = db.Column(db.Float, nullable=False) # General Election 2019 SNP Party votes (Scottish National Party) - PCVote19 = db.Column(db.Float, nullable=False) # General Election 2019 Plaid Cymru Party votes (only in Wales) - UKIPVote19 = db.Column(db.Float, nullable=False) # General Election 2019 UKIP Party votes - GreenVote19 = db.Column(db.Float, nullable=False) # General Election 2019 Green Party votes - BrexitVote19 = db.Column(db.Float, nullable=False) # General Election 2019 Brexit Party votes - TotalVote19 = db.Column(db.Float, nullable=False) # General Election 2019 total number of votes - c11PopulationDensity = db.Column(db.Float, nullable=False) # UK census 2011 population density - c11Female = db.Column(db.Float, nullable=False) # UK census 2011 - percentage of population who are female - c11FulltimeStudent = db.Column(db.Float, nullable=False) # UK census 2011 - percentage of pop who are students - c11Retired = db.Column(db.Float, nullable=False) # UK census 2011 - percentage of population who are retired - c11HouseOwned = db.Column(db.Float, nullable=False) # UK census 2011 - percentage of population who own their home - c11HouseholdMarried = db.Column(db.Float, nullable=False) # UK census 2011 - percentage of pop who are married + id = db.Column(db.String(9), primary_key=True) + constituency_name = db.Column(db.Text, nullable=False) + country = db.Column(db.String(8), nullable=False) + region = db.Column(db.String(24), nullable=False) + Turnout19 = db.Column(db.Float, nullable=False) + ConVote19 = db.Column(db.Float, nullable=False) + LabVote19 = db.Column(db.Float, nullable=False) + LDVote19 = db.Column(db.Float) # Nullable, as not all parties contest all seats + SNPVote19 = db.Column(db.Float) + PCVote19 = db.Column(db.Float) + UKIPVote19 = db.Column(db.Float) + GreenVote19 = db.Column(db.Float) + BrexitVote19 = db.Column(db.Float) + TotalVote19 = db.Column(db.Float, nullable=False) + c11PopulationDensity = db.Column(db.Float, nullable=False) + c11Female = db.Column(db.Float, nullable=False) + c11FulltimeStudent = db.Column(db.Float, nullable=False) + c11Retired = db.Column(db.Float, nullable=False) + c11HouseOwned = db.Column(db.Float, nullable=False) + c11HouseholdMarried = db.Column(db.Float, nullable=False) + def __repr__(self): return f"UkData('{self.constituency_name}')" \ No newline at end of file diff --git a/flaskapp/routes.py b/flaskapp/routes.py index ac7f7f5f0..e38396f62 100644 --- a/flaskapp/routes.py +++ b/flaskapp/routes.py @@ -1,6 +1,6 @@ from flask import render_template, flash, redirect, url_for, request from flaskapp import app, db -from flaskapp.models import BlogPost, IpView, Day +from flaskapp.models import BlogPost, IpView, Day, UkData from flaskapp.forms import PostForm import datetime @@ -8,24 +8,42 @@ import json import plotly import plotly.express as px +import sqlalchemy +# --- Helper function to calculate vote shares --- +def get_vote_share_df(all_uk_data_objects): + if not all_uk_data_objects: + return pd.DataFrame() + data_list = [{column.name: getattr(row, column.name) for column in UkData.__table__.columns} for row in all_uk_data_objects] + df = pd.DataFrame(data_list) + if df.empty or 'TotalVote19' not in df.columns: return df -# Route for the home page, which is where the blog posts will be shown + vote_cols_to_check = [f'{p}Vote19' for p in ['Con', 'Lab', 'LD', 'Green', 'Brexit', 'UKIP', 'SNP', 'PC']] + for col in vote_cols_to_check + ['TotalVote19']: + if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce') + df['TotalVote19'] = df['TotalVote19'].replace(0, pd.NA) # Avoid division by zero, treat 0 as NA for share calculation + + for party_prefix in ['Con', 'Lab', 'LD', 'Green', 'Brexit', 'UKIP', 'SNP', 'PC']: + vote_col = f'{party_prefix}Vote19' + share_col = f'{party_prefix}Vote19Share' + if vote_col in df.columns: + # Ensure TotalVote19 is not NA before division + df[share_col] = df.apply(lambda row: (row[vote_col] / row['TotalVote19']) * 100 + if pd.notna(row['TotalVote19']) and row['TotalVote19'] != 0 and pd.notna(row[vote_col]) + else pd.NA, axis=1) + return df + +# --- Standard Routes --- @app.route("/") @app.route("/home") def home(): - # Querying all blog posts from the database posts = BlogPost.query.all() return render_template('home.html', posts=posts) - -# Route for the about page @app.route("/about") def about(): return render_template('about.html', title='About page') - -# Route to where users add posts (needs to accept get and post requests) @app.route("/post/new", methods=['GET', 'POST']) def new_post(): form = PostForm() @@ -37,37 +55,206 @@ def new_post(): return redirect(url_for('home')) return render_template('create_post.html', title='New Post', form=form) - -# Route to the dashboard page @app.route('/dashboard') def dashboard(): days = Day.query.all() - df = pd.DataFrame([{'Date': day.id, 'Page views': day.views} for day in days]) + df_data = [{'Date': d.id, 'Page views': d.views} for d in days if d.id and d.views is not None] + + fig_title = "Page Views Per Day" + if df_data: + df = pd.DataFrame(df_data) + df['Date'] = pd.to_datetime(df['Date']) + df = df.sort_values(by='Date') + fig = px.bar(df, x='Date', y='Page views', title=fig_title) + else: + flash("No page view data available to display.", "info") + fig = px.bar(title=f"{fig_title} - No Data Available") # Show empty chart with title + + fig.update_layout(title_x=0.5) + graphJSON = json.dumps(fig, cls=plotly.utils.PlotlyJSONEncoder) + return render_template('dashboard.html', title='Site Analytics: Page Views', graphJSON=graphJSON) - fig = px.bar(df, x='Date', y='Page views') +# --- Page 1: Interactive Scatter Plot --- +@app.route('/interactive_scatter', methods=['GET', 'POST']) +def interactive_scatter(): + page_title = "Interactive Scatter Plot: Census vs. Vote Share" + all_uk_data = UkData.query.all() + df_uk = get_vote_share_df(all_uk_data) - graphJSON = json.dumps(fig, cls=plotly.utils.PlotlyJSONEncoder) - return render_template('dashboard.html', title='Page views per day', graphJSON=graphJSON) + # Define options for dropdowns + census_vars = { + 'c11Retired': '% Retired (2011)', + 'c11PopulationDensity': 'Pop Density (2011)', + 'c11HouseOwned': '% House Owned (2011)', + 'c11FulltimeStudent': '% Full-time Students (2011)', + 'Turnout19': 'Turnout % (2019 GE)' + } + party_shares_all = { + 'ConVote19Share': 'Con Share', 'LabVote19Share': 'Lab Share', + 'LDVote19Share': 'LD Share', 'GreenVote19Share': 'Green Share', + 'SNPVote19Share': 'SNP Share', 'PCVote19Share': 'PC Share', + 'BrexitVote19Share': 'Brexit Party Share' + } + color_vars_all = {'country': 'Country', 'region': 'Region'} + if df_uk.empty: + flash("No UK election data available for scatter plot.", "warning") + return render_template('interactive_scatter.html', page_title=page_title, plot_title="Data Unavailable", graphJSON="{}", + census_vars=census_vars, party_shares={}, color_vars={}, + selected_x=list(census_vars.keys())[0], selected_y=None, selected_color=None) -@app.before_request -def before_request_func(): - day_id = datetime.date.today() # get our day_id - client_ip = request.remote_addr # get the ip address of where the client request came from - - query = Day.query.filter_by(id=day_id) # try to get the row associated to the current day - if query.count() > 0: - # the current day is already in table, simply increment its views - current_day = query.first() - current_day.views += 1 + # Filter options based on available and non-empty columns in df_uk + valid_census_vars = {k: v for k, v in census_vars.items() if k in df_uk.columns and df_uk[k].notna().any()} + valid_party_shares = {k: v for k, v in party_shares_all.items() if k in df_uk.columns and df_uk[k].notna().any()} + valid_color_vars = {k: v for k, v in color_vars_all.items() if k in df_uk.columns and df_uk[k].notna().any()} + + selected_x = request.form.get('x_var', request.args.get('x_var', list(valid_census_vars.keys())[0] if valid_census_vars else None)) + selected_y = request.form.get('y_var', request.args.get('y_var', list(valid_party_shares.keys())[0] if valid_party_shares else None)) + selected_color = request.form.get('color_var', request.args.get('color_var', list(valid_color_vars.keys())[0] if valid_color_vars else "")) + + # Ensure selections are valid + if selected_x not in valid_census_vars: selected_x = list(valid_census_vars.keys())[0] if valid_census_vars else None + if selected_y not in valid_party_shares: selected_y = list(valid_party_shares.keys())[0] if valid_party_shares else None + if selected_color != "" and selected_color not in valid_color_vars: selected_color = "" # Default to no color if invalid + + graphJSON = "{}" + plot_title = "Select variables to plot" + + if selected_x and selected_y: + # Prepare data for plotting + columns_to_select = [selected_x, selected_y, 'constituency_name'] + if selected_color and selected_color in valid_color_vars: + columns_to_select.append(selected_color) + + df_plot = df_uk[columns_to_select].copy() + # Ensure selected_x and selected_y are numeric for plotting + for col in [selected_x, selected_y]: + if col in df_plot.columns: + df_plot[col] = pd.to_numeric(df_plot[col], errors='coerce') + df_plot.dropna(subset=[selected_x, selected_y], inplace=True) # Drop rows where essential plot vars are NA + + if not df_plot.empty: + plot_title = f"{valid_census_vars.get(selected_x, selected_x)} vs. {valid_party_shares.get(selected_y, selected_y)}" + color_arg = selected_color if selected_color and selected_color in df_plot.columns else None + + fig = px.scatter(df_plot, x=selected_x, y=selected_y, color=color_arg, + hover_name='constituency_name', + labels={ + selected_x: valid_census_vars.get(selected_x, selected_x), + selected_y: valid_party_shares.get(selected_y, selected_y) + }) + + fig.update_layout(title_text='', title_x=0.5) + graphJSON = json.dumps(fig, cls=plotly.utils.PlotlyJSONEncoder) + else: + plot_title = f"No valid data for {valid_census_vars.get(selected_x, selected_x)} vs. {valid_party_shares.get(selected_y, selected_y)}" + flash(f"No valid data to plot for the selected combination: {plot_title}", "info") + elif not selected_x or not selected_y: + plot_title = "Please select both X and Y axis variables." + flash("Please select variables for both X and Y axes for the scatter plot.", "warning") + else: # Should not be reached if defaults are set from valid_..._vars + plot_title = "Invalid variables selected." + flash("One or both selected variables for the scatter plot are not available or invalid.", "warning") + + return render_template('interactive_scatter.html', page_title=page_title, plot_title=plot_title, graphJSON=graphJSON, + census_vars=valid_census_vars, party_shares=valid_party_shares, color_vars=valid_color_vars, + selected_x=selected_x, selected_y=selected_y, selected_color=selected_color) + +# --- Page 2: Election Summaries --- +@app.route('/election_summaries', methods=['GET', 'POST']) +def election_summaries(): + page_title = "Election Summaries: National & Regional" + all_uk_data = UkData.query.all() + df_uk = get_vote_share_df(all_uk_data) + + if df_uk.empty: + flash("No UK election data available for summaries.", "warning") + return render_template('election_summaries.html', page_title=page_title, + graphJSON_national_votes="{}", graphJSON_regional_shares="{}", graphJSON_regional_census="{}", + regions=[], selected_region="All UK", + title_reg_shares="Regional Party Shares", title_reg_census="Regional Census Metrics") + + # National Vote Totals + national_votes_cols = [f'{p}Vote19' for p in ['Con', 'Lab', 'LD', 'SNP', 'PC', 'Green', 'Brexit', 'UKIP'] if f'{p}Vote19' in df_uk.columns] + national_totals_df = df_uk[national_votes_cols].sum(numeric_only=True).dropna() + national_totals_df = national_totals_df[national_totals_df > 0].sort_values(ascending=False) + if not national_totals_df.empty: + national_totals_df.index = national_totals_df.index.str.replace('Vote19', '') + fig_nat_votes = px.bar(national_totals_df, x=national_totals_df.index, y=national_totals_df.values, labels={'x':'Party', 'y':'Total National Votes'}) else: - # the current day does not exist, it's the first view for the day. - current_day = Day(id=day_id, views=1) - db.session.add(current_day) # insert a new day into the day table + fig_nat_votes = px.bar(title="National Vote Totals - No Data") + fig_nat_votes.update_layout(title_text='National Vote Totals (2019 GE)', title_x=0.5) + graphJSON_national_votes = json.dumps(fig_nat_votes, cls=plotly.utils.PlotlyJSONEncoder) - query = IpView.query.filter_by(ip=client_ip, date_id=day_id) - if query.count() == 0: # check if it's the first time a viewer from this ip address is viewing the website - ip_view = IpView(ip=client_ip, date_id=day_id) - db.session.add(ip_view) # insert into the ip_view table + # Regional Summaries + regions = ["All UK"] + sorted(df_uk['region'].dropna().unique().tolist()) + selected_region = request.form.get('region_select', request.args.get('region_select', 'All UK')) + if selected_region not in regions: selected_region = "All UK" - db.session.commit() # commit all the changes to the database + df_focus = df_uk if selected_region == "All UK" else df_uk[df_uk['region'] == selected_region] + + graphJSON_regional_shares, graphJSON_regional_census = "{}", "{}" + title_reg_shares = f"Avg Party Shares: {selected_region}" + title_reg_census = f"Avg Census Metrics: {selected_region}" + + if not df_focus.empty: + party_share_cols_all = [f'{p}Vote19Share' for p in ['Con', 'Lab', 'LD', 'SNP', 'PC', 'Green', 'Brexit']] + party_share_cols_exist = [col for col in party_share_cols_all if col in df_focus.columns and df_focus[col].notna().any()] + + if party_share_cols_exist: + avg_reg_shares = df_focus[party_share_cols_exist].mean().dropna() + avg_reg_shares = avg_reg_shares[avg_reg_shares > 0.5].sort_values(ascending=False) + if not avg_reg_shares.empty: + avg_reg_shares.index = avg_reg_shares.index.str.replace('Vote19Share', '') + fig_reg_shares = px.bar(avg_reg_shares, x=avg_reg_shares.index, y=avg_reg_shares.values, labels={'x':'Party', 'y':'Avg. Share (%)'}) + fig_reg_shares.update_layout(title_text='', title_x=0.5) + graphJSON_regional_shares = json.dumps(fig_reg_shares, cls=plotly.utils.PlotlyJSONEncoder) + + census_cols_map = {'c11Retired': '% Retired', 'c11PopulationDensity': 'Pop Density', 'c11HouseOwned': '% House Owned', 'Turnout19':'Turnout %'} + census_cols_exist_keys = [k for k,v in census_cols_map.items() if k in df_focus.columns and df_focus[k].notna().any()] + + if census_cols_exist_keys: + avg_reg_census = df_focus[census_cols_exist_keys].mean().dropna() + if not avg_reg_census.empty: + avg_reg_census.index = avg_reg_census.index.map(lambda x: census_cols_map.get(x,x)) + fig_reg_census = px.bar(avg_reg_census, x=avg_reg_census.index, y=avg_reg_census.values, labels={'x':'Metric', 'y':'Avg. Value'}) + fig_reg_census.update_layout(title_text='', title_x=0.5, xaxis_tickangle=-30) + graphJSON_regional_census = json.dumps(fig_reg_census, cls=plotly.utils.PlotlyJSONEncoder) + + return render_template('election_summaries.html', page_title=page_title, + graphJSON_national_votes=graphJSON_national_votes, + regions=regions, selected_region=selected_region, + title_reg_shares=title_reg_shares, graphJSON_regional_shares=graphJSON_regional_shares, + title_reg_census=title_reg_census, graphJSON_regional_census=graphJSON_regional_census) + +# --- before_request function --- +@app.before_request +def before_request_func(): + if request.blueprint == 'static' or request.path.startswith('/static/'): + return # Skip DB operations for static file requests + try: + day_id = datetime.date.today() + client_ip = request.remote_addr + + current_day = Day.query.filter_by(id=day_id).first() + if current_day: + current_day.views += 1 + else: + current_day = Day(id=day_id, views=1) + db.session.add(current_day) + + ip_view_today = IpView.query.filter_by(ip=client_ip, date_id=day_id).first() + if not ip_view_today: + new_ip_view = IpView(ip=client_ip, date_id=day_id) + db.session.add(new_ip_view) + + # Only commit if there are pending changes + if db.session.dirty or db.session.new: + db.session.commit() + + except sqlalchemy.exc.OperationalError as e: + db.session.rollback() + app.logger.warning(f"DB lock or operational error in before_request for IP {request.remote_addr if request else 'Unknown'}: {e}") + except Exception as e: + db.session.rollback() + app.logger.error(f"Unexpected error in before_request: {e}", exc_info=True) \ No newline at end of file diff --git a/flaskapp/static/main.css b/flaskapp/static/main.css index 82afa3a62..27813a9fa 100644 --- a/flaskapp/static/main.css +++ b/flaskapp/static/main.css @@ -1,87 +1,206 @@ -/* Reset default browser styles */ -html, -body, -ul, -li, -h1, -h2, -p, -header, -nav, -div { - margin: 0; - padding: 0; - border: 0; - box-sizing: border-box; +/* flaskapp/static/main.css */ + +/* --- Global Resets & Base Styles --- */ +:root { + --primary-color: #007bff; + --secondary-color: #6c757d; + --light-grey: #f8f9fa; + --medium-grey: #e9ecef; + --dark-grey: #343a40; + --text-color: #212529; + --link-color: var(--primary-color); + --font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --border-radius: 0.25rem; } body { - font-family: Arial, sans-serif; + font-family: var(--font-family-sans-serif); line-height: 1.6; + background-color: var(--light-grey); + color: var(--text-color); + padding-top: 70px; + margin: 0; } -/* Header styles */ -header { - background-color: #333; - color: #fff; - padding: 10px 0; +h1, h2, h3, h4, h5, h6 { + color: var(--dark-grey); + margin-top: 1.5rem; + margin-bottom: 1rem; + font-weight: 500; } -#container { - width: 80%; - margin: 0 auto; -} +h1 { font-size: 2.25rem; } +h2 { font-size: 1.75rem; } +h3 { font-size: 1.5rem; } +h4 { font-size: 1.25rem; } -.logo { - font-size: 24px; +p { + margin-bottom: 1rem; } -.menu { - list-style-type: none; +a { + color: var(--link-color); + text-decoration: none; } - -.menu li { - display: inline; - margin-right: 20px; +a:hover { + color: darken(var(--link-color), 10%); + text-decoration: underline; } -.menu li:last-child { - margin-right: 0; +hr { + border-top: 1px solid var(--medium-grey); } -.menu li a { - color: #fff; - text-decoration: none; +/* --- Navbar --- */ +.navbar { + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + border-bottom: 1px solid var(--medium-grey); } - -.menu li a:hover { - text-decoration: underline; +.navbar-brand { + font-weight: 600; + font-size: 1.25rem; } +.nav-link { + font-weight: 500; +} +.nav-item.active .nav-link { + color: #fff !important; +} + -/* Main content styles */ +/* --- Main Content Container --- */ .container { - width: 80%; - margin: 20px auto; + max-width: 1140px; + padding-top: 1.5rem; + padding-bottom: 1.5rem; } -.article { - margin-bottom: 20px; +/* --- Card Styling (Used for dashboard sections) --- */ +.card { + border: 1px solid var(--medium-grey); + border-radius: var(--border-radius); + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + margin-bottom: 1.5rem; +} +.card-header { + background-color: #fff; /* Cleaner header */ + border-bottom: 1px solid var(--medium-grey); + padding: 0.75rem 1.25rem; + font-weight: 600; +} +.card-header h1, .card-header h2, .card-header h3, .card-header h4 { + margin-top: 0; + margin-bottom: 0; + font-size: 1.2rem; + color: var(--primary-color); +} +.card-body { + padding: 1.5rem; } -.article-title { - color: #333; - text-decoration: none; +/* --- Form Styling --- */ +.form-group label { + font-weight: 500; + margin-bottom: 0.3rem; + font-size: 0.9rem; +} +.form-control, .btn { + border-radius: var(--border-radius); +} +.form-control-sm { + font-size: 0.875rem; +} +.form-inline .form-group { + margin-bottom: 0.5rem; +} +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); +} +.btn-primary:hover { + background-color: darken(var(--primary-color), 10%); + border-color: darken(var(--primary-color), 10%); } -.article-title:hover { - text-decoration: underline; +/* --- Chart Containers --- */ +.chart, .chart_medium, .chart_small { + width: 100%; + border: 1px solid #ddd; + border-radius: var(--border-radius); + background-color: #fff; + padding: 10px; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.05); +} +.chart { min-height: 450px; } +.chart_medium { min-height: 400px; } +.chart_small { min-height: 350px; } + +/* --- Explanation Text --- */ +.explanation { + font-size: 0.9rem; + color: #555; + margin-top: 1rem; + padding: 0.75rem 1rem; + background-color: var(--light-grey); + border: 1px solid var(--medium-grey); + border-radius: var(--border-radius); +} +.explanation p:last-child { + margin-bottom: 0; } -.article-author { - font-style: italic; - color: #777; +/* --- Alert Styling --- */ +.alert { + border-radius: var(--border-radius); + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; } +/* --- Blog Post Article Styling --- */ +.article { +margin-bottom: 1.5rem; +padding: 1.5rem; +background: #fff; +border: 1px solid var(--medium-grey); +border-radius: var(--border-radius); +box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} +.article-title { +color: var(--primary-color); +font-size: 1.5rem; +margin-bottom: 0.25rem; +} +.article-author { +font-style: italic; +color: var(--secondary-color); +font-size: 0.85rem; +margin-bottom: 0.75rem; +} .article-content { - color: #555; +color: var(--text-color); } + +/* --- Lead Paragraph --- */ +.lead { + font-size: 1.1rem; + font-weight: 300; + color: #495057; +} + +/* --- Utility Classes --- */ +.mb-4 { margin-bottom: 1.5rem !important; } +.mt-4 { margin-top: 1.5rem !important; } + +/* --- Responsiveness for Navbar Toggler --- */ +@media (max-width: 767.98px) { + .navbar-nav { + text-align: center; + width: 100%; + } + .navbar-nav .nav-item { + margin-bottom: 0.5rem; + } + .navbar-nav .dropdown-menu { + text-align: center; + } +} \ No newline at end of file diff --git a/flaskapp/templates/dashboard.html b/flaskapp/templates/dashboard.html index 3eb6e0ede..0701fbd66 100644 --- a/flaskapp/templates/dashboard.html +++ b/flaskapp/templates/dashboard.html @@ -1,10 +1,45 @@ {% extends "layout.html" %} {% block content %} -
Overview of national voting patterns and regional demographic & voting averages.
+{{ plot_title }}
+Explore correlations between census demographics and party performance. Hover over points for constituency details.
+