diff --git a/src/js/console/grid.js b/src/js/console/grid.js
index b8695f67f..0ac03b64c 100644
--- a/src/js/console/grid.js
+++ b/src/js/console/grid.js
@@ -47,6 +47,100 @@ const CHECK_ICON_SVG =
'' +
""
+// Rotate icon for bar/ohlc visualization toggle (screen_rotation inspired)
+const ROTATE_ICON_SVG =
+ '"
+
+// Regex to detect visualization function calls in SQL.
+// Uses word boundary to avoid matching e.g. "foobar(" or "sidebar("
+const BAR_FN_REGEX =
+ /\b(?:ohlc_bar_labels|ohlc_bar|bar|sparkline|depth_chart_labels|depth_chart)\s*\(/i
+
+function queryContainsBarFunction(sqlText) {
+ if (!sqlText) return false
+ return BAR_FN_REGEX.test(sqlText)
+}
+
+function detectVisualizationType(value) {
+ if (!value || typeof value !== "string") return null
+
+ // Step 1: depth_chart
+ // U+254E (box drawings light double dash vertical) is the spread separator
+ if (value.includes("\u254e")) {
+ return "depth_chart"
+ }
+
+ // Step 2: ohlc_bar
+ // U+2800 (braille blank) is used as padding in ohlc_bar output.
+ // U+2591 (light shade) is used for bearish candle bodies.
+ if (value.includes("\u2800") || value.includes("\u2591")) {
+ return "ohlc_bar"
+ }
+
+ let hasFullBlock = false
+ let hasFractionalBlock = false
+ let hasLowerBlock = false
+ let hasBoxHorizontal = false
+
+ for (const ch of value) {
+ const cp = ch.codePointAt(0)
+ if (cp >= 0x2589 && cp <= 0x258f) hasFractionalBlock = true
+ if (cp === 0x2588) hasFullBlock = true
+ if (cp >= 0x2581 && cp <= 0x2587) hasLowerBlock = true
+ if (cp === 0x2500) hasBoxHorizontal = true
+ }
+
+ // Bullish-only ohlc_bar candles have full blocks + box horizontal (wicks)
+ // but no fractional blocks and no lower blocks
+ if (hasFullBlock && hasBoxHorizontal && !hasFractionalBlock && !hasLowerBlock) {
+ return "ohlc_bar"
+ }
+
+ // Step 3: bar
+ if (hasFractionalBlock) return "bar"
+ if (hasFullBlock && !hasLowerBlock) return "bar"
+
+ // Step 4: sparkline
+ if (hasLowerBlock) return "sparkline"
+
+ return null
+}
+
+// Split visualization labels output into bar portion, separator, and label portion.
+// OHLC labels start with "O:", depth_chart labels start with "bb:"
+const VIZ_LABEL_REGEX = /^(.*?)([\s\u2800]+)((?:O:|bb:).*)$/
+
+function splitVizLabels(value) {
+ const m = VIZ_LABEL_REGEX.exec(value)
+ if (m) {
+ return { bar: m[1], sep: m[2], labels: m[3] }
+ }
+ return { bar: value, sep: null, labels: null }
+}
+
+// Check at least 2 out of 3 non-null values match
+function detectVisualizationFromData(dataPage, varcharColIndex) {
+ if (!dataPage || dataPage.length === 0) return null
+ let matches = 0
+ let checked = 0
+ let detectedType = null
+
+ for (let i = 0; i < dataPage.length && checked < 3; i++) {
+ const val = dataPage[i][varcharColIndex]
+ if (val === null) continue
+ checked++
+ const vtype = detectVisualizationType(val)
+ if (vtype) {
+ if (!detectedType) detectedType = vtype
+ if (vtype === detectedType) matches++
+ }
+ }
+
+ return matches >= 2 ? detectedType : null
+}
+
export function grid(rootElement, _paginationFn, id) {
const defaults = {
gridID: "qdb-grid",
@@ -65,6 +159,7 @@ export function grid(rootElement, _paginationFn, id) {
cellWidthMultiplier: 9.6,
arrayCellWidthMultiplier: 8.3,
maxCellWidthMultiplier: 0.8,
+ vizCellWidthMultiplier: 1.4,
}
const ACTIVE_CELL_CLASS = "qg-c-active"
const NAV_EVENT_ANY_VERTICAL = 0
@@ -200,6 +295,11 @@ export function grid(rootElement, _paginationFn, id) {
let initialFocusSkipped = false
+ // Bar/OHLC visualization state
+ let vizType = null // 'bar', 'ohlc_bar', or null
+ let vizRotated = false // whether the rotated (horizontal) view is active
+ let rotatedContainer
+
function getColumn(index) {
return columns[columnPositions[index]]
}
@@ -806,6 +906,16 @@ export function grid(rootElement, _paginationFn, id) {
return lines.join("\n")
}
+ // Get cell text values from a row, using data-viz-raw for viz cells
+ function getCellTexts(row) {
+ const texts = []
+ for (const cell of row.childNodes) {
+ const raw = cell.getAttribute && cell.getAttribute("data-viz-raw")
+ texts.push(raw || cell.innerText || "")
+ }
+ return texts
+ }
+
function getResultSetGridAsMarkdown() {
// first, we get a starting width, based on the column header
// this is necessary to get a properly formatted, pipe-aligned table
@@ -824,7 +934,7 @@ export function grid(rootElement, _paginationFn, id) {
// then we loop over our rows to check how wide it needs to be according to the data
for (const row of rows) {
- let row_splits = row.innerText.split(/\n/)
+ let row_splits = getCellTexts(row)
for (const [i, row_split] of row_splits.entries()) {
column_widths[i] = Math.max(column_widths[i], row_split.length)
}
@@ -852,7 +962,7 @@ export function grid(rootElement, _paginationFn, id) {
let data_rows_builder = []
for (const row of rows) {
- let row_splits = row.innerText.split(/\n/)
+ let row_splits = getCellTexts(row)
// sometimes we get arrays like this: [""] in rows
// this usually happens at the end of the result set
@@ -1013,6 +1123,28 @@ export function grid(rootElement, _paginationFn, id) {
addClass(hNameRow, "qg-header-name-row")
hNameRow.append(hName, copyBtn)
+ // Add rotate icon only for 2-column (timestamp + varchar) bar/ohlc results
+ const canRotate = (vizType === "ohlc_bar" || vizType === "bar") && findVizColumns()
+ if (canRotate && c.type.toUpperCase() === "VARCHAR") {
+ const rotateBtn = document.createElement("div")
+ addClass(rotateBtn, "qg-header-rotate")
+ if (vizRotated) {
+ addClass(rotateBtn, "qg-header-rotate-active")
+ }
+ rotateBtn.innerHTML = ROTATE_ICON_SVG
+ rotateBtn.onclick = function (e) {
+ e.stopPropagation()
+ vizRotated = !vizRotated
+ if (vizRotated) {
+ addClass(rotateBtn, "qg-header-rotate-active")
+ } else {
+ removeClass(rotateBtn, "qg-header-rotate-active")
+ }
+ triggerEvent("viz.rotate", { rotated: vizRotated, type: vizType })
+ }
+ hNameRow.append(rotateBtn)
+ }
+
h.append(hysteresis, hBorderSpan)
h.append(hNameRow, hType)
@@ -1080,6 +1212,15 @@ export function grid(rootElement, _paginationFn, id) {
layoutStoreColumnSetSha256 = undefined
panelLeftWidth = 0
deferVisualsCompute = false
+ vizType = null
+ vizRotated = false
+ if (rotatedContainer) {
+ rotatedContainer.style.display = "none"
+ rotatedContainer.innerHTML = ""
+ }
+ // Restore normal grid elements that showRotatedView may have hidden
+ if (header) header.style.display = ""
+ if (viewport) viewport.style.display = ""
setFreezeLeft0(0)
enableHover()
}
@@ -1185,10 +1326,35 @@ export function grid(rootElement, _paginationFn, id) {
if (cellData !== null) {
const layoutEntry = getLayoutEntry()
const columnWidth = layoutEntry.deviants[column.name] ?? null
- cell.textContent = unescapeHtml(
+ const displayValue = unescapeHtml(
getDisplayedCellValue(column, cellData, columnWidth),
)
+ // Apply coloring and monospace font for visualization columns
+ if (
+ vizType &&
+ column.type.toUpperCase() === "VARCHAR" &&
+ typeof displayValue === "string"
+ ) {
+ if (vizType === "ohlc_bar") {
+ renderOhlcCell(cell, displayValue)
+ } else if (vizType === "depth_chart") {
+ renderDepthChartCell(cell, displayValue)
+ } else {
+ // bar and sparkline: monospace font, preserve whitespace
+ cell.textContent = displayValue
+ cell.style.whiteSpace = "pre"
+ addClass(cell, "qg-ohlc-cell")
+ cell.title = ""
+ }
+ } else {
+ cell.textContent = displayValue
+ cell.title = ""
+ cell.style.whiteSpace = ""
+ removeClass(cell, "qg-ohlc-cell")
+ cell.removeAttribute("data-viz-raw")
+ }
+
cell.classList.remove("qg-null")
if (column.type === "ARRAY") {
@@ -1200,6 +1366,168 @@ export function grid(rootElement, _paginationFn, id) {
}
}
+ // Regex to match O:, H:, L:, C: prefixes in labels
+ // Parse "O:1.234 H:5.678 L:0.123 C:4.567" into key-value pairs
+ const OHLC_PAIR_REGEX = /([OHLC]):(\S+)/g
+
+ function renderOhlcLabels(container, labels) {
+ let m
+ OHLC_PAIR_REGEX.lastIndex = 0
+ while ((m = OHLC_PAIR_REGEX.exec(labels)) !== null) {
+ const pair = document.createElement("span")
+ pair.className = "qg-ohlc-label-pair"
+
+ const keySpan = document.createElement("span")
+ keySpan.className = "qg-ohlc-label-key"
+ keySpan.textContent = m[1] + ":"
+ pair.appendChild(keySpan)
+
+ pair.appendChild(document.createTextNode(m[2]))
+ container.appendChild(pair)
+ }
+ }
+
+ function renderOhlcCell(cell, value, stripLabels) {
+ cell.textContent = ""
+ cell.removeAttribute("title")
+ cell.style.whiteSpace = "pre"
+ addClass(cell, "qg-ohlc-cell")
+ // Store raw value for markdown export
+ cell.setAttribute("data-viz-raw", value)
+
+ // Always split bar from labels
+ const parts = splitVizLabels(value)
+ const textToRender = parts.bar
+
+ if (stripLabels) {
+ // In rotated view: show labels as tooltip
+ if (parts.labels) {
+ cell.title = parts.labels
+ }
+ }
+
+ let currentRun = ""
+ let currentType = null // null, 'bullish', 'bearish'
+
+ for (const ch of textToRender) {
+ const cp = ch.codePointAt(0)
+ let charType = null
+ if (cp === 0x2588) {
+ charType = "bullish"
+ } else if (cp === 0x2591) {
+ charType = "bearish"
+ }
+
+ if (charType !== currentType) {
+ if (currentRun) {
+ if (currentType) {
+ const span = document.createElement("span")
+ span.className =
+ currentType === "bullish"
+ ? "qg-ohlc-bullish"
+ : "qg-ohlc-bearish"
+ span.textContent = currentRun
+ cell.appendChild(span)
+ } else {
+ cell.appendChild(document.createTextNode(currentRun))
+ }
+ }
+ currentRun = ch
+ currentType = charType
+ } else {
+ currentRun += ch
+ }
+ }
+
+ // Flush remaining
+ if (currentRun) {
+ if (currentType) {
+ const span = document.createElement("span")
+ span.className =
+ currentType === "bullish" ? "qg-ohlc-bullish" : "qg-ohlc-bearish"
+ span.textContent = currentRun
+ cell.appendChild(span)
+ } else {
+ cell.appendChild(document.createTextNode(currentRun))
+ }
+ }
+
+ // In normal view, render labels right-aligned in a floated container
+ if (!stripLabels && parts.labels) {
+ const labelsContainer = document.createElement("span")
+ labelsContainer.className = "qg-ohlc-labels"
+ renderOhlcLabels(labelsContainer, parts.labels)
+ cell.insertBefore(labelsContainer, cell.firstChild)
+ }
+ }
+
+ // Parse "bb:73339 ba:80708 tb:4.24B ta:4.42B" into key-value pairs
+ const DEPTH_PAIR_REGEX = /(bb|ba|tb|ta):(\S+)/g
+
+ function renderDepthChartLabels(container, labels) {
+ let m
+ DEPTH_PAIR_REGEX.lastIndex = 0
+ while ((m = DEPTH_PAIR_REGEX.exec(labels)) !== null) {
+ const pair = document.createElement("span")
+ pair.className = "qg-ohlc-label-pair"
+
+ const keySpan = document.createElement("span")
+ keySpan.className = "qg-ohlc-label-key"
+ keySpan.textContent = m[1] + ":"
+ pair.appendChild(keySpan)
+
+ pair.appendChild(document.createTextNode(m[2]))
+ container.appendChild(pair)
+ }
+ }
+
+ function renderDepthChartCell(cell, value) {
+ cell.textContent = ""
+ cell.removeAttribute("title")
+ cell.style.whiteSpace = "pre"
+ addClass(cell, "qg-ohlc-cell")
+ // Store raw value for markdown export
+ cell.setAttribute("data-viz-raw", value)
+
+ const parts = splitVizLabels(value)
+ const barPart = parts.bar
+
+ // Split on spread character U+254E
+ const spreadIdx = barPart.indexOf("\u254e")
+ if (spreadIdx === -1) {
+ // No spread char found, render as plain
+ cell.textContent = barPart
+ } else {
+ const bidPart = barPart.slice(0, spreadIdx)
+ const askPart = barPart.slice(spreadIdx + 1)
+
+ if (bidPart) {
+ const bidSpan = document.createElement("span")
+ bidSpan.className = "qg-ohlc-bullish"
+ bidSpan.textContent = bidPart
+ cell.appendChild(bidSpan)
+ }
+
+ // Spread character in default color
+ cell.appendChild(document.createTextNode("\u254e"))
+
+ if (askPart) {
+ const askSpan = document.createElement("span")
+ askSpan.className = "qg-ohlc-bearish"
+ askSpan.textContent = askPart
+ cell.appendChild(askSpan)
+ }
+ }
+
+ // Render labels right-aligned
+ if (parts.labels) {
+ const labelsContainer = document.createElement("span")
+ labelsContainer.className = "qg-ohlc-labels"
+ renderDepthChartLabels(labelsContainer, parts.labels)
+ cell.insertBefore(labelsContainer, cell.firstChild)
+ }
+ }
+
function setCellDataAndAttributes(row, rowData, columnIndex) {
const cell = row.childNodes[columnIndex % visColumnCount]
configureCell(cell, columnIndex)
@@ -1675,6 +2003,11 @@ export function grid(rootElement, _paginationFn, id) {
}
function render() {
+ // Skip normal grid rendering when rotated view is active
+ if (vizRotated && vizType) {
+ return
+ }
+
if (noData()) {
renderColumns()
renderRows(0)
@@ -2002,6 +2335,37 @@ export function grid(rootElement, _paginationFn, id) {
}
}
+ function isVizColumn(columnIndex) {
+ return vizType && getColumn(columnIndex).type.toUpperCase() === "VARCHAR"
+ }
+
+ // Find timestamp and varchar column indices for a 2-column viz result
+ function findVizColumns() {
+ if (columnCount !== 2) return null
+ let tsCol = -1
+ let varcharCol = -1
+ for (let i = 0; i < 2; i++) {
+ const t = columns[i].type.toUpperCase()
+ if (t === "TIMESTAMP" || t === "TIMESTAMP_NS") tsCol = i
+ else if (t === "VARCHAR") varcharCol = i
+ }
+ if (tsCol === -1 || varcharCol === -1) return null
+ return { tsCol, varcharCol }
+ }
+
+ // Width calculation for viz columns. Unicode block and braille characters
+ // render wider than the standard cellWidthMultiplier assumes.
+ function getVizCellWidth(str) {
+ return Math.max(
+ defaults.minColumnWidth,
+ Math.ceil(
+ str.length *
+ defaults.cellWidthMultiplier *
+ defaults.vizCellWidthMultiplier,
+ ),
+ )
+ }
+
function computeColumnWidthsFromData() {
const maxWidth =
viewport.getBoundingClientRect().width * defaults.maxCellWidthMultiplier
@@ -2014,6 +2378,7 @@ export function grid(rootElement, _paginationFn, id) {
// a little inefficient, but lets traverse
for (let i = 0; i < columnCount; i++) {
let w = getColumnWidth(i)
+ const uncapped = isVizColumn(i)
// Traverse the page to find the widest value in the column, set the width to the widest value
for (let j = 0; j < (dataPage?.length ?? 0); j++) {
@@ -2023,12 +2388,15 @@ export function grid(rootElement, _paginationFn, id) {
str = "null"
} else if (Array.isArray(value)) {
const arrayColumnWidth = getArrayColumnWidth(value, i)
- w = Math.min(maxWidth, Math.max(w, arrayColumnWidth))
+ w = uncapped
+ ? Math.max(w, arrayColumnWidth)
+ : Math.min(maxWidth, Math.max(w, arrayColumnWidth))
continue
} else {
str = value.toString()
}
- w = Math.min(maxWidth, Math.max(w, getCellWidth(str.length)))
+ const cellW = uncapped ? getVizCellWidth(str) : getCellWidth(str.length)
+ w = uncapped ? Math.max(w, cellW) : Math.min(maxWidth, Math.max(w, cellW))
}
offsets[i] = offset
offset += w
@@ -2062,7 +2430,8 @@ export function grid(rootElement, _paginationFn, id) {
// this assumes that initial width has been set to the width of the header
let w
- if (deviants) {
+ const uncapped = isVizColumn(i)
+ if (deviants && !uncapped) {
w = deviants[getColumn(i).name]
}
@@ -2076,12 +2445,15 @@ export function grid(rootElement, _paginationFn, id) {
str = "null"
} else if (getColumn(i).type === "ARRAY") {
const arrayColumnWidth = getArrayColumnWidth(value, i)
- w = Math.min(maxWidth, Math.max(w, arrayColumnWidth))
+ w = uncapped
+ ? Math.max(w, arrayColumnWidth)
+ : Math.min(maxWidth, Math.max(w, arrayColumnWidth))
continue
} else {
str = value.toString()
}
- w = Math.min(maxWidth, Math.max(w, getCellWidth(str.length)))
+ const cellW = uncapped ? getVizCellWidth(str) : getCellWidth(str.length)
+ w = uncapped ? Math.max(w, cellW) : Math.min(maxWidth, Math.max(w, cellW))
}
} else {
columnOffsets[i] = offset
@@ -2117,6 +2489,8 @@ export function grid(rootElement, _paginationFn, id) {
}
function setDataPart1(_data) {
+ const prevVizRotated = vizRotated
+ const prevSql = sql
clear()
sql = _data.query
data.push(_data.dataset)
@@ -2126,6 +2500,37 @@ export function grid(rootElement, _paginationFn, id) {
ogTimestampIndex = _data.timestamp
timestampIndex = ogTimestampIndex
rowCount = _data.count
+
+ // Detect bar/ohlc/sparkline visualization
+ const isBarQuery = queryContainsBarFunction(sql)
+ if (isBarQuery && data[0]) {
+ // For 2-column results (timestamp + varchar), use strict detection
+ const vizCols = findVizColumns()
+ if (vizCols) {
+ vizType = detectVisualizationFromData(data[0], vizCols.varcharCol)
+ } else {
+ // For multi-column results, check each VARCHAR column
+ for (let i = 0; i < columnCount; i++) {
+ if (columns[i].type.toUpperCase() === "VARCHAR") {
+ const detected = detectVisualizationFromData(data[0], i)
+ if (detected) {
+ vizType = detected
+ break
+ }
+ }
+ }
+ }
+ }
+
+ // Preserve rotation state only if the new result can actually rotate
+ // (bar/ohlc type with exactly 2 columns: timestamp + varchar)
+ const canRotate =
+ (vizType === "ohlc_bar" || vizType === "bar") && findVizColumns()
+ if (canRotate && queryContainsBarFunction(prevSql)) {
+ vizRotated = prevVizRotated
+ }
+ // Otherwise vizRotated stays false (reset by clear())
+
computeHeaderWidths()
computeVisibleAreaAfterDataIsSet()
}
@@ -2181,6 +2586,14 @@ export function grid(rootElement, _paginationFn, id) {
// we can assume that viewport already rendered top left corner of the data set
focusTopLeftCell()
setBothRowsActive()
+
+ // If vizRotated was preserved from a previous bar/ohlc query, show rotated view
+ if (vizRotated && vizType) {
+ showRotatedView()
+ } else if (rotatedContainer) {
+ rotatedContainer.style.display = "none"
+ rotatedContainer.innerHTML = ""
+ }
}
function showPanelLeft() {
@@ -2295,6 +2708,136 @@ export function grid(rootElement, _paginationFn, id) {
}
}
+ function showRotatedView() {
+ // Keep header visible so the rotate icon remains clickable
+ viewport.style.display = "none"
+ panelLeft.style.display = "none"
+ panelLeftGhost.style.display = "none"
+ panelLeftSnapGhost.style.display = "none"
+ panelLeftInitialHysteresis.style.display = "none"
+ rotatedContainer.style.display = "flex"
+ renderRotatedView()
+ }
+
+ function hideRotatedView() {
+ viewport.style.display = ""
+ rotatedContainer.style.display = "none"
+ rotatedContainer.innerHTML = ""
+ render()
+ }
+
+ function renderRotatedView() {
+ rotatedContainer.innerHTML = ""
+ if (!data || data.length === 0) return
+
+ const vizCols = findVizColumns()
+ if (!vizCols) return
+ const tsColIndex = vizCols.tsCol
+ const varcharColIndex = vizCols.varcharCol
+
+ // Collect all rows from all data pages
+ const allRows = []
+ for (let p = 0; p < data.length; p++) {
+ if (data[p]) {
+ for (let r = 0; r < data[p].length; r++) {
+ allRows.push(data[p][r])
+ }
+ }
+ }
+ if (allRows.length === 0) return
+
+ // Layout: each original row becomes a visual column.
+ // The bar string is kept intact and rotated via writing-mode so the
+ // horizontal bar becomes vertical. Time flows left-to-right.
+
+ const scrollArea = document.createElement("div")
+ addClass(scrollArea, "qg-rotated-scroll")
+
+ const barCells = []
+
+ for (let ri = 0; ri < allRows.length; ri++) {
+ const row = allRows[ri]
+ const colDiv = document.createElement("div")
+ addClass(colDiv, "qg-rotated-col")
+
+ // Bar cell: the entire bar string, rotated via CSS
+ const barCell = document.createElement("div")
+ addClass(barCell, "qg-rotated-bar")
+ const barVal = row[varcharColIndex]
+ if (barVal !== null) {
+ // Strip labels before reversing, show as tooltip
+ const parts = splitVizLabels(barVal)
+ if (parts.labels) {
+ colDiv.title = parts.labels
+ }
+ // Reverse the bar string so that in vertical-rl writing mode,
+ // char[0] (low price) is at the bottom and char[last] (high price)
+ // is at the top, matching a traditional chart orientation.
+ const reversed = [...parts.bar].reverse().join("")
+ if (vizType === "ohlc_bar") {
+ renderOhlcCell(barCell, reversed)
+ } else {
+ // For bar charts, replace left fractional blocks (U+2589-U+258F)
+ // in rotated view: >= 1/2 becomes full block, < 1/2 becomes light
+ // shade to suggest a partial fill without rendering artifacts
+ let cleaned = ""
+ for (const ch of reversed) {
+ const cp = ch.codePointAt(0)
+ if (cp >= 0x2589 && cp <= 0x258c) {
+ // 7/8, 3/4, 5/8, 1/2 -> full block
+ cleaned += "\u2588"
+ } else if (cp >= 0x258d && cp <= 0x258f) {
+ // 3/8, 1/4, 1/8 -> light shade
+ cleaned += "\u2591"
+ } else {
+ cleaned += ch
+ }
+ }
+ barCell.textContent = cleaned
+ }
+ }
+ barCells.push(barCell)
+ colDiv.appendChild(barCell)
+
+ // Timestamp label at bottom - time + date on two lines
+ const tsCell = document.createElement("div")
+ addClass(tsCell, "qg-rotated-ts")
+ const tsVal = row[tsColIndex]
+ if (tsVal !== null) {
+ const ts = tsVal.toString()
+ const timeMatch = ts.match(/T(\d{2}:\d{2})/)
+ const dateMatch = ts.match(/^(\d{4}-\d{2}-\d{2})/)
+ const timeLine = document.createElement("div")
+ timeLine.textContent = timeMatch ? timeMatch[1] : ts.slice(11, 16)
+ tsCell.appendChild(timeLine)
+ if (dateMatch) {
+ const dateLine = document.createElement("div")
+ addClass(dateLine, "qg-rotated-ts-date")
+ dateLine.textContent = dateMatch[1]
+ tsCell.appendChild(dateLine)
+ }
+ tsCell.title = ts
+ }
+ colDiv.appendChild(tsCell)
+
+ scrollArea.appendChild(colDiv)
+ }
+
+ rotatedContainer.appendChild(scrollArea)
+
+ // After DOM insertion, compute available height and cap bars to 80%
+ requestAnimationFrame(function () {
+ const containerH = rotatedContainer.getBoundingClientRect().height
+ // Reserve space for timestamps (~35px) and top gap
+ const tsHeight = 35
+ const availableH = containerH - tsHeight
+ const maxBarH = Math.floor(availableH * 0.8)
+ for (const barCell of barCells) {
+ barCell.style.maxHeight = maxBarH + "px"
+ }
+ })
+ }
+
function triggerEvent(eventName, data) {
grid.dispatchEvent(new CustomEvent(eventName, { detail: data }))
}
@@ -2377,6 +2920,10 @@ export function grid(rootElement, _paginationFn, id) {
panelLeftInitialHysteresis.onmousemove = colFreezeMouseMoveGhostHandle
panelLeftInitialHysteresis.onmousedown = colFreezeMouseDown
+ rotatedContainer = document.createElement("div")
+ rotatedContainer.className = "qg-rotated-container"
+ rotatedContainer.style.display = "none"
+
grid.append(
header,
viewport,
@@ -2384,7 +2931,17 @@ export function grid(rootElement, _paginationFn, id) {
panelLeftGhost,
panelLeftSnapGhost,
panelLeftInitialHysteresis,
+ rotatedContainer,
)
+
+ // Listen for rotation toggle
+ grid.addEventListener("viz.rotate", function (e) {
+ if (e.detail.rotated) {
+ showRotatedView()
+ } else {
+ hideRotatedView()
+ }
+ })
// when grid is navigated via keyboard, mouse hover is disabled
// to not confuse user. Hover is then re-enabled on mouse move
grid.onmousemove = enableHover
diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss
index c8e4365e2..b3928fe6e 100644
--- a/src/styles/_grid.scss
+++ b/src/styles/_grid.scss
@@ -154,6 +154,52 @@ $drag-handle-margin: 2px;
display: flex;
}
+.qg-header-rotate {
+ display: flex;
+ padding: 2px;
+ cursor: pointer;
+ color: #bbb;
+ border-radius: 3px;
+ line-height: 0;
+ flex-shrink: 0;
+
+ &:hover {
+ color: #f8f8f2;
+ }
+}
+
+.qg-header-rotate-active {
+ color: #8be9fd;
+
+ &:hover {
+ color: #8be9fd;
+ }
+}
+
+.qg-ohlc-bullish {
+ color: #50fa7b;
+}
+
+.qg-ohlc-bearish {
+ color: #ff5555;
+}
+
+.qg-ohlc-cell {
+ font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.qg-ohlc-labels {
+ float: right;
+}
+
+.qg-ohlc-label-pair {
+ padding-left: 1ch;
+}
+
+.qg-ohlc-label-key {
+ color: #ffb86c;
+}
+
.qg-header-border {
position: absolute;
margin-top: 10px;
@@ -363,3 +409,62 @@ $top-shadow: 0 2px 5px 0 rgba(23, 23, 23, 0.86);
margin-left: -2px;
cursor: grab;
}
+
+// Rotated (horizontal) bar/ohlc visualization view
+.qg-rotated-container {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: #282a36;
+}
+
+.qg-rotated-scroll {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ overflow-x: auto;
+ overflow-y: hidden;
+ height: 100%;
+}
+
+.qg-rotated-col {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: flex-end;
+ max-height: 100%;
+ border-right: thin dotted #44475a;
+}
+
+.qg-rotated-bar {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ writing-mode: vertical-rl;
+ white-space: pre;
+ font-family: Menlo, Monaco, Consolas, "Liberation Mono",
+ "Courier New", monospace;
+ color: #f8f8f2;
+ line-height: 1;
+ padding: 0 0 4px 0;
+}
+
+.qg-rotated-ts {
+ background: #21222c;
+ color: #50fa7b;
+ font-size: 10px;
+ padding: 4px 2px;
+ white-space: nowrap;
+ border-top: 1px solid #44475a;
+ text-align: center;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ overflow: hidden;
+ line-height: 1.3;
+}
+
+.qg-rotated-ts-date {
+ color: #bbbbbb;
+ font-size: 9px;
+}