sqlite-hub 0.6.0 → 0.8.7

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 (44) hide show
  1. package/README.md +107 -0
  2. package/bin/sqlite-hub.js +285 -4
  3. package/changelog.md +21 -0
  4. package/docs/DESIGN_GUIDELINES.md +36 -0
  5. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  6. package/frontend/index.html +80 -0
  7. package/frontend/js/api.js +34 -0
  8. package/frontend/js/app.js +188 -10
  9. package/frontend/js/components/connectionCard.js +3 -4
  10. package/frontend/js/components/emptyState.js +4 -4
  11. package/frontend/js/components/modal.js +341 -24
  12. package/frontend/js/components/queryChartRenderer.js +125 -0
  13. package/frontend/js/components/queryEditor.js +33 -6
  14. package/frontend/js/components/queryHistoryDetail.js +16 -7
  15. package/frontend/js/components/queryHistoryPanel.js +18 -7
  16. package/frontend/js/components/rowEditorPanel.js +59 -54
  17. package/frontend/js/components/sidebar.js +32 -32
  18. package/frontend/js/components/structureGraph.js +54 -122
  19. package/frontend/js/components/tableDesignerEditor.js +9 -8
  20. package/frontend/js/components/tableDesignerSidebar.js +52 -48
  21. package/frontend/js/components/tableDesignerSqlPreview.js +60 -28
  22. package/frontend/js/lib/queryChartOptions.js +283 -0
  23. package/frontend/js/lib/queryCharts.js +560 -0
  24. package/frontend/js/router.js +8 -0
  25. package/frontend/js/store.js +641 -0
  26. package/frontend/js/views/charts.js +499 -0
  27. package/frontend/js/views/connections.js +5 -17
  28. package/frontend/js/views/data.js +40 -27
  29. package/frontend/js/views/editor.js +19 -13
  30. package/frontend/js/views/overview.js +149 -10
  31. package/frontend/js/views/settings.js +2 -2
  32. package/frontend/js/views/structure.js +105 -59
  33. package/frontend/js/views/tableDesigner.js +7 -2
  34. package/frontend/styles/base.css +62 -0
  35. package/frontend/styles/components.css +68 -118
  36. package/frontend/styles/structure-graph.css +19 -82
  37. package/frontend/styles/tokens.css +2 -0
  38. package/frontend/styles/views.css +293 -66
  39. package/package.json +2 -1
  40. package/server/routes/charts.js +153 -0
  41. package/server/server.js +6 -0
  42. package/server/services/sqlite/overviewService.js +68 -0
  43. package/server/services/storage/appStateStore.js +400 -1
  44. package/server/services/storage/queryHistoryChartUtils.js +145 -0
@@ -1,5 +1,10 @@
1
1
  import { escapeHtml, truncateMiddle } from "../utils/format.js";
2
2
  import { renderConnectionLogo } from "./connectionLogo.js";
3
+ import {
4
+ analyzeQueryChartResult,
5
+ getQueryChartTypeLabel,
6
+ QUERY_CHART_TYPES,
7
+ } from "../lib/queryCharts.js";
3
8
 
4
9
  function renderField({ label, name, type = "text", placeholder = "", value = "" }) {
5
10
  return `
@@ -8,7 +13,7 @@ function renderField({ label, name, type = "text", placeholder = "", value = ""
8
13
  ${escapeHtml(label)}
9
14
  </span>
10
15
  <input
11
- class="w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
16
+ class="control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
12
17
  name="${escapeHtml(name)}"
13
18
  placeholder="${escapeHtml(placeholder)}"
14
19
  type="${escapeHtml(type)}"
@@ -20,14 +25,18 @@ function renderField({ label, name, type = "text", placeholder = "", value = ""
20
25
 
21
26
  function renderCheckboxField({ label, name, checked = false, text }) {
22
27
  return `
23
- <label class="flex items-center gap-3 border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface">
24
- <input
25
- class="rounded-none border-outline bg-surface-container-lowest text-primary-container focus:ring-primary-container"
26
- ${checked ? "checked" : ""}
27
- name="${escapeHtml(name)}"
28
- type="checkbox"
29
- />
30
- ${escapeHtml(text || label)}
28
+ <label class="block space-y-2">
29
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
30
+ ${escapeHtml(label)}
31
+ </span>
32
+ <span class="standard-checkbox">
33
+ <input
34
+ ${checked ? "checked" : ""}
35
+ name="${escapeHtml(name)}"
36
+ type="checkbox"
37
+ />
38
+ <span>${escapeHtml(text || label)}</span>
39
+ </span>
31
40
  </label>
32
41
  `;
33
42
  }
@@ -45,7 +54,7 @@ function renderFileField({
45
54
  </span>
46
55
  <input
47
56
  accept="${escapeHtml(accept)}"
48
- class="block w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface file:mr-4 file:border-0 file:bg-primary-container file:px-3 file:py-2 file:text-xs file:font-bold file:text-on-primary"
57
+ class="control-input block w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface file:mr-4 file:border-0 file:bg-primary-container file:px-3 file:py-2 file:text-xs file:font-bold file:text-on-primary"
49
58
  name="${escapeHtml(name)}"
50
59
  type="file"
51
60
  />
@@ -58,6 +67,31 @@ function renderFileField({
58
67
  `;
59
68
  }
60
69
 
70
+ function renderSelectField({ label, name, value = "", options = [], bind = "" }) {
71
+ return `
72
+ <label class="block space-y-2">
73
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
74
+ ${escapeHtml(label)}
75
+ </span>
76
+ <select
77
+ class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
78
+ ${bind ? `data-bind="${escapeHtml(bind)}"` : ""}
79
+ ${name ? `name="${escapeHtml(name)}"` : ""}
80
+ >
81
+ ${options
82
+ .map(
83
+ (option) => `
84
+ <option value="${escapeHtml(option.value)}" ${String(option.value) === String(value) ? "selected" : ""}>
85
+ ${escapeHtml(option.label)}
86
+ </option>
87
+ `
88
+ )
89
+ .join("")}
90
+ </select>
91
+ </label>
92
+ `;
93
+ }
94
+
61
95
  function renderError(error) {
62
96
  if (!error) {
63
97
  return "";
@@ -94,14 +128,14 @@ function renderOpenConnectionForm(modal) {
94
128
  ${renderError(modal.error)}
95
129
  <div class="flex items-center justify-end gap-3 pt-2">
96
130
  <button
97
- class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
131
+ class="standard-button"
98
132
  data-action="close-modal"
99
133
  type="button"
100
134
  >
101
135
  Cancel
102
136
  </button>
103
137
  <button
104
- class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
138
+ class="standard-button"
105
139
  type="submit"
106
140
  >
107
141
  ${modal.submitting ? "Opening..." : "Open Database"}
@@ -168,14 +202,14 @@ function renderEditConnectionForm(modal) {
168
202
  ${renderError(modal.error)}
169
203
  <div class="flex items-center justify-end gap-3 pt-2">
170
204
  <button
171
- class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
205
+ class="standard-button"
172
206
  data-action="close-modal"
173
207
  type="button"
174
208
  >
175
209
  Cancel
176
210
  </button>
177
211
  <button
178
- class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
212
+ class="standard-button"
179
213
  type="submit"
180
214
  >
181
215
  ${modal.submitting ? "Saving..." : "Save Changes"}
@@ -201,14 +235,14 @@ function renderCreateDatabaseForm(modal) {
201
235
  ${renderError(modal.error)}
202
236
  <div class="flex items-center justify-end gap-3 pt-2">
203
237
  <button
204
- class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
238
+ class="standard-button"
205
239
  data-action="close-modal"
206
240
  type="button"
207
241
  >
208
242
  Cancel
209
243
  </button>
210
244
  <button
211
- class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
245
+ class="standard-button"
212
246
  type="submit"
213
247
  >
214
248
  ${modal.submitting ? "Creating..." : "Create Database"}
@@ -235,7 +269,7 @@ function renderImportTargetOptions(state) {
235
269
  Import Target
236
270
  </span>
237
271
  <select
238
- class="w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
272
+ class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
239
273
  name="targetMode"
240
274
  >
241
275
  ${
@@ -256,7 +290,7 @@ function renderImportTargetOptions(state) {
256
290
  Recent Connection
257
291
  </span>
258
292
  <select
259
- class="w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
293
+ class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
260
294
  name="targetConnectionId"
261
295
  >
262
296
  ${recentOptions}
@@ -294,14 +328,14 @@ function renderImportSqlForm(modal, state) {
294
328
  ${renderError(modal.error)}
295
329
  <div class="flex items-center justify-end gap-3 pt-2">
296
330
  <button
297
- class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
331
+ class="standard-button"
298
332
  data-action="close-modal"
299
333
  type="button"
300
334
  >
301
335
  Cancel
302
336
  </button>
303
337
  <button
304
- class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
338
+ class="standard-button"
305
339
  type="submit"
306
340
  >
307
341
  ${modal.submitting ? "Importing..." : "Import SQL Dump"}
@@ -363,14 +397,14 @@ function renderDeleteRowConfirmForm(modal) {
363
397
  ${renderError(modal.error)}
364
398
  <div class="flex items-center justify-end gap-3 pt-2">
365
399
  <button
366
- class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
400
+ class="standard-button"
367
401
  data-action="close-modal"
368
402
  type="button"
369
403
  >
370
404
  Cancel
371
405
  </button>
372
406
  <button
373
- class="border border-error/25 bg-error-container/10 px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-error"
407
+ class="delete-button"
374
408
  type="submit"
375
409
  >
376
410
  ${modal.submitting ? "Deleting..." : "Delete Row"}
@@ -380,6 +414,279 @@ function renderDeleteRowConfirmForm(modal) {
380
414
  `;
381
415
  }
382
416
 
417
+ function renderChartColumnOptions(analysis, { allowEmpty = false, includeNumericHint = false } = {}) {
418
+ const options = allowEmpty ? [{ value: "", label: "None" }] : [];
419
+
420
+ return options.concat(
421
+ (analysis?.columns ?? []).map((column) => ({
422
+ value: column.name,
423
+ label: `${column.name} (${column.type}${includeNumericHint && column.type === "number" ? " numeric" : ""})`,
424
+ }))
425
+ );
426
+ }
427
+
428
+ function renderChartEditorForm(modal, state) {
429
+ const draft = modal.draft ?? {};
430
+ const analysis = state.charts.result ? analyzeQueryChartResult(state.charts.result) : null;
431
+ const chartTypeOptions = QUERY_CHART_TYPES.map((chartType) => ({
432
+ value: chartType,
433
+ label: getQueryChartTypeLabel(chartType),
434
+ }));
435
+ const columnOptions = renderChartColumnOptions(analysis);
436
+ const optionalColumnOptions = renderChartColumnOptions(analysis, { allowEmpty: true });
437
+ let chartSpecificFields = "";
438
+
439
+ if (draft.chartType === "bar") {
440
+ chartSpecificFields = `
441
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
442
+ ${renderSelectField({
443
+ label: "X Column",
444
+ value: draft.config?.x_column ?? "",
445
+ options: columnOptions,
446
+ bind: "query-chart-draft-config:x_column",
447
+ })}
448
+ ${renderSelectField({
449
+ label: "Y Column",
450
+ value: draft.config?.y_column ?? "",
451
+ options: columnOptions,
452
+ bind: "query-chart-draft-config:y_column",
453
+ })}
454
+ </div>
455
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
456
+ ${renderSelectField({
457
+ label: "Sort Direction",
458
+ value: draft.config?.sort_direction ?? "asc",
459
+ options: [
460
+ { value: "asc", label: "Ascending" },
461
+ { value: "desc", label: "Descending" },
462
+ ],
463
+ bind: "query-chart-draft-config:sort_direction",
464
+ })}
465
+ ${renderCheckboxField({
466
+ label: "Show legend",
467
+ name: "",
468
+ checked: Boolean(draft.config?.show_legend),
469
+ text: "Show legend",
470
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
471
+ ${renderCheckboxField({
472
+ label: "Show labels",
473
+ name: "",
474
+ checked: Boolean(draft.config?.show_labels),
475
+ text: "Show labels",
476
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_labels"')}
477
+ </div>
478
+ `;
479
+ } else if (draft.chartType === "line") {
480
+ chartSpecificFields = `
481
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
482
+ ${renderSelectField({
483
+ label: "X Column",
484
+ value: draft.config?.x_column ?? "",
485
+ options: columnOptions,
486
+ bind: "query-chart-draft-config:x_column",
487
+ })}
488
+ ${renderSelectField({
489
+ label: "Y Column",
490
+ value: draft.config?.y_column ?? "",
491
+ options: columnOptions,
492
+ bind: "query-chart-draft-config:y_column",
493
+ })}
494
+ </div>
495
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
496
+ ${renderSelectField({
497
+ label: "Sort Direction",
498
+ value: draft.config?.sort_direction ?? "asc",
499
+ options: [
500
+ { value: "asc", label: "Ascending" },
501
+ { value: "desc", label: "Descending" },
502
+ ],
503
+ bind: "query-chart-draft-config:sort_direction",
504
+ })}
505
+ ${renderCheckboxField({
506
+ label: "Smooth line",
507
+ name: "",
508
+ checked: Boolean(draft.config?.smooth),
509
+ text: "Smooth line",
510
+ }).replace("<input", '<input data-bind="query-chart-draft-config:smooth"')}
511
+ ${renderCheckboxField({
512
+ label: "Show legend",
513
+ name: "",
514
+ checked: Boolean(draft.config?.show_legend),
515
+ text: "Show legend",
516
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
517
+ ${renderCheckboxField({
518
+ label: "Show labels",
519
+ name: "",
520
+ checked: Boolean(draft.config?.show_labels),
521
+ text: "Show labels",
522
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_labels"')}
523
+ </div>
524
+ `;
525
+ } else if (draft.chartType === "pie") {
526
+ chartSpecificFields = `
527
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
528
+ ${renderSelectField({
529
+ label: "Label Column",
530
+ value: draft.config?.label_column ?? "",
531
+ options: columnOptions,
532
+ bind: "query-chart-draft-config:label_column",
533
+ })}
534
+ ${renderSelectField({
535
+ label: "Value Column",
536
+ value: draft.config?.value_column ?? "",
537
+ options: columnOptions,
538
+ bind: "query-chart-draft-config:value_column",
539
+ })}
540
+ </div>
541
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
542
+ ${renderCheckboxField({
543
+ label: "Donut",
544
+ name: "",
545
+ checked: Boolean(draft.config?.donut),
546
+ text: "Render as donut",
547
+ }).replace("<input", '<input data-bind="query-chart-draft-config:donut"')}
548
+ ${renderCheckboxField({
549
+ label: "Show legend",
550
+ name: "",
551
+ checked: Boolean(draft.config?.show_legend),
552
+ text: "Show legend",
553
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
554
+ ${renderCheckboxField({
555
+ label: "Show labels",
556
+ name: "",
557
+ checked: Boolean(draft.config?.show_labels),
558
+ text: "Show labels",
559
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_labels"')}
560
+ </div>
561
+ `;
562
+ } else if (draft.chartType === "scatter") {
563
+ chartSpecificFields = `
564
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
565
+ ${renderSelectField({
566
+ label: "X Column",
567
+ value: draft.config?.x_column ?? "",
568
+ options: columnOptions,
569
+ bind: "query-chart-draft-config:x_column",
570
+ })}
571
+ ${renderSelectField({
572
+ label: "Y Column",
573
+ value: draft.config?.y_column ?? "",
574
+ options: columnOptions,
575
+ bind: "query-chart-draft-config:y_column",
576
+ })}
577
+ </div>
578
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
579
+ ${renderSelectField({
580
+ label: "Size Column",
581
+ value: draft.config?.size_column ?? "",
582
+ options: optionalColumnOptions,
583
+ bind: "query-chart-draft-config:size_column",
584
+ })}
585
+ ${renderSelectField({
586
+ label: "Series Column",
587
+ value: draft.config?.series_column ?? "",
588
+ options: optionalColumnOptions,
589
+ bind: "query-chart-draft-config:series_column",
590
+ })}
591
+ ${renderCheckboxField({
592
+ label: "Show legend",
593
+ name: "",
594
+ checked: Boolean(draft.config?.show_legend),
595
+ text: "Show legend",
596
+ }).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
597
+ </div>
598
+ `;
599
+ }
600
+
601
+ return `
602
+ <form class="space-y-5" data-form="save-query-chart">
603
+ ${renderField({
604
+ label: "Chart Name",
605
+ name: "chartName",
606
+ value: draft.name ?? "",
607
+ }).replace("<input", '<input data-bind="query-chart-draft:name"')}
608
+ ${renderSelectField({
609
+ label: "Chart Type",
610
+ value: draft.chartType ?? "bar",
611
+ options: chartTypeOptions,
612
+ bind: "query-chart-draft:chartType",
613
+ })}
614
+ ${chartSpecificFields}
615
+ ${
616
+ analysis
617
+ ? `
618
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-4">
619
+ <div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
620
+ Result Columns
621
+ </div>
622
+ <div class="mt-3 flex flex-wrap gap-2">
623
+ ${analysis.columns
624
+ .map(
625
+ (column) => `
626
+ <span class="border border-outline-variant/15 bg-surface-container px-2 py-1 text-[10px] font-mono uppercase tracking-[0.12em] text-on-surface-variant/70">
627
+ ${escapeHtml(column.name)} • ${escapeHtml(column.type)}
628
+ </span>
629
+ `
630
+ )
631
+ .join("")}
632
+ </div>
633
+ </div>
634
+ `
635
+ : ""
636
+ }
637
+ ${renderError(modal.error)}
638
+ <div class="flex items-center justify-end gap-3 pt-2">
639
+ <button
640
+ class="standard-button"
641
+ data-action="close-modal"
642
+ type="button"
643
+ >
644
+ Cancel
645
+ </button>
646
+ <button
647
+ class="standard-button"
648
+ type="submit"
649
+ >
650
+ ${modal.submitting ? "Saving..." : draft.mode === "edit" ? "Save Chart" : "Create Chart"}
651
+ </button>
652
+ </div>
653
+ </form>
654
+ `;
655
+ }
656
+
657
+ function renderDeleteChartForm(modal) {
658
+ return `
659
+ <form class="space-y-5" data-form="delete-query-chart">
660
+ <div class="space-y-3">
661
+ <p class="text-sm leading-7 text-on-surface">
662
+ Delete chart <span class="font-bold text-primary-container">${escapeHtml(
663
+ modal.chartName ?? "Chart"
664
+ )}</span>?
665
+ </p>
666
+ <p class="text-sm leading-7 text-on-surface-variant/65">
667
+ The linked query-history entry stays intact. Only this chart definition is removed.
668
+ </p>
669
+ </div>
670
+ ${renderError(modal.error)}
671
+ <div class="flex items-center justify-end gap-3 pt-2">
672
+ <button
673
+ class="standard-button"
674
+ data-action="close-modal"
675
+ type="button"
676
+ >
677
+ Cancel
678
+ </button>
679
+ <button
680
+ class="delete-button"
681
+ type="submit"
682
+ >
683
+ ${modal.submitting ? "Deleting..." : "Delete Chart"}
684
+ </button>
685
+ </div>
686
+ </form>
687
+ `;
688
+ }
689
+
383
690
  export function renderModal(state) {
384
691
  const modal = state.modal;
385
692
 
@@ -413,6 +720,16 @@ export function renderModal(state) {
413
720
  title: "Delete Row",
414
721
  body: renderDeleteRowConfirmForm(modal),
415
722
  },
723
+ "chart-editor": {
724
+ eyebrow: "Charts // Configure query-based ECharts panel",
725
+ title: modal.draft?.mode === "edit" ? "Edit Chart" : "New Chart",
726
+ body: renderChartEditorForm(modal, state),
727
+ },
728
+ "delete-chart": {
729
+ eyebrow: "Charts // Confirm chart deletion",
730
+ title: "Delete Chart",
731
+ body: renderDeleteChartForm(modal),
732
+ },
416
733
  };
417
734
 
418
735
  const config = contentByKind[modal.kind];
@@ -423,7 +740,7 @@ export function renderModal(state) {
423
740
 
424
741
  return `
425
742
  <div class="fixed inset-0 z-50 flex items-center justify-center bg-background/85 px-4 backdrop-blur-sm">
426
- <div class="w-full max-w-xl border border-outline-variant/20 bg-surface-container shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
743
+ <div class="w-full ${modal.kind === "chart-editor" ? "max-w-3xl" : "max-w-xl"} border border-outline-variant/20 bg-surface-container shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
427
744
  <div class="flex items-start justify-between gap-4 border-b border-outline-variant/10 bg-surface-container-low px-6 py-5">
428
745
  <div>
429
746
  <div class="text-[10px] font-mono uppercase tracking-[0.26em] text-primary-container/70">
@@ -434,7 +751,7 @@ export function renderModal(state) {
434
751
  </h2>
435
752
  </div>
436
753
  <button
437
- class="flex h-10 w-10 items-center justify-center border border-outline-variant/20 text-on-surface-variant hover:bg-surface-container-highest hover:text-primary-container"
754
+ class="control-icon-button border border-outline-variant/20 text-on-surface-variant hover:bg-surface-container-highest hover:text-primary-container"
438
755
  data-action="close-modal"
439
756
  type="button"
440
757
  >
@@ -0,0 +1,125 @@
1
+ import {
2
+ buildBarChartOption,
3
+ buildLineChartOption,
4
+ buildPieChartOption,
5
+ buildScatterChartOption,
6
+ } from "../lib/queryChartOptions.js";
7
+ import { analyzeQueryChartResult, validateQueryChartConfig } from "../lib/queryCharts.js";
8
+
9
+ const chartInstances = new Map();
10
+ const resizeObservers = new Map();
11
+
12
+ function getEchartsRuntime() {
13
+ return window.echarts ?? null;
14
+ }
15
+
16
+ function getOptionBuilder(chartType) {
17
+ if (chartType === "bar") {
18
+ return buildBarChartOption;
19
+ }
20
+
21
+ if (chartType === "line") {
22
+ return buildLineChartOption;
23
+ }
24
+
25
+ if (chartType === "pie") {
26
+ return buildPieChartOption;
27
+ }
28
+
29
+ if (chartType === "scatter") {
30
+ return buildScatterChartOption;
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ function disposeChart(chartId) {
37
+ const instance = chartInstances.get(chartId);
38
+
39
+ if (instance) {
40
+ instance.dispose();
41
+ chartInstances.delete(chartId);
42
+ }
43
+
44
+ const observer = resizeObservers.get(chartId);
45
+
46
+ if (observer) {
47
+ observer.disconnect();
48
+ resizeObservers.delete(chartId);
49
+ }
50
+ }
51
+
52
+ export function teardownQueryChartRenderer() {
53
+ Array.from(chartInstances.keys()).forEach((chartId) => disposeChart(chartId));
54
+ }
55
+
56
+ export function mountQueryChartRenderer(state) {
57
+ const echartsRuntime = getEchartsRuntime();
58
+ const charts = state.charts.detail?.charts ?? [];
59
+ const result = state.charts.result;
60
+
61
+ teardownQueryChartRenderer();
62
+
63
+ if (!echartsRuntime || !result || !charts.length) {
64
+ return;
65
+ }
66
+
67
+ const analysis = analyzeQueryChartResult(result);
68
+
69
+ charts.forEach((chart) => {
70
+ const host = document.querySelector(`[data-query-chart-id="${CSS.escape(String(chart.id))}"]`);
71
+
72
+ if (!(host instanceof HTMLElement)) {
73
+ return;
74
+ }
75
+
76
+ const validation = validateQueryChartConfig(chart.chartType, chart.config, analysis);
77
+
78
+ if (!validation.valid) {
79
+ return;
80
+ }
81
+
82
+ const buildOption = getOptionBuilder(chart.chartType);
83
+
84
+ if (!buildOption) {
85
+ return;
86
+ }
87
+
88
+ const instance = echartsRuntime.init(host);
89
+
90
+ instance.setOption(buildOption(chart, result.rows ?? [], analysis), true);
91
+ chartInstances.set(String(chart.id), instance);
92
+
93
+ const resizeObserver = new ResizeObserver(() => {
94
+ instance.resize();
95
+ });
96
+
97
+ resizeObserver.observe(host);
98
+ resizeObservers.set(String(chart.id), resizeObserver);
99
+ });
100
+ }
101
+
102
+ export function exportQueryChartAsPng(chartId) {
103
+ const instance = chartInstances.get(String(chartId));
104
+
105
+ if (!instance) {
106
+ return false;
107
+ }
108
+
109
+ const link = document.createElement("a");
110
+ const chartNode = document.querySelector(
111
+ `[data-query-chart-id="${CSS.escape(String(chartId))}"]`
112
+ );
113
+ const fileName = chartNode?.dataset.chartExportName ?? `chart-${chartId}`;
114
+
115
+ link.href = instance.getDataURL({
116
+ type: "png",
117
+ pixelRatio: 2,
118
+ backgroundColor: "#131313",
119
+ });
120
+ link.download = `${fileName}.png`;
121
+ document.body.appendChild(link);
122
+ link.click();
123
+ link.remove();
124
+ return true;
125
+ }