use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::artist::Artist;
use crate::axes::Axes;
use crate::data::NdArray;
use crate::error::IrError;
use crate::ids::{DataId, NodeId};
use crate::link::AxisLink;
use crate::style::Color;
use crate::text::Text;
pub const SCHEMA_VERSION: &str = "0.2.0";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Figure {
pub schema_version: String,
pub id: NodeId,
pub title: Option<Text>,
pub size: FigureSize,
pub font_set: FontSetId,
pub font_size_pt: f64,
pub background: Color,
pub layout: TileLayout,
pub data: BTreeMap<DataId, NdArray>,
pub axes: Vec<Axes>,
pub links: Vec<AxisLink>,
pub provenance: Provenance,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub parameters: BTreeMap<String, Parameter>,
#[serde(skip)]
#[schemars(skip)]
pub id_allocator: NodeIdAllocator,
}
impl Default for Figure {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION.to_owned(),
id: NodeId(0),
title: None,
size: FigureSize::default(),
font_set: FontSetId::StixTwo,
font_size_pt: 9.0,
background: Color::WHITE,
layout: TileLayout::default(),
data: BTreeMap::new(),
axes: Vec::new(),
links: Vec::new(),
provenance: Provenance::default(),
parameters: BTreeMap::new(),
id_allocator: NodeIdAllocator::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum Parameter {
Bool(bool),
Integer(i64),
Number(f64),
String(String),
}
impl From<bool> for Parameter {
fn from(value: bool) -> Self {
Parameter::Bool(value)
}
}
impl From<i32> for Parameter {
fn from(value: i32) -> Self {
Parameter::Integer(i64::from(value))
}
}
impl From<i64> for Parameter {
fn from(value: i64) -> Self {
Parameter::Integer(value)
}
}
impl From<f64> for Parameter {
fn from(value: f64) -> Self {
Parameter::Number(value)
}
}
impl From<&str> for Parameter {
fn from(value: &str) -> Self {
Parameter::String(value.to_owned())
}
}
impl From<String> for Parameter {
fn from(value: String) -> Self {
Parameter::String(value)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NodeIdAllocator {
pub next: u64,
}
impl PartialEq for NodeIdAllocator {
fn eq(&self, _other: &Self) -> bool {
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FigureSize {
pub width_mm: f64,
pub height_mm: f64,
}
impl Default for FigureSize {
fn default() -> Self {
Self {
width_mm: 160.0,
height_mm: 100.0,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FontSetId {
#[default]
StixTwo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct TileLayout {
pub rows: u32,
pub cols: u32,
}
impl Default for TileLayout {
fn default() -> Self {
Self { rows: 1, cols: 1 }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Provenance {
pub ironlab_version: String,
pub typesetter: String,
pub fonts: Vec<String>,
}
impl Default for Provenance {
fn default() -> Self {
Self {
ironlab_version: env!("CARGO_PKG_VERSION").to_owned(),
typesetter: "latex-rust 1.0.2".to_owned(),
fonts: vec!["STIX Two Text".to_owned(), "STIX Two Math".to_owned()],
}
}
}
impl Figure {
pub fn new() -> Self {
Self::default()
}
pub fn alloc_node_id(&mut self) -> NodeId {
const EXHAUSTED: &str = "the node identifier space is exhausted";
let largest = self.node_ids().map(|id| id.0).max().unwrap_or(self.id.0);
let id = largest
.checked_add(1)
.expect(EXHAUSTED)
.max(self.id_allocator.next);
self.id_allocator.next = id.checked_add(1).expect(EXHAUSTED);
NodeId(id)
}
pub(crate) fn node_ids(&self) -> impl Iterator<Item = NodeId> + '_ {
std::iter::once(self.id).chain(
self.axes.iter().flat_map(|axes| {
std::iter::once(axes.id).chain(axes.artists.iter().map(Artist::id))
}),
)
}
pub fn add_data(&mut self, array: NdArray) -> DataId {
let id = match self.data.last_key_value() {
None => DataId(0),
Some((last, _)) => match last.0.checked_add(1) {
Some(next) => DataId(next),
None => (0..)
.map(DataId)
.zip(self.data.keys())
.find(|(candidate, used)| candidate != *used)
.map(|(candidate, _)| candidate)
.expect("the data identifier space is exhausted"),
},
};
self.data.insert(id, array);
id
}
pub fn axes(&self, id: NodeId) -> Option<&Axes> {
self.axes.iter().find(|axes| axes.id == id)
}
pub fn axes_mut(&mut self, id: NodeId) -> Option<&mut Axes> {
self.axes.iter_mut().find(|axes| axes.id == id)
}
pub fn artist(&self, id: NodeId) -> Option<(&Axes, &Artist)> {
self.axes.iter().find_map(|axes| {
axes.artists
.iter()
.find(|artist| artist.id() == id)
.map(|artist| (axes, artist))
})
}
pub fn artist_mut(&mut self, id: NodeId) -> Option<&mut Artist> {
self.axes
.iter_mut()
.flat_map(|axes| axes.artists.iter_mut())
.find(|artist| artist.id() == id)
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("a figure always serialises to JSON")
}
pub fn from_json(json: &str) -> Result<Figure, IrError> {
#[derive(Deserialize)]
struct VersionProbe {
schema_version: serde_json::Value,
}
let probe: VersionProbe = serde_json::from_str(json)?;
if !probe.schema_version.as_str().is_some_and(is_compatible) {
let found = match probe.schema_version {
serde_json::Value::String(version) => version,
other => other.to_string(),
};
return Err(IrError::IncompatibleSchemaVersion {
found,
supported: SCHEMA_VERSION,
});
}
Ok(serde_json::from_str(json)?)
}
}
impl Figure {
pub fn to_protobuf(&self) -> Vec<u8> {
use prost::Message;
crate::wire::Figure::from(self).encode_to_vec()
}
pub fn from_protobuf(bytes: &[u8]) -> Result<Figure, IrError> {
use prost::Message;
#[derive(Clone, PartialEq, prost::Message)]
struct VersionProbe {
#[prost(string, tag = "1")]
schema_version: String,
}
let probe = VersionProbe::decode(bytes)?;
if !is_compatible(&probe.schema_version) {
return Err(IrError::IncompatibleSchemaVersion {
found: probe.schema_version,
supported: SCHEMA_VERSION,
});
}
Figure::try_from(crate::wire::Figure::decode(bytes)?)
}
}
fn is_compatible(version: &str) -> bool {
parse_version(version)
.zip(parse_version(SCHEMA_VERSION))
.is_some_and(|(found, supported)| found[..2] == supported[..2])
}
fn parse_version(version: &str) -> Option<[u64; 3]> {
let mut parts = version.split('.').map(|part| {
if !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()) {
part.parse::<u64>().ok()
} else {
None
}
});
let version = [parts.next()??, parts.next()??, parts.next()??];
parts.next().is_none().then_some(version)
}