Skip to main content

ggsci_ggsql/
lib.rs

1//! Use ggsci color palettes in ggsql visualizations.
2//!
3//! ggsql does not currently expose a third-party palette provider or registry
4//! API. This adapter therefore converts ggsci palettes to explicit color arrays,
5//! either as typed [`ggsql::plot::scale::OutputRange`] values or as textual
6//! `SCALE` clauses.
7
8use std::fmt;
9
10use ggsql::plot::{scale::OutputRange, ArrayElement};
11
12/// An error produced while resolving a palette or building a ggsql clause.
13#[derive(Debug)]
14pub enum Error {
15    /// An error reported by the core ggsci crate.
16    Ggsci(ggsci::Error),
17    /// An aesthetic was not a valid unquoted SQL identifier.
18    InvalidAesthetic {
19        /// The invalid aesthetic supplied by the caller.
20        aesthetic: String,
21    },
22}
23
24impl fmt::Display for Error {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Ggsci(error) => error.fmt(f),
28            Self::InvalidAesthetic { aesthetic } => write!(
29                f,
30                "invalid ggsql aesthetic `{aesthetic}`; expected an ASCII identifier matching [A-Za-z_][A-Za-z0-9_]*"
31            ),
32        }
33    }
34}
35
36impl std::error::Error for Error {
37    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
38        match self {
39            Self::Ggsci(error) => Some(error),
40            Self::InvalidAesthetic { .. } => None,
41        }
42    }
43}
44
45impl From<ggsci::Error> for Error {
46    fn from(error: ggsci::Error) -> Self {
47        Self::Ggsci(error)
48    }
49}
50
51/// The kind of ggsql `SCALE` clause to emit.
52///
53/// This is distinct from [`ggsci::PaletteKind`], which describes the semantic
54/// domain of the source palette.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ScaleKind {
57    /// A categorical scale.
58    Discrete,
59    /// A continuous scale.
60    Continuous,
61    /// A scale that groups a continuous domain into bins.
62    Binned,
63    /// An ordered categorical scale.
64    Ordinal,
65}
66
67impl ScaleKind {
68    /// Returns the uppercase keyword used in a ggsql `SCALE` clause.
69    #[must_use]
70    pub const fn as_sql_keyword(self) -> &'static str {
71        match self {
72            Self::Discrete => "DISCRETE",
73            Self::Continuous => "CONTINUOUS",
74            Self::Binned => "BINNED",
75            Self::Ordinal => "ORDINAL",
76        }
77    }
78}
79
80impl From<ggsci::PaletteKind> for ScaleKind {
81    fn from(kind: ggsci::PaletteKind) -> Self {
82        match kind {
83            ggsci::PaletteKind::Discrete => Self::Discrete,
84            ggsci::PaletteKind::Continuous => Self::Continuous,
85        }
86    }
87}
88
89/// A resolved ggsci palette ready for ggsql conversion.
90///
91/// The wrapper retains the source palette semantics until a caller selects a
92/// ggsql scale kind or converts the colors to an untyped output range.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct GgsqlPalette {
95    colors: Vec<ggsci::Rgb>,
96    palette_kind: ggsci::PaletteKind,
97}
98
99impl GgsqlPalette {
100    /// Creates a wrapper from already resolved colors and their source
101    /// semantics.
102    #[must_use]
103    pub fn from_colors(colors: Vec<ggsci::Rgb>, palette_kind: ggsci::PaletteKind) -> Self {
104        Self {
105            colors,
106            palette_kind,
107        }
108    }
109
110    /// Resolves and samples a core palette using its discrete or continuous
111    /// semantics.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error when the specification is invalid, the palette is not
116    /// known, or a discrete palette does not have `n` colors.
117    pub fn from_spec(spec: &str, n: usize) -> Result<Self, Error> {
118        sample_spec(spec, n).map_err(Error::from)
119    }
120
121    /// Resolves and interpolates a continuous core palette with explicit
122    /// options.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error when the palette cannot be found or is not continuous.
127    pub fn from_continuous(
128        spec: &str,
129        n: usize,
130        options: ggsci::ContinuousOptions,
131    ) -> Result<Self, Error> {
132        let palette = ggsci::palette_by_spec(spec)?;
133        let colors = palette.interpolate_with(n, options)?;
134
135        Ok(Self {
136            colors,
137            palette_kind: ggsci::PaletteKind::Continuous,
138        })
139    }
140
141    /// Resolves colors from a fixed discrete iTerm theme variant.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error when the theme is unknown or `n` exceeds the six
146    /// colors in a variant.
147    pub fn from_iterm(
148        palette: &str,
149        variant: ggsci::ItermVariant,
150        n: usize,
151    ) -> Result<Self, Error> {
152        let colors = ggsci::iterm_palette(palette)?.take(variant, n)?;
153        Ok(Self {
154            colors,
155            palette_kind: ggsci::PaletteKind::Discrete,
156        })
157    }
158
159    /// Generates colors from a generative discrete Gephi palette.
160    ///
161    /// Use [`Self::from_gephi_with_seed`] when reproducibility matters.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error when the palette is unknown or generation fails.
166    pub fn from_gephi(palette: &str, n: usize) -> Result<Self, Error> {
167        let colors = ggsci::gephi_palette(palette)?.generate(n)?;
168        Ok(Self {
169            colors,
170            palette_kind: ggsci::PaletteKind::Discrete,
171        })
172    }
173
174    /// Generates reproducible colors from a generative discrete Gephi palette.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error when the palette is unknown or generation fails.
179    pub fn from_gephi_with_seed(palette: &str, n: usize, seed: u64) -> Result<Self, Error> {
180        let colors = ggsci::gephi_palette(palette)?.generate_with_seed(n, seed)?;
181        Ok(Self {
182            colors,
183            palette_kind: ggsci::PaletteKind::Discrete,
184        })
185    }
186
187    /// Returns the resolved colors.
188    #[must_use]
189    pub fn colors(&self) -> &[ggsci::Rgb] {
190        &self.colors
191    }
192
193    /// Consumes the wrapper and returns its resolved colors.
194    #[must_use]
195    pub fn into_colors(self) -> Vec<ggsci::Rgb> {
196        self.colors
197    }
198
199    /// Returns the source palette's semantic kind.
200    #[must_use]
201    pub const fn palette_kind(&self) -> ggsci::PaletteKind {
202        self.palette_kind
203    }
204
205    /// Formats the colors as a ggsql array of uppercase `#RRGGBB` strings.
206    #[must_use]
207    pub fn to_sql_array(&self) -> String {
208        format_sql_array(&self.colors)
209    }
210
211    /// Converts the colors to ggsql's typed explicit output range.
212    ///
213    /// The resulting value intentionally does not retain scale-kind metadata.
214    #[must_use]
215    pub fn to_output_range(&self) -> OutputRange {
216        self.into()
217    }
218
219    /// Emits an explicit-array ggsql scale clause of the selected kind.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`Error::InvalidAesthetic`] unless `aesthetic`, after trimming,
224    /// is an ASCII identifier matching `[A-Za-z_][A-Za-z0-9_]*`.
225    pub fn to_scale_clause(&self, kind: ScaleKind, aesthetic: &str) -> Result<String, Error> {
226        let aesthetic = validate_aesthetic(aesthetic)?;
227        Ok(format_scale_clause(kind, aesthetic, &self.colors))
228    }
229
230    /// Emits an explicit-array ggsql scale clause whose kind defaults from the
231    /// source palette semantics.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`Error::InvalidAesthetic`] unless `aesthetic`, after trimming,
236    /// is a valid ASCII identifier.
237    pub fn to_default_scale_clause(&self, aesthetic: &str) -> Result<String, Error> {
238        self.to_scale_clause(ScaleKind::from(self.palette_kind()), aesthetic)
239    }
240}
241
242impl From<GgsqlPalette> for OutputRange {
243    fn from(palette: GgsqlPalette) -> Self {
244        Self::Array(
245            palette
246                .colors
247                .into_iter()
248                .map(|color| ArrayElement::String(color.to_hex_string()))
249                .collect(),
250        )
251    }
252}
253
254impl From<&GgsqlPalette> for OutputRange {
255    fn from(palette: &GgsqlPalette) -> Self {
256        Self::Array(
257            palette
258                .colors
259                .iter()
260                .map(|color| ArrayElement::String(color.to_hex_string()))
261                .collect(),
262        )
263    }
264}
265
266/// Emits a ggsql color array for the first `n` colors in a discrete core
267/// palette.
268///
269/// # Errors
270///
271/// Returns any palette lookup or length error reported by ggsci. In particular,
272/// a continuous palette returns [`ggsci::Error::NotDiscretePalette`].
273pub fn color_array(spec: &str, n: usize) -> Result<String, ggsci::Error> {
274    resolve_discrete_hex(spec, n).map(|colors| format_sql_hex_array(&colors))
275}
276
277/// Emits a ggsql discrete scale clause using a discrete core palette.
278///
279/// This compatibility helper preserves its original ggsci-only error type. Use
280/// [`GgsqlPalette::to_scale_clause`] when aesthetic validation is required.
281///
282/// # Errors
283///
284/// Returns any palette lookup or length error reported by ggsci. In particular,
285/// a continuous palette returns [`ggsci::Error::NotDiscretePalette`].
286pub fn scale_discrete(aesthetic: &str, spec: &str, n: usize) -> Result<String, ggsci::Error> {
287    let colors = resolve_discrete_hex(spec, n)?;
288    Ok(format_scale_clause_with_array(
289        ScaleKind::Discrete,
290        aesthetic.trim(),
291        &format_sql_hex_array(&colors),
292    ))
293}
294
295/// Resolves either kind of core palette as a typed ggsql output range.
296///
297/// # Errors
298///
299/// Returns any palette lookup, sampling, or length error reported by ggsci.
300pub fn output_range(spec: &str, n: usize) -> Result<OutputRange, ggsci::Error> {
301    sample_spec(spec, n).map(OutputRange::from)
302}
303
304/// Emits a continuous ggsql scale clause with explicit interpolation options.
305///
306/// # Errors
307///
308/// Returns an adapter error when palette resolution fails or the aesthetic is
309/// invalid.
310pub fn scale_continuous(
311    aesthetic: &str,
312    spec: &str,
313    n: usize,
314    options: ggsci::ContinuousOptions,
315) -> Result<String, Error> {
316    GgsqlPalette::from_continuous(spec, n, options)?
317        .to_scale_clause(ScaleKind::Continuous, aesthetic)
318}
319
320/// Emits a discrete ggsql scale clause from a fixed iTerm theme variant.
321///
322/// # Errors
323///
324/// Returns an adapter error when palette resolution fails or the aesthetic is
325/// invalid.
326pub fn scale_iterm_discrete(
327    aesthetic: &str,
328    palette: &str,
329    variant: ggsci::ItermVariant,
330    n: usize,
331) -> Result<String, Error> {
332    GgsqlPalette::from_iterm(palette, variant, n)?.to_scale_clause(ScaleKind::Discrete, aesthetic)
333}
334
335/// Emits a reproducible discrete ggsql scale clause from a Gephi generator.
336///
337/// # Errors
338///
339/// Returns an adapter error when palette generation fails or the aesthetic is
340/// invalid.
341pub fn scale_gephi_discrete_with_seed(
342    aesthetic: &str,
343    palette: &str,
344    n: usize,
345    seed: u64,
346) -> Result<String, Error> {
347    GgsqlPalette::from_gephi_with_seed(palette, n, seed)?
348        .to_scale_clause(ScaleKind::Discrete, aesthetic)
349}
350
351fn sample_spec(spec: &str, n: usize) -> Result<GgsqlPalette, ggsci::Error> {
352    let palette = ggsci::palette_by_spec(spec)?;
353    let colors = palette.sample(n)?;
354
355    Ok(GgsqlPalette {
356        colors,
357        palette_kind: palette.kind(),
358    })
359}
360
361fn resolve_discrete_hex(spec: &str, n: usize) -> Result<Vec<String>, ggsci::Error> {
362    ggsci::palette_by_spec(spec)?.take_hex(n)
363}
364
365fn format_sql_array(colors: &[ggsci::Rgb]) -> String {
366    let colors = colors
367        .iter()
368        .copied()
369        .map(ggsci::Rgb::to_hex_string)
370        .collect::<Vec<_>>();
371    format_sql_hex_array(&colors)
372}
373
374fn format_sql_hex_array(colors: &[String]) -> String {
375    let quoted = colors
376        .iter()
377        .map(|color| format!("'{color}'"))
378        .collect::<Vec<_>>()
379        .join(", ");
380    format!("[{quoted}]")
381}
382
383fn format_scale_clause(kind: ScaleKind, aesthetic: &str, colors: &[ggsci::Rgb]) -> String {
384    format_scale_clause_with_array(kind, aesthetic, &format_sql_array(colors))
385}
386
387fn format_scale_clause_with_array(kind: ScaleKind, aesthetic: &str, array: &str) -> String {
388    format!("SCALE {} {aesthetic} TO {array}", kind.as_sql_keyword())
389}
390
391fn validate_aesthetic(aesthetic: &str) -> Result<&str, Error> {
392    let trimmed = aesthetic.trim();
393    let mut bytes = trimmed.bytes();
394    let valid = bytes
395        .next()
396        .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_')
397        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
398
399    if valid {
400        Ok(trimmed)
401    } else {
402        Err(Error::InvalidAesthetic {
403            aesthetic: aesthetic.to_owned(),
404        })
405    }
406}