<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: datasette-public-office-hours</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/datasette-public-office-hours.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2025-06-03T19:42:35+00:00</updated><author><name>Simon Willison</name></author><entry><title>Datasette Public Office Hours: Tools in LLM</title><link href="https://simonwillison.net/2025/Jun/3/datasette-public-office-hours/#atom-tag" rel="alternate"/><published>2025-06-03T19:42:35+00:00</published><updated>2025-06-03T19:42:35+00:00</updated><id>https://simonwillison.net/2025/Jun/3/datasette-public-office-hours/#atom-tag</id><summary type="html">
    &lt;p&gt;We're hosting the sixth in our series of Datasette Public Office Hours livestream sessions this Friday, 6th of June at 2pm PST (here's &lt;a href="http://www.worldtimebuddy.com/event?lid=5391959&amp;amp;h=5391959&amp;amp;sts=29153220&amp;amp;sln=14-15.5&amp;amp;a=show&amp;amp;euid=55524301-3dd4-c954-b5df-239b73f1a7da"&gt;that time in your location&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The topic is going to be &lt;strong&gt;tool support in LLM&lt;/strong&gt;, as &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;introduced here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'll be walking through the new features, and we're also inviting five minute lightning demos from community members who are doing fun things with the new capabilities. If you'd like to present one of those please get in touch &lt;a href="https://docs.google.com/forms/d/e/1FAIpQLSf4EGqdTWUXII7gBxdvsUbIVR-vECjfssrVni-R3Bzc8ns-bA/viewform?usp=dialog"&gt;via this form&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Datasette Public Office Hours #06 - Tool Support in LLM! Friday June 6th, 2025 @ 2pm PST Hosted in the Datasette Discord https://discord.gg/M4tFcgVFXf" src="https://static.simonwillison.net/static/2025/tool-support.png" /&gt;&lt;/p&gt;
&lt;p&gt;Here's a link to &lt;a href="https://calendar.google.com/calendar/u/0/r/eventedit?text=Datasette+Public+Office+Hours+%2306&amp;amp;details=Tool+support+in+LLM+-+https://discord.gg/Pb5dRA8RTa?event%3D1379484629672661122&amp;amp;location&amp;amp;dates=20250606T140000/20250606T153000&amp;amp;ctz=America/Los_Angeles"&gt;add it to Google Calendar&lt;/a&gt;.&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-tool-use"&gt;llm-tool-use&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="llm-tool-use"/><category term="datasette-public-office-hours"/></entry><entry><title>Datasette Public Office Hours 31st Jan at 2pm Pacific</title><link href="https://simonwillison.net/2025/Jan/30/datasette-public-office-hours/#atom-tag" rel="alternate"/><published>2025-01-30T21:45:57+00:00</published><updated>2025-01-30T21:45:57+00:00</updated><id>https://simonwillison.net/2025/Jan/30/datasette-public-office-hours/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://discord.gg/Pb5dRA8RTa?event=1329974203805601832"&gt;Datasette Public Office Hours 31st Jan at 2pm Pacific&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
We're running another &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours/"&gt;Datasette Public Office Hours&lt;/a&gt; session on Friday 31st January at 2pm Pacific (&lt;a href="https://www.timeanddate.com/worldclock/converter.html?iso=20250131T220000&amp;amp;p1=224&amp;amp;p2=75&amp;amp;p3=2485&amp;amp;p4=179&amp;amp;p5=136"&gt;more timezones here&lt;/a&gt;). We'll be featuring demos from the community again - take a look at the videos &lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/"&gt;of the six demos&lt;/a&gt; from our last session for an idea of what to expect.&lt;/p&gt;
&lt;p&gt;&lt;img alt="" src="https://static.simonwillison.net/static/2025/public-office-hours-31-jan.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;If you have something you would like to show, please &lt;a href="https://forms.gle/1k5i8Ku9DeoyN7EN9"&gt;drop us a line&lt;/a&gt;! We still have room for a few more demos.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/community"&gt;community&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/discord"&gt;discord&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;&lt;/p&gt;



</summary><category term="community"/><category term="datasette"/><category term="discord"/><category term="datasette-public-office-hours"/></entry><entry><title>Six short video demos of LLM and Datasette projects</title><link href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#atom-tag" rel="alternate"/><published>2025-01-22T02:09:54+00:00</published><updated>2025-01-22T02:09:54+00:00</updated><id>https://simonwillison.net/2025/Jan/22/office-hours-demos/#atom-tag</id><summary type="html">
    &lt;p&gt;Last Friday Alex Garcia and I hosted a new kind of Datasette Public Office Hours session, inviting members of the Datasette community to share short demos of projects that they had built. The session lasted just over an hour and featured demos from six different people.&lt;/p&gt;
&lt;p&gt;We broadcast live on YouTube, but I've now edited the session into separate videos. These are listed below, along with project summaries and show notes for each presentation.&lt;/p&gt;
&lt;p&gt;You can also watch all six videos in &lt;a href="https://www.youtube.com/playlist?list=PLSocEbMlNGotyeonEbgFP1_uf9gk1z7zm"&gt;this YouTube playlist&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#llm-logs-feedback-by-matthias-l-bken"&gt;llm-logs-feedback by Matthias Lübken&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#llm-model-gateway-and-llm-consortium-by-thomas-hughes"&gt;llm-model-gateway and llm-consortium by Thomas Hughes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#congressional-travel-explorer-with-derek-willis"&gt;Congressional Travel Explorer with Derek Willis&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#llm-questioncache-with-nat-knight"&gt;llm-questioncache with Nat Knight&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#improvements-to-datasette-enrichments-with-simon-willison"&gt;Improvements to Datasette Enrichments with Simon Willison&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Jan/22/office-hours-demos/#datasette-comments-pins-and-write-ui-with-alex-garcia"&gt;Datasette comments, pins and write UI with Alex Garcia&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="llm-logs-feedback-by-matthias-l-bken"&gt;llm-logs-feedback by Matthias Lübken&lt;/h4&gt;
&lt;p&gt;&lt;lite-youtube videoid="9pEP6auZmvg"
  title="llm-logs-feedback by Matthias Lübken"
  playlabel="Play: llm-logs-feedback by Matthias Lübken"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/luebken/llm-logs-feedback"&gt;llm-logs-feedback&lt;/a&gt; is a plugin by Matthias Lübken for &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; which adds the ability to store feedback on prompt responses, using new &lt;code&gt;llm feedback+1&lt;/code&gt; and &lt;code&gt;llm feedback-1&lt;/code&gt; commands. These also accept an optional comment, and the feedback is stored in a &lt;code&gt;feedback&lt;/code&gt; table in SQLite.&lt;/p&gt;
&lt;p&gt;You can install the plugin from PyPI like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-logs-feedback&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The full plugin implementation is in the &lt;a href="https://github.com/luebken/llm-logs-feedback/blob/main/llm_logs_feedback.py"&gt;llm_logs_feedback.py file&lt;/a&gt; in Matthias' GitHub repository.&lt;/p&gt;
&lt;h4 id="llm-model-gateway-and-llm-consortium-by-thomas-hughes"&gt;llm-model-gateway and llm-consortium by Thomas Hughes&lt;/h4&gt;
&lt;p&gt;&lt;lite-youtube videoid="Th5WOyjuRdk"
  title="llm-model-gateway and llm-consortium by Thomas Hughes"
  playlabel="Play: llm-model-gateway and llm-consortium by Thomas Hughes"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;
&lt;p&gt;Tommy Hughes has been developing a whole array of LLM plugins, including his &lt;a href="https://github.com/irthomasthomas/llm-plugin-generator"&gt;llm-plugin-generator&lt;/a&gt; which is a plugin that can help write new plugins!&lt;/p&gt;
&lt;p&gt;He started by demonstrating &lt;a href="https://github.com/irthomasthomas/llm-model-gateway"&gt;llm-model-gateway&lt;/a&gt;, a plugin that adds a &lt;code&gt;llm serve&lt;/code&gt; command which starts a localhost server running an imitation of the OpenAI API against LLM models:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-model-gateway
llm serve&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Tommy's main demo was of &lt;a href="https://github.com/irthomasthomas/llm-consortium"&gt;llm-consortium&lt;/a&gt;, a plugin which can use a consortium of collaborating LLM models working together to solve problems.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-consortium
llm consortium &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Best way to prepare avocado toast&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  --models claude-3.5-sonnet \
  --models gpt-4o \
  --arbiter gegemini-1.5-pro-latest \
  --confidence-threshold 0.8 \
  --max-iterations 3 \
  --output results.json&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I ran this and got back:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The best way to prepare avocado toast involves selecting quality ingredients and careful preparation. Start with a thick slice of good quality bread, such as sourdough or whole grain. Toast it to your preferred level of crispness.  While the bread is toasting, prepare a ripe avocado. Cut it in half, remove the pit, and scoop the flesh into a bowl. Mash the avocado with a fork, leaving some chunks for texture. Season with salt, black pepper, and a squeeze of fresh lemon juice to prevent browning.  Optional additions include a touch of red pepper flakes.&lt;/p&gt;
&lt;p&gt;Once the toast is ready, let it cool slightly before spreading the seasoned avocado evenly over it. Consider lightly rubbing a cut garlic clove on the warm toast for an extra layer of flavor (optional).&lt;/p&gt;
&lt;p&gt;Enhance your avocado toast with your favorite toppings. Popular choices include: everything bagel seasoning, sliced tomatoes, radishes, a poached or fried egg (for added protein), microgreens, smoked salmon (for a more savory option), feta cheese crumbles, or a drizzle of hot sauce.  For a finishing touch, drizzle with high-quality olive oil and sprinkle with sesame or chia seeds for added texture.&lt;/p&gt;
&lt;p&gt;Consider dietary needs when choosing toppings. For example, those following a low-carb diet might skip the tomatoes and opt for more protein and healthy fats.&lt;/p&gt;
&lt;p&gt;Finally, pay attention to presentation. Arrange the toppings neatly for a visually appealing toast. Serve immediately to enjoy the fresh flavors and crispy toast.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But the really interesting thing is the full log of the prompts and responses sent to Claude 3.5 Sonnet and GPT-4o, followed by a combined prompt to Gemini 1.5 Pro to have it arbitrate between the two responses. You can see &lt;a href="https://gist.github.com/simonw/425f42f8ec1a963ae13c5b57ba580f56"&gt;the full logged prompts and responses here&lt;/a&gt;. Here's that &lt;a href="https://gist.github.com/simonw/e82370f0e5986a15823c82200c1b77f8"&gt;results.json&lt;/a&gt; output file.&lt;/p&gt;
&lt;h4 id="congressional-travel-explorer-with-derek-willis"&gt;Congressional Travel Explorer with Derek Willis&lt;/h4&gt;
&lt;p&gt;&lt;lite-youtube videoid="CDilLbFP1DY"
  title="Congressional Travel Explorer with Derek Willis"
  playlabel="Play: Congressional Travel Explorer with Derek Willis"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;
&lt;p&gt;Derek Willis teaches data journalism at the Philip Merrill College of Journalism at the University of Maryland. For a recent project his students built a &lt;a href="https://cnsmaryland.org/interactives/fall-2024/congressional_travel_explorer/index.html"&gt;Congressional Travel Explorer&lt;/a&gt; interactive using Datasette, AWS Extract and Claude 3.5 Sonnet to analyze travel disclosures from members of Congress.&lt;/p&gt;
&lt;p&gt;One of the outcomes from the project was this story in Politico: &lt;a href="https://www.politico.com/news/2024/10/30/israel-aipac-funded-congress-travel-00185167"&gt;Members of Congress have taken hundreds of AIPAC-funded trips to Israel in the past decade&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="llm-questioncache-with-nat-knight"&gt;llm-questioncache with Nat Knight&lt;/h4&gt;
&lt;p&gt;&lt;lite-youtube videoid="lXwfEYXjsak"
  title="llm-questioncache with Nat Knight"
  playlabel="Play: llm-questioncache with Nat Knight"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/nathanielknight/llm-questioncache"&gt;llm-questioncache&lt;/a&gt; builds on top of &lt;a href="https://llm.datasette.io/"&gt;https://llm.datasette.io/&lt;/a&gt; to cache answers to questions, using embeddings to return similar answers if they have already been stored.&lt;/p&gt;
&lt;p&gt;Using embeddings for de-duplication of similar questions is an interesting way to apply LLM's &lt;a href="https://llm.datasette.io/en/stable/embeddings/python-api.html"&gt;embeddings feature&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="improvements-to-datasette-enrichments-with-simon-willison"&gt;Improvements to Datasette Enrichments with Simon Willison&lt;/h4&gt;
&lt;p&gt;&lt;lite-youtube videoid="GumAgaYpda0"
  title="Improvements to Datasette Enrichments with Simon Willison"
  playlabel="Play: Improvements to Datasette Enrichments with Simon Willison"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;
&lt;p&gt;I've demonstrated improvements I've been making to Datasette's &lt;a href="https://enrichments.datasette.io/"&gt;Enrichments&lt;/a&gt; system over the past few weeks.&lt;/p&gt;
&lt;p&gt;Enrichments allow you to apply an operation - such as geocoding, a QuickJS JavaScript transformation or an LLM prompt - against selected rows within a table.&lt;/p&gt;
&lt;p&gt;The latest release of &lt;a href="https://github.com/datasette/datasette-enrichments/releases/tag/0.5"&gt;datasette-enrichments&lt;/a&gt; adds visible progress bars and the ability to pause, resume and cancel an enrichment job that is running against a table.&lt;/p&gt;
&lt;h4 id="datasette-comments-pins-and-write-ui-with-alex-garcia"&gt;Datasette comments, pins and write UI with Alex Garcia&lt;/h4&gt;
&lt;p&gt;&lt;lite-youtube videoid="i0u4N6g15Zg"
  title="Datasette comments, pins and write UI with Alex Garcia"
  playlabel="Play: Datasette comments, pins and write UI with Alex Garcia"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;
&lt;p&gt;We finished with three plugin demos from Alex, showcasing collaborative features we have been developing for &lt;a href="https://www.datasette.cloud/"&gt;Datasette Cloud&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/datasette/datasette-write-ui"&gt;datasette-write-ui&lt;/a&gt; provides tools for editing and adding data to Datasette tables. A new feature here is the ability to shift-click a row to open the editing interface for that row.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/datasette/datasette-pins"&gt;datasette-pins&lt;/a&gt; allows users to pin tables and databases to their Datasette home page, making them easier to find.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/datasette/datasette-comments"&gt;datasette-comments&lt;/a&gt; adds a commenting interface to Datasette, allowing users to leave comments on individual rows in a table.&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/community"&gt;community&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/demos"&gt;demos&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/derek-willis"&gt;derek-willis&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/youtube"&gt;youtube&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alex-garcia"&gt;alex-garcia&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/enrichments"&gt;enrichments&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="community"/><category term="data-journalism"/><category term="demos"/><category term="derek-willis"/><category term="youtube"/><category term="ai"/><category term="datasette"/><category term="alex-garcia"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="enrichments"/><category term="datasette-public-office-hours"/></entry><entry><title>Datasette Public Office Hours Application</title><link href="https://simonwillison.net/2025/Jan/16/datasette-public-office-hours/#atom-tag" rel="alternate"/><published>2025-01-16T18:38:31+00:00</published><updated>2025-01-16T18:38:31+00:00</updated><id>https://simonwillison.net/2025/Jan/16/datasette-public-office-hours/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.google.com/forms/d/e/1FAIpQLSf4EGqdTWUXII7gBxdvsUbIVR-vECjfssrVni-R3Bzc8ns-bA/viewform"&gt;Datasette Public Office Hours Application&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
We are running another Datasette Public Office Hours event &lt;a href="https://discord.gg/38DnWBvQ?event=1328432594295066664"&gt;on Discord&lt;/a&gt; tomorrow (Friday 17th January 2025) at 2pm Pacific / 5pm Eastern / 10pm GMT / &lt;a href="https://www.timeanddate.com/worldclock/converter.html?iso=20250117T220000&amp;amp;p1=224&amp;amp;p2=75&amp;amp;p3=2485&amp;amp;p4=179&amp;amp;p5=136"&gt;more timezones here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The theme this time around is &lt;strong&gt;lightning talks&lt;/strong&gt; - we're looking for 5-8 minute long talks from community members about projects they are working on or things they have built using the Datasette family of tools (which includes &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; and &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; as well).&lt;/p&gt;
&lt;p&gt;If you have a demo you'd like to share, please &lt;a href="https://docs.google.com/forms/d/e/1FAIpQLSf4EGqdTWUXII7gBxdvsUbIVR-vECjfssrVni-R3Bzc8ns-bA/viewform"&gt;let us know&lt;/a&gt; via this form.&lt;/p&gt;
&lt;p&gt;I'm going to be demonstrating my recent work on the next generation of &lt;a href="https://enrichments.datasette.io/"&gt;Datasette Enrichments&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/lightning-talks"&gt;lightning-talks&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/discord"&gt;discord&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/enrichments"&gt;enrichments&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;&lt;/p&gt;



</summary><category term="lightning-talks"/><category term="datasette"/><category term="discord"/><category term="enrichments"/><category term="datasette-public-office-hours"/></entry><entry><title>Project: Civic Band - scraping and searching PDF meeting minutes from hundreds of municipalities</title><link href="https://simonwillison.net/2024/Nov/16/civic-band/#atom-tag" rel="alternate"/><published>2024-11-16T22:14:01+00:00</published><updated>2024-11-16T22:14:01+00:00</updated><id>https://simonwillison.net/2024/Nov/16/civic-band/#atom-tag</id><summary type="html">
    &lt;p&gt;I interviewed &lt;a href="https://phildini.dev/"&gt;Philip James&lt;/a&gt; about &lt;a href="https://civic.band/"&gt;Civic Band&lt;/a&gt;, his "slowly growing collection of databases of the minutes from civic governments". Philip demonstrated the site and talked through his pipeline for scraping and indexing meeting minutes from many different local government authorities around the USA.&lt;/p&gt;

&lt;iframe style="margin-top: 1.5em; margin-bottom: 1.5em;" width="560" height="315" src="https://www.youtube-nocookie.com/embed/OziYd7xcGzc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="allowfullscreen"&gt; &lt;/iframe&gt;

&lt;p&gt;We recorded this conversation as part of yesterday's Datasette Public Office Hours session.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/16/civic-band/#civic-band"&gt;Civic Band&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/16/civic-band/#the-technical-stack"&gt;The technical stack&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/16/civic-band/#scale-and-storage"&gt;Scale and storage&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/16/civic-band/#future-plans"&gt;Future plans&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="civic-band"&gt;Civic Band&lt;/h4&gt;
&lt;p&gt;Philip was inspired to start thinking more about local government after the 2016 US election. He realised that there was a huge amount of information about decisions made by local authorities tucked away in their meeting minutes,but that information was hidden away in thousands of PDF files across many different websites.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;There was this massive backlog of basically every decision that had ever been made by one of these bodies. But it was almost impossible to discover because it lives in these systems where the method of exchange is a PDF.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Philip lives in Alameda, which makes its minutes available &lt;a href="https://alameda.legistar.com/Calendar.aspx"&gt;via this portal&lt;/a&gt; powered by &lt;a href="https://granicus.com/product/legistar-agenda-management/"&gt;Legistar&lt;/a&gt;. It turns out there are a small number of vendors that provide this kind of software tool, so once you've written a scraper for one it's likely to work for many others as well.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://alameda.ca.civic.band/"&gt;the Civic Band portal for Alameda&lt;/a&gt;, powered by &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/civic-band-1.jpg" alt="Datasette instance titled Alameda Civic Data, has search box, a note that says  A fully-searchable database of Alameda, CA civic meeting minutes. Last updated: 2024-11-15T20:27:36. See the full list at Civic Band and a meetings database with tables minutes and agendas." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;It's running the &lt;a href="https://github.com/simonw/datasette-search-all"&gt;datasette-search-all&lt;/a&gt; plugin and has both tables configured for full-text search. Here's a &lt;a href="https://alameda.ca.civic.band/-/search?q=housing"&gt;search for housing&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/civic-band-2.jpg" alt="Search all tables - for housing. 43 results in meetings: agendas. Each result shows a meeting, date, page, text and a rendered page image" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="the-technical-stack"&gt;The technical stack&lt;/h4&gt;
&lt;p&gt;The public Civic Band sites all run using Datasette in Docker Containers - one container per municipality. They're hosted on a single &lt;a href="https://www.hetzner.com/"&gt;Hetzner&lt;/a&gt; machine.&lt;/p&gt;
&lt;p&gt;The ingestion pipeline runs separately from the main hosting environment, using a Mac Mini on Philp's desk at home.&lt;/p&gt;
&lt;p&gt;OCR works by breaking each PDF up into images and then running &lt;a href="https://github.com/tesseract-ocr/tesseract"&gt;Tesseract OCR&lt;/a&gt; against them directly on the Mac Mini. This processes in the order of 10,000 or less new pages of documents a day.&lt;/p&gt;
&lt;p&gt;Philip treats PDF as a normalization target, because the pipeline is designed around documents with pages of text. In the rare event that a municipality publishes documents in another format such as &lt;code&gt;.docx&lt;/code&gt; he converts them to PDF before processing.&lt;/p&gt;
&lt;p&gt;PNG images of the PDF pages are served via a CDN, and the OCRd text is written to SQLite database files - one per municipality. &lt;a href="https://sqlite.org/fts5.html"&gt;SQLite FTS&lt;/a&gt; provides full-text search.&lt;/p&gt;
&lt;h4 id="scale-and-storage"&gt;Scale and storage&lt;/h4&gt;
&lt;p&gt;The entire project currently comes to about 265GB on disk.  The PNGs of the pages use about 350GB of CDN storage.&lt;/p&gt;
&lt;p&gt;Most of the individual SQLite databases are very small. The largest is for &lt;a href="https://maui-county.hi.civic.band/"&gt;Maui County&lt;/a&gt; which is around 535MB because that county has professional stenographers taking detailed notes for every one of their meetings.&lt;/p&gt;
&lt;p&gt;Each city adds only a few documents a week so growth is manageable even as the number of cities grows.&lt;/p&gt;
&lt;h4 id="future-plans"&gt;Future plans&lt;/h4&gt;
&lt;p&gt;We talked quite a bit about a goal to allow users to subscribe to updates that match specific search terms.&lt;/p&gt;
&lt;p&gt;Philip has been building out a separate site called Civic Observer to address this need, which will store searches and then execute the periodically using the Datasette JSON API, with a Django app to record state to avoid sending the same alert more than once.&lt;/p&gt;

&lt;p&gt;I've had a long term ambition to build some kind of saved search alerts plugin for Datasette generally, to allow users to subscribe to new results for arbitrary SQL queries. My &lt;a href="https://github.com/simonw/sqlite-chronicle"&gt;sqlite-chronicle&lt;/a&gt; library is part or that effort - it uses SQLite triggers to maintain version numbers for individual rows in a table, allowing you to query just the rows that have been inserted or modified since the version number last time you ran the query.&lt;/p&gt;

&lt;p&gt;Philip is keen to talk to anyone who is interested in using Civic Band or helping expand it to even more cities. You can find him on the &lt;a href="https://datasette.io/discord"&gt;Datasette Discord&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/political-hacking"&gt;political-hacking&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/politics"&gt;politics&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="data-journalism"/><category term="political-hacking"/><category term="politics"/><category term="sqlite"/><category term="datasette"/><category term="datasette-public-office-hours"/></entry><entry><title>Visualizing local election results with Datasette, Observable and MapLibre GL</title><link href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#atom-tag" rel="alternate"/><published>2024-11-09T23:32:06+00:00</published><updated>2024-11-09T23:32:06+00:00</updated><id>https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#atom-tag</id><summary type="html">
    &lt;p&gt;Alex Garcia and myself hosted the first &lt;a href="https://simonwillison.net/2024/Nov/7/datasette-public-office-hours/"&gt;Datasette Open Office Hours&lt;/a&gt; on Friday - a live-streamed video session where we hacked on a project together and took questions and tips from community members on Discord.&lt;/p&gt;
&lt;p&gt;We didn't record this one (surprisingly not a feature that Discord offers) but we hope to do more of these and record them in the future.&lt;/p&gt;
&lt;p&gt;This post is a detailed write-up of what we built during the session.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#san-mateo-county-election-results"&gt;San Mateo County election results&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#importing-csv-data-into-datasette"&gt;Importing CSV data into Datasette&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#modifying-the-schema"&gt;Modifying the schema&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#faceting-and-filtering-the-table"&gt;Faceting and filtering the table&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#importing-geospatial-precinct-shapes"&gt;Importing geospatial precinct shapes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#enriching-that-data-to-extract-the-precinct-ids"&gt;Enriching that data to extract the precinct IDs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#running-a-join"&gt;Running a join&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#creating-an-api-token-to-access-the-data"&gt;Creating an API token to access the data&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#getting-cors-working"&gt;Getting CORS working&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#working-with-datasette-in-observable"&gt;Working with Datasette in Observable&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#visualizing-those-with-maplibre-gl"&gt;Visualizing those with MapLibre GL&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#observable-plot"&gt;Observable Plot&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#bringing-it-all-together"&gt;Bringing it all together&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Nov/9/visualizing-local-election-results/#we-ll-be-doing-this-again"&gt;We'll be doing this again&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h4 id="san-mateo-county-election-results"&gt;San Mateo County election results&lt;/h4&gt;
&lt;p&gt;I live in El Granada, a tiny town just north of Half Moon Bay in San Mateo County, California.&lt;/p&gt;
&lt;p&gt;Every county appears to handle counting and publishing election results differently. For San Mateo County the results are published &lt;a href="https://smcacre.gov/elections/november-5-2024-election-results"&gt;on this page&lt;/a&gt;, and detailed per-precinct and per-candidate breakdowns are made available as a CSV file.&lt;/p&gt;
&lt;p&gt;(I optimistically set up a &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraper&lt;/a&gt; for these results in &lt;a href="https://github.com/simonw/scrape-san-mateo-county-election-results-2024"&gt;simonw/scrape-san-mateo-county-election-results-2024&lt;/a&gt; only to learn that the CSV is updated just once a day, not continually as the ballots are counted.)&lt;/p&gt;
&lt;p&gt;I'm particularly invested in the results of the &lt;a href="http://granada.ca.gov/"&gt;Granada Community Services District&lt;/a&gt; board member elections. Our little town of El Granada is in "unincorporated San Mateo County" which means we don't have a mayor or any local officials, so the closest we get to hyper-local government is the officials that run our local sewage and parks organization! My partner Natalie ran &lt;a href="https://til.simonwillison.net/youtube/livestreaming"&gt;the candidate forum event&lt;/a&gt; (effectively the debate) featuring three of the four candidates running for the two open places on the board.&lt;/p&gt;
&lt;p&gt;Let's explore the data for that race using Datasette.&lt;/p&gt;
&lt;h4 id="importing-csv-data-into-datasette"&gt;Importing CSV data into Datasette&lt;/h4&gt;
&lt;p&gt;I ran my part of the demo using &lt;a href="https://www.datasette.cloud/"&gt;Datasette Cloud&lt;/a&gt;, the beta of my new hosted Datasette service.&lt;/p&gt;
&lt;p&gt;I started by using the pre-configured &lt;a href="https://github.com/datasette/datasette-import"&gt;datasette-import&lt;/a&gt; plugin to import the data from the CSV file into a fresh table:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/datasette-import-loop.gif" alt="Paste data to create a table - I drag and drop on a CSV file, which produces a preview of the first 100 of 15,589 rows. I click to Upload and a progress bar runs before redirecting me to the resulting table." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="modifying-the-schema"&gt;Modifying the schema&lt;/h4&gt;
&lt;p&gt;The table imported cleanly, but all of the columns from the CSV were still being treated as text. I used the &lt;a href=""&gt;datasette-edit-schema&lt;/a&gt; plugin to switch the relevant columns to integers so that we could run sums and sorts against them.&lt;/p&gt;
&lt;p&gt;(I also noted that I really should add a "detect column types" feature to that plugin!)&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/datasette-edit-schema.jpg" alt="Edit table data/san_mateo_election_results - an option to rename table and then one to change existing columns, where each column is listed in turn and some have their type select box set to integer instead of the default of text" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The resulting 15,589 rows represent counts from individual precincts around the county for each of the races and measures on the ballot, with a row per precinct per candidate/choice per race.&lt;/p&gt;
&lt;h4 id="faceting-and-filtering-the-table"&gt;Faceting and filtering the table&lt;/h4&gt;
&lt;p&gt;Since I'm interested in the Granada Community Services District election, I applied a facet on "Contest_title" and then used that to select that specific race.&lt;/p&gt;
&lt;p&gt;I applied additional facets on "candidate_name" and "Precinct name".&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/facet-candidates.jpg" alt="28 rows where Contest_title = Granada Community Services District Members, Board of Directors. Facets are precinct name (7 choices), candidate name (IRIS GRANT, JANET BRAYER, NANCY MARSH, WANDA BOWLES) and Contest_title" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This looks right to me: we have 7 precincts and 4 candidates for 28 rows in total.&lt;/p&gt;
&lt;h4 id="importing-geospatial-precinct-shapes"&gt;Importing geospatial precinct shapes&lt;/h4&gt;
&lt;p&gt;Those precinct names are pretty non-descriptive! What does 33001 mean?&lt;/p&gt;
&lt;p&gt;To answer that question, I added a new table.&lt;/p&gt;
&lt;p&gt;San Mateo County offers &lt;a href="https://smcacre.gov/elections/precinct-maps-pdf"&gt;precinct maps&lt;/a&gt; in the form of 23 PDF files. Our precincts are in the "Unincorporated Coastside" file:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/precinct-map-from-pdf.jpg" alt="Screenshot from a PDF - label is Unincorporated Coastside, it shows the area north of Half Moon Bay with a bunch of polygons with numeric identifiers." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Thankfully the county &lt;em&gt;also&lt;/em&gt; makes that data available as &lt;a href="https://data.smcgov.org/Government/Election-Precincts/g5sj-6zp8/about_data"&gt;geospatial data&lt;/a&gt;, hosted using Socrata with an option to export as GeoJSON.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/precincts-socrata.jpg" alt="Socrata interface, Election Precincts updated March 7 2022 - 533 views, 72 downloads, and export dataset modal shows a GeoJSON option to export 783 rows." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;datasette-import&lt;/code&gt; plugin can handle JSON files... and if a JSON file contains a top-level object with a key that is an array of objects, it will import those objects as a table.&lt;/p&gt;
&lt;p&gt;Dragging that file into Datasette is enough to import it as a table with a &lt;code&gt;properties&lt;/code&gt; JSON column containing properties and a &lt;code&gt;geometry&lt;/code&gt; JSON columnn with the GeoJSON geometry.&lt;/p&gt;
&lt;p&gt;Here's where another plugin kicks in: &lt;a href="https://datasette.io/plugins/datasette-leaflet-geojson"&gt;datasette-leaflet-geojson&lt;/a&gt; looks for columns that contain valid GeoJSON geometries and... draws them on a map!&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/precincts-in-datasette.jpg" alt="Datasette precincts table with 783 rows. The properties column contains JSON keys lastupdate, creationda, prencitid, notes and active - the geometry column renders maps with polygons showing the shape of the precinct." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;So now we can see the shape of the individual geometries.&lt;/p&gt;
&lt;h4 id="enriching-that-data-to-extract-the-precinct-ids"&gt;Enriching that data to extract the precinct IDs&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;precinctid&lt;/code&gt; is present in the data, but it's tucked away in a JSON object in that &lt;code&gt;properties&lt;/code&gt; JSON blob. It would be more convenient if it was a top-level column.&lt;/p&gt;
&lt;p&gt;Datasette's &lt;a href="https://simonwillison.net/2023/Dec/1/datasette-enrichments/"&gt;enrichments feature&lt;/a&gt; provides tools for running operations against every row in a table and adding new columns based on the results.&lt;/p&gt;
&lt;p&gt;My Datasette Cloud instance was missing the &lt;a href="https://github.com/datasette/datasette-enrichments-quickjs"&gt;datasette-enrichments-quickjs plugin&lt;/a&gt; that would let me run JavaScript code against the data. I used my privileged access on Datasette Cloud to add that plugin to my requirements and restarted the instance to install it.&lt;/p&gt;
&lt;p&gt;I used that to run this JavaScript code against every row in the table and saved the output in a new &lt;code&gt;precinct_id&lt;/code&gt; column:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;enrich&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;row&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;row&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;properties&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;precinctid&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/enrich-precincts.jpg" alt="Enrich data in precincts. 783 rows selected. JavaScript. Enrich data with a custom JavaScript function. JavaScript function: function enrich(row) { return JSON.stringify(row) + &amp;quot; enriched&amp;quot;; } - Define an enrich(row) JavaScript function taking an object and returning a value. Row keys: properties, geometry. Output mode: store the function result in a single column. Output clumn name: precinct_id. The column to store the output in - will be created if it does not exist. Output column type: text." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This took less than a second to run, adding and populating a new &lt;code&gt;precinct_id&lt;/code&gt; column for the table.&lt;/p&gt;
&lt;h4 id="running-a-join"&gt;Running a join&lt;/h4&gt;
&lt;p&gt;I demonstrated how to run a join between the election results and the precincts table using the Datasette SQL query editor.&lt;/p&gt;
&lt;p&gt;I tried a few different things, but the most interesting query was this one:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  Precinct_name,
  &lt;span class="pl-c1"&gt;precincts&lt;/span&gt;.&lt;span class="pl-c1"&gt;geometry&lt;/span&gt;,
  total_ballots,
  json_group_object(
    candidate_name,
    total_votes
  ) &lt;span class="pl-k"&gt;as&lt;/span&gt; votes_by_candidate
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  election_results 
  &lt;span class="pl-k"&gt;join&lt;/span&gt; precincts &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;election_results&lt;/span&gt;.&lt;span class="pl-c1"&gt;Precinct_name&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;precincts&lt;/span&gt;.&lt;span class="pl-c1"&gt;precinct_id&lt;/span&gt;
&lt;span class="pl-k"&gt;where&lt;/span&gt; 
  Contest_title &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Granada Community Services District Members, Board of Directors&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;group by&lt;/span&gt; 
  Precinct_name,
  &lt;span class="pl-c1"&gt;precincts&lt;/span&gt;.&lt;span class="pl-c1"&gt;geometry&lt;/span&gt;,
  total_ballots;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/joined-precincts.jpg" alt="The SQL query returned four columns: Precinct_name, geometry with a map of the precinct, total_ballots with a number and votes_by_candidate with a JSON object mapping each candidate name to their number of votes." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="creating-an-api-token-to-access-the-data"&gt;Creating an API token to access the data&lt;/h4&gt;
&lt;p&gt;I was nearly ready to hand over to Alex for the second half of our demo, where he would use Observable Notebooks to build some custom visualizations on top of the data.&lt;/p&gt;
&lt;p&gt;A great pattern for this is to host the data in Datasette and then fetch it into Observable via the Datasette JSON API.&lt;/p&gt;
&lt;p&gt;Since Datasette Cloud instances are private by default we would need to create an API token that could do this.&lt;/p&gt;
&lt;p&gt;I used this interface (from the &lt;a href="https://github.com/simonw/datasette-auth-tokens"&gt;datasette-auth-tokens plugin&lt;/a&gt;) to create a new token with read-only access to all databases and tables in the instance:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/create-api-token.jpg" alt="Create an API token interface. This token will allow API access with the same abilities as your current user, swillison .Token will be restricted to: all databases and tables: view-database, all databases and tables: view-table, all databases and tables: execute-sql - token is set to read-only and never expires, a list of possible permissions with checkboxes is listed below." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Since we're running a dedicated instance just for Datasette Public Office Hours there's no reason not to distribute that read-only token in publically accessible code.&lt;/p&gt;
&lt;h4 id="getting-cors-working"&gt;Getting CORS working&lt;/h4&gt;
&lt;p&gt;Embarrassingly, I had forgotten that we would need CORS headers in order to access the data from an Observable notebook. Thankfully we have another plugin for that: &lt;a href="https://datasette.io/plugins/datasette-cors"&gt;datasette-cors&lt;/a&gt;. I installed that quickly and we confirmed that it granted access to the API from Observable as intended.&lt;/p&gt;
&lt;p&gt;I handed over to Alex for the next section of the demo.&lt;/p&gt;
&lt;h4 id="working-with-datasette-in-observable"&gt;Working with Datasette in Observable&lt;/h4&gt;
&lt;p&gt;Alex started by running a SQL query from client-side JavaScript to pull in the joined data for our specific El Granada race:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;sql&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&lt;/span&gt;
&lt;span class="pl-s"&gt;select&lt;/span&gt;
&lt;span class="pl-s"&gt;  Precinct_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  precincts.geometry,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Split_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Reporting_flag,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Update_count,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Pct_Id,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Pct_seq_nbr,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Reg_voters,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Turn_Out,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Contest_Id,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Contest_seq_nbr,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Contest_title,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Contest_party_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Selectable_Options,&lt;/span&gt;
&lt;span class="pl-s"&gt;  candidate_id,&lt;/span&gt;
&lt;span class="pl-s"&gt;  candidate_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Candidate_Type,&lt;/span&gt;
&lt;span class="pl-s"&gt;  cand_seq_nbr,&lt;/span&gt;
&lt;span class="pl-s"&gt;  Party_Code,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_ballots,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_votes,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_under_votes,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_over_votes,&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote Centers_ballots],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote Centers_votes],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote Centers_under_votes],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote Centers_over_votes],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote by Mail_ballots],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote by Mail_votes],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote by Mail_under_votes],&lt;/span&gt;
&lt;span class="pl-s"&gt;  [Vote by Mail_over_votes]&lt;/span&gt;
&lt;span class="pl-s"&gt;from&lt;/span&gt;
&lt;span class="pl-s"&gt;  election_results join precincts on election_results.Precinct_name = precincts.precinct_id&lt;/span&gt;
&lt;span class="pl-s"&gt;where "Contest_title" = "Granada Community Services District Members, Board of Directors"&lt;/span&gt;
&lt;span class="pl-s"&gt;limit 101;`&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And in the next cell:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;raw_data&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-s"&gt;`https://datasette-public-office-hours.datasette.cloud/data/-/query.json?_shape=array&amp;amp;sql=&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;    &lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;  &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;headers&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;Authorization&lt;/span&gt;: &lt;span class="pl-s"&gt;`Bearer &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;secret&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Note the &lt;code&gt;?_shape=array&lt;/code&gt; parameter there, which causes Datasette to output the results directly as a JSON array of objects.&lt;/p&gt;
&lt;p&gt;That's all it takes to get the data into Observable. Adding another cell like this confirms that the data is now available:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-v"&gt;Inputs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;table&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;raw_data&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/inputs-table-raw-data.jpg" alt="An Observable cell running Inputs.table(raw_data) and displaying a table of Precinct_name and geometry columns, with GeoJSON" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="visualizing-those-with-maplibre-gl"&gt;Visualizing those with MapLibre GL&lt;/h4&gt;
&lt;p&gt;There are plenty of good options for visualizing GeoJSON data using JavaScript in an Observable notebook.&lt;/p&gt;
&lt;p&gt;Alex started with &lt;a href="https://maplibre.org/maplibre-gl-js/docs/"&gt;MapLibre GL&lt;/a&gt;, using the excellent &lt;a href="https://simonwillison.net/2024/Sep/28/openfreemap/"&gt;OpenFreeMap 3D tiles&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;viewof&lt;/span&gt; &lt;span class="pl-s1"&gt;map&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-s1"&gt;const&lt;/span&gt; container &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;html&lt;/span&gt;&lt;span class="pl-s"&gt;`&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;div&lt;/span&gt; &lt;span class="pl-c1"&gt;style&lt;/span&gt;="&lt;span class="pl-s"&gt;height:800px;&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;yield&lt;/span&gt; &lt;span class="pl-s1"&gt;container&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;map&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;container&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-s1"&gt;maplibregl&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;Map&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
    container&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;zoom&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c"&gt;//style: "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",&lt;/span&gt;
    &lt;span class="pl-c1"&gt;style&lt;/span&gt;: &lt;span class="pl-s"&gt;"https://tiles.openfreemap.org/styles/liberty"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;scrollZoom&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;yield&lt;/span&gt; &lt;span class="pl-s1"&gt;container&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;on&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"load"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;fitBounds&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d3&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;geoBounds&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt; &lt;span class="pl-c1"&gt;duration&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addSource&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"precincts"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;"geojson"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;data&lt;/span&gt;: &lt;span class="pl-s1"&gt;data&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addLayer&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;id&lt;/span&gt;: &lt;span class="pl-s"&gt;"precincts"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;"fill"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;source&lt;/span&gt;: &lt;span class="pl-s"&gt;"precincts"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;paint&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
        &lt;span class="pl-s"&gt;"fill-opacity"&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.4&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
        &lt;span class="pl-s"&gt;"fill-color"&lt;/span&gt;: &lt;span class="pl-kos"&gt;[&lt;/span&gt;
          &lt;span class="pl-s"&gt;"case"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"=="&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"get"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"ratio"&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"#000000"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-kos"&gt;[&lt;/span&gt;
            &lt;span class="pl-s"&gt;"interpolate"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"linear"&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"get"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"ratio"&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;0.0&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"#0000ff"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"#d3d3d3"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;1.0&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"#ff0000"&lt;/span&gt;
          &lt;span class="pl-kos"&gt;]&lt;/span&gt;
        &lt;span class="pl-kos"&gt;]&lt;/span&gt;
      &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;on&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"click"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"precincts"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;e&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt; precinct&lt;span class="pl-kos"&gt;,&lt;/span&gt; ratio &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;e&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;features&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;properties&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;description&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

      &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-s1"&gt;maplibregl&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;Popup&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
        &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;setLngLat&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;e&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;lngLat&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
        &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;setHTML&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;description&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
        &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addTo&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;invalidation&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;remove&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/maplibre-gl.jpg" alt="An Observable cell showing a map of El Granada - a black shape shows the outlines of the precincts." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;(This is just one of several iterations, I didn't capture detailed notes of every change Alex made to the code.)&lt;/p&gt;
&lt;h4 id="observable-plot"&gt;Observable Plot&lt;/h4&gt;
&lt;p&gt;Observable notebooks come pre-loaded with the excellent Observable Plot charting library - Mike Bostock's high-level charting tool built on top of D3.&lt;/p&gt;
&lt;p&gt;Alex used that to first render the shapes of the precincts directly, without even needing a tiled basemap:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-v"&gt;Plot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;plot&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
  width&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;600&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;legend&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;projection&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;"conic-conformal"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;parallels&lt;/span&gt;: &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;37&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;4&lt;/span&gt; &lt;span class="pl-c1"&gt;/&lt;/span&gt; &lt;span class="pl-c1"&gt;60&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;38&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;26&lt;/span&gt; &lt;span class="pl-c1"&gt;/&lt;/span&gt; &lt;span class="pl-c1"&gt;60&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;rotate&lt;/span&gt;: &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;120&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;30&lt;/span&gt; &lt;span class="pl-c1"&gt;/&lt;/span&gt; &lt;span class="pl-c1"&gt;60&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;domain&lt;/span&gt;: &lt;span class="pl-s1"&gt;data&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;marks&lt;/span&gt;: &lt;span class="pl-kos"&gt;[&lt;/span&gt;
    &lt;span class="pl-v"&gt;Plot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;geo&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;strokeOpacity&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.1&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;fill&lt;/span&gt;: &lt;span class="pl-s"&gt;"total_votes"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-en"&gt;title&lt;/span&gt;: &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;properties&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;tip&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
  &lt;span class="pl-kos"&gt;]&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;parallels&lt;/code&gt; and &lt;code&gt;rotate&lt;/code&gt; options there come from the handy &lt;a href="https://github.com/veltman/d3-stateplane?tab=readme-ov-file#nad83--california-zone-3-epsg26943"&gt;veltman/d3-stateplane&lt;/a&gt; repo, which lists recommended settings for the &lt;a href="https://en.wikipedia.org/wiki/State_Plane_Coordinate_System"&gt;State Plane Coordinate System&lt;/a&gt; used with projections in D3. Those values are for &lt;a href="https://www.conservation.ca.gov/cgs/rgm/state-plane-coordinate-system"&gt;California Zone 3&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/color-precincts.jpg" alt="An Observable cell shows six five distinct colored polygons, each for a different precinct. The shape of El Granada is clearly visible despite no other map tiles or labels." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="bringing-it-all-together"&gt;Bringing it all together&lt;/h4&gt;
&lt;p&gt;For the grand finale, Alex combined everything learned so far to build an interactive map allowing a user to select any of the 110 races on the ballot and see a heatmap of results for any selected candidate and option:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/select-map.gif" alt="Animated demo. Choose a contest select - picking different contests updates the map at the bottom. For each contest the candidates or options are shown as radio buttons, and selecting those updates the map to show a heatmap of votes for that candidate in different precincts." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;You can try this out in &lt;a href="https://observablehq.com/d/2ed2ad2443d7bbb5"&gt;Alex's notebook&lt;/a&gt;. Here's the relevant code (Observable cells are divided by &lt;code&gt;// ---&lt;/code&gt; comments). Note that Observable notebooks are reactive and allow variables to be referenced out of order.&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Select the contest&lt;/span&gt;
&lt;span class="pl-s1"&gt;viewof&lt;/span&gt; &lt;span class="pl-s1"&gt;contest&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Inputs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;select&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;contests&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt; &lt;span class="pl-c1"&gt;label&lt;/span&gt;: &lt;span class="pl-s"&gt;"Choose a contest"&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-c"&gt;// And the candidate&lt;/span&gt;
&lt;span class="pl-s1"&gt;viewof&lt;/span&gt;&lt;span class="pl-kos"&gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;candidate&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Inputs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;radio&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-s1"&gt;candidates&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;label&lt;/span&gt;: &lt;span class="pl-s"&gt;"Choose a candidate"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;value&lt;/span&gt;: &lt;span class="pl-s1"&gt;candidates&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-c"&gt;// Show the map itself&lt;/span&gt;
&lt;span class="pl-v"&gt;Plot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;plot&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
  width&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;600&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;legend&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;color&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt; &lt;span class="pl-c1"&gt;scheme&lt;/span&gt;: &lt;span class="pl-s"&gt;"blues"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;legend&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;projection&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;"mercator"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;domain&lt;/span&gt;: &lt;span class="pl-s1"&gt;data2&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;marks&lt;/span&gt;: &lt;span class="pl-kos"&gt;[&lt;/span&gt;
    &lt;span class="pl-v"&gt;Plot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;geo&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;data2&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;strokeOpacity&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.1&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;fill&lt;/span&gt;: &lt;span class="pl-s"&gt;"ratio"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;tip&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
  &lt;span class="pl-kos"&gt;]&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
# &lt;span class="pl-c1"&gt;--&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;
&lt;span class="pl-s1"&gt;data2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;"FeatureCollection"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;features&lt;/span&gt;: &lt;span class="pl-s1"&gt;raw_data2&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;"Feature"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;properties&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;precinct&lt;/span&gt;: &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;Precinct_name&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;total_ballots&lt;/span&gt;: &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;total_ballots&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
      &lt;span class="pl-c1"&gt;ratio&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;votes_by_candidate&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;candidate&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt; &lt;span class="pl-c1"&gt;/&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;total_ballots&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;geometry&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;geometry&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-s1"&gt;raw_data2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-s"&gt;`select&lt;/span&gt;
&lt;span class="pl-s"&gt;  Precinct_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  precincts.geometry,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_ballots,&lt;/span&gt;
&lt;span class="pl-s"&gt;  json_grop_object(&lt;/span&gt;
&lt;span class="pl-s"&gt;    candidate_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;    total_votes&lt;/span&gt;
&lt;span class="pl-s"&gt;  ) as votes_by_candidate&lt;/span&gt;
&lt;span class="pl-s"&gt;from&lt;/span&gt;
&lt;span class="pl-s"&gt;  election_results &lt;/span&gt;
&lt;span class="pl-s"&gt;  join precincts on election_results.Precinct_name = precincts.precinct_id&lt;/span&gt;
&lt;span class="pl-s"&gt;where Contest_title = :contest&lt;/span&gt;
&lt;span class="pl-s"&gt;group by &lt;/span&gt;
&lt;span class="pl-s"&gt;  Precinct_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  precincts.geometry,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_ballots;`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-kos"&gt;{&lt;/span&gt; contest &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-s1"&gt;raw_data2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-s"&gt;`select&lt;/span&gt;
&lt;span class="pl-s"&gt;  Precinct_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  precincts.geometry,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_ballots,&lt;/span&gt;
&lt;span class="pl-s"&gt;  json_group_object(&lt;/span&gt;
&lt;span class="pl-s"&gt;    candidate_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;    total_votes&lt;/span&gt;
&lt;span class="pl-s"&gt;  ) as votes_by_candidate&lt;/span&gt;
&lt;span class="pl-s"&gt;from&lt;/span&gt;
&lt;span class="pl-s"&gt;  election_results &lt;/span&gt;
&lt;span class="pl-s"&gt;  join precincts on election_results.Precinct_name = precincts.precinct_id&lt;/span&gt;
&lt;span class="pl-s"&gt;where Contest_title = :contest&lt;/span&gt;
&lt;span class="pl-s"&gt;group by &lt;/span&gt;
&lt;span class="pl-s"&gt;  Precinct_name,&lt;/span&gt;
&lt;span class="pl-s"&gt;  precincts.geometry,&lt;/span&gt;
&lt;span class="pl-s"&gt;  total_ballots;`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-kos"&gt;{&lt;/span&gt; contest &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-c"&gt;// Fetch the available contests&lt;/span&gt;
&lt;span class="pl-s1"&gt;contests&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"select distinct Contest_title from election_results"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;Contest_title&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-c"&gt;// Extract available candidates for selected contest&lt;/span&gt;

&lt;span class="pl-s1"&gt;candidates&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Object&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;keys&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;raw_data2&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;votes_by_candidate&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;

&lt;span class="pl-c"&gt;// ---&lt;/span&gt;

&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;params&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
    &lt;span class="pl-s"&gt;`https://datasette-public-office-hours.datasette.cloud/data/-/query.json?&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;URLSearchParams&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;      &lt;span class="pl-kos"&gt;{&lt;/span&gt; sql&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;_shape&lt;/span&gt;: &lt;span class="pl-s"&gt;"array"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; ...&lt;span class="pl-s1"&gt;params&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;    &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;toString&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;headers&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
        &lt;span class="pl-c1"&gt;Authorization&lt;/span&gt;: &lt;span class="pl-s"&gt;`Bearer &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;secret&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;
      &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h4 id="we-ll-be-doing-this-again"&gt;We'll be doing this again&lt;/h4&gt;
&lt;p&gt;This was our first time trying something like this and I think it worked &lt;em&gt;really&lt;/em&gt; well. We're already thinking about ways to improve it next time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I want to record these sessions and make them available on YouTube for people who couldn't be there live&lt;/li&gt;
&lt;li&gt;It would be fun to mix up the format. I'm particularly keen on getting more people involved giving demos - maybe having 5-10 minute lightning demo slots so we can see what other people are working on&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep an eye on this blog or on the &lt;a href="https://datasette.io/discord"&gt;Datasette Discord&lt;/a&gt; for news about future sessions.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/geospatial"&gt;geospatial&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mapping"&gt;mapping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/politics"&gt;politics&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-cloud"&gt;datasette-cloud&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alex-garcia"&gt;alex-garcia&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/leaflet"&gt;leaflet&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="geospatial"/><category term="mapping"/><category term="politics"/><category term="projects"/><category term="datasette"/><category term="datasette-cloud"/><category term="alex-garcia"/><category term="datasette-public-office-hours"/><category term="leaflet"/></entry><entry><title>Datasette Public Office Hours, Friday Nov 8th at 2pm PT</title><link href="https://simonwillison.net/2024/Nov/7/datasette-public-office-hours/#atom-tag" rel="alternate"/><published>2024-11-07T19:10:10+00:00</published><updated>2024-11-07T19:10:10+00:00</updated><id>https://simonwillison.net/2024/Nov/7/datasette-public-office-hours/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://discord.gg/udUyEnv3?event=1304134449453072435"&gt;Datasette Public Office Hours, Friday Nov 8th at 2pm PT&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Tomorrow afternoon (Friday 8th November) at 2pm PT we'll be hosting the first &lt;strong&gt;Datasette Public Office Hours&lt;/strong&gt; - a livestream video session on Discord where Alex Garcia and myself will live code on some &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; projects and hang out to chat about the project.&lt;/p&gt;
&lt;p&gt;This is our first time trying this format. If it works out well I plan to turn it into a series.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Discord event card promoting Datasette Public Office Hours" src="https://static.simonwillison.net/static/2024/datasette-public-office-hours.jpg" /&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/discord"&gt;discord&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alex-garcia"&gt;alex-garcia&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-public-office-hours"&gt;datasette-public-office-hours&lt;/a&gt;&lt;/p&gt;



</summary><category term="open-source"/><category term="datasette"/><category term="discord"/><category term="alex-garcia"/><category term="datasette-public-office-hours"/></entry></feed>