1use std::fmt;
9
10use ggsql::plot::{scale::OutputRange, ArrayElement};
11
12#[derive(Debug)]
14pub enum Error {
15 Ggsci(ggsci::Error),
17 InvalidAesthetic {
19 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ScaleKind {
57 Discrete,
59 Continuous,
61 Binned,
63 Ordinal,
65}
66
67impl ScaleKind {
68 #[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#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct GgsqlPalette {
95 colors: Vec<ggsci::Rgb>,
96 palette_kind: ggsci::PaletteKind,
97}
98
99impl GgsqlPalette {
100 #[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 pub fn from_spec(spec: &str, n: usize) -> Result<Self, Error> {
118 sample_spec(spec, n).map_err(Error::from)
119 }
120
121 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 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 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 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 #[must_use]
189 pub fn colors(&self) -> &[ggsci::Rgb] {
190 &self.colors
191 }
192
193 #[must_use]
195 pub fn into_colors(self) -> Vec<ggsci::Rgb> {
196 self.colors
197 }
198
199 #[must_use]
201 pub const fn palette_kind(&self) -> ggsci::PaletteKind {
202 self.palette_kind
203 }
204
205 #[must_use]
207 pub fn to_sql_array(&self) -> String {
208 format_sql_array(&self.colors)
209 }
210
211 #[must_use]
215 pub fn to_output_range(&self) -> OutputRange {
216 self.into()
217 }
218
219 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 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
266pub 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
277pub 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
295pub fn output_range(spec: &str, n: usize) -> Result<OutputRange, ggsci::Error> {
301 sample_spec(spec, n).map(OutputRange::from)
302}
303
304pub 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
320pub 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
335pub 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}