sqlite-hub 0.6.0 → 0.7.0
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.
- package/README.md +107 -0
- package/bin/sqlite-hub.js +285 -4
- package/changelog.md +7 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/index.html +1 -0
- package/frontend/js/api.js +34 -0
- package/frontend/js/app.js +99 -2
- package/frontend/js/components/modal.js +314 -1
- package/frontend/js/components/queryChartRenderer.js +125 -0
- package/frontend/js/components/queryEditor.js +35 -6
- package/frontend/js/components/queryHistoryPanel.js +16 -5
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/components/tableDesignerSidebar.js +41 -38
- package/frontend/js/lib/queryChartOptions.js +283 -0
- package/frontend/js/lib/queryCharts.js +560 -0
- package/frontend/js/router.js +8 -0
- package/frontend/js/store.js +618 -0
- package/frontend/js/views/charts.js +471 -0
- package/frontend/js/views/data.js +29 -15
- package/frontend/js/views/editor.js +19 -13
- package/frontend/js/views/structure.js +81 -39
- package/frontend/styles/components.css +5 -13
- package/frontend/styles/views.css +296 -66
- package/package.json +2 -1
- package/server/routes/charts.js +153 -0
- package/server/server.js +6 -0
- package/server/services/storage/appStateStore.js +400 -1
- package/server/services/storage/queryHistoryChartUtils.js +145 -0
package/frontend/js/app.js
CHANGED
|
@@ -4,9 +4,14 @@ import { renderSidebar } from "./components/sidebar.js";
|
|
|
4
4
|
import { renderStatusBar } from "./components/statusBar.js";
|
|
5
5
|
import {
|
|
6
6
|
mountStructureGraph,
|
|
7
|
-
resetPersistedStructureGraphState,
|
|
8
7
|
teardownStructureGraph,
|
|
8
|
+
resetPersistedStructureGraphState,
|
|
9
9
|
} from "./components/structureGraph.js";
|
|
10
|
+
import {
|
|
11
|
+
exportQueryChartAsPng,
|
|
12
|
+
mountQueryChartRenderer,
|
|
13
|
+
teardownQueryChartRenderer,
|
|
14
|
+
} from "./components/queryChartRenderer.js";
|
|
10
15
|
import { renderToasts } from "./components/toast.js";
|
|
11
16
|
import { renderTopNav } from "./components/topNav.js";
|
|
12
17
|
import { createRouter } from "./router.js";
|
|
@@ -31,10 +36,14 @@ import {
|
|
|
31
36
|
openQueryHistoryInEditor,
|
|
32
37
|
openDeleteDataRowModal,
|
|
33
38
|
openDeleteEditorRowModal,
|
|
39
|
+
openDeleteQueryChartModal,
|
|
34
40
|
openEditConnectionModal,
|
|
41
|
+
openCreateQueryChartModal,
|
|
42
|
+
openEditQueryChartModal,
|
|
35
43
|
refreshCurrentRoute,
|
|
36
44
|
removeConnection,
|
|
37
45
|
runQueryHistoryItem,
|
|
46
|
+
saveCurrentQueryChartDraft,
|
|
38
47
|
selectDataRow,
|
|
39
48
|
selectEditorRow,
|
|
40
49
|
selectConnection,
|
|
@@ -45,8 +54,13 @@ import {
|
|
|
45
54
|
setDataPageSize,
|
|
46
55
|
setDataSearchColumn,
|
|
47
56
|
setDataSearchQuery,
|
|
57
|
+
toggleDataTablesPanel,
|
|
48
58
|
setCurrentQuery,
|
|
59
|
+
setChartsHeightPreset,
|
|
60
|
+
setEditorPanelVisibility,
|
|
49
61
|
setEditorTab,
|
|
62
|
+
submitDeleteChartConfirmation,
|
|
63
|
+
setQueryHistoryPanelVisibility,
|
|
50
64
|
sortDataTableByColumn,
|
|
51
65
|
sortEditorResultsByColumn,
|
|
52
66
|
setQueryHistorySearchInput,
|
|
@@ -55,6 +69,8 @@ import {
|
|
|
55
69
|
saveQueryHistoryNotes,
|
|
56
70
|
saveQueryHistoryTitle,
|
|
57
71
|
saveCurrentTableDesignerDraft,
|
|
72
|
+
toggleChartsResultsPanel,
|
|
73
|
+
toggleChartsSqlPanel,
|
|
58
74
|
queueTableDesignerCsvImport,
|
|
59
75
|
showToast,
|
|
60
76
|
submitCreateConnection,
|
|
@@ -66,11 +82,14 @@ import {
|
|
|
66
82
|
submitOpenConnection,
|
|
67
83
|
subscribe,
|
|
68
84
|
toggleQueryHistorySavedState,
|
|
85
|
+
updateCurrentQueryChartDraftConfigField,
|
|
86
|
+
updateCurrentQueryChartDraftField,
|
|
69
87
|
updateCurrentTableDesignerColumnField,
|
|
70
88
|
updateCurrentTableDesignerField,
|
|
71
89
|
addCurrentTableDesignerColumn,
|
|
72
90
|
removeCurrentTableDesignerColumn,
|
|
73
91
|
} from "./store.js";
|
|
92
|
+
import { renderChartsView } from "./views/charts.js";
|
|
74
93
|
import { renderConnectionsView } from "./views/connections.js";
|
|
75
94
|
import { renderDataView } from "./views/data.js";
|
|
76
95
|
import { renderEditorView } from "./views/editor.js";
|
|
@@ -218,6 +237,8 @@ function resolveView(state) {
|
|
|
218
237
|
return renderConnectionsView(state);
|
|
219
238
|
case "overview":
|
|
220
239
|
return renderOverviewView(state);
|
|
240
|
+
case "charts":
|
|
241
|
+
return renderChartsView(state);
|
|
221
242
|
case "data":
|
|
222
243
|
return renderDataView(state);
|
|
223
244
|
case "editor":
|
|
@@ -411,7 +432,7 @@ function renderApp(state) {
|
|
|
411
432
|
const focusedInput = captureFocusedInputState();
|
|
412
433
|
const previousRoutePath = lastRenderedRoutePath;
|
|
413
434
|
const { main, panel } = resolveView(state);
|
|
414
|
-
const isLockedRoute = ["editor", "editorResults", "data", "structure", "tableDesigner"].includes(
|
|
435
|
+
const isLockedRoute = ["editor", "editorResults", "data", "charts", "structure", "tableDesigner"].includes(
|
|
415
436
|
state.route.name
|
|
416
437
|
);
|
|
417
438
|
const isEnteringNewTableDesignerRoute =
|
|
@@ -426,6 +447,7 @@ function renderApp(state) {
|
|
|
426
447
|
}
|
|
427
448
|
|
|
428
449
|
teardownStructureGraph();
|
|
450
|
+
teardownQueryChartRenderer();
|
|
429
451
|
shellRefs.topNav.innerHTML = renderTopNav(state);
|
|
430
452
|
shellRefs.sidebar.innerHTML = renderSidebar(state);
|
|
431
453
|
shellRefs.statusBar.innerHTML = renderStatusBar(state);
|
|
@@ -456,6 +478,10 @@ function renderApp(state) {
|
|
|
456
478
|
console.error("Failed to mount structure graph.", error);
|
|
457
479
|
});
|
|
458
480
|
}
|
|
481
|
+
|
|
482
|
+
if (state.route.name === "charts") {
|
|
483
|
+
mountQueryChartRenderer(state);
|
|
484
|
+
}
|
|
459
485
|
}
|
|
460
486
|
|
|
461
487
|
const router = createRouter((route) => {
|
|
@@ -480,6 +506,15 @@ async function handleAction(actionNode) {
|
|
|
480
506
|
case "open-modal":
|
|
481
507
|
openModal(actionNode.dataset.modal);
|
|
482
508
|
return;
|
|
509
|
+
case "open-create-query-chart-modal":
|
|
510
|
+
openCreateQueryChartModal();
|
|
511
|
+
return;
|
|
512
|
+
case "open-edit-query-chart-modal":
|
|
513
|
+
openEditQueryChartModal(actionNode.dataset.chartId);
|
|
514
|
+
return;
|
|
515
|
+
case "open-delete-query-chart-modal":
|
|
516
|
+
openDeleteQueryChartModal(actionNode.dataset.chartId);
|
|
517
|
+
return;
|
|
483
518
|
case "edit-connection":
|
|
484
519
|
openEditConnectionModal(actionNode.dataset.connectionId);
|
|
485
520
|
return;
|
|
@@ -554,6 +589,16 @@ async function handleAction(actionNode) {
|
|
|
554
589
|
await setQueryHistoryTab(actionNode.dataset.tab);
|
|
555
590
|
}
|
|
556
591
|
return;
|
|
592
|
+
case "toggle-query-history-panel":
|
|
593
|
+
setQueryHistoryPanelVisibility(
|
|
594
|
+
actionNode.dataset.nextValue ? actionNode.dataset.nextValue === "true" : undefined
|
|
595
|
+
);
|
|
596
|
+
return;
|
|
597
|
+
case "toggle-editor-panel":
|
|
598
|
+
setEditorPanelVisibility(
|
|
599
|
+
actionNode.dataset.nextValue ? actionNode.dataset.nextValue === "true" : undefined
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
557
602
|
case "load-more-query-history":
|
|
558
603
|
await loadMoreQueryHistory();
|
|
559
604
|
return;
|
|
@@ -576,6 +621,22 @@ async function handleAction(actionNode) {
|
|
|
576
621
|
);
|
|
577
622
|
}
|
|
578
623
|
return;
|
|
624
|
+
case "toggle-charts-sql-panel":
|
|
625
|
+
toggleChartsSqlPanel();
|
|
626
|
+
return;
|
|
627
|
+
case "toggle-charts-results-panel":
|
|
628
|
+
toggleChartsResultsPanel();
|
|
629
|
+
return;
|
|
630
|
+
case "set-charts-height-preset":
|
|
631
|
+
if (actionNode.dataset.preset) {
|
|
632
|
+
setChartsHeightPreset(actionNode.dataset.preset);
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
case "export-query-chart-png":
|
|
636
|
+
if (actionNode.dataset.chartId && !exportQueryChartAsPng(actionNode.dataset.chartId)) {
|
|
637
|
+
showToast("The selected chart is not ready for PNG export.", "alert");
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
579
640
|
case "delete-query-history":
|
|
580
641
|
if (
|
|
581
642
|
actionNode.dataset.historyId &&
|
|
@@ -599,6 +660,9 @@ async function handleAction(actionNode) {
|
|
|
599
660
|
case "export-data-csv":
|
|
600
661
|
await exportCurrentDataTableCsv();
|
|
601
662
|
return;
|
|
663
|
+
case "toggle-data-tables":
|
|
664
|
+
toggleDataTablesPanel();
|
|
665
|
+
return;
|
|
602
666
|
case "select-structure-entry":
|
|
603
667
|
if (actionNode.dataset.entryName) {
|
|
604
668
|
await selectStructureEntry(actionNode.dataset.entryName);
|
|
@@ -794,6 +858,11 @@ document.addEventListener("input", (event) => {
|
|
|
794
858
|
|
|
795
859
|
if (bindNode.dataset.bind === "query-history-search") {
|
|
796
860
|
setQueryHistorySearchInput(bindNode.value);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (bindNode.dataset.bind === "query-chart-draft:name") {
|
|
865
|
+
updateCurrentQueryChartDraftField("name", bindNode.value);
|
|
797
866
|
}
|
|
798
867
|
});
|
|
799
868
|
|
|
@@ -853,6 +922,28 @@ document.addEventListener("change", (event) => {
|
|
|
853
922
|
bindNode.dataset.field,
|
|
854
923
|
bindNode.checked
|
|
855
924
|
);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (bindNode.dataset.bind === "query-chart-draft:chartType") {
|
|
929
|
+
updateCurrentQueryChartDraftField("chartType", bindNode.value);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (bindNode.dataset.bind === "query-chart-draft:tableVisible") {
|
|
934
|
+
updateCurrentQueryChartDraftField("tableVisible", bindNode.checked);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (bindNode.dataset.bind?.startsWith("query-chart-draft-config:")) {
|
|
939
|
+
const field = bindNode.dataset.bind.slice("query-chart-draft-config:".length);
|
|
940
|
+
const value =
|
|
941
|
+
bindNode instanceof HTMLInputElement && bindNode.type === "checkbox"
|
|
942
|
+
? bindNode.checked
|
|
943
|
+
: bindNode.value === ""
|
|
944
|
+
? null
|
|
945
|
+
: bindNode.value;
|
|
946
|
+
updateCurrentQueryChartDraftConfigField(field, value);
|
|
856
947
|
}
|
|
857
948
|
});
|
|
858
949
|
|
|
@@ -938,6 +1029,12 @@ document.addEventListener("submit", async (event) => {
|
|
|
938
1029
|
case "delete-row-confirm":
|
|
939
1030
|
await submitDeleteRowConfirmation();
|
|
940
1031
|
return;
|
|
1032
|
+
case "save-query-chart":
|
|
1033
|
+
await saveCurrentQueryChartDraft();
|
|
1034
|
+
return;
|
|
1035
|
+
case "delete-query-chart":
|
|
1036
|
+
await submitDeleteChartConfirmation();
|
|
1037
|
+
return;
|
|
941
1038
|
case "save-data-row": {
|
|
942
1039
|
const values = {};
|
|
943
1040
|
|
|
@@ -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 `
|
|
@@ -58,6 +63,31 @@ function renderFileField({
|
|
|
58
63
|
`;
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
function renderSelectField({ label, name, value = "", options = [], bind = "" }) {
|
|
67
|
+
return `
|
|
68
|
+
<label class="block space-y-2">
|
|
69
|
+
<span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
|
|
70
|
+
${escapeHtml(label)}
|
|
71
|
+
</span>
|
|
72
|
+
<select
|
|
73
|
+
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"
|
|
74
|
+
${bind ? `data-bind="${escapeHtml(bind)}"` : ""}
|
|
75
|
+
${name ? `name="${escapeHtml(name)}"` : ""}
|
|
76
|
+
>
|
|
77
|
+
${options
|
|
78
|
+
.map(
|
|
79
|
+
(option) => `
|
|
80
|
+
<option value="${escapeHtml(option.value)}" ${String(option.value) === String(value) ? "selected" : ""}>
|
|
81
|
+
${escapeHtml(option.label)}
|
|
82
|
+
</option>
|
|
83
|
+
`
|
|
84
|
+
)
|
|
85
|
+
.join("")}
|
|
86
|
+
</select>
|
|
87
|
+
</label>
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
function renderError(error) {
|
|
62
92
|
if (!error) {
|
|
63
93
|
return "";
|
|
@@ -380,6 +410,279 @@ function renderDeleteRowConfirmForm(modal) {
|
|
|
380
410
|
`;
|
|
381
411
|
}
|
|
382
412
|
|
|
413
|
+
function renderChartColumnOptions(analysis, { allowEmpty = false, includeNumericHint = false } = {}) {
|
|
414
|
+
const options = allowEmpty ? [{ value: "", label: "None" }] : [];
|
|
415
|
+
|
|
416
|
+
return options.concat(
|
|
417
|
+
(analysis?.columns ?? []).map((column) => ({
|
|
418
|
+
value: column.name,
|
|
419
|
+
label: `${column.name} (${column.type}${includeNumericHint && column.type === "number" ? " numeric" : ""})`,
|
|
420
|
+
}))
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderChartEditorForm(modal, state) {
|
|
425
|
+
const draft = modal.draft ?? {};
|
|
426
|
+
const analysis = state.charts.result ? analyzeQueryChartResult(state.charts.result) : null;
|
|
427
|
+
const chartTypeOptions = QUERY_CHART_TYPES.map((chartType) => ({
|
|
428
|
+
value: chartType,
|
|
429
|
+
label: getQueryChartTypeLabel(chartType),
|
|
430
|
+
}));
|
|
431
|
+
const columnOptions = renderChartColumnOptions(analysis);
|
|
432
|
+
const optionalColumnOptions = renderChartColumnOptions(analysis, { allowEmpty: true });
|
|
433
|
+
let chartSpecificFields = "";
|
|
434
|
+
|
|
435
|
+
if (draft.chartType === "bar") {
|
|
436
|
+
chartSpecificFields = `
|
|
437
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
438
|
+
${renderSelectField({
|
|
439
|
+
label: "X Column",
|
|
440
|
+
value: draft.config?.x_column ?? "",
|
|
441
|
+
options: columnOptions,
|
|
442
|
+
bind: "query-chart-draft-config:x_column",
|
|
443
|
+
})}
|
|
444
|
+
${renderSelectField({
|
|
445
|
+
label: "Y Column",
|
|
446
|
+
value: draft.config?.y_column ?? "",
|
|
447
|
+
options: columnOptions,
|
|
448
|
+
bind: "query-chart-draft-config:y_column",
|
|
449
|
+
})}
|
|
450
|
+
</div>
|
|
451
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
452
|
+
${renderSelectField({
|
|
453
|
+
label: "Sort Direction",
|
|
454
|
+
value: draft.config?.sort_direction ?? "asc",
|
|
455
|
+
options: [
|
|
456
|
+
{ value: "asc", label: "Ascending" },
|
|
457
|
+
{ value: "desc", label: "Descending" },
|
|
458
|
+
],
|
|
459
|
+
bind: "query-chart-draft-config:sort_direction",
|
|
460
|
+
})}
|
|
461
|
+
${renderCheckboxField({
|
|
462
|
+
label: "Show legend",
|
|
463
|
+
name: "",
|
|
464
|
+
checked: Boolean(draft.config?.show_legend),
|
|
465
|
+
text: "Show legend",
|
|
466
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
|
|
467
|
+
${renderCheckboxField({
|
|
468
|
+
label: "Show labels",
|
|
469
|
+
name: "",
|
|
470
|
+
checked: Boolean(draft.config?.show_labels),
|
|
471
|
+
text: "Show labels",
|
|
472
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_labels"')}
|
|
473
|
+
</div>
|
|
474
|
+
`;
|
|
475
|
+
} else if (draft.chartType === "line") {
|
|
476
|
+
chartSpecificFields = `
|
|
477
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
478
|
+
${renderSelectField({
|
|
479
|
+
label: "X Column",
|
|
480
|
+
value: draft.config?.x_column ?? "",
|
|
481
|
+
options: columnOptions,
|
|
482
|
+
bind: "query-chart-draft-config:x_column",
|
|
483
|
+
})}
|
|
484
|
+
${renderSelectField({
|
|
485
|
+
label: "Y Column",
|
|
486
|
+
value: draft.config?.y_column ?? "",
|
|
487
|
+
options: columnOptions,
|
|
488
|
+
bind: "query-chart-draft-config:y_column",
|
|
489
|
+
})}
|
|
490
|
+
</div>
|
|
491
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
492
|
+
${renderSelectField({
|
|
493
|
+
label: "Sort Direction",
|
|
494
|
+
value: draft.config?.sort_direction ?? "asc",
|
|
495
|
+
options: [
|
|
496
|
+
{ value: "asc", label: "Ascending" },
|
|
497
|
+
{ value: "desc", label: "Descending" },
|
|
498
|
+
],
|
|
499
|
+
bind: "query-chart-draft-config:sort_direction",
|
|
500
|
+
})}
|
|
501
|
+
${renderCheckboxField({
|
|
502
|
+
label: "Smooth line",
|
|
503
|
+
name: "",
|
|
504
|
+
checked: Boolean(draft.config?.smooth),
|
|
505
|
+
text: "Smooth line",
|
|
506
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:smooth"')}
|
|
507
|
+
${renderCheckboxField({
|
|
508
|
+
label: "Show legend",
|
|
509
|
+
name: "",
|
|
510
|
+
checked: Boolean(draft.config?.show_legend),
|
|
511
|
+
text: "Show legend",
|
|
512
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
|
|
513
|
+
${renderCheckboxField({
|
|
514
|
+
label: "Show labels",
|
|
515
|
+
name: "",
|
|
516
|
+
checked: Boolean(draft.config?.show_labels),
|
|
517
|
+
text: "Show labels",
|
|
518
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_labels"')}
|
|
519
|
+
</div>
|
|
520
|
+
`;
|
|
521
|
+
} else if (draft.chartType === "pie") {
|
|
522
|
+
chartSpecificFields = `
|
|
523
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
524
|
+
${renderSelectField({
|
|
525
|
+
label: "Label Column",
|
|
526
|
+
value: draft.config?.label_column ?? "",
|
|
527
|
+
options: columnOptions,
|
|
528
|
+
bind: "query-chart-draft-config:label_column",
|
|
529
|
+
})}
|
|
530
|
+
${renderSelectField({
|
|
531
|
+
label: "Value Column",
|
|
532
|
+
value: draft.config?.value_column ?? "",
|
|
533
|
+
options: columnOptions,
|
|
534
|
+
bind: "query-chart-draft-config:value_column",
|
|
535
|
+
})}
|
|
536
|
+
</div>
|
|
537
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
538
|
+
${renderCheckboxField({
|
|
539
|
+
label: "Donut",
|
|
540
|
+
name: "",
|
|
541
|
+
checked: Boolean(draft.config?.donut),
|
|
542
|
+
text: "Render as donut",
|
|
543
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:donut"')}
|
|
544
|
+
${renderCheckboxField({
|
|
545
|
+
label: "Show legend",
|
|
546
|
+
name: "",
|
|
547
|
+
checked: Boolean(draft.config?.show_legend),
|
|
548
|
+
text: "Show legend",
|
|
549
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
|
|
550
|
+
${renderCheckboxField({
|
|
551
|
+
label: "Show labels",
|
|
552
|
+
name: "",
|
|
553
|
+
checked: Boolean(draft.config?.show_labels),
|
|
554
|
+
text: "Show labels",
|
|
555
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_labels"')}
|
|
556
|
+
</div>
|
|
557
|
+
`;
|
|
558
|
+
} else if (draft.chartType === "scatter") {
|
|
559
|
+
chartSpecificFields = `
|
|
560
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
561
|
+
${renderSelectField({
|
|
562
|
+
label: "X Column",
|
|
563
|
+
value: draft.config?.x_column ?? "",
|
|
564
|
+
options: columnOptions,
|
|
565
|
+
bind: "query-chart-draft-config:x_column",
|
|
566
|
+
})}
|
|
567
|
+
${renderSelectField({
|
|
568
|
+
label: "Y Column",
|
|
569
|
+
value: draft.config?.y_column ?? "",
|
|
570
|
+
options: columnOptions,
|
|
571
|
+
bind: "query-chart-draft-config:y_column",
|
|
572
|
+
})}
|
|
573
|
+
</div>
|
|
574
|
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
575
|
+
${renderSelectField({
|
|
576
|
+
label: "Size Column",
|
|
577
|
+
value: draft.config?.size_column ?? "",
|
|
578
|
+
options: optionalColumnOptions,
|
|
579
|
+
bind: "query-chart-draft-config:size_column",
|
|
580
|
+
})}
|
|
581
|
+
${renderSelectField({
|
|
582
|
+
label: "Series Column",
|
|
583
|
+
value: draft.config?.series_column ?? "",
|
|
584
|
+
options: optionalColumnOptions,
|
|
585
|
+
bind: "query-chart-draft-config:series_column",
|
|
586
|
+
})}
|
|
587
|
+
${renderCheckboxField({
|
|
588
|
+
label: "Show legend",
|
|
589
|
+
name: "",
|
|
590
|
+
checked: Boolean(draft.config?.show_legend),
|
|
591
|
+
text: "Show legend",
|
|
592
|
+
}).replace("<input", '<input data-bind="query-chart-draft-config:show_legend"')}
|
|
593
|
+
</div>
|
|
594
|
+
`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return `
|
|
598
|
+
<form class="space-y-5" data-form="save-query-chart">
|
|
599
|
+
${renderField({
|
|
600
|
+
label: "Chart Name",
|
|
601
|
+
name: "chartName",
|
|
602
|
+
value: draft.name ?? "",
|
|
603
|
+
}).replace("<input", '<input data-bind="query-chart-draft:name"')}
|
|
604
|
+
${renderSelectField({
|
|
605
|
+
label: "Chart Type",
|
|
606
|
+
value: draft.chartType ?? "bar",
|
|
607
|
+
options: chartTypeOptions,
|
|
608
|
+
bind: "query-chart-draft:chartType",
|
|
609
|
+
})}
|
|
610
|
+
${chartSpecificFields}
|
|
611
|
+
${
|
|
612
|
+
analysis
|
|
613
|
+
? `
|
|
614
|
+
<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-4">
|
|
615
|
+
<div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
|
|
616
|
+
Result Columns
|
|
617
|
+
</div>
|
|
618
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
619
|
+
${analysis.columns
|
|
620
|
+
.map(
|
|
621
|
+
(column) => `
|
|
622
|
+
<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">
|
|
623
|
+
${escapeHtml(column.name)} • ${escapeHtml(column.type)}
|
|
624
|
+
</span>
|
|
625
|
+
`
|
|
626
|
+
)
|
|
627
|
+
.join("")}
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
`
|
|
631
|
+
: ""
|
|
632
|
+
}
|
|
633
|
+
${renderError(modal.error)}
|
|
634
|
+
<div class="flex items-center justify-end gap-3 pt-2">
|
|
635
|
+
<button
|
|
636
|
+
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"
|
|
637
|
+
data-action="close-modal"
|
|
638
|
+
type="button"
|
|
639
|
+
>
|
|
640
|
+
Cancel
|
|
641
|
+
</button>
|
|
642
|
+
<button
|
|
643
|
+
class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
|
|
644
|
+
type="submit"
|
|
645
|
+
>
|
|
646
|
+
${modal.submitting ? "Saving..." : draft.mode === "edit" ? "Save Chart" : "Create Chart"}
|
|
647
|
+
</button>
|
|
648
|
+
</div>
|
|
649
|
+
</form>
|
|
650
|
+
`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function renderDeleteChartForm(modal) {
|
|
654
|
+
return `
|
|
655
|
+
<form class="space-y-5" data-form="delete-query-chart">
|
|
656
|
+
<div class="space-y-3">
|
|
657
|
+
<p class="text-sm leading-7 text-on-surface">
|
|
658
|
+
Delete chart <span class="font-bold text-primary-container">${escapeHtml(
|
|
659
|
+
modal.chartName ?? "Chart"
|
|
660
|
+
)}</span>?
|
|
661
|
+
</p>
|
|
662
|
+
<p class="text-sm leading-7 text-on-surface-variant/65">
|
|
663
|
+
The linked query-history entry stays intact. Only this chart definition is removed.
|
|
664
|
+
</p>
|
|
665
|
+
</div>
|
|
666
|
+
${renderError(modal.error)}
|
|
667
|
+
<div class="flex items-center justify-end gap-3 pt-2">
|
|
668
|
+
<button
|
|
669
|
+
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"
|
|
670
|
+
data-action="close-modal"
|
|
671
|
+
type="button"
|
|
672
|
+
>
|
|
673
|
+
Cancel
|
|
674
|
+
</button>
|
|
675
|
+
<button
|
|
676
|
+
class="border border-error/25 bg-error-container/10 px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-error"
|
|
677
|
+
type="submit"
|
|
678
|
+
>
|
|
679
|
+
${modal.submitting ? "Deleting..." : "Delete Chart"}
|
|
680
|
+
</button>
|
|
681
|
+
</div>
|
|
682
|
+
</form>
|
|
683
|
+
`;
|
|
684
|
+
}
|
|
685
|
+
|
|
383
686
|
export function renderModal(state) {
|
|
384
687
|
const modal = state.modal;
|
|
385
688
|
|
|
@@ -413,6 +716,16 @@ export function renderModal(state) {
|
|
|
413
716
|
title: "Delete Row",
|
|
414
717
|
body: renderDeleteRowConfirmForm(modal),
|
|
415
718
|
},
|
|
719
|
+
"chart-editor": {
|
|
720
|
+
eyebrow: "Charts // Configure query-based ECharts panel",
|
|
721
|
+
title: modal.draft?.mode === "edit" ? "Edit Chart" : "New Chart",
|
|
722
|
+
body: renderChartEditorForm(modal, state),
|
|
723
|
+
},
|
|
724
|
+
"delete-chart": {
|
|
725
|
+
eyebrow: "Charts // Confirm chart deletion",
|
|
726
|
+
title: "Delete Chart",
|
|
727
|
+
body: renderDeleteChartForm(modal),
|
|
728
|
+
},
|
|
416
729
|
};
|
|
417
730
|
|
|
418
731
|
const config = contentByKind[modal.kind];
|
|
@@ -423,7 +736,7 @@ export function renderModal(state) {
|
|
|
423
736
|
|
|
424
737
|
return `
|
|
425
738
|
<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)]">
|
|
739
|
+
<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
740
|
<div class="flex items-start justify-between gap-4 border-b border-outline-variant/10 bg-surface-container-low px-6 py-5">
|
|
428
741
|
<div>
|
|
429
742
|
<div class="text-[10px] font-mono uppercase tracking-[0.26em] text-primary-container/70">
|
|
@@ -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
|
+
}
|