Group Kino notebooks under their own section (#830)

This commit is contained in:
Jonatan Kłosko 2021-12-27 21:01:31 +01:00 committed by GitHub
parent 4bacba6b1d
commit 5670e5ccb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 386 additions and 14 deletions

View file

@ -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

View file

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = `
<div id="count"></div>
<button id="bump">Bump</button>
`;
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! 🚀

View file

@ -1,4 +1,4 @@
# Interactions with Kino
# Introduction to Kino
## Setup

View file

@ -1,4 +1,4 @@
# Building Pong
# Building multiplayer Pong
## Introduction

View file

@ -66,6 +66,9 @@ defmodule LivebookWeb.ExploreLive do
<ExploreHelpers.notebook_card notebook_info={info} socket={@socket} />
<% end %>
</div>
<%= for group_info <- Explore.group_infos() do %>
<.notebook_group group_info={group_info} socket={@socket} />
<% end %>
</div>
</div>
</div>
@ -78,6 +81,46 @@ defmodule LivebookWeb.ExploreLive do
"""
end
defp notebook_group(assigns) do
~H"""
<div>
<div class="p-8 rounded-2xl border border-gray-300 flex space-x-8 items-center">
<img src={@group_info.cover_url} width="100" />
<div>
<div class="inline-flex px-2 py-0.5 bg-gray-200 rounded-3xl text-gray-700 text-xs font-medium">
<%= length(@group_info.notebook_infos) %> notebooks
</div>
<h3 class="mt-1 text-2xl text-gray-800 font-semibold">
<%= @group_info.title %>
</h3>
<p class="mt-2 text-gray-700">
<%= @group_info.description %>
</p>
</div>
</div>
<div class="mt-4">
<ul>
<%= for {notebook_info, number} <- Enum.with_index(@group_info.notebook_infos, 1) do %>
<li class="py-4 flex items-center space-x-5 border-b border-gray-200 last:border-b-0">
<div class="text-lg text-gray-400 font-semibold">
<%= number |> Integer.to_string() |> String.pad_leading(2, "0") %>
</div>
<div class="flex-grow text-lg text-gray-800 font-semibold">
<%= notebook_info.title %>
</div>
<%= 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 %>
</li>
<% end %>
</ul>
</div>
</div>
"""
end
@impl true
def handle_params(%{"slug" => "new"}, _url, socket) do
{:noreply, create_session(socket)}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB