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. ![Broadway Dashboard](https://raw.githubusercontent.com/dashbitco/broadway_dashboard/4da2a5f388a7579d41b63803652796c106b74785/priv/static/broadway-dashboard-01.gif) +## 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); + } + } +})();