diff --git a/eco/bernina/beamline_interact.svg b/eco/bernina/beamline_interact.svg
new file mode 100644
index 0000000..d31bd6e
--- /dev/null
+++ b/eco/bernina/beamline_interact.svg
@@ -0,0 +1,10623 @@
+
+
diff --git a/eco/bernina/bernina.py b/eco/bernina/bernina.py
index 960c796..383ff7e 100644
--- a/eco/bernina/bernina.py
+++ b/eco/bernina/bernina.py
@@ -45,6 +45,7 @@ namespace = Namespace(
required_names_directory="/sf/bernina/code/gac-bernina/eco_cnf_bernina/required_bernina_names.json",
)
namespace.alias_namespace.data = []
+namespace._show_svg = str(Path(__file__).parent / "beamline_interact.svg")
# Adding stuff that might be relevant for stuff configured below (e.g. config)
_config_bernina_dict = AdjustableFS(
diff --git a/eco/elements/assembly.py b/eco/elements/assembly.py
index 0d09c30..417fc2a 100644
--- a/eco/elements/assembly.py
+++ b/eco/elements/assembly.py
@@ -601,6 +601,28 @@ class Assembly:
return make_assembly_tk_window(self)
+ def show(self, in_window=False, exclude_group_ids=None):
+ """Opens the interactive SVG viewer for `_show_svg`, if defined on this instance.
+
+ Commands clicked in the SVG run against this assembly's own full
+ alias name as namespace prefix, e.g. clicking a device labelled
+ "slit_att" inside an assembly aliased "bernina.optics" runs
+ "bernina.optics.slit_att" in the IPython session.
+ """
+ svg_path = getattr(self, "_show_svg", None)
+ if not svg_path:
+ print(f"No _show_svg defined for {self.alias.get_full_name()}.")
+ return
+
+ from eco.utilities.svg_interactor import launch_svg_viewer
+
+ launch_svg_viewer(
+ svg_path,
+ in_window=in_window,
+ namespace_prefix=self.alias.get_full_name(),
+ exclude_group_ids=exclude_group_ids,
+ )
+
import epics.pv
import time
diff --git a/eco/microscopes/microscopes.py b/eco/microscopes/microscopes.py
index f65dbdc..3390e06 100644
--- a/eco/microscopes/microscopes.py
+++ b/eco/microscopes/microscopes.py
@@ -144,7 +144,8 @@ class OptoSigmaZoom(Assembly):
[self.zoom_raw],
# lambda x: abs(round(x / 4260 * 100) - 100),
# lambda x: round(abs(x - 100) / 100 * 4260),
- lambda x: 4260 - (x * 5260 / 100),
+ lambda x: abs(round((x+1006) / 5260 * 100) - 100),
+ lambda x: round(abs(x - 100) / 100 * 5260 - 1006),
name="zoom",
is_setting=False,
)
diff --git a/eco/utilities/svg_interactor.py b/eco/utilities/svg_interactor.py
new file mode 100644
index 0000000..d067dc2
--- /dev/null
+++ b/eco/utilities/svg_interactor.py
@@ -0,0 +1,430 @@
+import os
+import json
+import threading
+from dash import Dash, html, dcc
+from dash.dependencies import Input, Output
+from flask import Response
+from IPython import get_ipython
+
+# Shared between the browser (Dash) and native-window (WebKitGTK) backends.
+# Extracts the Python command attached to an SVG element, in priority order:
+# 1. onclick="// eco: " - set via Inkscape's Object Properties
+# (Ctrl+Shift+O) "Interactivity" section, "onclick" field. Deliberately
+# wrapped as a JS comment rather than raw code: onclick is a real DOM
+# event-handler attribute, so if this SVG is ever opened standalone or
+# embedded on a real page, a genuine browser will try to execute its
+# content as JavaScript on click. A JS line comment makes that a
+# guaranteed no-op (verified empirically) instead of a broken/misleading
+# attempt to run Python as JS. "eco" is this project's marker so it reads
+# clearly in the raw XML and won't collide with unrelated onclick use.
+# 2. child element - set via Inkscape's Object Properties (Ctrl+Shift+O)
+# "Description" field. This is a CHILD ELEMENT, not a desc="..." attribute -
+# so what you see in Inkscape's Properties dialog is exactly what runs.
+# Deliberately NOT wiring up "Title" too: Title is commonly used as a
+# plain accessible label, and treating it as code would make plain labels
+# crash as syntax errors.
+# 3. Beamline-diagram convention (only when NAMESPACE_PREFIX is set): a
+# label whose first monospace-font tspan names a device, e.g. "Attenuator"
+# / "att". Any trailing "@123°" reference-angle annotation on that line is
+# stripped first.
+#
+# When NAMESPACE_PREFIX is set, it is prepended to whichever raw string was
+# found above (from onclick, , OR the monospace convention) - e.g.
+# prefix "bernina" + raw "slit_att" -> "bernina.slit_att", executed against
+# that namespace object in the IPython session rather than as a standalone
+# expression. With no prefix, onclick/ content is used verbatim (e.g. a
+# full print(...) call).
+_EXTRACT_COMMAND_JS = r"""
+const ONCLICK_MARKER_RE = /^\s*\/\/\s*eco:\s*(.*)$/;
+
+function isInsideExcludedGroup(el) {
+ for (let node = el; node; node = node.parentNode) {
+ if (node.getAttribute && EXCLUDE_GROUP_IDS.includes(node.getAttribute("id"))) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function extractCommand(el) {
+ let raw = null;
+
+ let onclickAttr = el.getAttribute("onclick");
+ if (onclickAttr) {
+ let m = onclickAttr.match(ONCLICK_MARKER_RE);
+ if (m && m[1].trim()) {
+ raw = m[1].trim();
+ }
+ }
+ if (!raw) {
+ let descEl = el.querySelector(":scope > desc");
+ if (descEl && descEl.textContent.trim()) {
+ raw = descEl.textContent.trim();
+ }
+ }
+ if (!raw && NAMESPACE_PREFIX) {
+ let textEl = el.closest("text");
+ if (textEl && isInsideExcludedGroup(textEl)) {
+ textEl = null;
+ }
+ if (textEl) {
+ let tspans = textEl.querySelectorAll("tspan");
+ for (let i = 0; i < tspans.length; i++) {
+ let fam = tspans[i].style.fontFamily || "";
+ if (fam.indexOf("mono") !== -1) {
+ let cleaned = (tspans[i].textContent || "")
+ .replace(ANGLE_SUFFIX_RE, "").trim();
+ if (cleaned) {
+ raw = cleaned;
+ }
+ break;
+ }
+ }
+ }
+ }
+ if (!raw) return null;
+ return NAMESPACE_PREFIX ? (NAMESPACE_PREFIX + "." + raw) : raw;
+}
+"""
+
+
+def _build_extract_command_js(namespace_prefix, exclude_group_ids):
+ header = (
+ "const NAMESPACE_PREFIX = " + json.dumps(namespace_prefix) + ";\n"
+ "const EXCLUDE_GROUP_IDS = " + json.dumps(list(exclude_group_ids or [])) + ";\n"
+ r"const ANGLE_SUFFIX_RE = /\s*@\s*[\d.]+\s*°?\s*$/;" + "\n"
+ )
+ return header + _EXTRACT_COMMAND_JS
+
+
+def _run_dash_server(svg_path, ip_instance, namespace_prefix=None, exclude_group_ids=None):
+ """Launches a local webserver hosting the interactive Inkscape SVG."""
+ app = Dash(__name__)
+
+ # 1. Read the SVG file content directly in Python
+ try:
+ with open(svg_path, "r", encoding="utf-8") as f:
+ svg_content = f.read()
+ except Exception as e:
+ print(f"Error reading SVG file: {e}")
+ return
+
+ # Serve the SVG from a same-origin Flask route instead of a data: URL.
+ # data: URLs get an opaque origin, so the browser blocks the parent page
+ # from accessing the