{"id":2359,"date":"2025-08-07T07:00:13","date_gmt":"2025-08-07T12:00:13","guid":{"rendered":"https:\/\/wollen.org\/blog\/?p=2359"},"modified":"2025-08-06T13:02:00","modified_gmt":"2025-08-06T18:02:00","slug":"interactive-plots-with-bokeh","status":"publish","type":"post","link":"https:\/\/wollen.org\/blog\/2025\/08\/interactive-plots-with-bokeh\/","title":{"rendered":"Interactive plots with Bokeh"},"content":{"rendered":"<!-- HFCM by 99 Robots - Snippet # 8: bokeh-script-support -->\n<script type=\"text\/javascript\" src=\"https:\/\/cdn.bokeh.org\/bokeh\/release\/bokeh-3.7.0.min.js\"><\/script>\r\n<script type=\"text\/javascript\" src=\"https:\/\/cdn.bokeh.org\/bokeh\/release\/bokeh-widgets-3.7.0.min.js\"><\/script>\r\n<script type=\"text\/javascript\">\r\n\tBokeh.set_log_level(\"info\");\r\n<\/script>\n<!-- \/end HFCM by 99 Robots -->\n\n<p>I&#8217;m most comfortable with Matplotlib. I think that&#8217;s obvious at this point. I&#8217;m also well aware that it has a (*ahem*) troubled reputation, despite its large footprint in the data world.<\/p>\n<p>Much of that reputation is deserved, of course. It&#8217;s over 20 years old and the design shows it. It usually provides several puzzlingly different ways to accomplish the same goal and they&#8217;re all clunky.<\/p>\n<p>One of its biggest problems is that the default style is unforgivably ugly. People see the output and get turned off. They realize how much effort it would take to produce an average-looking plot and decide it isn&#8217;t worth the trouble. I think that&#8217;s a shame because once you have a feel for the library, it offers probably the finest degree of control in customizing your work.<\/p>\n<figure id=\"attachment_2365\" aria-describedby=\"caption-attachment-2365\" style=\"width: 300px\" class=\"wp-caption aligncenter\"><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/matplotlib_default.png\"><img loading=\"lazy\" decoding=\"async\" class=\"wp-image-2365 size-medium\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/matplotlib_default-300x225.png\" alt=\"\" width=\"300\" height=\"225\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/matplotlib_default-300x225.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/matplotlib_default.png 640w\" sizes=\"auto, (max-width: 300px) 100vw, 300px\" \/><\/a><figcaption id=\"caption-attachment-2365\" class=\"wp-caption-text\">An example of Matplotlib&#8217;s default style. It would be right at home in a 1970s high school chemistry textbook.<\/figcaption><\/figure>\n<p>I didn&#8217;t intend for this blog to become <em>exclusively<\/em> the realm of Matplotlib, but I enjoy taking a library that most people dislike and creating (what I hope are) nice data visualizations.<\/p>\n<p>That said, I think it&#8217;s a good time to step outside my comfort zone and use a different tool. <a href=\"http:\/\/bokeh.org\/\" target=\"_blank\" rel=\"noopener\">Bokeh<\/a> is a data viz library that helps you create clean, modern-looking plots that embed in a web page. You could easily spin up a dashboard and display stock prices, weather data, or anything else. One of Bokeh&#8217;s biggest draws is that, unlike Matplotlib, it looks good without putting any real effort into the style.<\/p>\n<p>For me, its most exciting feature is that the plots can be interactive. Even default plots allow users to zoom and drag the window. You can push it further with sliders, spinners, and other built-in widgets, all backed up by fully customizable Javascript.<\/p>\n<p>Some of its methods are unintuitive, in my opinion, but it&#8217;s understandable because Bokeh is designed to translate Python code into Javascript. It&#8217;s kind of like writing instructions for a robot to bake a cake rather than baking it yourself.<\/p>\n<p>Bokeh gets bonus points for dumping all the code into a single HTML file. You could easily share your work with someone and they wouldn&#8217;t need to install anything. They could use the interactive features without a server running somewhere in the background.<\/p>\n<hr width=\"50%\" \/>\n<p>This post will focus mostly on the visualization. I won&#8217;t spend too much time gathering data. Instead I&#8217;ll extend my <a href=\"https:\/\/wollen.org\/blog\/2025\/05\/partisan-growing-pains\">previous post<\/a> about county-level election results. In that post, we looked at population change and how it related to partisan preference in the 2024 election. Here, we&#8217;ll narrow the scope to counties in California, but we&#8217;ll add two new variables:<\/p>\n<ul>\n<li>The percentage of residents age 25+ with a bachelor&#8217;s degree or higher (<a style=\"color: #9885bf;\" href=\"https:\/\/data.census.gov\/table?q=Educational+Attainment&amp;g=010XX00US$0500000\" target=\"_blank\" rel=\"noopener\">S1501<\/a>).<\/li>\n<li>Median annual earnings of residents age 16+ (<a style=\"color: #9885bf;\" href=\"https:\/\/data.census.gov\/table\/ACSST1Y2023.S2001?q=S2001:+Earnings+in+the+Past+12+Months+(in+2023+Inflation-Adjusted+Dollars)\" target=\"_blank\" rel=\"noopener\">S2001<\/a>).<\/li>\n<\/ul>\n<p>Both of these statistics are available from the US Census Bureau as part of the <a href=\"https:\/\/www.census.gov\/programs-surveys\/acs\/about.html\" target=\"_blank\" rel=\"noopener\">American Community Survey<\/a>.<strong> Our primary focus will be on the relationship between education and vote preference.<\/strong> Population and median earnings will add a degree of interactivity to the plot.<\/p>\n<hr \/>\n<h4>1. Prepare the data.<\/h4>\n<p>I&#8217;m going to skip past merging the datasets. I already covered what&#8217;s essentially the same process in my <a href=\"https:\/\/wollen.org\/blog\/2025\/05\/partisan-growing-pains\">previous post<\/a>, so click the link for a more detailed explanation. Today, we&#8217;ll start with a cleaned, ready-to-go county-level CSV that holds all the relevant variables.<\/p>\n<p>Load the dataset into a pandas DataFrame and filter it down to California rows. The <code>set_option<\/code> method allows for more columns to be printed on screen.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\n\r\npd.set_option(\"display.expand_frame_repr\", False)\r\n\r\ndf = pd.read_csv(\"county_data.csv\")\r\n\r\ndf = df[df['state'] == \"California\"]<\/pre>\n<p><code>df.head()<\/code> is shown below. County and state have their own columns, as do the three demographic variables. <em>dem_margin<\/em> is the percentage point difference between Democratic and Republican vote share. A 55-45 blue county would show up as 10.0.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">                 county       state  population  bachelor_or_higher_pct  median_earnings  dem_margin\r\n41       Alameda County  California     1649060               51.901837            64242   53.562556\r\n42         Butte County  California      208334               29.851859            36717   -3.121991\r\n43  Contra Costa County  California     1172607               46.191173            60735   37.946395\r\n44     El Dorado County  California      192823               40.665969            55187  -11.981410\r\n45        Fresno County  California     1024125               24.769337            40124   -4.385357<\/pre>\n<p>We have education and earnings data for 42 of California&#8217;s 58 counties. I&#8217;d love to have all 58 but unfortunately the American Community Survey doesn&#8217;t have universal coverage. The Census Bureau can&#8217;t collect enough responses in some counties to provide a reliable estimate. Still, those 42 counties account for approximately 99% of the state&#8217;s population, so it&#8217;s not a huge loss in terms of people.<\/p>\n<p>I&#8217;m getting ahead of myself and we have to take care of something before moving on. The plan is to create a scatter plot of education and vote preference, and hovering over a marker will display that county&#8217;s population and median earnings. Since it will be presented to the user, we should clean up how figures are presented on screen. For example, earnings of 50000 can be displayed as $50,000. Similarly, let&#8217;s include commas in population numbers. Create new columns to hold <em>display<\/em> strings.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df.loc[:, 'population_display'] = df['population'].apply(lambda x: f\"{x:,}\")\r\n\r\ndf.loc[:, 'earnings_display'] = df['median_earnings'].apply(lambda x: f\"${x:,}\")<\/pre>\n<p>Now <code>df.head()<\/code> looks like this. I think the two columns on the right are much easier to read.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">                 county       state  population  bachelor_or_higher_pct  median_earnings  dem_margin population_display earnings_display\r\n41       Alameda County  California     1649060               51.901837            64242   53.562556          1,649,060          $64,242\r\n42         Butte County  California      208334               29.851859            36717   -3.121991            208,334          $36,717\r\n43  Contra Costa County  California     1172607               46.191173            60735   37.946395          1,172,607          $60,735\r\n44     El Dorado County  California      192823               40.665969            55187  -11.981410            192,823          $55,187\r\n45        Fresno County  California     1024125               24.769337            40124   -4.385357          1,024,125          $40,124<\/pre>\n<p>Now run a simple linear regression of <em>dem_margin<\/em> on <em>bachelor_or_higher_pct<\/em>. This will give us a &#8220;line of best fit&#8221; through the trend.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from scipy.stats import linregress\r\n\r\nslope, intercept, r_value, p_value, std_err = linregress(df['bachelor_or_higher_pct'], df['dem_margin'])\r\n\r\nx_reg = [df['bachelor_or_higher_pct'].min(), df['bachelor_or_higher_pct'].max()]\r\ny_reg = [n * slope + intercept for n in x_reg]<\/pre>\n<p>R<sup>2<\/sup> measures how tightly data points fit the trend line. If you square <code>r_value<\/code> to get R<sup>2<\/sup>, you&#8217;ll find it&#8217;s 0.63. It&#8217;s fairly high for a relationship like this in the real world.<\/p>\n<p>In fact, California has the 8th-highest R<sup>2<\/sup> of all the states in our dataset. That&#8217;s one reason the state was a good subject for this post. Nationwide, R<sup>2<\/sup> for all 830 counties is 0.35. So the relationship between education and vote is significantly stronger in California than the US as a whole.<\/p>\n<p>As a disclaimer, linear regressions don&#8217;t measure cause and effect. It&#8217;s always worth reminding yourself, especially in a political context. It&#8217;s possible but you would have to do more work to show that x <em>causes<\/em> y.<\/p>\n<hr \/>\n<h4>2. Plot the data<\/h4>\n<p>Now we can build a scatter plot.<\/p>\n<p>As a Matplotlib user, I find a Bokeh <code>figure<\/code> object similar to a Matplotlib <code>Axes<\/code>. We can call <code>figure.line<\/code> or <code>figure.scatter<\/code> to plot the data.<\/p>\n<p>There are many differences, like how we immediately set labels in the code below. Setting <code>sizing_mode<\/code> to &#8220;stretch_width&#8221; allows the figure to expand and fill its parent container. It&#8217;s probably not necessary in most cases but it will help to display the output on this page.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from bokeh.plotting import figure\r\n\r\nfig = figure(height=400,\r\n             sizing_mode=\"stretch_width\",\r\n             title=f\"California Counties  |  2024 Presidential Vote  |  R\u00b2 = {r_value ** 2:.2f}\",\r\n             x_axis_label=\"Bachelor's Degree or Higher\",\r\n             y_axis_label=\"Vote Margin\")<\/pre>\n<p>A <code>ColumnDataSource<\/code> object is how we&#8217;ll pass data to the plotting methods. It&#8217;s a Bokeh data structure that works much like a Python dictionary. You could pass a dictionary type and it would work just as well. Conveniently for us, it accepts pandas DataFrames.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from bokeh.models import ColumnDataSource\r\n\r\ndata_source = ColumnDataSource(data=df)<\/pre>\n<p>Now let&#8217;s call <code>scatter<\/code> on the <code>figure<\/code> we created a moment ago.<\/p>\n<p>Our <code>ColumnDataSource<\/code> gets passed to the <code>source<\/code> parameter. Then declare which variables go along the horizontal and vertical axes and set marker style.<\/p>\n<p>It&#8217;s not strictly necessary but I want to save the scatter plot as a variable (<code>scatter1<\/code>) and enable hover <a href=\"https:\/\/en.wikipedia.org\/wiki\/Tooltip\" target=\"_blank\" rel=\"noopener\">tooltips<\/a> later.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">scatter1 = fig.scatter(source=data_source,\r\n                       x=\"bachelor_or_higher_pct\",\r\n                       y=\"dem_margin\",\r\n                       size=12,\r\n                       color=\"#7865BF\",\r\n                       alpha=0.75)<\/pre>\n<p>Now draw the regression line on the <code>figure<\/code>. We saved its points in <code>x_reg<\/code> and <code>y_reg<\/code> lists.<\/p>\n<p>This doesn&#8217;t need to be assigned a variable name because we don&#8217;t want tooltips enabled. We only want to show county population and earnings when hovering.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">fig.line(x=x_reg,\r\n         y=y_reg,\r\n         line_width=2,\r\n         color=\"#333333\")<\/pre>\n<p>Next, define the window. Bokeh doesn&#8217;t accept <code>range<\/code> types so we have to convert x-ticks into a Python list. Or use <code>numpy.arange<\/code> or similar.<\/p>\n<p>The x variable is percentage of residents with a bachelor&#8217;s degree or higher so let&#8217;s make them strings with a % symbol. Set <code>major_label_overrides<\/code> to a dictionary of {tick: label} pairs.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">x_ticks = list(range(10, 70, 10))\r\nfig.xaxis.ticker = x_ticks\r\nfig.xaxis.major_label_overrides = {n: f\"{n}%\" for n in x_ticks}\r\nfig.x_range.start = 8\r\nfig.x_range.end = 63<\/pre>\n<p>The process is essentially the same for the y-axis. I wrote a function to convert vote margins into D-R partisanship. For example, -20 becomes R+20.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">def get_ytick_labels(ticks):\r\n    labels = []\r\n    for tick in ticks:\r\n        if tick &gt; 0:\r\n            labels.append(f\"D+{tick}\")\r\n        elif tick &lt; 0:\r\n            labels.append(f\"R+{abs(tick)}\")\r\n        else:\r\n            labels.append(\"TIE\")\r\n    return dict(zip(ticks, labels))\r\n\r\n\r\ny_ticks = list(range(-40, 80, 20))\r\nfig.yaxis.ticker = y_ticks\r\nfig.yaxis.major_label_overrides = get_ytick_labels(y_ticks)\r\nfig.y_range.start = -50\r\nfig.y_range.end = 70<\/pre>\n<p>To create hover tooltips, we pass a list of tuples to <code>HoverTool<\/code>. The first element of the tuple is the label on screen. The second element is @ the DataFrame column name.<\/p>\n<p>This is why we saved the <code>scatter<\/code> instance as a variable. Only objects passed to <code>renderers<\/code> will have tooltips. The regression line won&#8217;t be included.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from bokeh.models import HoverTool\r\n\r\ntool_tips = [(\"County\", \"@county\"),\r\n             (\"Population\", \"@population_display\"),\r\n             (\"Earnings\", \"@earnings_display\")]\r\nfig.add_tools(HoverTool(renderers=[scatter1], tooltips=tool_tips))<\/pre>\n<p>This is already more interactive than my usual Matplotlib plots but I think we can do better. It seems like a waste to stop at tooltips.<\/p>\n<p>Let&#8217;s create a slider below the plot that can set minimum earnings. Only counties that exceed the slider&#8217;s value will appear on the plot. So as you drag it to the right, counties with the lowest earnings will gradually disappear. You might be interested to see how the relationship looks for counties with above-average incomes.<\/p>\n<p><code>start<\/code> and <code>end<\/code> define the lowest- and highest-possible values of the slider. <code>step<\/code> is the distance between each value, e.g. with <code>step=4<\/code> you could snap to 0, 4, 8, 12, etc. <code>value<\/code> is where the slider is located when the page loads. Let&#8217;s start it at the far left so all points are included. <code>title<\/code> and <code>width<\/code> define the widget&#8217;s appearance.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from bokeh.models import Slider\r\n\r\nslider = Slider(start=30000,\r\n                end=75000,\r\n                step=1000,\r\n                value=30000,\r\n                title=\"Median Earnings (Minimum)\",\r\n                width=250)<\/pre>\n<p>Next is the complicated part (at least for me). We have to write a custom Javascript function to handle the slider&#8217;s logic. Thankfully, there&#8217;s nothing too far out there. I&#8217;m far from a Javascript expert and I managed to work through it.<\/p>\n<p>A &#8220;callback&#8221; is a function that&#8217;s triggered when some action or event occurs, like a key press or a button click. In this case, we&#8217;ll create a callback function to run whenever the slider moves.<\/p>\n<p>The <code>CustomJS<\/code> method is a little unintuitive because it tells Bokeh how to write a Javascript function. It&#8217;s like meta-code. We have to create parameters and arguments at the same time.<\/p>\n<p>First, save an extra copy of the <code>ColumnDataSource<\/code>. Every time the slider moves, it will filter values from the data source hooked up to the plot, so we&#8217;ll need an original copy to work from.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">original_data_source = ColumnDataSource(data=df)<\/pre>\n<p>Now build the callback function. <code>args<\/code> creates parameters <em>and<\/em> defines the arguments that will be passed to them. We need the <code>data_source<\/code> that&#8217;s hooked up to the plot, the <code>original_data_source<\/code> that we&#8217;ll pull from, and the <code>slider<\/code> widget itself.<\/p>\n<p>The code&#8217;s syntax is similar enough to Python. Every time the slider moves, we rebuild the whole data source from scratch. The function creates an empty Javascript &#8220;dictionary.&#8221; Then it steps through each row of the original, complete data source. If median earnings exceed the slider&#8217;s value, that row is appended to the dictionary. Finally, the dictionary is assigned back to <code>data_source<\/code>, which is hooked up to the plot.<\/p>\n<p>It might look intimidating if you aren&#8217;t used to Javascript, but take it slow and it will make sense. There&#8217;s nothing here that you haven&#8217;t done a million times in Python. Now that I have this working example, I&#8217;m confident that I&#8217;ll be able to create all kinds of custom logic for Bokeh widgets in the future. It boils down to filtering the data on every pass through the function and assigning it back to the <code>ColumnDataSource<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from bokeh.models.callbacks import CustomJS\r\n\r\ncallback = CustomJS(args={\"data_source\": data_source,\r\n                          \"original_data_source\": original_data_source,\r\n                          \"slider\": slider},\r\n                    code=\"\"\"\r\n    var original_data = original_data_source.data;\r\n\r\n    var filtered_data = {bachelor_or_higher_pct: [], \r\n                         dem_margin: [],\r\n                         county: [],\r\n                         population_display: [],\r\n                         earnings_display: []};\r\n\r\n    for (var i = 0; i &lt; original_data['median_earnings'].length; i++) {\r\n        if (original_data['median_earnings'][i] &gt;= slider.value) {\r\n            filtered_data.bachelor_or_higher_pct.push(original_data['bachelor_or_higher_pct'][i]);\r\n            filtered_data.dem_margin.push(original_data['dem_margin'][i]);\r\n            filtered_data.county.push(original_data['county'][i]);\r\n            filtered_data.population_display.push(original_data['population_display'][i]);\r\n            filtered_data.earnings_display.push(original_data['earnings_display'][i]);\r\n        }\r\n    }\r\n    \r\n    data_source.data = filtered_data;\"\"\")<\/pre>\n<p>Attach the custom Javascript function to the slider. &#8220;value&#8221; represents the number currently selected on the slider.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">slider.js_on_change(\"value\", callback)<\/pre>\n<p>With that, it&#8217;s time to save the output. Bokeh has a few layout methods for arranging various elements. In this case, I think it makes sense to place the slider below the plot. That means <code>fig<\/code> and <code>slider<\/code> are arranged as a <code>column<\/code>.<\/p>\n<p>Earlier we set the figure&#8217;s <code>sizing_mode<\/code> to &#8220;stretch_width&#8221; so it will fill the column, but we also need to set the column&#8217;s <code>sizing_mode<\/code> to fill the page. It took me a minute to understand this but I think it makes sense.<\/p>\n<p><code>show()<\/code> is what triggers the whole backend process. Bokeh builds the plot and saves it as an HTML file in the project folder.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from bokeh.plotting import show\r\nfrom bokeh.layouts import column\r\n\r\nshow(column(fig, slider, sizing_mode=\"stretch_width\"))<\/pre>\n<p>Normally the plot would open in a new browser window but I&#8217;ve embedded it in this page. Bokeh&#8217;s portability is one of its greatest strengths!<\/p>\n<hr \/>\n<h4>3. The output.<\/h4>\n<!-- HFCM by 99 Robots - Snippet # 10: bokeh-california-scatter -->\n<div id=\"ae754c91-56cf-47db-af8c-2f3587353e37\" data-root-id=\"p1065\" style=\"display: contents;\"><\/div>\r\n\r\n<script type=\"application\/json\" id=\"c7520e8f-ef33-492d-b9cb-5fb19c959d7a\">\r\n      {\"d076ca5b-ca55-4b7d-9283-13c6ba17872c\":{\"version\":\"3.7.0\",\"title\":\"Bokeh Application\",\"roots\":[{\"type\":\"object\",\"name\":\"Column\",\"id\":\"p1065\",\"attributes\":{\"sizing_mode\":\"stretch_width\",\"children\":[{\"type\":\"object\",\"name\":\"Figure\",\"id\":\"p1001\",\"attributes\":{\"height\":400,\"sizing_mode\":\"stretch_width\",\"x_range\":{\"type\":\"object\",\"name\":\"DataRange1d\",\"id\":\"p1002\",\"attributes\":{\"start\":8,\"end\":63}},\"y_range\":{\"type\":\"object\",\"name\":\"DataRange1d\",\"id\":\"p1003\",\"attributes\":{\"start\":-50,\"end\":70}},\"x_scale\":{\"type\":\"object\",\"name\":\"LinearScale\",\"id\":\"p1011\"},\"y_scale\":{\"type\":\"object\",\"name\":\"LinearScale\",\"id\":\"p1012\"},\"title\":{\"type\":\"object\",\"name\":\"Title\",\"id\":\"p1004\",\"attributes\":{\"text\":\"California Counties  |  2024 Presidential Vote  |  R\\u00b2 = 0.63\"}},\"renderers\":[{\"type\":\"object\",\"name\":\"GlyphRenderer\",\"id\":\"p1045\",\"attributes\":{\"data_source\":{\"type\":\"object\",\"name\":\"ColumnDataSource\",\"id\":\"p1036\",\"attributes\":{\"selected\":{\"type\":\"object\",\"name\":\"Selection\",\"id\":\"p1037\",\"attributes\":{\"indices\":[],\"line_indices\":[]}},\"selection_policy\":{\"type\":\"object\",\"name\":\"UnionRenderers\",\"id\":\"p1038\"},\"data\":{\"type\":\"map\",\"entries\":[[\"index\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"KQAAACoAAAArAAAALAAAAC0AAAAuAAAALwAAADAAAAAxAAAAMgAAADMAAAA0AAAANQAAADYAAAA3AAAAOAAAADkAAAA6AAAAOwAAADwAAAA9AAAAPgAAAD8AAABAAAAAQQAAAEIAAABDAAAARAAAAEUAAABGAAAARwAAAEgAAABJAAAASgAAAEsAAABMAAAATQAAAE4AAABPAAAAUAAAAFEAAABSAAAA\"},\"shape\":[42],\"dtype\":\"int32\",\"order\":\"little\"}],[\"county\",{\"type\":\"ndarray\",\"array\":[\"Alameda County\",\"Butte County\",\"Contra Costa County\",\"El Dorado County\",\"Fresno County\",\"Humboldt County\",\"Imperial County\",\"Kern County\",\"Kings County\",\"Lake County\",\"Los Angeles County\",\"Madera County\",\"Marin County\",\"Mendocino County\",\"Merced County\",\"Monterey County\",\"Napa County\",\"Nevada County\",\"Orange County\",\"Placer County\",\"Riverside County\",\"Sacramento County\",\"San Benito County\",\"San Bernardino County\",\"San Diego County\",\"San Francisco County\",\"San Joaquin County\",\"San Luis Obispo County\",\"San Mateo County\",\"Santa Barbara County\",\"Santa Clara County\",\"Santa Cruz County\",\"Shasta County\",\"Solano County\",\"Sonoma County\",\"Stanislaus County\",\"Sutter County\",\"Tehama County\",\"Tulare County\",\"Ventura County\",\"Yolo County\",\"Yuba County\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}],[\"state\",{\"type\":\"ndarray\",\"array\":[\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}],[\"population\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"pCkZAM4tAwB\/5BEAN\/ECAH2gDwAcBQIA3MUCAKETDgAhXQIAtAgBAPvhlAA4hgIAkOkDAFdcAQBGhwQAG6gGAHcGAgAzjwEAg2AwAJ6eBgCNmiYA35UYACcOAQCJySEA71UyAIagDADscwwA80wEAO1VCwBUyAYAtWQdAAYBBACBwwIAvfEGAP9nBwCsfwgA8YABAMP7AADaYAcAY78MAONvAwCtVQEA\"},\"shape\":[42],\"dtype\":\"int32\",\"order\":\"little\"}],[\"bachelor_or_higher_pct\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"qBZmZm\/zSUCf8PduE9o9QAwGq154GEdAaGgZej5VREAbHmZC88Q4QCo4jPwUDkJA5ZAi7EOQMEA4Jv5qEwoyQAxzoWEw4iRAqWgpotLVMEApDWoH50BCQAd0HHdsLi9Abj03bBn0TUBuD4ronGk8QE42quvm+ixA3alTot3tPECQx3RAp8NDQEc6bKTDekRAty9qK+ZDRkCQKF4lbyRGQOMYDAulgjpAPjdE8zaNQUCsM4djA5E2QMfBcz2InDdAnY1DGaDFRUBaFspqITBOQIqrcxiEnjdARBUuCCYSRUCj21BRwmpLQNHRfJmsDkJAb7O+mZ96TEBft6uDhjhGQCYHzhSw5TZAO+HAoksPPUCzElCcCLdCQLEElq6R+TRAye1DzVoVM0DrjYZMIDc2QA5F0sK\/6zBAi91JOO\/4QkBV+eCUkBhHQDC89S9kfjBA\"},\"shape\":[42],\"dtype\":\"float64\",\"order\":\"little\"}],[\"median_earnings\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"8voAAG2PAAA\/7QAAk9cAALycAACjfAAAiowAAI2QAABwkQAAMpAAAJ2sAABThwAAOAABAKqLAAAejQAAeZkAAFzKAAArwAAAMsYAAEruAADfpAAAj70AADbLAACnoAAA2sgAALI+AQB+qwAAc5kAAHscAQDImQAAxCIBAA2kAABBqwAAFcEAANzFAABDpAAAoJYAAO6VAABpjQAAR7kAABCkAABNsAAA\"},\"shape\":[42],\"dtype\":\"int32\",\"order\":\"little\"}],[\"dem_margin\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"wN6m1QHISkCxilaq1vkIwICjEHcj+UJA+ZqiaXv2J8AMoB\/4mooRwNU0frR\/ZjxAap+elaFs679CFHpQIQ41wK8jiNIGBjfAFjXmN6vx9b+4dOazPXVAQPs7ULtdzTTARuvT3hH0T0A5hHrJDdQ6QGQMfauibhHAvwZuOVngPUDvQzxd+25BQGJl2E6QfChA0J0nZVw7BUDDRnvQSAUhwIiOC2XzPPS\/XJHmp+C1M0D2CDnwpyYpQI5XAzQTHwHAt+F\/a+zJMEBxUg7MRDNQQLo\/S\/kcguy\/ARsCDFC7JUCcyM8CDypJQOViwGOrrTpANwRuvPn5Q0C28xdWBjhLQG6A0g1QOkLAcm51\/Kz+NkAWsweLXxpHQHU8yi+A6iXAHFGCdVNpP8BfjYn0QOVEwKR\/kvc3tTTAS260LLEyLkBiBKEUpx9CQJXsG0iP0znA\"},\"shape\":[42],\"dtype\":\"float64\",\"order\":\"little\"}],[\"population_display\",{\"type\":\"ndarray\",\"array\":[\"1,649,060\",\"208,334\",\"1,172,607\",\"192,823\",\"1,024,125\",\"132,380\",\"181,724\",\"922,529\",\"154,913\",\"67,764\",\"9,757,179\",\"165,432\",\"256,400\",\"89,175\",\"296,774\",\"436,251\",\"132,727\",\"102,195\",\"3,170,435\",\"433,822\",\"2,529,933\",\"1,611,231\",\"69,159\",\"2,214,281\",\"3,298,799\",\"827,526\",\"816,108\",\"281,843\",\"742,893\",\"444,500\",\"1,926,325\",\"262,406\",\"181,121\",\"455,101\",\"485,375\",\"556,972\",\"98,545\",\"64,451\",\"483,546\",\"835,427\",\"225,251\",\"87,469\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}],[\"earnings_display\",{\"type\":\"ndarray\",\"array\":[\"$64,242\",\"$36,717\",\"$60,735\",\"$55,187\",\"$40,124\",\"$31,907\",\"$35,978\",\"$37,005\",\"$37,232\",\"$36,914\",\"$44,189\",\"$34,643\",\"$65,592\",\"$35,754\",\"$36,126\",\"$39,289\",\"$51,804\",\"$49,195\",\"$50,738\",\"$61,002\",\"$42,207\",\"$48,527\",\"$52,022\",\"$41,127\",\"$51,418\",\"$81,586\",\"$43,902\",\"$39,283\",\"$72,827\",\"$39,368\",\"$74,436\",\"$41,997\",\"$43,841\",\"$49,429\",\"$50,652\",\"$42,051\",\"$38,560\",\"$38,382\",\"$36,201\",\"$47,431\",\"$42,000\",\"$45,133\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}]]}}},\"view\":{\"type\":\"object\",\"name\":\"CDSView\",\"id\":\"p1046\",\"attributes\":{\"filter\":{\"type\":\"object\",\"name\":\"AllIndices\",\"id\":\"p1047\"}}},\"glyph\":{\"type\":\"object\",\"name\":\"Scatter\",\"id\":\"p1042\",\"attributes\":{\"x\":{\"type\":\"field\",\"field\":\"bachelor_or_higher_pct\"},\"y\":{\"type\":\"field\",\"field\":\"dem_margin\"},\"size\":{\"type\":\"value\",\"value\":12},\"line_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"line_alpha\":{\"type\":\"value\",\"value\":0.75},\"fill_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"fill_alpha\":{\"type\":\"value\",\"value\":0.75},\"hatch_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"hatch_alpha\":{\"type\":\"value\",\"value\":0.75}}},\"nonselection_glyph\":{\"type\":\"object\",\"name\":\"Scatter\",\"id\":\"p1043\",\"attributes\":{\"x\":{\"type\":\"field\",\"field\":\"bachelor_or_higher_pct\"},\"y\":{\"type\":\"field\",\"field\":\"dem_margin\"},\"size\":{\"type\":\"value\",\"value\":12},\"line_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"line_alpha\":{\"type\":\"value\",\"value\":0.1},\"fill_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"fill_alpha\":{\"type\":\"value\",\"value\":0.1},\"hatch_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"hatch_alpha\":{\"type\":\"value\",\"value\":0.1}}},\"muted_glyph\":{\"type\":\"object\",\"name\":\"Scatter\",\"id\":\"p1044\",\"attributes\":{\"x\":{\"type\":\"field\",\"field\":\"bachelor_or_higher_pct\"},\"y\":{\"type\":\"field\",\"field\":\"dem_margin\"},\"size\":{\"type\":\"value\",\"value\":12},\"line_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"line_alpha\":{\"type\":\"value\",\"value\":0.2},\"fill_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"fill_alpha\":{\"type\":\"value\",\"value\":0.2},\"hatch_color\":{\"type\":\"value\",\"value\":\"#7865BF\"},\"hatch_alpha\":{\"type\":\"value\",\"value\":0.2}}}}},{\"type\":\"object\",\"name\":\"GlyphRenderer\",\"id\":\"p1054\",\"attributes\":{\"data_source\":{\"type\":\"object\",\"name\":\"ColumnDataSource\",\"id\":\"p1048\",\"attributes\":{\"selected\":{\"type\":\"object\",\"name\":\"Selection\",\"id\":\"p1049\",\"attributes\":{\"indices\":[],\"line_indices\":[]}},\"selection_policy\":{\"type\":\"object\",\"name\":\"UnionRenderers\",\"id\":\"p1050\"},\"data\":{\"type\":\"map\",\"entries\":[[\"x\",[10.441775370557956,60.376019810367055]],[\"y\",[-25.715508866388156,56.13684603899182]]]}}},\"view\":{\"type\":\"object\",\"name\":\"CDSView\",\"id\":\"p1055\",\"attributes\":{\"filter\":{\"type\":\"object\",\"name\":\"AllIndices\",\"id\":\"p1056\"}}},\"glyph\":{\"type\":\"object\",\"name\":\"Line\",\"id\":\"p1051\",\"attributes\":{\"x\":{\"type\":\"field\",\"field\":\"x\"},\"y\":{\"type\":\"field\",\"field\":\"y\"},\"line_color\":\"#333333\",\"line_width\":2}},\"nonselection_glyph\":{\"type\":\"object\",\"name\":\"Line\",\"id\":\"p1052\",\"attributes\":{\"x\":{\"type\":\"field\",\"field\":\"x\"},\"y\":{\"type\":\"field\",\"field\":\"y\"},\"line_color\":\"#333333\",\"line_alpha\":0.1,\"line_width\":2}},\"muted_glyph\":{\"type\":\"object\",\"name\":\"Line\",\"id\":\"p1053\",\"attributes\":{\"x\":{\"type\":\"field\",\"field\":\"x\"},\"y\":{\"type\":\"field\",\"field\":\"y\"},\"line_color\":\"#333333\",\"line_alpha\":0.2,\"line_width\":2}}}}],\"toolbar\":{\"type\":\"object\",\"name\":\"Toolbar\",\"id\":\"p1010\",\"attributes\":{\"tools\":[{\"type\":\"object\",\"name\":\"PanTool\",\"id\":\"p1023\"},{\"type\":\"object\",\"name\":\"WheelZoomTool\",\"id\":\"p1024\",\"attributes\":{\"renderers\":\"auto\"}},{\"type\":\"object\",\"name\":\"BoxZoomTool\",\"id\":\"p1025\",\"attributes\":{\"dimensions\":\"both\",\"overlay\":{\"type\":\"object\",\"name\":\"BoxAnnotation\",\"id\":\"p1026\",\"attributes\":{\"syncable\":false,\"line_color\":\"black\",\"line_alpha\":1.0,\"line_width\":2,\"line_dash\":[4,4],\"fill_color\":\"lightgrey\",\"fill_alpha\":0.5,\"level\":\"overlay\",\"visible\":false,\"left\":{\"type\":\"number\",\"value\":\"nan\"},\"right\":{\"type\":\"number\",\"value\":\"nan\"},\"top\":{\"type\":\"number\",\"value\":\"nan\"},\"bottom\":{\"type\":\"number\",\"value\":\"nan\"},\"left_units\":\"canvas\",\"right_units\":\"canvas\",\"top_units\":\"canvas\",\"bottom_units\":\"canvas\",\"handles\":{\"type\":\"object\",\"name\":\"BoxInteractionHandles\",\"id\":\"p1032\",\"attributes\":{\"all\":{\"type\":\"object\",\"name\":\"AreaVisuals\",\"id\":\"p1031\",\"attributes\":{\"fill_color\":\"white\",\"hover_fill_color\":\"lightgray\"}}}}}}}},{\"type\":\"object\",\"name\":\"SaveTool\",\"id\":\"p1033\"},{\"type\":\"object\",\"name\":\"ResetTool\",\"id\":\"p1034\"},{\"type\":\"object\",\"name\":\"HelpTool\",\"id\":\"p1035\"},{\"type\":\"object\",\"name\":\"HoverTool\",\"id\":\"p1059\",\"attributes\":{\"renderers\":[{\"id\":\"p1045\"}],\"tooltips\":[[\"County\",\"@county\"],[\"Population\",\"@population_display\"],[\"Earnings\",\"@earnings_display\"]]}}]}},\"left\":[{\"type\":\"object\",\"name\":\"LinearAxis\",\"id\":\"p1018\",\"attributes\":{\"ticker\":{\"type\":\"object\",\"name\":\"FixedTicker\",\"id\":\"p1058\",\"attributes\":{\"ticks\":[-40,-20,0,20,40,60],\"minor_ticks\":[]}},\"formatter\":{\"type\":\"object\",\"name\":\"BasicTickFormatter\",\"id\":\"p1020\"},\"axis_label\":\"Vote Margin\",\"major_label_overrides\":{\"type\":\"map\",\"entries\":[[-40,\"R+40\"],[-20,\"R+20\"],[0,\"TIE\"],[20,\"D+20\"],[40,\"D+40\"],[60,\"D+60\"]]},\"major_label_policy\":{\"type\":\"object\",\"name\":\"AllLabels\",\"id\":\"p1021\"}}}],\"below\":[{\"type\":\"object\",\"name\":\"LinearAxis\",\"id\":\"p1013\",\"attributes\":{\"ticker\":{\"type\":\"object\",\"name\":\"FixedTicker\",\"id\":\"p1057\",\"attributes\":{\"ticks\":[10,20,30,40,50,60],\"minor_ticks\":[]}},\"formatter\":{\"type\":\"object\",\"name\":\"BasicTickFormatter\",\"id\":\"p1015\"},\"axis_label\":\"Bachelor's Degree or Higher\",\"major_label_overrides\":{\"type\":\"map\",\"entries\":[[10,\"10%\"],[20,\"20%\"],[30,\"30%\"],[40,\"40%\"],[50,\"50%\"],[60,\"60%\"]]},\"major_label_policy\":{\"type\":\"object\",\"name\":\"AllLabels\",\"id\":\"p1016\"}}}],\"center\":[{\"type\":\"object\",\"name\":\"Grid\",\"id\":\"p1017\",\"attributes\":{\"axis\":{\"id\":\"p1013\"}}},{\"type\":\"object\",\"name\":\"Grid\",\"id\":\"p1022\",\"attributes\":{\"dimension\":1,\"axis\":{\"id\":\"p1018\"}}}]}},{\"type\":\"object\",\"name\":\"Slider\",\"id\":\"p1060\",\"attributes\":{\"js_property_callbacks\":{\"type\":\"map\",\"entries\":[[\"change:value\",[{\"type\":\"object\",\"name\":\"CustomJS\",\"id\":\"p1064\",\"attributes\":{\"args\":{\"type\":\"map\",\"entries\":[[\"data_source\",{\"id\":\"p1036\"}],[\"original_data_source\",{\"type\":\"object\",\"name\":\"ColumnDataSource\",\"id\":\"p1061\",\"attributes\":{\"selected\":{\"type\":\"object\",\"name\":\"Selection\",\"id\":\"p1062\",\"attributes\":{\"indices\":[],\"line_indices\":[]}},\"selection_policy\":{\"type\":\"object\",\"name\":\"UnionRenderers\",\"id\":\"p1063\"},\"data\":{\"type\":\"map\",\"entries\":[[\"index\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"KQAAACoAAAArAAAALAAAAC0AAAAuAAAALwAAADAAAAAxAAAAMgAAADMAAAA0AAAANQAAADYAAAA3AAAAOAAAADkAAAA6AAAAOwAAADwAAAA9AAAAPgAAAD8AAABAAAAAQQAAAEIAAABDAAAARAAAAEUAAABGAAAARwAAAEgAAABJAAAASgAAAEsAAABMAAAATQAAAE4AAABPAAAAUAAAAFEAAABSAAAA\"},\"shape\":[42],\"dtype\":\"int32\",\"order\":\"little\"}],[\"county\",{\"type\":\"ndarray\",\"array\":[\"Alameda County\",\"Butte County\",\"Contra Costa County\",\"El Dorado County\",\"Fresno County\",\"Humboldt County\",\"Imperial County\",\"Kern County\",\"Kings County\",\"Lake County\",\"Los Angeles County\",\"Madera County\",\"Marin County\",\"Mendocino County\",\"Merced County\",\"Monterey County\",\"Napa County\",\"Nevada County\",\"Orange County\",\"Placer County\",\"Riverside County\",\"Sacramento County\",\"San Benito County\",\"San Bernardino County\",\"San Diego County\",\"San Francisco County\",\"San Joaquin County\",\"San Luis Obispo County\",\"San Mateo County\",\"Santa Barbara County\",\"Santa Clara County\",\"Santa Cruz County\",\"Shasta County\",\"Solano County\",\"Sonoma County\",\"Stanislaus County\",\"Sutter County\",\"Tehama County\",\"Tulare County\",\"Ventura County\",\"Yolo County\",\"Yuba County\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}],[\"state\",{\"type\":\"ndarray\",\"array\":[\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\",\"California\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}],[\"population\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"pCkZAM4tAwB\/5BEAN\/ECAH2gDwAcBQIA3MUCAKETDgAhXQIAtAgBAPvhlAA4hgIAkOkDAFdcAQBGhwQAG6gGAHcGAgAzjwEAg2AwAJ6eBgCNmiYA35UYACcOAQCJySEA71UyAIagDADscwwA80wEAO1VCwBUyAYAtWQdAAYBBACBwwIAvfEGAP9nBwCsfwgA8YABAMP7AADaYAcAY78MAONvAwCtVQEA\"},\"shape\":[42],\"dtype\":\"int32\",\"order\":\"little\"}],[\"bachelor_or_higher_pct\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"qBZmZm\/zSUCf8PduE9o9QAwGq154GEdAaGgZej5VREAbHmZC88Q4QCo4jPwUDkJA5ZAi7EOQMEA4Jv5qEwoyQAxzoWEw4iRAqWgpotLVMEApDWoH50BCQAd0HHdsLi9Abj03bBn0TUBuD4ronGk8QE42quvm+ixA3alTot3tPECQx3RAp8NDQEc6bKTDekRAty9qK+ZDRkCQKF4lbyRGQOMYDAulgjpAPjdE8zaNQUCsM4djA5E2QMfBcz2InDdAnY1DGaDFRUBaFspqITBOQIqrcxiEnjdARBUuCCYSRUCj21BRwmpLQNHRfJmsDkJAb7O+mZ96TEBft6uDhjhGQCYHzhSw5TZAO+HAoksPPUCzElCcCLdCQLEElq6R+TRAye1DzVoVM0DrjYZMIDc2QA5F0sK\/6zBAi91JOO\/4QkBV+eCUkBhHQDC89S9kfjBA\"},\"shape\":[42],\"dtype\":\"float64\",\"order\":\"little\"}],[\"median_earnings\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"8voAAG2PAAA\/7QAAk9cAALycAACjfAAAiowAAI2QAABwkQAAMpAAAJ2sAABThwAAOAABAKqLAAAejQAAeZkAAFzKAAArwAAAMsYAAEruAADfpAAAj70AADbLAACnoAAA2sgAALI+AQB+qwAAc5kAAHscAQDImQAAxCIBAA2kAABBqwAAFcEAANzFAABDpAAAoJYAAO6VAABpjQAAR7kAABCkAABNsAAA\"},\"shape\":[42],\"dtype\":\"int32\",\"order\":\"little\"}],[\"dem_margin\",{\"type\":\"ndarray\",\"array\":{\"type\":\"bytes\",\"data\":\"wN6m1QHISkCxilaq1vkIwICjEHcj+UJA+ZqiaXv2J8AMoB\/4mooRwNU0frR\/ZjxAap+elaFs679CFHpQIQ41wK8jiNIGBjfAFjXmN6vx9b+4dOazPXVAQPs7ULtdzTTARuvT3hH0T0A5hHrJDdQ6QGQMfauibhHAvwZuOVngPUDvQzxd+25BQGJl2E6QfChA0J0nZVw7BUDDRnvQSAUhwIiOC2XzPPS\/XJHmp+C1M0D2CDnwpyYpQI5XAzQTHwHAt+F\/a+zJMEBxUg7MRDNQQLo\/S\/kcguy\/ARsCDFC7JUCcyM8CDypJQOViwGOrrTpANwRuvPn5Q0C28xdWBjhLQG6A0g1QOkLAcm51\/Kz+NkAWsweLXxpHQHU8yi+A6iXAHFGCdVNpP8BfjYn0QOVEwKR\/kvc3tTTAS260LLEyLkBiBKEUpx9CQJXsG0iP0znA\"},\"shape\":[42],\"dtype\":\"float64\",\"order\":\"little\"}],[\"population_display\",{\"type\":\"ndarray\",\"array\":[\"1,649,060\",\"208,334\",\"1,172,607\",\"192,823\",\"1,024,125\",\"132,380\",\"181,724\",\"922,529\",\"154,913\",\"67,764\",\"9,757,179\",\"165,432\",\"256,400\",\"89,175\",\"296,774\",\"436,251\",\"132,727\",\"102,195\",\"3,170,435\",\"433,822\",\"2,529,933\",\"1,611,231\",\"69,159\",\"2,214,281\",\"3,298,799\",\"827,526\",\"816,108\",\"281,843\",\"742,893\",\"444,500\",\"1,926,325\",\"262,406\",\"181,121\",\"455,101\",\"485,375\",\"556,972\",\"98,545\",\"64,451\",\"483,546\",\"835,427\",\"225,251\",\"87,469\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}],[\"earnings_display\",{\"type\":\"ndarray\",\"array\":[\"$64,242\",\"$36,717\",\"$60,735\",\"$55,187\",\"$40,124\",\"$31,907\",\"$35,978\",\"$37,005\",\"$37,232\",\"$36,914\",\"$44,189\",\"$34,643\",\"$65,592\",\"$35,754\",\"$36,126\",\"$39,289\",\"$51,804\",\"$49,195\",\"$50,738\",\"$61,002\",\"$42,207\",\"$48,527\",\"$52,022\",\"$41,127\",\"$51,418\",\"$81,586\",\"$43,902\",\"$39,283\",\"$72,827\",\"$39,368\",\"$74,436\",\"$41,997\",\"$43,841\",\"$49,429\",\"$50,652\",\"$42,051\",\"$38,560\",\"$38,382\",\"$36,201\",\"$47,431\",\"$42,000\",\"$45,133\"],\"shape\":[42],\"dtype\":\"object\",\"order\":\"little\"}]]}}}],[\"slider\",{\"id\":\"p1060\"}]]},\"code\":\"\\n    var original_data = original_data_source.data;\\n\\n    var filtered_data = {bachelor_or_higher_pct: [], \\n                         dem_margin: [],\\n                         county: [],\\n                         population_display: [],\\n                         earnings_display: []};\\n\\n    for (var i = 0; i &lt; original_data['median_earnings'].length; i++) {\\n        if (original_data['median_earnings'][i] &gt;= slider.value) {\\n            filtered_data.bachelor_or_higher_pct.push(original_data['bachelor_or_higher_pct'][i]);\\n            filtered_data.dem_margin.push(original_data['dem_margin'][i]);\\n            filtered_data.county.push(original_data['county'][i]);\\n            filtered_data.population_display.push(original_data['population_display'][i]);\\n            filtered_data.earnings_display.push(original_data['earnings_display'][i]);\\n        }\\n    }\\n    \\n    data_source.data = filtered_data;\"}}]]]},\"width\":250,\"title\":\"Median Earnings (Minimum)\",\"start\":30000,\"end\":75000,\"value\":30000,\"step\":1000}}]}}]}}\r\n<\/script>\r\n<script type=\"text\/javascript\">\r\n\t(function() {\r\n\t\tconst fn = function() {\r\n\t\t\tBokeh.safely(function() {\r\n\t\t\t\t(function(root) {\r\n\t\t\t\t\tfunction embed_document(root) {\r\n\t\t\t\t\t\tconst docs_json = document.getElementById('c7520e8f-ef33-492d-b9cb-5fb19c959d7a').textContent;\r\n\t\t\t\t\t\tconst render_items = [{\"docid\":\"d076ca5b-ca55-4b7d-9283-13c6ba17872c\",\"roots\":{\"p1065\":\"ae754c91-56cf-47db-af8c-2f3587353e37\"},\"root_ids\":[\"p1065\"]}];\r\n\t\t\t\t\t\troot.Bokeh.embed.embed_items(docs_json, render_items);\r\n\t\t\t\t\t}\r\n\t\t\t\t\tif (root.Bokeh !== undefined) {\r\n\t\t\t\t\t\tembed_document(root);\r\n\t\t\t\t\t} else {\r\n\t\t\t\t\t\tlet attempts = 0;\r\n\t\t\t\t\t\tconst timer = setInterval(function(root) {\r\n\t\t\t\t\t\t\tif (root.Bokeh !== undefined) {\r\n\t\t\t\t\t\t\t\tclearInterval(timer);\r\n\t\t\t\t\t\t\t\tembed_document(root);\r\n\t\t\t\t\t\t\t} else {\r\n\t\t\t\t\t\t\t\tattempts++;\r\n\t\t\t\t\t\t\t\tif (attempts > 100) {\r\n\t\t\t\t\t\t\t\t\tclearInterval(timer);\r\n\t\t\t\t\t\t\t\t\tconsole.log(\"Bokeh: ERROR: Unable to run BokehJS code because BokehJS library is missing\");\r\n\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}, 10, root)\r\n\t\t\t\t\t\t}\r\n\t\t\t\t})(window);\r\n\t\t\t});\r\n\t\t};\r\n\t\tif (document.readyState != \"loading\") fn();\r\n\t\telse document.addEventListener(\"DOMContentLoaded\", fn);\r\n\t})();\r\n<\/script>\n<!-- \/end HFCM by 99 Robots -->\n\n<p>I tried to scale the plot to fit both desktop and mobile browsers but it ends up looking awkward in both. You get the idea.<\/p>\n<p>The hover tooltips may not work on mobile devices. If you&#8217;re using a desktop browser, hover your mouse over a dot to see information about the county it represents.<\/p>\n<p>Drag the slider to change minimum earnings. You can see that higher-income counties tended to vote for Harris. Earnings are only slightly less correlated with vote preference than our actual x-axis variable, education. That&#8217;s not surprising because education and income tend to go together.<\/p>\n<p>In this model, earnings would be considered a <em>confounding<\/em> variable. It&#8217;s associated with both the x and y variables so we can&#8217;t draw conclusions about cause and effect. Maybe high incomes caused people to vote for Harris regardless of education. Or maybe there&#8217;s a hidden variable driving the correlations. <strong>That uncertainty is okay.<\/strong> It&#8217;s still helpful to know that education is strongly correlated with vote preference.<\/p>\n<p>I get it if you aren&#8217;t super interested in California elections, but you can begin to imagine what&#8217;s possible with Bokeh interactive plots. Check out their <a href=\"https:\/\/docs.bokeh.org\/en\/latest\/docs\/gallery.html\" target=\"_blank\" rel=\"noopener\">example gallery<\/a>, especially the <em>Interaction<\/em> tab. They show off several widgets like <code>Slider<\/code> that can be added to plots.<\/p>\n<p>I&#8217;ve immediately become a big fan of the library. Chances are I&#8217;ll use it again in a blog post. I have an ancient-looking <a href=\"https:\/\/developers.google.com\/chart\/interactive\/docs\/quick_start\" target=\"_blank\" rel=\"noopener\">Google Charts<\/a> dashboard that I put together nearly a decade ago. If Google ever shuts down the API, I think I&#8217;ll rebuild it with Bokeh.<!-- HFCM by 99 Robots - Snippet # 15: endmark-python -->\n<span class=\"endmark-python\"><\/span>\n<!-- \/end HFCM by 99 Robots -->\n<\/p>\n<hr \/>\n<p><a href=\"https:\/\/wollen.org\/misc\/bokeh_california_2025.zip\"><strong>Download the data.<\/strong><\/a><\/p>\n<p><strong>Full code:<\/strong><\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\nfrom scipy.stats import linregress\r\nfrom bokeh.plotting import figure, show\r\nfrom bokeh.models import ColumnDataSource, HoverTool, Slider\r\nfrom bokeh.models.callbacks import CustomJS\r\nfrom bokeh.layouts import column\r\n\r\n\r\ndef get_ytick_labels(ticks):\r\n    labels = []\r\n    for tick in ticks:\r\n        if tick &gt; 0:\r\n            labels.append(f\"D+{tick}\")\r\n        elif tick &lt; 0:\r\n            labels.append(f\"R+{abs(tick)}\")\r\n        else:\r\n            labels.append(\"TIE\")\r\n    return dict(zip(ticks, labels))\r\n\r\n\r\npd.set_option(\"display.expand_frame_repr\", False)\r\n\r\ndf = pd.read_csv(\"county_data.csv\")\r\n\r\ndf = df[df['state'] == \"California\"]\r\n\r\ndf.loc[:, 'population_display'] = df['population'].apply(lambda x: f\"{x:,}\")\r\n\r\ndf.loc[:, 'earnings_display'] = df['median_earnings'].apply(lambda x: f\"${x:,}\")\r\n\r\nslope, intercept, r_value, p_value, std_err = linregress(df['bachelor_or_higher_pct'], df['dem_margin'])\r\n\r\nx_reg = [df['bachelor_or_higher_pct'].min(), df['bachelor_or_higher_pct'].max()]\r\ny_reg = [n * slope + intercept for n in x_reg]\r\n\r\nfig = figure(height=400,\r\n             sizing_mode=\"stretch_width\",\r\n             title=f\"California Counties  |  2024 Presidential Vote  |  R\u00b2 = {r_value ** 2:.2f}\",\r\n             x_axis_label=\"Bachelor's Degree or Higher\",\r\n             y_axis_label=\"Vote Margin\")\r\n\r\ndata_source = ColumnDataSource(data=df)\r\n\r\nscatter1 = fig.scatter(source=data_source,\r\n                       x=\"bachelor_or_higher_pct\",\r\n                       y=\"dem_margin\",\r\n                       size=12,\r\n                       color=\"#7865BF\",\r\n                       alpha=0.75)\r\n\r\nfig.line(x=x_reg,\r\n         y=y_reg,\r\n         line_width=2,\r\n         color=\"#333333\")\r\n\r\nx_ticks = list(range(10, 70, 10))\r\nfig.xaxis.ticker = x_ticks\r\nfig.xaxis.major_label_overrides = {n: f\"{n}%\" for n in x_ticks}\r\nfig.x_range.start = 8\r\nfig.x_range.end = 63\r\n\r\ny_ticks = list(range(-40, 80, 20))\r\nfig.yaxis.ticker = y_ticks\r\nfig.yaxis.major_label_overrides = get_ytick_labels(y_ticks)\r\nfig.y_range.start = -50\r\nfig.y_range.end = 70\r\n\r\ntool_tips = [(\"County\", \"@county\"),\r\n             (\"Population\", \"@population_display\"),\r\n             (\"Earnings\", \"@earnings_display\")]\r\nfig.add_tools(HoverTool(renderers=[scatter1], tooltips=tool_tips))\r\n\r\nslider = Slider(start=30000,\r\n                end=75000,\r\n                step=1000,\r\n                value=30000,\r\n                title=\"Median Earnings (Minimum)\",\r\n                width=250)\r\n\r\noriginal_data_source = ColumnDataSource(data=df)\r\n\r\ncallback = CustomJS(args={\"data_source\": data_source,\r\n                          \"original_data_source\": original_data_source,\r\n                          \"slider\": slider},\r\n                    code=\"\"\"\r\n    var original_data = original_data_source.data;\r\n\r\n    var filtered_data = {bachelor_or_higher_pct: [], \r\n                         dem_margin: [],\r\n                         county: [],\r\n                         population_display: [],\r\n                         earnings_display: []};\r\n\r\n    for (var i = 0; i &lt; original_data['median_earnings'].length; i++) {\r\n        if (original_data['median_earnings'][i] &gt;= slider.value) {\r\n            filtered_data.bachelor_or_higher_pct.push(original_data['bachelor_or_higher_pct'][i]);\r\n            filtered_data.dem_margin.push(original_data['dem_margin'][i]);\r\n            filtered_data.county.push(original_data['county'][i]);\r\n            filtered_data.population_display.push(original_data['population_display'][i]);\r\n            filtered_data.earnings_display.push(original_data['earnings_display'][i]);\r\n        }\r\n    }\r\n    \r\n    data_source.data = filtered_data;\"\"\")\r\n\r\nslider.js_on_change(\"value\", callback)\r\n\r\nshow(column(fig, slider, sizing_mode=\"stretch_width\"))<\/pre>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;m most comfortable with Matplotlib. I think that&#8217;s obvious at this point. I&#8217;m also well aware that it has a (*ahem*) troubled reputation, despite its large footprint in the data world. Much of that reputation is deserved, of course. It&#8217;s<\/p>\n","protected":false},"author":1,"featured_media":2397,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[238,469],"tags":[468,470,479,474,472,23,482,481,480,473,22,172,122,485,483,185,467,476,484,471,486,24,30,31,25,117,201,475,63,116,477,395,141,294,478],"class_list":["post-2359","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-government","category-stats","tag-468","tag-bokeh","tag-california","tag-callback","tag-causal-inference","tag-census","tag-census-bureau","tag-counties","tag-county","tag-customjs","tag-data","tag-data-viz","tag-dataset","tag-earnings","tag-education","tag-election","tag-harris","tag-hover","tag-income","tag-javascript","tag-margin","tag-matplotlib","tag-pandas","tag-population","tag-python","tag-regression","tag-scatter","tag-slider","tag-statistics","tag-stats","tag-tooltip","tag-trump","tag-visualization","tag-vote","tag-widget"],"_links":{"self":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2359","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/comments?post=2359"}],"version-history":[{"count":31,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2359\/revisions"}],"predecessor-version":[{"id":3257,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2359\/revisions\/3257"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media\/2397"}],"wp:attachment":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media?parent=2359"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/categories?post=2359"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/tags?post=2359"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}