Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 162 additions & 4 deletions packages/web-console/src/js/console/quick-vis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function quickVis(
let cachedResponse: any
let cachedQuery: AnyIfEmpty
let requestActive: boolean = false
let selectedTimeFormat: string = "auto"

const chartTypePicker = new SlimSelect({
select: "#_qvis_frm_chart_type",
Expand All @@ -94,6 +95,10 @@ export function quickVis(
select: "#_qvis_frm_axis_y",
})

const timeFormatPicker = new SlimSelect({
select: "#_qvis_frm_time_format",
})

function resize() {
echart.resize()
}
Expand All @@ -104,6 +109,93 @@ export function quickVis(
}
}

function detectTimeColumns(columns: any[]) {
return columns.filter(col => {
// Detect timestamp columns by type or name patterns
return col.type === 'TIMESTAMP' ||
col.type === 'TIMESTAMP_NS' ||
/timestamp|time|date|created|updated/i.test(col.name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot rely on column name for detecting the timestamp. Types are already enough for this purpose. We have an utility function in ImportCSVFiles, so you can move it to src/utils/... and use it here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll work on this.

})
}

function isValidTimestamp(value: any): boolean {
if (typeof value === 'string') {
const parsed = Date.parse(value)
return !isNaN(parsed) && parsed > 0
}
if (typeof value === 'number') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In which cases does QuestDB return a number for a timestamp column? Why do we have this check?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the time is stored in milliseconds since epoch for example.

return value > 0 && value < 9999999999999
}
return false
}

function formatTimestamp(timestamp: any, format: string): string {
let date: Date

if (typeof timestamp === 'string') {
date = new Date(timestamp)
} else if (typeof timestamp === 'number') {
date = new Date(timestamp)
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which case is covered in this branch? Can you give an example?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are asking about number, then like before it could be the milliseconds type. Or an ISO Format String.

return timestamp.toString()
}

if (isNaN(date.getTime())) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which case is covered in this branch? Can you give an example?

return timestamp.toString()
}

// Helper function to pad numbers with leading zeros
const pad = (num: number): string => num.toString().padStart(2, '0')

switch (format) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have date-fns dependency and it provides useful functions for this purpose.
This one can be replaced with format function from that package.

case 'HH:mm:ss':
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
case 'HH:mm':
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
case 'yyyy-MM-dd HH:mm:ss':
return date.toLocaleString('sv-SE') // ISO-like format
case 'MM/dd HH:mm':
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
case 'MMM dd, yyyy':
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
case 'relative':
return formatRelativeTime(date.getTime())
case 'auto':
default:
return autoDetectTimeFormat(date.getTime())
}
}

function formatRelativeTime(timestamp: number): string {
const now = new Date().getTime()
const diff = now - timestamp
const absDiff = Math.abs(diff)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which case do we cover with abs call?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An edge case where future timestamps may be stored in a table. Like forecasts, for example.


if (absDiff < 60000) return `${Math.floor(absDiff/1000)}s ago`
if (absDiff < 3600000) return `${Math.floor(absDiff/60000)}m ago`
if (absDiff < 86400000) return `${Math.floor(absDiff/3600000)}h ago`
return `${Math.floor(absDiff/86400000)}d ago`
}

function autoDetectTimeFormat(timestamp: number): string {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue, use date-fns.

const date = new Date(timestamp)
const now = new Date().getTime()
const diff = Math.abs(now - timestamp)

// Helper function to pad numbers with leading zeros
const pad = (num: number): string => num.toString().padStart(2, '0')

if (diff < 3600000) { // < 1 hour: show time with seconds
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
} else if (diff < 86400000) { // < 1 day: show time without seconds
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
} else if (diff < 2592000000) { // < 30 days: show date + time
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
} else { // > 30 days: show date only
return date.toLocaleDateString('sv-SE').substring(0, 10) // yyyy-MM-dd
}
}

function setDrawBtnToCancel() {
btnDraw.html('<i class="icon icon-stop"></i><span>Cancel</span>')
btnDraw.removeClass("js-chart-draw").addClass("js-chart-cancel")
Expand Down Expand Up @@ -137,10 +229,34 @@ export function quickVis(
data[i] = dataset[i][xAxisDataIndex]
}

optionXAxis = {
type: "category",
name: xAxis,
data: data,
// Check if this is a time column
const timeColumns = detectTimeColumns(columns)
const isTimeColumn = timeColumns.some(col => col.name === xAxis)

if (isTimeColumn) {
// For time columns, format the data and use time axis
const formattedData = data.map(value => {
if (isValidTimestamp(value)) {
return formatTimestamp(value, selectedTimeFormat)
}
return value
})

optionXAxis = {
type: "category",
name: xAxis,
data: formattedData,
axisLabel: {
rotate: data.length > 10 ? 45 : 0,
interval: 'auto'
}
}
} else {
optionXAxis = {
type: "category",
name: xAxis,
data: data,
}
}
} else {
optionXAxis = {}
Expand Down Expand Up @@ -228,6 +344,7 @@ export function quickVis(
setDrawBtnToCancel()
requestActive = true
chartType = chartTypePicker.selected()
selectedTimeFormat = timeFormatPicker.selected()

// check if the only change is chart type
const selectedXAxis = xAxisPicker.selected()
Expand Down Expand Up @@ -295,6 +412,29 @@ export function quickVis(

yAxisPicker.set(x.slice(1).map((item) => item.text))

// Time format picker options
const timeFormatOptions = [
{ text: "Auto", value: "auto" },
{ text: "2021-11-21 14:04:09", value: "yyyy-MM-dd HH:mm:ss" },
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value string is explicit enough so that we can use it in text as well. These numbers are arbitrary.

{ text: "14:04:09", value: "HH:mm:ss" },
{ text: "14:04", value: "HH:mm" },
{ text: "11/21 14:04", value: "MM/dd HH:mm" },
{ text: "Nov 21, 2021", value: "MMM dd, yyyy" },
{ text: "Relative (5m ago)", value: "relative" }
]
timeFormatPicker.setData(timeFormatOptions)

// Show/hide time format picker based on time column detection
const timeColumns = detectTimeColumns(columns)
const timeFormatGroup = document.querySelector('.time-format-group')
if (timeFormatGroup) {
if (timeColumns.length > 0) {
timeFormatGroup.classList.add('visible')
} else {
timeFormatGroup.classList.remove('visible')
}
}

// stash query text so that we can use this later to server for chart column values
query = data.query
clearChart()
Expand Down Expand Up @@ -322,6 +462,24 @@ export function quickVis(
echart = echarts.init(viewport, eChartsMacarons)
eventBus.subscribe(EventType.MSG_QUERY_DATASET, updatePickers)
btnDraw.click(btnDrawClick)

// Event listener for X-axis selection to toggle time format visibility
xAxisPicker.onChange = (info: any) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if i specify a format from format picker, and include a timestamp column in y-axis?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timestamp values will be displayed as data points.

if (cachedResponse && cachedResponse.columns) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time format picker is shown in the first render regardless of the contents of the x-axis and y-axis elements.
If you remove timestamp from x-axis, then the picker visibility is not responsive.
If you rely on the cached response, the visibility will depend on the previous draw, which is not expected.

const timeColumns = detectTimeColumns(cachedResponse.columns)
const isTimeColumn = timeColumns.some(col => col.name === info.value)
const timeFormatGroup = document.querySelector('.time-format-group')

if (timeFormatGroup) {
if (isTimeColumn) {
timeFormatGroup.classList.add('visible')
} else {
timeFormatGroup.classList.remove('visible')
}
}
}
}

clearChart()
}

Expand Down
15 changes: 15 additions & 0 deletions packages/web-console/src/scenes/Result/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,21 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => {
multiple
/>
</div>
<div className="form-group time-format-group">
<label>Time Format</label>
<select
id="_qvis_frm_time_format"
data-hook="chart-panel-time-format-select"
>
<option value="auto">Auto</option>
<option value="yyyy-MM-dd HH:mm:ss">2021-11-21 14:04:09</option>
<option value="HH:mm:ss">14:04:09</option>
<option value="HH:mm">14:04</option>
<option value="MM/dd HH:mm">11/21 14:04</option>
<option value="MMM dd, yyyy">Nov 21, 2021</option>
<option value="relative">Relative (5m ago)</option>
</select>
</div>
<button
className="button-primary js-chart-draw"
id="_qvis_frm_draw"
Expand Down
37 changes: 37 additions & 0 deletions packages/web-console/src/styles/_quick-vis.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,43 @@
.quick-vis-controls {
flex: 0 0 350px;
padding: 2rem 1rem;

.form-group {
margin-bottom: 1.5rem;

label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #f8f8f2;
}

select {
width: 100%;
padding: 0.5rem;
border: 1px solid #44475a;
border-radius: 4px;
background-color: #282a36;
color: #f8f8f2;
font-size: 14px;

option {
background-color: #282a36;
color: #f8f8f2;
}
}

&.time-format-group {
display: none;
opacity: 0;
transition: opacity 0.3s ease-in-out;

&.visible {
display: block;
opacity: 1;
}
}
}
}

#_qvis_frm_draw {
Expand Down