diff --git a/pyproject.toml b/pyproject.toml index e84f5dbae..4d5bf0c57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ documentation = [ "numpy", "bqplot", "altair", + "folium", "ipycanvas", "ipyleaflet", "matplotlib", diff --git a/solara/website/components/mailchimp.py b/solara/website/components/mailchimp.py new file mode 100644 index 000000000..7c2cfaf0c --- /dev/null +++ b/solara/website/components/mailchimp.py @@ -0,0 +1,13 @@ +from pathlib import Path + +import solara + +HERE = Path(__file__).parent + +# html = Path(HERE / "mailchimp.v").read_text() + + +@solara.component_vue("mailchimp.vue") +def MailChimp(): + # solara.HTML(unsafe_innerHTML=html) + pass diff --git a/solara/website/components/mailchimp.vue b/solara/website/components/mailchimp.vue new file mode 100644 index 000000000..1a6804875 --- /dev/null +++ b/solara/website/components/mailchimp.vue @@ -0,0 +1,57 @@ + diff --git a/solara/website/pages/docs/content/04-tutorial/60-jupyter-dashboard-part1.py b/solara/website/pages/docs/content/04-tutorial/60-jupyter-dashboard-part1.py new file mode 100644 index 000000000..349754887 --- /dev/null +++ b/solara/website/pages/docs/content/04-tutorial/60-jupyter-dashboard-part1.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import solara +from solara.website.components.mailchimp import MailChimp +from solara.website.components.notebook import Notebook + +HERE = Path(__file__).parent +title = "Jupyter Dashboard (1/3)" + + +@solara.component +def Page(): + title = "Build your Jupyter dashboard using Solara" + solara.Meta(property="og:title", content=title) + solara.Meta(name="twitter:title", content=title) + solara.Title(title) + + img = "https://solara.dev/static/public/docs/tutorial/jupyter-dashboard1.png" + solara.Meta(name="twitter:image", content=img) + solara.Meta(property="og:image", content=img) + + description = "Learn how to build a Jupyter dashboard and deploy it as a web app using Solara." + solara.Meta(name="description", property="og:description", content=description) + solara.Meta(name="twitter:description", content=description) + tags = [ + "jupyter", + "jupyter dashboard", + "dashboard", + "web app", + "deploy", + "solara", + ] + solara.Meta(name="keywords", content=", ".join(tags)) + + Notebook(Path(HERE / "_jupyter_dashboard_1.ipynb"), show_last_expressions=True) + solara.Markdown( + """ + Don’t miss the next tutorial and stay updated with the latest techniques and insights by subscribing to our newsletter. + """ + ) + MailChimp() diff --git a/solara/website/pages/docs/content/04-tutorial/SF_crime_sample.csv.gz b/solara/website/pages/docs/content/04-tutorial/SF_crime_sample.csv.gz new file mode 100644 index 000000000..8a05bd929 Binary files /dev/null and b/solara/website/pages/docs/content/04-tutorial/SF_crime_sample.csv.gz differ diff --git a/solara/website/pages/docs/content/04-tutorial/_jupyter_dashboard_1.ipynb b/solara/website/pages/docs/content/04-tutorial/_jupyter_dashboard_1.ipynb new file mode 100644 index 000000000..bc47e90fe --- /dev/null +++ b/solara/website/pages/docs/content/04-tutorial/_jupyter_dashboard_1.ipynb @@ -0,0 +1,408 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8aeeb4f6", + "metadata": {}, + "source": [ + "# Build your Jupyter dashboard using Solara\n", + "\n", + "Welcome to **the** first part of a series of tutorial**s** that will show you how to create a dashboard in Jupyter and deploy it as a standalone web **app**. **Importantly, there will be** no need to rewrite your app in a different framework, no need to use a non-Python solution, **and** no need to use JavaScript or CSS.\n", + "\n", + "Jupyter notebooks are an incredible tool for data analysis, **since they enable** blending code, visualization and narrative in**to** a single document.\n", + "However, if the insights need to be presented to a non-technical audience, we usually do not want to show the code.\n", + "\n", + "In this tutorial, we will create a simple dashboard using Solara's UI components. **The final product will** allow an end-user to filter,\n", + "visualize and explore a dataset on a map.\n", + "\n", + "![image](/static/public/docs/tutorial/jupyter-dashboard1.png)\n", + "\n", + "## Pre-requisits \n", + "\n", + "You need to install `pandas`, `matplotlib`, `folium` and `solara`. Assuming you are using pip, you can execute on your shell:\n", + "\n", + "```\n", + "$ pip install pandas matplotlib folium solara\n", + "```\n", + "\n", + "\n", + "Or in your notebook\n", + "```\n", + "%pip install pandas matplotlib folium solara\n", + "```\n", + "\n", + "## The start\n", + "\n", + "We will use a subsample of the [San Fanfrisco crime dataset](https://www.kaggle.com/competitions/sf-crime/data) which **contains information on types of crimes and where they were committed**.\n", + "\n", + "[Download the CSV file](https://raw.githubusercontent.com/widgetti/solara/master/solara/website/pages/docs/content/04-tutorial/SF_crime_sample.csv.gz) if you want to run this locally, or let the code below sort it out." + ] + }, + { + "cell_type": "markdown", + "id": "6cc6256a", + "metadata": {}, + "source": [ + "The first thing we do when we read in the data is to print it out, to see what the dataset contains." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f399bdc", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pathlib import Path\n", + "import solara\n", + "\n", + "ROOT = Path(solara.__file__).parent / 'website' / 'pages' / 'docs' / 'content' / '04-tutorial'\n", + "path = ROOT / Path('SF_crime_sample.csv.gz')\n", + "url = \"https://raw.githubusercontent.com/widgetti/solara/master/solara/website/pages/docs/content/04-tutorial/SF_crime_sample.csv\"\n", + "\n", + "df_crime = pd.read_csv(path)\n", + "# run this locally\n", + "# if path.exists():\n", + "# df_crime = pd.read_csv(path)\n", + "# else:\n", + "# df_crime = pd.read_csv(url)\n", + "\n", + "df_crime\n" + ] + }, + { + "cell_type": "markdown", + "id": "08a9644a", + "metadata": {}, + "source": [ + "The data looks clean but since we will work with the `Category` and `PdDistrict` column data, lets **convert those columns to title case**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3373227", + "metadata": {}, + "outputs": [], + "source": [ + "df_crime['Category'] = df_crime['Category'].str.title()\n", + "df_crime['PdDistrict'] = df_crime['PdDistrict'].str.title()\n", + "df_crime\n" + ] + }, + { + "cell_type": "markdown", + "id": "62df988f", + "metadata": {}, + "source": [ + "Using proper software engineering practices, we write a function that filter**s** a dataframe to contain only the rows that match our chosen districts and categories." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7d17a84", + "metadata": {}, + "outputs": [], + "source": [ + "def crime_filter(df, district_values, category_values):\n", + " df_dist = df.loc[df['PdDistrict'].isin(district_values)]\n", + " df_category = df_dist.loc[df_dist['Category'].isin(category_values)]\n", + " return df_category\n", + "\n", + "\n", + "dff_crime = crime_filter(df_crime, ['Bayview', 'Northern'], ['Vandalism', 'Assault', 'Robbery'])\n" + ] + }, + { + "cell_type": "markdown", + "id": "b0e37cb4", + "metadata": {}, + "source": [ + "Now, with our filtered dataset, we create two barcharts. We use regular pandas and matplotlib, but seaborn or plotly would also have been appropriate choices." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3254c59d", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "def crime_charts(df):\n", + " cat_unique = df['Category'].value_counts()\n", + " cat_unique = cat_unique.reset_index()\n", + " \n", + " dist_unique = df['PdDistrict'].value_counts()\n", + " dist_unique = dist_unique.reset_index()\n", + " \n", + " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20,10))\n", + "\n", + " ax1.bar(cat_unique['Category'], cat_unique['count'])\n", + " ax1.set_title('Amount of Criminal Case Based on Category')\n", + " ax2.bar(dist_unique['PdDistrict'], dist_unique['count'])\n", + " ax2.set_title('Amount of Criminal Case in Selected District')\n", + " \n", + " plt.show()\n", + "\n", + "crime_charts(dff_crime)\n" + ] + }, + { + "cell_type": "markdown", + "id": "0e71ff2f", + "metadata": {}, + "source": [ + "Since we do not need bi-directional communication (e.g. we do not need to receive events or data from our map), we use folium to display the locations of the committed crimes on a map. If we do need bi-directional communication, we can also decide to use [ipyleaflet](https://ipyleaflet.readthedocs.io/en/latest/usage/index.html).\n", + "\n", + "We cannot display all the data on the map without crashing your browser, so we limit to a maximum of a 1000 points." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82f1d2f7", + "metadata": {}, + "outputs": [], + "source": [ + "import folium\n", + "import folium.plugins\n", + "\n", + "\n", + "def crime_map(df):\n", + " latitude = 37.77\n", + " longitude = -122.42\n", + " \n", + " sanfran_map = folium.Map(location=[latitude, longitude], zoom_start=12)\n", + "\n", + " incidents = folium.plugins.MarkerCluster().add_to(sanfran_map)\n", + "\n", + " # loop through the dataframe and add each data point to the mark cluster\n", + " for lat, lng, label, in zip(df.Y, df.X, df.Category):\n", + " folium.Marker(\n", + " location=[lat, lng],\n", + " icon=None,\n", + " popup=label,\n", + " ).add_to(incidents)\n", + " \n", + " # show map\n", + " display(sanfran_map)\n", + "crime_map(dff_crime.iloc[0:1000, :])\n" + ] + }, + { + "cell_type": "markdown", + "id": "b74914fd", + "metadata": {}, + "source": [ + "## Making our first reactive visualization\n", + "\n", + "The above code works nicely, but if we want to explore different types of crimes, we need to manually modify and run all cells that determine out output. Would it not be much better to have a UI with controls that determine the filtering, and a view that displays the filtered data interactively?\n", + "\n", + "Lets start by importing the solara package, and create three reactive variables." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e7ea361", + "metadata": {}, + "outputs": [], + "source": [ + "import solara\n", + "districts = solara.reactive(['Bayview', 'Northern'],)\n", + "categories = solara.reactive(['Vandalism', 'Assault', 'Robbery'])\n", + "limit = solara.reactive(100)\n" + ] + }, + { + "cell_type": "markdown", + "id": "28622c20", + "metadata": {}, + "source": [ + " A reactive variable is a container around a value (like a int, string or list) that allows the UI to automatically listen to changes. Any change to `your_reactive_variable.value` will be picked up by solara component that use them, so that they can automatically redraw or update itself.\n", + "\n", + " We now create our first component (`View`) which filters the data (based on the reactive variables), and shows the map and the charts. Solara supports the `display` mechanism of Jupyter, so we can simply use our previously defined functions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56055643", + "metadata": {}, + "outputs": [], + "source": [ + "@solara.component\n", + "def View():\n", + " dff = crime_filter(df_crime, districts.value, categories.value)\n", + " row_count = len(dff)\n", + " if row_count > limit.value:\n", + " solara.Warning(f\"Only showing the first {limit.value} of {row_count:,} crimes on map\")\n", + " crime_map(dff.iloc[:limit.value])\n", + " if row_count > 0:\n", + " crime_charts(dff)\n", + " else:\n", + " solara.Warning(\"You filtered out all the data, no charts shown\")\n", + "View()\n" + ] + }, + { + "cell_type": "markdown", + "id": "0b05c1db", + "metadata": {}, + "source": [ + "Note that some of the code (like the warning and the charts) are conditional. Solara will automatically find out what to add, remove or update without you having to do this manually. Solara is declarative (similar to ReactJS), but also reactive. If we change the reactive variables, Solara sees that changes and notifies the component instances that use its value. After executing the next lines of code, our `View` will automatically update." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fef5d187", + "metadata": {}, + "outputs": [], + "source": [ + "limit.value = 200\n", + "districts.value = ['Soutern', 'Northern']\n" + ] + }, + { + "cell_type": "markdown", + "id": "8822d100", + "metadata": {}, + "source": [ + "We can now explore out data much faster, since we don't need to re-run the cells that depended on it. " + ] + }, + { + "cell_type": "markdown", + "id": "917a1501", + "metadata": {}, + "source": [ + "## Adding controls\n", + "\n", + "We created a mini app in our notebook that is declerative *and* reactive, but we still need to manually modify the values by executing a code cell, while we promised a UI to control it. Luckily, all Solara input components supports reactive variables. This means that controlling a reactive variable using a UI element is often a one-liner." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c78010ec", + "metadata": {}, + "outputs": [], + "source": [ + "solara.SelectMultiple('District', all_values=[str(k) for k in df_crime['PdDistrict'].unique().tolist()], values=districts)\n" + ] + }, + { + "cell_type": "markdown", + "id": "74484e0c", + "metadata": {}, + "source": [ + "Whow, that was simple! We can now easily change the filter and see the results update. Lets do this for all our reactive variables, and put them into a single component." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18290364", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "@solara.component\n", + "def Controls():\n", + " solara.SelectMultiple('District', all_values=[str(k) for k in df_crime['PdDistrict'].unique().tolist()], values=districts)\n", + " solara.SelectMultiple('Category', all_values=[str(k) for k in df_crime['Category'].unique().tolist()], values=categories)\n", + " solara.Text(\"Maximum number of rows to show on map\")\n", + " solara.SliderInt('', value=limit, min=1, max=1000)\n", + "Controls()\n" + ] + }, + { + "cell_type": "markdown", + "id": "0c1ac606", + "metadata": {}, + "source": [ + "Note that the reactive variables are bi-directional, meaning that if you change it in the UI elements, it gets reflected on the Python code!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ca68fe8", + "metadata": {}, + "outputs": [], + "source": [ + "# Note that we can read AND write reactive variables\n", + "categories.value = [*categories.value, 'Vandalism']\n" + ] + }, + { + "cell_type": "markdown", + "id": "5af8e2b8", + "metadata": {}, + "source": [ + "## The final dashboard\n", + "\n", + "We now have two parts of our UI in separate cells. This can be an amazing experience when developing **in a** notebook, as it flows **naturally** in the data exploration process while writing your notebook.\n", + "\n", + "However, your end user will probably want something more coherent. The components we created are perfectly re-usable, so we put them together in a single UI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af686391", + "metadata": {}, + "outputs": [], + "source": [ + "@solara.component\n", + "def Page():\n", + " with solara.Sidebar():\n", + " Controls()\n", + " View()\n", + "Page()\n" + ] + }, + { + "cell_type": "markdown", + "id": "e07fd010", + "metadata": {}, + "source": [ + "## Conclusions\n", + "\n", + "Using Solara, you created an interactive dashboard **within a** Jupyter notebook. Your Solara components are declarative, and **when** using reactive variables also reactive. **Whether** you change a reactive variables via code or the UI elements, your visualizations and map update automatically.\n", + "\n", + "Your dashboard prototype now runs in your Jupyter notebook environment, but we still have a few steps to we want to take. In our next tutorial, we will focus on deploying our notebook, without making any code changes. In our third tutorial we will expand our dashboard with a few more components and focus on creating a more advanced layout.\n", + "\n", + "\n", + "\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/solara/website/public/docs/tutorial/jupyter-dashboard1.png b/solara/website/public/docs/tutorial/jupyter-dashboard1.png new file mode 100644 index 000000000..4e1f3d5f6 Binary files /dev/null and b/solara/website/public/docs/tutorial/jupyter-dashboard1.png differ