diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/liquid_handling/backends/chatterbox.py index 183eebaab60..ce95096c078 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox.py +++ b/pylabrobot/liquid_handling/backends/chatterbox.py @@ -43,6 +43,7 @@ def __init__(self, num_channels: int = 8): """Initialize a chatter box backend.""" super().__init__() self._num_channels = num_channels + self.num_arms = 1 async def setup(self): await super().setup() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 42a63aa6cb6..7fefffb6786 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1448,6 +1448,7 @@ async def setup( self.autoload_installed = autoload_configuration_byte == "1" self.core96_head_installed = left_x_drive_configuration_byte_1[2] == "1" self.iswap_installed = left_x_drive_configuration_byte_1[1] == "1" + self.num_arms = 1 if self.iswap_installed else 0 self._head96_information: Optional[Head96Information] = None initialized = await self.request_instrument_initialization_status() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index ade7567e232..b3f8443c213 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -10,17 +10,24 @@ class STARChatterboxBackend(STARBackend): """Chatterbox backend for 'STAR'""" - def __init__(self, num_channels: int = 8, core96_head_installed: bool = True): + def __init__( + self, + num_channels: int = 8, + core96_head_installed: bool = True, + iswap_installed: bool = True, + ): """Initialize a chatter box backend. Args: num_channels: Number of pipetting channels (default: 8) core96_head_installed: Whether the CoRe 96 head is installed (default: True) + iswap_installed: Whether the iSWAP robotic arm is installed (default: True) """ super().__init__() self._num_channels = num_channels self._iswap_parked = True self._core96_head_installed = core96_head_installed + self._iswap_installed = iswap_installed async def setup( self, @@ -53,6 +60,7 @@ async def setup( # Use bitwise operations to check specific bits self.iswap_installed = bool(xl_value & 0b10) # Check bit 1 self.core96_head_installed = bool(xl_value & 0b100) # Check bit 2 + self.num_arms = 1 if self.iswap_installed else 0 # Parse autoload from kb configuration byte configuration_data1 = bin(conf["kb"]).split("b")[-1].zfill(8) @@ -132,12 +140,13 @@ async def request_extended_configuration(self): """ # Calculate xl byte based on installed modules # Bit 0: (reserved) - # Bit 1: iSWAP (always True in this mock) + # Bit 1: iSWAP (based on __init__ parameter) # Bit 2: 96-head (based on __init__ parameter) - xl_value = 0b10 # iSWAP installed (bit 1) + xl_value = 0 + if self._iswap_installed: + xl_value |= 0b10 # Add iSWAP (bit 1) if self._core96_head_installed: xl_value |= 0b100 # Add 96-head (bit 2) - # Result: xl = 6 (0b110) if 96-head installed, 2 (0b10) if not self._extended_conf = { "ka": 65537, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 89ade38b7f1..92be623f6bb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -178,6 +178,7 @@ def __init__(self): async def setup(self) -> None: # type: ignore self._num_channels = 8 self.iswap_installed = True + self.num_arms = 1 self.core96_head_installed = True self._core_parked = True @@ -262,6 +263,7 @@ def __init__(self, name: str): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True + self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1093,6 +1095,7 @@ async def asyncSetUp(self): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True + self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1222,6 +1225,7 @@ async def asyncSetUp(self): self.star._num_channels = 8 self.star.core96_head_installed = True self.star.iswap_installed = True + self.star.num_arms = 1 self.star.setup = unittest.mock.AsyncMock() self.star._core_parked = True self.star._iswap_parked = True @@ -1418,6 +1422,7 @@ async def asyncSetUp(self): self.backend._num_channels = 8 self.backend.core96_head_installed = True self.backend.iswap_installed = True + self.backend.num_arms = 1 self.backend.setup = unittest.mock.AsyncMock() self.backend._core_parked = True self.backend._iswap_parked = True diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 2c612016dfa..3ecbd3957cd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -217,6 +217,7 @@ async def setup(self) -> None: # type: ignore self.setup_finished = True self._num_channels = 8 self.iswap_installed = True + self.num_arms = 1 self.core96_head_installed = True async def send_command( diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index e3b96012ca1..209ddfefedf 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -145,7 +145,17 @@ def __init__( self.location = Coordinate.zero() super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) - self._resource_pickup: Optional[ResourcePickup] = None + num_arms = getattr(self.backend, "num_arms", 0) + self._resource_pickups: Dict[int, Optional[ResourcePickup]] = {a: None for a in range(num_arms)} + + @property + def _resource_pickup(self) -> Optional[ResourcePickup]: + """Backward-compatible access to the first arm's pickup state.""" + return self._resource_pickups.get(0) + + @_resource_pickup.setter + def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: + self._resource_pickups[0] = value async def setup(self, **backend_kwargs): """Prepare the robot for use.""" @@ -158,16 +168,55 @@ async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} - self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} - self._resource_pickup = None + has_head96 = getattr(self.backend, "core96_head_installed", True) + self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} if has_head96 else {} + + self.backend.set_heads(head=self.head, head96=self.head96 or None) + + for tracker in self.head.values(): + tracker.register_callback(self._state_updated) + for tracker in self.head96.values(): + tracker.register_callback(self._state_updated) + + num_arms = getattr(self.backend, "num_arms", 0) + self._resource_pickups = {a: None for a in range(num_arms)} def serialize_state(self) -> Dict[str, Any]: """Serialize the state of this liquid handler. Use :meth:`~Resource.serialize_all_states` to serialize the state of the liquid handler and all children (the deck).""" head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} - return {"head_state": head_state} + head96_state = ( + {channel: tracker.serialize() for channel, tracker in self.head96.items()} + if self.head96 + else None + ) + if self._resource_pickups: + arm_state: Optional[Dict[int, Any]] = {} + for arm_id, pickup in self._resource_pickups.items(): + if pickup is not None: + res = pickup.resource + arm_entry: Dict[str, Any] = { + "has_resource": True, + "resource_name": res.name, + "resource_type": type(res).__name__, + "direction": pickup.direction.name, + "pickup_distance_from_top": pickup.pickup_distance_from_top, + "size_x": res.get_size_x(), + "size_y": res.get_size_y(), + "size_z": res.get_size_z(), + } + if hasattr(res, "num_items_x"): + arm_entry["num_items_x"] = res.num_items_x + if hasattr(res, "num_items_y"): + arm_entry["num_items_y"] = res.num_items_y + arm_state[arm_id] = arm_entry + else: + arm_state[arm_id] = None + else: + arm_state = None + return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} def load_state(self, state: Dict[str, Any]): """Load the liquid handler state from a file. Use :meth:`~Resource.load_all_state` to load the @@ -177,6 +226,13 @@ def load_state(self, state: Dict[str, Any]): for channel, tracker_state in head_state.items(): self.head[channel].load_state(tracker_state) + head96_state = state.get("head96_state", {}) + for channel, tracker_state in head96_state.items(): + self.head96[channel].load_state(tracker_state) + + # arm_state is informational only (read via serialize_state); no load needed since + # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. + def update_head_state(self, state: Dict[int, Optional[Tip]]): """Update the state of the liquid handler head. @@ -1964,6 +2020,9 @@ async def pick_up_resource( direction=direction, ) + if not self._resource_pickups: + raise RuntimeError("No robotic arm is installed on this liquid handler.") + if pickup_distance_from_top is None: if resource.preferred_pickup_location is not None: logger.debug( @@ -2001,6 +2060,8 @@ async def pick_up_resource( self._resource_pickup = None raise e + self._state_updated() + async def move_picked_up_resource( self, to: Coordinate, @@ -2166,6 +2227,7 @@ async def drop_resource( result = await self.backend.drop_resource(drop=drop, **backend_kwargs) self._resource_pickup = None + self._state_updated() # we rotate the resource on top of its original rotation. So in order to set the new rotation, # we have to subtract its current rotation. diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 41431412035..19bcc8f608c 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -66,6 +66,7 @@ def _create_mock_backend(num_channels: int = 8): """Create a mock LiquidHandlerBackend with the specified number of channels.""" mock = unittest.mock.create_autospec(LiquidHandlerBackend, instance=True) type(mock).num_channels = PropertyMock(return_value=num_channels) + mock.num_arms = 1 mock.can_pick_up_tip.return_value = True return mock diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 95fbafd85a4..90da419da42 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -111,6 +111,10 @@ def remove_tip(self, commit: bool = False) -> None: def commit(self) -> None: """Commit the pending operations.""" self._tip = self._pending_tip + # Propagate state-update callback to the tip's volume tracker so that + # aspirate/dispense volume changes trigger a visualizer refresh. + if self._tip is not None and self._callback is not None: + self._tip.tracker.register_callback(self._callback) if self._callback is not None: self._callback() diff --git a/pylabrobot/visualizer/img/integrated_arm.png b/pylabrobot/visualizer/img/integrated_arm.png new file mode 100644 index 00000000000..0e518888d83 Binary files /dev/null and b/pylabrobot/visualizer/img/integrated_arm.png differ diff --git a/pylabrobot/visualizer/img/logo.png b/pylabrobot/visualizer/img/logo.png new file mode 100644 index 00000000000..affe21787f7 Binary files /dev/null and b/pylabrobot/visualizer/img/logo.png differ diff --git a/pylabrobot/visualizer/img/multi_channel_pipette.png b/pylabrobot/visualizer/img/multi_channel_pipette.png new file mode 100644 index 00000000000..c66c881c07b Binary files /dev/null and b/pylabrobot/visualizer/img/multi_channel_pipette.png differ diff --git a/pylabrobot/visualizer/img/single_channel_pipette.png b/pylabrobot/visualizer/img/single_channel_pipette.png new file mode 100644 index 00000000000..e0cc7cd26b7 Binary files /dev/null and b/pylabrobot/visualizer/img/single_channel_pipette.png differ diff --git a/pylabrobot/visualizer/index.html b/pylabrobot/visualizer/index.html index 8ba6ddea3ce..af3f2282592 100644 --- a/pylabrobot/visualizer/index.html +++ b/pylabrobot/visualizer/index.html @@ -5,6 +5,7 @@ PyLabRobot Visualizer + - + + +
+ +
+
+
+ + +
+ + - -
- - Loading... +
+ + + +
+
+ + + + + x + + + + y + +
+
+
+
-
- - -
-
-
+ + +
@@ -92,10 +354,11 @@ + - - + + diff --git a/pylabrobot/visualizer/lib.js b/pylabrobot/visualizer/lib.js index 8c463d8d5c2..ec241c3bc43 100644 --- a/pylabrobot/visualizer/lib.js +++ b/pylabrobot/visualizer/lib.js @@ -34,10 +34,492 @@ var stage; var selectedResource; var canvasWidth, canvasHeight; +var activeTool = "cursor"; // "cursor" or "coords" +var wrtHighlightCircle; +var resHighlightBullseye; +var deltaLinesGroup = null; // Konva.Group for Δ lines between resource and wrt bullseyes + +function buildWrtDropdown() { + var wrtSelect = document.getElementById("coords-wrt-ref"); + if (!wrtSelect) return; + var prevValue = wrtSelect.value; + wrtSelect.innerHTML = ''; + + // Collect all visible (not inside a collapsed container) resources from the sidebar tree. + var tree = document.getElementById("resource-tree"); + if (!tree) return; + + function collectVisible(container) { + var items = []; + var nodes = container.querySelectorAll(":scope > .tree-node"); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var name = node.dataset.resourceName; + if (name) items.push(name); + // Recurse into children if not collapsed + var childrenDiv = node.querySelector(":scope > .tree-node-children"); + if (childrenDiv && !childrenDiv.classList.contains("collapsed")) { + items = items.concat(collectVisible(childrenDiv)); + } + } + return items; + } + + var visibleNames = collectVisible(tree); + + for (var i = 0; i < visibleNames.length; i++) { + var opt = document.createElement("option"); + opt.value = visibleNames[i]; + opt.textContent = visibleNames[i]; + wrtSelect.appendChild(opt); + } + + // Restore previous selection if still valid + var allValues = Array.from(wrtSelect.options).map(function (o) { return o.value; }); + if (allValues.indexOf(prevValue) >= 0) { + wrtSelect.value = prevValue; + } else if (visibleNames.length > 0) { + wrtSelect.value = visibleNames[0]; + } +} + +function updateCoordsPanel(resource) { + // No-op; dropdown is built once via buildWrtDropdown. +} + +function updateWrtBullseyeScale() { + if (!wrtHighlightCircle) return; + var s = Math.pow(1 / Math.abs(stage.scaleX()), 0.95); + wrtHighlightCircle.scaleX(s); + wrtHighlightCircle.scaleY(s); + var haloGroup = wrtHighlightCircle.getChildren()[0]; + if (haloGroup && haloGroup.clearCache) { + var r = 9.2, barH = r * 1.0125; + var pad = 20 / s; + haloGroup.cache({ x: -r - barH - pad, y: -r - barH - pad, width: (r + barH) * 2 + pad * 2, height: (r + barH) * 2 + pad * 2 }); + } + resourceLayer.draw(); +} + +function updateTooltipScale() { + if (!tooltip) return; + var ts = Math.pow(1 / Math.abs(stage.scaleX()), 0.97); + tooltip.scaleX(ts); + tooltip.scaleY(-ts); +} + +function clearDeltaLines() { + if (deltaLinesGroup) { deltaLinesGroup.destroy(); deltaLinesGroup = null; } + resourceLayer.draw(); +} + +function updateDeltaLinesScale() { + if (!deltaLinesGroup) return; + var s = Math.pow(1 / Math.abs(stage.scaleX()), 0.95); + deltaLinesGroup.getChildren().forEach(function (child) { + if (child.getClassName() === "Label") { + child.scaleY(-s); + child.scaleX(s); + } else if (child.getClassName() === "Line") { + child.strokeWidth(child._baseStrokeWidth * s); + child.dash([14 * s, 6 * s]); + } + }); + resourceLayer.draw(); +} + +function drawDeltaLines(resource) { + if (deltaLinesGroup) { deltaLinesGroup.destroy(); deltaLinesGroup = null; } + if (!resource || activeTool !== "coords") return; + var toggle = document.getElementById("delta-lines-toggle"); + if (toggle && !toggle.checked) return; + + // Get WRT bullseye position + var wrtRef = document.getElementById("coords-wrt-ref"); + var wrtName = wrtRef ? wrtRef.value : null; + var wrtRes = wrtName ? resources[wrtName] : null; + if (!wrtRes) return; + var wrtAbs = wrtRes.getAbsoluteLocation(); + var wrtOff = getWrtAnchorOffset(wrtRes); + var wx = wrtAbs.x + wrtOff.x; + var wy = wrtAbs.y + wrtOff.y; + + // Get resource bullseye position + var abs = resource.getAbsoluteLocation(); + var xRef = document.getElementById("coords-x-ref"); + var yRef = document.getElementById("coords-y-ref"); + var xOff = !xRef || xRef.value === "left" ? 0 : xRef.value === "center" ? resource.size_x / 2 : resource.size_x; + var yOff = !yRef || yRef.value === "front" ? 0 : yRef.value === "center" ? resource.size_y / 2 : resource.size_y; + var rx = abs.x + xOff; + var ry = abs.y + yOff; + + var dx = rx - wx; + var dy = ry - wy; + if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return; + + deltaLinesGroup = new Konva.Group({ listening: false }); + var s = Math.pow(1 / Math.abs(stage.scaleX()), 0.95); + var xColor = "#dc3545"; // matches x-axis legend + var yColor = "#198754"; // matches y-axis legend + var xHalo = "#EE8866"; + var yHalo = "#BBCC33"; + var corner = { x: rx, y: wy }; // L-shape corner + + // Halo lines (thicker, behind main lines) + var xHaloLine = new Konva.Line({ + points: [wx, wy, corner.x, corner.y], + stroke: xHalo, strokeWidth: 8 * s, dash: [14 * s, 6 * s], opacity: 0.4, + }); + xHaloLine._baseStrokeWidth = 8; + deltaLinesGroup.add(xHaloLine); + + var yHaloLine = new Konva.Line({ + points: [corner.x, corner.y, rx, ry], + stroke: yHalo, strokeWidth: 8 * s, dash: [14 * s, 6 * s], opacity: 0.4, + }); + yHaloLine._baseStrokeWidth = 8; + deltaLinesGroup.add(yHaloLine); + + // Main horizontal line (Δx): wrt → corner + var xLine = new Konva.Line({ + points: [wx, wy, corner.x, corner.y], + stroke: xColor, strokeWidth: 3 * s, dash: [14 * s, 6 * s], + }); + xLine._baseStrokeWidth = 3; + deltaLinesGroup.add(xLine); + + // Main vertical line (Δy): corner → resource + var yLine = new Konva.Line({ + points: [corner.x, corner.y, rx, ry], + stroke: yColor, strokeWidth: 3 * s, dash: [14 * s, 6 * s], + }); + yLine._baseStrokeWidth = 3; + deltaLinesGroup.add(yLine); + + // Δx label at midpoint of horizontal line + var dxLabel = new Konva.Label({ + x: (wx + corner.x) / 2, + y: wy, + }); + dxLabel.add(new Konva.Tag({ + fill: "white", + opacity: 0.85, + cornerRadius: 3, + })); + dxLabel.add(new Konva.Text({ + text: "\u0394x=" + dx.toFixed(1), + fontSize: 18, + fill: xColor, + fontStyle: "bold", + fontFamily: "Arial", + padding: 3, + })); + dxLabel.offsetX(dxLabel.width() / 2); + dxLabel.offsetY(-4); + dxLabel.scaleY(-s); + dxLabel.scaleX(s); + deltaLinesGroup.add(dxLabel); + + // Δy label at midpoint of vertical line + var dyLabel = new Konva.Label({ + x: rx, + y: (wy + ry) / 2, + }); + dyLabel.add(new Konva.Tag({ + fill: "white", + opacity: 0.85, + cornerRadius: 3, + })); + dyLabel.add(new Konva.Text({ + text: "\u0394y=" + dy.toFixed(1), + fontSize: 18, + fill: yColor, + fontStyle: "bold", + fontFamily: "Arial", + padding: 3, + })); + dyLabel.offsetY(dyLabel.height() / 2); + dyLabel.offsetX(-6); + dyLabel.scaleY(-s); + dyLabel.scaleX(s); + deltaLinesGroup.add(dyLabel); + + resourceLayer.add(deltaLinesGroup); + deltaLinesGroup.moveToTop(); + resourceLayer.draw(); +} + +function updateWrtHighlight() { + if (wrtHighlightCircle) { wrtHighlightCircle.destroy(); wrtHighlightCircle = undefined; } + if (activeTool !== "coords") return; + var wrtRef = document.getElementById("coords-wrt-ref"); + var wrtName = wrtRef ? wrtRef.value : null; + var wrtRes = wrtName ? resources[wrtName] : null; + if (!wrtRes) return; + var wrtAbs = wrtRes.getAbsoluteLocation(); + var wrtOff = getWrtAnchorOffset(wrtRes); + var cx = wrtAbs.x + wrtOff.x; + var cy = wrtAbs.y + wrtOff.y; + var r = 9.2; + var barH = r * 1.0125; + var wrtHaloColor = "#DDDDDD"; + var wrtHaloExtra = 4; + wrtHighlightCircle = new Konva.Group({ x: cx, y: cy, listening: false }); + // Halo: blurred duplicate behind + var wrtHaloGroup = new Konva.Group({ opacity: 0.5 }); + wrtHaloGroup.filters([Konva.Filters.Blur]); + wrtHaloGroup.blurRadius(6); + wrtHaloGroup.add(new Konva.Circle({ + x: 0, y: 0, radius: r, + fill: "transparent", stroke: wrtHaloColor, strokeWidth: 4.32 + wrtHaloExtra * 2, + })); + wrtHaloGroup.add(new Konva.Circle({ + x: 0, y: 0, radius: 2.4 + wrtHaloExtra / 2, + fill: wrtHaloColor, + })); + wrtHaloGroup.add(new Konva.Line({ + points: [-r - barH, 0, -r, 0], + stroke: wrtHaloColor, strokeWidth: 3.6 + wrtHaloExtra * 2, + })); + wrtHaloGroup.add(new Konva.Line({ + points: [r, 0, r + barH, 0], + stroke: wrtHaloColor, strokeWidth: 3.6 + wrtHaloExtra * 2, + })); + wrtHaloGroup.add(new Konva.Line({ + points: [0, -r - barH, 0, -r], + stroke: wrtHaloColor, strokeWidth: 3.6 + wrtHaloExtra * 2, + })); + wrtHaloGroup.add(new Konva.Line({ + points: [0, r, 0, r + barH], + stroke: wrtHaloColor, strokeWidth: 3.6 + wrtHaloExtra * 2, + })); + wrtHaloGroup.cache({ x: -r - barH - 20, y: -r - barH - 20, width: (r + barH) * 2 + 40, height: (r + barH) * 2 + 40 }); + wrtHighlightCircle.add(wrtHaloGroup); + // Main bullseye on top + wrtHighlightCircle.add(new Konva.Circle({ + x: 0, y: 0, radius: r, + fill: "transparent", stroke: "#FFAABB", strokeWidth: 4.32, + })); + wrtHighlightCircle.add(new Konva.Circle({ + x: 0, y: 0, radius: 2.4, + fill: "#FFAABB", opacity: 0.85, + })); + wrtHighlightCircle.add(new Konva.Line({ + points: [-r - barH, 0, -r, 0], + stroke: "#FFAABB", strokeWidth: 3.6, + })); + wrtHighlightCircle.add(new Konva.Line({ + points: [r, 0, r + barH, 0], + stroke: "#FFAABB", strokeWidth: 3.6, + })); + wrtHighlightCircle.add(new Konva.Line({ + points: [0, -r - barH, 0, -r], + stroke: "#FFAABB", strokeWidth: 3.6, + })); + wrtHighlightCircle.add(new Konva.Line({ + points: [0, r, 0, r + barH], + stroke: "#FFAABB", strokeWidth: 3.6, + })); + resourceLayer.add(wrtHighlightCircle); + wrtHighlightCircle.moveToTop(); + updateWrtBullseyeScale(); + updateTooltipScale(); + updateDeltaLinesScale(); +} + +function updateBullseyeScale() { + if (!resHighlightBullseye) return; + var s = Math.pow(1 / Math.abs(stage.scaleX()), 0.95); + resHighlightBullseye.scaleX(s); + resHighlightBullseye.scaleY(s); + // Re-cache halo for blur filter at new scale + var haloGroup = resHighlightBullseye.getChildren()[0]; + if (haloGroup && haloGroup.clearCache) { + var r = 9.2, barH = r * 1.0125; + var pad = 20 / s; + haloGroup.cache({ x: -r - barH - pad, y: -r - barH - pad, width: (r + barH) * 2 + pad * 2, height: (r + barH) * 2 + pad * 2 }); + } + resourceLayer.draw(); +} + +function showResHighlightBullseye(resource) { + if (resHighlightBullseye) { resHighlightBullseye.destroy(); resHighlightBullseye = undefined; } + if (!resource) return; + var abs = resource.getAbsoluteLocation(); + var xRef = document.getElementById("coords-x-ref"); + var yRef = document.getElementById("coords-y-ref"); + var xOff = !xRef || xRef.value === "left" ? 0 : xRef.value === "center" ? resource.size_x / 2 : resource.size_x; + var yOff = !yRef || yRef.value === "front" ? 0 : yRef.value === "center" ? resource.size_y / 2 : resource.size_y; + var cx = abs.x + xOff; + var cy = abs.y + yOff; + var r = 9.2; + var barH = r * 1.0125; + var color = "#99DDFF"; + var haloColor = "#BBCC33"; + // Position group at bullseye center; draw elements relative to (0,0) + resHighlightBullseye = new Konva.Group({ x: cx, y: cy, listening: false }); + // Halo: blurred, thicker duplicate of every element behind the bullseye + var haloExtra = 4; + var haloOpacity = 0.5; + var haloGroup = new Konva.Group({ + opacity: haloOpacity, + }); + haloGroup.filters([Konva.Filters.Blur]); + haloGroup.blurRadius(6); + haloGroup.add(new Konva.Circle({ + x: 0, y: 0, radius: r, + fill: "transparent", stroke: haloColor, strokeWidth: 4.32 + haloExtra * 2, + })); + haloGroup.add(new Konva.Circle({ + x: 0, y: 0, radius: 2.4 + haloExtra / 2, + fill: haloColor, + })); + haloGroup.add(new Konva.Line({ + points: [-r - barH, 0, -r, 0], + stroke: haloColor, strokeWidth: 3.6 + haloExtra * 2, + })); + haloGroup.add(new Konva.Line({ + points: [r, 0, r + barH, 0], + stroke: haloColor, strokeWidth: 3.6 + haloExtra * 2, + })); + haloGroup.add(new Konva.Line({ + points: [0, -r - barH, 0, -r], + stroke: haloColor, strokeWidth: 3.6 + haloExtra * 2, + })); + haloGroup.add(new Konva.Line({ + points: [0, r, 0, r + barH], + stroke: haloColor, strokeWidth: 3.6 + haloExtra * 2, + })); + // Must cache after adding children for blur filter to work + haloGroup.cache({ x: -r - barH - 20, y: -r - barH - 20, width: (r + barH) * 2 + 40, height: (r + barH) * 2 + 40 }); + resHighlightBullseye.add(haloGroup); + // Main bullseye on top + resHighlightBullseye.add(new Konva.Circle({ + x: 0, y: 0, radius: r, + fill: "transparent", stroke: color, strokeWidth: 4.32, + })); + resHighlightBullseye.add(new Konva.Circle({ + x: 0, y: 0, radius: 2.4, + fill: color, opacity: 0.85, + })); + resHighlightBullseye.add(new Konva.Line({ + points: [-r - barH, 0, -r, 0], + stroke: color, strokeWidth: 3.6, + })); + resHighlightBullseye.add(new Konva.Line({ + points: [r, 0, r + barH, 0], + stroke: color, strokeWidth: 3.6, + })); + resHighlightBullseye.add(new Konva.Line({ + points: [0, -r - barH, 0, -r], + stroke: color, strokeWidth: 3.6, + })); + resHighlightBullseye.add(new Konva.Line({ + points: [0, r, 0, r + barH], + stroke: color, strokeWidth: 3.6, + })); + resourceLayer.add(resHighlightBullseye); + resHighlightBullseye.moveToTop(); + // Apply inverse scale so bullseye appears constant size + updateBullseyeScale(); +} + +function getAncestorAtDepth(resource, depth) { + // Walk up from the resource to the sidebar root, skipping deck-like + // intermediaries (same flattening as the sidebar). + var rawChain = []; + var cur = resource; + while (cur) { + rawChain.unshift(cur); + if (sidebarRootResource && cur === sidebarRootResource) break; + cur = cur.parent; + } + var displayChain = []; + for (var i = 0; i < rawChain.length; i++) { + // Keep the root (index 0) and skip deck-like intermediaries + if (i > 0 && isDeckLike(rawChain[i])) continue; + // Also skip ResourceHolder intermediaries + if (i > 0 && isResourceHolder(rawChain[i])) continue; + displayChain.push(rawChain[i]); + } + if (depth < displayChain.length) return displayChain[depth]; + return null; +} + +function getWrtAnchorOffset(wrtResource) { + var xRef = document.getElementById("coords-wrt-x-ref"); + var yRef = document.getElementById("coords-wrt-y-ref"); + var zRef = document.getElementById("coords-wrt-z-ref"); + + var xOff = 0; + if (xRef && xRef.value === "center") xOff = wrtResource.size_x / 2; + else if (xRef && xRef.value === "right") xOff = wrtResource.size_x; + + var yOff = 0; + if (yRef && yRef.value === "center") yOff = wrtResource.size_y / 2; + else if (yRef && yRef.value === "back") yOff = wrtResource.size_y; + + var zOff = 0; + if (zRef) { + if (zRef.value === "center") zOff = wrtResource.size_z / 2; + else if (zRef.value === "top") zOff = wrtResource.size_z; + else if (zRef.value === "cavity_bottom") { + if (wrtResource instanceof Container && wrtResource.material_z_thickness != null) { + zOff = wrtResource.material_z_thickness; + } + } + } + + return { x: xOff, y: yOff, z: zOff }; +} + +function getLocationWrt(resource, wrtName) { + var wrtResource = resources[wrtName]; + if (!wrtResource) return resource.getAbsoluteLocation(); + var abs = resource.getAbsoluteLocation(); + var wrtAbs = wrtResource.getAbsoluteLocation(); + var wrtOff = getWrtAnchorOffset(wrtResource); + return { + x: abs.x - (wrtAbs.x + wrtOff.x), + y: abs.y - (wrtAbs.y + wrtOff.y), + z: (abs.z || 0) - ((wrtAbs.z || 0) + wrtOff.z), + }; +} var scaleX, scaleY; var resources = {}; // name -> Resource object +// Serialized resource data saved before resources are destroyed (e.g. picked up by arm). +// Used by the arm panel to re-instantiate the resource and draw it on a live Konva stage +// using the exact same draw() code as the main canvas — guaranteeing visual consistency. +// Each entry is a plain JS object from resource.serialize() (~1-5 KB for a 96-well plate). +var resourceSnapshots = {}; // name -> serialized resource data + +var rootResource = null; // the root resource for fit-to-viewport + +function fitToViewport() { + if (!rootResource || !stage) return; + const padding = 40; + const stageW = stage.width(); + const stageH = stage.height(); + const viewW = stageW - padding * 2; + const viewH = stageH - padding * 2; + const fitScale = Math.min(viewW / rootResource.size_x, viewH / rootResource.size_y, 1); + + stage.scaleX(fitScale); + stage.scaleY(-fitScale); + + const centerX = (stageW - rootResource.size_x * fitScale) / 2; + const centerY = (stageH + rootResource.size_y * fitScale) / 2 - stageH * fitScale; + stage.x(centerX); + stage.y(centerY); + + if (typeof updateScaleBar === "function") updateScaleBar(); + updateBullseyeScale(); + updateWrtBullseyeScale(); + updateTooltipScale(); + updateDeltaLinesScale(); +} let trash; @@ -50,6 +532,7 @@ let isRecording = false; let recordingCounter = 0; // Counter to track the number of recorded frames var frameImages = []; let frameInterval = 8; +var _recordingTimer = null; function getSnappingResourceAndLocationAndSnappingBox(resourceToSnap, x, y) { // Return the snapping resource that the given point is within, or undefined if there is no such resource. @@ -222,13 +705,16 @@ class Resource { this.size_z = size_z; this.location = location; this.parent = parent; + this.resourceType = resourceData.type || this.constructor.name; + this.category = resourceData.category || ""; + this.methods = resourceData.methods || []; this.color = "#5B6D8F"; this.children = []; for (let i = 0; i < children.length; i++) { const child = children[i]; - const childClass = classForResourceType(child.type); + const childClass = classForResourceType(child.type, child.category); const childInstance = new childClass(child, this); this.assignChild(childInstance); @@ -286,10 +772,43 @@ class Resource { if (tooltip !== undefined) { tooltip.destroy(); } + var labelText; + if (activeTool === "coords") { + const xRef = document.getElementById("coords-x-ref"); + const yRef = document.getElementById("coords-y-ref"); + const zRef = document.getElementById("coords-z-ref"); + const wrtRef = document.getElementById("coords-wrt-ref"); + const wrtName = wrtRef ? wrtRef.value : "root"; + const base = getLocationWrt(this, wrtName); + const xOff = !xRef || xRef.value === "left" ? 0 : xRef.value === "center" ? this.size_x / 2 : this.size_x; + const yOff = !yRef || yRef.value === "front" ? 0 : yRef.value === "center" ? this.size_y / 2 : this.size_y; + var zOff = 0; + var zNA = false; + if (zRef) { + if (zRef.value === "center") zOff = this.size_z / 2; + else if (zRef.value === "top") zOff = this.size_z; + else if (zRef.value === "cavity_bottom") { + if (this instanceof Container && this.material_z_thickness != null) { + zOff = this.material_z_thickness; + } else { + zNA = true; + } + } + } + const cx = base.x + xOff; + const cy = base.y + yOff; + const cz = (base.z || 0) + zOff; + const czStr = zNA ? "na" : cz.toFixed(1); + const wrtLabel = "wrt " + wrtName; + labelText = `${this.name}\n${wrtLabel}: (${cx.toFixed(1)}, ${cy.toFixed(1)}, ${czStr}) mm`; + } else { + labelText = this.tooltipLabel(); + } tooltip = new Konva.Label({ x: x + this.size_x / 2, - y: y + this.size_y / 2, + y: y + this.size_y / 2 + (activeTool === "coords" ? this.size_y * 0.25 : 0), opacity: 0.75, + listening: false, }); tooltip.add( new Konva.Tag({ @@ -306,18 +825,136 @@ class Resource { ); tooltip.add( new Konva.Text({ - text: this.tooltipLabel(), + text: labelText, fontFamily: "Arial", - fontSize: 18, + fontSize: activeTool === "coords" ? 17.5 : 21.4, + lineHeight: activeTool === "coords" ? 1.6 : 1.2, padding: 5, fill: "white", }) ); - tooltip.scaleY(-1); + var ts = Math.pow(1 / Math.abs(stage.scaleX()), 0.97); + tooltip.scaleX(ts); + tooltip.scaleY(-ts); layer.add(tooltip); + if (typeof highlightSidebarRow === "function") { + highlightSidebarRow(this.name); + } + if (activeTool === "coords") { + updateCoordsPanel(this); + showResHighlightBullseye(this); + drawDeltaLines(this); + } + }); + this.mainShape.on("click", () => { + if (activeTool === "coords") { + const xRef = document.getElementById("coords-x-ref"); + const yRef = document.getElementById("coords-y-ref"); + const zRef = document.getElementById("coords-z-ref"); + const wrtRef = document.getElementById("coords-wrt-ref"); + const wrtName = wrtRef ? wrtRef.value : "root"; + const base = getLocationWrt(this, wrtName); + const xOff = !xRef || xRef.value === "left" ? 0 : xRef.value === "center" ? this.size_x / 2 : this.size_x; + const yOff = !yRef || yRef.value === "front" ? 0 : yRef.value === "center" ? this.size_y / 2 : this.size_y; + var zOff = 0; + var zNA = false; + if (zRef) { + if (zRef.value === "center") zOff = this.size_z / 2; + else if (zRef.value === "top") zOff = this.size_z; + else if (zRef.value === "cavity_bottom") { + if (this instanceof Container && this.material_z_thickness != null) { + zOff = this.material_z_thickness; + } else { + zNA = true; + } + } + } + const cx = base.x + xOff; + const cy = base.y + yOff; + const cz = (base.z || 0) + zOff; + const czStr = zNA ? "na" : cz.toFixed(1); + const container = document.getElementById("coords-measurements"); + if (container) { + const xLabel = xRef ? xRef.value : "left"; + const yLabel = yRef ? yRef.value : "front"; + const zLabel = zRef ? zRef.value : "bottom"; + const row = document.createElement("div"); + row.style.padding = "5px 0"; + row.style.borderBottom = "2px solid #ced4da"; + row.style.fontSize = "14px"; + row.style.lineHeight = "1.4"; + row.style.display = "flex"; + row.style.alignItems = "flex-start"; + + const content = document.createElement("div"); + content.style.flex = "1"; + + const xl = xLabel[0]; + const yl = yLabel[0]; + const zl = zLabel[0]; + const wrtXRef = document.getElementById("coords-wrt-x-ref"); + const wrtYRef = document.getElementById("coords-wrt-y-ref"); + const wrtZRef = document.getElementById("coords-wrt-z-ref"); + const wxl = wrtXRef ? wrtXRef.value[0] : "l"; + const wyl = wrtYRef ? wrtYRef.value[0] : "f"; + const wzl = wrtZRef ? wrtZRef.value[0] : "b"; + + const nameLine = document.createElement("div"); + nameLine.style.fontWeight = "600"; + nameLine.style.color = "#333"; + nameLine.textContent = `${this.name} (${xl}, ${yl}, ${zl})`; + + const wrtLine = document.createElement("div"); + wrtLine.style.color = "#888"; + wrtLine.style.fontSize = "12px"; + wrtLine.textContent = `wrt ${wrtName} (${wxl}, ${wyl}, ${wzl})`; + + const coordLine = document.createElement("div"); + coordLine.style.fontFamily = "monospace"; + coordLine.style.color = "#1a4b8c"; + coordLine.style.fontWeight = "600"; + coordLine.textContent = `(${cx.toFixed(1)}, ${cy.toFixed(1)}, ${czStr})`; + + content.appendChild(nameLine); + content.appendChild(wrtLine); + content.appendChild(coordLine); + + const deleteBtn = document.createElement("button"); + deleteBtn.textContent = "×"; + deleteBtn.style.background = "none"; + deleteBtn.style.border = "none"; + deleteBtn.style.color = "#aaa"; + deleteBtn.style.fontSize = "18px"; + deleteBtn.style.cursor = "pointer"; + deleteBtn.style.padding = "0 2px"; + deleteBtn.style.lineHeight = "1"; + deleteBtn.style.flexShrink = "0"; + deleteBtn.onmouseover = () => { deleteBtn.style.color = "#d33"; }; + deleteBtn.onmouseout = () => { deleteBtn.style.color = "#aaa"; }; + deleteBtn.onclick = () => { row.remove(); }; + + row.appendChild(content); + row.appendChild(deleteBtn); + var hint = document.getElementById("coords-measurements-hint"); + if (hint) hint.remove(); + container.appendChild(row); + container.scrollTop = container.scrollHeight; + } + } }); this.mainShape.on("mouseout", () => { tooltip.destroy(); + showResHighlightBullseye(null); + clearDeltaLines(); + if (typeof clearSidebarHighlight === "function") { + clearSidebarHighlight(); + } + if (activeTool === "coords") { + updateCoordsPanel(null); + } + }); + this.mainShape.on("dblclick dbltap", () => { + showUmlPanel(this.name); }); } } @@ -420,12 +1057,7 @@ class Resource { update() { this.draw(resourceLayer); - if (isRecording) { - if (recordingCounter % frameInterval == 0) { - stageToBlob(stage, handleBlob); - } - recordingCounter += 1; - } + // GIF frame capture is now driven by _recordingTimer (setInterval) } setState() {} @@ -485,14 +1117,15 @@ class HamiltonSTARDeck extends Deck { // Add a text label every 5 rails. Rails are 1-indexed. // Keep in mind that the stage is flipped vertically. - if ((i + 1) % 5 === 0) { + if ((i + 1) % 5 === 0 || i === 0) { const railLabel = new Konva.Text({ - x: 100 + i * 22.5, // 22.5 mm per rail + x: 100 + i * 22.5 + 11.25, // center of rail (between lines) y: 50, text: i + 1, - fontSize: 12, + fontSize: 15, fill: "black", }); + railLabel.offsetX(railLabel.width() / 2); railLabel.scaleY(-1); // Flip the text vertically mainShape.add(railLabel); } @@ -558,14 +1191,15 @@ class VantageDeck extends Deck { }); mainShape.add(rail); - if ((i + 1) % 5 === 0) { + if ((i + 1) % 5 === 0 || i === 0) { const railLabel = new Konva.Text({ - x: railX, + x: railX + 11.25, // center of rail (between lines) y: 50, text: i + 1, - fontSize: 12, + fontSize: 15, fill: "black", }); + railLabel.offsetX(railLabel.width() / 2); railLabel.scaleY(-1); mainShape.add(railLabel); } @@ -708,9 +1342,10 @@ class Plate extends Resource { class Container extends Resource { constructor(resourceData, parent) { super(resourceData, parent); - const { max_volume } = resourceData; + const { max_volume, material_z_thickness } = resourceData; this.maxVolume = max_volume; this.volume = resourceData.volume || 0; + this.material_z_thickness = material_z_thickness; } static colorForVolume(volume, maxVolume) { @@ -757,6 +1392,13 @@ class Well extends Container { this.cross_section_type = cross_section_type; } + serialize() { + return { + ...super.serialize(), + cross_section_type: this.cross_section_type, + }; + } + drawMainShape() { const mainShape = new Konva.Group({}); if (this.cross_section_type === "circle") { @@ -1009,17 +1651,831 @@ class TubeRack extends Resource { class PlateHolder extends ResourceHolder {} +// Track the currently open pipette info panel so it can be refreshed on state updates. +var _pipetteInfoState = null; // { ch, kind ("channel"|"tip"), anchorDropdown } + +function buildChannelAttrs(ch, headState) { + var chState = headState[ch] || {}; + var tipData = chState.tip; + var hasTip = tipData !== null && tipData !== undefined; + var attrs = [{ key: "channel", value: ch }]; + if (hasTip) { + attrs.push({ key: "has_tip", value: "true" }); + attrs.push({ key: "tip_type", value: tipData.type || "Unknown" }); + attrs.push({ key: "tip_size", value: (tipData.tip_size || "").replace(/_/g, " ") }); + attrs.push({ key: "max_volume", value: (tipData.maximal_volume || "?") + " \u00B5L" }); + attrs.push({ key: "has_filter", value: tipData.has_filter ? "Yes" : "No" }); + attrs.push({ key: "tip_length", value: (tipData.total_tip_length || "?") + " mm" }); + } else { + attrs.push({ key: "has_tip", value: "false" }); + } + return attrs; +} + +function buildTipAttrs(ch, headState) { + var chState = headState[ch] || {}; + var tipData = chState.tip; + var tipStateData = chState.tip_state; + if (!tipData) return null; + var attrs = [ + { key: "name", value: tipData.name || "Unknown" }, + { key: "type", value: tipData.type || "Unknown" }, + { key: "tip_size", value: (tipData.tip_size || "").replace(/_/g, " ") }, + { key: "total_tip_length", value: (tipData.total_tip_length || "?") + " mm" }, + { key: "has_filter", value: tipData.has_filter ? "Yes" : "No" }, + { key: "maximal_volume", value: (tipData.maximal_volume || "?") + " \u00B5L" }, + { key: "pickup_method", value: (tipData.pickup_method || "").replace(/_/g, " ") }, + ]; + if (tipStateData) { + attrs.push({ key: "volume", value: (tipStateData.volume || 0) + " / " + (tipStateData.max_volume || "?") + " \u00B5L" }); + attrs.push({ key: "origin", value: tipStateData.thing || "?" }); + } + return attrs; +} + +// Refresh the pipette info panel if one is open (called after state updates). +function refreshPipetteInfoPanel(headState) { + if (!_pipetteInfoState) return; + var existing = document.getElementById("pipette-info-panel"); + if (!existing) { _pipetteInfoState = null; return; } + var s = _pipetteInfoState; + var title, type, attrs; + if (s.kind === "channel") { + title = "Channel " + s.ch; + type = "PipetteChannel"; + attrs = buildChannelAttrs(s.ch, headState); + } else { + title = "Tip @ Channel " + s.ch; + type = "Tip"; + attrs = buildTipAttrs(s.ch, headState); + if (!attrs) { + // Tip was removed — close the panel + existing.remove(); + _pipetteInfoState = null; + return; + } + } + // No toggle — force show with updated data + existing.remove(); + _showPipetteInfoPanelInner(title, type, attrs, s.anchorDropdown); +} + +// Build a UML-style info panel for a pipette channel or tip, shown on click. +// `title` is the header name, `type` is the guillemet type label, +// `attrs` is an array of {key, value} pairs. +function showPipetteInfoPanel(title, type, attrs, anchorDropdown, ch, kind) { + var existing = document.getElementById("pipette-info-panel"); + if (existing) { + // Toggle off if clicking same thing + if (existing.dataset.key === title) { + existing.remove(); + _pipetteInfoState = null; + return; + } + existing.remove(); + } + _pipetteInfoState = { ch: ch, kind: kind, anchorDropdown: anchorDropdown }; + _showPipetteInfoPanelInner(title, type, attrs, anchorDropdown); +} + +function _showPipetteInfoPanelInner(title, type, attrs, anchorDropdown) { + + var panel = document.createElement("div"); + panel.className = "uml-panel"; + panel.id = "pipette-info-panel"; + panel.dataset.key = title; + + var closeBtn = document.createElement("button"); + closeBtn.className = "uml-close-btn"; + closeBtn.textContent = "\u00D7"; + closeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + panel.remove(); + }); + panel.appendChild(closeBtn); + + var header = document.createElement("div"); + header.className = "uml-header"; + var nameDiv = document.createElement("div"); + nameDiv.className = "uml-header-name"; + nameDiv.textContent = title; + var typeDiv = document.createElement("div"); + typeDiv.className = "uml-header-type"; + typeDiv.textContent = "\u00AB" + type + "\u00BB"; + header.appendChild(nameDiv); + header.appendChild(typeDiv); + panel.appendChild(header); + + var sep = document.createElement("div"); + sep.className = "uml-separator"; + panel.appendChild(sep); + + var section = document.createElement("div"); + section.className = "uml-section"; + var sTitle = document.createElement("div"); + sTitle.className = "uml-section-title"; + sTitle.textContent = "Attributes"; + section.appendChild(sTitle); + for (var i = 0; i < attrs.length; i++) { + var row = document.createElement("div"); + row.className = "uml-row"; + var keySpan = document.createElement("span"); + keySpan.className = "uml-key"; + keySpan.textContent = attrs[i].key + ":"; + var valSpan = document.createElement("span"); + valSpan.className = "uml-value"; + valSpan.textContent = " " + attrs[i].value; + row.appendChild(keySpan); + row.appendChild(valSpan); + section.appendChild(row); + } + panel.appendChild(section); + + var mainEl = document.querySelector("main"); + if (!mainEl) return; + mainEl.appendChild(panel); + + // Position to the left of the single-channel dropdown + if (anchorDropdown) { + var mainRect = mainEl.getBoundingClientRect(); + var ddRect = anchorDropdown.getBoundingClientRect(); + var panelW = panel.offsetWidth; + panel.style.top = (ddRect.top - mainRect.top) + "px"; + panel.style.right = "auto"; + panel.style.left = (ddRect.right - mainRect.left + 8) + "px"; + } +} + +function fillHeadIcons(panel, headState) { + panel.innerHTML = ""; + // Fixed height: pipette (27) + max tip (80mm * 0.8 = 64px) + var maxTipPx = 64; // 80mm max tip + var fixedSvgH = 27 + maxTipPx; + var channels = Object.keys(headState).sort(function (a, b) { return +a - +b; }); + for (var ci = 0; ci < channels.length; ci++) { + var ch = channels[ci]; + var tipData = headState[ch] && headState[ch].tip; + var hasTip = tipData !== null && tipData !== undefined; + // Scale tip length: total_tip_length in mm, map to px (0.8 px/mm, clamp 10mm–80mm) + var tipLenPx = 0; + if (hasTip && tipData.total_tip_length) { + var clampedMm = Math.max(10, Math.min(80, tipData.total_tip_length)); + tipLenPx = clampedMm * 0.8; + } + var col = document.createElement("div"); + col.style.display = "flex"; + col.style.flexDirection = "column"; + col.style.alignItems = "center"; + col.style.position = "relative"; + // Label + channel cylinder: clickable with hover glow + var label = document.createElement("span"); + label.textContent = ch; + label.style.fontSize = "15px"; + label.style.fontWeight = "700"; + label.style.color = "#888"; + label.style.marginBottom = "2px"; + label.style.cursor = "pointer"; + label.title = "Channel " + ch + " — click for details"; + (function (ch) { + label.addEventListener("click", function (e) { + e.stopPropagation(); + showPipetteInfoPanel("Channel " + ch, "PipetteChannel", buildChannelAttrs(ch, headState), panel, ch, "channel"); + }); + })(ch); + col.appendChild(label); + var icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("width", "14"); + icon.setAttribute("height", String(fixedSvgH)); + icon.setAttribute("viewBox", "0 0 14 " + fixedSvgH); + icon.style.overflow = "visible"; + icon.style.display = "block"; + // Glow filters for hover + var defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + defs.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + icon.appendChild(defs); + // Channel shapes (black cylinder + silver cylinder) — hover glow + click + var channelG = document.createElementNS("http://www.w3.org/2000/svg", "g"); + channelG.style.cursor = "pointer"; + (function (idx, ch) { + channelG.addEventListener("mouseenter", function () { this.setAttribute("filter", "url(#chGlow" + idx + ")"); }); + channelG.addEventListener("mouseleave", function () { this.removeAttribute("filter"); }); + channelG.addEventListener("click", function (e) { + e.stopPropagation(); + showPipetteInfoPanel("Channel " + ch, "PipetteChannel", buildChannelAttrs(ch, headState), panel, ch, "channel"); + }); + })(ci, ch); + channelG.innerHTML = + '' + + '' + + '' + + '' + + '' + + ''; + var channelTitle = document.createElementNS("http://www.w3.org/2000/svg", "title"); + channelTitle.textContent = "Channel " + ch + " — click for details"; + channelG.appendChild(channelTitle); + icon.appendChild(channelG); + if (hasTip) { + var collarH = 6.5; + var collarY = 19.5; + var bodyStart = collarY + collarH; + var straightH = Math.round(tipLenPx * 0.4); + var taperH = tipLenPx - straightH; + var straightEnd = bodyStart + straightH; + var tipEnd = straightEnd + taperH; + var tipG = document.createElementNS("http://www.w3.org/2000/svg", "g"); + tipG.style.cursor = "pointer"; + (function (idx) { + tipG.addEventListener("mouseenter", function () { this.setAttribute("filter", "url(#tipGlow" + idx + ")"); }); + tipG.addEventListener("mouseleave", function () { this.removeAttribute("filter"); }); + })(ci); + var botW = (tipData.total_tip_length > 50) ? 2 : 1; + var botL = 7 - botW / 2; + var botR = 7 + botW / 2; + var tipShapes = + '' + + '' + + ''; + // Volume fill overlay + var fillSvg = ""; + var chTipState = (headState[ch] || {}).tip_state; + var fillRatio = 0; + if (chTipState && chTipState.max_volume > 0) { + fillRatio = Math.min(1, (chTipState.volume || 0) / chTipState.max_volume); + } + if (fillRatio > 0) { + var totalFillableH = straightH + taperH; + var fillH = fillRatio * totalFillableH; + var fillColor = "rgba(0,119,187,0.45)"; + if (fillH <= taperH) { + var widthAtFill = botW + (8 - botW) * (fillH / taperH); + var leftX = 7 - widthAtFill / 2; + var rightX = 7 + widthAtFill / 2; + var fillTop = tipEnd - fillH; + fillSvg = ''; + } else { + var bodyFillH = fillH - taperH; + var bodyFillY = straightEnd - bodyFillH; + fillSvg = + '' + + ''; + } + } + tipG.innerHTML = tipShapes + fillSvg; + var tipTitle = document.createElementNS("http://www.w3.org/2000/svg", "title"); + tipTitle.textContent = "Tip on channel " + ch + " — click for details"; + tipG.appendChild(tipTitle); + (function (ch) { + tipG.addEventListener("click", function (e) { + e.stopPropagation(); + showPipetteInfoPanel("Tip @ Channel " + ch, "Tip", buildTipAttrs(ch, headState), panel, ch, "tip"); + }); + })(ch); + icon.appendChild(tipG); + } + // Pending tip: pulsing blurred overlay + var chStateObj = headState[ch] || {}; + var pendingTip = chStateObj.pending_tip; + var currentTip = chStateObj.tip; + var isPending = (pendingTip !== null && pendingTip !== undefined) !== (currentTip !== null && currentTip !== undefined); + if (isPending) { + var pendingFilter = document.createElementNS("http://www.w3.org/2000/svg", "filter"); + pendingFilter.setAttribute("id", "pendingGlow" + ci); + pendingFilter.setAttribute("x", "-100%"); + pendingFilter.setAttribute("y", "-100%"); + pendingFilter.setAttribute("width", "300%"); + pendingFilter.setAttribute("height", "300%"); + pendingFilter.innerHTML = ''; + defs.appendChild(pendingFilter); + var pendingRect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + pendingRect.setAttribute("x", "0"); + pendingRect.setAttribute("y", "1"); + pendingRect.setAttribute("width", "14"); + pendingRect.setAttribute("height", String(fixedSvgH - 1)); + pendingRect.setAttribute("rx", "3"); + pendingRect.setAttribute("fill", "#EE8866"); + pendingRect.setAttribute("filter", "url(#pendingGlow" + ci + ")"); + pendingRect.style.pointerEvents = "none"; + pendingRect.style.animation = "pendingPulse 1.5s ease-in-out infinite"; + icon.appendChild(pendingRect); + } + col.appendChild(icon); + panel.appendChild(col); + } +} + +function head96PosId(ch, startCh) { + var local = ch - startCh; + var row = local % 8; + var col = Math.floor(local / 8); + return String.fromCharCode(65 + row) + (col + 1); +} + +function fillHead96Grid(panel, head96State) { + panel.innerHTML = ""; + if (!head96State || Object.keys(head96State).length === 0) { + // Set panel dimensions to match a normal 96-head grid, then center message + panel.style.minWidth = "180px"; + panel.style.minHeight = "130px"; + panel.style.alignItems = "center"; + panel.style.justifyContent = "center"; + var msg = document.createElement("span"); + msg.style.color = "#888"; + msg.style.fontSize = "13px"; + msg.style.fontWeight = "500"; + msg.style.textAlign = "center"; + msg.style.padding = "16px"; + msg.textContent = "No multi-channel pipette is installed on this liquid handler."; + panel.appendChild(msg); + return; + } + // Split channels into groups of 96 (one grid per multi-channel pipette) + var allChannels = Object.keys(head96State).sort(function (a, b) { return +a - +b; }); + var numPipettes = Math.max(1, Math.ceil(allChannels.length / 96)); + // Compute dot size to fit within panel height + var panelH = parseFloat(panel.style.height) || 0; + var availH = panelH > 0 ? panelH - 32 - 12 - 3 : 100; + // 8 rows with gaps: 8d + 7*0.25d = 9.75d = availH + var dotSize = Math.max(6, Math.min(14, Math.floor(availH / 9.75))); + var gapSize = Math.max(1, Math.round(dotSize * 0.25)); + for (var p = 0; p < numPipettes; p++) { + var startCh = p * 96; + var box = document.createElement("div"); + box.style.border = "1.5px solid #555"; + box.style.borderRadius = "6px"; + box.style.padding = "6px"; + box.style.background = "#444"; + box.style.cursor = "pointer"; + box.title = "96-head pipette — click for details"; + box.onmouseover = function () { box.style.boxShadow = "0 0 8px 3px rgba(68, 187, 153, 0.5), 0 0 20px 6px rgba(68, 187, 153, 0.25)"; }; + box.onmouseout = function () { box.style.boxShadow = "none"; }; + (function (startCh, head96State) { + box.addEventListener("click", function (e) { + var tipCount = 0; + for (var i = startCh; i < startCh + 96; i++) { + var s = head96State[String(i)] || head96State[i]; + if (s && s.tip !== null && s.tip !== undefined) tipCount++; + } + var attrs = [ + { key: "channels", value: "96" }, + { key: "tips_loaded", value: tipCount + " / 96" }, + ]; + showPipetteInfoPanel("96-Head Pipette", "CoRe96Head", attrs, panel, String(startCh), "channel"); + }); + })(startCh, head96State); + box.style.display = "inline-flex"; + box.style.flexDirection = "column"; + box.style.alignItems = "center"; + var grid = document.createElement("div"); + grid.style.display = "grid"; + grid.style.gridTemplateColumns = "repeat(12, " + dotSize + "px)"; + grid.style.gridTemplateRows = "repeat(8, " + dotSize + "px)"; + grid.style.gap = gapSize + "px"; + // 8 rows x 12 cols, column-major within each 96-group + for (var row = 0; row < 8; row++) { + for (var col = 0; col < 12; col++) { + var ch = startCh + col * 8 + row; + var chState = head96State[String(ch)] || head96State[ch]; + var hasTip = chState && chState.tip !== null && chState.tip !== undefined; + var dot = document.createElement("div"); + dot.style.width = dotSize + "px"; + dot.style.height = dotSize + "px"; + dot.style.borderRadius = "50%"; + dot.style.border = "1.5px solid " + (hasTip ? "#40CDA1" : "#555"); + dot.style.background = hasTip ? "#40CDA1" : "white"; + var posId = head96PosId(ch, startCh); + dot.title = "Channel " + ch + " / " + posId + (hasTip ? " (tip)" : ""); + if (hasTip) { + dot.style.cursor = "pointer"; + dot.onmouseover = function () { this.style.boxShadow = "0 0 6px 2px rgba(238, 221, 136, 0.6), 0 0 14px 4px rgba(238, 221, 136, 0.3)"; }; + dot.onmouseout = function () { this.style.boxShadow = "none"; }; + (function (ch, posId) { + dot.addEventListener("click", function (e) { + e.stopPropagation(); + var tipAttrs = buildTipAttrs(String(ch), head96State); + if (tipAttrs) { + showPipetteInfoPanel("Tip @ Channel " + ch + " / " + posId, "Tip", tipAttrs, panel, String(ch), "tip"); + } + }); + })(ch, posId); + } else { + (function (ch, posId) { + dot.style.cursor = "pointer"; + dot.addEventListener("click", function (e) { + e.stopPropagation(); + showPipetteInfoPanel("Channel " + ch + " / " + posId, "PipetteChannel", buildChannelAttrs(String(ch), head96State), panel, String(ch), "channel"); + }); + })(ch, posId); + } + grid.appendChild(dot); + } + } + // Bars outside the grid box, 60% of the corresponding dimension, centered + var gridW = 12 * dotSize + 11 * gapSize; + var gridH = 8 * dotSize + 7 * gapSize; + var hBarW = Math.round(gridW * 0.6); + var vBarH = Math.round(gridH * 0.6); + function makeHBar() { + var bar = document.createElement("div"); + bar.style.width = hBarW + "px"; + bar.style.height = "4px"; + bar.style.background = "#444"; + return bar; + } + function makeVBar() { + var bar = document.createElement("div"); + bar.style.width = "4px"; + bar.style.height = vBarH + "px"; + bar.style.background = "#444"; + return bar; + } + + // Row: left bar + box + right bar + box.appendChild(grid); + var midRow = document.createElement("div"); + midRow.style.display = "flex"; + midRow.style.flexDirection = "row"; + midRow.style.alignItems = "center"; + midRow.appendChild(makeVBar()); + midRow.appendChild(box); + midRow.appendChild(makeVBar()); + + // Column: top bar + midRow + bottom bar + var boxWrap = document.createElement("div"); + boxWrap.style.display = "flex"; + boxWrap.style.flexDirection = "column"; + boxWrap.style.alignItems = "center"; + boxWrap.appendChild(makeHBar()); + boxWrap.appendChild(midRow); + boxWrap.appendChild(makeHBar()); + + // Pipette index label (only when multiple 96-head pipettes) + if (numPipettes > 1) { + var wrapper = document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + wrapper.style.alignItems = "center"; + var idLabel = document.createElement("span"); + idLabel.textContent = String(p); + idLabel.style.fontSize = "15px"; + idLabel.style.fontWeight = "700"; + idLabel.style.color = "#888"; + idLabel.style.marginBottom = "2px"; + wrapper.appendChild(idLabel); + wrapper.appendChild(boxWrap); + panel.appendChild(wrapper); + } else { + panel.appendChild(boxWrap); + } + } +} + +function buildSingleArm(armData, anchorDropdown, armId) { + // Build one gripper visualization column + var hasResource = armData !== null && armData !== undefined; + var col = document.createElement("div"); + col.style.display = "flex"; + col.style.flexDirection = "column"; + col.style.alignItems = "center"; + col.style.justifyContent = "center"; + + // Compute scaled plate dimensions for gripper sizing. + // The serialized resource data (saved before destruction in resourceSnapshots) is used + // to re-instantiate the resource and draw it on a live Konva stage inside the arm panel, + // using the exact same draw() code as the main canvas. + var plateW = 52, plateH = 22; + var snapshot = null; // serialized resource data, or null + if (hasResource) { + snapshot = resourceSnapshots[armData.resource_name] || null; + var sizeX = snapshot ? snapshot.size_x : (armData.size_x || 127); + var sizeY = snapshot ? snapshot.size_y : (armData.size_y || 86); + var scale = Math.min(80 / sizeX, 80 / sizeY); + plateW = Math.round(sizeX * scale); + plateH = Math.round(sizeY * scale); + } + + // Carriage uses a fixed "closed" gap regardless of plate presence. + // Fingers spread outward to accommodate a held plate. + // SVG is always wide enough for a standard plate (127×86 mm) so the popup + // does not resize when a plate is picked up or dropped. + var stdPlateW = Math.round(127 * Math.min(80 / 127, 80 / 86)); // ≈80px + var minFingerGap = Math.round((stdPlateW + 16) * 1.1); // ≈106px + var closedGap = Math.round((52 + 16) * 1.1); // default closed spacing + var fingerGap = hasResource ? Math.round((plateW + 16) * 1.1) : closedGap; + var svgW = Math.max(closedGap, fingerGap, minFingerGap) + 28; // 14px margin each side (room for outer guide bars) + var svgH = 110; + var cx = svgW / 2; // centre x + + // Finger (rail) positions spread based on plate size + var lRailX = cx - fingerGap / 2 - 7; // left rail, offset outward from center + var rRailX = cx + fingerGap / 2 - 1; // right rail, offset outward from center + + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", String(svgW)); + svg.setAttribute("height", String(svgH)); + svg.setAttribute("viewBox", "0 0 " + svgW + " " + svgH); + svg.style.overflow = "visible"; + var defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + defs.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + ''; + svg.appendChild(defs); + var shapes = ""; + + // Horizontal guide bars — drawn first (behind rails), extending inward from each finger + var barH = 5.1, barW = 12, barY = 10; // aligned with top of rails + shapes += ''; + shapes += ''; + + // Draw rails after guide bars (painter's order) + // Left rail (7px wide, 90% of original 8px) + shapes += ''; + // Right rail + shapes += ''; + + // Top carriage block (grey, wide) — fixed size, drawn after rails so it covers them + var carriageW = closedGap + 8; + var carriageX = cx - carriageW / 2; + shapes += ''; + // Darker top strip on carriage + shapes += ''; + // Centre mounting post + shapes += ''; + + // Cushion geometry: pad y=74, height=22 → center at y=85 + var cushY = 74, cushH = 22, pinH = 2.4; + var cushCenterY = cushY + cushH / 2; // 85 + var pinOffset = 5; // distance from center to pin center + var pinTopY = cushCenterY - pinOffset - pinH / 2; // 78.8 + var pinBotY = cushCenterY + pinOffset - pinH / 2; // 88.8 + + // Left finger cushion — pins drawn first (behind), then vertical pad on top + var lCushX = lRailX + 7 + 2; // rail width + gap + shapes += ''; + shapes += ''; + shapes += ''; + + // Right finger cushion — pins drawn first (behind), then vertical pad on top + var rCushX = rRailX + 1 - 2 - 4; // rail left edge - gap - cushion width + shapes += ''; + shapes += ''; + shapes += ''; + + var gripperG = document.createElementNS("http://www.w3.org/2000/svg", "g"); + gripperG.innerHTML = shapes; + gripperG.style.cursor = "pointer"; + var gripperTitle = document.createElementNS("http://www.w3.org/2000/svg", "title"); + gripperTitle.textContent = "Arm " + armId + (hasResource ? " — holding " + armData.resource_name : " — empty") + " — click for details"; + gripperG.appendChild(gripperTitle); + gripperG.addEventListener("mouseenter", function () { gripperG.setAttribute("filter", "url(#armGlow)"); }); + gripperG.addEventListener("mouseleave", function () { gripperG.removeAttribute("filter"); }); + gripperG.addEventListener("click", function (e) { + e.stopPropagation(); + var attrs = [{ key: "arm", value: armId }]; + attrs.push({ key: "has_resource", value: hasResource ? "true" : "false" }); + if (hasResource) { + attrs.push({ key: "resource_name", value: armData.resource_name }); + attrs.push({ key: "resource_type", value: armData.resource_type || "Unknown" }); + attrs.push({ key: "direction", value: armData.direction || "?" }); + attrs.push({ key: "pickup_distance_from_top", value: (armData.pickup_distance_from_top || 0) + " mm" }); + attrs.push({ key: "size", value: (armData.size_x || "?") + " × " + (armData.size_y || "?") + " × " + (armData.size_z || "?") + " mm" }); + if (armData.num_items_x) attrs.push({ key: "wells", value: (armData.num_items_x * (armData.num_items_y || 1)) }); + } + showPipetteInfoPanel("Arm " + armId, "IntegratedArm", attrs, anchorDropdown, armId, "channel"); + }); + svg.appendChild(gripperG); + // Wrap the SVG and plate in a positioned container. + var svgContainer = document.createElement("div"); + svgContainer.style.position = "relative"; + svgContainer.style.width = svgW + "px"; + svgContainer.style.height = svgH + "px"; + svgContainer.appendChild(svg); + + if (hasResource && snapshot) { + // Render the plate using the exact same Konva draw() code as the main canvas. + // The serialized resource data (saved before destruction) is re-instantiated via + // loadResource() and drawn on a temporary DOM-attached Konva stage. The result is + // exported as a PNG data URL and displayed as an overlay on the gripper SVG. + // Konva requires its container to be in the DOM to render, so we use a hidden div + // attached to document.body, then clean up after export. + // Cost: one temporary Konva stage + ~97 nodes for a 96-well plate, created and + // destroyed each time the arm panel updates. + var plateX = cx - plateW / 2; + var plateY = 85 - plateH / 2; + try { + var realW = Math.ceil(snapshot.size_x); + var realH = Math.ceil(snapshot.size_y); + // Create a hidden div in the DOM for Konva to render into + var tmpDiv = document.createElement("div"); + tmpDiv.style.position = "fixed"; + tmpDiv.style.left = "-9999px"; + tmpDiv.style.top = "-9999px"; + document.body.appendChild(tmpDiv); + var plateStage = new Konva.Stage({ container: tmpDiv, width: realW, height: realH }); + var plateLayer = new Konva.Layer(); + plateStage.add(plateLayer); + // Re-instantiate the resource from saved serialized data and draw it. + // Temporarily save/restore global resources to avoid conflicts. + var savedRes = {}; + var snapshotData = JSON.parse(JSON.stringify(snapshot)); + snapshotData.parent_name = undefined; + snapshotData.location = { x: 0, y: 0, z: 0, type: "Coordinate" }; + function _saveKey(n) { if (n in resources) savedRes[n] = resources[n]; } + _saveKey(snapshotData.name); + for (var si = 0; si < (snapshotData.children || []).length; si++) { + _saveKey(snapshotData.children[si].name); + } + var plateCopy = loadResource(snapshotData); + plateCopy.draw(plateLayer); + plateLayer.draw(); + // Export to data URL + var plateDataUrl = plateStage.toDataURL({ pixelRatio: 2 }); + // Clean up: restore resources, destroy offscreen stage + delete resources[plateCopy.name]; + for (var ci2 = 0; ci2 < plateCopy.children.length; ci2++) { + delete resources[plateCopy.children[ci2].name]; + } + for (var rk in savedRes) { resources[rk] = savedRes[rk]; } + plateStage.destroy(); + document.body.removeChild(tmpDiv); + // Display as an overlay + var plateImg = document.createElement("img"); + plateImg.src = plateDataUrl; + plateImg.style.position = "absolute"; + plateImg.style.left = plateX + "px"; + plateImg.style.top = plateY + "px"; + plateImg.style.width = plateW + "px"; + plateImg.style.height = plateH + "px"; + plateImg.style.pointerEvents = "none"; + svgContainer.appendChild(plateImg); + } catch (e) { + console.warn("[arm plate render] failed:", e); + } + } else if (hasResource) { + // Fallback: simple colored rectangle when no serialized data is available + var plateX2 = cx - plateW / 2; + var plateY2 = 85 - plateH / 2; + var fallbackColor = RESOURCE_COLORS[armData.resource_type] || RESOURCE_COLORS["Resource"]; + var fallbackSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + fallbackSvg.setAttribute("width", String(svgW)); + fallbackSvg.setAttribute("height", String(svgH)); + fallbackSvg.setAttribute("viewBox", "0 0 " + svgW + " " + svgH); + fallbackSvg.style.position = "absolute"; + fallbackSvg.style.left = "0"; + fallbackSvg.style.top = "0"; + fallbackSvg.style.pointerEvents = "none"; + fallbackSvg.innerHTML = ''; + svgContainer.appendChild(fallbackSvg); + } + + svgContainer.style.cursor = "pointer"; + col.appendChild(svgContainer); + var label = document.createElement("div"); + label.style.fontSize = "11px"; + label.style.fontWeight = "600"; + label.style.color = "#666"; + label.style.marginTop = "4px"; + label.style.textAlign = "center"; + if (hasResource) { + label.textContent = armData.resource_name + " (" + armData.resource_type + ")"; + } else { + label.textContent = "No resource held"; + } + col.appendChild(label); + return col; +} + +function fillArmPanel(panel, armState) { + panel.innerHTML = ""; + if (!armState || Object.keys(armState).length === 0) { + // Set panel dimensions to match a normal arm panel, then center message + var stdW = Math.round((Math.round(127 * Math.min(80 / 127, 80 / 86)) + 16) * 1.1) + 28; + panel.style.minWidth = stdW + "px"; + panel.style.minHeight = "130px"; + panel.style.alignItems = "center"; + panel.style.justifyContent = "center"; + var msg = document.createElement("span"); + msg.style.color = "#888"; + msg.style.fontSize = "13px"; + msg.style.fontWeight = "500"; + msg.style.textAlign = "center"; + msg.style.padding = "16px"; + msg.textContent = "No robotic arm is installed on this liquid handler."; + panel.appendChild(msg); + return; + } + var arms = Object.keys(armState).sort(function (a, b) { return +a - +b; }); + for (var i = 0; i < arms.length; i++) { + var armId = arms[i]; + var wrapper = document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + wrapper.style.alignItems = "center"; + // Arm index label (only when multiple arms) + if (arms.length > 1) { + var idLabel = document.createElement("span"); + idLabel.textContent = armId; + idLabel.style.fontSize = "15px"; + idLabel.style.fontWeight = "700"; + idLabel.style.color = "#888"; + idLabel.style.marginBottom = "2px"; + idLabel.title = "Arm " + armId + " — click gripper for details"; + wrapper.appendChild(idLabel); + } + wrapper.appendChild(buildSingleArm(armState[armId], panel, armId)); + panel.appendChild(wrapper); + } +} + class LiquidHandler extends Resource { + constructor(resource) { + super(resource); + this.numHeads = 0; + this.headState = {}; + this.head96State = {}; + this.armState = {}; + } + drawMainShape() { return undefined; // just draw the children (deck and so on) } + + setState(state) { + if (state.head_state) { + this.headState = state.head_state; + this.numHeads = Object.keys(state.head_state).length; + var panel = document.getElementById("single-channel-dropdown-" + this.name); + if (panel) { + fillHeadIcons(panel, this.headState); + refreshPipetteInfoPanel(this.headState); + } + } + if ("head96_state" in state) { + this.head96State = state.head96_state; + // Show/hide multi-channel button based on whether the actuator exists + var multiBtn = document.getElementById("multi-channel-btn-" + this.name); + if (multiBtn) multiBtn.style.display = (this.head96State !== null && this.head96State !== undefined) ? "" : "none"; + var panel96 = document.getElementById("multi-channel-dropdown-" + this.name); + if (panel96) { + fillHead96Grid(panel96, this.head96State); + } + } + if ("arm_state" in state) { + this.armState = state.arm_state; + // Show/hide arm button based on whether the actuator exists + var armBtnEl = document.getElementById("arm-btn-" + this.name); + if (armBtnEl) armBtnEl.style.display = (this.armState !== null && this.armState !== undefined) ? "" : "none"; + // Snapshot each held resource NOW, while it is still in the resources dict. + // pick_up_resource() sends set_state BEFORE resource_unassigned fires, so + // the resource and all its children are still intact at this point. + for (var armKey in (this.armState || {})) { + var ad = this.armState[armKey]; + if (ad && ad.resource_name && !resourceSnapshots[ad.resource_name]) { + var res = resources[ad.resource_name]; + if (res) { + try { + resourceSnapshots[ad.resource_name] = res.serialize(); + } catch (e) { + console.warn("[arm snapshot] failed for " + ad.resource_name, e); + } + } + } + } + var armPanel = document.getElementById("arm-dropdown-" + this.name); + if (armPanel) { + fillArmPanel(armPanel, this.armState); + } + } + } } // =========================================================================== // Utility for mapping resource type strings to classes // =========================================================================== -function classForResourceType(type) { +function classForResourceType(type, category) { switch (type) { case "Deck": return Deck; @@ -1065,13 +2521,53 @@ function classForResourceType(type) { return TubeRack; case "Tube": return Tube; + default: + break; + } + + // Fall back to category for unrecognized type names (e.g. concrete carrier subclasses). + switch (category) { + case "tip_carrier": + return TipCarrier; + case "plate_carrier": + return PlateCarrier; + case "trough_carrier": + return TroughCarrier; + case "tube_carrier": + return TubeCarrier; + case "carrier": + case "mfx_carrier": + return Carrier; + case "deck": + return Deck; + case "liquid_handler": + return LiquidHandler; + case "tip_rack": + return TipRack; + case "plate": + return Plate; + case "well": + return Well; + case "tip_spot": + return TipSpot; + case "resource_holder": + return ResourceHolder; + case "plate_holder": + return PlateHolder; + case "tube_rack": + return TubeRack; + case "tube": + return Tube; + case "container": + case "trough": + return Container; default: return Resource; } } function loadResource(resourceData) { - const resourceClass = classForResourceType(resourceData.type); + const resourceClass = classForResourceType(resourceData.type, resourceData.category); const parentName = resourceData.parent_name; var parent = undefined; @@ -1082,6 +2578,12 @@ function loadResource(resourceData) { const resource = new resourceClass(resourceData, parent); resources[resource.name] = resource; + // If the resource has a parent, ensure it's registered in the parent's children list. + // The constructor sets this.parent but doesn't add to parent.children. + if (parent && !parent.children.includes(resource)) { + parent.assignChild(resource); + } + return resource; } @@ -1103,29 +2605,12 @@ window.addEventListener("load", function () { stage.scaleY(-1); stage.offsetY(canvasHeight); - let minX = -(1 / 2) * canvasWidth; - let minY = -(1 / 2) * canvasHeight; - let maxX = (1 / 2) * canvasWidth; - let maxY = (1 / 2) * canvasHeight; - - // limit draggable area to size of canvas - stage.dragBoundFunc(function (pos) { - // Set the bounds of the draggable area to 1/2 off the canvas. - let newX = Math.max(minX, Math.min(maxX, pos.x)); - let newY = Math.max(minY, Math.min(maxY, pos.y)); - - return { - x: newX, - y: newY, - }; - }); - - // add white background + // add white background (large enough to cover any pan position) var background = new Konva.Rect({ - x: minX, - y: minY, - width: canvasWidth - minX + maxX, - height: canvasHeight - minY + maxY, + x: -5000, + y: -5000, + width: 10000, + height: 10000, fill: "white", listening: false, }); @@ -1136,6 +2621,112 @@ window.addEventListener("load", function () { layer.add(background); + // Scale bar update: picks a round mm value that fits ~80-120px on screen. + function updateScaleBar() { + const scale = stage.scaleX(); // CSS pixels per mm + // Choose a nice round distance whose bar width falls near 100px + const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]; + let bestMM = niceSteps[0]; + for (let i = 0; i < niceSteps.length; i++) { + if (niceSteps[i] * scale >= 60) { + bestMM = niceSteps[i]; + break; + } + bestMM = niceSteps[i]; + } + const barPx = bestMM * scale; + const barLine = document.getElementById("scale-bar-line"); + const barLabel = document.getElementById("scale-bar-label"); + if (barLine) barLine.style.width = barPx + "px"; + if (barLabel) barLabel.textContent = bestMM + " mm"; + } + + // Mouse wheel zoom + const scaleBy = 1.1; + stage.on("wheel", function (e) { + e.evt.preventDefault(); + const oldScale = stage.scaleX(); + const pointer = stage.getPointerPosition(); + + // scaleY is negative (flipped), so use absolute value for uniform zoom + const mousePointTo = { + x: (pointer.x - stage.x()) / oldScale, + y: (pointer.y - stage.y()) / stage.scaleY(), + }; + + const direction = e.evt.deltaY > 0 ? -1 : 1; + const newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; + + // Clamp zoom level + const clampedScale = Math.max(0.1, Math.min(30, newScale)); + + stage.scaleX(clampedScale); + stage.scaleY(-clampedScale); // keep Y flipped + + const newPos = { + x: pointer.x - mousePointTo.x * clampedScale, + y: pointer.y - mousePointTo.y * (-clampedScale), + }; + stage.position(newPos); + updateScaleBar(); + updateBullseyeScale(); + updateWrtBullseyeScale(); + updateTooltipScale(); + updateDeltaLinesScale(); + }); + + updateScaleBar(); + + // Keep Konva stage sized to its container when the layout changes + // (e.g. sidebar expand/collapse/resize, window resize). + const resizeObserver = new ResizeObserver(function () { + const newWidth = canvas.offsetWidth; + const newHeight = canvas.offsetHeight; + if (newWidth > 0 && newHeight > 0) { + stage.width(newWidth); + stage.height(newHeight); + stage.offsetY(newHeight); + } + }); + resizeObserver.observe(canvas); + + // Home button: reset view to initial position and zoom + const homeBtn = document.getElementById("home-button"); + if (homeBtn) { + homeBtn.addEventListener("click", function () { + fitToViewport(); + homeBtn.classList.add("clicked"); + setTimeout(function () { homeBtn.classList.remove("clicked"); }, 400); + }); + } + + // Zoom buttons + function zoomByFactor(factor) { + var oldScale = stage.scaleX(); + var newScale = Math.max(0.1, Math.min(15, oldScale * factor)); + var center = { x: stage.width() / 2, y: stage.height() / 2 }; + var mousePointTo = { + x: (center.x - stage.x()) / oldScale, + y: (center.y - stage.y()) / stage.scaleY(), + }; + stage.scaleX(newScale); + stage.scaleY(-newScale); + stage.position({ + x: center.x - mousePointTo.x * newScale, + y: center.y - mousePointTo.y * (-newScale), + }); + if (typeof updateScaleBar === "function") updateScaleBar(); + updateBullseyeScale(); + updateWrtBullseyeScale(); + updateTooltipScale(); + updateDeltaLinesScale(); + } + + var zoomInBtn = document.getElementById("zoom-in-btn"); + var zoomOutBtn = document.getElementById("zoom-out-btn"); + if (zoomInBtn) zoomInBtn.addEventListener("click", function () { zoomByFactor(1.2); }); + if (zoomOutBtn) zoomOutBtn.addEventListener("click", function () { zoomByFactor(1 / 1.2); }); + // Check if there is an after stage setup callback, and if so, call it. if (typeof afterStageSetup === "function") { afterStageSetup(); @@ -1176,7 +2767,10 @@ async function startRecording() { var info = document.getElementById("progressBar"); info.innerText = " GIF Rendering Progress: " + Math.round(0 * 100) + "%"; - stageToBlob(stage, handleBlob); + // Start sequential capture loop: capture a frame, wait for the interval, + // then capture the next. This guarantees every tick produces a frame + // even when html2canvas is slower than the interval. + _captureLoop(); gifResetUI(); gifShowRecordingUI(); @@ -1188,24 +2782,24 @@ function stopRecording() { // Turn recording off isRecording = false; - - // Render the final image - // Do it twice bc it looks better - - stageToBlob(stage, handleBlob); - stageToBlob(stage, handleBlob); - - gif = new GIF({ - workers: 10, - workerScript: "gif.worker.js", - background: "#FFFFFF", - width: stage.width(), - height: stage.height(), - }); + if (_recordingTimer) { clearTimeout(_recordingTimer); _recordingTimer = null; } + + // Wait for any in-flight capture to finish before building GIF + setTimeout(function () { + // Use dimensions from the first captured frame (includes overflow) + var gifW = frameImages.length > 0 ? frameImages[0].width : stage.width(); + var gifH = frameImages.length > 0 ? frameImages[0].height : stage.height(); + gif = new GIF({ + workers: 10, + workerScript: "gif.worker.js", + background: "#FFFFFF", + width: gifW, + height: gifH, + }); // Add each frame to the GIF for (var i = 0; i < frameImages.length; i++) { - gif.addFrame(frameImages[i], { delay: 1 }); + gif.addFrame(frameImages[i], { delay: Math.max(200, frameInterval * 50) }); } // Add progress bar based on how much the gif is rendered @@ -1222,32 +2816,59 @@ function stopRecording() { gifShowStartUI(); }); - gif.render(); + gif.render(); + }, 1500); } -// convert stage to a blob and handle the blob -function stageToBlob(stage, callback) { - stage.toBlob({ - callback: callback, - mimeType: "image/jpg", - quality: 0.3, +// Capture the entire
element (canvas + overlays) as a frame +// Sequential capture loop: capture one frame, wait for the remaining interval +// time, then schedule the next. Every tick produces exactly one frame. +function _captureLoop() { + if (!isRecording) return; + var intervalMs = Math.max(200, frameInterval * 50); + var startTime = Date.now(); + var mainEl = document.querySelector("main"); + if (!mainEl) return; + // Compute capture size including any rightward/downward overflow + var captureW = mainEl.offsetWidth; + var captureH = mainEl.offsetHeight; + var mainRect = mainEl.getBoundingClientRect(); + var overflows = mainEl.querySelectorAll(".actuator-dropdown.open, .tool-panel, .uml-panel"); + overflows.forEach(function (el) { + var r = el.getBoundingClientRect(); + var right = r.right - mainRect.left; + var bottom = r.bottom - mainRect.top; + if (right > captureW) captureW = Math.ceil(right); + if (bottom > captureH) captureH = Math.ceil(bottom); + }); + html2canvas(mainEl, { + backgroundColor: "#FFFFFF", + scale: 1, + useCORS: true, + logging: false, + width: captureW, + height: captureH, + }).then(function (canvas) { + canvas.toBlob(function (blob) { + if (blob) { + var url = URL.createObjectURL(blob); + var myImg = new Image(); + myImg.src = url; + myImg.width = canvas.width; + myImg.height = canvas.height; + frameImages.push(myImg); + myImg.onload = function () { URL.revokeObjectURL(url); }; + } + // Wait for the remaining interval time before next capture + var elapsed = Date.now() - startTime; + var waitMs = Math.max(0, intervalMs - elapsed); + _recordingTimer = setTimeout(_captureLoop, waitMs); + }, "image/jpeg", 0.3); + }).catch(function () { + var elapsed = Date.now() - startTime; + var waitMs = Math.max(0, intervalMs - elapsed); + _recordingTimer = setTimeout(_captureLoop, waitMs); }); -} - -// handle the blob (e.g., create an Image element and add it to frameImages) -function handleBlob(blob) { - const url = URL.createObjectURL(blob); - const myImg = new Image(); - - myImg.src = url; - myImg.width = stage.width(); - myImg.height = stage.height(); - - frameImages.push(myImg); - - myImg.onload = function () { - URL.revokeObjectURL(url); // Free up memory - }; } // Set up event listeners for the buttons @@ -1293,12 +2914,1854 @@ document this.value = value; // Update the slider value document.getElementById("current-value").textContent = - "Frame Save Interval: " + value; + "Frame Interval: " + value; frameInterval = value; + // New interval takes effect on the next iteration of _captureLoop automatically }); window.addEventListener("load", function () { gifResetUI(); gifShowStartUI(); }); + +// =========================================================================== +// Sidepanel Resource Tree +// =========================================================================== + +var sidepanelSelectedResource = null; +var sidebarRootResource = null; +var sidepanelHighlightRect = null; +var sidepanelHoverRect = null; +var sidepanelHoverGlow = null; + +function getResourceTypeName(resource) { + return resource.constructor.name; +} + +function getResourceColor(resource) { + const typeName = getResourceTypeName(resource); + return RESOURCE_COLORS[typeName] || RESOURCE_COLORS["Resource"]; +} + +function isTipRackLike(resource) { + return resource.category === "tip_rack" || + resource instanceof TipRack || + (resource.children.length > 0 && resource.children[0] instanceof TipSpot); +} + +function isPlateLike(resource) { + return resource.category === "plate" || + resource instanceof Plate || + (resource.children.length > 0 && resource.children[0] instanceof Well); +} + +function isTubeRackLike(resource) { + return resource.category === "tube_rack" || + resource instanceof TubeRack || + (resource.children.length > 0 && resource.children[0] instanceof Tube); +} + +function isCarrierLike(resource) { + return resource instanceof Carrier || + ["carrier", "tip_carrier", "plate_carrier", "trough_carrier", "tube_carrier"] + .includes(resource.category); +} + +function getResourceSummary(resource) { + if (isTipRackLike(resource)) { + const totalSpots = resource.children.length; + let tipsPresent = 0; + for (let i = 0; i < resource.children.length; i++) { + if (resource.children[i].has_tip) { + tipsPresent++; + } + } + return `${tipsPresent}/${totalSpots} tips`; + } + + if (isPlateLike(resource)) { + const numChildren = resource.children.length; + return `${numChildren} wells`; + } + + if (isTubeRackLike(resource)) { + const numChildren = resource.children.length; + return `${numChildren} tubes`; + } + + if (resource instanceof Container) { + const vol = resource.getVolume(); + if (vol > 0) { + return `${vol.toFixed(1)} \u00B5L`; + } + return ""; + } + + if (isCarrierLike(resource)) { + // Count meaningful children (skip empty resource holders) + let childCount = 0; + let childType = ""; + for (let i = 0; i < resource.children.length; i++) { + const holder = resource.children[i]; + if (holder.children && holder.children.length > 0) { + childCount++; + if (!childType && holder.children[0]) { + childType = (holder.children[0].resourceType || "item").toLowerCase() + "s"; + } + } + } + if (childCount > 0) { + return `${childCount} ${childType}`; + } + return `${resource.children.length} sites`; + } + + return ""; +} + +function isResourceHolder(resource) { + return resource instanceof ResourceHolder || resource instanceof PlateHolder || + resource.category === "resource_holder" || resource.category === "plate_holder"; +} + +function isDeckLike(resource) { + return resource instanceof Deck || resource instanceof LiquidHandler || + ["deck", "liquid_handler"].includes(resource.category); +} + +// Get the "display children" of a resource, skipping invisible intermediaries +// like ResourceHolders, and flattening Deck/LiquidHandler wrappers. +function getDisplayChildren(resource) { + if (isDeckLike(resource)) { + // Flatten: collect all children, recursing through nested decks/LiquidHandlers + let result = []; + for (let i = 0; i < resource.children.length; i++) { + const child = resource.children[i]; + if (isDeckLike(child)) { + result = result.concat(getDisplayChildren(child)); + } else { + result.push(child); + } + } + return result; + } + + if (isCarrierLike(resource)) { + // Return one entry per slot. Each entry is {index, resource} or {index, empty: true}. + let result = []; + for (let i = 0; i < resource.children.length; i++) { + const holder = resource.children[i]; + if (isResourceHolder(holder) && holder.children.length > 0) { + for (let j = 0; j < holder.children.length; j++) { + result.push({ index: i, resource: holder.children[j] }); + } + } else if (isResourceHolder(holder)) { + result.push({ index: i, empty: true, holderName: holder.name }); + } else { + result.push({ index: i, resource: holder }); + } + } + // Sort by high y to low y; when y is equal, sort by ascending x. + function getSlotLocation(entry) { + if (entry.empty) return resource.children[entry.index].location; + return entry.resource.parent ? entry.resource.parent.location : entry.resource.location; + } + result.sort((a, b) => { + const al = getSlotLocation(a); + const bl = getSlotLocation(b); + if (bl.y !== al.y) return bl.y - al.y; + return al.x - bl.x; + }); + // Assign display indices: for vertical layouts (varying y), use n,...,1,0 top to bottom. + // For horizontal layouts (same y, varying x), use 0,...,n-1 left to right. + const allSameY = result.length > 1 && result.every(function (e) { + return getSlotLocation(e).y === getSlotLocation(result[0]).y; + }); + for (let i = 0; i < result.length; i++) { + result[i].index = allSameY ? i : result.length - 1 - i; + } + return result; + } + + // Leaf containers: don't show individual wells/tips/tubes + if (isTipRackLike(resource) || isPlateLike(resource) || isTubeRackLike(resource)) { + return []; + } + + return resource.children || []; +} + +function buildEmptySlotDOM(index, depth, holderName) { + const node = document.createElement("div"); + node.className = "tree-node tree-node-empty"; + + const row = document.createElement("div"); + row.className = "tree-node-row empty-slot"; + row.style.paddingLeft = (8 + depth * 16) + "px"; + + const arrow = document.createElement("span"); + arrow.className = "tree-node-arrow"; + + const indexSpan = document.createElement("span"); + indexSpan.className = "tree-node-index"; + indexSpan.textContent = index; + + const nameSpan = document.createElement("span"); + nameSpan.className = "tree-node-name empty"; + nameSpan.textContent = ""; + + row.appendChild(arrow); + row.appendChild(indexSpan); + row.appendChild(nameSpan); + node.appendChild(row); + + // Hover to highlight the resource holder on canvas + if (holderName) { + row.addEventListener("mouseenter", function () { + showHoverHighlight(holderName); + }); + row.addEventListener("mouseleave", function () { + clearHoverHighlight(); + }); + } + + return node; +} + +function buildTreeNodeDOM(resource, depth, slotIndex) { + const node = document.createElement("div"); + node.className = "tree-node"; + node.dataset.resourceName = resource.name; + + const row = document.createElement("div"); + row.className = "tree-node-row"; + row.style.paddingLeft = (8 + depth * 16) + "px"; + + const displayChildren = getDisplayChildren(resource); + // Carrier children are {index, resource} objects; others are plain resources + const isCarrier = isCarrierLike(resource); + const hasVisibleChildren = displayChildren.length > 0; + + // Arrow + const arrow = document.createElement("span"); + arrow.className = "tree-node-arrow"; + if (hasVisibleChildren) { + arrow.classList.add("has-children"); + arrow.textContent = "\u25BC"; + } + + // Slot index (shown for resources inside a carrier) + if (slotIndex !== undefined) { + const indexSpan = document.createElement("span"); + indexSpan.className = "tree-node-index"; + indexSpan.textContent = slotIndex; + row.appendChild(arrow); + row.appendChild(indexSpan); + } else { + // Color dot (only for top-level items without a slot index) + const dot = document.createElement("span"); + dot.className = "tree-node-dot"; + dot.style.backgroundColor = getResourceColor(resource); + row.appendChild(arrow); + row.appendChild(dot); + } + + // Name + const nameSpan = document.createElement("span"); + nameSpan.className = "tree-node-name"; + nameSpan.textContent = resource.name; + nameSpan.title = `${resource.name} (${resource.resourceType})`; + + // Type label + const typeSpan = document.createElement("span"); + typeSpan.className = "tree-node-type"; + typeSpan.textContent = resource.resourceType; + + // Info summary + const info = document.createElement("span"); + info.className = "tree-node-info"; + info.textContent = getResourceSummary(resource); + + row.appendChild(nameSpan); + row.appendChild(typeSpan); + row.appendChild(info); + node.appendChild(row); + + // Hover row to show yellow highlight on canvas + row.addEventListener("mouseenter", function () { + showHoverHighlight(resource.name); + }); + row.addEventListener("mouseleave", function () { + clearHoverHighlight(); + }); + + // Click row to select + show UML panel on canvas (single click only) + var clickTimer = null; + row.addEventListener("click", function (e) { + e.stopPropagation(); + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; return; } + clickTimer = setTimeout(function () { + clickTimer = null; + showUmlPanel(resource.name); + }, 250); + }); + + // Double-click row to focus/zoom on resource + row.addEventListener("dblclick", function (e) { + e.stopPropagation(); + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } + focusOnResource(resource.name); + }); + + // Click arrow to toggle children + if (hasVisibleChildren) { + arrow.addEventListener("click", function (e) { + e.stopPropagation(); + const childrenContainer = node.querySelector(":scope > .tree-node-children"); + if (childrenContainer) { + const isCollapsed = childrenContainer.classList.toggle("collapsed"); + arrow.textContent = isCollapsed ? "\u25B6" : "\u25BC"; + buildWrtDropdown(); + } + }); + } + + // Build children + if (hasVisibleChildren) { + const childrenDiv = document.createElement("div"); + childrenDiv.className = "tree-node-children"; + for (let i = 0; i < displayChildren.length; i++) { + const entry = displayChildren[i]; + if (isCarrier && entry.empty) { + childrenDiv.appendChild(buildEmptySlotDOM(entry.index, depth + 1, entry.holderName)); + } else if (isCarrier && entry.resource) { + childrenDiv.appendChild(buildTreeNodeDOM(entry.resource, depth + 1, entry.index)); + } else { + childrenDiv.appendChild(buildTreeNodeDOM(entry, depth + 1)); + } + } + node.appendChild(childrenDiv); + } + + return node; +} + +function buildResourceTree(rootResource, { rebuildNavbar = true } = {}) { + const treeContainer = document.getElementById("resource-tree"); + if (!treeContainer) return; + treeContainer.innerHTML = ""; + if (!rootResource) return; + sidebarRootResource = rootResource; + + // Build a root node for the master resource (e.g. LiquidHandler) + const rootNode = document.createElement("div"); + rootNode.className = "tree-node"; + rootNode.dataset.resourceName = rootResource.name; + + const rootRow = document.createElement("div"); + rootRow.className = "tree-node-row"; + rootRow.style.paddingLeft = "8px"; + + const rootArrow = document.createElement("span"); + rootArrow.className = "tree-node-arrow has-children"; + rootArrow.textContent = "\u25BC"; + + const rootDot = document.createElement("span"); + rootDot.className = "tree-node-dot"; + rootDot.style.backgroundColor = getResourceColor(rootResource); + + const rootName = document.createElement("span"); + rootName.className = "tree-node-name"; + rootName.textContent = rootResource.name; + rootName.title = `${rootResource.name} (${rootResource.resourceType})`; + + const rootType = document.createElement("span"); + rootType.className = "tree-node-type"; + rootType.textContent = rootResource.resourceType; + + rootRow.appendChild(rootArrow); + rootRow.appendChild(rootDot); + rootRow.appendChild(rootName); + rootRow.appendChild(rootType); + rootNode.appendChild(rootRow); + + rootRow.addEventListener("mouseenter", function () { + showHoverHighlight(rootResource.name); + }); + rootRow.addEventListener("mouseleave", function () { + clearHoverHighlight(); + }); + var rootClickTimer = null; + rootRow.addEventListener("click", function (e) { + e.stopPropagation(); + if (rootClickTimer) { clearTimeout(rootClickTimer); rootClickTimer = null; return; } + rootClickTimer = setTimeout(function () { + rootClickTimer = null; + showUmlPanel(rootResource.name); + }, 250); + }); + rootRow.addEventListener("dblclick", function (e) { + e.stopPropagation(); + if (rootClickTimer) { clearTimeout(rootClickTimer); rootClickTimer = null; } + focusOnResource(rootResource.name); + }); + + // Build deck-level children inside the root node + const topChildren = getDisplayChildren(rootResource); + topChildren.sort((a, b) => { + const ax = a.location ? a.location.x : (a.getAbsoluteLocation ? a.getAbsoluteLocation().x : 0); + const bx = b.location ? b.location.x : (b.getAbsoluteLocation ? b.getAbsoluteLocation().x : 0); + return ax - bx; + }); + + const childrenDiv = document.createElement("div"); + childrenDiv.className = "tree-node-children"; + for (let i = 0; i < topChildren.length; i++) { + childrenDiv.appendChild(buildTreeNodeDOM(topChildren[i], 1)); + } + rootNode.appendChild(childrenDiv); + + rootArrow.addEventListener("click", function (e) { + e.stopPropagation(); + const isCollapsed = childrenDiv.classList.toggle("collapsed"); + rootArrow.textContent = isCollapsed ? "\u25B6" : "\u25BC"; + }); + + treeContainer.appendChild(rootNode); + buildWrtDropdown(); + if (rebuildNavbar) buildNavbarLHActuators(); +} + +function addResourceToTree(resource) { + if (!resource || !resource.parent) return; + const treeContainer = document.getElementById("resource-tree"); + if (!treeContainer) return; + + // Find the parent node in the tree + const parentNode = treeContainer.querySelector( + `.tree-node[data-resource-name="${CSS.escape(resource.parent.name)}"]` + ); + if (!parentNode) { + // Parent not in tree; rebuild the whole tree + const rootName = Object.keys(resources).find( + (n) => resources[n] && !resources[n].parent + ); + if (rootName) buildResourceTree(resources[rootName]); + return; + } + + // Rebuild the parent node's subtree to reflect the new child + const parentResource = resources[resource.parent.name]; + if (!parentResource) return; + const depth = getResourceDepth(parentResource); + const newParentNode = buildTreeNodeDOM(parentResource, depth); + + parentNode.replaceWith(newParentNode); +} + +function removeResourceFromTree(resourceName) { + const treeContainer = document.getElementById("resource-tree"); + if (!treeContainer) return; + + const node = treeContainer.querySelector( + `.tree-node[data-resource-name="${CSS.escape(resourceName)}"]` + ); + if (node) { + // Find the parent tree-node and rebuild it + const parentTreeNode = node.parentElement && node.parentElement.closest(".tree-node"); + if (parentTreeNode) { + const parentName = parentTreeNode.dataset.resourceName; + const parentResource = resources[parentName]; + if (parentResource) { + const depth = getResourceDepth(parentResource); + const newNode = buildTreeNodeDOM(parentResource, depth); + parentTreeNode.replaceWith(newNode); + return; + } + } + node.remove(); + } +} + +function getResourceDepth(resource) { + let depth = 0; + let current = resource; + while (current.parent) { + depth++; + current = current.parent; + } + return depth; +} + +function updateSidepanelState(resourceName) { + const treeContainer = document.getElementById("resource-tree"); + if (!treeContainer) return; + + const resource = resources[resourceName]; + if (!resource) return; + + // For tip spots and wells, update the parent summary instead + if (resource instanceof TipSpot || resource instanceof Well || resource instanceof Tube) { + if (resource.parent) { + updateSidepanelNodeInfo(resource.parent.name); + } + return; + } + + updateSidepanelNodeInfo(resourceName); +} + +function updateSidepanelNodeInfo(resourceName) { + const treeContainer = document.getElementById("resource-tree"); + if (!treeContainer) return; + + const resource = resources[resourceName]; + if (!resource) return; + + const node = treeContainer.querySelector( + `.tree-node[data-resource-name="${CSS.escape(resourceName)}"]` + ); + if (!node) return; + + const infoSpan = node.querySelector(":scope > .tree-node-row > .tree-node-info"); + if (infoSpan) { + infoSpan.textContent = getResourceSummary(resource); + } +} + +function showHoverHighlight(resourceName) { + clearHoverHighlight(); + const resource = resources[resourceName]; + if (!resource || !resource.group) return; + const absPos = resource.getAbsoluteLocation(); + // Outer glow rect (turquoise shadow, no fill) + sidepanelHoverGlow = new Konva.Rect({ + x: absPos.x, + y: absPos.y, + width: resource.size_x, + height: resource.size_y, + stroke: "rgba(0, 220, 220, 0.7)", + strokeWidth: 2, + shadowColor: "rgba(0, 220, 220, 0.8)", + shadowBlur: 12, + shadowOffsetX: 0, + shadowOffsetY: 0, + listening: false, + }); + // Inner fill rect (yellow, no shadow) + sidepanelHoverRect = new Konva.Rect({ + x: absPos.x, + y: absPos.y, + width: resource.size_x, + height: resource.size_y, + fill: "rgba(255, 230, 0, 0.25)", + listening: false, + }); + resourceLayer.add(sidepanelHoverGlow); + resourceLayer.add(sidepanelHoverRect); + resourceLayer.draw(); +} + +function clearHoverHighlight() { + if (sidepanelHoverGlow) { + sidepanelHoverGlow.destroy(); + sidepanelHoverGlow = null; + } + if (sidepanelHoverRect) { + sidepanelHoverRect.destroy(); + sidepanelHoverRect = null; + } + resourceLayer.draw(); +} + +function highlightSidebarRow(resourceName) { + clearSidebarHighlight(); + const tree = document.getElementById("resource-tree"); + if (!tree) return; + const nodes = tree.querySelectorAll(".tree-node"); + const nodeByName = {}; + for (const node of nodes) { + if (node.dataset.resourceName) { + nodeByName[node.dataset.resourceName] = node; + } + } + // Walk up the parent chain until we find a resource with a sidebar entry. + let name = resourceName; + while (name) { + if (nodeByName[name]) { + const row = nodeByName[name].querySelector(":scope > .tree-node-row"); + if (row) { + row.classList.add("canvas-hover"); + row.scrollIntoView({ block: "nearest" }); + } + return; + } + const res = resources[name]; + if (res && res.parent) { + name = res.parent.name; + } else { + break; + } + } +} + +function clearSidebarHighlight() { + const tree = document.getElementById("resource-tree"); + if (!tree) return; + const highlighted = tree.querySelectorAll(".tree-node-row.canvas-hover"); + for (const row of highlighted) { + row.classList.remove("canvas-hover"); + } +} + +function highlightResourceOnCanvas(resourceName) { + const resource = resources[resourceName]; + if (!resource) return; + + // Update selected state in tree + const treeContainer = document.getElementById("resource-tree"); + if (treeContainer) { + const prev = treeContainer.querySelector(".tree-node-row.selected"); + if (prev) prev.classList.remove("selected"); + + const node = treeContainer.querySelector( + `.tree-node[data-resource-name="${CSS.escape(resourceName)}"]` + ); + if (node) { + const row = node.querySelector(":scope > .tree-node-row"); + if (row) row.classList.add("selected"); + } + } + + // Remove previous highlight + if (sidepanelHighlightRect) { + sidepanelHighlightRect.destroy(); + sidepanelHighlightRect = null; + } + + if (!resource.group) return; + + // Get absolute position on the canvas + const absPos = resource.getAbsoluteLocation(); + + // Draw a highlight rectangle on the resource layer + sidepanelHighlightRect = new Konva.Rect({ + x: absPos.x - 2, + y: absPos.y - 2, + width: resource.size_x + 4, + height: resource.size_y + 4, + stroke: "#0d6efd", + strokeWidth: 2, + dash: [6, 3], + listening: false, + }); + resourceLayer.add(sidepanelHighlightRect); + resourceLayer.draw(); + + // Auto-remove highlight after 2 seconds + setTimeout(function () { + if (sidepanelHighlightRect) { + sidepanelHighlightRect.destroy(); + sidepanelHighlightRect = null; + resourceLayer.draw(); + } + }, 2000); +} + +function focusOnResource(resourceName) { + var resource = resources[resourceName]; + if (!resource || !stage) return; + + var absPos = resource.getAbsoluteLocation(); + var padding = 60; + var stageW = stage.width(); + var stageH = stage.height(); + var viewW = stageW - padding * 2; + var viewH = stageH - padding * 2; + var fitScale = Math.min(viewW / resource.size_x, viewH / resource.size_y); + + // Adaptive max zoom based on resource size (smaller resources get more zoom) + var resourceArea = resource.size_x * resource.size_y; + var maxScale; + if (resourceArea > 50000) maxScale = 2; // large (e.g. deck, carrier) + else if (resourceArea > 5000) maxScale = 5; // medium (e.g. plate, tiprack) + else if (resourceArea > 500) maxScale = 10; // small (e.g. well, tipspot) + else maxScale = 15; // tiny + fitScale = Math.min(fitScale, maxScale); + + stage.scaleX(fitScale); + stage.scaleY(-fitScale); + + // Center the resource in the viewport + var centerX = (stageW - resource.size_x * fitScale) / 2 - absPos.x * fitScale; + var centerY = (stageH + resource.size_y * fitScale) / 2 + absPos.y * fitScale - stageH * fitScale; + stage.x(centerX); + stage.y(centerY); + + if (typeof updateScaleBar === "function") updateScaleBar(); + updateBullseyeScale(); + updateWrtBullseyeScale(); + updateTooltipScale(); + updateDeltaLinesScale(); + stage.batchDraw(); + + // Also highlight the resource + highlightResourceOnCanvas(resourceName); +} + +// Expand/collapse tree nodes to a given depth +function getTreeDepthLimit() { + var input = document.getElementById("tree-depth-input"); + if (!input) return 1; + var val = parseInt(input.value, 10); + return isNaN(val) || val < 0 ? 1 : val; +} + +function setTreeNodeExpansion(node, depth, maxDepth, expand) { + var children = node.querySelectorAll(":scope > .tree-node-children > .tree-node"); + var childrenContainer = node.querySelector(":scope > .tree-node-children"); + var arrow = node.querySelector(":scope > .tree-node-row > .tree-node-arrow.has-children"); + if (!childrenContainer) return; + + if (expand && depth < maxDepth) { + childrenContainer.classList.remove("collapsed"); + if (arrow) arrow.textContent = "\u25BC"; + } else if (!expand && depth >= maxDepth) { + childrenContainer.classList.add("collapsed"); + if (arrow) arrow.textContent = "\u25B6"; + } + + children.forEach(function (child) { + setTreeNodeExpansion(child, depth + 1, maxDepth, expand); + }); +} + +function getMaxTreeDepth(container, depth) { + var maxD = depth; + var nodes = container.querySelectorAll(":scope > .tree-node"); + for (var i = 0; i < nodes.length; i++) { + var childrenDiv = nodes[i].querySelector(":scope > .tree-node-children"); + if (childrenDiv) { + var childMax = getMaxTreeDepth(childrenDiv, depth + 1); + if (childMax > maxD) maxD = childMax; + } + } + return maxD; +} + +function expandAllTreeNodes() { + var tree = document.getElementById("resource-tree"); + if (!tree) return; + tree.querySelectorAll(".tree-node-children.collapsed").forEach(function (el) { + el.classList.remove("collapsed"); + }); + tree.querySelectorAll(".tree-node-arrow.has-children").forEach(function (el) { + el.textContent = "\u25BC"; + }); + var depthInput = document.getElementById("tree-depth-input"); + if (depthInput) { + depthInput.value = getMaxTreeDepth(tree, 0); + } + buildWrtDropdown(); +} + +function showToDepth() { + var tree = document.getElementById("resource-tree"); + if (!tree) return; + var maxDepth = getTreeDepthLimit(); + var roots = tree.querySelectorAll(":scope > .tree-node"); + roots.forEach(function (root) { + setTreeNodeExpansion(root, 0, maxDepth, true); + setTreeNodeExpansion(root, 0, maxDepth, false); + }); + buildWrtDropdown(); +} + +function collapseAllTreeNodes() { + var tree = document.getElementById("resource-tree"); + if (!tree) return; + var maxDepth = getTreeDepthLimit(); + var roots = tree.querySelectorAll(":scope > .tree-node"); + roots.forEach(function (root) { + setTreeNodeExpansion(root, 0, maxDepth, false); + }); + buildWrtDropdown(); +} + +// Sidepanel collapse toggle, resize, expand/collapse all +window.addEventListener("load", function () { + var expandBtn = document.getElementById("expand-all-btn"); + var collapseBtn = document.getElementById("collapse-all-btn"); + var treeExpanded = true; + if (expandBtn) expandBtn.addEventListener("click", function () { + if (treeExpanded) { + // Collapse all: force depth 0 + var tree = document.getElementById("resource-tree"); + if (tree) { + tree.querySelectorAll(".tree-node-children").forEach(function (el) { + el.classList.add("collapsed"); + }); + tree.querySelectorAll(".tree-node-arrow.has-children").forEach(function (el) { + el.textContent = "\u25B6"; + }); + buildWrtDropdown(); + } + expandBtn.title = "Expand All"; + expandBtn.innerHTML = '' + + '' + + '' + + ''; + } else { + // Expand all without updating depth input + var tree = document.getElementById("resource-tree"); + if (tree) { + tree.querySelectorAll(".tree-node-children.collapsed").forEach(function (el) { + el.classList.remove("collapsed"); + }); + tree.querySelectorAll(".tree-node-arrow.has-children").forEach(function (el) { + el.textContent = "\u25BC"; + }); + buildWrtDropdown(); + } + expandBtn.title = "Collapse All"; + expandBtn.innerHTML = '' + + '' + + '' + + ''; + } + treeExpanded = !treeExpanded; + }); + if (collapseBtn) collapseBtn.addEventListener("click", showToDepth); + + var depthInput = document.getElementById("tree-depth-input"); + if (depthInput) { + // depth input no longer auto-applies; user must click the button + } + + // Left toolbar tool switching + var cursorBtn = document.getElementById("toolbar-cursor-btn"); + var coordsBtn = document.getElementById("toolbar-coords-btn"); + var coordsPanel = document.getElementById("coords-panel"); + var gifBtn = document.getElementById("toolbar-gif-btn"); + var gifPanel = document.getElementById("gif-panel"); + function setActiveTool(tool) { + activeTool = tool === "gif" ? "cursor" : tool; + if (cursorBtn) cursorBtn.classList.toggle("active", tool === "cursor"); + if (coordsBtn) coordsBtn.classList.toggle("active", tool === "coords"); + if (gifBtn) gifBtn.classList.toggle("active", tool === "gif"); + if (coordsPanel) coordsPanel.style.display = tool === "coords" ? "" : "none"; + if (gifPanel) gifPanel.style.display = tool === "gif" ? "" : "none"; + if (tool !== "coords") { + updateCoordsPanel(null); + } + clearDeltaLines(); + updateWrtHighlight(); + } + if (cursorBtn) cursorBtn.addEventListener("click", function () { setActiveTool("cursor"); }); + if (coordsBtn) coordsBtn.addEventListener("click", function () { setActiveTool("coords"); }); + if (gifBtn) gifBtn.addEventListener("click", function () { setActiveTool("gif"); }); + + // Update wrt highlight when dropdowns change + ["coords-wrt-ref", "coords-wrt-x-ref", "coords-wrt-y-ref", "coords-wrt-z-ref"].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener("change", updateWrtHighlight); + }); + + // Left toolbar collapse toggle + var leftToggle = document.getElementById("toolbar-left-toggle"); + var leftToolbar = document.getElementById("toolbar-left"); + if (leftToggle && leftToolbar) { + leftToggle.addEventListener("click", function () { + leftToolbar.classList.toggle("collapsed"); + }); + } + + const toggle = document.getElementById("toolbar-tree-btn"); + const searchBtn = document.getElementById("toolbar-search-btn"); + const panel = document.getElementById("sidepanel"); + const treeHeader = document.querySelector(".sidepanel-header"); + const treeView = document.getElementById("resource-tree"); + const searchView = document.getElementById("search-view"); + const searchInput = document.getElementById("search-input"); + + var sidepanelWidthBeforeCollapse = null; + + function setSidepanelView(view) { + // Ensure sidepanel is visible + if (panel.classList.contains("collapsed")) { + panel.classList.remove("collapsed"); + if (sidepanelWidthBeforeCollapse) { + panel.style.width = sidepanelWidthBeforeCollapse; + } else { + panel.style.width = ""; + } + } + + if (view === "tree") { + treeHeader.style.display = ""; + treeView.style.display = ""; + searchView.style.display = "none"; + toggle.classList.add("active"); + searchBtn.classList.remove("active"); + } else if (view === "search") { + treeHeader.style.display = "none"; + treeView.style.display = "none"; + searchView.style.display = ""; + toggle.classList.remove("active"); + searchBtn.classList.add("active"); + searchInput.focus(); + } + } + + if (toggle && panel) { + toggle.addEventListener("click", function () { + if (!panel.classList.contains("collapsed") && toggle.classList.contains("active")) { + // Already showing tree — collapse + sidepanelWidthBeforeCollapse = panel.style.width || panel.offsetWidth + "px"; + panel.style.width = ""; + panel.classList.add("collapsed"); + toggle.classList.remove("active"); + } else { + setSidepanelView("tree"); + } + }); + } + + if (searchBtn && panel) { + searchBtn.addEventListener("click", function () { + if (!panel.classList.contains("collapsed") && searchBtn.classList.contains("active")) { + // Already showing search — collapse + sidepanelWidthBeforeCollapse = panel.style.width || panel.offsetWidth + "px"; + panel.style.width = ""; + panel.classList.add("collapsed"); + searchBtn.classList.remove("active"); + } else { + setSidepanelView("search"); + } + }); + } + + // Fuzzy search on resources + function fuzzyMatch(query, text) { + query = query.toLowerCase(); + text = text.toLowerCase(); + var qi = 0; + for (var ti = 0; ti < text.length && qi < query.length; ti++) { + if (text[ti] === query[qi]) qi++; + } + return qi === query.length; + } + + function fuzzyScore(query, text) { + query = query.toLowerCase(); + text = text.toLowerCase(); + // Prioritize: exact match > starts with > contains > fuzzy + if (text === query) return 4; + if (text.startsWith(query)) return 3; + if (text.indexOf(query) !== -1) return 2; + return 1; + } + + // Collect all resources with a sort key matching the Workcell Tree display order. + // Each resource gets a hierarchical key [topX, depth, slotIndex, ...] for sorting. + function getResourcesInTreeOrder() { + if (!rootResource) return []; + var result = []; + + function addResource(res, sortKey) { + result.push({ resource: res, sortKey: sortKey }); + } + + // Get top-level children (deck items) sorted by x + var topChildren = getDisplayChildren(rootResource); + topChildren.sort(function (a, b) { + var ax = a.location ? a.location.x : 0; + var bx = b.location ? b.location.x : 0; + return ax - bx; + }); + + addResource(rootResource, [-1]); // root first + + for (var ti = 0; ti < topChildren.length; ti++) { + var topChild = topChildren[ti]; + var topKey = [ti]; + addResource(topChild, topKey); + + // Carrier children — include sites (resource holders) for search + var carrierChildren = getDisplayChildren(topChild); + for (var ci = 0; ci < carrierChildren.length; ci++) { + var entry = carrierChildren[ci]; + // Insert the site holder before its child at this slot + if (isCarrierLike(topChild) && entry.index !== undefined) { + var holder = topChild.children[entry.index]; + if (holder && isResourceHolder(holder)) { + addResource(holder, topKey.concat([ci, 0])); + } + } + var child = entry.resource || entry; + if (!child || entry.empty) continue; + addResource(child, topKey.concat([ci, 1])); + + // Leaf children (wells/tips) not shown in tree + if (isTipRackLike(child) || isPlateLike(child) || isTubeRackLike(child)) { + for (var li = 0; li < (child.children || []).length; li++) { + addResource(child.children[li], topKey.concat([ci, 1, li])); + } + } + + // Deeper children + var subChildren = getDisplayChildren(child); + for (var si = 0; si < subChildren.length; si++) { + var subEntry = subChildren[si]; + var subChild = subEntry.resource || subEntry; + if (!subChild || subEntry.empty) continue; + addResource(subChild, topKey.concat([ci, 1, si])); + } + } + + // If top-level item is itself a leaf rack (not on a carrier) + if (isTipRackLike(topChild) || isPlateLike(topChild) || isTubeRackLike(topChild)) { + for (var li = 0; li < (topChild.children || []).length; li++) { + addResource(topChild.children[li], topKey.concat([li])); + } + } + } + + // Sort by hierarchical key + result.sort(function (a, b) { + var ka = a.sortKey, kb = b.sortKey; + for (var i = 0; i < Math.min(ka.length, kb.length); i++) { + if (ka[i] !== kb[i]) return ka[i] - kb[i]; + } + return ka.length - kb.length; + }); + + // Deduplicate (sites may appear for each child in the same slot) + var seen = {}; + var deduped = []; + for (var i = 0; i < result.length; i++) { + var name = result[i].resource.name; + if (!seen[name]) { + seen[name] = true; + deduped.push(result[i].resource); + } + } + return deduped; + } + + function performSearch(query) { + var resultsDiv = document.getElementById("search-results"); + if (!resultsDiv) return; + resultsDiv.innerHTML = ""; + + if (!query || query.trim() === "") return; + + var includeWells = document.getElementById("search-include-wells"); + var showWells = includeWells && includeWells.checked; + var includeTips = document.getElementById("search-include-tips"); + var showTips = includeTips && includeTips.checked; + var includeSites = document.getElementById("search-include-sites"); + var showSites = includeSites && includeSites.checked; + var terms = query.trim().toLowerCase().split(/\s+/); + var allResources = getResourcesInTreeOrder(); + var matches = []; + + for (var ri = 0; ri < allResources.length; ri++) { + var resource = allResources[ri]; + var name = resource.name; + if (!resource) continue; + if (!showWells && resource instanceof Container) continue; + if (!showTips && resource instanceof TipSpot) continue; + if (!showSites && isResourceHolder(resource)) continue; + var allMatch = true; + var totalScore = 0; + for (var i = 0; i < terms.length; i++) { + if (fuzzyMatch(terms[i], name)) { + totalScore += fuzzyScore(terms[i], name); + } else if (fuzzyMatch(terms[i], resource.resourceType || "")) { + totalScore += fuzzyScore(terms[i], resource.resourceType || ""); + } else { + allMatch = false; + break; + } + } + if (allMatch) matches.push({ resource: resource, score: totalScore, order: ri }); + } + + matches.sort(function (a, b) { + if (b.score !== a.score) return b.score - a.score; + return a.order - b.order; + }); + + for (var i = 0; i < matches.length; i++) { + var res = matches[i].resource; + var row = document.createElement("div"); + row.className = "tree-node-row"; + row.style.paddingLeft = "12px"; + + var dot = document.createElement("span"); + dot.className = "tree-node-dot"; + dot.style.backgroundColor = getResourceColor(res); + + var nameSpan = document.createElement("span"); + nameSpan.className = "tree-node-name"; + nameSpan.textContent = res.name; + nameSpan.title = res.name + " (" + (res.resourceType || "") + ")"; + + var typeSpan = document.createElement("span"); + typeSpan.className = "tree-node-type"; + typeSpan.textContent = res.resourceType || ""; + + row.appendChild(dot); + row.appendChild(nameSpan); + row.appendChild(typeSpan); + + row.addEventListener("mouseenter", (function (rName) { + return function () { showHoverHighlight(rName); }; + })(res.name)); + row.addEventListener("mouseleave", function () { + clearHoverHighlight(); + }); + (function (rName) { + var searchClickTimer = null; + row.addEventListener("click", function () { + if (searchClickTimer) { clearTimeout(searchClickTimer); searchClickTimer = null; return; } + searchClickTimer = setTimeout(function () { + searchClickTimer = null; + showUmlPanel(rName); + }, 250); + }); + row.addEventListener("dblclick", function () { + if (searchClickTimer) { clearTimeout(searchClickTimer); searchClickTimer = null; } + focusOnResource(rName); + }); + })(res.name); + + resultsDiv.appendChild(row); + } + } + + if (searchInput) { + searchInput.addEventListener("input", function () { + performSearch(this.value); + }); + } + + var includeWellsCheckbox = document.getElementById("search-include-wells"); + if (includeWellsCheckbox && searchInput) { + includeWellsCheckbox.addEventListener("change", function () { + performSearch(searchInput.value); + }); + } + + var includeTipsCheckbox = document.getElementById("search-include-tips"); + if (includeTipsCheckbox && searchInput) { + includeTipsCheckbox.addEventListener("change", function () { + performSearch(searchInput.value); + }); + } + + var includeSitesCheckbox = document.getElementById("search-include-sites"); + if (includeSitesCheckbox && searchInput) { + includeSitesCheckbox.addEventListener("change", function () { + performSearch(searchInput.value); + }); + } + + // Navbar right toggle (mirrors toolbar-tree-btn behavior) + var rightToggle = document.getElementById("toolbar-right-toggle"); + var rightToolbar = document.getElementById("toolbar"); + var sidebarWasCollapsedBeforeInspectorClose = false; + + if (rightToggle && panel && rightToolbar) { + rightToggle.addEventListener("click", function () { + var toolbarHidden = rightToolbar.style.display === "none"; + + if (toolbarHidden) { + // Toolbar hidden — reopen toolbar, restore sidebar to previous state + rightToolbar.style.display = ""; + if (!sidebarWasCollapsedBeforeInspectorClose) { + panel.classList.remove("collapsed"); + if (sidepanelWidthBeforeCollapse) { + panel.style.width = sidepanelWidthBeforeCollapse; + } else { + panel.style.width = ""; + } + toggle.classList.add("active"); + } + } else { + // Toolbar visible — remember sidebar state, then collapse all + sidebarWasCollapsedBeforeInspectorClose = panel.classList.contains("collapsed"); + if (!sidebarWasCollapsedBeforeInspectorClose) { + sidepanelWidthBeforeCollapse = panel.style.width || panel.offsetWidth + "px"; + panel.style.width = ""; + panel.classList.add("collapsed"); + } + rightToolbar.style.display = "none"; + toggle.classList.remove("active"); + searchBtn.classList.remove("active"); + } + }); + } + + // Drag-to-resize handle + const handle = document.getElementById("sidepanel-resize-handle"); + if (handle && panel) { + let dragging = false; + + handle.addEventListener("mousedown", function (e) { + e.preventDefault(); + dragging = true; + handle.classList.add("dragging"); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }); + + document.addEventListener("mousemove", function (e) { + if (!dragging) return; + const newWidth = window.innerWidth - e.clientX; + const clamped = Math.max(150, Math.min(newWidth, window.innerWidth * 0.6)); + panel.style.width = clamped + "px"; + }); + + document.addEventListener("mouseup", function () { + if (!dragging) return; + dragging = false; + handle.classList.remove("dragging"); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }); + } +}); + +// =========================================================================== +// UML-Style Resource Info Panel +// =========================================================================== + +var umlPanelResourceName = null; +var _umlPanelOpenedAt = 0; + +function getUmlAttributes(resource) { + var attrs = []; + attrs.push({ key: "name", value: JSON.stringify(resource.name) }); + attrs.push({ key: "type", value: resource.resourceType || resource.constructor.name }); + attrs.push({ key: "size_x", value: resource.size_x }); + attrs.push({ key: "size_y", value: resource.size_y }); + attrs.push({ key: "size_z", value: resource.size_z }); + if (resource.location) { + attrs.push({ key: "location", value: "(" + resource.location.x + ", " + resource.location.y + ", " + (resource.location.z || 0) + ")" }); + } + var abs = resource.getAbsoluteLocation(); + attrs.push({ key: "abs_location", value: "(" + abs.x.toFixed(1) + ", " + abs.y.toFixed(1) + ", " + (abs.z || 0).toFixed(1) + ")" }); + attrs.push({ key: "parent", value: resource.parent ? JSON.stringify(resource.parent.name) : "none" }); + attrs.push({ key: "children", value: resource.children ? resource.children.length : 0 }); + if (resource.category) { + attrs.push({ key: "category", value: resource.category }); + } + + // Container (Well/Trough/Tube) + if (resource instanceof Container) { + attrs.push({ key: "max_volume", value: resource.maxVolume }); + attrs.push({ key: "volume", value: resource.volume }); + if (resource.material_z_thickness != null) { + attrs.push({ key: "material_z_thickness", value: resource.material_z_thickness }); + } + } + // Well-specific + if (resource instanceof Well) { + if (resource.cross_section_type) { + attrs.push({ key: "cross_section_type", value: resource.cross_section_type }); + } + } + // TipSpot + if (resource instanceof TipSpot) { + attrs.push({ key: "has_tip", value: resource.has_tip }); + if (resource.tip) { + attrs.push({ key: "tip", value: JSON.stringify(resource.tip) }); + } + } + // Plate/TipRack/TubeRack + if (resource instanceof Plate || resource instanceof TipRack || resource instanceof TubeRack) { + if (resource.num_items_x != null) attrs.push({ key: "num_items_x", value: resource.num_items_x }); + if (resource.num_items_y != null) attrs.push({ key: "num_items_y", value: resource.num_items_y }); + } + // ResourceHolder + if (resource instanceof ResourceHolder) { + if (resource.spot != null) attrs.push({ key: "spot", value: resource.spot }); + } + // HamiltonSTARDeck + if (resource instanceof HamiltonSTARDeck) { + attrs.push({ key: "num_rails", value: resource.num_rails }); + } + + return attrs; +} + +function getUmlMethods(resource) { + if (resource.methods && resource.methods.length > 0) { + return resource.methods; + } + return []; +} + +function buildUmlPanelDOM(resource) { + var panel = document.createElement("div"); + panel.className = "uml-panel"; + panel.id = "uml-panel"; + + // Close button + var closeBtn = document.createElement("button"); + closeBtn.className = "uml-close-btn"; + closeBtn.textContent = "\u00D7"; + closeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + hideUmlPanel(); + }); + panel.appendChild(closeBtn); + + // Header + var header = document.createElement("div"); + header.className = "uml-header"; + var nameDiv = document.createElement("div"); + nameDiv.className = "uml-header-name"; + nameDiv.textContent = resource.name; + var typeDiv = document.createElement("div"); + typeDiv.className = "uml-header-type"; + typeDiv.textContent = "\u00AB" + (resource.resourceType || resource.constructor.name) + "\u00BB"; + header.appendChild(nameDiv); + header.appendChild(typeDiv); + panel.appendChild(header); + + // Separator + var sep1 = document.createElement("div"); + sep1.className = "uml-separator"; + panel.appendChild(sep1); + + // Attributes section + var attrsSection = document.createElement("div"); + attrsSection.className = "uml-section"; + var attrsTitle = document.createElement("div"); + attrsTitle.className = "uml-section-title"; + attrsTitle.textContent = "Attributes"; + attrsSection.appendChild(attrsTitle); + + var attrs = getUmlAttributes(resource); + for (var i = 0; i < attrs.length; i++) { + var row = document.createElement("div"); + row.className = "uml-row"; + var keySpan = document.createElement("span"); + keySpan.className = "uml-key"; + keySpan.textContent = attrs[i].key + ":"; + var valSpan = document.createElement("span"); + valSpan.className = "uml-value"; + valSpan.textContent = " " + attrs[i].value; + row.appendChild(keySpan); + row.appendChild(valSpan); + attrsSection.appendChild(row); + } + panel.appendChild(attrsSection); + + // Separator + var sep2 = document.createElement("div"); + sep2.className = "uml-separator"; + panel.appendChild(sep2); + + // Methods section + var methodsSection = document.createElement("div"); + methodsSection.className = "uml-section"; + + var methodsHeader = document.createElement("div"); + methodsHeader.style.display = "flex"; + methodsHeader.style.alignItems = "center"; + methodsHeader.style.justifyContent = "space-between"; + methodsHeader.style.cursor = "pointer"; + + var methodsTitle = document.createElement("div"); + methodsTitle.className = "uml-section-title"; + methodsTitle.textContent = "Methods"; + methodsTitle.style.marginBottom = "0"; + + var toggleBtn = document.createElement("button"); + toggleBtn.style.background = "none"; + toggleBtn.style.border = "none"; + toggleBtn.style.cursor = "pointer"; + toggleBtn.style.padding = "0 2px"; + toggleBtn.style.fontSize = "12px"; + toggleBtn.style.color = "#999"; + toggleBtn.style.lineHeight = "1"; + toggleBtn.innerHTML = "▼"; + + methodsHeader.appendChild(methodsTitle); + methodsHeader.appendChild(toggleBtn); + methodsSection.appendChild(methodsHeader); + + var methodsList = document.createElement("div"); + methodsList.style.display = "none"; + + var methods = getUmlMethods(resource); + for (var i = 0; i < methods.length; i++) { + var methodDiv = document.createElement("div"); + methodDiv.className = "uml-method"; + methodDiv.textContent = methods[i]; + methodsList.appendChild(methodDiv); + } + methodsSection.appendChild(methodsList); + + methodsHeader.addEventListener("click", function () { + var collapsed = methodsList.style.display === "none"; + methodsList.style.display = collapsed ? "block" : "none"; + toggleBtn.innerHTML = collapsed ? "▲" : "▼"; + }); + + panel.appendChild(methodsSection); + + return panel; +} + +function showUmlPanel(resourceName) { + var resource = resources[resourceName]; + if (!resource) return; + + // Toggle off if clicking the same resource + if (umlPanelResourceName === resourceName) { + hideUmlPanel(); + return; + } + + umlPanelResourceName = resourceName; + _umlPanelOpenedAt = Date.now(); + + // Remove existing panel + var existing = document.getElementById("uml-panel"); + if (existing) existing.remove(); + + // Build and insert the panel into
+ var mainEl = document.querySelector("main"); + if (!mainEl) return; + var panelDOM = buildUmlPanelDOM(resource); + mainEl.appendChild(panelDOM); + + // Also highlight the resource on canvas + highlightResourceOnCanvas(resourceName); +} + +function hideUmlPanel() { + umlPanelResourceName = null; + var existing = document.getElementById("uml-panel"); + if (existing) existing.remove(); +} + +// Close UML panel when clicking on empty canvas area +window.addEventListener("load", function () { + var kanvas = document.getElementById("kanvas"); + if (kanvas) { + kanvas.addEventListener("click", function (e) { + // Only close if the click is directly on the kanvas container (not on a child) + // Skip if a panel was just opened (e.g. via dblclick) to avoid race conditions + if (e.target === kanvas || e.target.tagName === "CANVAS") { + if (Date.now() - _umlPanelOpenedAt > 400) { + hideUmlPanel(); + } + } + }); + } +}); + +// =========================================================================== +// Navbar Liquid Handler Actuator Buttons +// =========================================================================== + +function makeSVG(viewBox, innerHTML) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", "40"); + svg.setAttribute("height", "40"); + svg.setAttribute("viewBox", viewBox); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.innerHTML = innerHTML; + return svg; +} + +var multiChannelSVG = (function () { + var body = + '' + + // === Main body — large isometric block === + '' + + '' + + '' + + '' + + // === Dark adapter plate === + '' + + '' + + ''; + + // === Isometric tip grid === + // The adapter plate bottom face is a diamond: + // back=(20,21), left=(2,21), front=(20,28), right=(38,21) + // Isometric axes on this face: + // "column" axis: left→right = from (2,21) toward (38,21) through back → direction (+2.25, -0.44) + // "row" axis: back→front = from back toward (20,28) → direction (-2.25, +0.44) per step + // But we want to shift each row forward (toward viewer). + // + // Grid: 8 columns x 6 rows. Origin at center of adapter bottom face. + // Column direction (along right edge): dx_c=+2.25, dy_c=-0.44 + // Row direction (toward front): dx_r=-2.25, dy_r=+0.44 + // Center of bottom face: (20, 24.5) + + var cx = 20, cy = 24.5; + var cols = 8, rows = 6; + var dx_c = 2.1, dy_c = -0.75; // step per column (going right-back) + var dx_r = -2.1, dy_r = 0.75; // step per row (going left-front) + var tipLen = 11; // tip length (straight down) + + // Collect all tips with their positions, sorted back-to-front for correct overlap + var tips = []; + for (var r = 0; r < rows; r++) { + for (var c = 0; c < cols; c++) { + var cc = c - (cols - 1) / 2; // center columns + var rr = r - (rows - 1) / 2; // center rows + var x = cx + cc * dx_c + rr * dx_r; + var y = cy + cc * dy_c + rr * dy_r; + // depth: back rows (low r) are far, front rows (high r) are near + tips.push({ x: x, y: y, row: r }); + } + } + // Sort back-to-front (low y first = back = draw first) + tips.sort(function (a, b) { return a.y - b.y; }); + + var tipsSvg = ''; + for (var i = 0; i < tips.length; i++) { + var t = tips[i]; + // Depth shading: back rows lighter, front rows darker + var frac = t.row / (rows - 1); // 0=back, 1=front + var gray = Math.round(180 - frac * 150); // 180 (light) → 30 (dark) + var sw = 0.5 + frac * 0.5; // 0.5 → 1.0 stroke width + var color = 'rgb(' + gray + ',' + gray + ',' + gray + ')'; + var x1 = t.x.toFixed(1); + var y1 = t.y.toFixed(1); + var y2 = (t.y + tipLen).toFixed(1); + tipsSvg += ''; + } + + return body + tipsSvg + ''; +})(); + +var singleChannelSVG = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + +var integratedArmSVG = + '' + + // --- Dark vertical column (isometric cylinder) --- + // Column left face + '' + + // Column right face + '' + + // Column top ellipse + '' + + // Highlight strip + '' + + // --- White carriage plate (isometric box) --- + // Plate top face + '' + + // Plate front-left face + '' + + // Plate front-right face + '' + + // --- Back arm (upper rail, extends front-right) --- + // Back arm top + '' + + // Back arm front face + '' + + // Back arm right end + '' + + // --- Front arm (lower rail, extends front-right) --- + // Front arm top + '' + + // Front arm front face + '' + + // Front arm right end + '' + + // --- Back gripper (at end of back arm) --- + // Back crossbar top + '' + + // Back crossbar front + '' + + // Back left finger + '' + + // Back right finger + '' + + // --- Front gripper (at end of front arm) --- + // Front crossbar top + '' + + // Front crossbar front + '' + + // Front left finger + '' + + // Front right finger + '' + + ''; + +function buildNavbarLHActuators() { + var container = document.getElementById("navbar-lh-actuators"); + if (!container) return; + container.innerHTML = ""; + + // Find all LiquidHandler resources + var lhNames = []; + for (var name in resources) { + if (resources[name] instanceof LiquidHandler) { + lhNames.push(name); + } + } + + for (var i = 0; i < lhNames.length; i++) { + var lhName = lhNames[i]; + var group = document.createElement("div"); + group.className = "navbar-pipette-group"; + + // Label (styled as button without changing appearance) + var label = document.createElement("button"); + label.className = "navbar-pipette-label"; + label.title = "Show/hide liquid handler actuators"; + label.innerHTML = lhName + "
Actuators"; + group.appendChild(label); + + // Collapsible container for actuator buttons + var actuatorBtns = document.createElement("div"); + actuatorBtns.className = "navbar-actuator-btns"; + group.appendChild(actuatorBtns); + + // Toggle actuator buttons on label click + label.addEventListener("click", function () { + var collapsed = actuatorBtns.classList.toggle("collapsed"); + label.classList.toggle("collapsed", collapsed); + // Close any open dropdowns when collapsing + if (collapsed) { + var dropdowns = document.querySelectorAll(".actuator-dropdown.open"); + dropdowns.forEach(function (d) { d.classList.remove("open"); }); + group.querySelectorAll(".navbar-pipette-btn.active").forEach(function (b) { b.classList.remove("active"); }); + } + }); + + // Multi-channel button (hidden unless setState has already confirmed actuator exists) + var lhRes = resources[lhName]; + var multiBtn = document.createElement("button"); + multiBtn.className = "navbar-pipette-btn"; + multiBtn.id = "multi-channel-btn-" + lhName; + multiBtn.style.display = (lhRes && lhRes.head96State !== null && lhRes.head96State !== undefined) ? "" : "none"; + multiBtn.title = "Multi-Channel Pipettes"; + var multiImg = document.createElement("img"); + multiImg.src = "img/multi_channel_pipette.png"; + multiImg.alt = "Multi-Channel Pipettes"; + multiImg.style.width = "44px"; + multiImg.style.height = "44px"; + multiImg.style.objectFit = "contain"; + multiBtn.appendChild(multiImg); + actuatorBtns.appendChild(multiBtn); + + // Single-channel button + var singleBtn = document.createElement("button"); + singleBtn.className = "navbar-pipette-btn"; + singleBtn.id = "single-channel-btn-" + lhName; + singleBtn.title = "Single-Channel Pipettes"; + var singleImg = document.createElement("img"); + singleImg.src = "img/single_channel_pipette.png"; + singleImg.alt = "Single-Channel Pipettes"; + singleImg.style.width = "44px"; + singleImg.style.height = "44px"; + singleImg.style.objectFit = "contain"; + singleBtn.appendChild(singleImg); + actuatorBtns.appendChild(singleBtn); + + // Helper: position both panels based on the single-channel button position + function positionPanels(handlerName, singleBtnRef) { + var mainEl = document.querySelector("main"); + if (!mainEl) return; + var mainRect = mainEl.getBoundingClientRect(); + var btnRect = singleBtnRef.getBoundingClientRect(); + var topPx = (btnRect.bottom - mainRect.top + 20); + var singleCenterPx = (btnRect.left - mainRect.left + btnRect.width / 2); + + var singlePanel = document.getElementById("single-channel-dropdown-" + handlerName); + + // Measure single panel (temporarily show if hidden) + var singleW = 0, singleH = 0, singleLeft = singleCenterPx; + if (singlePanel) { + // Temporarily set left + transform so we can measure offsetWidth accurately + singlePanel.style.top = topPx + "px"; + singlePanel.style.left = singleCenterPx + "px"; + var wasHidden = !singlePanel.classList.contains("open"); + if (wasHidden) { singlePanel.style.visibility = "hidden"; singlePanel.classList.add("open"); } + singleW = singlePanel.offsetWidth; + singleH = singlePanel.offsetHeight; + singleLeft = singleCenterPx - singleW / 2; + if (wasHidden) { singlePanel.classList.remove("open"); singlePanel.style.visibility = ""; } + } + + // Determine multi-channel and arm panel widths for clamping + var multiPanel = document.getElementById("multi-channel-dropdown-" + handlerName); + var armPanel = document.getElementById("arm-dropdown-" + handlerName); + var multiW = 0; + if (multiPanel && multiPanel.classList.contains("open")) { + multiPanel.style.transform = "none"; + multiW = multiPanel.offsetWidth; + } + var armW = 0; + if (armPanel && armPanel.classList.contains("open")) { + armPanel.style.transform = "none"; + armW = armPanel.offsetWidth; + } + + // Clamp: ensure the leftmost panel doesn't go below 0 + var totalLeftEdge = singleLeft - (multiW > 0 ? multiW + 8 : 0); + if (totalLeftEdge < 0) { + singleLeft = singleLeft + (-totalLeftEdge); + } + + // Always position single panel with direct left (no CSS transform), + // because html2canvas misrenders translateX(-50%). + if (singlePanel) { + singlePanel.style.transform = "none"; + singlePanel.style.left = Math.max(0, singleLeft) + "px"; + } + + if (multiPanel && multiPanel.classList.contains("open")) { + multiPanel.style.top = topPx + "px"; + multiPanel.style.height = singleH > 0 ? singleH + "px" : "auto"; + multiPanel.style.left = Math.max(0, singleLeft - multiW - 8) + "px"; + } + + if (armPanel && armPanel.classList.contains("open")) { + armPanel.style.top = topPx + "px"; + armPanel.style.height = singleH > 0 ? singleH + "px" : "auto"; + var singleRight = singleLeft + singleW; + armPanel.style.left = (singleRight + 8) + "px"; + } + } + + // Single-channel dropdown panel + (function (btn, handlerName) { + var panelId = "single-channel-dropdown-" + handlerName; + btn.addEventListener("click", function () { + var existing = document.getElementById(panelId); + if (existing) { + var isOpen = existing.classList.toggle("open"); + btn.classList.toggle("active", isOpen); + positionPanels(handlerName, btn); + return; + } + var mainEl = document.querySelector("main"); + if (!mainEl) return; + var panel = document.createElement("div"); + panel.className = "actuator-dropdown open"; + panel.id = panelId; + var lhResource = resources[handlerName]; + var headState = (lhResource && lhResource.headState) ? lhResource.headState : {}; + fillHeadIcons(panel, headState); + mainEl.appendChild(panel); + btn.classList.add("active"); + positionPanels(handlerName, btn); + }); + })(singleBtn, lhName); + + // Multi-channel dropdown panel + (function (btn, handlerName, singleBtnRef) { + var panelId = "multi-channel-dropdown-" + handlerName; + btn.addEventListener("click", function () { + var existing = document.getElementById(panelId); + if (existing) { + var isOpen = existing.classList.toggle("open"); + btn.classList.toggle("active", isOpen); + if (isOpen) positionPanels(handlerName, singleBtnRef); + return; + } + var mainEl = document.querySelector("main"); + if (!mainEl) return; + var panel = document.createElement("div"); + panel.className = "actuator-dropdown multi-channel open"; + panel.id = panelId; + var lhResource = resources[handlerName]; + var head96State = (lhResource && lhResource.head96State) ? lhResource.head96State : {}; + fillHead96Grid(panel, head96State); + mainEl.appendChild(panel); + positionPanels(handlerName, singleBtnRef); + btn.classList.add("active"); + }); + })(multiBtn, lhName, singleBtn); + + // Integrated arm button (hidden unless setState has already confirmed actuator exists) + var armBtn = document.createElement("button"); + armBtn.className = "navbar-pipette-btn"; + armBtn.id = "arm-btn-" + lhName; + armBtn.style.display = (lhRes && lhRes.armState !== null && lhRes.armState !== undefined) ? "" : "none"; + armBtn.title = "Integrated Arms"; + var armImg = document.createElement("img"); + armImg.src = "img/integrated_arm.png"; + armImg.alt = "Integrated Arms"; + armImg.style.width = "44px"; + armImg.style.height = "44px"; + armImg.style.objectFit = "contain"; + armBtn.appendChild(armImg); + actuatorBtns.appendChild(armBtn); + + // Arm dropdown panel + (function (btn, handlerName, singleBtnRef) { + var panelId = "arm-dropdown-" + handlerName; + btn.addEventListener("click", function () { + var existing = document.getElementById(panelId); + if (existing) { + var isOpen = existing.classList.toggle("open"); + btn.classList.toggle("active", isOpen); + if (isOpen) positionPanels(handlerName, singleBtnRef); + return; + } + var mainEl = document.querySelector("main"); + if (!mainEl) return; + var panel = document.createElement("div"); + panel.className = "actuator-dropdown arm open"; + panel.id = panelId; + var lhResource = resources[handlerName]; + var armState = (lhResource && lhResource.armState) ? lhResource.armState : {}; + fillArmPanel(panel, armState); + mainEl.appendChild(panel); + positionPanels(handlerName, singleBtnRef); + btn.classList.add("active"); + }); + })(armBtn, lhName, singleBtn); + + container.appendChild(group); + } +} + +/** + * Programmatically open all visible actuator panels (single-channel, multi-channel, arm) + * for every LiquidHandler. Buttons that are hidden (actuator absent) are skipped. + */ +function openAllActuatorPanels() { + for (var name in resources) { + if (!(resources[name] instanceof LiquidHandler)) continue; + var btns = [ + document.getElementById("single-channel-btn-" + name), + document.getElementById("multi-channel-btn-" + name), + document.getElementById("arm-btn-" + name), + ]; + for (var i = 0; i < btns.length; i++) { + var btn = btns[i]; + if (btn && btn.style.display !== "none") { + btn.click(); + } + } + } +} diff --git a/pylabrobot/visualizer/main.css b/pylabrobot/visualizer/main.css index 3ec438f0b47..ee210112229 100644 --- a/pylabrobot/visualizer/main.css +++ b/pylabrobot/visualizer/main.css @@ -11,26 +11,760 @@ button { nav.navbar { height: 60px; + border-bottom: 3px solid #888; + padding: 0; +} + +nav.navbar .container-fluid { + justify-content: flex-start; + align-items: center; + height: 100%; + padding: 0; + gap: 0; +} + +.navbar-divider { + width: 1px; + height: 100%; + background: #ced4da; + flex-shrink: 0; +} + +nav.navbar .navbar-brand { + margin-right: 0; + margin-left: 12px; +} + + +nav.navbar .status-box { + margin-left: auto; + margin-right: 16px; +} + +#navbar-lh-actuators { + display: flex; + align-items: center; + gap: 8px; +} + +.navbar-pipette-group { + display: flex; + align-items: center; + border: 1.5px solid #ced4da; + border-radius: 6px; + background: #f8f9fa; + overflow: hidden; +} + +.navbar-pipette-label { + padding: 0 8px 0 10px; + font-size: 12px; + font-weight: 700; + color: #555; + white-space: nowrap; + border-right: 1px solid #ced4da; + border-top: none; + border-bottom: none; + border-left: none; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + line-height: 1.3; + background: transparent; + cursor: pointer; + font-family: inherit; +} + +.navbar-pipette-btn { + border: none; + background: transparent; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.15s, background 0.15s; + padding: 0; +} + +.navbar-pipette-btn:hover { + opacity: 1; + background: #dee2e6; +} + +.navbar-pipette-btn + .navbar-pipette-btn { + border-left: 1px solid #ced4da; +} + +.navbar-actuator-btns { + display: flex; + align-items: center; + overflow: hidden; + transition: max-width 0.25s ease, opacity 0.2s ease; + max-width: 500px; + opacity: 1; +} + +.navbar-actuator-btns.collapsed { + max-width: 0; + opacity: 0; +} + +.navbar-pipette-label.collapsed { + border-right: none; +} + +.navbar-pipette-btn.active { + background: #cfe2ff; + opacity: 1; +} + +.actuator-dropdown { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 2px solid #999; + border-radius: 8px; + padding: 16px 20px; + z-index: 5; + width: fit-content; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); + display: none; +} + +.actuator-dropdown.open { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: flex-start; + overflow: hidden; + box-sizing: border-box; +} + +.actuator-dropdown.multi-channel.open { + gap: 10px; + align-items: center; + justify-content: center; +} + +.actuator-dropdown.arm.open { + align-items: center; + justify-content: center; } .content { height: calc(100vh - 60px); margin: 0; + display: flex; } main { height: 100%; + flex: 1; + min-width: 0; + position: relative; +} + +#zoom-controls { + position: absolute; + bottom: 58px; + right: 12px; + display: flex; + flex-direction: column; + z-index: 5; +} + +#zoom-controls button { + width: 32px; + height: 32px; + border: 1px solid rgba(150, 150, 150, 0.4); + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: #111; + font-size: 22px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; + line-height: 1; + padding: 0; +} + +#zoom-in-btn { + border-radius: 6px 6px 0 0; + border-bottom: none; +} + +#zoom-out-btn { + border-radius: 0 0 6px 6px; +} + +#zoom-controls button:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.6); +} + +#home-button { + position: absolute; + top: 20px; + right: 75px; + cursor: pointer; + opacity: 0.55; + border-radius: 6px; + padding: 2px; + background: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: opacity 0.15s, transform 0.15s, background 0.15s, box-shadow 0.15s; + z-index: 5; + line-height: 0; +} + +#home-button:hover { + opacity: 1; + transform: scale(1.12); + background: rgba(0, 255, 255, 0.4); + box-shadow: 0 0 10px rgba(0, 255, 255, 0.8); +} + +#home-button.clicked { + background: rgba(255, 230, 0, 0.65); + box-shadow: 0 0 12px rgba(255, 230, 0, 0.9); + opacity: 1; +} + +#axis-legend { + position: absolute; + top: 8px; + right: 8px; + pointer-events: none; + opacity: 0.85; + background: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 6px; + padding: 2px; +} + +#scale-bar { + position: absolute; + bottom: 12px; + right: 12px; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + background: rgba(255, 255, 255, 0.35); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: 6px; + padding: 4px 8px; +} + +#scale-bar-line { + height: 8px; + border-left: 3px solid #333; + border-right: 3px solid #333; + border-bottom: 3px solid #333; +} + +#scale-bar-label { + font-size: 13px; + font-weight: 700; + color: #333; + margin-top: 2px; + white-space: nowrap; +} + +/* Toolbar (permanent left sidebar) */ +#toolbar-left { + width: 48px; + min-width: 48px; + height: 100%; + background: #dde1e5; + border-right: 1px solid #ced4da; + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + padding-top: 6px; + transition: width 0.15s, min-width 0.15s, padding 0.15s, border 0.15s; + overflow: hidden; +} + +.toolbar-left-toggle { + width: 46px; + min-width: 46px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; +} + +.toolbar-left-toggle:hover, +.toolbar-right-toggle:hover { + opacity: 1; + background: #dee2e6; +} + +.toolbar-right-toggle { + width: 47px; + min-width: 47px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; +} + +#toolbar-left.collapsed { + width: 0; + min-width: 0; + padding-top: 0; + border-right: none; + overflow: hidden; +} + +/* Toolbar (permanent right sidebar) */ +#toolbar { + width: 48px; + min-width: 48px; + height: 100%; + background: #dde1e5; + border-left: 1px solid #ced4da; + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + padding-top: 6px; +} + +.toolbar-section { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid #dee2e6; +} + +.toolbar-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: 1.5px solid #999; + background: transparent; + border-radius: 6px; + cursor: pointer; + opacity: 0.55; + transition: opacity 0.15s, background 0.15s, border-color 0.15s; +} + +.toolbar-btn:hover { + opacity: 1; + background: #ced4da; + border-color: #adb5bd; +} + +.toolbar-btn.active { + opacity: 1; + background: #cfe2ff; + border-color: #999; +} + +.tool-panel { + position: absolute; + top: 58px; + left: 8px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid #ced4da; + border-radius: 8px; + padding: 8px 12px; + font-size: 15px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; + display: flex; + flex-direction: column; + gap: 0; + z-index: 20; +} + +.tool-panel-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.tool-panel-title { + font-size: 15px; + font-weight: 700; + color: #555; + margin-bottom: 6px; + width: 100%; +} + +.tool-panel-axes { + display: flex; + flex-direction: row; + gap: 12px; +} + +.tool-panel-row-full { + flex-direction: row; + gap: 6px; + width: 105%; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #ced4da; + align-self: center; + justify-content: center; +} + +.tool-panel-row-full .tool-panel-select { + flex: 0 1 auto; + min-width: 80px; +} + +.tool-panel-select { + font-size: 14px; + font-family: inherit; + border: 1px solid #ced4da; + border-radius: 3px; + background: #f8f9fa; + color: #333; + padding: 1px 2px; + cursor: pointer; + text-align: center; +} + +.tool-panel-label { + font-weight: 700; + color: #555; + margin-right: 2px; +} + +.tool-panel-value { + color: #333; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +@keyframes pendingPulse { + 0%, 100% { opacity: 0.15; } + 50% { opacity: 0.55; } +} + +/* Sidepanel */ +#sidepanel { + position: relative; + width: 310px; + min-width: 150px; + max-width: 60vw; + height: 100%; + border-left: 1px solid #dee2e6; + background: #f8f9fa; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#sidepanel.collapsed { + width: 0 !important; + min-width: 0; + border-left: none; +} + +#sidepanel.collapsed .sidepanel-header, +#sidepanel.collapsed #resource-tree, +#sidepanel.collapsed .sidepanel-resize-handle { + display: none; +} + +.sidepanel-resize-handle { + position: absolute; + left: 0; + top: 0; + width: 5px; + height: 100%; + cursor: col-resize; + z-index: 10; +} + +.sidepanel-resize-handle:hover, +.sidepanel-resize-handle.dragging { + background: #0d6efd; + opacity: 0.4; +} + +.sidepanel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid #dee2e6; + background: #e9ecef; +} + +.sidepanel-title { + font-weight: 700; + font-size: 18px; + color: #333; +} + +.sidepanel-header-actions { + display: flex; + gap: 4px; +} + +.tree-action-btn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #ced4da; + background: #f8f9fa; + border-radius: 4px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; +} + +.tree-action-btn:hover { + opacity: 1; + background: #99DDFF; +} + +#collapse-all-btn { + width: 32px; + border-radius: 4px 0 0 4px; + border-right: none; +} + +.tree-depth-input { + width: 32px; + height: 26px; + text-align: center; + font-size: 13px; + font-weight: 600; + border: 1px solid #ced4da; + border-radius: 0 4px 4px 0; + background: #f8f9fa; + color: #333; + padding: 0 2px; + -moz-appearance: textfield; + transition: background 0.15s ease; +} + +.tree-depth-input:hover { + background: #99DDFF; +} + +.tree-depth-input::-webkit-outer-spin-button, +.tree-depth-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +#resource-tree { + flex: 1; + overflow-y: auto; + padding: 4px 0; + font-size: 17px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* Tree nodes */ +.tree-node { + user-select: none; +} + +.tree-node-row { + display: flex; + align-items: center; + padding: 3px 8px; + cursor: pointer; + white-space: nowrap; +} + +.tree-node-row:hover, +.tree-node-row.canvas-hover { + background: #dee2e6; +} + +.tree-node-row.selected { + background: #cfe2ff; +} + +.tree-node-arrow { + width: 16px; + font-size: 10px; + color: #666; + text-align: center; + flex-shrink: 0; +} + +.tree-node-arrow.has-children { + cursor: pointer; +} + +.tree-node-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + flex-shrink: 0; +} + +.tree-node-index { + min-width: 18px; + text-align: center; + font-size: 12px; + font-weight: 700; + color: #666; + margin-right: 5px; + flex-shrink: 0; +} + +.tree-node-row.empty-slot .tree-node-name.empty { + color: #aaa; + font-style: italic; + font-weight: 400; +} + +.tree-node-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + color: #333; + font-weight: 600; +} + +.tree-node-type { + margin-left: 6px; + font-size: 12px; + color: #999; + flex-shrink: 0; + font-weight: 400; +} + +.tree-node-info { + margin-left: 6px; + font-size: 14px; + color: #555; + flex-shrink: 0; + font-weight: 600; +} + +.tree-node-children { + display: block; +} + +.tree-node-children.collapsed { + display: none; +} + +/* Search view */ +#search-view { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.search-header { + padding: 8px 12px; + border-bottom: 1px solid #dee2e6; +} + +.search-input { + width: 100%; + padding: 8px 10px; + font-size: 17px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + border: 1px solid #ced4da; + border-radius: 5px; + background: #fff; + color: #333; + outline: none; +} + +.search-filters { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.search-filters-label { + font-size: 15px; + font-weight: 600; + color: #555; +} + +.search-filter-label { + display: flex; + align-items: center; + gap: 5px; + font-size: 15px; + color: #555; + cursor: pointer; + user-select: none; +} + +.search-input:focus { + border-color: #86b7fe; + box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.15); +} + +#search-results { + flex: 1; + overflow-y: auto; + padding: 4px 0; + font-size: 17px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } #kanvas { width: 100%; height: 100%; - overflow: scroll; + overflow: hidden; +} + +.status-box { + display: flex; + align-items: center; + gap: 8px; } #status-indicator { - height: 10px; - width: 10px; + height: 14px; + width: 14px; border-radius: 50%; display: inline-block; background-color: gray; @@ -38,22 +772,48 @@ main { #status-label { color: gray; + font-size: 18px; + font-weight: 600; } #status-indicator.connected { background-color: green; + animation: dot-glow-connected 4s ease-in-out infinite; } #status-indicator.disconnected { background-color: red; + animation: dot-glow-disconnected 4s ease-in-out infinite; } #status-label.connected { color: green; + animation: glow-connected 4s ease-in-out infinite; } #status-label.disconnected { color: red; + animation: glow-disconnected 4s ease-in-out infinite; +} + +@keyframes glow-connected { + 0%, 100% { text-shadow: none; } + 50% { text-shadow: 0 0 9px rgba(187, 204, 51, 0.7), 0 0 19px rgba(187, 204, 51, 0.4), 0 0 25px rgba(187, 204, 51, 0.15); } +} + +@keyframes glow-disconnected { + 0%, 100% { text-shadow: none; } + 50% { text-shadow: 0 0 9px rgba(238, 136, 102, 0.7), 0 0 19px rgba(238, 136, 102, 0.4), 0 0 25px rgba(238, 136, 102, 0.15); } +} + +@keyframes dot-glow-connected { + 0%, 100% { box-shadow: none; } + 50% { box-shadow: 0 0 9px rgba(187, 204, 51, 0.7), 0 0 19px rgba(187, 204, 51, 0.4), 0 0 25px rgba(187, 204, 51, 0.15); } +} + +@keyframes dot-glow-disconnected { + 0%, 100% { box-shadow: none; } + 50% { box-shadow: 0 0 9px rgba(238, 136, 102, 0.7), 0 0 19px rgba(238, 136, 102, 0.4), 0 0 25px rgba(238, 136, 102, 0.15); } } h3.form-row-header { @@ -62,14 +822,111 @@ h3.form-row-header { margin-bottom: 8px; } -/* gif recording */ +/* UML-style resource info panel */ +.uml-panel { + position: absolute; + top: 58px; + right: 8px; + width: 290px; + max-height: calc(100% - 70px); + overflow-y: auto; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid #ced4da; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; + font-size: 13px; + z-index: 6; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); +} + +.uml-header { + padding: 10px 12px 8px; +} + +.uml-header-name { + font-weight: 700; + font-size: 15px; + color: #222; +} + +.uml-header-type { + font-style: italic; + font-size: 12px; + color: #777; + margin-top: 1px; +} + +.uml-separator { + height: 1px; + background: #ced4da; + margin: 0; +} -.recording-stuff { +.uml-section { + padding: 6px 12px; +} + +.uml-section-title { + font-size: 11px; + font-weight: 700; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.uml-row { + display: flex; + align-items: baseline; + padding: 1px 0; + line-height: 1.4; +} + +.uml-key { + color: #777; + margin-right: 4px; + flex-shrink: 0; +} + +.uml-value { + color: #333; + font-weight: 600; + word-break: break-all; +} + +.uml-method { + color: #555; + padding: 1px 0; + line-height: 1.4; +} + +.uml-close-btn { + position: absolute; + top: 6px; + right: 8px; + width: 20px; + height: 20px; display: flex; align-items: center; justify-content: center; + border: none; + background: transparent; + cursor: pointer; + font-size: 16px; + color: #999; + border-radius: 3px; + line-height: 1; +} + +.uml-close-btn:hover { + background: #dee2e6; + color: #333; } +/* gif recording */ + .gif-box { display: flex; align-items: center; diff --git a/pylabrobot/visualizer/vis.js b/pylabrobot/visualizer/vis.js index 9c3c6a5a47b..67ac2592181 100644 --- a/pylabrobot/visualizer/vis.js +++ b/pylabrobot/visualizer/vis.js @@ -30,11 +30,27 @@ function setRootResource(data) { resource.location = { x: 0, y: 0, z: 0 }; resource.draw(resourceLayer); - // center the root resource on the stage. - let centerXOffset = (stage.width() - resource.size_x) / 2; - let centerYOffset = (stage.height() - resource.size_y) / 2; - stage.x(centerXOffset); - stage.y(-centerYOffset); + // Store globally so fitToViewport() can use it. + rootResource = resource; + + fitToViewport(); + + buildResourceTree(resource); +} + +// Save the full serialized resource data before it is destroyed. +// Called from the resource_unassigned handler while the resource and all its +// children are still intact. The serialized data is later used by buildSingleArm +// to create a live Konva stage using the exact same draw() code as the main canvas. +// Cost: one serialize() call per unassigned resource — negligible. +function snapshotResource(resourceName) { + var res = resources[resourceName]; + if (!res) return; + try { + resourceSnapshots[resourceName] = res.serialize(); + } catch (e) { + console.warn("[snapshot] failed for " + resourceName, e); + } } function removeResource(resourceName) { @@ -46,7 +62,15 @@ function setState(allStates) { for (let resourceName in allStates) { let state = allStates[resourceName]; let resource = resources[resourceName]; - resource.setState(state); + if (!resource) { + console.warn(`[setState] resource not found: ${resourceName}`); + continue; + } + try { + resource.setState(state); + } catch (e) { + console.error(`[setState] error for ${resourceName}:`, e); + } } } @@ -60,16 +84,30 @@ async function processCentralEvent(event, data) { resource = loadResource(data.resource); resource.draw(resourceLayer); setState(data.state); + addResourceToTree(resource); break; case "resource_unassigned": + // Snapshot the resource before destruction so the arm panel can show a + // pixel-perfect replica. Done here (not in destroy()) because the Konva + // group and all children are guaranteed intact at this point. + snapshotResource(data.resource_name); + removeResourceFromTree(data.resource_name); removeResource(data.resource_name); break; case "set_state": let allStates = data; setState(allStates); + // Rebuild the sidepanel tree so summaries reflect the updated state + const rootName = Object.keys(resources).find( + (n) => resources[n] && !resources[n].parent + ); + if (rootName) buildResourceTree(resources[rootName], { rebuildNavbar: false }); + break; + case "show_actuators": + openAllActuatorPanels(); break; default: diff --git a/pylabrobot/visualizer/visualizer.py b/pylabrobot/visualizer/visualizer.py index 5c70a9f8667..b32e86ec770 100644 --- a/pylabrobot/visualizer/visualizer.py +++ b/pylabrobot/visualizer/visualizer.py @@ -1,5 +1,6 @@ import asyncio import http.server +import inspect import json import logging import os @@ -20,10 +21,41 @@ from pylabrobot.__version__ import STANDARD_FORM_JSON_VERSION from pylabrobot.resources import Resource +from pylabrobot.resources.tip_tracker import set_tip_tracking +from pylabrobot.resources.volume_tracker import set_volume_tracking logger = logging.getLogger("pylabrobot") +def _get_public_methods(resource: Resource) -> list: + """Get public method signatures from a resource instance for the visualizer UI.""" + methods = [] + for name in dir(resource): + if name.startswith("_"): + continue + try: + attr = getattr(type(resource), name, None) + except Exception: + continue + if attr is None or not callable(attr) or isinstance(attr, property): + continue + try: + sig = inspect.signature(attr) + params = [p for p in sig.parameters if p != "self"] + methods.append(f"{name}({', '.join(params)})") + except (ValueError, TypeError): + methods.append(f"{name}()") + return sorted(methods) + + +def _serialize_with_methods(resource: Resource) -> dict: + """Serialize a resource and enrich with Python method signatures for the visualizer.""" + data = resource.serialize() + data["methods"] = _get_public_methods(resource) + data["children"] = [_serialize_with_methods(child) for child in resource.children] + return data + + class Visualizer: """A class for visualizing resources and their states in a web browser. @@ -45,6 +77,9 @@ def __init__( ws_port: int = 2121, fs_port: int = 1337, open_browser: bool = True, + name: Optional[str] = None, + favicon: Optional[str] = None, + show_actuators_at_start: bool = True, ): """Create a new Visualizer. Use :meth:`.setup` to start the visualization. @@ -55,9 +90,30 @@ def __init__( fs_port: The port of the file server. If this port is in use, the port will be incremented until a free port is found. open_browser: If `True`, the visualizer will open a browser window when it is started. + name: A custom name to display in the browser header. If ``None``, the filename of the + calling script or notebook is detected automatically. + favicon: Path to a ``.png`` file to use as the browser tab icon. If ``None``, the + PyLabRobot logo is used. + show_actuators_at_start: If ``True``, actuator popups (pipettes, arm) are opened automatically + when the visualizer starts. """ self.setup_finished = False + self._show_actuators_at_start = show_actuators_at_start + + if name is not None: + self._source_filename = name + else: + self._source_filename = self._detect_source_filename() + + if favicon is not None: + if not favicon.endswith(".png"): + raise ValueError("favicon must be a .png file") + if not os.path.isfile(favicon): + raise FileNotFoundError(f"favicon file not found: {favicon}") + self._favicon_path = os.path.abspath(favicon) + else: + self._favicon_path = os.path.join(os.path.dirname(__file__), "img", "logo.png") # Hook into the resource (un)assigned callbacks so we can send the appropriate events to the # browser. @@ -93,6 +149,9 @@ def register_state_update(resource): self._t: Optional[threading.Thread] = None self._stop_: Optional[asyncio.Future] = None + self._pending_state_updates: Dict[str, dict] = {} + self._flush_scheduled = False + self.received: List[dict] = [] @property @@ -182,7 +241,13 @@ def _assemble_command( "data": data, "event": event, } - return json.dumps(command_data), id_ + # Python's json.dumps outputs bare Infinity/-Infinity for float('inf'), which is + # not valid JSON and causes JSON.parse() in the browser to throw SyntaxError. + # Replace bare tokens with quoted strings so the browser's JSON reviver can handle them. + serialized = json.dumps(command_data) + serialized = serialized.replace(": Infinity", ': "Infinity"') + serialized = serialized.replace(": -Infinity", ': "-Infinity"') + return serialized, id_ def has_connection(self) -> bool: """Return `True` if a websocket connection has been established.""" @@ -255,6 +320,92 @@ def fst(self) -> threading.Thread: raise RuntimeError("The file server thread has not been started yet.") return self._fst + @staticmethod + def _detect_source_filename() -> str: + """Detect the filename of the calling script or notebook.""" + + # 1. VS Code sets __vsc_ipynb_file__ in the IPython user namespace. + try: + ipython = get_ipython() # type: ignore[name-defined] # noqa: F821 + vsc_file = getattr(ipython, "user_ns", {}).get("__vsc_ipynb_file__") + if vsc_file: + return os.path.basename(vsc_file) + except NameError: + pass + + # 2. Try ipynbname package (works for classic Jupyter Notebook and JupyterLab). + try: + import ipynbname # type: ignore[import-untyped] + + nb_path = ipynbname.path() + if nb_path: + return os.path.basename(str(nb_path)) + except Exception: + pass + + # 3. Query the Jupyter REST API using the kernel connection file. + try: + import json as _json + import re + import urllib.request + + import ipykernel # type: ignore[import-untyped] + + # Get the kernel id from the connection file path. + connection_file = ipykernel.get_connection_file() + kernel_id = os.path.basename(connection_file).replace("kernel-", "").replace(".json", "") + + # Try common Jupyter server ports and tokens. + # First, try to get server info from jupyter_core / notebook. + servers = [] + try: + from jupyter_server.serverapp import list_running_servers # type: ignore[import-untyped] + + servers = list(list_running_servers()) + except Exception: + pass + if not servers: + try: + from notebook.notebookapp import list_running_servers # type: ignore[import-untyped] + + servers = list(list_running_servers()) + except Exception: + pass + + for srv in servers: + base_url = srv.get("url", "").rstrip("/") + token = srv.get("token", "") + try: + api_url = f"{base_url}/api/sessions" + if token: + api_url += f"?token={token}" + req = urllib.request.Request(api_url) + with urllib.request.urlopen(req, timeout=2) as resp: + sessions = _json.loads(resp.read().decode()) + for sess in sessions: + kid = sess.get("kernel", {}).get("id", "") + if kid == kernel_id: + nb_path = sess.get("notebook", {}).get("path", "") or sess.get("path", "") + if nb_path: + return os.path.basename(nb_path) + except Exception: + continue + except Exception: + pass + + # 4. Fall back to stack inspection for .py scripts. + for frame_info in inspect.stack(): + fname = frame_info.filename + if fname == __file__: + continue + basename = os.path.basename(fname) + if "ipykernel" in fname or fname.startswith("<"): + return "" + if basename.endswith(".py"): + return basename + + return "" + async def setup(self): """Start the visualizer. @@ -264,6 +415,10 @@ async def setup(self): if self.setup_finished: raise RuntimeError("The visualizer has already been started.") + # Enable tip and volume tracking so the visualizer receives real-time state updates. + set_tip_tracking(True) + set_volume_tracking(True) + await self._run_ws_server() self._run_file_server() self.setup_finished = True @@ -318,7 +473,8 @@ def _run_file_server(self): ) def start_server(lock): - ws_port, fs_port = self.ws_port, self.fs_port + ws_port, fs_port, source_filename = self.ws_port, self.fs_port, self._source_filename + favicon_path = self._favicon_path # try to start the server. If the port is in use, try with another port until it succeeds. class QuietSimpleHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): @@ -330,6 +486,12 @@ def __init__(self, *args, **kwargs): def log_message(self, format, *args): pass + def end_headers(self): + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + super().end_headers() + def do_GET(self) -> None: # rewrite some info in the index.html file on the fly, # like a simple template engine @@ -339,11 +501,19 @@ def do_GET(self) -> None: content = content.replace("{{ ws_port }}", str(ws_port)) content = content.replace("{{ fs_port }}", str(fs_port)) + content = content.replace("{{ source_filename }}", source_filename) self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(content.encode("utf-8")) + elif self.path == "/favicon.png": + with open(favicon_path, "rb") as f: + data = f.read() + self.send_response(200) + self.send_header("Content-type", "image/png") + self.end_headers() + self.wfile.write(data) else: return super().do_GET() @@ -421,7 +591,7 @@ async def _send_resources_and_state(self): # send the serialized root resource (including all children) to the browser await self.send_command( "set_root_resource", - {"resource": self._root_resource.serialize()}, + {"resource": _serialize_with_methods(self._root_resource)}, wait_for_response=False, ) @@ -431,16 +601,18 @@ async def _send_resources_and_state(self): def save_resource_state(resource: Resource): """Recursively save the state of the resource and all child resources.""" - if hasattr(resource, "tracker"): - resource_state = resource.tracker.serialize() - if resource_state is not None: - state[resource.name] = resource_state + resource_state = resource.serialize_state() + if resource_state: + state[resource.name] = resource_state for child in resource.children: save_resource_state(child) save_resource_state(self._root_resource) await self.send_command("set_state", state, wait_for_response=False) + if self._show_actuators_at_start: + await self.send_command("show_actuators", {}, wait_for_response=False) + def _handle_resource_assigned_callback(self, resource: Resource) -> None: """Called when a resource is assigned to a resource already in the tree starting from the root resource. This method will send an event about the new resource""" @@ -458,7 +630,7 @@ def register_state_update(resource: Resource): # Send a `resource_assigned` event to the browser. data = { - "resource": resource.serialize(), + "resource": _serialize_with_methods(resource), "state": resource.serialize_all_state(), "parent_name": (resource.parent.name if resource.parent else None), } @@ -475,10 +647,19 @@ def _handle_resource_unassigned_callback(self, resource: Resource) -> None: asyncio.run_coroutine_threadsafe(fut, self.loop) def _handle_state_update_callback(self, resource: Resource) -> None: - """Called when the state of a resource is updated. This method will send an event to the - browser about the updated state.""" - - # Send a `set_state` event to the browser. - data = {resource.name: resource.serialize_state()} - fut = self.send_command(event="set_state", data=data, wait_for_response=False) - asyncio.run_coroutine_threadsafe(fut, self.loop) + """Called when the state of a resource is updated. Updates are batched so that + rapid successive changes (e.g. 96-channel pickup) are sent as a single message.""" + + self._pending_state_updates[resource.name] = resource.serialize_state() + if not self._flush_scheduled: + self._flush_scheduled = True + self.loop.call_soon_threadsafe(self._flush_state_updates) + + def _flush_state_updates(self) -> None: + """Send all pending state updates as a single ``set_state`` event.""" + data = self._pending_state_updates + self._pending_state_updates = {} + self._flush_scheduled = False + if data: + fut = self.send_command(event="set_state", data=data, wait_for_response=False) + asyncio.ensure_future(fut) diff --git a/pylabrobot/visualizer/visualizer_tests.py b/pylabrobot/visualizer/visualizer_tests.py index e97c88f1a7a..0503833fa92 100644 --- a/pylabrobot/visualizer/visualizer_tests.py +++ b/pylabrobot/visualizer/visualizer_tests.py @@ -14,6 +14,7 @@ Resource, ) from pylabrobot.visualizer import Visualizer +from pylabrobot.visualizer.visualizer import _serialize_with_methods class VisualizerSetupStopTests(unittest.IsolatedAsyncioTestCase): @@ -75,7 +76,7 @@ async def test_connect(self): { "event": "set_root_resource", "data": { - "resource": self.r.serialize(), + "resource": _serialize_with_methods(self.r), }, "id": "0001", "version": STANDARD_FORM_JSON_VERSION, @@ -86,6 +87,7 @@ async def test_event_sent(self): await self.client.send('{"event": "ready"}') _ = await self.client.recv() # set_root_resource _ = await self.client.recv() # set_state + _ = await self.client.recv() # show_actuators await self.vis.send_command("test", wait_for_response=False) recv = await self.client.recv() @@ -115,7 +117,7 @@ async def test_assign_child_resource(self): self.vis.send_command.assert_called_once_with( # type: ignore[attr-defined] event="resource_assigned", data={ - "resource": child.serialize(), + "resource": _serialize_with_methods(child), "state": child.serialize_all_state(), "parent_name": "root", },