Skip to main content

xpict_core/doc/
spec.rs

1//! Live document wire types — **collocated** so opts + nodes stay aligned.
2//!
3//! # Cascade model
4//!
5//! - [`Opts`] is a **list** (or singleton) of [`OptsPatch`] rules.
6//! - Patches are discriminated by document node `type` (`TypedOptsPatch`) or
7//!   multi-select via reserved meta key `for_types` ([`ForTypesPatch`]).
8//!   Omitting both = universal (applies to every kind).
9//! - Meta keys (`type`, `for_types`) are selectors — not paint options.
10//! - Resolve: walk ancestors → node; apply matching patches in order; child
11//!   wins; nested objects (`shade`) deep-merge.
12//! - [`MolNode`] / [`DepictSpec`] **extend** paint opts with non-cascading
13//!   identity keys (`smiles`, `id`, `align_to`, shade **scores**, …). Local
14//!   flat `color` / `weight` / `scale` / `halo` are the leaf’s own opts
15//!   (same fields as [`MolOpts`]) and merge last.
16
17use serde::{Deserialize, Serialize};
18
19#[cfg(feature = "codegen")]
20use schemars::JsonSchema;
21#[cfg(feature = "codegen")]
22use ts_rs::TS;
23
24use crate::edge::AlignOpts;
25
26// ---------------------------------------------------------------------------
27// Shade: scores (non-cascading, on mol) vs style window (cascading, in opts)
28// ---------------------------------------------------------------------------
29
30/// Cascading shade window / LUT (not per-atom scores).
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
32#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
33#[cfg_attr(feature = "codegen", ts(export))]
34pub struct ShadeStyle {
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[cfg_attr(feature = "codegen", ts(optional))]
37    pub colormap: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    #[cfg_attr(feature = "codegen", ts(optional))]
40    pub vmin: Option<f64>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    #[cfg_attr(feature = "codegen", ts(optional))]
43    pub vmax: Option<f64>,
44}
45
46impl ShadeStyle {
47    pub fn merge_from(&mut self, other: &ShadeStyle) {
48        if other.colormap.is_some() {
49            self.colormap = other.colormap.clone();
50        }
51        if other.vmin.is_some() {
52            self.vmin = other.vmin;
53        }
54        if other.vmax.is_some() {
55            self.vmax = other.vmax;
56        }
57    }
58}
59
60/// Per-atom / per-bond colormap scores (+ legacy window fields on the mol).
61///
62/// Prefer putting `colormap` / `vmin` / `vmax` in cascading [`MolOpts::shade`];
63/// values here still apply as local leaf overrides for compat.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
66#[cfg_attr(feature = "codegen", ts(export))]
67pub struct ShadeSpec {
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    #[cfg_attr(feature = "codegen", ts(optional))]
70    pub atoms: Option<Vec<f64>>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    #[cfg_attr(feature = "codegen", ts(optional))]
73    pub bonds: Option<Vec<f64>>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    #[cfg_attr(feature = "codegen", ts(optional))]
76    pub colormap: Option<String>,
77    #[serde(default = "default_shade_vmin")]
78    pub vmin: f64,
79    #[serde(default = "default_shade_vmax")]
80    pub vmax: f64,
81}
82
83fn default_shade_vmin() -> f64 {
84    0.0
85}
86fn default_shade_vmax() -> f64 {
87    1.0
88}
89
90impl Default for ShadeSpec {
91    fn default() -> Self {
92        Self {
93            atoms: None,
94            bonds: None,
95            colormap: None,
96            vmin: 0.0,
97            vmax: 1.0,
98        }
99    }
100}
101
102impl ShadeSpec {
103    /// Hoist legacy window fields into a cascading [`ShadeStyle`].
104    pub fn style(&self) -> ShadeStyle {
105        ShadeStyle {
106            colormap: self.colormap.clone(),
107            vmin: Some(self.vmin),
108            vmax: Some(self.vmax),
109        }
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Align (non-cascading topology)
115// ---------------------------------------------------------------------------
116
117/// Object form of document ``align_to`` (template ref + EdgePlan-style opts).
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
120#[cfg_attr(feature = "codegen", ts(export))]
121pub struct AlignToSpec {
122    /// Id of the template mol in this group.
123    #[serde(rename = "ref")]
124    #[cfg_attr(feature = "codegen", ts(rename = "ref"))]
125    pub ref_id: String,
126    /// Pairs `(query, template)` vs the template; skips MCS when set.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    #[cfg_attr(feature = "codegen", ts(optional))]
129    pub atom_map: Option<Vec<(u32, u32)>>,
130    /// Override [`crate::edge::MIN_MCS_ATOMS`] when set.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    #[cfg_attr(feature = "codegen", ts(optional))]
133    pub min_atoms: Option<u32>,
134}
135
136/// Document align target: id string or `{ "ref", "atom_map"?, "min_atoms"? }`.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(untagged)]
139#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
140#[cfg_attr(feature = "codegen", ts(export))]
141pub enum AlignTo {
142    /// Shorthand for `{ "ref": "…" }`.
143    Ref(String),
144    Spec(AlignToSpec),
145}
146
147impl AlignTo {
148    pub fn ref_id(&self) -> &str {
149        match self {
150            AlignTo::Ref(s) => s.as_str(),
151            AlignTo::Spec(s) => s.ref_id.as_str(),
152        }
153    }
154
155    pub fn align_opts(&self) -> AlignOpts {
156        match self {
157            AlignTo::Ref(_) => AlignOpts::default(),
158            AlignTo::Spec(s) => AlignOpts {
159                atom_map: s.atom_map.clone(),
160                min_atoms: s.min_atoms,
161            },
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Cascading opts — list of patches; `type` / `for_types` are meta selectors
168// ---------------------------------------------------------------------------
169
170/// Document node kinds opts may target (matches wire `"type"` discriminants).
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
174#[cfg_attr(feature = "codegen", ts(export))]
175pub enum NodeType {
176    Mol,
177    Group,
178    /// Multi-mol scheme with edge nodes in `children` (`"type": "reaction_scheme"`).
179    ReactionScheme,
180    /// Edge / reaction link node (`"type": "edge"`).
181    Edge,
182    /// Text caption / chrome node (`"type": "text"`).
183    Text,
184}
185
186impl NodeType {
187    pub fn as_str(self) -> &'static str {
188        match self {
189            NodeType::Mol => "mol",
190            NodeType::Group => "group",
191            NodeType::ReactionScheme => "reaction_scheme",
192            NodeType::Edge => "edge",
193            NodeType::Text => "text",
194        }
195    }
196}
197
198/// Universal cascading keys (any node kind).
199#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
200#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
201#[cfg_attr(feature = "codegen", ts(export))]
202pub struct CommonOpts {
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    #[cfg_attr(feature = "codegen", ts(optional))]
205    pub color: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    #[cfg_attr(feature = "codegen", ts(optional))]
208    pub scale: Option<f64>,
209}
210
211impl CommonOpts {
212    pub fn merge_from(&mut self, other: &CommonOpts) {
213        if other.color.is_some() {
214            self.color = other.color.clone();
215        }
216        if other.scale.is_some() {
217            self.scale = other.scale;
218        }
219    }
220}
221
222/// Mol cascading paint opts — what a mol leaf consumes from the cascade.
223///
224/// [`MolNode`] carries the same fields at the top level (local leaf opts) plus
225/// non-cascading identity keys.
226#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
227#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
228#[cfg_attr(feature = "codegen", ts(export))]
229pub struct MolOpts {
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    #[cfg_attr(feature = "codegen", ts(optional))]
232    pub color: Option<String>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    #[cfg_attr(feature = "codegen", ts(optional))]
235    pub scale: Option<f64>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    #[cfg_attr(feature = "codegen", ts(optional))]
238    pub weight: Option<f64>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    #[cfg_attr(feature = "codegen", ts(optional))]
241    pub halo: Option<bool>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    #[cfg_attr(feature = "codegen", ts(optional))]
244    pub shade: Option<ShadeStyle>,
245}
246
247impl MolOpts {
248    pub fn merge_from(&mut self, other: &MolOpts) {
249        if other.color.is_some() {
250            self.color = other.color.clone();
251        }
252        if other.scale.is_some() {
253            self.scale = other.scale;
254        }
255        if other.weight.is_some() {
256            self.weight = other.weight;
257        }
258        if other.halo.is_some() {
259            self.halo = other.halo;
260        }
261        if let Some(ref s) = other.shade {
262            self.shade.get_or_insert_with(ShadeStyle::default).merge_from(s);
263        }
264    }
265
266    pub fn merge_common(&mut self, common: &CommonOpts) {
267        if common.color.is_some() {
268            self.color = common.color.clone();
269        }
270        if common.scale.is_some() {
271            self.scale = common.scale;
272        }
273    }
274}
275
276/// Discriminated opts patch: `{ "type": "mol"|"group"|"reaction_scheme"|"edge"|"text", …opts }`.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(tag = "type", rename_all = "snake_case")]
279#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
280#[cfg_attr(feature = "codegen", ts(export))]
281pub enum TypedOptsPatch {
282    Mol {
283        #[serde(flatten)]
284        opts: MolOpts,
285    },
286    Group {
287        #[serde(flatten)]
288        opts: CommonOpts,
289    },
290    ReactionScheme {
291        #[serde(flatten)]
292        opts: CommonOpts,
293    },
294    Edge {
295        #[serde(flatten)]
296        opts: CommonOpts,
297    },
298    Text {
299        #[serde(flatten)]
300        opts: CommonOpts,
301    },
302}
303
304/// Multi-kind patch: `{ "for_types": ["mol","group"], …common opts }`.
305///
306/// Only [`CommonOpts`] keys are allowed here (intersection of kind bags).
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
309#[cfg_attr(feature = "codegen", ts(export))]
310pub struct ForTypesPatch {
311    pub for_types: Vec<NodeType>,
312    #[serde(flatten)]
313    pub opts: CommonOpts,
314}
315
316/// One cascade rule. Untagged order: typed → for_types → universal.
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(untagged)]
319#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
320#[cfg_attr(feature = "codegen", ts(export))]
321pub enum OptsPatch {
322    /// `{ "type": "mol", "weight": 1.2 }` — discriminated kind.
323    Typed(TypedOptsPatch),
324    /// `{ "for_types": ["mol","group"], "color": "#111" }`.
325    ForTypes(ForTypesPatch),
326    /// `{ "color": "#111" }` — applies to every kind.
327    Universal(CommonOpts),
328}
329
330impl OptsPatch {
331    /// Whether this patch applies when resolving for `target`.
332    pub fn applies_to(&self, target: NodeType) -> bool {
333        match self {
334            OptsPatch::Typed(TypedOptsPatch::Mol { .. }) => target == NodeType::Mol,
335            OptsPatch::Typed(TypedOptsPatch::Group { .. }) => target == NodeType::Group,
336            OptsPatch::Typed(TypedOptsPatch::ReactionScheme { .. }) => {
337                target == NodeType::ReactionScheme
338            }
339            OptsPatch::Typed(TypedOptsPatch::Edge { .. }) => target == NodeType::Edge,
340            OptsPatch::Typed(TypedOptsPatch::Text { .. }) => target == NodeType::Text,
341            OptsPatch::ForTypes(p) => p.for_types.contains(&target),
342            OptsPatch::Universal(_) => true,
343        }
344    }
345
346    pub fn apply_to_mol(&self, out: &mut MolOpts) {
347        match self {
348            OptsPatch::Typed(TypedOptsPatch::Mol { opts }) => out.merge_from(opts),
349            OptsPatch::Typed(TypedOptsPatch::Group { .. }) => {}
350            OptsPatch::Typed(TypedOptsPatch::ReactionScheme { .. }) => {}
351            OptsPatch::Typed(TypedOptsPatch::Edge { .. }) => {}
352            OptsPatch::Typed(TypedOptsPatch::Text { .. }) => {}
353            OptsPatch::ForTypes(p) => out.merge_common(&p.opts),
354            OptsPatch::Universal(c) => out.merge_common(c),
355        }
356    }
357}
358
359/// Cascade bag: singleton patch or list of patches.
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361#[serde(untagged)]
362#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
363#[cfg_attr(feature = "codegen", ts(export))]
364pub enum Opts {
365    One(OptsPatch),
366    Many(Vec<OptsPatch>),
367}
368
369impl Opts {
370    pub fn patches(&self) -> Vec<&OptsPatch> {
371        match self {
372            Opts::One(p) => vec![p],
373            Opts::Many(ps) => ps.iter().collect(),
374        }
375    }
376
377    pub fn apply_to_mol(&self, out: &mut MolOpts) {
378        for p in self.patches() {
379            if p.applies_to(NodeType::Mol) {
380                p.apply_to_mol(out);
381            }
382        }
383    }
384}
385
386/// Apply a chain of opts bags (ancestor → … → leaf) for a mol.
387pub fn resolve_mol_opts<'a, I>(bags: I) -> MolOpts
388where
389    I: IntoIterator<Item = Option<&'a Opts>>,
390{
391    let mut out = MolOpts::default();
392    for bag in bags {
393        if let Some(opts) = bag {
394            opts.apply_to_mol(&mut out);
395        }
396    }
397    out
398}
399
400// ---------------------------------------------------------------------------
401// Nodes — extend opts with non-cascading identity / topology keys
402// ---------------------------------------------------------------------------
403
404/// Discriminator for mol nodes (`"type": "mol"`).
405#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "lowercase")]
407#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
408#[cfg_attr(feature = "codegen", ts(export))]
409pub enum MolNodeKind {
410    #[default]
411    Mol,
412}
413
414/// Mol node — [`MolOpts`] fields + non-cascading identity / scores / align.
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
417#[cfg_attr(feature = "codegen", ts(export))]
418pub struct MolNode {
419    #[serde(rename = "type", default)]
420    pub type_: MolNodeKind,
421    // --- non-cascading identity / topology ---
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    #[cfg_attr(feature = "codegen", ts(optional))]
424    pub smiles: Option<String>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    #[cfg_attr(feature = "codegen", ts(optional))]
427    pub cxsmiles: Option<String>,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    #[cfg_attr(feature = "codegen", ts(optional))]
430    pub molfile: Option<String>,
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    #[cfg_attr(feature = "codegen", ts(optional))]
433    pub id: Option<String>,
434    /// Shade **scores** (+ legacy window); window also cascades via [`Opts`].
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    #[cfg_attr(feature = "codegen", ts(optional))]
437    pub shade: Option<ShadeSpec>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    #[cfg_attr(feature = "codegen", ts(optional))]
440    pub star_labels: Option<Vec<Option<String>>>,
441    /// Template id string, or `{ "ref", "atom_map"?, "min_atoms"? }`.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    #[cfg_attr(feature = "codegen", ts(optional))]
444    pub align_to: Option<AlignTo>,
445    /// Caption: [`Label`] (string id, list, or `{id, pos?}` — text-node refs).
446    ///
447    /// `pos` on a placement chooses caption side (`above`/`below`/`left`/`right`);
448    /// hosts measure the resolved text and fold that into node size for layout.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    #[cfg_attr(feature = "codegen", ts(optional))]
451    pub label: Option<Label>,
452    // --- local leaf opts (same keys as MolOpts; merge last) ---
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    #[cfg_attr(feature = "codegen", ts(optional))]
455    pub color: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    #[cfg_attr(feature = "codegen", ts(optional))]
458    pub scale: Option<f64>,
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    #[cfg_attr(feature = "codegen", ts(optional))]
461    pub weight: Option<f64>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    #[cfg_attr(feature = "codegen", ts(optional))]
464    pub halo: Option<bool>,
465    /// Cascade patches for this node (list or singleton).
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    #[cfg_attr(feature = "codegen", ts(optional))]
468    pub opts: Option<Opts>,
469}
470
471impl MolNode {
472    /// Local leaf opts from flat fields (+ legacy shade window).
473    pub fn local_opts(&self) -> MolOpts {
474        let mut o = MolOpts {
475            color: self.color.clone(),
476            scale: self.scale,
477            weight: self.weight,
478            halo: self.halo,
479            shade: None,
480        };
481        if let Some(ref shade) = self.shade {
482            o.shade = Some(shade.style());
483        }
484        o
485    }
486
487    pub fn structure(&self) -> Result<&str, String> {
488        for s in [&self.molfile, &self.cxsmiles, &self.smiles] {
489            if let Some(t) = s.as_ref().filter(|x| !x.trim().is_empty()) {
490                return Ok(t.as_str());
491            }
492        }
493        Err("mol node needs smiles, cxsmiles, or molfile".into())
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Edges / nodes — reaction links are edge nodes in `children`
499// ---------------------------------------------------------------------------
500
501/// Arrow head / shaft style for diagram edges.
502#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
505#[cfg_attr(feature = "codegen", ts(export))]
506pub enum EdgeArrow {
507    /// Single →
508    #[default]
509    Forward,
510    /// ⇌ stacked half-arrows
511    Equilibrium,
512    /// ⇒ hollow head (retrosynthetic-style)
513    Open,
514    /// Connector without arrowhead
515    Line,
516}
517
518/// Discriminator for edge nodes (`"type": "edge"`).
519#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "snake_case")]
521#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
522#[cfg_attr(feature = "codegen", ts(export))]
523pub enum EdgeNodeKind {
524    #[default]
525    Edge,
526}
527
528/// Discriminator for text nodes (`"type": "text"`).
529#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
532#[cfg_attr(feature = "codegen", ts(export))]
533pub enum TextNodeKind {
534    #[default]
535    Text,
536}
537
538/// Text **node** — caption / chrome referenced by mols and edges via id.
539///
540/// ```json
541/// { "type": "text", "id": "adh", "text": "ADH" }
542/// ```
543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
544#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
545#[cfg_attr(feature = "codegen", ts(export))]
546pub struct TextNode {
547    #[serde(rename = "type")]
548    #[cfg_attr(feature = "codegen", ts(rename = "type"))]
549    pub type_: TextNodeKind,
550    /// Stable id — required when other nodes [`MolNode::label`] / edge lanes ref it.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    #[cfg_attr(feature = "codegen", ts(optional))]
553    pub id: Option<String>,
554    /// Display text (markup-capable later).
555    pub text: String,
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    #[cfg_attr(feature = "codegen", ts(optional))]
558    pub color: Option<String>,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    #[cfg_attr(feature = "codegen", ts(optional))]
561    pub scale: Option<f64>,
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    #[cfg_attr(feature = "codegen", ts(optional))]
564    pub opts: Option<Opts>,
565}
566
567/// Side of an edge shaft (or mol caption) for a label placement.
568#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(rename_all = "snake_case")]
570#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
571#[cfg_attr(feature = "codegen", ts(export))]
572pub enum LabelPos {
573    #[default]
574    Above,
575    Below,
576    Left,
577    Right,
578}
579
580/// Placed label: `{ "id": "adh", "pos": "below" }` (`pos` optional → above).
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(deny_unknown_fields)]
583#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
584#[cfg_attr(feature = "codegen", ts(export))]
585pub struct LabelPlacement {
586    /// Id of a [`TextNode`] or (on edges) [`MolNode`].
587    pub id: String,
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    #[cfg_attr(feature = "codegen", ts(optional))]
590    pub pos: Option<LabelPos>,
591}
592
593impl LabelPlacement {
594    pub fn pos_or_default(&self) -> LabelPos {
595        self.pos.unwrap_or(LabelPos::Above)
596    }
597}
598
599/// One entry in a label list: bare id or `{ id, pos? }`.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(untagged)]
602#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
603#[cfg_attr(feature = "codegen", ts(export))]
604pub enum LabelItem {
605    Id(String),
606    Placed(LabelPlacement),
607}
608
609impl LabelItem {
610    pub fn id(&self) -> &str {
611        match self {
612            LabelItem::Id(s) => s.as_str(),
613            LabelItem::Placed(p) => p.id.as_str(),
614        }
615    }
616
617    pub fn pos(&self) -> LabelPos {
618        match self {
619            LabelItem::Id(_) => LabelPos::Above,
620            LabelItem::Placed(p) => p.pos_or_default(),
621        }
622    }
623}
624
625/// Lane bag: `{ "above": [...], "below": [...], "left": [...], "right": [...] }`.
626#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(deny_unknown_fields)]
628#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
629#[cfg_attr(feature = "codegen", ts(export))]
630pub struct LabelLanes {
631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
632    pub above: Vec<String>,
633    #[serde(default, skip_serializing_if = "Vec::is_empty")]
634    pub below: Vec<String>,
635    #[serde(default, skip_serializing_if = "Vec::is_empty")]
636    pub left: Vec<String>,
637    #[serde(default, skip_serializing_if = "Vec::is_empty")]
638    pub right: Vec<String>,
639}
640
641/// Unified label: string id, list, placed object, or lane object.
642///
643/// ```json
644/// "label": "adh"
645/// "label": ["adh", {"id": "rt", "pos": "below"}]
646/// "label": { "id": "adh", "pos": "left" }
647/// "label": { "above": ["adh"], "below": ["rt"], "left": ["nabh4"] }
648/// ```
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(untagged)]
651#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
652#[cfg_attr(feature = "codegen", ts(export))]
653pub enum Label {
654    /// Bare node id (default position: above).
655    Id(String),
656    /// Ordered list of ids / placements.
657    Items(Vec<LabelItem>),
658    /// Single placement with optional `pos`.
659    Placed(LabelPlacement),
660    /// Explicit above / below / left / right id lists.
661    Lanes(LabelLanes),
662}
663
664impl Label {
665    /// Flatten to `(id, pos)` pairs in document order.
666    pub fn placements(&self) -> Vec<(String, LabelPos)> {
667        match self {
668            Label::Id(id) => vec![(id.clone(), LabelPos::Above)],
669            Label::Items(items) => items
670                .iter()
671                .map(|it| (it.id().to_string(), it.pos()))
672                .collect(),
673            Label::Placed(p) => vec![(p.id.clone(), p.pos_or_default())],
674            Label::Lanes(lanes) => {
675                let mut out = Vec::new();
676                for id in &lanes.above {
677                    out.push((id.clone(), LabelPos::Above));
678                }
679                for id in &lanes.below {
680                    out.push((id.clone(), LabelPos::Below));
681                }
682                for id in &lanes.left {
683                    out.push((id.clone(), LabelPos::Left));
684                }
685                for id in &lanes.right {
686                    out.push((id.clone(), LabelPos::Right));
687                }
688                out
689            }
690        }
691    }
692}
693
694/// One or more mol ids (reactants / products).
695///
696/// Wire: `"a"` or `["a", "b"]` — reactions may have multiple reactants and products.
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(untagged)]
699#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
700#[cfg_attr(feature = "codegen", ts(export))]
701pub enum MolIds {
702    One(String),
703    Many(Vec<String>),
704}
705
706impl MolIds {
707    pub fn as_slice(&self) -> &[String] {
708        match self {
709            MolIds::One(s) => std::slice::from_ref(s),
710            MolIds::Many(v) => v.as_slice(),
711        }
712    }
713
714    pub fn iter(&self) -> impl Iterator<Item = &str> {
715        self.as_slice().iter().map(String::as_str)
716    }
717
718    pub fn is_empty(&self) -> bool {
719        match self {
720            MolIds::One(s) => s.trim().is_empty(),
721            MolIds::Many(v) => v.is_empty() || v.iter().all(|s| s.trim().is_empty()),
722        }
723    }
724
725    pub fn len(&self) -> usize {
726        self.as_slice().len()
727    }
728}
729
730/// Edge **node** — a reaction / network link between mol ids.
731///
732/// [`Self::sources`] / [`Self::targets`] are one or many mol ids (A+B → C+D).
733/// Label chrome refs sibling text/mol nodes via [`Self::label`].
734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
735#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
736#[cfg_attr(feature = "codegen", ts(export))]
737pub struct EdgeNode {
738    /// Wire discriminant — required so untagged [`Node`] does not absorb edges as mols.
739    #[serde(rename = "type")]
740    #[cfg_attr(feature = "codegen", ts(rename = "type"))]
741    pub type_: EdgeNodeKind,
742    /// Reactant mol id(s) — `"a"` or `["a","b"]` (alias: `source`).
743    #[serde(alias = "source")]
744    pub sources: MolIds,
745    /// Product mol id(s) — `"c"` or `["c","d"]` (alias: `target`).
746    #[serde(alias = "target")]
747    pub targets: MolIds,
748    /// Label chrome: id / list / `{id, pos?}` / `{above,below,left,right}`.
749    ///
750    /// Hosts should measure each placement’s text (or mol viewport) and pass
751    /// those boxes to the layout engine with the placement `pos` (edge side).
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    #[cfg_attr(feature = "codegen", ts(optional))]
754    pub label: Option<Label>,
755    /// Per-edge override of scheme [`LayoutOpts::edge_routing`].
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    #[cfg_attr(feature = "codegen", ts(optional))]
758    pub edge_routing: Option<EdgeRouting>,
759    /// Optional semantic role (e.g. enzyme) — not drawn by default.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    #[cfg_attr(feature = "codegen", ts(optional))]
762    pub role: Option<String>,
763    #[serde(default)]
764    pub arrow: EdgeArrow,
765    #[serde(default, skip_serializing_if = "Option::is_none")]
766    #[cfg_attr(feature = "codegen", ts(optional))]
767    pub color: Option<String>,
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    #[cfg_attr(feature = "codegen", ts(optional))]
770    pub stroke_width: Option<f64>,
771    #[serde(default)]
772    pub dashed: bool,
773}
774
775impl EdgeNode {
776    /// Effective shaft routing: edge override, else scheme layout default.
777    pub fn edge_routing_or(&self, scheme: &LayoutOpts) -> EdgeRouting {
778        self.edge_routing
779            .unwrap_or_else(|| scheme.edge_routing_or_default())
780    }
781}
782
783/// Document **node**: mol, edge, or text.
784///
785/// Untagged so each variant keeps its own `"type"` field.
786/// - [`DepictSpec::Group`]: mol | text (no edges).
787/// - [`DepictSpec::ReactionScheme`]: mol | edge | text.
788#[derive(Debug, Clone, Serialize, Deserialize)]
789#[serde(untagged)]
790#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
791#[cfg_attr(feature = "codegen", ts(export))]
792pub enum Node {
793    Mol(MolNode),
794    Edge(EdgeNode),
795    Text(TextNode),
796}
797
798impl Node {
799    pub fn as_mol(&self) -> Option<&MolNode> {
800        match self {
801            Node::Mol(m) => Some(m),
802            _ => None,
803        }
804    }
805
806    pub fn as_edge(&self) -> Option<&EdgeNode> {
807        match self {
808            Node::Edge(e) => Some(e),
809            _ => None,
810        }
811    }
812
813    pub fn as_text(&self) -> Option<&TextNode> {
814        match self {
815            Node::Text(t) => Some(t),
816            _ => None,
817        }
818    }
819
820    /// Stable id when present (mol / text); edges have no id.
821    pub fn id(&self) -> Option<&str> {
822        match self {
823            Node::Mol(m) => m.id.as_deref().map(str::trim).filter(|s| !s.is_empty()),
824            Node::Text(t) => t.id.as_deref().map(str::trim).filter(|s| !s.is_empty()),
825            Node::Edge(_) => None,
826        }
827    }
828}
829
830// ---------------------------------------------------------------------------
831// Layout — reaction_scheme (backend-agnostic)
832// ---------------------------------------------------------------------------
833
834/// Flow axis for scheme layout.
835#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "snake_case")]
837#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
838#[cfg_attr(feature = "codegen", ts(export))]
839pub enum LayoutDirection {
840    #[default]
841    Right,
842    Left,
843    Up,
844    Down,
845}
846
847/// How edge shafts are drawn between nodes.
848#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
849#[serde(rename_all = "snake_case")]
850#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
851#[cfg_attr(feature = "codegen", ts(export))]
852pub enum EdgeRouting {
853    Orthogonal,
854    #[default]
855    Polyline,
856    Splines,
857}
858
859/// Placement algorithm for scheme layout.
860///
861/// Hosts map these onto ELK (or another engine):
862/// - [`Self::Layered`] — Sugiyama / layered (default; best for reactions)
863/// - [`Self::Radial`] — hub-and-spoke / concentric rings (star-like nets)
864/// - [`Self::Force`] — force-directed (undirected / cyclic networks)
865/// - [`Self::Stress`] — stress majorization (compact network alternative)
866#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
867#[serde(rename_all = "snake_case")]
868#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
869#[cfg_attr(feature = "codegen", ts(export))]
870pub enum LayoutAlgorithm {
871    #[default]
872    Layered,
873    Radial,
874    Force,
875    Stress,
876}
877
878/// Layout for [`DepictSpec::ReactionScheme`].
879///
880/// Backend-agnostic knobs — hosts map these onto ELK, Dagre, or another engine.
881/// Defaults when omitted: [`LayoutDirection::Right`], [`EdgeRouting::Polyline`],
882/// [`LayoutAlgorithm::Layered`].
883/// Individual [`EdgeNode`]s may override [`Self::edge_routing`].
884///
885/// Spacing (px, document space) controls packing tightness:
886/// - [`Self::node_spacing`] — gap between nodes in the same layer (default ~20)
887/// - [`Self::layer_spacing`] — gap between reactant/product layers (default ~0)
888#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
889#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
890#[cfg_attr(feature = "codegen", ts(export))]
891pub struct LayoutOpts {
892    /// Placement algorithm (`layered` / `radial` / `force` / `stress`).
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    #[cfg_attr(feature = "codegen", ts(optional))]
895    pub algorithm: Option<LayoutAlgorithm>,
896    /// Flow axis (`right` / `left` / `up` / `down`).
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    #[cfg_attr(feature = "codegen", ts(optional))]
899    pub direction: Option<LayoutDirection>,
900    /// Shaft style (`polyline` / `orthogonal` / `splines`).
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    #[cfg_attr(feature = "codegen", ts(optional))]
903    pub edge_routing: Option<EdgeRouting>,
904    /// Within-layer node gap (px). Smaller → tighter pack.
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    #[cfg_attr(feature = "codegen", ts(optional))]
907    pub node_spacing: Option<f64>,
908    /// Between-layer gap along the flow axis (px). Smaller → tighter pack.
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    #[cfg_attr(feature = "codegen", ts(optional))]
911    pub layer_spacing: Option<f64>,
912}
913
914impl LayoutOpts {
915    /// Default within-layer gap for reaction schemes (px).
916    pub const DEFAULT_NODE_SPACING: f64 = 20.0;
917    /// Default between-layer gap for reaction schemes (px).
918    pub const DEFAULT_LAYER_SPACING: f64 = 0.0;
919
920    pub fn algorithm_or_default(&self) -> LayoutAlgorithm {
921        self.algorithm.unwrap_or_default()
922    }
923
924    pub fn direction_or_default(&self) -> LayoutDirection {
925        self.direction.unwrap_or_default()
926    }
927
928    pub fn edge_routing_or_default(&self) -> EdgeRouting {
929        self.edge_routing.unwrap_or_default()
930    }
931
932    pub fn node_spacing_or_default(&self) -> f64 {
933        self.node_spacing.unwrap_or(Self::DEFAULT_NODE_SPACING)
934    }
935
936    pub fn layer_spacing_or_default(&self) -> f64 {
937        self.layer_spacing.unwrap_or(Self::DEFAULT_LAYER_SPACING)
938    }
939}
940
941/// Declarative document (`mol`, `group`, or `reaction_scheme` root).
942#[derive(Debug, Clone, Serialize, Deserialize)]
943#[serde(tag = "type", rename_all = "snake_case")]
944#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
945#[cfg_attr(feature = "codegen", ts(export))]
946pub enum DepictSpec {
947    Mol {
948        #[serde(default, skip_serializing_if = "Option::is_none")]
949        #[cfg_attr(feature = "codegen", ts(optional))]
950        smiles: Option<String>,
951        #[serde(default, skip_serializing_if = "Option::is_none")]
952        #[cfg_attr(feature = "codegen", ts(optional))]
953        cxsmiles: Option<String>,
954        #[serde(default, skip_serializing_if = "Option::is_none")]
955        #[cfg_attr(feature = "codegen", ts(optional))]
956        molfile: Option<String>,
957        #[serde(default, skip_serializing_if = "Option::is_none")]
958        #[cfg_attr(feature = "codegen", ts(optional))]
959        id: Option<String>,
960        #[serde(default, skip_serializing_if = "Option::is_none")]
961        #[cfg_attr(feature = "codegen", ts(optional))]
962        shade: Option<ShadeSpec>,
963        #[serde(default, skip_serializing_if = "Option::is_none")]
964        #[cfg_attr(feature = "codegen", ts(optional))]
965        star_labels: Option<Vec<Option<String>>>,
966        #[serde(default, skip_serializing_if = "Option::is_none")]
967        #[cfg_attr(feature = "codegen", ts(optional))]
968        align_to: Option<AlignTo>,
969        /// Caption: [`Label`] (string id / list / `{id, pos?}` — text-node refs).
970        #[serde(default, skip_serializing_if = "Option::is_none")]
971        #[cfg_attr(feature = "codegen", ts(optional))]
972        label: Option<Label>,
973        #[serde(default, skip_serializing_if = "Option::is_none")]
974        #[cfg_attr(feature = "codegen", ts(optional))]
975        color: Option<String>,
976        #[serde(default, skip_serializing_if = "Option::is_none")]
977        #[cfg_attr(feature = "codegen", ts(optional))]
978        scale: Option<f64>,
979        #[serde(default, skip_serializing_if = "Option::is_none")]
980        #[cfg_attr(feature = "codegen", ts(optional))]
981        weight: Option<f64>,
982        #[serde(default, skip_serializing_if = "Option::is_none")]
983        #[cfg_attr(feature = "codegen", ts(optional))]
984        halo: Option<bool>,
985        #[serde(default, skip_serializing_if = "Option::is_none")]
986        #[cfg_attr(feature = "codegen", ts(optional))]
987        opts: Option<Opts>,
988    },
989    Group {
990        #[serde(default, skip_serializing_if = "Option::is_none")]
991        #[cfg_attr(feature = "codegen", ts(optional))]
992        id: Option<String>,
993        /// When true, later children align onto the first (or each `align_to`).
994        #[serde(default)]
995        align: bool,
996        /// Child **nodes** — mol | text (no edges).
997        #[serde(default)]
998        children: Vec<Node>,
999        /// Group-level cascade bag (list container for child inheritance).
1000        #[serde(default, skip_serializing_if = "Option::is_none")]
1001        #[cfg_attr(feature = "codegen", ts(optional))]
1002        opts: Option<Opts>,
1003        #[serde(default, skip_serializing_if = "Option::is_none")]
1004        #[cfg_attr(feature = "codegen", ts(optional))]
1005        color: Option<String>,
1006        #[serde(default, skip_serializing_if = "Option::is_none")]
1007        #[cfg_attr(feature = "codegen", ts(optional))]
1008        scale: Option<f64>,
1009    },
1010    /// Reaction / pathway scheme: mixed **node** children (`mol` | `edge` | `text`).
1011    ReactionScheme {
1012        #[serde(default, skip_serializing_if = "Option::is_none")]
1013        #[cfg_attr(feature = "codegen", ts(optional))]
1014        id: Option<String>,
1015        /// Child **nodes** — mols, edges, and text (any order).
1016        #[serde(default)]
1017        children: Vec<Node>,
1018        /// Scheme layout (direction, edge routing); backend maps these.
1019        #[serde(default, skip_serializing_if = "Option::is_none")]
1020        #[cfg_attr(feature = "codegen", ts(optional))]
1021        layout: Option<LayoutOpts>,
1022        /// Scheme-level cascade bag for child mol inheritance.
1023        #[serde(default, skip_serializing_if = "Option::is_none")]
1024        #[cfg_attr(feature = "codegen", ts(optional))]
1025        opts: Option<Opts>,
1026        #[serde(default, skip_serializing_if = "Option::is_none")]
1027        #[cfg_attr(feature = "codegen", ts(optional))]
1028        color: Option<String>,
1029        #[serde(default, skip_serializing_if = "Option::is_none")]
1030        #[cfg_attr(feature = "codegen", ts(optional))]
1031        scale: Option<f64>,
1032    },
1033}
1034
1035impl DepictSpec {
1036    /// Flatten to mol nodes in document order (skips edge / text children).
1037    pub fn mols(&self) -> Vec<MolNode> {
1038        match self {
1039            DepictSpec::Mol {
1040                smiles,
1041                cxsmiles,
1042                molfile,
1043                id,
1044                shade,
1045                star_labels,
1046                align_to,
1047                label,
1048                color,
1049                scale,
1050                weight,
1051                halo,
1052                opts,
1053            } => vec![MolNode {
1054                type_: MolNodeKind::Mol,
1055                smiles: smiles.clone(),
1056                cxsmiles: cxsmiles.clone(),
1057                molfile: molfile.clone(),
1058                id: id.clone(),
1059                shade: shade.clone(),
1060                star_labels: star_labels.clone(),
1061                align_to: align_to.clone(),
1062                label: label.clone(),
1063                color: color.clone(),
1064                scale: *scale,
1065                weight: *weight,
1066                halo: *halo,
1067                opts: opts.clone(),
1068            }],
1069            DepictSpec::Group { children, .. }
1070            | DepictSpec::ReactionScheme { children, .. } => children
1071                .iter()
1072                .filter_map(Node::as_mol)
1073                .cloned()
1074                .collect(),
1075        }
1076    }
1077
1078    /// Child nodes of a container (empty for a mol root).
1079    pub fn nodes(&self) -> &[Node] {
1080        match self {
1081            DepictSpec::Group { children, .. }
1082            | DepictSpec::ReactionScheme { children, .. } => children.as_slice(),
1083            DepictSpec::Mol { .. } => &[],
1084        }
1085    }
1086
1087    /// Edge nodes in document order.
1088    pub fn edges(&self) -> Vec<&EdgeNode> {
1089        self.nodes().iter().filter_map(Node::as_edge).collect()
1090    }
1091
1092    /// Text nodes in document order.
1093    pub fn texts(&self) -> Vec<&TextNode> {
1094        self.nodes().iter().filter_map(Node::as_text).collect()
1095    }
1096
1097    /// Look up a mol or text node by id (for label / lane refs).
1098    pub fn node_by_id(&self, id: &str) -> Option<&Node> {
1099        self.nodes().iter().find(|n| n.id() == Some(id))
1100    }
1101
1102    pub fn align_enabled(&self) -> bool {
1103        matches!(self, DepictSpec::Group { align: true, .. })
1104    }
1105
1106    /// Container-level opts bag (`group` or `reaction_scheme`).
1107    pub fn container_opts(&self) -> Option<&Opts> {
1108        match self {
1109            DepictSpec::Group { opts, .. } | DepictSpec::ReactionScheme { opts, .. } => {
1110                opts.as_ref()
1111            }
1112            DepictSpec::Mol { .. } => None,
1113        }
1114    }
1115
1116    /// Group-level opts bag (if this is a group).
1117    #[inline]
1118    pub fn group_opts(&self) -> Option<&Opts> {
1119        match self {
1120            DepictSpec::Group { opts, .. } => opts.as_ref(),
1121            _ => None,
1122        }
1123    }
1124
1125    /// Resolve cascading mol opts for child index `i` (0 for a mol root).
1126    ///
1127    /// Order: container `opts` list → container flat common → node `opts` list →
1128    /// node local flat fields (incl. legacy shade window).
1129    pub fn resolve_mol_chrome(&self, i: usize) -> MolOpts {
1130        let mols = self.mols();
1131        let node = mols.get(i).expect("mol index");
1132        let mut o = MolOpts::default();
1133        if let Some(bag) = self.container_opts() {
1134            bag.apply_to_mol(&mut o);
1135        }
1136        match self {
1137            DepictSpec::Group { color, scale, .. }
1138            | DepictSpec::ReactionScheme { color, scale, .. } => {
1139                o.merge_common(&CommonOpts {
1140                    color: color.clone(),
1141                    scale: *scale,
1142                });
1143            }
1144            DepictSpec::Mol { .. } => {}
1145        }
1146        if let Some(ref bag) = node.opts {
1147            bag.apply_to_mol(&mut o);
1148        }
1149        o.merge_from(&node.local_opts());
1150        o
1151    }
1152}