sqlite-hub 0.5.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 +109 -2
- package/bin/sqlite-hub.js +285 -4
- package/changelog.md +15 -0
- package/frontend/assets/mockups/connections.png +0 -0
- package/frontend/assets/mockups/data.png +0 -0
- package/frontend/assets/mockups/data_row_editor.png +0 -0
- package/frontend/assets/mockups/home.png +0 -0
- package/frontend/assets/mockups/sql_editor.png +0 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
- package/frontend/assets/mockups/structure.png +0 -0
- package/frontend/assets/mockups/structure_inspector.png +0 -0
- package/frontend/index.html +1 -0
- package/frontend/js/api.js +49 -0
- package/frontend/js/app.js +377 -9
- package/frontend/js/components/modal.js +314 -1
- package/frontend/js/components/queryChartRenderer.js +125 -0
- package/frontend/js/components/queryEditor.js +37 -8
- package/frontend/js/components/queryHistoryPanel.js +16 -5
- package/frontend/js/components/sidebar.js +2 -0
- package/frontend/js/components/tableDesignerEditor.js +356 -0
- package/frontend/js/components/tableDesignerSidebar.js +129 -0
- package/frontend/js/components/tableDesignerSqlPreview.js +40 -0
- package/frontend/js/lib/queryChartOptions.js +283 -0
- package/frontend/js/lib/queryCharts.js +560 -0
- package/frontend/js/router.js +18 -0
- package/frontend/js/store.js +914 -1
- package/frontend/js/utils/tableDesigner.js +1192 -0
- package/frontend/js/views/charts.js +471 -0
- package/frontend/js/views/data.js +281 -277
- package/frontend/js/views/editor.js +19 -13
- package/frontend/js/views/structure.js +81 -39
- package/frontend/js/views/tableDesigner.js +37 -0
- package/frontend/styles/base.css +87 -73
- package/frontend/styles/components.css +790 -251
- package/frontend/styles/views.css +316 -46
- package/package.json +2 -1
- package/server/routes/charts.js +153 -0
- package/server/routes/tableDesigner.js +60 -0
- package/server/server.js +10 -0
- package/server/services/sqlite/tableDesigner/changeAnalysis.js +295 -0
- package/server/services/sqlite/tableDesigner/schemaMapping.js +233 -0
- package/server/services/sqlite/tableDesigner/sql.js +63 -0
- package/server/services/sqlite/tableDesigner/validation.js +245 -0
- package/server/services/sqlite/tableDesignerService.js +181 -0
- package/server/services/storage/appStateStore.js +400 -1
- package/server/services/storage/queryHistoryChartUtils.js +145 -0
- package/frontend/assets/mockups/data_edit.png +0 -0
- package/frontend/assets/mockups/graph_visualize.png +0 -0
- package/frontend/assets/mockups/overview.png +0 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
const QUERY_CHART_TYPES = ["bar", "line", "pie", "scatter"];
|
|
2
|
+
const QUERY_CHART_TYPE_LABELS = {
|
|
3
|
+
bar: "Bar",
|
|
4
|
+
line: "Line",
|
|
5
|
+
pie: "Pie",
|
|
6
|
+
scatter: "Scatter",
|
|
7
|
+
};
|
|
8
|
+
const TIME_ONLY_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.(\d{1,3}))?)?$/;
|
|
9
|
+
|
|
10
|
+
function isFiniteNumber(value) {
|
|
11
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isNumericString(value) {
|
|
15
|
+
if (typeof value !== "string") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
return trimmed !== "" && Number.isFinite(Number(trimmed));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseTimeOnlyValue(value) {
|
|
24
|
+
if (typeof value !== "string") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
const match = trimmed.match(TIME_ONLY_PATTERN);
|
|
30
|
+
|
|
31
|
+
if (!match) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [, hoursText, minutesText, secondsText = "0", millisecondsText = "0"] = match;
|
|
36
|
+
const hours = Number(hoursText);
|
|
37
|
+
const minutes = Number(minutesText);
|
|
38
|
+
const seconds = Number(secondsText);
|
|
39
|
+
const milliseconds = Number(millisecondsText.padEnd(3, "0"));
|
|
40
|
+
|
|
41
|
+
return (((hours * 60 + minutes) * 60 + seconds) * 1000) + milliseconds;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isDatetimeLikeValue(value) {
|
|
45
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof value !== "string") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
|
|
55
|
+
if (!trimmed) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (parseTimeOnlyValue(trimmed) !== null) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!/[-/:T.Z]/.test(trimmed) && !/\b\d{4}\b/.test(trimmed)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return !Number.isNaN(Date.parse(trimmed));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getDatetimeKind(value) {
|
|
71
|
+
if (parseTimeOnlyValue(value) !== null) {
|
|
72
|
+
return "time";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isDatetimeLikeValue(value)) {
|
|
76
|
+
return "datetime";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inferDatetimeKindFromSamples(values = []) {
|
|
83
|
+
const kinds = values
|
|
84
|
+
.map((value) => getDatetimeKind(value))
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
if (!kinds.length) {
|
|
88
|
+
return "datetime";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return kinds.every((kind) => kind === "time") ? "time" : "datetime";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function coerceDatetimeChartValue(value) {
|
|
95
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
96
|
+
return value.getTime();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value !== "string") {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const trimmed = value.trim();
|
|
104
|
+
|
|
105
|
+
if (!trimmed) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const timeOnlyValue = parseTimeOnlyValue(trimmed);
|
|
110
|
+
|
|
111
|
+
if (timeOnlyValue !== null) {
|
|
112
|
+
return timeOnlyValue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parsed = Date.parse(trimmed);
|
|
116
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function compareValues(left, right) {
|
|
120
|
+
if (left === right) {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (left === null || left === undefined) {
|
|
125
|
+
return -1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (right === null || right === undefined) {
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isFiniteNumber(left) && isFiniteNumber(right)) {
|
|
133
|
+
return left - right;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const leftDate = isDatetimeLikeValue(left) ? coerceDatetimeChartValue(left) : null;
|
|
137
|
+
const rightDate = isDatetimeLikeValue(right) ? coerceDatetimeChartValue(right) : null;
|
|
138
|
+
|
|
139
|
+
if (leftDate !== null && rightDate !== null) {
|
|
140
|
+
return leftDate - rightDate;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return String(left).localeCompare(String(right), undefined, {
|
|
144
|
+
numeric: true,
|
|
145
|
+
sensitivity: "base",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isBlobValue(value) {
|
|
150
|
+
return Boolean(value && typeof value === "object" && value.__type === "blob");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getColumnNameSignal(columnName = "") {
|
|
154
|
+
const normalized = String(columnName ?? "").trim().toLowerCase();
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
looksNumeric:
|
|
158
|
+
/(count|total|sum|avg|amount|score|size|value|price|cost|rate|percent|pct|number)/.test(
|
|
159
|
+
normalized
|
|
160
|
+
),
|
|
161
|
+
looksDatetime:
|
|
162
|
+
/(date|time|day|week|month|quarter|year|bucket|hour|minute|second|at)/.test(normalized),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function inferColumnTypeFromSamples(columnName, values) {
|
|
167
|
+
const samples = values.filter(
|
|
168
|
+
(value) => value !== null && value !== undefined && !isBlobValue(value)
|
|
169
|
+
);
|
|
170
|
+
const nameSignal = getColumnNameSignal(columnName);
|
|
171
|
+
|
|
172
|
+
if (!samples.length) {
|
|
173
|
+
if (nameSignal.looksDatetime) {
|
|
174
|
+
return "datetime";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (nameSignal.looksNumeric) {
|
|
178
|
+
return "number";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return "unknown";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const numericValues = samples.filter((value) => isFiniteNumber(value) || isNumericString(value));
|
|
185
|
+
|
|
186
|
+
if (numericValues.length === samples.length) {
|
|
187
|
+
return "number";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const datetimeValues = samples.filter((value) => isDatetimeLikeValue(value));
|
|
191
|
+
|
|
192
|
+
if (datetimeValues.length === samples.length) {
|
|
193
|
+
return "datetime";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (samples.every((value) => typeof value === "string")) {
|
|
197
|
+
return "text";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return "unknown";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectDistinctValues(rows, columnName, limit = 25) {
|
|
204
|
+
const values = [];
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
|
|
207
|
+
for (const row of rows ?? []) {
|
|
208
|
+
const value = row?.[columnName];
|
|
209
|
+
|
|
210
|
+
if (value === null || value === undefined || isBlobValue(value)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const key = JSON.stringify(value);
|
|
215
|
+
|
|
216
|
+
if (seen.has(key)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
seen.add(key);
|
|
221
|
+
values.push(value);
|
|
222
|
+
|
|
223
|
+
if (values.length >= limit) {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return values;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function coerceNumericChartValue(value) {
|
|
232
|
+
if (isFiniteNumber(value)) {
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isNumericString(value)) {
|
|
237
|
+
return Number(value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function analyzeQueryChartResult(result) {
|
|
244
|
+
const rows = result?.rows ?? [];
|
|
245
|
+
const columns = (result?.columns ?? []).map((columnName) => {
|
|
246
|
+
const distinctValues = collectDistinctValues(rows, columnName);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
name: columnName,
|
|
250
|
+
type: inferColumnTypeFromSamples(columnName, distinctValues),
|
|
251
|
+
datetimeKind: inferDatetimeKindFromSamples(distinctValues),
|
|
252
|
+
distinctValues,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
const columnsByName = new Map(columns.map((column) => [column.name, column]));
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
rows,
|
|
259
|
+
columns,
|
|
260
|
+
columnsByName,
|
|
261
|
+
numberColumns: columns.filter((column) => column.type === "number"),
|
|
262
|
+
textColumns: columns.filter((column) => column.type === "text"),
|
|
263
|
+
datetimeColumns: columns.filter((column) => column.type === "datetime"),
|
|
264
|
+
unknownColumns: columns.filter((column) => column.type === "unknown"),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function buildQueryChartResultColumns(analysis) {
|
|
269
|
+
return (analysis?.columns ?? []).map((column) => ({
|
|
270
|
+
name: column.name,
|
|
271
|
+
type: column.type,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function getAnalysisColumn(analysis, columnName) {
|
|
276
|
+
return analysis?.columnsByName?.get(columnName) ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function getQueryChartTypeLabel(chartType) {
|
|
280
|
+
return QUERY_CHART_TYPE_LABELS[chartType] ?? String(chartType ?? "").trim();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function buildDefaultQueryChartName(chartType, queryName) {
|
|
284
|
+
const normalizedQueryName = String(queryName ?? "").replace(/\s+/g, " ").trim() || "Query";
|
|
285
|
+
return `${getQueryChartTypeLabel(chartType)}_${normalizedQueryName}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function resolveUniqueQueryChartName(baseName, existingCharts = [], excludeChartId = null) {
|
|
289
|
+
const normalizedBaseName = String(baseName ?? "").replace(/\s+/g, " ").trim() || "Chart";
|
|
290
|
+
const existingNames = new Set(
|
|
291
|
+
(existingCharts ?? [])
|
|
292
|
+
.filter((chart) => Number(chart.id) !== Number(excludeChartId))
|
|
293
|
+
.map((chart) => chart.name)
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
if (!existingNames.has(normalizedBaseName)) {
|
|
297
|
+
return normalizedBaseName;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let suffix = 2;
|
|
301
|
+
|
|
302
|
+
while (existingNames.has(`${normalizedBaseName}_${suffix}`)) {
|
|
303
|
+
suffix += 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return `${normalizedBaseName}_${suffix}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getFirstColumn(columns, predicate) {
|
|
310
|
+
return (columns ?? []).find((column) => predicate(column)) ?? null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getNextNumericColumn(analysis, excludedNames = []) {
|
|
314
|
+
const excluded = new Set(excludedNames);
|
|
315
|
+
return getFirstColumn(analysis?.numberColumns, (column) => !excluded.has(column.name));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getPrimaryDimensionColumn(analysis) {
|
|
319
|
+
return (
|
|
320
|
+
getFirstColumn(analysis?.datetimeColumns, () => true) ??
|
|
321
|
+
getFirstColumn(analysis?.textColumns, () => true) ??
|
|
322
|
+
getFirstColumn(analysis?.unknownColumns, () => true)
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getTimeDimensionColumn(analysis) {
|
|
327
|
+
return (
|
|
328
|
+
getFirstColumn(analysis?.datetimeColumns, () => true) ??
|
|
329
|
+
getFirstColumn(analysis?.columns, (column) => getColumnNameSignal(column.name).looksDatetime)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function shouldPreferPie(analysis) {
|
|
334
|
+
const dimensionColumn = getPrimaryDimensionColumn(analysis);
|
|
335
|
+
const numericColumn = getNextNumericColumn(analysis);
|
|
336
|
+
|
|
337
|
+
if (!dimensionColumn || !numericColumn) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const distinctCount = dimensionColumn.distinctValues?.length ?? 0;
|
|
342
|
+
return distinctCount > 0 && distinctCount <= 8 && (analysis?.numberColumns?.length ?? 0) === 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function suggestQueryChartType(analysis) {
|
|
346
|
+
if ((analysis?.numberColumns?.length ?? 0) >= 2 && !(analysis?.textColumns?.length ?? 0)) {
|
|
347
|
+
return "scatter";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (getTimeDimensionColumn(analysis) && getNextNumericColumn(analysis)) {
|
|
351
|
+
return "line";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (shouldPreferPie(analysis)) {
|
|
355
|
+
return "pie";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (getPrimaryDimensionColumn(analysis) && getNextNumericColumn(analysis)) {
|
|
359
|
+
return "bar";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if ((analysis?.numberColumns?.length ?? 0) >= 2) {
|
|
363
|
+
return "scatter";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return "bar";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function buildSuggestedChartConfig(chartType, analysis) {
|
|
370
|
+
const type = QUERY_CHART_TYPES.includes(chartType) ? chartType : "bar";
|
|
371
|
+
const primaryDimension = getPrimaryDimensionColumn(analysis);
|
|
372
|
+
const timeDimension = getTimeDimensionColumn(analysis);
|
|
373
|
+
const firstNumeric = getNextNumericColumn(analysis);
|
|
374
|
+
const secondNumeric = getNextNumericColumn(analysis, [firstNumeric?.name]);
|
|
375
|
+
|
|
376
|
+
switch (type) {
|
|
377
|
+
case "line":
|
|
378
|
+
return {
|
|
379
|
+
x_column: timeDimension?.name ?? primaryDimension?.name ?? "",
|
|
380
|
+
y_column: firstNumeric?.name ?? "",
|
|
381
|
+
show_legend: true,
|
|
382
|
+
show_labels: false,
|
|
383
|
+
sort_direction: "asc",
|
|
384
|
+
smooth: false,
|
|
385
|
+
};
|
|
386
|
+
case "pie":
|
|
387
|
+
return {
|
|
388
|
+
label_column: primaryDimension?.name ?? "",
|
|
389
|
+
value_column: firstNumeric?.name ?? "",
|
|
390
|
+
show_legend: true,
|
|
391
|
+
show_labels: true,
|
|
392
|
+
donut: false,
|
|
393
|
+
};
|
|
394
|
+
case "scatter":
|
|
395
|
+
return {
|
|
396
|
+
x_column: firstNumeric?.name ?? "",
|
|
397
|
+
y_column: secondNumeric?.name ?? firstNumeric?.name ?? "",
|
|
398
|
+
size_column: null,
|
|
399
|
+
series_column: primaryDimension?.name ?? null,
|
|
400
|
+
show_legend: true,
|
|
401
|
+
};
|
|
402
|
+
case "bar":
|
|
403
|
+
default:
|
|
404
|
+
return {
|
|
405
|
+
x_column: primaryDimension?.name ?? timeDimension?.name ?? "",
|
|
406
|
+
y_column: firstNumeric?.name ?? "",
|
|
407
|
+
show_legend: true,
|
|
408
|
+
show_labels: false,
|
|
409
|
+
sort_direction: "asc",
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function getColumn(analysis, columnName) {
|
|
415
|
+
return getAnalysisColumn(analysis, columnName);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function ensureColumnExists(analysis, columnName, label, errors) {
|
|
419
|
+
if (!columnName) {
|
|
420
|
+
errors.push(`${label} is required.`);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const column = getColumn(analysis, columnName);
|
|
425
|
+
|
|
426
|
+
if (!column) {
|
|
427
|
+
errors.push(`${label} "${columnName}" is missing from the current result set.`);
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return column;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function validateNumericColumn(column, label, errors) {
|
|
435
|
+
if (!column) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (column.type !== "number") {
|
|
440
|
+
errors.push(`${label} "${column.name}" must be numeric.`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function validateScatterAxisColumn(column, label, errors) {
|
|
445
|
+
if (!column) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!["number", "datetime"].includes(column.type)) {
|
|
450
|
+
errors.push(`${label} "${column.name}" must be numeric or datetime.`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function validateQueryChartConfig(chartType, config, analysis) {
|
|
455
|
+
const errors = [];
|
|
456
|
+
const type = QUERY_CHART_TYPES.includes(chartType) ? chartType : null;
|
|
457
|
+
|
|
458
|
+
if (!type) {
|
|
459
|
+
return {
|
|
460
|
+
valid: false,
|
|
461
|
+
errors: [`Unsupported chart type "${chartType}".`],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!analysis?.columns?.length) {
|
|
466
|
+
return {
|
|
467
|
+
valid: false,
|
|
468
|
+
errors: ["The query result has no columns available for chart mapping."],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
switch (type) {
|
|
473
|
+
case "bar": {
|
|
474
|
+
ensureColumnExists(analysis, config?.x_column, "Bar x column", errors);
|
|
475
|
+
ensureColumnExists(analysis, config?.y_column, "Bar y column", errors);
|
|
476
|
+
validateNumericColumn(getColumn(analysis, config?.y_column), "Bar y column", errors);
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
case "line": {
|
|
480
|
+
ensureColumnExists(analysis, config?.x_column, "Line x column", errors);
|
|
481
|
+
ensureColumnExists(analysis, config?.y_column, "Line y column", errors);
|
|
482
|
+
validateNumericColumn(getColumn(analysis, config?.y_column), "Line y column", errors);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case "pie": {
|
|
486
|
+
ensureColumnExists(analysis, config?.label_column, "Pie label column", errors);
|
|
487
|
+
ensureColumnExists(analysis, config?.value_column, "Pie value column", errors);
|
|
488
|
+
validateNumericColumn(getColumn(analysis, config?.value_column), "Pie value column", errors);
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "scatter": {
|
|
492
|
+
const xColumn = ensureColumnExists(analysis, config?.x_column, "Scatter x column", errors);
|
|
493
|
+
const yColumn = ensureColumnExists(analysis, config?.y_column, "Scatter y column", errors);
|
|
494
|
+
validateScatterAxisColumn(xColumn, "Scatter x column", errors);
|
|
495
|
+
validateScatterAxisColumn(yColumn, "Scatter y column", errors);
|
|
496
|
+
|
|
497
|
+
if (config?.size_column) {
|
|
498
|
+
validateNumericColumn(
|
|
499
|
+
ensureColumnExists(analysis, config.size_column, "Scatter size column", errors),
|
|
500
|
+
"Scatter size column",
|
|
501
|
+
errors
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (config?.series_column) {
|
|
506
|
+
ensureColumnExists(analysis, config.series_column, "Scatter series column", errors);
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
default:
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
valid: errors.length === 0,
|
|
515
|
+
errors,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function sortQueryChartRows(rows, columnName, direction = "asc") {
|
|
520
|
+
const multiplier = direction === "desc" ? -1 : 1;
|
|
521
|
+
|
|
522
|
+
return [...(rows ?? [])].sort(
|
|
523
|
+
(left, right) => compareValues(left?.[columnName], right?.[columnName]) * multiplier
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function formatQueryChartAxisValue(value) {
|
|
528
|
+
if (value === null || value === undefined) {
|
|
529
|
+
return "NULL";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return String(value);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function formatTimeOfDayAxisValue(value) {
|
|
536
|
+
const numericValue = Number(value);
|
|
537
|
+
|
|
538
|
+
if (!Number.isFinite(numericValue)) {
|
|
539
|
+
return String(value ?? "");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const totalMilliseconds = Math.max(0, Math.round(numericValue));
|
|
543
|
+
const totalSeconds = Math.floor(totalMilliseconds / 1000);
|
|
544
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
545
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
546
|
+
const seconds = totalSeconds % 60;
|
|
547
|
+
|
|
548
|
+
if (seconds) {
|
|
549
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(
|
|
550
|
+
seconds
|
|
551
|
+
).padStart(2, "0")}`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export {
|
|
558
|
+
QUERY_CHART_TYPE_LABELS,
|
|
559
|
+
QUERY_CHART_TYPES,
|
|
560
|
+
};
|
package/frontend/js/router.js
CHANGED
|
@@ -13,6 +13,14 @@ export function parseHash(hash = window.location.hash) {
|
|
|
13
13
|
return { name: "connections", path: "/connections", params: {} };
|
|
14
14
|
case "overview":
|
|
15
15
|
return { name: "overview", path: "/overview", params: {} };
|
|
16
|
+
case "charts":
|
|
17
|
+
return {
|
|
18
|
+
name: "charts",
|
|
19
|
+
path: cleanPath,
|
|
20
|
+
params: {
|
|
21
|
+
historyId: segments[1] ? decodeURIComponent(segments[1]) : null,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
16
24
|
case "editor":
|
|
17
25
|
if (segments[1] === "results") {
|
|
18
26
|
return { name: "editorResults", path: "/editor/results", params: {} };
|
|
@@ -29,6 +37,16 @@ export function parseHash(hash = window.location.hash) {
|
|
|
29
37
|
};
|
|
30
38
|
case "structure":
|
|
31
39
|
return { name: "structure", path: "/structure", params: {} };
|
|
40
|
+
case "table-designer":
|
|
41
|
+
return {
|
|
42
|
+
name: "tableDesigner",
|
|
43
|
+
path: cleanPath,
|
|
44
|
+
params: {
|
|
45
|
+
isNew: segments[1] === "new",
|
|
46
|
+
tableName:
|
|
47
|
+
segments[1] && segments[1] !== "new" ? decodeURIComponent(segments[1]) : null,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
32
50
|
case "settings":
|
|
33
51
|
return { name: "settings", path: "/settings", params: {} };
|
|
34
52
|
default:
|