diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf3964b..557b29a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add pan and zoom controls for large pipeline graphs. Users can now:
+ - Scroll to zoom in/out (centered on cursor position)
+ - Click and drag to pan the view
+ - Use pinch gestures on touch devices
+ - Use control buttons for zoom in, zoom out, reset, and fit-to-view
+
+ To enable, add `BroadwayDashboard.Hooks` to your LiveDashboard `on_mount` configuration.
+ Addresses [#17](https://github.com/dashbitco/broadway_dashboard/issues/17).
+
+### Changed
+
+- Require `phoenix_live_dashboard` version 0.8.5 or later (was 0.8.0).
+
## [0.4.1] - 2024-01-09
### Fixed
diff --git a/README.md b/README.md
index dbb201f..d16b4ce 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,31 @@ See [Distribution](#distribution) for details.

+## Pan and Zoom
+
+For pipelines with large numbers of concurrent processors or batchers, you can use the
+pan and zoom controls to navigate the pipeline graph:
+
+- **Scroll to zoom**: Use your mouse wheel to zoom in and out (centered on cursor position)
+- **Drag to pan**: Click and drag to move the view around
+- **Touch support**: Pinch to zoom and drag to pan on touch devices
+- **Control buttons**: Use the buttons in the top-right corner:
+ - `+` / `-` for zoom in/out
+ - Reset button to return to default view
+ - Fit button to fit the entire pipeline in view
+
+To enable pan/zoom functionality, add `BroadwayDashboard.Hooks` to your LiveDashboard configuration:
+
+```elixir
+live_dashboard "/dashboard",
+ additional_pages: [
+ broadway: BroadwayDashboard
+ ],
+ on_mount: [BroadwayDashboard.Hooks]
+```
+
+**Note:** This feature requires `phoenix_live_dashboard` version 0.8.5 or later.
+
## Integration with Phoenix LiveDashboard
You can add this page to your Phoenix LiveDashboard by adding as a page in
diff --git a/dev.exs b/dev.exs
index a17446b..91127a6 100644
--- a/dev.exs
+++ b/dev.exs
@@ -171,7 +171,8 @@ defmodule DemoWeb.Router do
allow_destructive_actions: true,
additional_pages: [
broadway: BroadwayDashboard
- ]
+ ],
+ on_mount: [BroadwayDashboard.Hooks]
)
end
end
diff --git a/lib/broadway_dashboard.ex b/lib/broadway_dashboard.ex
index 5794a5d..33dfcf4 100644
--- a/lib/broadway_dashboard.ex
+++ b/lib/broadway_dashboard.ex
@@ -276,7 +276,17 @@ defmodule BroadwayDashboard do
~H"""
<.row>
<:col>
- <.live_layered_graph layers={@layers} id="pipeline" title="Pipeline" hint={@hint} background={&background/1} format_detail={&format_detail/1} />
+
+
+ +
+ -
+ 100%
+ ↺
+ ☐
+
+ <.live_layered_graph layers={@layers} id="pipeline" title="Pipeline" hint={@hint} background={&background/1} format_detail={&format_detail/1} />
+
Scroll to zoom, drag to pan
+
"""
diff --git a/lib/broadway_dashboard/hooks.ex b/lib/broadway_dashboard/hooks.ex
new file mode 100644
index 0000000..1adf00e
--- /dev/null
+++ b/lib/broadway_dashboard/hooks.ex
@@ -0,0 +1,68 @@
+defmodule BroadwayDashboard.Hooks do
+ @moduledoc """
+ LiveView hooks for Broadway Dashboard pan/zoom functionality.
+
+ This module provides an `on_mount` callback that registers the JavaScript
+ hooks required for pan/zoom functionality on the pipeline graph.
+
+ ## Usage
+
+ Add this module to your LiveDashboard configuration in your router:
+
+ live_dashboard "/dashboard",
+ additional_pages: [
+ broadway: BroadwayDashboard
+ ],
+ on_mount: [BroadwayDashboard.Hooks]
+
+ The hooks will automatically be injected into the page head, enabling
+ pan/zoom functionality on the pipeline visualization.
+
+ ## Features
+
+ - **Mouse wheel zoom**: Scroll to zoom in/out, centered on cursor position
+ - **Drag to pan**: Click and drag to move the view
+ - **Touch support**: Pinch to zoom and drag to pan on touch devices
+ - **Zoom controls**: Buttons for zoom in, zoom out, reset, and fit-to-view
+
+ ## Manual JavaScript Setup (Alternative)
+
+ If you prefer to bundle the JavaScript yourself instead of using `on_mount`:
+
+ 1. Copy `priv/static/js/broadway_dashboard.js` to your assets
+ 2. Import and register the hooks with your LiveSocket:
+
+ ```javascript
+ import BroadwayDashboardHooks from "./broadway_dashboard.js"
+
+ let liveSocket = new LiveSocket("/live", Socket, {
+ hooks: { ...BroadwayDashboardHooks }
+ })
+ ```
+
+ Note: When using the manual setup, you don't need to add this module
+ to the `on_mount` configuration.
+ """
+
+ import Phoenix.Component
+
+ alias Phoenix.LiveDashboard.PageBuilder
+
+ @doc """
+ Callback for `on_mount` that registers the Broadway Dashboard JavaScript hooks.
+ """
+ def on_mount(:default, _params, _session, socket) do
+ {:cont, PageBuilder.register_after_opening_head_tag(socket, &after_opening_head_tag/1)}
+ end
+
+ defp after_opening_head_tag(assigns) do
+ ~H"""
+
+
+ """
+ end
+end
diff --git a/lib/broadway_dashboard/pan_zoom.ex b/lib/broadway_dashboard/pan_zoom.ex
new file mode 100644
index 0000000..469927b
--- /dev/null
+++ b/lib/broadway_dashboard/pan_zoom.ex
@@ -0,0 +1,406 @@
+defmodule BroadwayDashboard.PanZoom do
+ @moduledoc false
+
+ # Internal module that provides the JavaScript and CSS code for pan/zoom functionality.
+
+ @doc """
+ Returns the JavaScript code that implements pan/zoom functionality.
+ This code self-initializes when the DOM is ready.
+ """
+ @spec javascript_code() :: String.t()
+ def javascript_code do
+ ~s"""
+ (function() {
+ "use strict";
+ if (window.__broadwayPanZoomInitialized) return;
+ window.__broadwayPanZoomInitialized = true;
+
+ const DEFAULT_CONFIG = {
+ minScale: 0.1,
+ maxScale: 5,
+ scaleStep: 0.1,
+ wheelZoomSpeed: 0.001
+ };
+
+ class PanZoomController {
+ constructor(container, svg, config) {
+ this.container = container;
+ this.svg = svg;
+ this.config = Object.assign({}, DEFAULT_CONFIG, config || {});
+ this.scale = 1;
+ this.translateX = 0;
+ this.translateY = 0;
+ this.isDragging = false;
+ this.isPinching = false;
+ this.lastMouseX = 0;
+ this.lastMouseY = 0;
+ this.lastPinchDistance = 0;
+ this.lastPinchCenter = null;
+ this.updateTransform();
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ var self = this;
+ this.container.addEventListener('wheel', function(e) { self.handleWheel(e); }, { passive: false });
+ this.container.addEventListener('mousedown', function(e) { self.handleMouseDown(e); });
+ document.addEventListener('mousemove', function(e) { self.handleMouseMove(e); });
+ document.addEventListener('mouseup', function(e) { self.handleMouseUp(e); });
+ this.container.addEventListener('touchstart', function(e) { self.handleTouchStart(e); }, { passive: false });
+ this.container.addEventListener('touchmove', function(e) { self.handleTouchMove(e); }, { passive: false });
+ this.container.addEventListener('touchend', function(e) { self.handleTouchEnd(e); });
+ this.container.addEventListener('contextmenu', function(e) { if (self.isDragging) e.preventDefault(); });
+ }
+
+ handleWheel(e) {
+ e.preventDefault();
+ var rect = this.container.getBoundingClientRect();
+ var mouseX = e.clientX - rect.left;
+ var mouseY = e.clientY - rect.top;
+ var delta = -e.deltaY * this.config.wheelZoomSpeed;
+ var newScale = Math.max(this.config.minScale, Math.min(this.config.maxScale, this.scale * (1 + delta)));
+ if (newScale !== this.scale) {
+ var scaleRatio = newScale / this.scale;
+ this.translateX = mouseX - (mouseX - this.translateX) * scaleRatio;
+ this.translateY = mouseY - (mouseY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+ }
+
+ handleMouseDown(e) {
+ if (e.button === 0) {
+ this.isDragging = true;
+ this.lastMouseX = e.clientX;
+ this.lastMouseY = e.clientY;
+ this.container.style.cursor = 'grabbing';
+ e.preventDefault();
+ }
+ }
+
+ handleMouseMove(e) {
+ if (this.isDragging) {
+ var dx = e.clientX - this.lastMouseX;
+ var dy = e.clientY - this.lastMouseY;
+ this.translateX += dx;
+ this.translateY += dy;
+ this.lastMouseX = e.clientX;
+ this.lastMouseY = e.clientY;
+ this.updateTransform();
+ }
+ }
+
+ handleMouseUp() {
+ if (this.isDragging) {
+ this.isDragging = false;
+ this.container.style.cursor = 'grab';
+ }
+ }
+
+ handleTouchStart(e) {
+ if (e.touches.length === 1) {
+ this.isDragging = true;
+ this.lastMouseX = e.touches[0].clientX;
+ this.lastMouseY = e.touches[0].clientY;
+ e.preventDefault();
+ } else if (e.touches.length === 2) {
+ this.isPinching = true;
+ this.isDragging = false;
+ this.lastPinchDistance = this.getPinchDistance(e.touches);
+ this.lastPinchCenter = this.getPinchCenter(e.touches);
+ e.preventDefault();
+ }
+ }
+
+ handleTouchMove(e) {
+ if (this.isDragging && e.touches.length === 1) {
+ var dx = e.touches[0].clientX - this.lastMouseX;
+ var dy = e.touches[0].clientY - this.lastMouseY;
+ this.translateX += dx;
+ this.translateY += dy;
+ this.lastMouseX = e.touches[0].clientX;
+ this.lastMouseY = e.touches[0].clientY;
+ this.updateTransform();
+ e.preventDefault();
+ } else if (this.isPinching && e.touches.length === 2) {
+ var newDistance = this.getPinchDistance(e.touches);
+ var newCenter = this.getPinchCenter(e.touches);
+ var scaleChange = newDistance / this.lastPinchDistance;
+ var newScale = Math.max(this.config.minScale, Math.min(this.config.maxScale, this.scale * scaleChange));
+ if (newScale !== this.scale) {
+ var rect = this.container.getBoundingClientRect();
+ var centerX = newCenter.x - rect.left;
+ var centerY = newCenter.y - rect.top;
+ var scaleRatio = newScale / this.scale;
+ this.translateX = centerX - (centerX - this.translateX) * scaleRatio;
+ this.translateY = centerY - (centerY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+ this.lastPinchDistance = newDistance;
+ this.lastPinchCenter = newCenter;
+ e.preventDefault();
+ }
+ }
+
+ handleTouchEnd(e) {
+ if (e.touches.length === 0) {
+ this.isDragging = false;
+ this.isPinching = false;
+ } else if (e.touches.length === 1) {
+ this.isPinching = false;
+ this.isDragging = true;
+ this.lastMouseX = e.touches[0].clientX;
+ this.lastMouseY = e.touches[0].clientY;
+ }
+ }
+
+ getPinchDistance(touches) {
+ var dx = touches[0].clientX - touches[1].clientX;
+ var dy = touches[0].clientY - touches[1].clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ getPinchCenter(touches) {
+ return { x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2 };
+ }
+
+ updateTransform() {
+ // Store transform in CSS custom properties on container so new SVGs inherit it
+ this.container.style.setProperty('--pz-tx', this.translateX + 'px');
+ this.container.style.setProperty('--pz-ty', this.translateY + 'px');
+ this.container.style.setProperty('--pz-scale', this.scale);
+ }
+
+ updateZoomIndicator() {
+ var indicator = this.container.querySelector('[data-zoom-level]');
+ if (indicator) { indicator.textContent = Math.round(this.scale * 100) + '%'; }
+ }
+
+ zoomIn() {
+ var rect = this.container.getBoundingClientRect();
+ var centerX = rect.width / 2;
+ var centerY = rect.height / 2;
+ var newScale = Math.min(this.config.maxScale, this.scale * (1 + this.config.scaleStep));
+ var scaleRatio = newScale / this.scale;
+ this.translateX = centerX - (centerX - this.translateX) * scaleRatio;
+ this.translateY = centerY - (centerY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ zoomOut() {
+ var rect = this.container.getBoundingClientRect();
+ var centerX = rect.width / 2;
+ var centerY = rect.height / 2;
+ var newScale = Math.max(this.config.minScale, this.scale * (1 - this.config.scaleStep));
+ var scaleRatio = newScale / this.scale;
+ this.translateX = centerX - (centerX - this.translateX) * scaleRatio;
+ this.translateY = centerY - (centerY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ reset() {
+ this.scale = 1;
+ this.translateX = 0;
+ this.translateY = 0;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ fitToView() {
+ var containerRect = this.container.getBoundingClientRect();
+ this.scale = 1;
+ this.translateX = 0;
+ this.translateY = 0;
+ this.updateTransform();
+ var svgBBox;
+ if (this.svg && this.svg.getBBox) {
+ try { svgBBox = this.svg.getBBox(); } catch(e) { svgBBox = null; }
+ }
+ if (!svgBBox || svgBBox.width === 0 || svgBBox.height === 0) {
+ svgBBox = { width: containerRect.width, height: containerRect.height };
+ }
+ var scaleX = (containerRect.width - 40) / svgBBox.width;
+ var scaleY = (containerRect.height - 40) / svgBBox.height;
+ this.scale = Math.min(scaleX, scaleY, 1);
+ var scaledWidth = svgBBox.width * this.scale;
+ var scaledHeight = svgBBox.height * this.scale;
+ this.translateX = (containerRect.width - scaledWidth) / 2;
+ this.translateY = (containerRect.height - scaledHeight) / 2;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+ }
+
+ function initPanZoom(container) {
+ // Find the card-body that contains the SVG
+ var cardBody = container.querySelector('.card-body');
+ if (!cardBody) return;
+
+ var svg = cardBody.querySelector('svg');
+ if (!svg) return;
+
+ // If controller already exists, just update the SVG reference and reapply transform
+ if (container._panZoomController) {
+ container._panZoomController.svg = svg;
+ container._panZoomController.updateTransform();
+ container._panZoomController.updateZoomIndicator();
+ return;
+ }
+
+ container.style.overflow = 'hidden';
+ container.style.cursor = 'grab';
+ container.style.position = 'relative';
+
+ // Apply transform directly to the SVG
+ var controller = new PanZoomController(container, svg);
+ container._panZoomController = controller;
+
+ var controls = container.querySelector('[data-panzoom-controls]');
+ if (controls && !controls._bindingsAttached) {
+ controls._bindingsAttached = true;
+ var zoomIn = controls.querySelector('[data-zoom-in]');
+ var zoomOut = controls.querySelector('[data-zoom-out]');
+ var zoomReset = controls.querySelector('[data-zoom-reset]');
+ var zoomFit = controls.querySelector('[data-zoom-fit]');
+ if (zoomIn) zoomIn.addEventListener('click', function() { container._panZoomController.zoomIn(); });
+ if (zoomOut) zoomOut.addEventListener('click', function() { container._panZoomController.zoomOut(); });
+ if (zoomReset) zoomReset.addEventListener('click', function() { container._panZoomController.reset(); });
+ if (zoomFit) zoomFit.addEventListener('click', function() { container._panZoomController.fitToView(); });
+ }
+ }
+
+ function initAll() {
+ var containers = document.querySelectorAll('.broadway-pipeline-zoom-container');
+ containers.forEach(function(container) { initPanZoom(container); });
+ }
+
+ // Set up MutationObserver when body is available
+ function setupObserver() {
+ if (!document.body) {
+ // Body not ready yet, wait for DOMContentLoaded
+ document.addEventListener('DOMContentLoaded', setupObserver);
+ return;
+ }
+
+ // Re-initialize on LiveView page updates using MutationObserver
+ var pendingUpdate = false;
+ var observer = new MutationObserver(function(mutations) {
+ var dominated = mutations.some(function(mutation) {
+ return mutation.type === 'childList' && mutation.addedNodes.length > 0;
+ });
+ if (dominated && !pendingUpdate) {
+ pendingUpdate = true;
+ requestAnimationFrame(function() {
+ initAll();
+ pendingUpdate = false;
+ });
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ // Also initialize immediately if containers already exist
+ initAll();
+ }
+
+ // Initialize when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', setupObserver);
+ } else {
+ setupObserver();
+ }
+
+ // Export for hook-based usage
+ window.BroadwayDashboardHooks = {
+ BroadwayPipelineZoom: {
+ mounted: function() { initPanZoom(this.el); },
+ updated: function() {
+ if (!this.el._panZoomController) { initPanZoom(this.el); }
+ }
+ }
+ };
+ })();
+ """
+ end
+
+ @doc """
+ Returns the CSS code for pan/zoom styling.
+ """
+ @spec css_code() :: String.t()
+ def css_code do
+ ~s"""
+ .broadway-pipeline-zoom-container {
+ position: relative;
+ overflow: hidden;
+ cursor: grab;
+ min-height: 200px;
+ }
+ .broadway-pipeline-zoom-container .card-body svg {
+ will-change: transform;
+ transform: translate(var(--pz-tx, 0px), var(--pz-ty, 0px)) scale(var(--pz-scale, 1));
+ transform-origin: 0 0;
+ }
+ .broadway-pipeline-zoom-container:active {
+ cursor: grabbing;
+ }
+ .broadway-pipeline-zoom-controls {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: flex;
+ gap: 4px;
+ z-index: 10;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 4px;
+ padding: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
+ }
+ .broadway-pipeline-zoom-controls button {
+ width: 28px;
+ height: 28px;
+ border: 1px solid #ddd;
+ background: white;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ color: #333;
+ transition: background-color 0.15s, border-color 0.15s;
+ }
+ .broadway-pipeline-zoom-controls button:hover {
+ background: #f5f5f5;
+ border-color: #ccc;
+ }
+ .broadway-pipeline-zoom-controls button:active {
+ background: #e5e5e5;
+ }
+ .broadway-pipeline-zoom-level {
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ font-size: 12px;
+ color: #666;
+ min-width: 45px;
+ justify-content: center;
+ }
+ .broadway-pipeline-zoom-hint {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ font-size: 11px;
+ color: #999;
+ background: rgba(255, 255, 255, 0.8);
+ padding: 2px 6px;
+ border-radius: 3px;
+ }
+ """
+ end
+end
diff --git a/mix.exs b/mix.exs
index 5e40cec..5a3ff67 100644
--- a/mix.exs
+++ b/mix.exs
@@ -30,14 +30,15 @@ defmodule BroadwayDashboard.MixProject do
defp deps do
[
{:broadway, "~> 1.0"},
- {:phoenix_live_dashboard, "~> 0.8.0"},
+ {:phoenix_live_dashboard, "~> 0.8.5"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_view, "~> 2.0 or ~> 1.0", only: [:test]},
{:plug_cowboy, "~> 2.0", only: :dev},
{:jason, "~> 1.0", only: [:dev, :test, :docs]},
{:ex_doc, "~> 0.24", only: [:docs], runtime: false},
{:stream_data, "~> 0.5", only: [:dev, :test]},
- {:floki, "~> 0.34", only: :test}
+ {:floki, "~> 0.34", only: :test},
+ {:lazy_html, ">= 0.1.0", only: :test}
]
end
@@ -58,7 +59,7 @@ defmodule BroadwayDashboard.MixProject do
"GitHub" => "https://github.com/dashbitco/broadway_dashboard",
"Broadway website" => "https://elixir-broadway.org"
},
- files: ~w(lib CHANGELOG.md LICENSE mix.exs README.md)
+ files: ~w(lib priv CHANGELOG.md LICENSE mix.exs README.md)
}
end
diff --git a/mix.lock b/mix.lock
index c5ccae9..f684eae 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,36 +1,40 @@
%{
"broadway": {:hex, :broadway, "1.1.0", "8ed3aea01fd6f5640b3e1515b90eca51c4fc1fac15fb954cdcf75dc054ae719c", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25e315ef1afe823129485d981dcc6d9b221cea30e625fd5439e9b05f44fb60e4"},
"castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
- "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
+ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
+ "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
- "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
+ "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"},
+ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
- "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"},
+ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
+ "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
"gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"},
- "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"},
+ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+ "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
- "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
- "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},
- "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
- "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"},
+ "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"},
- "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
- "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
- "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"},
- "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
- "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
+ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
+ "plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"},
+ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+ "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"},
- "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
- "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
+ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
- "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"},
+ "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}
diff --git a/priv/static/js/broadway_dashboard.js b/priv/static/js/broadway_dashboard.js
new file mode 100644
index 0000000..033cded
--- /dev/null
+++ b/priv/static/js/broadway_dashboard.js
@@ -0,0 +1,426 @@
+/**
+ * BroadwayDashboard - Pan and Zoom functionality for pipeline graphs
+ *
+ * This module provides LiveView hooks for adding pan/zoom capabilities
+ * to the Broadway pipeline visualization.
+ *
+ * Usage:
+ * 1. Include this script in your application
+ * 2. Register the hooks with your LiveSocket
+ * 3. Or use the BroadwayDashboard.Hooks module with on_mount
+ */
+
+(function() {
+ "use strict";
+
+ // Default configuration
+ const DEFAULT_CONFIG = {
+ minScale: 0.1,
+ maxScale: 5,
+ scaleStep: 0.1,
+ wheelZoomSpeed: 0.001
+ };
+
+ /**
+ * Creates a pan/zoom controller for an SVG element
+ */
+ class PanZoomController {
+ constructor(container, svg, config = {}) {
+ this.container = container;
+ this.svg = svg;
+ this.config = { ...DEFAULT_CONFIG, ...config };
+
+ // Transform state
+ this.scale = 1;
+ this.translateX = 0;
+ this.translateY = 0;
+
+ // Drag state
+ this.isDragging = false;
+ this.lastMouseX = 0;
+ this.lastMouseY = 0;
+
+ // Store original viewBox for reset
+ this.originalViewBox = svg.getAttribute('viewBox');
+ this.originalStyle = svg.getAttribute('style') || '';
+
+ // Create transform group wrapper
+ this.setupTransformGroup();
+
+ // Bind event handlers
+ this.bindEvents();
+ }
+
+ setupTransformGroup() {
+ // Check if we already have a transform group
+ let transformGroup = this.svg.querySelector('[data-panzoom-group]');
+
+ if (!transformGroup) {
+ // Create a new group to wrap all SVG content
+ transformGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ transformGroup.setAttribute('data-panzoom-group', 'true');
+
+ // Move all children into the group
+ while (this.svg.firstChild) {
+ transformGroup.appendChild(this.svg.firstChild);
+ }
+
+ this.svg.appendChild(transformGroup);
+ }
+
+ this.transformGroup = transformGroup;
+ this.updateTransform();
+ }
+
+ bindEvents() {
+ // Mouse wheel for zoom
+ this.container.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
+
+ // Mouse drag for pan
+ this.container.addEventListener('mousedown', this.handleMouseDown.bind(this));
+ document.addEventListener('mousemove', this.handleMouseMove.bind(this));
+ document.addEventListener('mouseup', this.handleMouseUp.bind(this));
+
+ // Touch events for mobile
+ this.container.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
+ this.container.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
+ this.container.addEventListener('touchend', this.handleTouchEnd.bind(this));
+
+ // Prevent context menu on right-click drag
+ this.container.addEventListener('contextmenu', (e) => {
+ if (this.isDragging) e.preventDefault();
+ });
+ }
+
+ handleWheel(e) {
+ e.preventDefault();
+
+ const rect = this.container.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ // Calculate zoom
+ const delta = -e.deltaY * this.config.wheelZoomSpeed;
+ const newScale = Math.max(
+ this.config.minScale,
+ Math.min(this.config.maxScale, this.scale * (1 + delta))
+ );
+
+ if (newScale !== this.scale) {
+ // Zoom towards mouse position
+ const scaleRatio = newScale / this.scale;
+ this.translateX = mouseX - (mouseX - this.translateX) * scaleRatio;
+ this.translateY = mouseY - (mouseY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+ }
+
+ handleMouseDown(e) {
+ if (e.button === 0) { // Left mouse button
+ this.isDragging = true;
+ this.lastMouseX = e.clientX;
+ this.lastMouseY = e.clientY;
+ this.container.style.cursor = 'grabbing';
+ e.preventDefault();
+ }
+ }
+
+ handleMouseMove(e) {
+ if (this.isDragging) {
+ const dx = e.clientX - this.lastMouseX;
+ const dy = e.clientY - this.lastMouseY;
+
+ this.translateX += dx;
+ this.translateY += dy;
+
+ this.lastMouseX = e.clientX;
+ this.lastMouseY = e.clientY;
+
+ this.updateTransform();
+ }
+ }
+
+ handleMouseUp() {
+ if (this.isDragging) {
+ this.isDragging = false;
+ this.container.style.cursor = 'grab';
+ }
+ }
+
+ // Touch event handling
+ handleTouchStart(e) {
+ if (e.touches.length === 1) {
+ this.isDragging = true;
+ this.lastMouseX = e.touches[0].clientX;
+ this.lastMouseY = e.touches[0].clientY;
+ e.preventDefault();
+ } else if (e.touches.length === 2) {
+ this.isPinching = true;
+ this.lastPinchDistance = this.getPinchDistance(e.touches);
+ this.lastPinchCenter = this.getPinchCenter(e.touches);
+ e.preventDefault();
+ }
+ }
+
+ handleTouchMove(e) {
+ if (this.isDragging && e.touches.length === 1) {
+ const dx = e.touches[0].clientX - this.lastMouseX;
+ const dy = e.touches[0].clientY - this.lastMouseY;
+
+ this.translateX += dx;
+ this.translateY += dy;
+
+ this.lastMouseX = e.touches[0].clientX;
+ this.lastMouseY = e.touches[0].clientY;
+
+ this.updateTransform();
+ e.preventDefault();
+ } else if (this.isPinching && e.touches.length === 2) {
+ const newDistance = this.getPinchDistance(e.touches);
+ const newCenter = this.getPinchCenter(e.touches);
+
+ const scaleChange = newDistance / this.lastPinchDistance;
+ const newScale = Math.max(
+ this.config.minScale,
+ Math.min(this.config.maxScale, this.scale * scaleChange)
+ );
+
+ if (newScale !== this.scale) {
+ const rect = this.container.getBoundingClientRect();
+ const centerX = newCenter.x - rect.left;
+ const centerY = newCenter.y - rect.top;
+
+ const scaleRatio = newScale / this.scale;
+ this.translateX = centerX - (centerX - this.translateX) * scaleRatio;
+ this.translateY = centerY - (centerY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ this.lastPinchDistance = newDistance;
+ this.lastPinchCenter = newCenter;
+ e.preventDefault();
+ }
+ }
+
+ handleTouchEnd(e) {
+ if (e.touches.length === 0) {
+ this.isDragging = false;
+ this.isPinching = false;
+ } else if (e.touches.length === 1) {
+ this.isPinching = false;
+ this.isDragging = true;
+ this.lastMouseX = e.touches[0].clientX;
+ this.lastMouseY = e.touches[0].clientY;
+ }
+ }
+
+ getPinchDistance(touches) {
+ const dx = touches[0].clientX - touches[1].clientX;
+ const dy = touches[0].clientY - touches[1].clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ getPinchCenter(touches) {
+ return {
+ x: (touches[0].clientX + touches[1].clientX) / 2,
+ y: (touches[0].clientY + touches[1].clientY) / 2
+ };
+ }
+
+ updateTransform() {
+ const transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
+ this.svg.style.transform = transform;
+ this.svg.style.transformOrigin = '0 0';
+ }
+
+ updateZoomIndicator() {
+ const indicator = this.container.querySelector('[data-zoom-level]');
+ if (indicator) {
+ indicator.textContent = `${Math.round(this.scale * 100)}%`;
+ }
+ }
+
+ // Public methods for control buttons
+ zoomIn() {
+ const rect = this.container.getBoundingClientRect();
+ const centerX = rect.width / 2;
+ const centerY = rect.height / 2;
+
+ const newScale = Math.min(this.config.maxScale, this.scale * (1 + this.config.scaleStep));
+ const scaleRatio = newScale / this.scale;
+
+ this.translateX = centerX - (centerX - this.translateX) * scaleRatio;
+ this.translateY = centerY - (centerY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ zoomOut() {
+ const rect = this.container.getBoundingClientRect();
+ const centerX = rect.width / 2;
+ const centerY = rect.height / 2;
+
+ const newScale = Math.max(this.config.minScale, this.scale * (1 - this.config.scaleStep));
+ const scaleRatio = newScale / this.scale;
+
+ this.translateX = centerX - (centerX - this.translateX) * scaleRatio;
+ this.translateY = centerY - (centerY - this.translateY) * scaleRatio;
+ this.scale = newScale;
+
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ reset() {
+ this.scale = 1;
+ this.translateX = 0;
+ this.translateY = 0;
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ fitToView() {
+ const containerRect = this.container.getBoundingClientRect();
+ const svgRect = this.svg.getBoundingClientRect();
+
+ // Reset first to get accurate SVG dimensions
+ this.scale = 1;
+ this.translateX = 0;
+ this.translateY = 0;
+ this.updateTransform();
+
+ // Recalculate after reset
+ const svgBBox = this.svg.getBBox ? this.svg.getBBox() : { width: svgRect.width, height: svgRect.height };
+
+ // Calculate scale to fit
+ const scaleX = (containerRect.width - 40) / svgBBox.width;
+ const scaleY = (containerRect.height - 40) / svgBBox.height;
+ this.scale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down
+
+ // Center the content
+ const scaledWidth = svgBBox.width * this.scale;
+ const scaledHeight = svgBBox.height * this.scale;
+ this.translateX = (containerRect.width - scaledWidth) / 2;
+ this.translateY = (containerRect.height - scaledHeight) / 2;
+
+ this.updateTransform();
+ this.updateZoomIndicator();
+ }
+
+ destroy() {
+ // Remove event listeners would go here if needed
+ // For LiveView, the container is removed automatically
+ }
+ }
+
+ /**
+ * LiveView Hook for Broadway Pipeline Zoom
+ */
+ const BroadwayPipelineZoom = {
+ mounted() {
+ this.initPanZoom();
+ },
+
+ updated() {
+ // Preserve zoom state when SVG content changes
+ if (this.controller) {
+ const svg = this.el.querySelector('svg');
+ if (svg && svg !== this.controller.svg) {
+ // Save current zoom state
+ const savedScale = this.controller.scale;
+ const savedTranslateX = this.controller.translateX;
+ const savedTranslateY = this.controller.translateY;
+
+ // Reinitialize with new SVG
+ this.initPanZoom();
+
+ // Restore zoom state
+ if (this.controller) {
+ this.controller.scale = savedScale;
+ this.controller.translateX = savedTranslateX;
+ this.controller.translateY = savedTranslateY;
+ this.controller.updateTransform();
+ this.controller.updateZoomIndicator();
+ }
+ }
+ }
+ },
+
+ destroyed() {
+ if (this.controller) {
+ this.controller.destroy();
+ }
+ },
+
+ initPanZoom() {
+ const svg = this.el.querySelector('svg');
+ if (!svg) {
+ console.warn('BroadwayPipelineZoom: No SVG element found');
+ return;
+ }
+
+ // Set up container styles
+ this.el.style.overflow = 'hidden';
+ this.el.style.cursor = 'grab';
+ this.el.style.position = 'relative';
+
+ // Create controller
+ this.controller = new PanZoomController(this.el, svg);
+
+ // Set up control button event handlers
+ this.setupControls();
+ },
+
+ setupControls() {
+ const controls = this.el.querySelector('[data-panzoom-controls]');
+ if (!controls) return;
+
+ controls.querySelector('[data-zoom-in]')?.addEventListener('click', () => {
+ this.controller.zoomIn();
+ });
+
+ controls.querySelector('[data-zoom-out]')?.addEventListener('click', () => {
+ this.controller.zoomOut();
+ });
+
+ controls.querySelector('[data-zoom-reset]')?.addEventListener('click', () => {
+ this.controller.reset();
+ });
+
+ controls.querySelector('[data-zoom-fit]')?.addEventListener('click', () => {
+ this.controller.fitToView();
+ });
+ }
+ };
+
+ // Export for different module systems
+ const BroadwayDashboardHooks = {
+ BroadwayPipelineZoom
+ };
+
+ // AMD
+ if (typeof define === 'function' && define.amd) {
+ define(function() { return BroadwayDashboardHooks; });
+ }
+ // CommonJS
+ else if (typeof module !== 'undefined' && module.exports) {
+ module.exports = BroadwayDashboardHooks;
+ }
+ // Browser global
+ else {
+ window.BroadwayDashboardHooks = BroadwayDashboardHooks;
+
+ // Also register with LiveDashboard if available
+ if (window.LiveDashboard && typeof window.LiveDashboard.registerCustomHooks === 'function') {
+ window.LiveDashboard.registerCustomHooks(BroadwayDashboardHooks);
+ }
+ }
+})();