Source code for qdk_chemistry.ui.visualization
"""MCP Apps visualisation tools for qdk-chemistry.
This module conditionally registers ``ui://`` resources backed by
the JavaScript components shipped with ``qsharp_widgets`` and
exposes interactive MCP tools:
* ``visualize_circuit`` - interactive quantum-circuit diagram
* ``visualize_orbital_entanglement`` - orbital-entanglement chord diagram
* ``visualize_molecule`` - interactive 3D molecule viewer
* ``visualize_orbitals`` - 3D molecule viewer with orbital isosurfaces
These tools are only registered when ``qsharp_widgets`` is installed.
The tools follow the same conventions as the rest of ``tools.py``:
they accept a ``project_name`` / filename pair, load a qdk/chemistry
data object, and return either an error string or a list of
``TextContent`` items with JSON data for the MCP Apps host.
"""
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
# ruff: noqa: ARG001, E501
# MCP tool functions accept ``project_name`` consumed by the
# ``@validate_project`` decorator.
from __future__ import annotations
import json
import pathlib
import re
import secrets
import threading
import time
from mcp.types import CallToolResult, TextContent
from qdk_chemistry import data
from .io import load_data_object
from .validation import strip_filename_path, validate_project
# ---------------------------------------------------------------------------
# Check for qsharp_widgets availability
# ---------------------------------------------------------------------------
try:
import qsharp_widgets as _qsharp_widgets
_WIDGETS_AVAILABLE = True
except ImportError:
_WIDGETS_AVAILABLE = False
# ---------------------------------------------------------------------------
# Locate the JavaScript / CSS bundle shipped with qsharp_widgets
# ---------------------------------------------------------------------------
_WIDGETS_CACHE: dict[str, pathlib.Path] = {}
def _widgets_static_dir() -> pathlib.Path:
"""Return the ``qsharp_widgets/static`` directory, raising if missing."""
if "static" not in _WIDGETS_CACHE:
_WIDGETS_CACHE["static"] = pathlib.Path(_qsharp_widgets.__file__).parent / "static"
return _WIDGETS_CACHE["static"]
def _json_script(identifier: str, value: object) -> str:
"""Return *value* as an inert, HTML-safe JSON script element."""
serialized = (
json.dumps(value, ensure_ascii=True).replace("<", r"\u003c").replace(">", r"\u003e").replace("&", r"\u0026")
)
return f'<script id="{identifier}" type="application/json">{serialized}</script>'
# ---------------------------------------------------------------------------
# HTML builder (uses MCP Apps SDK + patched widget bundle)
# ---------------------------------------------------------------------------
def _build_html(
*,
title: str,
component_name: str,
app_name: str,
embedded_data: dict | None = None,
min_height: int = 500,
) -> str:
"""Build a self-contained HTML page for a qsharp-widgets component."""
static = _widgets_static_dir()
js_text = (static / "index.js").read_text(encoding="utf-8")
css_text = (static / "index.css").read_text(encoding="utf-8")
m = re.search(r"export\{(\w+) as default,(\w+) as mdRenderer\}", js_text)
if m:
default_var, md_var = m.group(1), m.group(2)
js_patched = js_text.replace(
m.group(0),
f"window.__qdk_widget={{default:{default_var},mdRenderer:{md_var}}}",
)
else:
js_patched = js_text
return (
"<!DOCTYPE html>\n"
'<html lang="en">\n'
"<head>\n"
'<meta charset="utf-8" />\n'
'<meta name="viewport" content="width=device-width, initial-scale=1" />\n'
"<title></title>\n"
"<style>\n"
" :root { color-scheme: light dark; }\n"
" html, body {\n"
" margin: 0; padding: 0;\n"
" font-family: system-ui, -apple-system, sans-serif;\n"
" }\n"
" #widget-root {\n"
" width: 100%;\n"
" }\n"
" #loading { text-align: center; padding: 2em; opacity: 0.6; }\n"
" .widget-error { padding: 16px; background: #3a1a1a; color: #f88;\n"
" border: 1px solid #f44; border-radius: 8px; font-family: monospace;\n"
" font-size: 13px; white-space: pre-wrap; word-break: break-word; }\n" + css_text + "\n</style>\n"
"</head>\n"
"<body>\n"
' <div id="widget-root"><div id="loading">Loading widget\u2026</div></div>\n'
"\n"
' <script type="module">\n' + js_patched + "\n </script>\n"
"\n"
' <script type="module">\n'
" try {\n"
' const config = JSON.parse(document.getElementById("widget-config").textContent);\n'
" document.title = config.title;\n"
" async function waitForWidget(ms = 5000) {\n"
" const t0 = Date.now();\n"
" while (!window.__qdk_widget && Date.now() - t0 < ms)\n"
" await new Promise(r => setTimeout(r, 50));\n"
" return window.__qdk_widget;\n"
" }\n"
"\n"
" const widgetModule = await waitForWidget();\n"
" if (!widgetModule) {\n"
' document.getElementById("loading").textContent = "Failed to load widget JS";\n'
' throw new Error("widget bundle did not load");\n'
" }\n"
"\n"
" function renderToolData(data) {\n"
" const widgetType = data.__widget_type || config.componentName;\n"
" let stateKeys;\n"
' if (widgetType === "MoleculeViewer") {\n'
" stateKeys = {\n"
' comp: "MoleculeViewer",\n'
" molecule_data: data.molecule_data,\n"
" cube_data: data.cube_data ?? {},\n"
" isoval: data.isoval ?? 0.02,\n"
" };\n"
' } else if (widgetType === "Circuit") {\n'
" stateKeys = {\n"
' comp: "Circuit",\n'
" ...data,\n"
" };\n"
" } else {\n"
" stateKeys = {\n"
" comp: widgetType,\n"
" ...data,\n"
" };\n"
" }\n"
"\n"
" const model = {\n"
" get(key) { return stateKeys[key]; },\n"
" set(key, val) { stateKeys[key] = val; },\n"
" save_changes() {},\n"
" on() {},\n"
" send() {},\n"
" };\n"
"\n"
' const el = document.getElementById("widget-root");\n'
' el.innerHTML = "";\n'
' if (widgetType === "MoleculeViewer") {\n'
' el.style.minHeight = "400px";\n'
" }\n"
" widgetModule.default.render({ model, el });\n"
" }\n"
"\n"
+ (
" renderToolData(config.embeddedData);\n"
if embedded_data is not None
else ' document.getElementById("loading")?.remove();\n'
)
+ " } catch(e) {\n"
' const el = document.getElementById("loading") || document.getElementById("widget-root");\n'
" if (el) {\n"
' const error = document.createElement("div");\n'
' error.className = "widget-error";\n'
' error.textContent = "Error: " + e.message + "\\n\\n" + e.stack;\n'
" el.replaceChildren(error);\n"
" }\n"
" }\n"
" </script>\n"
"</body>\n"
"</html>".replace(
"</body>",
_json_script(
"widget-config", {"title": title, "componentName": component_name, "embeddedData": embedded_data}
)
+ "\n</body>",
)
)
# ---------------------------------------------------------------------------
# Widget data bridge: sync tool data → resource HTML
# ---------------------------------------------------------------------------
class _WidgetBridge:
"""Store tool payloads for unique, single-use ``ui://`` resources.
Each call to :meth:`send` receives an unguessable resource URI. The
matching resource handler removes the payload before rendering it, so
concurrent clients cannot overwrite or retrieve each other's data.
"""
def __init__(
self,
*,
resource_uri: str,
component_name: str,
app_name: str,
title: str,
min_height: int = 500,
payload_ttl: float = 300,
) -> None:
self.resource_uri_template = f"{resource_uri}/{{token}}"
self._resource_uri = resource_uri
self._component_name = component_name
self._app_name = app_name
self._title = title
self._min_height = min_height
self._payload_ttl = payload_ttl
self._payloads: dict[str, tuple[float, dict]] = {}
self._lock = threading.Lock()
def _discard_expired(self, now: float) -> None:
expired = [token for token, (created, _) in self._payloads.items() if now - created >= self._payload_ttl]
for token in expired:
del self._payloads[token]
# Called by the tool
def send(self, payload: dict) -> CallToolResult:
"""Store *payload* and return its unique resource URI."""
token = secrets.token_urlsafe(32)
now = time.monotonic()
with self._lock:
self._discard_expired(now)
self._payloads[token] = (now, dict(payload))
resource_uri = f"{self._resource_uri}/{token}"
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(payload))],
structuredContent=payload,
meta={
"ui": {"resourceUri": resource_uri},
"ui/resourceUri": resource_uri,
},
)
def receive(self, token: str) -> dict | None:
"""Remove and return the payload for *token*, if it is still valid."""
now = time.monotonic()
with self._lock:
self._discard_expired(now)
entry = self._payloads.pop(token, None)
return entry[1] if entry is not None else None
# Called by the resource handler
def receive_html(self, token: str) -> str:
"""Consume *token* and return self-contained HTML for its payload."""
payload = self.receive(token)
if payload is None:
return _expired_visualization_html()
return _build_html(
title=self._title,
component_name=self._component_name,
app_name=self._app_name,
embedded_data=payload,
min_height=self._min_height,
)
def _expired_visualization_html() -> str:
"""Return a non-sensitive response for an expired or consumed URI."""
return "<html><body><p>This visualization has expired or was already opened.</p></body></html>"
# ---------------------------------------------------------------------------
# Public API: register resources and tools on the MCPServer app
# ---------------------------------------------------------------------------
[docs]
def register_visualization_tools(app) -> None:
"""Register interactive widget-based visualization tools on an MCP server.
Tools are registered only when ``qsharp_widgets`` is installed. Otherwise,
this function is a no-op.
Args:
app: MCP server application that receives the widget resources and
tools.
"""
if not _WIDGETS_AVAILABLE:
return
# ── Circuit viewer ────────────────────────────────────────────
_circuit_bridge = _WidgetBridge(
resource_uri="ui://qdk-chem-mcp/circuit-viewer",
component_name="Circuit",
app_name="qdk-circuit-viewer",
title="Circuit Viewer",
min_height=600,
)
@app.resource(
_circuit_bridge.resource_uri_template,
name="circuit_viewer",
description="Interactive quantum-circuit diagram (qsharp-widgets Circuit component)",
mime_type="text/html;profile=mcp-app",
)
def circuit_viewer_resource(token: str) -> str:
return _circuit_bridge.receive_html(token)
@app.tool(
description="Render a saved Circuit in VS Code MCP Apps.",
meta={
"ui": {"resourceUri": _circuit_bridge.resource_uri_template},
"ui/resourceUri": _circuit_bridge.resource_uri_template,
},
structured_output=False,
)
@validate_project
def visualize_circuit(
project_name: str,
circuit_filename: str,
) -> str | list:
"""Render a saved Circuit in VS Code MCP Apps."""
circuit_filename = strip_filename_path(circuit_filename)
try:
circuit_obj = load_data_object(circuit_filename, data.Circuit)
except (RuntimeError, ValueError) as e:
return f"Failed to load circuit from {circuit_filename}: {e!s}"
# Convert the qdk-chemistry Circuit to the widget-compatible format:
# 1. Extract QASM via circuit_obj.get_qasm()
# 2. Convert to a qsharp Circuit via qdk.openqasm.circuit()
# 3. Serialise with .json() → widget-compatible JSON string
from qdk.openqasm import circuit as openqasm_circuit # noqa: PLC0415
try:
qasm_str = circuit_obj.get_qasm()
qsharp_circuit = openqasm_circuit(qasm_str)
circuit_json_str = qsharp_circuit.json()
except Exception as e: # noqa: BLE001
return (
f"Cannot build circuit visualisation from {circuit_filename}: {e!s}. "
f"Make sure the file contains a valid qdk/chemistry Circuit object."
)
circuit_data = {
"circuit_json": circuit_json_str,
}
return _circuit_bridge.send(circuit_data)
# ── Orbital-entanglement chord diagram ────────────────────────
_entanglement_bridge = _WidgetBridge(
resource_uri="ui://qdk-chem-mcp/orbital-entanglement",
component_name="Entanglement",
app_name="qdk-orbital-entanglement",
title="Orbital Entanglement",
min_height=700,
)
@app.resource(
_entanglement_bridge.resource_uri_template,
name="orbital_entanglement",
description="Interactive orbital-entanglement chord diagram (qsharp-widgets Entanglement component)",
mime_type="text/html;profile=mcp-app",
)
def orbital_entanglement_resource(token: str) -> str:
return _entanglement_bridge.receive_html(token)
@app.tool(
description="Render RDM/MI orbital entanglement with absolute indices in VS Code MCP Apps.",
meta={
"ui": {"resourceUri": _entanglement_bridge.resource_uri_template},
"ui/resourceUri": _entanglement_bridge.resource_uri_template,
},
structured_output=False,
)
@validate_project
def visualize_orbital_entanglement(
project_name: str,
wavefunction_filename: str,
selected_indices: list[int] | None = None,
group_selected: bool = False,
mi_threshold: float | None = None,
) -> str | list:
"""Render RDM/MI orbital entanglement with absolute indices in VS Code MCP Apps."""
wavefunction_filename = strip_filename_path(wavefunction_filename)
try:
wavefunction = load_data_object(wavefunction_filename, data.Wavefunction)
except (RuntimeError, ValueError) as e:
return f"Failed to load wavefunction from {wavefunction_filename}: {e!s}"
# ── Convert absolute orbital indices to diagram-relative positions ──
# The widget labels arcs with absolute orbital indices (e.g., 6..11)
# but its selected_indices parameter expects 0-based positions into
# the diagram (0 = first arc, 1 = second arc, ...).
#
# We build the label list first, then map the caller's absolute
# indices to their positions in that list.
diagram_selected = None
if selected_indices is not None:
try:
import numpy as _np # noqa: PLC0415
n_entropies = len(_np.asarray(wavefunction.get_single_orbital_entropies()))
# Build the same label list the widget would generate
try:
orbitals = wavefunction.get_orbitals()
if orbitals.has_active_space():
active_indices = list(orbitals.get_active_space_indices()[0])
else:
active_indices = list(range(n_entropies))
except (AttributeError, TypeError, IndexError):
active_indices = list(range(n_entropies))
# Map absolute indices → diagram positions
abs_to_pos = {abs_idx: pos for pos, abs_idx in enumerate(active_indices)}
diagram_selected = []
bad_indices = []
for idx in selected_indices:
if idx in abs_to_pos:
diagram_selected.append(abs_to_pos[idx])
else:
bad_indices.append(idx)
if bad_indices:
return (
f"selected_indices {bad_indices} are not valid absolute orbital indices "
f"for this wavefunction. Valid absolute indices are: {active_indices}"
)
except (RuntimeError, ValueError, AttributeError):
# Fall back to treating them as-is (diagram-relative)
diagram_selected = selected_indices
# Use the Entanglement widget to extract all data from the
# wavefunction. The widget is a Python-side convenience that maps
# wavefunction data onto ChordDiagram traitlets — it handles
# entropy/MI extraction, orbital labels, and default options.
from qsharp_widgets import Entanglement # noqa: PLC0415
opts: dict = {}
if group_selected:
opts["group_selected"] = group_selected
if mi_threshold is not None:
opts["mi_threshold"] = mi_threshold
try:
widget = Entanglement(
wavefunction=wavefunction,
selected_indices=diagram_selected,
**opts,
)
except (RuntimeError, ValueError, AttributeError) as e:
return (
f"Cannot build orbital-entanglement diagram from {wavefunction_filename}: {e!s}. "
f"Make sure the wavefunction was produced by a multi-configurational calculation "
f"with calculate_one_rdm=True, calculate_two_rdm=True, and "
f"calculate_mutual_information=True."
)
# Read the traitlet values the widget computed
s1_entropies = list(widget.s1_entropies)
mutual_info = [list(row) for row in widget.mutual_information]
labels = list(widget.labels)
options = dict(widget.options)
entanglement_data = {
"s1_entropies": s1_entropies,
"mutual_information": mutual_info,
"labels": labels,
"selected_indices": list(widget.selected_indices) if widget.selected_indices else None,
"options": options,
}
return _entanglement_bridge.send(entanglement_data)
# ── Molecule viewer ───────────────────────────────────────────
molecule_viewer_uri = "ui://qdk-chem-mcp/molecule-viewer"
_molecule_bridge = _WidgetBridge(
resource_uri=molecule_viewer_uri,
component_name="MoleculeViewer",
app_name="qdk-molecule-viewer",
title="Molecule Viewer",
min_height=550,
)
@app.resource(
_molecule_bridge.resource_uri_template,
name="molecule_viewer",
description="Interactive 3D molecule viewer (qsharp-widgets MoleculeViewer component)",
mime_type="text/html;profile=mcp-app",
)
def molecule_viewer_resource(token: str) -> str:
return _molecule_bridge.receive_html(token)
@app.tool(
description="Render a saved Structure in VS Code MCP Apps.",
meta={
"ui": {"resourceUri": _molecule_bridge.resource_uri_template},
"ui/resourceUri": _molecule_bridge.resource_uri_template,
},
structured_output=False,
)
@validate_project
def visualize_molecule(
project_name: str,
structure_filename: str,
) -> str | list:
"""Render a saved Structure in VS Code MCP Apps."""
structure_filename = strip_filename_path(structure_filename)
try:
structure = load_data_object(structure_filename, data.Structure)
except (RuntimeError, ValueError) as e:
return f"Failed to load structure from {structure_filename}: {e!s}"
xyz_str = structure.to_xyz()
payload = {
"__widget_type": "MoleculeViewer",
"molecule_data": xyz_str,
"cube_data": {},
"isoval": 0.02,
}
return _molecule_bridge.send(payload)
# ── Orbital viewer (molecule + orbital isosurfaces) ───────────
@app.tool(
description="Render saved Wavefunction orbitals in VS Code MCP Apps.",
meta={
"ui": {"resourceUri": _molecule_bridge.resource_uri_template},
"ui/resourceUri": _molecule_bridge.resource_uri_template,
},
structured_output=False,
)
@validate_project
def visualize_orbitals(
project_name: str,
wavefunction_filename: str,
orbital_indices: list[int] | None = None,
isoval: float = 0.02,
grid_size: int = 40,
) -> str | list:
"""Render saved Wavefunction orbitals in VS Code MCP Apps."""
wavefunction_filename = strip_filename_path(wavefunction_filename)
try:
wavefunction = load_data_object(wavefunction_filename, data.Wavefunction)
except (RuntimeError, ValueError) as e:
return f"Failed to load wavefunction from {wavefunction_filename}: {e!s}"
# Extract orbitals and structure from the wavefunction
try:
orbitals = wavefunction.get_orbitals()
structure = orbitals.get_basis_set().get_structure()
except (RuntimeError, AttributeError) as e:
return f"Cannot extract structure/orbitals from {wavefunction_filename}: {e!s}"
# Generate XYZ string for the molecule
xyz_str = structure.to_xyz()
# Generate cube data for the requested orbitals
from qdk_chemistry.utils.cubegen import generate_cubefiles_from_orbitals # noqa: PLC0415
try:
cube_data = generate_cubefiles_from_orbitals(
orbitals,
output_folder=None, # return dict[label, cube_content]
indices=orbital_indices,
grid_size=(grid_size, grid_size, grid_size),
)
except (RuntimeError, ValueError) as e:
return f"Failed to generate cube data: {e!s}"
payload = {
"__widget_type": "MoleculeViewer",
"molecule_data": xyz_str,
"cube_data": cube_data,
"isoval": isoval,
}
return _molecule_bridge.send(payload)
# ── Scatter plot (inline SVG) ─────────────────────────────────
def _build_plotly_html(payload: dict) -> str:
"""Build a self-contained HTML page with an inline SVG scatter plot.
Uses pure SVG + vanilla JS (no CDN) so it works in VS Code
webview sandboxed iframes.
"""
import math # noqa: PLC0415
title = payload.get("title", "Scatter Plot")
x_label = payload.get("x_label", "X")
y_label = payload.get("y_label", "Y")
log_x = payload.get("log_x", False)
log_y = payload.get("log_y", False)
series_list = payload.get("series", [])
# Collect all x/y values to compute axis ranges
all_x: list[float] = []
all_y: list[float] = []
for s in series_list:
all_x.extend(s.get("x", []))
all_y.extend(s.get("y", []))
if not all_x or not all_y:
return "<html><body><p>No data to plot.</p></body></html>"
if log_x:
all_x = [math.log10(v) if v > 0 else 0 for v in all_x]
if log_y:
all_y = [math.log10(v) if v > 0 else 0 for v in all_y]
x_min, x_max = min(all_x), max(all_x)
y_min, y_max = min(all_y), max(all_y)
x_pad = (x_max - x_min) * 0.08 or 1
y_pad = (y_max - y_min) * 0.08 or 1
x_min -= x_pad
x_max += x_pad
y_min -= y_pad
y_max += y_pad
# SVG layout
w, h = 700, 450
ml, mr, mt, mb = 80, 30, 50, 60 # margins
pw = w - ml - mr
ph = h - mt - mb
colors = ["#89b4fa", "#f38ba8", "#a6e3a1", "#fab387", "#cba6f7", "#94e2d5", "#f9e2af", "#74c7ec"]
def _nice_ticks(lo: float, hi: float, n: int = 5) -> list[float]:
rng = hi - lo
if rng <= 0:
return [lo]
raw = rng / n
mag = 10 ** math.floor(math.log10(raw))
for step in (1, 2, 5, 10):
s = step * mag
if rng / s <= n + 1:
break
start = math.ceil(lo / s) * s
ticks = []
v = start
while v <= hi + s * 0.01:
ticks.append(round(v, 10))
v += s
return ticks
x_ticks = _nice_ticks(x_min, x_max)
y_ticks = _nice_ticks(y_min, y_max)
chart = {
"title": title,
"xLabel": x_label,
"yLabel": y_label,
"logX": log_x,
"logY": log_y,
"series": series_list,
"layout": {"width": w, "height": h, "marginLeft": ml, "marginTop": mt, "plotWidth": pw, "plotHeight": ph},
"xRange": [x_min, x_max],
"yRange": [y_min, y_max],
"xTicks": x_ticks,
"yTicks": y_ticks,
"colors": colors,
}
return (
"<!DOCTYPE html>\n"
'<html lang="en">\n'
"<head>\n"
'<meta charset="utf-8"/>\n'
'<meta name="viewport" content="width=device-width,initial-scale=1"/>\n'
"<title></title>\n"
"<style>\n"
" html, body { margin:0; padding:0; background:#1e1e2e;\n"
" width:100%; height:100%; overflow:hidden; }\n"
" svg { display:block; width:100%; height:100%; }\n"
" #tooltip { position:fixed; pointer-events:none; display:none;\n"
" background:#313244; color:#cdd6f4; padding:8px 12px;\n"
" border-radius:6px; font-size:12px; font-family:system-ui,sans-serif;\n"
" box-shadow:0 2px 8px rgba(0,0,0,0.4); z-index:100;\n"
" max-width:260px; line-height:1.5; }\n"
"</style>\n"
"</head>\n"
"<body>\n"
'<div id="tooltip"></div>\n' + _json_script("scatter-plot-data", chart) + "\n<script>\n"
"const chart=JSON.parse(document.getElementById('scatter-plot-data').textContent);\n"
"document.title=chart.title;\n"
"const svgNs='http://www.w3.org/2000/svg', svg=document.createElementNS(svgNs,'svg');\n"
"const tip=document.getElementById('tooltip');\n"
"const {width:w,height:h,marginLeft:ml,marginTop:mt,plotWidth:pw,plotHeight:ph}=chart.layout;\n"
"const [xMin,xMax]=chart.xRange,[yMin,yMax]=chart.yRange;\n"
"svg.setAttribute('viewBox',`0 0 ${w} ${h}`); svg.setAttribute('preserveAspectRatio','xMidYMid meet');\n"
"function node(name,attrs={},text){const el=document.createElementNS(svgNs,name);for(const [key,value] of Object.entries(attrs))el.setAttribute(key,String(value));if(text!==undefined)el.textContent=text;svg.append(el);return el;}\n"
"function tx(value){return ml+(value-xMin)/(xMax-xMin)*pw;} function ty(value){return mt+ph-(value-yMin)/(yMax-yMin)*ph;}\n"
"function xText(value){return chart.logX ? (10**value).toFixed(0) : String(value);} function yText(value){return chart.logY ? (10**value).toExponential(1) : String(value);}\n"
"node('rect',{width:w,height:h,fill:'#1e1e2e'}); node('rect',{x:ml,y:mt,width:pw,height:ph,fill:'#181825',stroke:'#45475a','stroke-width':1});\n"
"for(const value of chart.xTicks){const x=tx(value);if(x>=ml&&x<=ml+pw){node('line',{x1:x,y1:mt,x2:x,y2:mt+ph,stroke:'#313244','stroke-width':.5});node('text',{x,y:mt+ph+16,'text-anchor':'middle',fill:'#a6adc8','font-size':11},xText(value));}}\n"
"for(const value of chart.yTicks){const y=ty(value);if(y>=mt&&y<=mt+ph){node('line',{x1:ml,y1:y,x2:ml+pw,y2:y,stroke:'#313244','stroke-width':.5});node('text',{x:ml-8,y:y+4,'text-anchor':'end',fill:'#a6adc8','font-size':11},yText(value));}}\n"
"node('text',{x:w/2,y:28,'text-anchor':'middle',fill:'#cdd6f4','font-size':15,'font-weight':600},chart.title);\n"
"node('text',{x:ml+pw/2,y:h-8,'text-anchor':'middle',fill:'#a6adc8','font-size':12},chart.xLabel);\n"
"node('text',{x:16,y:mt+ph/2,'text-anchor':'middle',fill:'#a6adc8','font-size':12,transform:`rotate(-90,16,${mt+ph/2})`},chart.yLabel);\n"
"let activePt; const xH=node('line',{x1:ml,y1:0,x2:ml+pw,y2:0,stroke:'#585b70','stroke-width':.5,'stroke-dasharray':'4,3',visibility:'hidden'}),xV=node('line',{x1:0,y1:mt,x2:0,y2:mt+ph,stroke:'#585b70','stroke-width':.5,'stroke-dasharray':'4,3',visibility:'hidden'});\n"
"function tooltip(point){tip.replaceChildren();const label=document.createElement('b');label.textContent=point.label;tip.append(label,document.createElement('br'),document.createTextNode(`${chart.xLabel}: ${point.x}`),document.createElement('br'),document.createTextNode(`${chart.yLabel}: ${point.y}`));}\n"
"for(const [index,series] of chart.series.entries()){const color=chart.colors[index%chart.colors.length],points=[];for(let i=0;i<Math.min(series.x.length,series.y.length);i++){const x=series.x[i],y=series.y[i],px=tx(chart.logX&&x>0?Math.log10(x):x),py=ty(chart.logY&&y>0?Math.log10(y):y);points.push({x,y,px,py,label:series.text?.[i]||`${x}, ${y}`});}if(series.mode?.includes('lines')&&points.length>1)node('path',{d:points.map((point,i)=>`${i?'L':'M'}${point.px.toFixed(1)},${point.py.toFixed(1)}`).join(' '),fill:'none',stroke:color,'stroke-width':2,opacity:.7});if(series.mode?.includes('markers')??true)for(const point of points){const circle=node('circle',{cx:point.px.toFixed(1),cy:point.py.toFixed(1),r:(series.marker_size??8)/2,fill:color,opacity:.9});circle.style.cursor='pointer';circle.addEventListener('mouseenter',()=>{if(activePt)activePt.setAttribute('r',activePt.dataset.origR);activePt=circle;circle.dataset.origR=circle.getAttribute('r');circle.setAttribute('r',String(Number(circle.dataset.origR)*1.8));circle.setAttribute('opacity','1');tooltip(point);tip.style.display='block';xH.setAttribute('y1',String(point.py));xH.setAttribute('y2',String(point.py));xH.setAttribute('visibility','visible');xV.setAttribute('x1',String(point.px));xV.setAttribute('x2',String(point.px));xV.setAttribute('visibility','visible');});circle.addEventListener('mousemove',event=>{tip.style.left=`${event.clientX+16}px`;tip.style.top=`${event.clientY-12}px`;});circle.addEventListener('mouseleave',()=>{circle.setAttribute('r',circle.dataset.origR);circle.setAttribute('opacity','.9');tip.style.display='none';xH.setAttribute('visibility','hidden');xV.setAttribute('visibility','hidden');activePt=null;});}}\n"
"if(chart.series.length>1||chart.series[0]?.name){let y=mt+12;for(const [index,series] of chart.series.entries()){const color=chart.colors[index%chart.colors.length];node('rect',{x:ml+pw-140,y,width:10,height:10,fill:color,rx:2});node('text',{x:ml+pw-125,y:y+9,fill:'#cdd6f4','font-size':11},series.name||`Series ${index+1}`);y+=18;}}\n"
"document.body.prepend(svg);\n"
"</script>\n"
"</body>\n"
"</html>"
)
_scatter_bridge = _WidgetBridge(
resource_uri="ui://qdk-chem-mcp/scatter-plot",
component_name="ScatterPlot",
app_name="qdk-scatter-plot",
title="Scatter Plot",
)
@app.resource(
_scatter_bridge.resource_uri_template,
name="scatter_plot",
description="Interactive Plotly scatter plot with optional log axes and multiple series",
mime_type="text/html;profile=mcp-app",
)
def scatter_plot_resource(token: str) -> str:
payload = _scatter_bridge.receive(token)
return _build_plotly_html(payload) if payload is not None else _expired_visualization_html()
@app.tool(
description="Render numeric series as an SVG scatter plot in VS Code MCP Apps.",
meta={
"ui": {"resourceUri": _scatter_bridge.resource_uri_template},
"ui/resourceUri": _scatter_bridge.resource_uri_template,
},
structured_output=False,
)
def visualize_scatter_plot(
series: list[dict],
title: str = "Scatter Plot",
x_label: str = "X",
y_label: str = "Y",
log_x: bool = False,
log_y: bool = False,
) -> CallToolResult:
"""Render numeric series as an SVG scatter plot in VS Code MCP Apps."""
payload = {
"title": title,
"x_label": x_label,
"y_label": y_label,
"log_x": log_x,
"log_y": log_y,
"series": series,
}
return _scatter_bridge.send(payload)