From 5670e5ccb69e97423a54e48126dc5ab36819568c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Mon, 27 Dec 2021 21:01:31 +0100 Subject: [PATCH] Group Kino notebooks under their own section (#830) --- lib/livebook/notebook/explore.ex | 63 +++- .../kino/creating_custom_widgets.livemd | 290 ++++++++++++++++++ .../explore/{ => kino}/intro_to_kino.livemd | 2 +- .../notebook/explore/{ => kino}/pong.livemd | 2 +- lib/livebook_web/live/explore_live.ex | 43 +++ static/images/pong.png | Bin 3880 -> 0 bytes 6 files changed, 386 insertions(+), 14 deletions(-) create mode 100644 lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd rename lib/livebook/notebook/explore/{ => kino}/intro_to_kino.livemd (99%) rename lib/livebook/notebook/explore/{ => kino}/pong.livemd (99%) delete mode 100644 static/images/pong.png diff --git a/lib/livebook/notebook/explore.ex b/lib/livebook/notebook/explore.ex index 8b45844d6..effcb1a17 100644 --- a/lib/livebook/notebook/explore.ex +++ b/lib/livebook/notebook/explore.ex @@ -12,6 +12,7 @@ defmodule Livebook.Notebook.Explore do end @type notebook_info :: %{ + ref: atom() | nil, slug: String.t(), livemd: String.t(), title: String.t(), @@ -26,6 +27,13 @@ defmodule Livebook.Notebook.Explore do cover_url: String.t() } + @type group_info :: %{ + title: String.t(), + description: String.t(), + cover_url: String.t(), + notebook_infos: list(notebook_info()) + } + images_dir = Path.expand("explore/images", __DIR__) welcome_config = %{ @@ -63,13 +71,6 @@ defmodule Livebook.Notebook.Explore do cover_url: "/images/vega_lite.png" } }, - %{ - path: Path.join(__DIR__, "explore/intro_to_kino.livemd"), - details: %{ - description: "Display and control rich and interactive widgets in Livebook.", - cover_url: "/images/kino.png" - } - }, %{ path: Path.join(__DIR__, "explore/intro_to_nx.livemd"), details: %{ @@ -93,11 +94,16 @@ defmodule Livebook.Notebook.Explore do } }, %{ - path: Path.join(__DIR__, "explore/pong.livemd"), - details: %{ - description: "Implement and play multiplayer Pong directly in Livebook.", - cover_url: "/images/pong.png" - } + ref: :kino_intro, + path: Path.join(__DIR__, "explore/kino/intro_to_kino.livemd") + }, + %{ + ref: :kino_pong, + path: Path.join(__DIR__, "explore/kino/pong.livemd") + }, + %{ + ref: :kino_custom_widgets, + path: Path.join(__DIR__, "explore/kino/creating_custom_widgets.livemd") } ] @@ -136,6 +142,7 @@ defmodule Livebook.Notebook.Explore do config[:slug] || path |> Path.basename() |> Path.rootname() |> String.replace("_", "-") %{ + ref: config[:ref], slug: slug, livemd: markdown, title: notebook.name, @@ -190,4 +197,36 @@ defmodule Livebook.Notebook.Explore do {notebook, notebook_info.images} end end + + @group_configs [ + %{ + title: "Interactions with Kino", + description: + "Kino is an Elixir package that allows for displaying and controlling rich, interactieve widgets in Livebook. Learn how to make your notebooks more engaging with inputs, plots, tables, and much more!", + cover_url: "/images/kino.png", + notebook_refs: [:kino_intro, :kino_pong, :kino_custom_widgets] + } + ] + + @doc """ + Returns a list of all defined notebook groups. + """ + @spec group_infos() :: list(group_info()) + def group_infos() do + notebook_infos = notebook_infos() + + for config <- @group_configs do + %{ + title: config.title, + description: config.description, + cover_url: config.cover_url, + notebook_infos: + for( + ref <- config.notebook_refs, + info = Enum.find(notebook_infos, &(&1[:ref] == ref)), + do: info + ) + } + end + end end diff --git a/lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd b/lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd new file mode 100644 index 000000000..dc6c7a18c --- /dev/null +++ b/lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd @@ -0,0 +1,290 @@ +# Creating custom widgets + +## Introduction + +The `Kino.JS` and `Kino.JS.Live` docs outline the API that enables +developing custom JavaScript powered widgets. The examples discussed +there are kept minimal to introduce the basic concepts without much +overhead. In this notebook we take things a bit further and showcase +a couple more elaborate use cases. + +```elixir +Mix.install([ + {:kino, github: "livebook-dev/kino"} +]) +``` + +## Diagrams with Mermaid + +As a quick recap let's define a widget for rendering diagrams +from text specification using [Mermaid](https://mermaid-js.github.io/mermaid/#/). + +```elixir +defmodule Kino.Mermaid do + use Kino.JS + + def new(graph) do + Kino.JS.new(__MODULE__, graph) + end + + asset "main.js" do + """ + import "https://cdn.jsdelivr.net/npm/mermaid@8.13.3/dist/mermaid.min.js"; + + mermaid.initialize({ startOnLoad: false }); + + export function init(ctx, graph) { + mermaid.render("graph1", graph, (svgSource, bindListeners) => { + ctx.root.innerHTML = svgSource; + bindListeners && bindListeners(ctx.root); + }); + } + """ + end +end +``` + +In this case we pass the graph specification to Mermaid, which +generates an SVG image for us and we embed it directly into the +page. Note how we import the package directly from a CDN. Using +this approach we can quickly create widgets without setting up +a whole JavaScript bundling system. + +Let's celebate our new widget with a couple graphs. Feel free +to try out other examples from the Mermaid website! + +```elixir +Kino.Mermaid.new(""" +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +""") +``` + +```elixir +Kino.Mermaid.new(""" +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains + CUSTOMER }|..|{ DELIVERY-ADDRESS : uses +""") +``` + +## Dynamic maps with Leaflet + +Widgets with static data are useful, but they really come down +to a piece of JavaScript. This time we will try out something +more exciting. We will set up a simple map and then push points +directly from the Elixir code! + +There is a number of different JavaScript packages to pick from +when dealing with maps, for our purpose we will use [Leaflet](https://leafletjs.com), +which is an established solution in this area. + +```elixir +defmodule Kino.Leaflet do + use Kino.JS + use Kino.JS.Live + + def new(center, zoom) do + Kino.JS.Live.new(__MODULE__, {normalize_location(center), zoom}) + end + + def add_marker(widget, location) do + Kino.JS.Live.cast(widget, {:add_marker, normalize_location(location)}) + end + + @impl true + def init({center, zoom}, ctx) do + {:ok, assign(ctx, center: center, zoom: zoom, locations: [])} + end + + @impl true + def handle_connect(ctx) do + data = %{ + center: ctx.assigns.center, + zoom: ctx.assigns.zoom, + locations: ctx.assigns.locations + } + + {:ok, data, ctx} + end + + @impl true + def handle_cast({:add_marker, location}, ctx) do + ctx = + ctx + |> broadcast_event("add_marker", location) + |> update(:locations, &[location | &1]) + + {:noreply, ctx} + end + + defp normalize_location({lag, lng}), do: [lag, lng] + + asset "main.js" do + """ + import * as L from "https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.esm.js"; + + export async function init(ctx, data) { + ctx.root.style.height = "400px"; + + // Leaflet requires styles to be present before creating the map, + // so we await for the import to finish + await ctx.importCSS("https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet.css"); + + const { center, zoom, locations } = data; + const map = L.map(ctx.root, { center, zoom }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(map); + + locations.forEach((location) => { + L.marker(location).addTo(map); + }); + + ctx.handleEvent("add_marker", (location) => { + L.marker(location).addTo(map); + }); + } + """ + end +end +``` + +This is a bit more code, however the flow is very straightforward. +The map is initialized with the central location and zoom, we store +those in the server state and pass to each client when they connect. + +Additionally we keep a list of locations that we want to mark on the +map. The public `add_marker` function allows for pushing new locations +to the server, in which case we send the it to the client. On the +client we render all initial markers we get and subscribe to any new +that appear later on. + +Note that we keep track of all locations on the server, this way +whenever a new user joins the page, we can send them all of the +locations we already have. To verify this behaviour you can refresh +the page and you should see all of the markers still in place. Feel +free to try this out in separte browser tabs too! + +```elixir +map = Kino.Leaflet.new({51.505, -0.09}, 13) +``` + +The below cell marks a random location, so you can evaluate it +multiple times for better results. + +```elixir +delta = fn -> (:rand.uniform() - 0.5) * 0.05 end + +Kino.Leaflet.add_marker(map, {51.505 + delta.(), -0.09 + delta.()}) +``` + +We barely scratched the surface of maps, the Leaflet API alone is extremely +extensive and there are other packages worth exploring. However, even with +this simple widget we could already visualize some geographic data in real-time! + +## Bidirectional live counter + +The map example reiterated how we can send events from the server +to the clients, however communication in the other direction is +possible as well! + +Let's build a counter that can be incremented both through Elixir +calls and client interactions. + +```elixir +defmodule Kino.Counter do + use Kino.JS + use Kino.JS.Live + + def new(count) do + Kino.JS.Live.new(__MODULE__, count) + end + + def bump(widget) do + Kino.JS.Live.cast(widget, :bump) + end + + @impl true + def init(count, ctx) do + {:ok, assign(ctx, count: count)} + end + + @impl true + def handle_connect(ctx) do + {:ok, ctx.assigns.count, ctx} + end + + @impl true + def handle_cast(:bump, ctx) do + {:noreply, bump_count(ctx)} + end + + @impl true + def handle_event("bump", _, ctx) do + {:noreply, bump_count(ctx)} + end + + defp bump_count(ctx) do + ctx = update(ctx, :count, &(&1 + 1)) + broadcast_event(ctx, "update", ctx.assigns.count) + end + + asset "main.js" do + """ + export function init(ctx, count) { + ctx.root.innerHTML = ` +
+ + `; + + const countEl = document.getElementById("count"); + const bumpEl = document.getElementById("bump"); + + countEl.innerHTML = count; + + ctx.handleEvent("update", (count) => { + countEl.innerHTML = count; + }); + + bumpEl.addEventListener("click", (event) => { + ctx.pushEvent("bump"); + }); + } + """ + end +end +``` + +At this point the server mechanics should be clear. On the +client side we listen to button clicks and whenever it happens +we send the `"bump"` event to the server. This event gets +handled by the `handle_event` callback, similarly to other +message types. + +Let's render our counter! + +```elixir +counter = Kino.Counter.new(0) +``` + +As an experiment you can open another browser tab to verify +that the counter is synchronized. + +In addition to client events we can also use the Elixir API +we defined for our counter. + +```elixir +Kino.Counter.bump(counter) +``` + +## Final words + +Hopefully these futher examples give you a better idea of the +possibilities enabled by custom JavaScript widgets. We would +love to see what cool stuff you can build with it! 🚀 diff --git a/lib/livebook/notebook/explore/intro_to_kino.livemd b/lib/livebook/notebook/explore/kino/intro_to_kino.livemd similarity index 99% rename from lib/livebook/notebook/explore/intro_to_kino.livemd rename to lib/livebook/notebook/explore/kino/intro_to_kino.livemd index 802470164..fce1931fd 100644 --- a/lib/livebook/notebook/explore/intro_to_kino.livemd +++ b/lib/livebook/notebook/explore/kino/intro_to_kino.livemd @@ -1,4 +1,4 @@ -# Interactions with Kino +# Introduction to Kino ## Setup diff --git a/lib/livebook/notebook/explore/pong.livemd b/lib/livebook/notebook/explore/kino/pong.livemd similarity index 99% rename from lib/livebook/notebook/explore/pong.livemd rename to lib/livebook/notebook/explore/kino/pong.livemd index bb1bd8ece..5b5cd7603 100644 --- a/lib/livebook/notebook/explore/pong.livemd +++ b/lib/livebook/notebook/explore/kino/pong.livemd @@ -1,4 +1,4 @@ -# Building Pong +# Building multiplayer Pong ## Introduction diff --git a/lib/livebook_web/live/explore_live.ex b/lib/livebook_web/live/explore_live.ex index bffb7f094..ddc446953 100644 --- a/lib/livebook_web/live/explore_live.ex +++ b/lib/livebook_web/live/explore_live.ex @@ -66,6 +66,9 @@ defmodule LivebookWeb.ExploreLive do <% end %> + <%= for group_info <- Explore.group_infos() do %> + <.notebook_group group_info={group_info} socket={@socket} /> + <% end %> @@ -78,6 +81,46 @@ defmodule LivebookWeb.ExploreLive do """ end + defp notebook_group(assigns) do + ~H""" +
+
+ +
+
+ <%= length(@group_info.notebook_infos) %> notebooks +
+

+ <%= @group_info.title %> +

+

+ <%= @group_info.description %> +

+
+
+
+
    + <%= for {notebook_info, number} <- Enum.with_index(@group_info.notebook_infos, 1) do %> +
  • +
    + <%= number |> Integer.to_string() |> String.pad_leading(2, "0") %> +
    +
    + <%= notebook_info.title %> +
    + <%= live_redirect to: Routes.explore_path(@socket, :notebook, notebook_info.slug), + class: "button-base button-outlined-gray" do %> + <.remix_icon icon="play-circle-line" class="align-middle mr-1" /> + Open notebook + <% end %> +
  • + <% end %> +
+
+
+ """ + end + @impl true def handle_params(%{"slug" => "new"}, _url, socket) do {:noreply, create_session(socket)} diff --git a/static/images/pong.png b/static/images/pong.png deleted file mode 100644 index aa2f579083e8c426ca9b410f1cb1e399fa33270e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3880 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 Y{K&qbDW#NdaLa{sFB@RrUa}4O7vB39_y7O^