diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/__init__.py b/src/python/impactx/dashboard/Input/visualization/lattice/__init__.py
index 4e3e27e36..bff3c7b57 100644
--- a/src/python/impactx/dashboard/Input/visualization/lattice/__init__.py
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/__init__.py
@@ -1,7 +1,9 @@
+from .dialogs import LatticeVisualizerDialogs as Dialogs
from .statistics import LatticeVisualizerStatisticComponents as StatComponents
from .statistics import LatticeVisualizerStatisticUtils as StatUtils
__all__ = [
+ "Dialogs",
"StatComponents",
"StatUtils",
]
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/dialogs.py b/src/python/impactx/dashboard/Input/visualization/lattice/dialogs.py
new file mode 100644
index 000000000..813ce708a
--- /dev/null
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/dialogs.py
@@ -0,0 +1,50 @@
+"""
+This file is part of ImpactX
+
+Copyright 2025 ImpactX contributors
+Authors: Parthib Roy
+License: BSD-3-Clause-LBNL
+"""
+
+from .... import vuetify
+from ....Input.components.card import CardComponents
+
+
+class LatticeVisualizerDialogs:
+ @staticmethod
+ def settings():
+ """
+ A button to open the settings dialog for the lattice visualizer.
+ """
+ CardComponents.card_button(
+ "mdi-cog",
+ color="white",
+ click="lattice_visualizer_dialog_settings = true",
+ description="Settings",
+ )
+
+ @staticmethod
+ def element_colors_tab():
+ """
+ A tab inside of the settings dialog to manage element colors.
+ """
+
+ with vuetify.VCardText():
+ with vuetify.VRow():
+ with vuetify.VCol(cols=12):
+ vuetify.VCardSubtitle(
+ "Element Color Mapping",
+ )
+
+ @staticmethod
+ def general_settings_tab():
+ """
+ A tab inside of the settings dialog for general settings.
+ """
+
+ with vuetify.VCardText():
+ with vuetify.VRow():
+ with vuetify.VCol(cols=12):
+ vuetify.VCardSubtitle(
+ "Settings",
+ )
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/statistics.py b/src/python/impactx/dashboard/Input/visualization/lattice/statistics.py
index 7735b3566..4319bece7 100644
--- a/src/python/impactx/dashboard/Input/visualization/lattice/statistics.py
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/statistics.py
@@ -26,6 +26,7 @@ def _extract_parameter_values(parameter_name: str, value_type=float):
"""
Helper function to extract parameter values from the lattice list.
+
:param parameter_name: Name of the parameter to extract (case-insensitive)
:param value_type: Type to convert values to (float, int, etc.)
:return: List of extracted values
@@ -42,6 +43,8 @@ def _extract_parameter_values(parameter_name: str, value_type=float):
return values
+ return values
+
@staticmethod
def update_length_statistics() -> None:
"""
@@ -67,11 +70,11 @@ def update_length_statistics() -> None:
state.length_stats_content = []
@staticmethod
- def update_element_counts() -> dict[str, int]:
+ def update_element_counts() -> list[tuple[str, int]]:
"""
Computes the element counts in the lattice list.
- :return: Dictionary of element names and their counts, sorted by count descending.
+ :return: List of (element name, count) tuples, sorted by count descending.
"""
counts = {}
for element in state.selected_lattice_list:
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/ui.py b/src/python/impactx/dashboard/Input/visualization/lattice/ui.py
index 6340530e2..452079e55 100644
--- a/src/python/impactx/dashboard/Input/visualization/lattice/ui.py
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/ui.py
@@ -6,9 +6,13 @@
License: BSD-3-Clause-LBNL
"""
-from .... import html, state, vuetify
-from ....Input.components import CardBase
-from . import StatComponents, StatUtils
+from trame.widgets import plotly
+
+from .... import ctrl, html, state, vuetify
+from ....Input.components.card import CardBase
+from ....Input.components.navigation import NavigationComponents
+from . import Dialogs, StatComponents, StatUtils
+from .visualization.plot import lattice_visualizer
def _update_statistics() -> None:
@@ -21,9 +25,17 @@ def _update_statistics() -> None:
StatUtils.update_length_statistics()
+def _update_lattice_visualization() -> None:
+ """
+ Updates the plotly figure with an updated lattice visualization.
+ """
+ ctrl.lattice_figure_update(lattice_visualizer())
+
+
@state.change("selected_lattice_list")
def on_lattice_list_change(**kwargs):
_update_statistics()
+ _update_lattice_visualization()
class LatticeVisualizer(CardBase):
@@ -38,6 +50,10 @@ def card_content(self):
"""
The content of the lattice visualizer.
"""
+ with vuetify.VDialog(
+ v_model=("lattice_visualizer_dialog_settings", False), max_width="33.33vw"
+ ):
+ self.dialog_settings()
with vuetify.VCard():
with vuetify.VCard(
@@ -50,4 +66,24 @@ def card_content(self):
with vuetify.VCardTitle(classes="d-flex align-center"):
html.Div("Lattice Statistics")
vuetify.VSpacer()
+ Dialogs.settings()
StatComponents.statistics()
+
+ with vuetify.VCard(color="#002949"):
+ with vuetify.VCardText():
+ ctrl.lattice_figure_update = plotly.Figure(
+ display_mode_bar="true", style="width: 100%; height: 50vh"
+ ).update
+
+ @staticmethod
+ def dialog_settings():
+ dialog_name = "lattice_visualizer_dialog_tab_settings"
+
+ with NavigationComponents.create_dialog_tabs(
+ dialog_name, 2, ["Element Colors", "General Settings"]
+ ):
+ with vuetify.VTabsWindow(v_model=(dialog_name, 0)):
+ with vuetify.VTabsWindowItem():
+ Dialogs.element_colors_tab()
+ with vuetify.VTabsWindowItem():
+ Dialogs.general_settings_tab()
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/visualization/__init__.py b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/__init__.py
new file mode 100644
index 000000000..b883a324e
--- /dev/null
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/__init__.py
@@ -0,0 +1,7 @@
+from .elements.elements import LatticeVisualizerElements
+from .utils import LatticeVisualizerUtils
+
+__all__ = [
+ "LatticeVisualizerElements",
+ "LatticeVisualizerUtils",
+]
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/__init__.py b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/calculations.py b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/calculations.py
new file mode 100644
index 000000000..b7900ea15
--- /dev/null
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/calculations.py
@@ -0,0 +1,58 @@
+"""
+This file is part of ImpactX
+
+Copyright 2025 ImpactX contributors
+Authors: Parthib Roy
+License: BSD-3-Clause-LBNL
+
+Mathematical calculations for lattice element positioning and transformations.
+"""
+
+import numpy as np
+
+
+def transform(x, y, rotation_deg, dx):
+ """
+ Transform coordinates based on angle and displacement.
+ """
+ rotation_rad = np.radians(rotation_deg)
+ x_new = x + dx * np.cos(rotation_rad)
+ y_new = y + dx * np.sin(rotation_rad)
+ return x_new, y_new
+
+
+def rotate_corners(
+ x: float, y: float, rotation_deg: float, ds: float = 1.0, width: float = 0.1
+) -> np.ndarray:
+ """
+ Generates rectangle's corners after applying rotation matrix.
+ This is utilized to properly visualize a rotated lattice element in Plotly.
+
+ :param x: starting x-coordinate before the rotation
+ :param y: starting y-coordinate before the rotation
+ :param rotation_deg: Rotation angle in degrees, counterclockwise.
+ :param ds: Length of the rectangle along the local X-axis (default is 1.0).
+ :param width: Half of the rectangle's height (default is 0.1).
+ :return: A NumPy array of shape (5, 2) with rotated (x, y) corner coordinates, closed for polygon plotting.
+ """
+ rotation_rad = np.radians(rotation_deg)
+
+ corners = np.array(
+ [
+ [0, -width],
+ [ds, -width],
+ [ds, width],
+ [0, width],
+ [0, -width], # close polygon
+ ]
+ )
+
+ R = np.array(
+ [
+ [np.cos(rotation_rad), -np.sin(rotation_rad)],
+ [np.sin(rotation_rad), np.cos(rotation_rad)],
+ ]
+ )
+
+ rotated = corners @ R.T + [x, y]
+ return rotated
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/elements.py b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/elements.py
new file mode 100644
index 000000000..c2a0502a2
--- /dev/null
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/elements/elements.py
@@ -0,0 +1,357 @@
+"""
+This file is part of ImpactX
+
+Copyright 2025 ImpactX contributors
+Authors: Parthib Roy
+License: BSD-3-Clause-LBNL
+"""
+
+import numpy as np
+import plotly.graph_objects as go
+
+from .calculations import rotate_corners, transform
+
+# Global color dictionary for quadrupole visual properties
+QUAD_COLORS = {
+ "focusing_quadrupole": {
+ "legend_name": "Focusing Quadrupole",
+ "line_color": "darkblue",
+ "fill_color": "lightblue",
+ "line_width": 2,
+ },
+ "defocusing_quadrupole": {
+ "legend_name": "Defocusing Quadrupole",
+ "line_color": "darkred",
+ "fill_color": "lightcoral",
+ "line_width": 2,
+ },
+ "quadrupole": {
+ "legend_name": "Quadrupole",
+ "line_color": "darkgreen",
+ "fill_color": "lightgreen",
+ "line_width": 2,
+ },
+}
+
+
+class LatticeVisualizerElements:
+ def __init__(self):
+ self.seen_elements = set()
+ self.show_labels = True # Track whether to show labels
+
+ def reset_legend(self):
+ self.seen_elements.clear()
+
+ def set_show_labels(self, show_labels):
+ """Set whether to show element labels on the plot."""
+ self.show_labels = show_labels
+
+ def _add_to_legend(self, element_type):
+ """
+ Adds an element type to the legend if it hasn't been added already.
+ Returns True if this is the first time seeing this element type.
+ """
+
+ if element_type not in self.seen_elements:
+ self.seen_elements.add(element_type)
+ return True
+ return False
+
+ def _add_annotation(self, fig, x, y, label, **kwargs):
+ """
+ This is the part that adds the label to the plotly figure.
+ """
+ kwargs.setdefault("text", label)
+ kwargs.setdefault("showarrow", False)
+ fig.add_annotation(x=x, y=y, **kwargs)
+
+ def _add_trace(
+ self,
+ fig,
+ show_legend=False,
+ element_name=None,
+ legend_name=None,
+ legend_group=None,
+ **kwargs,
+ ):
+ """
+ This is the function that actually draws on the plotly figure.
+ """
+ kwargs.setdefault("showlegend", show_legend)
+ kwargs.setdefault("hoverinfo", "text")
+
+ # Use element_name as default for legend_name and legend_group if not provided
+ if element_name:
+ legend_name = legend_name or element_name
+ legend_group = legend_group or element_name
+
+ if show_legend and legend_name:
+ kwargs.setdefault("name", legend_name)
+ kwargs.setdefault("legendgroup", legend_group or legend_name)
+ elif legend_group:
+ kwargs.setdefault("legendgroup", legend_group)
+ kwargs.setdefault("showlegend", False)
+
+ # Properly handle hover information
+ if "text" in kwargs:
+ kwargs["hovertemplate"] = kwargs["text"] + ""
+ else:
+ kwargs.setdefault("hoverinfo", "skip")
+
+ trace = go.Scatter(**kwargs)
+ fig.add_trace(trace)
+
+ def _generate_hover_text(
+ self, index, label, ds, dx, dy, rotation, **special_params
+ ):
+ """
+ Generate standardized hover text for lattice elements.
+
+ :param index: Element index in the lattice sequence
+ :param label: Element label/name
+ :param ds: Element length
+ :param dx: X displacement
+ :param dy: Y displacement
+ :param rotation: Rotation angle
+ :param special_params: Additional parameters specific to element type (k, rc, phi, etc.)
+ :return: Formatted hover text string
+ """
+ text = f"Element #{index}
{label}
ds: {ds} m
"
+
+ # Add special parameters if provided
+ for param_name, param_value in special_params.items():
+ if param_name == "k":
+ text += f"k: {param_value} m⁻²
"
+ elif param_name == "rc":
+ text += f"rc: {param_value} m
"
+ elif param_name == "phi":
+ text += f"phi: {param_value}°
"
+
+ text += f"dx: {dx} m
dy: {dy} m
rotation: {rotation}°"
+
+ return text
+
+ def drift(self, fig, x, y, ds, dx, dy, rotation, label, index, element_name):
+ show_legend = self._add_to_legend(element_name)
+ rotation_rad = np.radians(rotation)
+ thickness = 0.05 # line thickness (half-height for visual padding)
+ x += dx
+ y += dy
+
+ rotated_corners = rotate_corners(x, y, rotation, ds, thickness)
+ xs, ys = rotated_corners[:, 0], rotated_corners[:, 1]
+
+ self._add_trace(
+ fig,
+ x=xs,
+ y=ys,
+ mode="lines",
+ fill="toself",
+ line=dict(color="gray", width=1),
+ fillcolor="lightgray",
+ text=self._generate_hover_text(index, label, ds, dx, dy, rotation),
+ show_legend=show_legend,
+ element_name=element_name,
+ )
+
+ if self.show_labels:
+ self._add_annotation(
+ fig,
+ x=np.mean(xs),
+ y=np.mean(ys) + 0.3,
+ label=label,
+ font=dict(size=10),
+ )
+
+ x1 = x + ds * np.cos(rotation_rad)
+ y1 = y + ds * np.sin(rotation_rad)
+ return x1, y1, rotation
+
+ def quad(self, fig, x, y, k, ds, dx, dy, rotation, label, index, element_name):
+ x1, y1 = transform(x, y, rotation, ds)
+ x += dx
+ y += dy
+
+ # Determine quad type and get visual properties based on k value
+ if k > 0:
+ quad_type = "focusing_quadrupole"
+ elif k < 0:
+ quad_type = "defocusing_quadrupole"
+ else:
+ quad_type = "quadrupole"
+
+ quad_colors = QUAD_COLORS[quad_type]
+ quad_legend_name = quad_colors["legend_name"]
+
+ show_legend = self._add_to_legend(quad_legend_name)
+ rotated_corners = rotate_corners(x, y, rotation, ds, 0.2)
+ xs, ys = rotated_corners[:, 0], rotated_corners[:, 1]
+
+ self._add_trace(
+ fig,
+ x=xs,
+ y=ys,
+ mode="lines",
+ fill="toself",
+ line=dict(color=quad_colors["line_color"], width=quad_colors["line_width"]),
+ fillcolor=quad_colors["fill_color"],
+ text=self._generate_hover_text(index, label, ds, dx, dy, rotation, k=k),
+ show_legend=show_legend,
+ legend_name=quad_legend_name,
+ legend_group=quad_legend_name,
+ )
+
+ if self.show_labels:
+ self._add_annotation(fig, x=(x + x1) / 2, y=y + 0.4, label=label)
+ return x1, y1, rotation
+
+ def sBend(self, fig, x, y, ds, dx, dy, rotation, rc, label, index, element_name):
+ show_legend = self._add_to_legend(element_name)
+ """
+ Draw a sector‐bend (SBEND) of length ds that has radius rc.
+ - rc is the radius of curvature (in meters).
+ - ds is the arc length (in meters).
+ - rotation is the incoming reference angle (in degrees).
+ - label is the magnet's name for hover/annotation.
+ """
+
+ # Apply any lateral offsets first:
+ x += dx
+ y += dy
+
+ phi_rad = ds / rc # Bend angle in radians
+ rotation_rad = np.radians(rotation)
+
+ # The circular‐arc center (in lab coords) is found by:
+ # cx = x - ρ sin(incoming_angle)
+ # cy = y + ρ cos(incoming_angle)
+ # because the bend is in the local x–z plane.
+ r = rc
+ cx = x - r * np.sin(rotation_rad)
+ cy = y + r * np.cos(rotation_rad)
+
+ n_points = 50
+ arc_thetas = np.linspace(0, phi_rad, n_points)
+ arc_x = cx + r * np.sin(rotation_rad + arc_thetas)
+ arc_y = cy - r * np.cos(rotation_rad + arc_thetas)
+
+ self._add_trace(
+ fig,
+ x=arc_x,
+ y=arc_y,
+ mode="lines",
+ line=dict(color="blue", width=3),
+ text=self._generate_hover_text(
+ index, label, ds, dx, dy, rotation, rc=rc, phi=np.degrees(phi_rad)
+ ),
+ show_legend=show_legend,
+ element_name=element_name,
+ )
+
+ if self.show_labels:
+ self._add_annotation(
+ fig,
+ x=np.mean(arc_x),
+ y=np.mean(arc_y) + 0.3,
+ label=label,
+ font=dict(size=10),
+ )
+
+ # Compute exit point and exit angle (in degrees)
+ x_end = arc_x[-1]
+ y_end = arc_y[-1]
+ final_angle = rotation + np.degrees(phi_rad)
+ return x_end, y_end, final_angle
+
+ def exactSBend(
+ self,
+ fig,
+ x,
+ y,
+ ds: float,
+ dx: float,
+ dy: float,
+ rotation_deg: float,
+ phi_deg: float,
+ label: str,
+ index: int,
+ element_name: str,
+ ):
+ show_legend = self._add_to_legend(element_name)
+ """
+ Draws an ExactSBend lattice element on the lattice visualization.
+ """
+
+ phi_rad = np.radians(phi_deg) # phi is given in degrees in the input
+ rotation_rad = np.radians(rotation_deg) # may or may not be given
+
+ # Ensure the bend starts at the proper x and y coordinates
+ x += dx
+ y += dy
+
+ # Compute curvature radius and arc path
+ r = ds / phi_rad # radius of curvature
+
+ # find coords of center of the circle
+ circle_center_x = x - r * np.sin(rotation_rad)
+ circle_center_y = y + r * np.cos(rotation_rad)
+
+ # Generate arc points
+ n_points = 100 # determines smoothness of the arc
+ arc_thetas = np.linspace(0, phi_rad, n_points)
+ arc_x = circle_center_x + r * np.sin(rotation_rad + arc_thetas)
+ arc_y = circle_center_y - r * np.cos(rotation_rad + arc_thetas)
+
+ self._add_trace(
+ fig,
+ x=arc_x,
+ y=arc_y,
+ mode="lines",
+ line=dict(color="blue", width=3),
+ text=self._generate_hover_text(
+ index, label, ds, dx, dy, rotation_deg, phi=phi_deg
+ ),
+ show_legend=show_legend,
+ element_name=element_name,
+ )
+
+ if self.show_labels:
+ self._add_annotation(
+ fig,
+ x=np.mean(arc_x),
+ y=np.mean(arc_y) + 0.3,
+ label=label,
+ font=dict(size=10),
+ )
+
+ # Compute new beamline exit point and angle
+ final_angle = rotation_deg + phi_deg
+ x_end = arc_x[-1]
+ y_end = arc_y[-1]
+ return x_end, y_end, final_angle
+
+ def beam_monitor(self, fig, x, y, rotation, length, label, index, element_name):
+ show_legend = self._add_to_legend(element_name)
+ x1, y1 = transform(x, y, rotation, length)
+ fig.add_shape(
+ type="rect",
+ x0=x,
+ x1=x1,
+ y0=y - 0.15,
+ y1=y + 0.15,
+ line=dict(color="darkgray"),
+ fillcolor="lightgray",
+ )
+ self._add_trace(
+ fig,
+ x=[(x + x1) / 2],
+ y=[y],
+ mode="markers",
+ marker=dict(size=15, color="rgba(0,0,0,0)"),
+ text=self._generate_hover_text(index, label, length, 0, 0, rotation),
+ show_legend=show_legend,
+ element_name=element_name,
+ )
+ if self.show_labels:
+ self._add_annotation(fig, x=(x + x1) / 2, y=y + 0.3, label=label)
+ return x1, y1, rotation
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/visualization/plot.py b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/plot.py
new file mode 100644
index 000000000..0f16c595c
--- /dev/null
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/plot.py
@@ -0,0 +1,204 @@
+"""
+This file is part of ImpactX
+
+Copyright 2025 ImpactX contributors
+Authors: Parthib Roy
+License: BSD-3-Clause-LBNL
+"""
+
+import plotly.graph_objects as go
+
+from ..... import setup_server
+from . import LatticeVisualizerElements as DrawElements
+from . import LatticeVisualizerUtils as Utils
+
+server, state, ctrl = setup_server()
+draw = DrawElements()
+utils = Utils()
+
+
+def _error_plot(fig: go.Figure) -> go.Figure:
+ """
+ The error plot to display when there is an issue with the lattice data.
+
+ :return: A plotly figure with an error message.
+ """
+ fig.add_annotation(
+ text="Error: No lattice elements found or invalid data.",
+ showarrow=False,
+ font=dict(size=16, color="red"),
+ align="center",
+ xref="paper",
+ yref="paper",
+ x=0.5,
+ y=0.5,
+ )
+ fig.update_layout(
+ title="Lattice Visualization - Error",
+ xaxis=dict(title="X (m)", scaleanchor="y", scaleratio=1),
+ yaxis=dict(title="Y (m)"),
+ plot_bgcolor="white",
+ margin=dict(l=30, r=30, t=40, b=40),
+ )
+ return fig
+
+
+def lattice_visualizer():
+ """
+ Displays the lattice visualization using a plotly figure.
+ Called every time the lattice list is modified.
+
+ The current parameters which affect the visualization are:
+ - `ds`: Length of the element (default 1.0 m)
+ - `dx`: X offset of the element (default 0.0 m)
+ - `dy`: Y offset of the element (default 0.0 m)
+ - `rotation`: Rotation of the element in degrees (default 0.0)
+ - `k`: Quadrupole strength (default 0.0)
+ - Defocusing quads are colored red, focusing quads are blue
+ - `phi`: Bend angle in radians (default 0.0)
+
+ Shapes:
+ - `drift`: Straight line representing drift space
+ - `quadrupole`: Rectangle representing quadrupole magnets
+ - `bend`: Arc representing bending magnets
+ - `monitor`: Small rectangle representing beam monitors
+
+ TO-DO:
+ - `rc`: Radius of curvature for bends (default 0.0)
+ - `name`: Name of the element (used for labeling)
+ - `color`: Color of the element (default based on type)
+ - `psi`: Pole face angle in radians (default 0.0)
+ - `g`: gap parameter in m
+ - more parameters as needed
+ """
+ fig = go.Figure()
+
+ if not state.selected_lattice_list:
+ return _error_plot(fig)
+
+ try:
+ x, y, rotation = 0, 0, 0
+ draw.reset_legend()
+
+ # Check if we should show labels based on element count
+ element_count = len(state.selected_lattice_list)
+ draw.set_show_labels(element_count <= 20)
+
+ for index, element in enumerate(state.selected_lattice_list, 1):
+ element_name = element.get(
+ "name", ""
+ ).lower() # This gets the actual element name
+ element_label = utils.get_element_name_param(
+ element
+ ) # This gets the name from parameters
+ ds = utils.get_element_param(element, "ds", 1.0)
+ dx = utils.get_element_param(element, "dx", 0.0)
+ dy = utils.get_element_param(element, "dy", 0.0)
+ element_rotation = utils.get_element_param(element, "rotation", 0.0)
+ rotation_total = rotation + element_rotation
+
+ # Classify and draw element based on name
+ if "drift" in element_name:
+ x, y, rotation = draw.drift(
+ fig,
+ x,
+ y,
+ ds,
+ dx,
+ dy,
+ rotation_total,
+ element_label,
+ index,
+ element_name,
+ )
+ elif "quad" in element_name:
+ k = utils.get_element_param(element, "k", 0.0)
+ x, y, rotation = draw.quad(
+ fig,
+ x,
+ y,
+ k,
+ ds,
+ dx,
+ dy,
+ rotation_total,
+ element_label,
+ index,
+ element_name,
+ )
+ elif "bend" in element_name or "dipole" in element_name:
+ if element_name.startswith("sbend"):
+ rc = utils.get_element_param(element, "rc", 0.0)
+ x, y, rotation = draw.sBend(
+ fig,
+ x,
+ y,
+ ds,
+ dx,
+ dy,
+ rotation_total,
+ rc,
+ element_label,
+ index,
+ element_name,
+ )
+ elif element_name.startswith("exactsbend"):
+ phi = utils.get_element_param(element, "phi", 0)
+ x, y, rotation = draw.exactSBend(
+ fig,
+ x,
+ y,
+ ds,
+ dx,
+ dy,
+ rotation_total,
+ phi,
+ element_label,
+ index,
+ element_name,
+ )
+ else:
+ # Default bend handling
+ x, y, rotation = draw.drift(
+ fig,
+ x,
+ y,
+ ds,
+ dx,
+ dy,
+ rotation_total,
+ element_label,
+ index,
+ element_name,
+ )
+ elif "monitor" in element_name or "bpm" in element_name:
+ x, y, rotation = draw.beam_monitor(
+ fig, x, y, rotation_total, ds, element_label, index, element_name
+ )
+ else:
+ # Default fallback to drift
+ x, y, rotation = draw.drift(
+ fig,
+ x,
+ y,
+ ds,
+ dx,
+ dy,
+ rotation_total,
+ element_label,
+ index,
+ element_name,
+ )
+
+ except ValueError:
+ return _error_plot(fig)
+
+ fig.update_layout(
+ title="Lattice Visualization",
+ xaxis=dict(title="X (m)", scaleanchor="y", scaleratio=1),
+ yaxis=dict(title="Y (m)"),
+ plot_bgcolor="white",
+ margin=dict(l=30, r=30, t=40, b=40),
+ )
+
+ return fig
diff --git a/src/python/impactx/dashboard/Input/visualization/lattice/visualization/utils.py b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/utils.py
new file mode 100644
index 000000000..4bbb25891
--- /dev/null
+++ b/src/python/impactx/dashboard/Input/visualization/lattice/visualization/utils.py
@@ -0,0 +1,34 @@
+"""
+This file is part of ImpactX
+
+Copyright 2025 ImpactX contributors
+Authors: Parthib Roy
+License: BSD-3-Clause-LBNL
+"""
+
+
+class LatticeVisualizerUtils:
+ @staticmethod
+ def get_element_param(element, name, default=0.0):
+ for param in element.get("parameters", []):
+ if param.get("parameter_name", "").lower() == name.lower():
+ try:
+ return float(param.get("sim_input", default))
+ except (ValueError, TypeError):
+ raise ValueError(
+ f"Invalid value for {name}: {param.get('sim_input', default)}"
+ )
+ return default
+
+ @staticmethod
+ def get_element_name_param(element):
+ """Get the name parameter from the element's parameters list."""
+ for param in element.get("parameters", []):
+ if param.get("parameter_name", "").lower() == "name":
+ name_value = param.get("sim_input", "")
+ if name_value and name_value.strip(): # Check if name is not empty
+ return name_value
+
+ # If no name parameter found or it's empty, create a meaningful fallback
+ element_type = element.get("name", "")
+ return f"{element_type}" # Use the element type as fallback