Visualize primary and replicas (inspired by Waterpark)
Mix.install([
{:kino, "~> 0.17.0"}
], consolidate_protocols: false)
Library modules
This is a visualization of how one could distribute stateful actors (a primary + n
replicas) across a cluster of VMs, spanning multiple regions and availability zones (in each region). This is inspired by the talk Waterpark: Transforming Healthcare with Distributed Actors - Bryan Hunter - NDC Oslo 2025.
This short stand-alone livebook is supposed to be opened using livebook.dev. The data for one sample customer is hard-coded in this livebook.
If you have questions, comments, hit me up :-).
defmodule ComputeNode do
@moduledoc """
Represents a compute node in the distributed system.
A compute node is uniquely identified by its region, availability zone, and VM ID.
"""
@enforce_keys [:region, :zone, :id]
defstruct [:region, :zone, :id]
@type t :: %__MODULE__{
region: String.t(),
zone: String.t(),
id: String.t()
}
@doc """
Creates a new compute node.
## Examples
iex> ComputeNode.new("Amsterdam", "AZ1", "VM1")
%ComputeNode{region: "Amsterdam", zone: "AZ1", id: "VM1"}
"""
def new(region, zone, id) when is_integer(id) do
%__MODULE__{
region: region,
zone: zone,
id: id |> Integer.to_string() |> String.pad_leading(4, "0")
}
end
def new(region, zone, id) do
%__MODULE__{
region: region,
zone: zone,
id: id
}
end
@doc """
Returns the availability zone tuple for the node.
"""
def az_tuple(%__MODULE__{region: region, zone: zone}) do
{region, zone}
end
end
defimpl String.Chars, for: ComputeNode do
def to_string(%ComputeNode{region: region, zone: zone, id: id}) when is_integer(id) do
"#{region}-#{zone}-#{String.pad_leading(Integer.to_string(id), 4, "0")}"
end
def to_string(%ComputeNode{region: region, zone: zone, id: id}) do
"#{region}-#{zone}-#{id}"
end
end
defmodule Drawing do
@node_width 32
@node_height 20
@node_spacing 8
@zone_padding 10
@region_padding 20
@canvas_padding 20
@zone_header_height 20
@region_header_height 25
@doc """
Generates an SVG visualization of a replication strategy.
## Parameters
- `nodes`: List of ComputeNode structs, where the first is the primary
- `replica_count`: Number of replicas to create from the rest of the list
## Returns
A string containing the SVG markup
"""
def generate_svg([primary | rest], replica_count) do
# Determine node roles
replicas = Enum.take(rest, replica_count)
unused = Enum.drop(rest, replica_count)
# Organize all nodes by region and zone
all_nodes = [primary | rest]
organized = organize_nodes(all_nodes)
# Create color mapping for each node
node_colors = create_node_color_map(primary, replicas, unused)
# Calculate complete layout with positions
layout = calculate_complete_layout(organized, node_colors)
# Generate SVG content
svg_content = generate_svg_structure(layout)
# Generate arrows from primary to replicas
arrows = generate_replica_arrows(primary, replicas, layout, organized)
# Combine everything
svg_content <> arrows <> "\n"
end
# Organize nodes into a nested map: region -> zone -> [nodes]
defp organize_nodes(nodes) do
nodes
|> Enum.group_by(& &1.region)
|> Enum.map(fn {region, region_nodes} ->
zones =
region_nodes
|> Enum.group_by(& &1.zone)
|> Enum.map(fn {zone, zone_nodes} ->
sorted_nodes = Enum.sort_by(zone_nodes, & &1.id)
{zone, sorted_nodes}
end)
|> Enum.sort_by(&elem(&1, 0))
|> Map.new()
{region, zones}
end)
|> Enum.sort_by(&elem(&1, 0))
|> Map.new()
end
# Create a map of node -> color for easy lookup
defp create_node_color_map(primary, replicas, unused) do
primary_map = %{node_key(primary) => "#ff4444"}
replica_map = replicas |> Enum.map(&{node_key(&1), "#4444ff"}) |> Map.new()
unused_map = unused |> Enum.map(&{node_key(&1), "#999999"}) |> Map.new()
Map.merge(primary_map, Map.merge(replica_map, unused_map))
end
# Create a unique key for a node
defp node_key(node), do: "#{node.region}-#{node.zone}-#{node.id}"
# Calculate complete layout with all positions
defp calculate_complete_layout(organized, node_colors) do
regions = Map.keys(organized) |> Enum.sort()
# Calculate region grid layout
{region_rows, region_cols} = calculate_grid_layout(length(regions), :region)
# First pass: calculate dimensions for all regions
region_dimensions =
regions
|> Enum.map(fn region_name ->
zones = Map.get(organized, region_name, %{})
{region_name, calculate_region_size(zones)}
end)
|> Map.new()
# Calculate canvas size based on grid layout
{canvas_width, canvas_height} =
calculate_canvas_size(
region_dimensions,
regions,
region_rows,
region_cols
)
# Second pass: position regions and their contents
positioned_regions =
regions
|> Enum.with_index()
|> Enum.map(fn {region_name, index} ->
row = div(index, region_cols)
col = rem(index, region_cols)
# Calculate region position
{region_x, region_y} =
calculate_region_position(
region_dimensions,
regions,
row,
col,
region_cols
)
# Get region dimensions
{region_width, region_height} = Map.get(region_dimensions, region_name)
# Position zones within region
zones = Map.get(organized, region_name, %{})
positioned_zones = position_zones_in_region(zones, region_x, region_y, node_colors)
%{
name: region_name,
x: region_x,
y: region_y,
width: region_width,
height: region_height,
zones: positioned_zones
}
end)
# Collect all node positions for arrow drawing
node_positions = collect_node_positions(positioned_regions)
%{
canvas_width: canvas_width,
canvas_height: canvas_height,
regions: positioned_regions,
node_positions: node_positions
}
end
# Calculate the size of a region based on its zones
defp calculate_region_size(zones) when zones == %{} do
# Minimum region size for empty regions
{120, 80}
end
defp calculate_region_size(zones) do
zone_count = map_size(zones)
{zone_rows, zone_cols} = calculate_grid_layout(zone_count, :zone)
# Calculate max zone dimensions
max_nodes_in_zone =
zones
|> Map.values()
|> Enum.map(&length/1)
|> Enum.max(fn -> 0 end)
# Calculate zone dimensions
{node_rows, node_cols} = calculate_grid_layout(max_nodes_in_zone, :node)
zone_width = node_cols * @node_width + (node_cols + 1) * @node_spacing
zone_height = @zone_header_height + node_rows * @node_height + (node_rows + 1) * @node_spacing
# Calculate region dimensions
region_width = zone_cols * zone_width + (zone_cols + 1) * @zone_padding
region_height =
@region_header_height + zone_rows * zone_height + (zone_rows + 1) * @zone_padding
{region_width, region_height}
end
# Calculate total canvas size
defp calculate_canvas_size(region_dimensions, regions, rows, cols) do
# Calculate max width for each column
col_widths =
0..(cols - 1)
|> Enum.map(fn col ->
regions
|> Enum.with_index()
|> Enum.filter(fn {_, idx} -> rem(idx, cols) == col end)
|> Enum.map(fn {region, _} ->
elem(Map.get(region_dimensions, region, {0, 0}), 0)
end)
|> Enum.max(fn -> 0 end)
end)
# Calculate max height for each row
row_heights =
0..(rows - 1)
|> Enum.map(fn row ->
regions
|> Enum.with_index()
|> Enum.filter(fn {_, idx} -> div(idx, cols) == row end)
|> Enum.map(fn {region, _} ->
elem(Map.get(region_dimensions, region, {0, 0}), 1)
end)
|> Enum.max(fn -> 0 end)
end)
total_width = Enum.sum(col_widths) + (cols + 1) * @region_padding + 2 * @canvas_padding
total_height = Enum.sum(row_heights) + (rows + 1) * @region_padding + 2 * @canvas_padding
{total_width, total_height}
end
# Calculate the position of a region in the grid
defp calculate_region_position(region_dimensions, regions, row, col, region_cols) do
# Calculate x offset from previous columns
x_offset =
regions
|> Enum.with_index()
|> Enum.filter(fn {_, idx} ->
div(idx, region_cols) == row && rem(idx, region_cols) < col
end)
|> Enum.map(fn {region, _} ->
elem(Map.get(region_dimensions, region, {0, 0}), 0)
end)
|> Enum.sum()
# Calculate y offset from previous rows
y_offset =
regions
|> Enum.with_index()
|> Enum.filter(fn {_, idx} -> div(idx, region_cols) < row end)
|> Enum.group_by(fn {_, idx} -> div(idx, region_cols) end)
|> Enum.map(fn {_, group} ->
group
|> Enum.map(fn {region, _} ->
elem(Map.get(region_dimensions, region, {0, 0}), 1)
end)
|> Enum.max(fn -> 0 end)
end)
|> Enum.sum()
x = @canvas_padding + @region_padding + x_offset + col * @region_padding
y = @canvas_padding + @region_padding + y_offset + row * @region_padding
{x, y}
end
# Position zones within a region
defp position_zones_in_region(zones, region_x, region_y, node_colors) do
zone_list = zones |> Map.to_list() |> Enum.sort_by(&elem(&1, 0))
zone_count = length(zone_list)
{_zone_rows, zone_cols} = calculate_grid_layout(zone_count, :zone)
zone_list
|> Enum.with_index()
|> Enum.map(fn {{zone_name, nodes}, index} ->
row = div(index, zone_cols)
col = rem(index, zone_cols)
# Calculate zone dimensions
node_count = length(nodes)
{node_rows, node_cols} = calculate_grid_layout(node_count, :node)
zone_width =
if node_count > 0 do
node_cols * @node_width + (node_cols + 1) * @node_spacing
else
80
end
zone_height =
if node_count > 0 do
@zone_header_height + node_rows * @node_height + (node_rows + 1) * @node_spacing
else
60
end
# Calculate zone position within region
zone_x = region_x + @zone_padding + col * (zone_width + @zone_padding)
zone_y =
region_y + @region_header_height + @zone_padding + row * (zone_height + @zone_padding)
# Position nodes within zone
positioned_nodes =
nodes
|> Enum.with_index()
|> Enum.map(fn {node, n_index} ->
n_row = div(n_index, node_cols)
n_col = rem(n_index, node_cols)
node_x =
zone_x + @node_spacing + n_col * (@node_width + @node_spacing) + @node_width / 2
node_y =
zone_y + @zone_header_height + @node_spacing + n_row * (@node_height + @node_spacing) +
@node_height / 2
%{
node: node,
x: node_x,
y: node_y,
color: Map.get(node_colors, node_key(node), "#999999")
}
end)
%{
name: zone_name,
x: zone_x,
y: zone_y,
width: zone_width,
height: zone_height,
nodes: positioned_nodes
}
end)
end
# Collect all node positions for arrow drawing
defp collect_node_positions(regions) do
regions
|> Enum.flat_map(fn region ->
region.zones
|> Enum.flat_map(fn zone ->
zone.nodes
|> Enum.map(fn node_info ->
{node_key(node_info.node), {node_info.x, node_info.y}}
end)
end)
end)
|> Map.new()
end
# Calculate optimal grid layout based on count and type
defp calculate_grid_layout(count, type) do
case {type, count} do
{_, 0} ->
{1, 1}
{_, 1} ->
{1, 1}
# Region layouts - prefer vertical for small counts
{:region, n} when n <= 2 ->
{n, 1}
{:region, 3} ->
{1, 3}
{:region, 4} ->
{2, 2}
{:region, 5} ->
{3, 2}
{:region, 6} ->
{3, 2}
{:region, n} when n <= 9 ->
{3, 3}
{:region, n} when n <= 12 ->
{4, 3}
# Zone layouts - prefer vertical for small counts
{:zone, n} when n <= 4 ->
{n, 1}
{:zone, n} when n <= 6 ->
{3, 2}
{:zone, n} when n <= 9 ->
{3, 3}
# Node layouts - prefer horizontal for small counts
{:node, n} when n <= 4 ->
{1, n}
{:node, n} when n <= 8 ->
{2, 4}
{:node, n} when n <= 12 ->
{3, 4}
# Default: make it as square as possible
{_, n} ->
cols = :math.ceil(:math.sqrt(n)) |> round()
rows = :math.ceil(n / cols) |> round()
{rows, cols}
end
end
# Generate the SVG structure
defp generate_svg_structure(layout) do
"""
#{generate_styles()}
#{generate_regions_svg(layout.regions)}
"""
end
# Generate CSS styles
defp generate_styles do
"""
.region { fill: #f5f5f5; stroke: #000000; stroke-width: 2; }
.zone { fill: #ffffff; stroke: #999999; stroke-width: 1; }
.node { stroke-width: 1; }
.node-text { fill: white; font-size: 10px; font-family: Arial, sans-serif; text-anchor: middle; }
.region-title { font-size: 14px; font-weight: bold; font-family: Arial, sans-serif; }
.zone-title { font-size: 11px; font-family: Arial, sans-serif; fill: #666666; }
.arrow-thick { stroke: #ff4444; stroke-width: 3; opacity: 1.0; fill: none; }
.arrow-medium { stroke: #ff4444; stroke-width: 2; opacity: 0.8; fill: none; }
.arrow-thin { stroke: #ff4444; stroke-width: 1; opacity: 0.5; stroke-dasharray: 4,2; fill: none; }
.arrowhead-thick { fill: #ff4444; opacity: 1.0; }
.arrowhead-medium { fill: #ff4444; opacity: 0.8; }
.arrowhead-thin { fill: #ff4444; opacity: 0.5; }
"""
end
# Generate SVG for all regions
defp generate_regions_svg(regions) do
regions
|> Enum.map(&generate_region_svg/1)
|> Enum.join("\n")
end
# Generate SVG for a single region
defp generate_region_svg(region) do
"""
#{region.name}
#{generate_zones_svg(region.zones)}
"""
end
# Generate SVG for all zones
defp generate_zones_svg(zones) do
zones
|> Enum.map(&generate_zone_svg/1)
|> Enum.join("\n")
end
# Generate SVG for a single zone
defp generate_zone_svg(zone) do
"""
#{zone.name}
#{generate_nodes_svg(zone.nodes)}
"""
end
# Generate SVG for all nodes
defp generate_nodes_svg(nodes) do
nodes
|> Enum.map(&generate_node_svg/1)
|> Enum.join("\n")
end
# Generate SVG for a single node
defp generate_node_svg(node_info) do
# Node position is center, adjust to top-left for rectangle
x = node_info.x - @node_width / 2
y = node_info.y - @node_height / 2
stroke_color =
case node_info.color do
"#ff4444" -> "#cc0000"
"#4444ff" -> "#0000cc"
_ -> "#666666"
end
"""
#{node_info.node.id}
"""
end
# Generate arrows from primary to replicas
defp generate_replica_arrows(primary, replicas, layout, organized) do
primary_pos = Map.get(layout.node_positions, node_key(primary))
if primary_pos == nil do
""
else
regions = Map.keys(organized) |> length()
# Track connected regions and zones for priority
{arrows, _} =
replicas
|> Enum.with_index()
|> Enum.map_reduce({MapSet.new(), MapSet.new()}, fn {replica, index},
{connected_regions, connected_zones} ->
replica_pos = Map.get(layout.node_positions, node_key(replica))
if replica_pos == nil do
{"", {connected_regions, connected_zones}}
else
# Determine arrow style based on priority
{arrow_class, new_regions, new_zones} =
determine_arrow_priority(
replica,
index,
regions,
connected_regions,
connected_zones
)
arrow_svg = draw_arrow_with_head(primary_pos, replica_pos, arrow_class)
{arrow_svg, {new_regions, new_zones}}
end
end)
arrows |> Enum.join("\n")
end
end
# Determine arrow priority and style
defp determine_arrow_priority(replica, _index, region_count, connected_regions, connected_zones) do
zone_key = {replica.region, replica.zone}
cond do
# First connections to each region (thick arrows)
not MapSet.member?(connected_regions, replica.region) and
MapSet.size(connected_regions) < region_count ->
{"arrow-thick", MapSet.put(connected_regions, replica.region),
MapSet.put(connected_zones, zone_key)}
# Connections to new zones (medium arrows)
not MapSet.member?(connected_zones, zone_key) ->
{"arrow-medium", connected_regions, MapSet.put(connected_zones, zone_key)}
# Additional redundancy (thin arrows)
true ->
{"arrow-thin", connected_regions, connected_zones}
end
end
# Draw arrow with proper intersection calculation
defp draw_arrow_with_head({x1, y1}, {x2, y2}, arrow_class) do
# Calculate angle from source to target
dx = x2 - x1
dy = y2 - y1
angle = :math.atan2(dy, dx)
# Calculate border intersection points
{start_x, start_y} = calculate_rect_border_point(x1, y1, angle, @node_width, @node_height)
{end_x, end_y} =
calculate_rect_border_point(x2, y2, angle + :math.pi(), @node_width, @node_height)
# Create arrow head
arrowhead_class = String.replace(arrow_class, "arrow-", "arrowhead-")
arrowhead = create_arrowhead(end_x, end_y, angle, arrowhead_class)
"""
#{arrowhead}
"""
end
# Calculate where line intersects rectangle border
defp calculate_rect_border_point(cx, cy, angle, width, height) do
# Rectangle half-dimensions
hw = width / 2
hh = height / 2
# Direction components
cos_a = :math.cos(angle)
sin_a = :math.sin(angle)
# Calculate intersection with rectangle edges
# We need the point where the ray from center intersects the rectangle
# Check which edge we'll hit first
t_x = if cos_a != 0, do: hw / abs(cos_a), else: 999_999
t_y = if sin_a != 0, do: hh / abs(sin_a), else: 999_999
t = min(t_x, t_y)
# Calculate intersection point
x = cx + t * cos_a
y = cy + t * sin_a
{x, y}
end
# Create SVG arrowhead
defp create_arrowhead(x, y, angle, class) do
# Arrowhead dimensions
size = 8
half_width = 3
# Calculate the three points of the triangle
cos_a = :math.cos(angle)
sin_a = :math.sin(angle)
# Perpendicular to arrow direction
perp_cos = -sin_a
perp_sin = cos_a
# Base center (back from tip)
base_x = x - size * cos_a
base_y = y - size * sin_a
# Two base points
p1_x = base_x + half_width * perp_cos
p1_y = base_y + half_width * perp_sin
p2_x = base_x - half_width * perp_cos
p2_y = base_y - half_width * perp_sin
"""
"""
end
end
nodes = [
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 5"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 2"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 1"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 6"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 1"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 5"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 7"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 2"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 8"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 7"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 2"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 1"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 6"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 4"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 6"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 6"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 5"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 4"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 2"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 4"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 4"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 6"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 5"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 8"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 1"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 1"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 4"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 1"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 3"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 6"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 8"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 4"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 4"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 8"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 5"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 1"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 3"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 3"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 6"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 5"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 6"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 7"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 7"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 3"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 7"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 8"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 3"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 7"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 8"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 2"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 3"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 4"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 7"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 2"},
%ComputeNode{region: "Tennessee", zone: "Zone A", id: "VM 8"},
%ComputeNode{region: "Texas", zone: "Zone B", id: "VM 2"},
%ComputeNode{region: "Utah", zone: "Zone B", id: "VM 3"},
%ComputeNode{region: "Florida", zone: "Zone B", id: "VM 2"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 5"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 1"},
%ComputeNode{region: "Florida", zone: "Zone A", id: "VM 7"},
%ComputeNode{region: "Utah", zone: "Zone A", id: "VM 5"},
%ComputeNode{region: "Tennessee", zone: "Zone B", id: "VM 3"},
%ComputeNode{region: "Texas", zone: "Zone A", id: "VM 8"}
]
Kino.nothing()
determine_nodes = fn (_name, _regions, _zones, _vms) ->
# datacenters =
# for region <- regions,
# az <- zones,
# id <- vms do
# ComputeNode.new(region, az, id)
# end
#
# bucket_hashes =
# datacenters
# |> RendevousHash.pre_compute_list()
#
# bucket_hashes
# |> RendevousHash.list(name)
# |> RendevousHash.sort_by_optimum_storage_resiliency()
nodes
end
# Create frames for dynamic content
slider_frame = Kino.Frame.new()
value_frame = Kino.Frame.new()
image2_frame = Kino.Frame.new()
# Variable to track current nodes and slider
current_slider = nil
# Function to render images and value display
render_content = fn nodes, scale_factor ->
max = length(nodes) - 1
max = if max < 1, do: 1, else: max
# Update value display
value_display = Kino.Markdown.new("**Replication Factor:** #{scale_factor} | **Nodes:** #{length(nodes)} | **Max:** #{max}")
Kino.Frame.render(value_frame, value_display)
# Generate and display images
image2 = Drawing.generate_svg(nodes, scale_factor) |> Kino.Image.new(:svg)
Kino.Frame.render(image2_frame, image2)
end
# Function to create and set up a new slider
create_slider = fn nodes ->
max = length(nodes) - 1
max = if max < 1, do: 1, else: max # Ensure max is at least 1
# Use consistent default value
default_scale = min(length(nodes), max)
slider = Kino.Input.range("Replication Factor", min: 1, max: max, default: default_scale, step: 1, debounce: 100)
slider
|> Kino.Control.stream()
|> Kino.listen(fn event ->
scale_factor = event.value |> trunc()
render_content.(nodes, scale_factor)
end)
# Return both slider and its default value
{slider, default_scale}
end
# Function to update everything when text changes
update_from_text = fn ->
# Determine nodes from text value
nodes = determine_nodes.(nil, nil, nil, nil)
# Create new slider with correct max and get its default value
{slider, default_scale} = create_slider.(nodes)
# Render the new slider
Kino.Frame.render(slider_frame, slider)
# Immediately render content with the slider's default value
render_content.(nodes, default_scale)
end
# Display the layout first
layout = [slider_frame,value_frame,image2_frame] |> Kino.Layout.grid(columns: 1)
# Initial setup with default value
update_from_text.() # Use the same default as the text input
layout