"""A thin-layer chromatography plate drawn from ordinary shapes and text (#91).
The plate is the canvas engine's own objects: a rectangle for the plate,
lines for the baseline and the solvent front, an ellipse per spot and text
for the Rf values and lane labels. So the canvas shows it, edits move it,
and figures print it like any other drawing; nothing special is stored.
A spot at retention factor Rf sits Rf of the way from the baseline to the
solvent front, the definition of Rf as the distance the compound travelled
over the distance the solvent travelled (IUPAC Gold Book, "retardation
factor in planar chromatography", doi:10.1351/goldbook.R05351).
"""
from __future__ import annotations
import copy
from collections.abc import Sequence
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
from vibesketch.document.model import Canvas, Document
__all__ = ["Lane", "TlcRefused", "with_tlc_plate"]
#: Plate geometry, in bond lengths.
_LANE = 2.6
_MARGIN = 0.4
_HEIGHT = 5.0
_BASELINE = 0.8
_FRONT = 0.5
_SPOT = (0.26, 0.17)
#: Space between the drawing and the plate.
_GAP = 2.0
[docs]
class TlcRefused(ValueError):
"""A plate that cannot be drawn as asked."""
[docs]
class Lane(BaseModel):
"""One lane: its label under the plate and the Rf of each spot in it."""
model_config = ConfigDict(extra="forbid")
label: Annotated[str, StringConstraints(min_length=1, max_length=20, pattern=r"^[^\n\r]+$")]
spots: list[Annotated[float, Field(ge=0, le=1, allow_inf_nan=False)]] = Field(
default_factory=list, max_length=6
)
def _bounds(data: dict[str, Any]) -> tuple[float, float, float] | None:
"""The drawing's largest x and its middle height, from every point it holds."""
from vibesketch.document.ket import read_scene
xs: list[float] = []
ys: list[float] = []
scene = read_scene(data)
for fragment in scene.fragments:
xmin, ymin, xmax, ymax = fragment.bounds
xs += [xmin, xmax]
ys += [ymin, ymax]
for arrow in scene.arrows:
xs += [arrow.tail[0], arrow.head[0]]
ys += [arrow.tail[1], arrow.head[1]]
for text in scene.texts:
xs.append(text.centre[0])
ys.append(text.centre[1])
for node in data.get("root", {}).get("nodes", []):
if node.get("type") == "simpleObject":
for point in node.get("data", {}).get("pos", []):
xs.append(float(point["x"]))
ys.append(float(point["y"]))
if not xs:
return None
return max(xs), min(ys), max(ys)
def _shape(mode: str, a: tuple[float, float], b: tuple[float, float]) -> dict[str, Any]:
return {
"type": "simpleObject",
"data": {
"mode": mode,
"pos": [{"x": a[0], "y": a[1], "z": 0}, {"x": b[0], "y": b[1], "z": 0}],
},
}
[docs]
def with_tlc_plate(document: Document, lanes: Sequence[Lane | dict[str, Any]]) -> Document:
"""``document`` with a TLC plate of ``lanes`` drawn to the right of everything else."""
from vibesketch.document.ket import text_node
try:
chosen = [lane if isinstance(lane, Lane) else Lane.model_validate(lane) for lane in lanes]
except ValueError as error:
raise TlcRefused(str(error)) from None
if not 1 <= len(chosen) <= 12:
raise TlcRefused("a plate has from 1 to 12 lanes")
data: dict[str, Any] = copy.deepcopy(document.canvas.data) or {"root": {"nodes": []}}
found = _bounds(data)
left = found[0] + _GAP if found else 0.0
bottom = (found[1] + found[2]) / 2 - _HEIGHT / 2 if found else -_HEIGHT / 2
width = 2 * _MARGIN + _LANE * len(chosen)
right, top = left + width, bottom + _HEIGHT
baseline, front = bottom + _BASELINE, top - _FRONT
nodes: list[dict[str, Any]] = [
_shape("rectangle", (left, bottom), (right, top)),
_shape("line", (left + 0.15, baseline), (right - 0.15, baseline)),
_shape("line", (left + 0.15, front), (right - 0.15, front)),
]
for index, lane in enumerate(chosen):
centre = left + _MARGIN + _LANE * (index + 0.5)
spot_x = centre - 0.8
for rf in lane.spots:
y = baseline + rf * (front - baseline)
nodes.append(
_shape(
"ellipse", (spot_x - _SPOT[0], y - _SPOT[1]), (spot_x + _SPOT[0], y + _SPOT[1])
)
)
nodes.append(text_node(f"{rf:.2f}", (centre + 0.4, y)))
nodes.append(text_node(lane.label, (spot_x, bottom - 0.45)))
root = data.setdefault("root", {"nodes": []})
root.setdefault("nodes", []).extend(nodes)
return document.model_copy(update={"canvas": Canvas(format="ket", data=data)})