001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.freemarker;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.commons.http.*;
024import org.apache.juneau.commons.utils.Shorts;
025import org.apache.juneau.marshall.collections.*;
026import org.apache.juneau.marshall.json5.*;
027import org.apache.juneau.marshall.marshaller.Json;
028import org.apache.juneau.console.*;
029import org.apache.juneau.console.views.*;
030
031/**
032 * Typed builder for a {@code chart} card.
033 *
034 * <p>
035 * Produces the same contract entry as {@code <@card type="chart">}; {@link PageSpec} runs it through
036 * {@link ChartCardType} when the page renders.  The factories are {@link #line(String)}, {@link #bar(String)},
037 * {@link #donut(String)}, {@link #heatmap(String)}, {@link #scatter(String)}, {@link #pie(String)}, {@link #treemap(String)} and
038 * {@link #raw(String)}.  {@code line}, {@code bar} and {@code donut} emit the flat legacy form
039 * ({@code kind:"line", x:..., series:[...]}) byte-for-byte; the other kinds emit {@code helper:{kind, ...}}, and
040 * {@code raw} emits neither (its {@link #option(JsonMap)} is the whole chart).
041 *
042 * <h5 class='section'>Example:</h5>
043 * <p class='bjava'>
044 *    <jv>page</jv>.card(ChartDef.<jsm>line</jsm>(<js>"orders-trend"</js>).title(<js>"Orders"</js>).src(<js>"/console/store/stats/daily"</js>)
045 *       .x(<js>"day"</js>).series(<js>"placed"</js>, <js>"Placed"</js>).series(<js>"delivered"</js>, <js>"Delivered"</js>).toCardSpec());
046 * </p>
047 *
048 * @since 10.0.0
049 */
050@SuppressWarnings({
051   "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape.
052   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
053})
054public final class ChartDef {
055
056   /** Minimum chart height in pixels. */
057   public static final int MIN_HEIGHT = 120;
058
059   /** The kinds ChartCardType and the page schema accept. */
060   static final Set<String> KINDS = Set.of("line", "bar", "donut", "heatmap", "scatter", "pie", "treemap");
061
062   // The legacy kinds keep their flat card JSON byte-for-byte; the others emit helper:{kind,...}.
063   static final Set<String> LEGACY_KINDS = Set.of("line", "bar", "donut");
064
065   // Keys a helper reads; they go under helper:{} for the non-legacy kinds and stay flat for the legacy ones.
066   static final Set<String> HELPER_KEYS = Set.of("x", "y", "series", "stacked", "orientation", "legend", "label",
067      "value", "center", "scale", "symbolSize", "groupBy", "size", "color", "tileFields");
068
069   private static final Set<String> DEDICATED = Set.of("id", "type", "title", "src", "kind", "data",
070      "series", "x", "y", "stacked", "orientation", "legend", "height", "refreshMs", "onClick", "emptyText", "label",
071      "value", "center", "visibleWhen", "source", "helper", "option", "patch", "decorators", "on", "group", "renderer",
072      "theme", "largeData", "partialFrames", "a11y", "maps", "scale", "symbolSize", "groupBy", "size", "color", "tileFields",
073      "topicActions", "actions");
074
075   // An id that can be the namespace of a custom topic.
076   private static final java.util.regex.Pattern CLICK_NAMESPACE = java.util.regex.Pattern.compile("[a-z][a-z0-9-]{0,31}");
077
078   private static final Set<String> ACTION_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
079
080   // Tones a treemap colour-key entry accepts (null is neutral): the chart tone names or a palette slot token.
081   static final Set<String> TREEMAP_TONES = Set.of("ok", "warning", "danger", "--jc-chart-1", "--jc-chart-2",
082      "--jc-chart-3", "--jc-chart-4", "--jc-chart-5", "--jc-chart-6", "--jc-chart-7", "--jc-chart-8");
083
084   // A tile field is a bare row-field name, so that {meta.<field>} is always a valid template token.
085   static final java.util.regex.Pattern TILE_FIELD = java.util.regex.Pattern.compile("[A-Za-z0-9_.-]+");
086
087   // Card-level keys: CardSpec owns them (CardSpec.body rejects publishes/subscribes), so wiring and templates are set
088   // on the CardSpec that toCardSpec() returns, never through set(...).
089   private static final Set<String> CARD_LEVEL = Set.of("publishes", "subscribes", "template", "bare", "class");
090
091   private final String kind;
092   private final String id;
093   private String title;
094   private String src;
095   private String source;
096   private List<Map<String,Object>> data;
097   private final Map<String,Object> body = new LinkedHashMap<>();
098   private final List<Map<String,Object>> series = new ArrayList<>();
099   private VisibilityRule[] visibleWhen;
100   private Object option;                          // JsonMap, or a JSON5 String parsed in toCardSpec()
101   private final List<Object> patches = new ArrayList<>();
102   private final List<JsonMap> decorators = new ArrayList<>();
103   private final Map<String,String> on = new LinkedHashMap<>();
104   private final Map<String,String> maps = new LinkedHashMap<>();
105   private final Map<String,Boolean> publishes = new LinkedHashMap<>();
106   private final List<Map<String,Object>> topicActions = new ArrayList<>();
107   private final List<Map<String,Object>> catalog = new ArrayList<>();
108   private final Set<String> actionIds = new HashSet<>();
109
110   private ChartDef(String kind, String id) {
111      if (ib(id))
112         throw iaex("ChartDef requires a non-blank id.");
113      this.kind = kind;
114      this.id = id;
115   }
116
117   /** @param id The card id. @return A new line chart. */
118   public static ChartDef line(String id) { return new ChartDef("line", id); }
119
120   /** @param id The card id. @return A new bar chart. */
121   public static ChartDef bar(String id) { return new ChartDef("bar", id); }
122
123   /** @param id The card id. @return A new donut chart. */
124   public static ChartDef donut(String id) { return new ChartDef("donut", id); }
125
126   /** @param id The card id. @return A new heatmap (category x, category y, a value field). */
127   public static ChartDef heatmap(String id) { return new ChartDef("heatmap", id); }
128
129   /** @param id The card id. @return A new scatter chart. */
130   public static ChartDef scatter(String id) { return new ChartDef("scatter", id); }
131
132   /** @param id The card id. @return A new pie chart. */
133   public static ChartDef pie(String id) { return new ChartDef("pie", id); }
134
135   /**
136    * A treemap: rows nested into boxes by {@link #groupBy(String...)}, one tile per row, with pan and zoom.
137    *
138    * <p>
139    * A click on a tile publishes the row's {@link #tileFields(String...)} as {@code meta} (the tile's {@code row} is
140    * always <jk>null</jk>); a click on a group does nothing.
141    *
142    * <h5 class='section'>Example:</h5>
143    * <p class='bjava'>
144    *    ChartDef.<jsm>treemap</jsm>(<js>"fleet"</js>).src(<js>"/api/hosts"</js>).groupBy(<js>"region"</js>, <js>"cluster"</js>).label(<js>"host"</js>)
145    *       .colorBy(<js>"health"</js>, ChartDef.<jsm>legendItem</jsm>(<js>"ok"</js>, <js>"Healthy"</js>, <js>"ok"</js>),
146    *          ChartDef.<jsm>legendItem</jsm>(<js>"down"</js>, <js>"Down"</js>, <js>"danger"</js>))
147    *       .tileFields(<js>"host"</js>, <js>"health"</js>);
148    * </p>
149    *
150    * @param id The card id.
151    * @return A new treemap; set its groups with {@link #groupBy(String...)} and its tile label with {@link #label(String)}.
152    */
153   public static ChartDef treemap(String id) { return new ChartDef("treemap", id); }
154
155   /**
156    * A chart with no helper.  Anything ECharts draws works this way, for example a box plot:
157    * <p class='bjava'>
158    *    ChartDef.<jsm>raw</jsm>(<js>"latency"</js>).src(<js>"/stats/latency"</js>)
159    *       .option(<js>"{xAxis:{type:'category'},yAxis:{},series:[{type:'boxplot',encode:{x:'day',y:['lo','q1','med','q3','hi']}}]}"</js>);
160    * </p>
161    *
162    * @param id The card id.
163    * @return A new chart with no helper; set its ECharts option with {@link #option(JsonMap)}.
164    */
165   public static ChartDef raw(String id) { return new ChartDef(null, id); }
166
167   /** @param v The card title. @return This object. */
168   public ChartDef title(String v) { title = v; return this; }
169
170   /** @param v A same-origin URL returning an array of rows or {@code {rows:[...]}}. @return This object. */
171   public ChartDef src(String v) { src = v; return this; }
172
173   /** @param rows Inline rows (flat maps). @return This object. */
174   public ChartDef data(List<? extends Map<String,?>> rows) {
175      if (rows == null)
176         throw iaex("ChartDef '%s' data rows must not be null.", id);
177      data = new ArrayList<>();
178      for (var r : rows)
179         data.add(new LinkedHashMap<>(r));
180      return this;
181   }
182
183   /** @param field The x (category) field. @return This object. */
184   public ChartDef x(String field) { return x(field, null, null); }
185
186   /**
187    * @param field The x field.
188    * @param type {@code category} (default), {@code number} or {@code time}; <jk>null</jk> for the default.
189    * @param label The axis label, or <jk>null</jk>.
190    * @return This object.
191    */
192   public ChartDef x(String field, String type, String label) {
193      if (nn(type) && ! Set.of("category", "number", "time").contains(type))
194         throw iaex("ChartDef '%s' x type '%s' must be 'category', 'number' or 'time'.", id, type);
195      var m = new LinkedHashMap<String,Object>();
196      m.put("field", field);
197      if (nn(type)) m.put("type", type);
198      if (nn(label)) m.put("label", label);
199      body.put("x", m);
200      return this;
201   }
202
203   /**
204    * @param label The value-axis label, or <jk>null</jk>.
205    * @param min The axis minimum, or <jk>null</jk> for 0 (or the data minimum when negative).
206    * @param format {@code integer}, {@code decimal}, {@code percent} or {@code compact}; <jk>null</jk> for the default.
207    * @return This object.
208    */
209   public ChartDef y(String label, Number min, String format) {
210      var m = new LinkedHashMap<String,Object>();
211      if (nn(label)) m.put("label", label);
212      if (nn(min)) m.put("min", min);
213      if (nn(format)) m.put("format", format);
214      body.put("y", m);
215      return this;
216   }
217
218   /** @param field A numeric row field. @param label The legend label. @return This object. */
219   public ChartDef series(String field, String label) { return addSeries(field, label, null); }
220
221   /** @param field A numeric row field. @param label The legend label. @param tone Palette slot 1-8. @return This object. */
222   public ChartDef series(String field, String label, int tone) {
223      if (tone < 1 || tone > 8)
224         throw iaex("ChartDef '%s' series tone '%s' must be 1-8.", id, tone);
225      return addSeries(field, label, tone);
226   }
227
228   /** @param field A numeric row field. @param label The legend label. @param tone {@code danger}, {@code ok} or {@code warning}. @return This object. */
229   public ChartDef series(String field, String label, String tone) {
230      if (! Set.of("danger", "ok", "warning").contains(tone))
231         throw iaex("ChartDef '%s' series tone '%s' must be 'danger', 'ok' or 'warning'.", id, tone);
232      return addSeries(field, label, tone);
233   }
234
235   private ChartDef addSeries(String field, String label, Object tone) {
236      if (ib(field))
237         throw iaex("ChartDef '%s' series requires a non-blank field.", id);
238      var m = new LinkedHashMap<String,Object>();
239      m.put("field", field);
240      if (nn(label)) m.put("label", label);
241      if (nn(tone)) m.put("tone", tone);
242      series.add(m);
243      return this;
244   }
245
246   /** @param v Whether bar series stack. @return This object. */
247   public ChartDef stacked(boolean v) { body.put("stacked", v); return this; }
248
249   /** Draws bars horizontally. @return This object. */
250   public ChartDef horizontal() { body.put("orientation", "horizontal"); return this; }
251
252   /** @param v {@code bottom} (default), {@code right} or {@code none}. @return This object. */
253   public ChartDef legend(String v) {
254      if (! Set.of("bottom", "right", "none").contains(v))
255         throw iaex("ChartDef '%s' legend '%s' must be 'bottom', 'right' or 'none'.", id, v);
256      body.put("legend", v);
257      return this;
258   }
259
260   /** @param px The plot height, at least {@link #MIN_HEIGHT}. @return This object. */
261   public ChartDef height(int px) {
262      if (px < MIN_HEIGHT)
263         throw iaex("ChartDef '%s' height '%s' is below the minimum '%s'.", id, px, MIN_HEIGHT);
264      body.put("height", px);
265      return this;
266   }
267
268   /** @param ms The re-fetch interval, at least {@link SafePathTemplate#MIN_POLL_INTERVAL_MS}. @return This object. */
269   public ChartDef refreshMs(long ms) {
270      if (ms < SafePathTemplate.MIN_POLL_INTERVAL_MS)
271         throw iaex("ChartDef '%s' refreshMs '%s' is below the minimum '%s'.", id, ms, SafePathTemplate.MIN_POLL_INTERVAL_MS);
272      body.put("refreshMs", ms);
273      return this;
274   }
275
276   /**
277    * @param template A same-origin path template ({@link SafePathTemplate#isSameOriginPath(String)}); {@code {field}}
278    *    is replaced with the clicked row's value.
279    * @return This object.
280    */
281   public ChartDef onClickHref(String template) {
282      if (! SafePathTemplate.isSameOriginPath(template))
283         throw iaex("ChartDef '%s' onClick href must be a same-origin path; got '%s'.", id, template);
284      onClickExcludes("action");
285      body.put("onClick", Map.of("href", template));
286      return this;
287   }
288
289   /**
290    * Runs a row action when a mark is clicked.
291    *
292    * <p>
293    * A treemap tile runs it; clicking a treemap group header only zooms and runs nothing.
294    *
295    * <p>
296    * Declares {@code on("click", "<id>.click")} unless a click topic is already declared, and runs {@code action}
297    * whenever that topic fires.  Exclusive with {@link #onClickHref(String)}.  The action is refused unless it has an
298    * {@code endpoint} (and, if set, a {@code form}) that is a same-origin path, a non-safe {@code method}, and no
299    * {@code present} other than {@code dialog}, and does not navigate on success.
300    *
301    * @param action The action; its id must be unique across the card's actions.
302    * @return This object.
303    * @throws IllegalArgumentException If the action is not valid on a chart, {@link #onClickHref(String)} was set, or no click
304    *    topic can be derived: the id must match <c>[a-z][a-z0-9-]{0,31}</c>, or call {@link #on(String, String)} with <js>"click"</js> first.
305    */
306   public ChartDef onClickAction(RowAction action) {
307      if (action == null)
308         throw iaex("ChartDef '%s' onClickAction requires an action.", id);
309      onClickExcludes("href");
310      var m = toMap(action);
311      checkAction("onClick action", m);
312      if (body.get("onClick") instanceof Map<?,?> old && old.get("action") instanceof Map<?,?> prev)
313         actionIds.remove(prev.get("id"));
314      claimId(m);
315      if (! on.containsKey("click")) {
316         if (! CLICK_NAMESPACE.matcher(id).matches())
317            throw iaex("ChartDef '%s' onClickAction cannot derive a click topic from the id; declare one with on(\"click\", topic).", id);
318         on("click", id + ".click");
319      }
320      body.put("onClick", Map.of("action", m));
321      return this;
322   }
323
324   /**
325    * Runs a row action when a topic this card publishes fires.
326    *
327    * <p>
328    * Only a click has a keyboard equivalent (Enter on the focused point synthesizes a click); an action bound to another event,
329    * such as {@code dblclick}, is reachable by pointer only.  A treemap group node publishes no events.
330    *
331    * @param topic A topic declared through {@link #on(String, String)}.
332    * @param action The action; its id must be unique across the card's actions.
333    * @return This object.
334    * @throws IllegalArgumentException If the topic is not declared, or the action is not valid on a chart.
335    */
336   public ChartDef onTopicAction(String topic, RowAction action) {
337      if (topic == null || ! on.containsValue(topic))
338         throw iaex("ChartDef '%s' onTopicAction topic '%s' must be one this card publishes through on(...).", id, topic);
339      if (action == null)
340         throw iaex("ChartDef '%s' onTopicAction requires an action.", id);
341      var m = toMap(action);
342      checkAction("topicActions[" + topicActions.size() + "] action", m);
343      claimId(m);
344      var e = new LinkedHashMap<String,Object>();
345      e.put("topic", topic);
346      e.put("action", m);
347      topicActions.add(e);
348      return this;
349   }
350
351   /**
352    * Adds dialog actions that other actions can open as steps.
353    *
354    * <p>
355    * Each entry must have {@code present} set to {@code dialog}, and obeys the same rules as
356    * {@link #onClickAction(RowAction)}.  Calls accumulate.
357    *
358    * @param actions The actions; ids must be unique across the card's actions.
359    * @return This object.
360    * @throws IllegalArgumentException If an entry is <jk>null</jk>, is not a dialog action, or is not valid on a chart.
361    */
362   public ChartDef actions(RowAction... actions) {
363      if (actions == null)
364         throw iaex("ChartDef '%s' actions entries must not be null.", id);
365      for (var a : actions) {
366         if (a == null)
367            throw iaex("ChartDef '%s' actions entries must not be null.", id);
368         var m = toMap(a);
369         var where = "actions[" + catalog.size() + "]";
370         checkAction(where, m);
371         if (! "dialog".equals(m.get("present")))
372            throw iaex("ChartDef '%s' %s '%s' must be present=dialog; a dialog button can only open a dialog action.", id, where, m.get("id"));
373         claimId(m);
374         catalog.add(m);
375      }
376      return this;
377   }
378
379   @SuppressWarnings({
380      "unchecked" // Json.to(..., Object.class) of a bean yields a JSON object, i.e. a Map with string keys.
381   })
382   private static Map<String,Object> toMap(RowAction a) {
383      return (Map<String,Object>)Json.to(Json.of(a), Object.class);
384   }
385
386   private void onClickExcludes(String other) {
387      if (body.get("onClick") instanceof Map<?,?> m && m.containsKey(other))
388         throw iaex("ChartDef '%s' onClick takes either href or action, not both.", id);
389   }
390
391   // The rules every chart action obeys, wherever it is declared: a chart has no cell, page or servlet to hand off to.
392   private void checkAction(String where, Map<String,Object> m) {
393      if (! (m.get("endpoint") instanceof String endpoint) || endpoint.isBlank())
394         throw iaex("ChartDef '%s' %s requires a non-blank endpoint.", id, where);
395      if (! SafePathTemplate.isSameOriginPath(endpoint))
396         throw iaex("ChartDef '%s' %s endpoint must be a same-origin path; got '%s'.", id, where, endpoint);
397      if (m.get("form") instanceof String form && ! SafePathTemplate.isSameOriginPath(form))
398         throw iaex("ChartDef '%s' %s form must be a same-origin path; got '%s'.", id, where, form);
399      if (! (m.get("method") instanceof String method) || ! ACTION_METHODS.contains(method))
400         throw iaex("ChartDef '%s' %s method must be POST, PUT, PATCH or DELETE; got '%s'.", id, where, m.get("method"));
401      if (nn(m.get("present")) && ! "dialog".equals(m.get("present")))
402         throw iaex("ChartDef '%s' %s present must be 'dialog' or absent; got '%s'.", id, where, m.get("present"));
403      if ("navigate".equals(m.get("onSuccess")))
404         throw iaex("ChartDef '%s' %s onSuccess must not be 'navigate'; a chart has nowhere to navigate.", id, where);
405   }
406
407   // Ids are unique across onClick.action, topicActions[] and actions[]: lookup is first-match.
408   private void claimId(Map<String,Object> m) {
409      if (! actionIds.add((String)m.get("id")))
410         throw iaex("ChartDef '%s' action id '%s' is declared more than once.", id, m.get("id"));
411   }
412
413   /** @param v The text shown when there are no rows. @return This object. */
414   public ChartDef emptyText(String v) { body.put("emptyText", v); return this; }
415
416   /** @param fields The treemap group fields, outermost first; at least one, each non-blank. @return This object. */
417   public ChartDef groupBy(String... fields) {
418      only("treemap", "groupBy");
419      if (fields == null || fields.length == 0 || Arrays.stream(fields).anyMatch(Shorts::ib))
420         throw iaex("ChartDef '%s' groupBy requires at least one non-blank field.", id);
421      body.put("groupBy", List.of(fields));
422      return this;
423   }
424
425   /** @param field The numeric treemap field that sizes each tile; without it every tile has the same size. @return This object. */
426   public ChartDef size(String field) {
427      only("treemap", "size");
428      if (ib(field))
429         throw iaex("ChartDef '%s' size requires a non-blank field.", id);
430      body.put("size", field);
431      return this;
432   }
433
434   /**
435    * @param field The treemap field whose value picks each tile's colour.
436    * @param legend The colour key, one {@link #legendItem(String, String, String)} per value; a value with no entry is neutral.
437    * @return This object.
438    */
439   public ChartDef colorBy(String field, JsonMap... legend) {
440      only("treemap", "colorBy");
441      if (ib(field))
442         throw iaex("ChartDef '%s' colorBy requires a non-blank field.", id);
443      if (legend == null || Arrays.stream(legend).anyMatch(Objects::isNull))
444         throw iaex("ChartDef '%s' colorBy legend entries must not be null.", id);
445      body.put("color", JsonMap.of("field", field, "legend", List.of(legend)));
446      return this;
447   }
448
449   /** @param fields The row fields a tile click publishes as {@code meta} and its tooltip shows; each matches {@code [A-Za-z0-9_.-]+}. @return This object. */
450   public ChartDef tileFields(String... fields) {
451      only("treemap", "tileFields");
452      if (fields == null)
453         throw iaex("ChartDef '%s' tileFields must not be null.", id);
454      for (var f : fields)
455         if (f == null || ! TILE_FIELD.matcher(f).matches())
456            throw iaex("ChartDef '%s' tileFields entry '%s' must match [A-Za-z0-9_.-]+.", id, f);
457      body.put("tileFields", List.of(fields));
458      return this;
459   }
460
461   /**
462    * One entry of a treemap colour key.
463    *
464    * @param value The field value this entry matches.
465    * @param label The key text; defaults to the value.
466    * @param tone {@code ok}, {@code warning}, {@code danger}, {@code --jc-chart-1} to {@code --jc-chart-8}, or <jk>null</jk> for neutral.
467    * @return The entry, for {@link #colorBy(String, JsonMap...)}.
468    */
469   public static JsonMap legendItem(String value, String label, String tone) {
470      if (ib(value))
471         throw iaex("ChartDef legendItem requires a non-blank value.");
472      if (nn(tone) && ! TREEMAP_TONES.contains(tone))
473         throw iaex("ChartDef legendItem tone '%s' must be 'ok', 'warning', 'danger', '--jc-chart-1'..'--jc-chart-8' or null.", tone);
474      var m = JsonMap.of("value", value, "label", nn(label) ? label : value);
475      if (nn(tone)) m.put("tone", tone);
476      return m;
477   }
478
479   /** @param field The donut slice-label field, or the treemap tile-label field. @return This object. */
480   public ChartDef label(String field) { body.put("label", field); return this; }
481
482   /** @param field The donut slice-value field. @return This object. */
483   public ChartDef value(String field) { body.put("value", field); return this; }
484
485   /** @param v {@code total} or literal center text. @return This object. */
486   public ChartDef center(String v) { body.put("center", v); return this; }
487
488   /** @param v A raw ECharts option (JSON only; functions through {@link Fn}). @return This object. */
489   public ChartDef option(JsonMap v) { option = rnn(v); return this; }
490
491   /** @param json5 A raw ECharts option as JSON5, parsed by {@link #toCardSpec()}. @return This object. */
492   public ChartDef option(String json5) { option = rnn(json5); return this; }
493
494   /** @param d A decorator; a {@code topic} it names is added to the card's {@code publishes}. @return This object. */
495   public ChartDef decorate(Decorator d) {
496      var m = d.toJson();
497      Fn.checkName(m.get("use") instanceof String s ? s : null, "decorator");
498      decorators.add(m);
499      if (m.get("topic") instanceof String t)
500         addPublish(t, false);
501      return this;
502   }
503
504   /** @param name A dotted app decorator name (or a built-in). @param args Its arguments (no {@code use} key). @return This object. */
505   public ChartDef decorate(String name, JsonMap args) {
506      Fn.checkName(name, "decorator");
507      if (args != null && args.containsKey("use"))
508         throw iaex("ChartDef '%s' decorator '%s' args must not set 'use'.", id, name);
509      var m = JsonMap.of("use", name);
510      if (args != null)
511         m.putAll(args);
512      decorators.add(m);
513      return this;
514   }
515
516   /** @param v Deep-merged into the final option (objects by key, arrays by element {@code id}). @return This object. */
517   public ChartDef patch(JsonMap v) { patches.add(rnn(v)); return this; }
518
519   /** @param json5 A patch as JSON5, parsed by {@link #toCardSpec()}. @return This object. */
520   public ChartDef patch(String json5) { patches.add(rnn(json5)); return this; }
521
522   /**
523    * @param event An ECharts event name matching {@code ^[a-z][a-z0-9_]{0,31}$}.
524    * @param topic A custom topic; added to the card's {@code publishes}.
525    * @return This object.
526    */
527   public ChartDef on(String event, String topic) {
528      if (event == null || ! BusWiringValidator.EVENT_NAME.matcher(event).matches())
529         throw new IllegalArgumentException("E-74: " + f(BusWiringValidator.E74, event, "event"));
530      addPublish(topic, false);
531      on.put(event, topic);
532      return this;
533   }
534
535   /** Publishes the zoom window on {@code zoom:<id>}. @return This object. */
536   public ChartDef publishZoom() { addPublish(Topics.zoom(id), true); return this; }
537
538   /** Publishes the legend selection on {@code legend:<id>}. @return This object. */
539   public ChartDef publishLegend() { addPublish(Topics.legend(id), true); return this; }
540
541   /** @param v A connect group shared with other chart cards. @return This object. */
542   public ChartDef group(String v) {
543      if (ib(v))
544         throw iaex("ChartDef '%s' group must be non-blank.", id);
545      body.put("group", v);
546      return this;
547   }
548
549   /** @param v {@code canvas} (default) or {@code svg}. @return This object. */
550   public ChartDef renderer(String v) {
551      if (! Set.of("canvas", "svg").contains(v))
552         throw iaex("ChartDef '%s' renderer '%s' must be 'canvas' or 'svg'.", id, v);
553      body.put("renderer", v);
554      return this;
555   }
556
557   /** @param v An ECharts theme name registered in the page; the default is the page theme. @return This object. */
558   public ChartDef theme(String v) { body.put("theme", v); return this; }
559
560   /** @param dataTopic A {@code data:} topic ({@link Topics#data(String)}). @return This object. */
561   public ChartDef source(String dataTopic) { source = Markers.dataTopicOf("ChartDef '" + id + "'", dataTopic); return this; }
562
563   /** @param field The heatmap y (category) field. @return This object. */
564   public ChartDef yField(String field) {
565      only("heatmap", "yField");
566      body.put("y", JsonMap.of("field", field));
567      return this;
568   }
569
570   /** @param min The heatmap colour-scale minimum. @param max Its maximum. @return This object. */
571   public ChartDef scale(Number min, Number max) {
572      only("heatmap", "scale");
573      body.put("scale", JsonMap.of("min", min, "max", max));
574      return this;
575   }
576
577   /** @param field The scatter field sized by square root. @return This object. */
578   public ChartDef symbolSizeBy(String field) {
579      only("scatter", "symbolSizeBy");
580      body.put("symbolSize", Fn.ref("symbolSizeBy", JsonMap.of("field", field)));
581      return this;
582   }
583
584   /** @param threshold Rows above which the large-data defaults apply. @return This object. */
585   public ChartDef largeData(int threshold) { return largeData(threshold, false); }
586
587   /** @param threshold Rows above which the large-data defaults apply. @param progressive {@code true} sets progressive to the threshold; {@code false} leaves the JS default (5000). @return This object. */
588   public ChartDef largeData(int threshold, boolean progressive) {
589      if (threshold < 1)
590         throw iaex("ChartDef '%s' largeData threshold '%s' must be at least 1.", id, threshold);
591      var m = JsonMap.of("threshold", threshold);
592      if (progressive)
593         m.put("progressive", threshold);
594      body.put("largeData", m);
595      return this;
596   }
597
598   /** Draws partial frames during a chunked {@code source} load. @return This object. */
599   public ChartDef partialFrames() { body.put("partialFrames", true); return this; }
600
601   /** @param summary The figure caption, or <jk>null</jk> for the generated one. @param table Show the data-table toggle. @return This object. */
602   public ChartDef a11y(String summary, boolean table) {
603      var m = new JsonMap();
604      if (nn(summary)) m.put("summary", summary);
605      m.put("table", table);
606      body.put("a11y", m);
607      return this;
608   }
609
610   /** @param name An ECharts map name. @param path A same-origin path to its GeoJSON. @return This object. */
611   public ChartDef map(String name, String path) {
612      if (! SafePathTemplate.isSameOriginPath(path))
613         throw iaex("ChartDef '%s' map '%s' must be a same-origin path; got '%s'.", id, name, path);
614      maps.put(name, path);
615      return this;
616   }
617
618   // raw(...) has no kind; Set.of(...).contains(null) throws.
619   private boolean legacy() {
620      return kind != null && LEGACY_KINDS.contains(kind);
621   }
622
623   private void only(String k, String method) {
624      if (neq(kind, k))
625         throw iaex("ChartDef '%s' %s(...) is for kind '%s' only.", id, method, k);
626   }
627
628   // One entry per topic: the first wins, except that a later retain=true upgrades it (zoom:/legend: need it).
629   private void addPublish(String topic, boolean retain) {
630      TopicDecl.of(topic);
631      publishes.merge(topic, retain, Boolean::logicalOr);
632   }
633
634   private JsonMap parsed(Object v, String what) {
635      if (v instanceof JsonMap m)
636         return m;
637      try {
638         return Json5Parser.DEFAULT.read(((String)v).trim(), JsonMap.class);
639      } catch (org.apache.juneau.marshall.parser.ParseException e) {
640         throw iaex("ChartDef '%s' %s is not valid JSON5: %s", id, what, e.getMessage());
641      }
642   }
643
644   /**
645    * Deep merge: objects by key; arrays element-by-element by {@code id} (a match merges, no match
646    * appends); an array whose elements carry no {@code id} replaces the whole array.  {@code into} is changed in place.
647    */
648   @SuppressWarnings({
649      "unchecked" // Narrows a JSON-shaped Map<?,?> to Map<String,Object>; its keys are always strings.
650   })
651   static void merge(Map<String,Object> into, Map<?,?> from) {
652      for (var e : from.entrySet()) {
653         var k = String.valueOf(e.getKey());
654         var cur = into.get(k);
655         var v = e.getValue();
656         if (cur instanceof Map && v instanceof Map) {
657            var copy = new JsonMap((Map<String,Object>)cur);
658            merge(copy, (Map<?,?>)v);
659            into.put(k, copy);
660         } else if (cur instanceof List<?> a && v instanceof List<?> b && hasIds(b)) {
661            var out = new ArrayList<Object>(a);
662            for (var item : b) {
663               var at = indexOfId(out, ((Map<?,?>)item).get("id"));
664               if (at < 0) {
665                  out.add(item);
666               } else {
667                  var copy = new JsonMap((Map<String,Object>)out.get(at));
668                  merge(copy, (Map<?,?>)item);
669                  out.set(at, copy);
670               }
671            }
672            into.put(k, out);
673         } else {
674            into.put(k, v);
675         }
676      }
677   }
678
679   private static boolean hasIds(List<?> l) {
680      return ! l.isEmpty() && l.stream().allMatch(x -> x instanceof Map<?,?> m && m.get("id") != null);
681   }
682
683   private static int indexOfId(List<Object> l, Object id) {
684      for (var i = 0; i < l.size(); i++)
685         if (l.get(i) instanceof Map<?,?> m && eq(m.get("id"), id))
686            return i;
687      return -1;
688   }
689
690   /**
691    * Sets a body key that has no dedicated method (for example an ECharts-level card key the glue reads).
692    * For a helper kind, a helper key set here is routed under {@code helper}.
693    *
694    * @param key A key with no dedicated method.
695    * @param value Its value.
696    * @return This object.
697    */
698   public ChartDef set(String key, Object value) {
699      if (CARD_LEVEL.contains(key))
700         throw iaex("ChartDef '%s' key '%s' is card-level; set it on toCardSpec().", id, key);
701      if (DEDICATED.contains(key))
702         throw iaex("ChartDef '%s' key '%s' has a dedicated method.", id, key);
703      body.put(key, value);
704      return this;
705   }
706
707   /** @param rules Card-level visibility rules, ANDed. @return This object. */
708   public ChartDef visibleWhen(VisibilityRule... rules) { visibleWhen = rules; return this; }
709
710   /**
711    * @return The {@code chart} card.
712    * @throws IllegalArgumentException If not exactly one of {@code src}/{@code data}/{@code source} is set, a kind's
713    *    required keys are missing, {@code raw} has no option, or an option/patch string is not valid JSON5.
714    */
715   public CardSpec toCardSpec() {
716      if ((nn(src) ? 1 : 0) + (nn(data) ? 1 : 0) + (nn(source) ? 1 : 0) != 1)
717         throw iaex("ChartDef '%s' requires exactly one of src(...), data(...) or source(...).", id);
718      if (! nn(src) && body.containsKey("refreshMs"))
719         throw iaex("ChartDef '%s' refreshMs(...) needs src(...); inline data never refreshes.", id);
720      if (body.containsKey("partialFrames") && ! nn(source))
721         throw iaex("ChartDef '%s' partialFrames() needs source(...).", id);
722      if (kind != null && Set.of("line", "bar", "scatter").contains(kind) && (! body.containsKey("x") || series.isEmpty()))
723         throw iaex("ChartDef '%s' kind '%s' requires x(...) and at least one series(...).", id, kind);
724      if (kind != null && Set.of("donut", "pie").contains(kind) && (! (body.get("label") instanceof String l) || l.isBlank() || ! (body.get("value") instanceof String v) || v.isBlank()))
725         throw iaex("ChartDef '%s' kind '%s' requires label(...) and value(...).", id, kind);
726      if (eq(kind, "treemap") && ! body.containsKey("groupBy"))
727         throw iaex("ChartDef '%s' kind 'treemap' requires groupBy(...).", id);
728      if (eq(kind, "treemap") && (! (body.get("label") instanceof String tl) || tl.isBlank()))
729         throw iaex("ChartDef '%s' kind 'treemap' requires label(field).", id);
730      if (eq(kind, "heatmap") && (! body.containsKey("x") || ! body.containsKey("y") || ! (body.get("value") instanceof String)))
731         throw iaex("ChartDef '%s' kind 'heatmap' requires x(...), yField(...) and value(...).", id);
732      if (kind == null && option == null)
733         throw iaex("ChartDef '%s' raw(...) requires option(...).", id);
734      if (kind == null && (! series.isEmpty() || body.keySet().stream().anyMatch(HELPER_KEYS::contains)))
735         throw iaex("ChartDef '%s' raw(...) takes no helper keys (x, y, series, ...); put them in option(...).", id);
736      var c = CardSpec.of("chart", id);
737      if (legacy())
738         c.body("kind", kind);
739      if (nn(title)) c.title(title);
740      if (nn(src)) c.src(src);
741      if (nn(source)) {
742         c.body("source", source);
743         c.subscribes(Subscription.to(source).as("data"));
744      }
745      if (legacy()) {
746         body.forEach(c::body);
747         if (! series.isEmpty()) c.body("series", List.copyOf(series));
748      } else {
749         var helper = kind == null ? null : JsonMap.of("kind", kind);
750         body.forEach((k, v) -> { if (helper != null && HELPER_KEYS.contains(k)) helper.put(k, v); else if (! HELPER_KEYS.contains(k)) c.body(k, v); });
751         if (helper != null && ! series.isEmpty()) helper.put("series", List.copyOf(series));
752         if (helper != null) c.body("helper", helper);
753      }
754      if (nn(data)) c.body("data", List.copyOf(data));
755      if (nn(option)) c.body("option", parsed(option, "option"));
756      if (! patches.isEmpty()) {
757         var p = new JsonMap();
758         for (var x : patches)
759            merge(p, parsed(x, "patch"));
760         c.body("patch", p);
761      }
762      if (! decorators.isEmpty()) c.body("decorators", List.copyOf(decorators));
763      if (! on.isEmpty()) c.body("on", new JsonMap(on));
764      if (! topicActions.isEmpty()) c.body("topicActions", List.copyOf(topicActions));
765      if (! catalog.isEmpty()) c.body("actions", List.copyOf(catalog));
766      if (! maps.isEmpty()) c.body("maps", new JsonMap(maps));
767      if (! publishes.isEmpty()) c.publishes(publishes.entrySet().stream().map(e -> TopicDecl.of(e.getKey()).retain(e.getValue())).toArray(TopicDecl[]::new));
768      if (nn(visibleWhen)) c.visibleWhen(visibleWhen);
769      return c;
770   }
771}