Line data Source code
1 : //! Rewrite the visualization tags of a query: set, replace or remove its renderer (`@vis`) and its board
2 : //! placement (`@board`), and the flow position of a script (`@flow`).
3 : //!
4 : //! The tags are edited in the annotation of the query (see `annotation`), so an edit leaves the annotation
5 : //! consolidated in one comment. The edited tag is printed anew, the other tags keep their content. A new
6 : //! renderer goes before the other `@vis` tags, a new board placement after the renderer or, without one, where a
7 : //! renderer would go. Any statement can be placed on the board, only queries have a renderer. The tags of older
8 : //! scripts, `@vis board`, `@vis card` and `@vis flow`, are replaced by `@board` and `@flow`. A statement that gains
9 : //! or loses its renderer loses its board placement, the card moves to another section of the board. `/*` and `*/` in
10 : //! the strings of a Vega-Lite spec are escaped, they would nest or end the comment.
11 :
12 : use crate::annotation::{Annotation, AnnotationTag};
13 : use crate::error::{Error, Result};
14 : use dashql_buffers::parser::StatementType;
15 : use dashql_buffers::status::StatusCode;
16 : use dashql_parser::parser::ParsedScript;
17 : use dashql_parser::span::TextSpan;
18 : use serde_json::{Map, Value};
19 :
20 : /// The tag of visualizations
21 : const VIS_TAG: &str = "vis";
22 : /// The tag of the dashboard placement of a visualization
23 : const BOARD_TAG: &str = "board";
24 : /// The tag of the position of a script on the dataflow canvas
25 : const FLOW_TAG: &str = "flow";
26 : /// The width of the JSON in `@vis vegalite` tags: 80 columns behind a comment prefix of 3 characters
27 : pub(crate) const VIS_JSON_WIDTH: usize = 77;
28 : /// The dashboard grid has 12 columns
29 : const GRID_COLUMNS: i32 = 12;
30 :
31 : /// The renderer of a visualization
32 : #[derive(Clone, Debug, PartialEq)]
33 : pub enum VisRenderer {
34 : /// A Vega-Lite spec without `data`
35 : VegaLite(Map<String, Value>),
36 : /// A Markdown row template
37 : Markdown(String),
38 : /// The result table
39 : Table,
40 : }
41 :
42 : impl VisRenderer {
43 : /// Read a renderer from its kind and its body, the body of `vegalite` is a JSON object
44 10 : pub fn from_parts(kind: &str, body: &str) -> Result<Self> {
45 3 : match kind {
46 10 : "vegalite" => match serde_json::from_str(body) {
47 5 : Ok(Value::Object(spec)) => Ok(VisRenderer::VegaLite(spec)),
48 1 : Ok(_) => Err(Error::invalid_argument("the Vega-Lite spec must be a JSON object")),
49 0 : Err(e) => Err(Error::invalid_argument(format!("invalid Vega-Lite JSON: {e}"))),
50 : },
51 4 : "markdown" if body.trim().is_empty() => Err(Error::invalid_argument("a Markdown template must not be empty")),
52 2 : "markdown" => Ok(VisRenderer::Markdown(body.to_string())),
53 1 : "table" => Ok(VisRenderer::Table),
54 1 : _ => Err(Error::invalid_argument(format!("unknown @vis renderer \"{kind}\""))),
55 : }
56 10 : }
57 :
58 : /// The `@vis` tag of the renderer
59 39 : pub fn tag(&self) -> AnnotationTag {
60 39 : let lines = match self {
61 29 : VisRenderer::VegaLite(spec) => {
62 29 : let json = crate::compact_json::to_string_compact(&Value::Object(spec.clone()), VIS_JSON_WIDTH);
63 : // `/*` and `*/` can only be part of JSON strings, escapes split them without changing the
64 : // value: `/*` would nest and `*/` would end a block comment
65 29 : let json = json.replace("/*", "/\\u002a").replace("*/", "*\\/");
66 29 : std::iter::once("vegalite".to_string()).chain(json.lines().map(str::to_string)).collect()
67 : }
68 2 : VisRenderer::Markdown(template) => {
69 2 : std::iter::once("markdown".to_string()).chain(template.trim_end().lines().map(str::to_string)).collect()
70 : }
71 8 : VisRenderer::Table => vec!["table".to_string()],
72 : };
73 39 : AnnotationTag::new(VIS_TAG, lines)
74 39 : }
75 : }
76 :
77 : /// The dashboard placement of a visualization
78 : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
79 : pub struct VisBoard {
80 : /// The grid row, None for automatic placement
81 : pub row: Option<i32>,
82 : /// The grid column, None for automatic placement
83 : pub column: Option<i32>,
84 : /// The width in grid columns
85 : pub width: i32,
86 : /// The height in grid rows
87 : pub height: i32,
88 : }
89 :
90 : impl VisBoard {
91 : /// The `@board` tag. Negative rows are dropped, the width is limited to the grid, the column to the
92 : /// free space.
93 27 : pub fn tag(&self) -> AnnotationTag {
94 27 : let width = self.width.clamp(1, GRID_COLUMNS);
95 27 : let mut args = Vec::new();
96 27 : if let Some(row) = self.row.filter(|&r| r >= 0) {
97 8 : args.push(format!("row={row}"));
98 19 : }
99 27 : if let Some(column) = self.column {
100 9 : args.push(format!("column={}", column.clamp(0, GRID_COLUMNS - width)));
101 21 : }
102 27 : args.push(format!("width={width} height={}", self.height.max(1)));
103 27 : AnnotationTag::new(BOARD_TAG, vec![args.join(" ")])
104 27 : }
105 : }
106 :
107 : /// The placement of a script on the dataflow canvas: a position, a size, or both
108 : #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109 : pub struct VisFlow {
110 : /// The x and y coordinates of the top left corner, None places the box automatically
111 : pub position: Option<(i32, i32)>,
112 : /// The width of the box, None for the default width
113 : pub width: Option<i32>,
114 : /// The height of the chart of the box, None for the default height
115 : pub height: Option<i32>,
116 : }
117 :
118 : impl VisFlow {
119 : /// The `@flow` tag, sizes are at least 1
120 8 : pub fn tag(&self) -> AnnotationTag {
121 8 : let mut args = Vec::new();
122 8 : if let Some((x, y)) = self.position {
123 7 : args.push(format!("x={x} y={y}"));
124 7 : }
125 8 : if let Some(width) = self.width {
126 2 : args.push(format!("width={}", width.max(1)));
127 6 : }
128 8 : if let Some(height) = self.height {
129 2 : args.push(format!("height={}", height.max(1)));
130 6 : }
131 8 : AnnotationTag::new(FLOW_TAG, vec![args.join(" ")])
132 8 : }
133 : }
134 :
135 : /// Is a tag a `@vis` tag?
136 185 : fn is_vis(tag: &AnnotationTag) -> bool {
137 185 : tag.name == VIS_TAG
138 185 : }
139 :
140 : /// Is a tag a tag of the visualization: `@vis`, `@board` or `@flow`?
141 15 : pub(crate) fn is_vis_family(tag: &AnnotationTag) -> bool {
142 15 : matches!(tag.name.as_str(), VIS_TAG | BOARD_TAG | FLOW_TAG)
143 15 : }
144 :
145 : /// Is a tag the renderer of a visualization?
146 117 : fn is_renderer(tag: &AnnotationTag) -> bool {
147 117 : is_vis(tag) && matches!(tag.kind(), "vegalite" | "markdown" | "table")
148 117 : }
149 :
150 : /// Is a tag the dashboard placement of a visualization, `@board` or the `@vis board` of older scripts?
151 63 : fn is_board(tag: &AnnotationTag) -> bool {
152 63 : tag.name == BOARD_TAG || (is_vis(tag) && matches!(tag.kind(), "board" | "card"))
153 63 : }
154 :
155 : /// Is a tag the flow position of a script, `@flow` or the `@vis flow` of older scripts?
156 34 : fn is_flow(tag: &AnnotationTag) -> bool {
157 34 : tag.name == FLOW_TAG || (is_vis(tag) && tag.kind() == "flow")
158 34 : }
159 :
160 : /// Rewrite the annotation of a query
161 52 : fn rewrite_query(
162 52 : text: &str,
163 52 : parsed: &ParsedScript,
164 52 : statement_id: usize,
165 52 : edit: impl FnOnce(&mut Annotation) -> Result<Option<usize>>,
166 52 : ) -> Result<(String, Option<TextSpan>)> {
167 52 : if !parsed.errors.is_empty() {
168 1 : return Err(Error::status(StatusCode::SCRIPT_NOT_PARSED));
169 51 : }
170 51 : let statement = parsed.statements.get(statement_id).ok_or_else(|| Error::invalid_argument("unknown statement"))?;
171 50 : if statement.statement_type != StatementType::SELECT {
172 1 : return Err(Error::invalid_argument("@vis visualizes a query"));
173 49 : }
174 49 : crate::annotation::rewrite(text, parsed, statement_id, edit)
175 52 : }
176 :
177 : /// Set or remove the renderer of a query. Adding the first renderer or removing the last one also removes the board
178 : /// placement. Returns the new text and the span of the written tag.
179 52 : pub(crate) fn rewrite_vis_renderer_tag(
180 52 : text: &str,
181 52 : parsed: &ParsedScript,
182 52 : statement_id: usize,
183 52 : renderer: Option<&VisRenderer>,
184 52 : ) -> Result<(String, Option<TextSpan>)> {
185 52 : rewrite_query(text, parsed, statement_id, |annotation| {
186 49 : let tags = &mut annotation.tags;
187 : // A statement that gains or loses its renderer moves between the sections of the board, its placement in the
188 : // section it leaves means nothing in the other one. The placement is dropped, the card flows into the free
189 : // space of its new section.
190 49 : if renderer.is_some() != tags.iter().any(is_renderer) {
191 29 : tags.retain(|tag| !is_board(tag));
192 20 : }
193 49 : Ok(match (renderer, tags.iter().position(is_renderer)) {
194 20 : (Some(renderer), Some(i)) => {
195 20 : tags[i] = renderer.tag();
196 20 : Some(i)
197 : }
198 19 : (Some(renderer), None) => {
199 19 : let i = tags.iter().position(is_vis_family).unwrap_or(tags.len());
200 19 : tags.insert(i, renderer.tag());
201 19 : Some(i)
202 : }
203 10 : (None, Some(i)) => {
204 10 : tags.remove(i);
205 10 : None
206 : }
207 0 : (None, None) => None,
208 : })
209 49 : })
210 52 : }
211 :
212 : /// Set or remove the renderer of a query
213 28 : pub fn rewrite_vis_renderer(text: &str, parsed: &ParsedScript, statement_id: usize, renderer: Option<&VisRenderer>) -> Result<String> {
214 28 : Ok(rewrite_vis_renderer_tag(text, parsed, statement_id, renderer)?.0)
215 28 : }
216 :
217 : /// Set or remove the board placement of a statement. Any statement is placed, a statement without a renderer is a
218 : /// collapsed card on the board.
219 30 : pub fn rewrite_vis_board(text: &str, parsed: &ParsedScript, statement_id: usize, board: Option<&VisBoard>) -> Result<String> {
220 30 : let (text, _) = crate::annotation::rewrite(text, parsed, statement_id, |annotation| {
221 29 : let tags = &mut annotation.tags;
222 29 : Ok(match (board, tags.iter().position(is_board)) {
223 4 : (Some(board), Some(i)) => {
224 4 : tags[i] = board.tag();
225 4 : Some(i)
226 : }
227 23 : (Some(board), None) => {
228 : // After the renderer, or where a renderer would go
229 23 : let i = match tags.iter().position(is_renderer) {
230 19 : Some(renderer) => renderer + 1,
231 4 : None => tags.iter().position(is_vis_family).unwrap_or(tags.len()),
232 : };
233 23 : tags.insert(i, board.tag());
234 23 : Some(i)
235 : }
236 1 : (None, Some(i)) => {
237 1 : tags.remove(i);
238 1 : None
239 : }
240 1 : (None, None) => None,
241 : })
242 29 : })?;
243 27 : Ok(text)
244 30 : }
245 :
246 : /// Set or remove the flow position of a script. The position is a tag of any statement: the first `@flow` tag in
247 : /// statement order is replaced where it is, the others are removed, and a new one goes after the visualization tags of
248 : /// the terminal statement. Removing the position removes every `@flow` tag.
249 11 : pub fn rewrite_vis_flow(text: &str, parsed: &ParsedScript, flow: Option<&VisFlow>) -> Result<String> {
250 11 : let insert_at = |tags: &[AnnotationTag]| tags.iter().rposition(is_vis_family).map_or(tags.len(), |i| i + 1);
251 11 : crate::annotation::rewrite_script_tag(text, parsed, is_flow, flow.map(VisFlow::tag), None, insert_at)
252 11 : }
|