sbatchman 2.0.0__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {sbatchman-2.0.0/src/sbatchman.egg-info → sbatchman-2.0.2}/PKG-INFO +3 -2
  2. {sbatchman-2.0.0 → sbatchman-2.0.2}/pyproject.toml +2 -3
  3. sbatchman-2.0.2/src/sbatchman/visualize/docs.html +138 -0
  4. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/handler.py +24 -1
  5. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/actions.js +2 -0
  6. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/axis_addon.js +1 -0
  7. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/builder.js +167 -16
  8. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/config.js +113 -31
  9. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/grid.js +2 -2
  10. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/grid_addon.js +39 -10
  11. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/main.js +43 -1
  12. sbatchman-2.0.2/src/sbatchman/visualize/static/plot_fields.xml +12 -0
  13. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/plotting.js +13 -3
  14. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/schema.js +12 -2
  15. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/state.js +4 -1
  16. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/style_addon.js +19 -4
  17. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/tabs.js +115 -6
  18. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/agg.py +1 -2
  19. sbatchman-2.0.2/src/sbatchman/visualize/utils/core.py +125 -0
  20. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/export.py +11 -2
  21. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/layout.py +44 -1
  22. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/matplotlib_backend.py +91 -34
  23. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/render.py +104 -37
  24. sbatchman-2.0.2/src/sbatchman/visualize/utils/script_paths.py +76 -0
  25. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/webapp.html +77 -19
  26. {sbatchman-2.0.0 → sbatchman-2.0.2/src/sbatchman.egg-info}/PKG-INFO +3 -2
  27. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman.egg-info/SOURCES.txt +3 -0
  28. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman.egg-info/requires.txt +2 -1
  29. sbatchman-2.0.0/src/sbatchman/visualize/utils/core.py +0 -68
  30. {sbatchman-2.0.0 → sbatchman-2.0.2}/MANIFEST.in +0 -0
  31. {sbatchman-2.0.0 → sbatchman-2.0.2}/README.md +0 -0
  32. {sbatchman-2.0.0 → sbatchman-2.0.2}/setup.cfg +0 -0
  33. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/__init__.py +0 -0
  34. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/cli.py +0 -0
  35. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/config/global_config.py +0 -0
  36. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/config/project_config.py +0 -0
  37. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/__init__.py +0 -0
  38. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/campaign.py +0 -0
  39. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/config_manager.py +0 -0
  40. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/job.py +0 -0
  41. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/jobs_manager.py +0 -0
  42. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/launcher.py +0 -0
  43. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/status.py +0 -0
  44. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/core/variables.py +0 -0
  45. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/exceptions.py +0 -0
  46. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/parser.py +0 -0
  47. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/remote/fetch.py +0 -0
  48. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/remote/ssh.py +0 -0
  49. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/remote/sync.py +0 -0
  50. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/schedulers/__init.py +0 -0
  51. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/schedulers/base.py +0 -0
  52. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/schedulers/local.py +0 -0
  53. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/schedulers/pbs.py +0 -0
  54. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/schedulers/slurm.py +0 -0
  55. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/tui/jobs_screen.py +0 -0
  56. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/tui/log_screen.py +0 -0
  57. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/tui/style.tcss +0 -0
  58. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/tui/tui_campaign.py +0 -0
  59. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/tui/tui_remote.py +0 -0
  60. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/tui/tui_status.py +0 -0
  61. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/split_webapp.py +0 -0
  62. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/export_image.js +0 -0
  63. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/font.js +0 -0
  64. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/log.js +0 -0
  65. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/static/opentype.js +0 -0
  66. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/db.py +0 -0
  67. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/plots.py +0 -0
  68. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/style_addon.py +0 -0
  69. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman/visualize/utils/styles.py +0 -0
  70. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman.egg-info/dependency_links.txt +0 -0
  71. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman.egg-info/entry_points.txt +0 -0
  72. {sbatchman-2.0.0 → sbatchman-2.0.2}/src/sbatchman.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbatchman
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: A utility to create, launch and monitor code experiments on SLURM, PBS, or local machines.
5
5
  Author: Lorenzo Pichetti
6
6
  Author-email: Thomas Pasquali <thomas.pasquali@unitn.it>, Salvatore Andaloro <sbatchman@sasso.dev>
@@ -19,10 +19,11 @@ Requires-Dist: platformdirs
19
19
  Requires-Dist: pandas
20
20
  Requires-Dist: paramiko
21
21
  Requires-Dist: tomli_w
22
- Requires-Dist: pysqlite3
23
22
  Provides-Extra: visualization
23
+ Requires-Dist: pysqlite3; extra == "visualization"
24
24
  Requires-Dist: matplotlib>=3.5; extra == "visualization"
25
25
  Requires-Dist: seaborn>=0.12; extra == "visualization"
26
+ Requires-Dist: numpy; extra == "visualization"
26
27
 
27
28
  <p align="center" style="padding: 0; margin: 0;">
28
29
  <img src="https://github.com/LorenzoPichetti/SbatchMan/blob/main/docs/images/SbatchManLogo.png" alt="SbatchManLogo" style="width: 6cm;">
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sbatchman"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  authors = [
9
9
  { name="Thomas Pasquali", email="thomas.pasquali@unitn.it" },
10
10
  { name="Salvatore Andaloro", email="sbatchman@sasso.dev" },
@@ -27,11 +27,10 @@ dependencies = [
27
27
  "pandas",
28
28
  "paramiko",
29
29
  "tomli_w",
30
- "pysqlite3",
31
30
  ]
32
31
 
33
32
  [project.optional-dependencies]
34
- visualization = ["matplotlib>=3.5", "seaborn>=0.12"]
33
+ visualization = ["pysqlite3", "matplotlib>=3.5", "seaborn>=0.12", "numpy"]
35
34
 
36
35
  [project.urls]
37
36
  "Homepage" = "https://github.com/LorenzoPichetti/SbatchMan"
@@ -0,0 +1,138 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Plot Builder — Quick Start</title>
7
+ <style>
8
+ :root{color-scheme:dark;--bg:#0d1117;--panel:#161b22;--text:#e6edf3;--muted:#9da7b3;--accent:#58a6ff;--border:#30363d}
9
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,sans-serif}
10
+ main{max-width:760px;margin:48px auto;padding:0 24px 48px}h1{font-size:28px;margin:0 0 8px}h2{font-size:18px;margin:0 0 6px}
11
+ p{margin:0;color:var(--muted)}ol{padding-left:24px;margin:24px 0;display:grid;gap:14px}
12
+ li{padding:16px 18px;background:var(--panel);border:1px solid var(--border);border-radius:8px}
13
+ li p{margin-top:4px}strong{color:var(--text)}code{color:#79c0ff;background:#21262d;padding:1px 5px;border-radius:4px;font-size:.92em}
14
+ .tip{border-left:3px solid var(--accent);padding:10px 14px;background:var(--panel);color:var(--muted)}
15
+ pre{overflow:auto;padding:14px 16px;background:#090d12;border:1px solid var(--border);border-radius:8px;line-height:1.5}
16
+ pre code{padding:0;background:transparent;color:#c9d1d9;white-space:pre}
17
+ ul{padding-left:24px;color:var(--muted)}li+li{margin-top:4px}
18
+ #data-driven-legends ul{display:block;margin:8px 0 14px}
19
+ #data-driven-legends li{padding:0;background:none;border:0;border-radius:0;list-style:disc}
20
+ #data-driven-legends p{margin:8px 0}
21
+ #data-driven-legends pre{margin:12px 0 16px}
22
+ a{color:var(--accent)}
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <main>
27
+ <h1>Make your first plot</h1>
28
+ <p>Follow these steps in order: query data, check its columns, configure the chart, then render it.</p>
29
+
30
+ <ol>
31
+ <li>
32
+ <h2>Choose a table and query</h2>
33
+ <p>In <strong>Schema</strong>, expand a table with its arrow. A new tab starts with a basic <code>SELECT *</code> query when a table is available. Edit the SQL in <strong>Data source</strong> to select the rows and columns you need.</p>
34
+ </li>
35
+ <li>
36
+ <h2>Run the query</h2>
37
+ <p>Click <strong>Run &amp; show</strong>. This checks the query and displays a data preview. The query’s column names then become available in the mapping controls.</p>
38
+ </li>
39
+ <li>
40
+ <h2>Configure the chart</h2>
41
+ <p>Choose a plot type, then select an <strong>X column</strong> and one or more <strong>Y columns</strong> in <strong>Data mappings</strong>. Some plot types show extra required fields; for example, a heatmap asks for a Z column.</p>
42
+ </li>
43
+ <li>
44
+ <h2>Render the plot</h2>
45
+ <p>Click <strong>Run</strong> in the top bar. If the result is not what you expect, adjust the SQL or mappings and run it again.</p>
46
+ </li>
47
+ <li>
48
+ <h2>Save your workspace</h2>
49
+ <p>Click <strong>Export workspace</strong> and save the JSON file (plots.json) to the directory from where your ran the <strong>visualize command</strong>. This way, next time you start the webUI, you will resume from where you left off.</p>
50
+ </li>
51
+ </ol>
52
+
53
+ <div class="tip">To save the result, choose PNG or SVG in the top bar and click <strong>Save image</strong>.</div>
54
+
55
+ <section id="data-transform" style="margin-top:36px">
56
+ <h1>Transform query data with Python</h1>
57
+ <p>Open <strong>Data transform (pandas)</strong> to change the SQL result before plotting. The result is provided as the pandas DataFrame <code>df</code>. Edit it in place or assign a new DataFrame to <code>df</code>; the final value of <code>df</code> is used for plotting.</p>
58
+ <pre><code>df['new_column'] = df['existing_column'].astype(str).str.lower()
59
+ df = df[df['ncpus'] > 1]</code></pre>
60
+ <p>Only the columns selected by SQL (including aliases) and columns created by the transform are plotted. Source tables are also available through <code>data</code> for lookups. The <code>pd</code> pandas module, <code>agg</code> helpers, and <code>log(msg)</code> are available in the script.</p>
61
+ <p>Use <strong>Script paths</strong> in the top bar to add directories containing your own Python modules. They are saved in your user config directory and are available to every Python script field; import them normally, for example <code>from my_helpers import clean_flags</code>. Workspace JSON exports include the configured paths and add any missing entries to the global path config.</p>
62
+ </section>
63
+
64
+ <section id="data-driven-legends" style="margin-top:36px">
65
+ <h1>Customize legends from data</h1>
66
+ <p>The <strong>Layout script</strong> runs after the plot data and traces are prepared. It can inspect the query result and change each trace's legend name, order, group, or visibility. The same context is available with either renderer. Only columns selected by SQL or created by the transform are available in <code>df</code>.</p>
67
+
68
+ <h2 style="margin-top:22px">Script context</h2>
69
+ <ul>
70
+ <li><code>df</code>: the result as a pandas DataFrame after SQL and the pandas transform.</li>
71
+ <li><code>df_data</code>: the same data in <code>{'columns': [...], 'rows': [...]}</code> form.</li>
72
+ <li><code>traces</code>: the current list of Plotly-style trace dictionaries.</li>
73
+ <li><code>layout</code>, <code>config</code>, <code>pd</code>, and <code>log(msg)</code> are also available.</li>
74
+ </ul>
75
+
76
+ <h2 style="margin-top:22px">Plotly: data-driven labels and order</h2>
77
+ <p>This example assumes one Y column, the trace name is the compiler value, and the query includes <code>compiler</code> and <code>rate</code>. Higher mean rates appear first and the mean is added to each label.</p>
78
+ <pre><code>mean_rate = df.groupby('compiler')['rate'].mean().to_dict()
79
+ order = sorted(mean_rate, key=mean_rate.get, reverse=True)
80
+ rank = {name: i for i, name in enumerate(order)}
81
+
82
+ for trace in traces:
83
+ name = trace.get('name', '')
84
+ if name in mean_rate:
85
+ trace['name'] = f"{name} — {mean_rate[name]:.1f}"
86
+ trace['legendrank'] = rank[name]
87
+ trace['legendgroup'] = name
88
+
89
+ layout.setdefault('legend', {}).update(
90
+ title={'text': 'Compiler · mean rate'},
91
+ orientation='h',
92
+ traceorder='normal',
93
+ )</code></pre>
94
+ <p>Other useful trace fields are <code>showlegend</code> to hide selected entries and <code>legendgroup</code> to link entries. Set the same <code>legendgroup</code> for traces that should toggle together. Plotly uses the lower <code>legendrank</code> first.</p>
95
+ <p>Trace names are generated from the selected group columns and Y columns. If you use multiple Y columns, names often include both the group and Y column; inspect <code>traces</code> or log the names first, then adapt the lookup in the example.</p>
96
+
97
+ <h2 style="margin-top:22px">Matplotlib: custom per-panel legend</h2>
98
+ <p>Define <code>customize_matplotlib</code> in the same script. It runs after the default legend is created; calling <code>ax.legend</code> replaces that axes' legend. In a grid, this hook runs once per panel, and its global <code>df</code>/<code>traces</code> belong to that panel. For independent panel legends, turn off <strong>share legend</strong>.</p>
99
+ <pre><code>mean_rate = df.groupby('compiler')['rate'].mean().to_dict()
100
+
101
+ def customize_matplotlib(fig, ax, config):
102
+ handles, labels = ax.get_legend_handles_labels()
103
+ pairs = [(mean_rate[label], handle, label)
104
+ for handle, label in zip(handles, labels)
105
+ if label in mean_rate]
106
+ pairs.sort(reverse=True, key=lambda item: item[0])
107
+ ax.legend(
108
+ [handle for _, handle, _ in pairs],
109
+ [f'{label} — {score:.1f}' for score, _, label in pairs],
110
+ title='Compiler · mean rate', ncol=2, frameon=False,
111
+ )</code></pre>
112
+
113
+ <h2 style="margin-top:22px">Matplotlib: custom shared legend for a grid</h2>
114
+ <p>Put this hook in Panel 1's Layout script. It runs once after all panels and the default shared legend are drawn. <code>axes</code> is a 2D NumPy array; <code>panel_data</code> contains one <code>{'df_data': ..., 'traces': ...}</code> object per rendered panel.</p>
115
+ <pre><code>def customize_matplotlib_legend(fig, axes, config, panel_data):
116
+ handles_by_name = {}
117
+ for ax in axes.flat:
118
+ handles, labels = ax.get_legend_handles_labels()
119
+ for handle, label in zip(handles, labels):
120
+ if label and not label.startswith('_'):
121
+ handles_by_name.setdefault(label, handle)
122
+
123
+ # Remove the default shared legend before creating the custom one.
124
+ fig.legends.clear()
125
+ labels = sorted(handles_by_name)
126
+ if not labels:
127
+ return
128
+ fig.legend(
129
+ [handles_by_name[label] for label in labels], labels,
130
+ title='All panels', loc='upper center', ncol=min(4, len(labels)),
131
+ frameon=False,
132
+ )</code></pre>
133
+
134
+ <p>Legend options in the Appearance section still set defaults. A layout script runs after those options, so its edits take precedence. For a grid with a shared legend, use the Panel 1 hook when you need to replace the single figure-level legend.</p>
135
+ </section>
136
+ </main>
137
+ </body>
138
+ </html>
@@ -68,7 +68,10 @@ def load_remote_systems():
68
68
 
69
69
 
70
70
  def db_status() -> dict:
71
- return {"databases": db.all_schemas(), "single_db": db.is_single_db(), "default_database": db.only_db_name()}
71
+ schemas = db.all_schemas()
72
+ return {"databases": schemas,
73
+ "table_counts": {name: db.table_counts(name) for name in db.DB_REGISTRY},
74
+ "single_db": db.is_single_db(), "default_database": db.only_db_name()}
72
75
 
73
76
 
74
77
  class Handler(BaseHTTPRequestHandler):
@@ -142,6 +145,12 @@ class Handler(BaseHTTPRequestHandler):
142
145
  self.send_json({"plot_types": plots.plot_type_catalog()})
143
146
  elif path == "/api/remote_systems":
144
147
  self.send_json({"systems": REMOTE_SYSTEMS})
148
+ elif path == "/api/script_paths":
149
+ from sbatchman.visualize.utils.script_paths import SCRIPT_PATHS_FILE, get_script_paths
150
+ try:
151
+ self.send_json({"paths": get_script_paths(), "config_file": str(SCRIPT_PATHS_FILE)})
152
+ except Exception as e:
153
+ self.send_json({"error": str(e), "config_file": str(SCRIPT_PATHS_FILE)}, 500)
145
154
  elif path == "/api/logs":
146
155
  self.send_json({"logs": SERVER_LOG[-200:]})
147
156
  elif path == "/api/initial_workspace":
@@ -174,6 +183,10 @@ class Handler(BaseHTTPRequestHandler):
174
183
  self.guarded_json(self._fetch_remote, payload)
175
184
  elif path == "/api/reload_plugins":
176
185
  self.guarded_json(self._reload_plugins, payload)
186
+ elif path == "/api/script_paths":
187
+ self.guarded_json(self._save_script_paths, payload)
188
+ elif path == "/api/script_paths/merge":
189
+ self.guarded_json(self._merge_script_paths, payload)
177
190
  elif path == "/api/log":
178
191
  entry = log(payload.get("message", ""), payload.get("level", "info"))
179
192
  self.send_json({"log_entry": entry})
@@ -211,6 +224,16 @@ class Handler(BaseHTTPRequestHandler):
211
224
  return {"reloaded": list(plots.PLUGIN_PLOTS.keys()), "plot_types": plots.plot_type_catalog(),
212
225
  "log_entry": entry}
213
226
 
227
+ def _save_script_paths(self, payload):
228
+ from sbatchman.visualize.utils.script_paths import SCRIPT_PATHS_FILE, set_script_paths
229
+ paths = set_script_paths(payload.get("paths", []))
230
+ return {"ok": True, "paths": paths, "config_file": str(SCRIPT_PATHS_FILE)}
231
+
232
+ def _merge_script_paths(self, payload):
233
+ from sbatchman.visualize.utils.script_paths import SCRIPT_PATHS_FILE, merge_script_paths
234
+ paths = merge_script_paths(payload.get("paths", []))
235
+ return {"ok": True, "paths": paths, "config_file": str(SCRIPT_PATHS_FILE)}
236
+
214
237
 
215
238
  def launch_visualize_web_server(parser, presets, port=8765, plugins=None, csv_files=False,
216
239
  data_source=None):
@@ -39,6 +39,7 @@ async function onReparseClick() {
39
39
  document.getElementById('reparse-result').textContent = res.message || res.error;
40
40
  if (res.databases) {
41
41
  G.databases = res.databases;
42
+ G.tableCounts = res.table_counts || {};
42
43
  G.singleDb = !!res.single_db;
43
44
  G.defaultDb = res.default_database || '';
44
45
  window.renderSchemaTree(); populateReparseSel(); populateAllDbSelects();
@@ -89,6 +90,7 @@ export async function runFetch() {
89
90
  document.getElementById('fetch-result').textContent = res.message || res.error;
90
91
  if (res.databases) {
91
92
  G.databases = res.databases;
93
+ G.tableCounts = res.table_counts || {};
92
94
  G.singleDb = !!res.single_db;
93
95
  G.defaultDb = res.default_database || '';
94
96
  window.renderSchemaTree(); populateReparseSel(); populateAllDbSelects();
@@ -51,6 +51,7 @@
51
51
 
52
52
  <div class="b-col-title" style="margin-top:10px" data-tip="Plot some Y columns against a second, independently-scaled y axis on the right">Secondary y-axis</div>
53
53
  <label>Columns on y2 (multi-select)</label>
54
+ <div style="font-size:9px;color:var(--text3);margin:2px 0">Choose columns already selected under Y column(s); they move to the right axis.</div>
54
55
  <div class="multi-col-list" id="y2-pills-${id}"></div>
55
56
  <input type="text" id="y2-label-${id}" placeholder="y2 axis label" style="margin-top:4px">
56
57
  <div style="display:flex;gap:4px;margin-top:4px">
@@ -53,6 +53,7 @@ export function renderPlotChips(id) {
53
53
  st.plotType = key;
54
54
  wrap.querySelectorAll('.plot-chip').forEach(c => c.classList.toggle('active', c.dataset.type === key));
55
55
  renderExtraOpts(id);
56
+ renderPlotSpecificFields(id);
56
57
  });
57
58
  wrap.appendChild(ch);
58
59
  }
@@ -68,15 +69,86 @@ export function renderExtraOpts(id) {
68
69
  for (const [key, val] of Object.entries(pt.defaults || {})) {
69
70
  const wrap = document.createElement('div');
70
71
  const lbl = document.createElement('label'); lbl.textContent = key;
72
+ if (key === 'mode') {
73
+ lbl.title = 'For line charts: choose lines, markers, or both. Supported by Plotly and Matplotlib.';
74
+ }
71
75
  const inp = document.createElement('input');
72
76
  inp.id = `extra-${id}-${key}`;
73
77
  inp.value = st.extra?.[key] ?? val;
74
78
  inp.type = typeof val === 'number' ? 'number' : 'text';
75
79
  wrap.appendChild(lbl); wrap.appendChild(inp);
80
+ if (key === 'mode') {
81
+ const hint = document.createElement('small');
82
+ hint.textContent = 'Line, markers, or both (Plotly and Matplotlib)';
83
+ hint.style.cssText = 'display:block;color:var(--text3);font-size:9px';
84
+ wrap.appendChild(hint);
85
+ }
76
86
  area.appendChild(wrap);
77
87
  }
78
88
  }
79
89
 
90
+ export function renderPlotSpecificFields(id) {
91
+ const panel = document.getElementById(`plot-specific-fields-${id}`);
92
+ const controls = document.getElementById(`plot-field-controls-${id}`);
93
+ const st = getState(id);
94
+ if (!panel || !controls || !st) return;
95
+ const fields = G.plotFields[st.plotType] || [];
96
+ panel.hidden = fields.length === 0;
97
+ controls.replaceChildren();
98
+
99
+ for (const field of fields) {
100
+ const wrap = document.createElement('div');
101
+ wrap.className = 'plot-specific-field';
102
+ const label = document.createElement('label');
103
+ label.htmlFor = `plot-field-${id}-${field.name}`;
104
+ label.textContent = `${field.label}${field.required ? ' *' : ''}`;
105
+ if (field.help) label.title = field.help;
106
+ wrap.appendChild(label);
107
+
108
+ let input;
109
+ if (field.type === 'column' || field.type === 'select') {
110
+ input = document.createElement('select');
111
+ const empty = document.createElement('option');
112
+ empty.value = '';
113
+ empty.textContent = field.required ? '— select a column —' : '— none —';
114
+ input.appendChild(empty);
115
+ const options = field.type === 'column'
116
+ ? (st.columns || []).map(value => ({value, label: value}))
117
+ : field.options;
118
+ options.forEach(option => {
119
+ const el = document.createElement('option');
120
+ el.value = option.value;
121
+ el.textContent = option.label;
122
+ input.appendChild(el);
123
+ });
124
+ } else if (field.type === 'textarea') {
125
+ input = document.createElement('textarea');
126
+ } else {
127
+ input = document.createElement('input');
128
+ input.type = field.type === 'number' ? 'number' : field.type === 'checkbox' ? 'checkbox' : 'text';
129
+ if (field.min !== null) input.min = field.min;
130
+ if (field.max !== null) input.max = field.max;
131
+ if (field.step !== null) input.step = field.step;
132
+ }
133
+ input.id = `plot-field-${id}-${field.name}`;
134
+ input.required = !!field.required;
135
+ if (field.placeholder && input.type !== 'checkbox') input.placeholder = field.placeholder;
136
+ const legacyValue = field.name === 'z' ? st.z : undefined;
137
+ const value = st.extra?.[field.name] ?? legacyValue ?? field.default ?? '';
138
+ if (input.type === 'checkbox') input.checked = value === true || value === 'true';
139
+ else input.value = value;
140
+ input.addEventListener('change', () => {
141
+ const current = getState(id);
142
+ if (!current.extra) current.extra = {};
143
+ current.extra[field.name] = input.type === 'checkbox' ? input.checked
144
+ : input.type === 'number' ? (input.value === '' ? '' : Number(input.value)) : input.value;
145
+ if (field.name === 'z') current.z = input.value;
146
+ });
147
+ wrap.appendChild(input);
148
+ controls.appendChild(wrap);
149
+ }
150
+ }
151
+
80
152
  // --- Axis / column controls --------------------------------------------
81
153
  export function updateAxisControls(id, columns) {
82
154
  const st = getState(id);
@@ -84,20 +156,19 @@ export function updateAxisControls(id, columns) {
84
156
  st.columns = columns;
85
157
  st.yCols = toArray(st.yCols).filter(c => columns.includes(c));
86
158
  st.groupCols = toArray(st.groupCols).filter(c => columns.includes(c));
159
+ renderPlotSpecificFields(id);
87
160
 
88
161
  const xSel = tabEl(id, 'x-col');
89
- const zSel = tabEl(id, 'z-col');
90
162
  const markerSel = tabEl(id, 'marker-by');
91
163
  const dashSel = tabEl(id, 'dash-by');
92
164
  const yPills = document.getElementById(`y-pills-${id}`);
93
165
  const grpPills = document.getElementById(`group-pills-${id}`);
94
166
  if (!xSel || !yPills) return;
95
167
 
96
- const prevX = xSel.value, prevZ = zSel?.value, prevMarker = markerSel?.value, prevDash = dashSel?.value;
168
+ const prevX = xSel.value, prevMarker = markerSel?.value, prevDash = dashSel?.value;
97
169
  const mkOpt = (v, sel) => { const o = document.createElement('option'); o.value = v; o.textContent = v; if (v === sel) o.selected = true; return o; };
98
170
 
99
171
  xSel.innerHTML = '<option value="">— x column —</option>';
100
- if (zSel) zSel.innerHTML = '<option value="">— none —</option>';
101
172
  if (markerSel) markerSel.innerHTML = '<option value="">— none —</option>';
102
173
  if (dashSel) dashSel.innerHTML = '<option value="">— none —</option>';
103
174
  yPills.innerHTML = '';
@@ -105,7 +176,6 @@ export function updateAxisControls(id, columns) {
105
176
 
106
177
  for (const col of columns) {
107
178
  xSel.appendChild(mkOpt(col, prevX || st.x));
108
- if (zSel) zSel.appendChild(mkOpt(col, prevZ || st.z));
109
179
  if (markerSel) markerSel.appendChild(mkOpt(col, prevMarker || st.markerBy));
110
180
  if (dashSel) dashSel.appendChild(mkOpt(col, prevDash || st.dashBy));
111
181
 
@@ -149,8 +219,8 @@ export function builderColumnsHTML(id) {
149
219
  <svg id="transform-arrow-${id}" width="9" height="9" viewBox="0 0 16 16" fill="currentColor" style="margin-left:auto;transition:transform .2s"><path d="M4 6l4 4 4-4"/></svg>
150
220
  </div>
151
221
  <div class="script-box" id="transform-box-${id}">
152
- <div class="script-hint">Runs after the SQL query. <b>data['result']</b> starts with exactly the query's selected columns, including <code>AS</code> aliases. Source tables are available for lookups, but source columns omitted from SQL are removed before plotting; select any original fields you want to plot. New transform columns that are not existing source-table fields are kept. <b>agg</b> exposes summarize()/speedup()/efficiency() for collapsing repeated runs into mean+error rows. Call <b>log(msg)</b> to print to the Logs panel while you iterate.</div>
153
- <textarea id="transform-${id}" class="code-editor python-editor" spellcheck="false" aria-label="Python pandas transform" placeholder="df = data['result']&#10;df['flags'] = df['flags'].map(map_compiler_flags_names)&#10;data['result'] = df"></textarea>
222
+ <div class="script-hint">Runs after the SQL query. <b>df</b> is a pandas DataFrame containing exactly the query's selected columns, including <code>AS</code> aliases. Modify <code>df</code> in place or reassign it (for example, <code>df = df.assign(...)</code>); the final DataFrame is plotted. Source tables remain available in <code>data</code> for lookups, but source columns omitted from SQL are removed before plotting; select any original fields you want to plot. New transform columns that are not existing source-table fields are kept. <b>agg</b> exposes summarize()/speedup()/efficiency() for collapsing repeated runs into mean+error rows. Call <b>log(msg)</b> to print to the Logs panel while you iterate.</div>
223
+ <textarea id="transform-${id}" class="code-editor python-editor" spellcheck="false" aria-label="Python pandas transform" placeholder="df['new_column'] = df['existing_column'].astype(str).str.lower()&#10;df = df[df['ncpus'] > 1]"></textarea>
154
224
  </div>
155
225
  </div>
156
226
 
@@ -171,11 +241,15 @@ export function builderColumnsHTML(id) {
171
241
  <svg id="script-arrow-${id}" width="9" height="9" viewBox="0 0 16 16" fill="currentColor" style="margin-left:auto;transition:transform .2s"><path d="M4 6l4 4 4-4"/></svg>
172
242
  </div>
173
243
  <div class="script-box" id="script-box-${id}">
174
- <div class="script-hint">def <b>plot</b>(df_data, config) → list of Plotly trace dicts. Overrides the plot-type chips above.</div>
175
- <textarea id="script-${id}" class="code-editor python-editor" spellcheck="false" aria-label="Custom Python plot script" placeholder="def plot(df_data, config):&#10; # return list of Plotly trace dicts&#10; ..."></textarea>
244
+ <div class="script-hint">Define <code>plot(df_data, config)</code> to return Plotly traces, or <code>plot_matplotlib(ax, df_data, config)</code> to draw directly with Matplotlib. You can define both; each backend uses its matching function. <code>df_data</code> has <code>columns</code> and <code>rows</code>.</div>
245
+ <textarea id="script-${id}" class="code-editor python-editor" spellcheck="false" aria-label="Custom Python plot script" placeholder="def plot_matplotlib(ax, df_data, config):&#10; x = df_data['columns'].index(config['x'])&#10; y = df_data['columns'].index(config['y'][0])&#10; ax.scatter([r[x] for r in df_data['rows']],&#10; [r[y] for r in df_data['rows']])"></textarea>
176
246
  </div>
177
247
  </div>
178
248
  <div class="extra-opts" id="extra-opts-${id}"></div>
249
+ <div class="plot-specific-fields" id="plot-specific-fields-${id}" hidden>
250
+ <div class="b-col-title">Plot-specific fields</div>
251
+ <div id="plot-field-controls-${id}"></div>
252
+ </div>
179
253
  </div>
180
254
 
181
255
  <div class="b-col" style="min-width:180px">
@@ -190,8 +264,6 @@ export function builderColumnsHTML(id) {
190
264
  <select id="marker-by-${id}"><option value="">— none —</option></select>
191
265
  <label style="margin-top:5px" data-tip="Assign a distinct linestyle (solid/dash/dot/…) to each value of this column (line charts)">Linestyle by</label>
192
266
  <select id="dash-by-${id}"><option value="">— none —</option></select>
193
- <label style="margin-top:5px" data-tip="Z column for heatmaps">Z column (heatmap)</label>
194
- <select id="z-col-${id}"><option value="">— none —</option></select>
195
267
  </div>
196
268
 
197
269
  <div class="b-col" style="min-width:180px">
@@ -216,6 +288,9 @@ export function builderColumnsHTML(id) {
216
288
  <input type="text" id="x-tickfmt-${id}" placeholder=".2f">
217
289
  <label style="margin-top:5px">Y tick format</label>
218
290
  <input type="text" id="y-tickfmt-${id}" placeholder=".2f">
291
+ <label style="margin-top:7px" data-tip="Optional Python formatter. When set, it takes precedence over the X/Y tick format strings above.">Custom tick formatter (Python)</label>
292
+ <textarea id="tick-formatter-${id}" class="tick-formatter-editor code-editor python-editor" spellcheck="false" aria-label="Python tick formatter" placeholder="def format_tick(value, axis):&#10; return f'{value:g}'"></textarea>
293
+ <div class="script-hint">Define <code>format_tick(value, axis)</code>; <code>axis</code> is <code>'x'</code> or <code>'y'</code>. Return the label as a string. It is used for both plotting backends.</div>
219
294
  <label style="margin-top:5px" data-tip="Where the legend sits. The 'inside' options give the compact, journal-figure look when there's an empty corner to put it in.">Legend position</label>
220
295
  <select id="legend-position-${id}">
221
296
  <option value="right">outside right</option>
@@ -244,22 +319,91 @@ export function builderColumnsHTML(id) {
244
319
  </label>
245
320
 
246
321
  <div style="margin-top:8px">
247
- <div class="script-toggle" id="layout-toggle-${id}" data-tip="A short Python snippet to fine-tune this plot's appearance beyond the fields above">
322
+ <div class="script-toggle" id="layout-toggle-${id}" data-tip="Customize layout and build data-driven legends with Python">
248
323
  <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1l1.8 3.6L14 5.2l-3 2.9.7 4.1L8 10.3l-3.7 1.9.7-4.1-3-2.9 4.2-.6z"/></svg>
249
324
  Layout script
250
325
  <svg id="layout-arrow-${id}" width="9" height="9" viewBox="0 0 16 16" fill="currentColor" style="margin-left:auto;transition:transform .2s"><path d="M4 6l4 4 4-4"/></svg>
251
326
  </div>
252
327
  <div class="script-box" id="layout-box-${id}">
253
328
  <div class="script-hint">
254
- <b>What this is:</b> everything above (title, labels, scales, tick format) covers the common cases. This box is for anything else Plotly supports — it's a few lines of Python that edit the chart's <code>layout</code> dictionary directly before the chart is drawn.<br><br>
255
- <b>How to use it:</b> the variable <code>layout</code> is already built from your settings above; just change the part you need, e.g. <code>layout['xaxis']['tickangle'] = 45</code>. <code>config</code> holds your current axis/label settings (read-only), and <code>log(msg)</code> prints to the Logs panel.
329
+ <b>Data-aware legend controls:</b> the script receives <code>df_data</code> (columns/rows), <code>df</code> (a pandas DataFrame), and <code>traces</code> (Plotly-style trace dictionaries). Use these to rename, rank, group, or hide legend entries.<br>
330
+ <b>Plotly:</b> edit <code>layout['legend']</code> and trace fields such as <code>name</code>, <code>legendrank</code>, <code>legendgroup</code>, and <code>showlegend</code>.<br>
331
+ <b>Matplotlib:</b> define <code>customize_matplotlib(fig, ax, config)</code> to edit an axes legend. For a custom shared grid legend, define <code>customize_matplotlib_legend(fig, axes, config, panel_data)</code> in Panel 1's script. See <a href="/docs#data-driven-legends" target="_blank" rel="noopener">data-driven legend examples</a>. <code>log(msg)</code> writes to the Logs panel.
256
332
  </div>
257
- <textarea id="layout-script-${id}" class="code-editor python-editor" spellcheck="false" aria-label="Python layout script" placeholder="layout['xaxis']['tickangle'] = 45&#10;layout['yaxis']['range'] = [0, 100]"></textarea>
333
+ <textarea id="layout-script-${id}" class="code-editor python-editor" spellcheck="false" aria-label="Python layout script" placeholder="layout['xaxis']['tickangle'] = 45&#10;&#10;def customize_matplotlib(fig, ax, config):&#10; ax.grid(True, alpha=0.25)"></textarea>
258
334
  </div>
259
335
  </div>
260
336
  </div>`;
261
337
  }
262
338
 
339
+ // Render syntax colors in a separate, inert layer. The textarea remains the
340
+ // only editable/source value; this code never assigns to editor.value.
341
+ const PY_KEYWORDS = new Set(('and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield True False None').split(' '));
342
+ const PY_BUILTINS = new Set(('abs all any ascii bin bool bytearray bytes callable chr classmethod compile complex dict dir divmod enumerate eval filter float format frozenset getattr hasattr hash help hex id input int isinstance issubclass iter len list map max memoryview min next object oct open ord pow print property range repr reversed round set setattr slice sorted staticmethod str sum super tuple type vars zip').split(' '));
343
+ const SQL_KEYWORDS = new Set(('select from where group by order having limit offset as distinct all and or not null is like ilike in between exists case when then else end join inner left right full outer cross on using union intersect except insert into values update set delete create table drop alter add column index view with recursive asc desc nulls first last count sum avg min max cast coalesce').split(' '));
344
+ const PY_TOKEN = /#[^\n]*|(?:"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b|\b[A-Za-z_]\w*\b/g;
345
+ const SQL_TOKEN = /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|"(?:""|[^"])*"|\b\d+(?:\.\d+)?\b|\b[A-Za-z_]\w*\b/gi;
346
+ function escapeCodeHTML(text) {
347
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
348
+ }
349
+ function highlightCode(text, language) {
350
+ const regex = language === 'sql' ? SQL_TOKEN : PY_TOKEN;
351
+ const chunks = [];
352
+ let cursor = 0;
353
+ for (const match of text.matchAll(regex)) {
354
+ const token = match[0];
355
+ const start = match.index;
356
+ chunks.push(escapeCodeHTML(text.slice(cursor, start)));
357
+ let kind = '';
358
+ if (language === 'sql') {
359
+ if (token.startsWith('--') || token.startsWith('/*')) kind = 'comment';
360
+ else if (token.startsWith("'") || token.startsWith('"')) kind = 'string';
361
+ else if (/^\d/.test(token)) kind = 'number';
362
+ else if (SQL_KEYWORDS.has(token.toLowerCase())) kind = 'keyword';
363
+ else kind = 'identifier';
364
+ } else {
365
+ if (token.startsWith('#')) kind = 'comment';
366
+ else if (token.startsWith("'") || token.startsWith('"')) kind = 'string';
367
+ else if (/^\d/.test(token)) kind = 'number';
368
+ else if (PY_KEYWORDS.has(token)) kind = 'keyword';
369
+ else if (PY_BUILTINS.has(token)) kind = 'builtin';
370
+ else if (/\b[A-Za-z_]\w*\b/.test(token) && /^\s*\(/.test(text.slice(start + token.length))) kind = 'function';
371
+ else kind = 'identifier';
372
+ }
373
+ chunks.push(`<span class="tok-${kind}">${escapeCodeHTML(token)}</span>`);
374
+ cursor = start + token.length;
375
+ }
376
+ chunks.push(escapeCodeHTML(text.slice(cursor)));
377
+ // Preserve the final line box when the editor ends with a newline.
378
+ return chunks.join('') + (text.endsWith('\n') ? ' ' : '');
379
+ }
380
+ export function enhanceCodeEditors(root = document) {
381
+ const editors = root.matches?.('textarea.code-editor') ? [root] : root.querySelectorAll?.('textarea.code-editor') || [];
382
+ for (const editor of editors) {
383
+ let layer = editor._codeHighlightLayer;
384
+ if (!layer) {
385
+ const wrapper = document.createElement('div');
386
+ wrapper.className = 'code-editor-wrap';
387
+ editor.parentNode.insertBefore(wrapper, editor);
388
+ wrapper.appendChild(editor);
389
+ layer = document.createElement('pre');
390
+ layer.className = 'code-highlight';
391
+ layer.setAttribute('aria-hidden', 'true');
392
+ wrapper.insertBefore(layer, editor);
393
+ editor._codeHighlightLayer = layer;
394
+ editor.classList.add('highlight-ready');
395
+ editor.addEventListener('input', () => enhanceCodeEditors(editor));
396
+ editor.addEventListener('scroll', () => {
397
+ layer.scrollTop = editor.scrollTop;
398
+ layer.scrollLeft = editor.scrollLeft;
399
+ }, { passive: true });
400
+ }
401
+ layer.innerHTML = highlightCode(editor.value, editor.classList.contains('sql-editor') ? 'sql' : 'python');
402
+ layer.scrollTop = editor.scrollTop;
403
+ layer.scrollLeft = editor.scrollLeft;
404
+ }
405
+ }
406
+
263
407
  // --- Wire up a single builder's controls (shared by tabs and panels) -----
264
408
  export function wireBuilderColumns(id) {
265
409
  const st = getState(id);
@@ -267,6 +411,7 @@ export function wireBuilderColumns(id) {
267
411
 
268
412
  renderPlotChips(id);
269
413
  renderExtraOpts(id);
414
+ renderPlotSpecificFields(id);
270
415
 
271
416
  const f = (sid, val) => { const el = document.getElementById(sid); if (el && val !== undefined) el.value = val; };
272
417
  f(`db-select-${id}`, st.database);
@@ -278,6 +423,7 @@ export function wireBuilderColumns(id) {
278
423
  f(`y-scale-${id}`, st.yScale);
279
424
  f(`x-tickfmt-${id}`, st.xTickFmt);
280
425
  f(`y-tickfmt-${id}`, st.yTickFmt);
426
+ f(`tick-formatter-${id}`, st.tickFormatter);
281
427
  f(`legend-position-${id}`, st.legendPosition || 'right');
282
428
  f(`legend-title-${id}`, st.legendTitle);
283
429
  f(`legend-orientation-${id}`, st.legendOrientation || '');
@@ -288,6 +434,7 @@ export function wireBuilderColumns(id) {
288
434
  f(`script-${id}`, st.customScript);
289
435
  f(`transform-${id}`, st.transformScript);
290
436
  f(`layout-script-${id}`, st.layoutScript);
437
+ enhanceCodeEditors(document.getElementById(`builder-inner-${id}`) || document);
291
438
 
292
439
  const dbWrap = document.getElementById(`db-select-wrap-${id}`);
293
440
  if (dbWrap) dbWrap.style.display = G.singleDb ? 'none' : 'block';
@@ -379,11 +526,13 @@ document.addEventListener('keydown', event => {
379
526
  const spaces = currentLine.match(/^ {1,4}/)?.[0].length || 0;
380
527
  if (spaces) editor.setRangeText('', lineStart, lineStart + spaces, 'end');
381
528
  if (spaces && start > lineStart + spaces) editor.setSelectionRange(start - spaces, start - spaces);
529
+ enhanceCodeEditors(editor);
382
530
  return;
383
531
  }
384
532
 
385
533
  if (!event.shiftKey && start === end) {
386
534
  editor.setRangeText(' ', start, end, 'end');
535
+ enhanceCodeEditors(editor);
387
536
  return;
388
537
  }
389
538
 
@@ -392,6 +541,7 @@ document.addEventListener('keydown', event => {
392
541
  const indented = selectedLines.map(line => ` ${line}`).join('\n');
393
542
  editor.setRangeText(indented, lineStart, lineEnd, 'select');
394
543
  editor.setSelectionRange(start + 4, end + selectedLines.length * 4);
544
+ enhanceCodeEditors(editor);
395
545
  return;
396
546
  }
397
547
 
@@ -407,9 +557,10 @@ document.addEventListener('keydown', event => {
407
557
  }).join('\n');
408
558
  editor.setRangeText(unindented, lineStart, lineEnd, 'select');
409
559
  editor.setSelectionRange(Math.max(lineStart, start - removedBeforeStart), Math.max(lineStart, end - removedBeforeEnd));
560
+ enhanceCodeEditors(editor);
410
561
  });
411
562
 
412
563
  Object.assign(window, {
413
- renderDataTable, renderMultiPreview, renderPlotChips, renderExtraOpts,
414
- updateAxisControls, builderColumnsHTML, wireBuilderColumns, runShow,
564
+ renderDataTable, renderMultiPreview, renderPlotChips, renderExtraOpts, renderPlotSpecificFields,
565
+ updateAxisControls, builderColumnsHTML, wireBuilderColumns, runShow, enhanceCodeEditors,
415
566
  });