Refresh Election Map Output¶
In [ ]:
%load_ext autoreload
%autoreload 2
In [ ]:
import geopandas as gpd
import datetime as dt
import json
from pathlib import Path
import bokeh
from bokeh.embed import components
from bokeh.plotting import figure
from bokeh.models import (
ResetTool,
PanTool,
WheelZoomTool,
GeoJSONDataSource,
Slider,
CustomJS,
ZoomInTool,
ZoomOutTool,
RadioButtonGroup,
LinearColorMapper,
)
from bokeh.transform import transform, linear_cmap
from jinja2 import FileSystemLoader, Environment
import colorcet as cc
In [ ]:
BOKEH_VERSION = bokeh.__version__
print(BOKEH_VERSION)
3.2.1
read in geojson
source data¶
In [ ]:
# Source: MIT Election Data and Science Lab, 2018, "County Presidential Election Returns 2000-2020", https://doi.org/10.7910/DVN/VOQCHQ, Harvard Dataverse, V11; countypres_2000-2020.tab [fileName], UNF:6:HaZ8GWG8D2abLleXN3uEig== [fileUNF]
p = Path('assets/mit_election_data_county_pres_mov_swing_2000_2020geo.json')
with p.open('+r') as f:
src = json.load(f)
gdf = gpd.GeoDataFrame.from_features(src['features'])
gdf.head()
Out[ ]:
geometry | county_fips | geo_id | state | county | name | lsad | censusarea | partisan_mov_norm_2000 | partisan_mov_norm_2004 | partisan_mov_norm_2008 | partisan_mov_norm_2012 | partisan_mov_norm_2016 | partisan_mov_norm_2020 | swing_norm_2000 | swing_norm_2004 | swing_norm_2008 | swing_norm_2012 | swing_norm_2016 | swing_norm_2020 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | POLYGON ((-86.49677 32.34444, -86.71790 32.402... | 01001 | 0500000US01001 | 01 | 001 | Autauga | County | 594.436 | 0.704876 | 0.759897 | 0.739203 | 0.730152 | 0.744985 | 0.722092 | None | 0.555022 | 0.479306 | 0.490949 | 0.514832 | 0.477108 |
1 | POLYGON ((-87.59893 30.99745, -87.59411 30.976... | 01003 | 0500000US01003 | 01 | 003 | Baldwin | County | 1589.784 | 0.737916 | 0.769561 | 0.757238 | 0.778844 | 0.785801 | 0.768812 | None | 0.531645 | 0.487676 | 0.521606 | 0.506956 | 0.483011 |
2 | POLYGON ((-85.05603 32.06305, -85.05021 32.024... | 01005 | 0500000US01005 | 01 | 005 | Barbour | County | 884.876 | 0.495575 | 0.549504 | 0.507266 | 0.484273 | 0.527844 | 0.538315 | None | 0.553929 | 0.457762 | 0.477007 | 0.543571 | 0.510471 |
3 | POLYGON ((-87.42120 32.87451, -87.42013 32.902... | 01007 | 0500000US01007 | 01 | 007 | Bibb | County | 622.582 | 0.610055 | 0.722566 | 0.729234 | 0.733373 | 0.775768 | 0.788640 | None | 0.612511 | 0.506668 | 0.504139 | 0.542395 | 0.512872 |
4 | POLYGON ((-86.57780 33.76532, -86.75914 33.840... | 01009 | 0500000US01009 | 01 | 009 | Blount | County | 644.776 | 0.713932 | 0.812686 | 0.847530 | 0.870470 | 0.904545 | 0.900011 | None | 0.598754 | 0.534844 | 0.522940 | 0.534075 | 0.495466 |
In [ ]:
spath = Path('assets/lower48_states_geo.json')
with spath.open('+r') as f:
state_src = json.load(f)
sgdf = gpd.GeoDataFrame.from_features(state_src['features'])
sgdf.head()
Out[ ]:
geometry | state | |
---|---|---|
0 | MULTIPOLYGON (((-88.12015 30.24615, -88.13708 ... | 01 |
1 | POLYGON ((-111.57953 31.49410, -112.24610 31.7... | 04 |
2 | POLYGON ((-93.48951 33.01844, -93.49052 33.018... | 05 |
3 | MULTIPOLYGON (((-121.86227 36.93155, -121.8801... | 06 |
4 | POLYGON ((-106.67563 36.99312, -106.86980 36.9... | 08 |
Create Visualization¶
In [ ]:
# map gdf columns to Margin of Victory (aka MoV), Swing, or Other
mov_cols = [x for x in gdf.columns if 'mov_' in x]
swing_cols = [x for x in gdf.columns if 'swing_' in x]
other_cols = [x for x in gdf.columns if x not in mov_cols + swing_cols]
# create color mappers
# use 0 and 1 as low and high so that 0.5 is the midpoint
# if value for MoV or swing is < 0.5, the outcome was in favor of Democrats
# if > 0.5, the outcome was in favor of Republicans
color_mapper = LinearColorMapper(
palette = cc.b_diverging_bwr_20_95_c54,
low = 0,
high = 1
)
# create Bokeh DataSources
counties_gsrc = GeoJSONDataSource(geojson = gdf.to_json())
states_gsrc = GeoJSONDataSource(geojson = sgdf.to_json())
# create Bokeh figure within which the patches will be drawin
p = figure(
width_policy="max",
height_policy="max",
aspect_ratio = 1.65,
background_fill_color=None,
tools = [
ResetTool(),
PanTool(),
WheelZoomTool(dimensions = "both"),
ZoomInTool(),
ZoomOutTool()
],
x_axis_location = None,
y_axis_location = None,
sizing_mode="scale_both",
output_backend='webgl' # use WebGL for GPU rendering
)
# map county shapes
# initial color value: MoV for 2000
counties = p.patches(
"xs",
"ys",
fill_color=transform("partisan_mov_norm_2000", color_mapper),
line_color="darkslategrey",
line_width=0.5,
source=counties_gsrc
)
# map state outlines
states = p.patches(
"xs",
"ys",
fill_color=None,
line_color="black",
line_width=1,
source=states_gsrc
)
# remove gridlines and Bokeh border
p.grid.grid_line_color = None
p.outline_line_color = None
p.border_fill_color = None
# add widgets
slider = Slider(
start = 2000, end = 2020, step = 4, title="Year", value = 2000,
orientation='horizontal',
sizing_mode = "scale_width", width_policy="max",
css_classes=["bokeh-widget", "slider"]
)
LABELS = ['Margin of Victory', 'Swing']
radio_button_group = RadioButtonGroup(
labels=LABELS, active = 0,
css_classes = ['bokeh-widget', 'radio-button-group']
)
# custom JS slider callback
# takes as input the counties patches and radio button group
# the radio button group `active` value determine whether to use
# logic for MoV or swing
slider_callback = CustomJS(
args=dict(
counties=counties,
rbg=radio_button_group
),
code="""
console.log('enter slider callback');
if (rbg.active == 0) {
counties.glyph.fill_color.field = 'partisan_mov_norm_' + this.value.toString();
} else if (rbg.active == 1) {
if (this.value < 2004) {
this.value = 2004;
}
counties.glyph.fill_color.field = 'swing_norm_' + this.value.toString();
}
counties.glyph.change.emit();
"""
)
# register slider callback
slider.js_on_change('value', slider_callback)
# likewise for the radio group, take as input the counties patches and slider
# if active dataset changes to MoV, set the glyph fill color field to MoV and vice versa
# if active dataset changes to Swing and slider value == 2000, update slider value to 2004
rb_callback = CustomJS(
args=dict(
counties=counties,
slider=slider
),
code="""
console.log('enter radio button group callback');
if (this.active == 0) {
counties.glyph.fill_color.field = "partisan_mov_norm_" + slider.value.toString();
}
else if (this.active == 1) {
if (slider.value < 2004) {
slider.value = 2004;
}
slider.change.emit();
counties.glyph.fill_color.field = 'swing_norm_' + slider.value.toString();
}
counties.glyph.change.emit();
""")
# register radio button group callback
radio_button_group.js_on_change('active', rb_callback)
In [ ]:
# create components from figure & widgets
script, div = components(
{'map': p, 'slider': slider, 'radio_button_group': radio_button_group}
)
In [ ]:
# load Jinja templates
env = Environment(
loader=FileSystemLoader('templates/')
)
bokeh_template = env.get_template('bokeh-template.html')
In [ ]:
# set template vars
vars = {
'bokeh_version': BOKEH_VERSION,
'title': 'Presidential Election Results by County',
'bokeh_script': script,
'main_title': 'Presidential Election Results by County, 2000–2020',
'main_viz_div': div['map'],
'main_caption': "Credit: MIT Election Data + Science Lab",
'widget_divs': [div['slider'], div['radio_button_group']]
}
In [ ]:
# render template with vars
rendered = bokeh_template.render(**vars)
In [ ]:
# save rendered template
html_path = Path(f'election_map_{dt.date.today().strftime("%Y%m%d")}.html')
html_path.parent.mkdir(exist_ok=True, parents=True)
with html_path.open('+w') as f:
f.write(rendered)