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}