mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-18 09:27:14 +08:00
Group Kino notebooks under their own section (#830)
This commit is contained in:
parent
4bacba6b1d
commit
5670e5ccb6
6 changed files with 386 additions and 14 deletions
|
@ -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
|
||||
|
|
|
@ -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: '© <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! 🚀
|
|
@ -1,4 +1,4 @@
|
|||
# Interactions with Kino
|
||||
# Introduction to Kino
|
||||
|
||||
## Setup
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Building Pong
|
||||
# Building multiplayer Pong
|
||||
|
||||
## Introduction
|
||||
|
|
@ -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 |
Loading…
Add table
Reference in a new issue