From 3171fe6d9ae0b8bd7ca1df77e2e4210cf38a18fd Mon Sep 17 00:00:00 2001 From: javier Date: Sun, 26 Apr 2026 14:16:16 +0200 Subject: [PATCH 1/9] feat: add rotated visualization for bar/ohlc_bar query results When a query uses bar(), ohlc_bar(), or ohlc_bar_labels() and returns exactly two columns (timestamp + varchar), a rotate icon appears in the varchar column header. Clicking it toggles a transposed view where time flows left-to-right with horizontal scroll, and bars render vertically like a traditional OHLC chart. - Detect visualization type via Unicode heuristic (2/3 rows must match) - Only run heuristic when query text contains the function calls - OHLC coloring: bullish (U+2588) in green, bearish (U+2591) in red - Rotation state preserved across consecutive bar/ohlc queries - Rotation resets when a non-bar query is executed --- src/js/console/grid.js | 333 ++++++++++++++++++++++++++++++++++++++++- src/styles/_grid.scss | 90 +++++++++++ 2 files changed, 422 insertions(+), 1 deletion(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index b8695f67f..14efea72e 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -47,6 +47,70 @@ const CHECK_ICON_SVG = '' + "" +// Rotate icon for bar/ohlc visualization toggle (screen_rotation inspired) +const ROTATE_ICON_SVG = + '' + + '' + + "" + +// Regex to detect bar/ohlc function calls in SQL +const BAR_FN_REGEX = /(?:ohlc_bar_labels|ohlc_bar|bar)\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: ohlc_bar + if (value.includes("\u2800") || value.includes("\u2591")) { + return "ohlc_bar" + } + + let hasFullBlock = false + let hasFractionalBlock = false + let hasLowerBlock = 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 + } + + // Step 2: bar + if (hasFractionalBlock) return "bar" + if (hasFullBlock && !hasLowerBlock) return "bar" + + // Step 3: sparkline + if (hasLowerBlock) return "sparkline" + + return 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", @@ -200,6 +264,10 @@ 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 + function getColumn(index) { return columns[columnPositions[index]] } @@ -1013,6 +1081,22 @@ export function grid(rootElement, _paginationFn, id) { addClass(hNameRow, "qg-header-name-row") hNameRow.append(hName, copyBtn) + // Add rotate icon for bar/ohlc visualization columns + if (vizType && 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 + triggerEvent("viz.rotate", { rotated: vizRotated, type: vizType }) + } + hNameRow.append(rotateBtn) + } + h.append(hysteresis, hBorderSpan) h.append(hNameRow, hType) @@ -1080,6 +1164,14 @@ export function grid(rootElement, _paginationFn, id) { layoutStoreColumnSetSha256 = undefined panelLeftWidth = 0 deferVisualsCompute = false + vizType = null + 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 +1277,21 @@ 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 OHLC coloring for ohlc_bar visualization columns + if ( + vizType === "ohlc_bar" && + column.type.toUpperCase() === "VARCHAR" && + typeof displayValue === "string" + ) { + renderOhlcCell(cell, displayValue) + } else { + cell.textContent = displayValue + } + cell.classList.remove("qg-null") if (column.type === "ARRAY") { @@ -1200,6 +1303,55 @@ export function grid(rootElement, _paginationFn, id) { } } + function renderOhlcCell(cell, value) { + cell.textContent = "" + let currentRun = "" + let currentType = null // null, 'bullish', 'bearish' + + for (const ch of value) { + 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)) + } + } + } + function setCellDataAndAttributes(row, rowData, columnIndex) { const cell = row.childNodes[columnIndex % visColumnCount] configureCell(cell, columnIndex) @@ -2117,6 +2269,8 @@ export function grid(rootElement, _paginationFn, id) { } function setDataPart1(_data) { + const prevVizRotated = vizRotated + const prevQueryWasBar = queryContainsBarFunction(sql) clear() sql = _data.query data.push(_data.dataset) @@ -2126,6 +2280,32 @@ export function grid(rootElement, _paginationFn, id) { ogTimestampIndex = _data.timestamp timestampIndex = ogTimestampIndex rowCount = _data.count + + // Detect bar/ohlc visualization + const isBarQuery = queryContainsBarFunction(sql) + if (isBarQuery && columnCount === 2) { + let tsColIndex = -1 + let varcharColIndex = -1 + for (let i = 0; i < 2; i++) { + const t = columns[i].type.toUpperCase() + if (t === "TIMESTAMP" || t === "TIMESTAMP_NS") { + tsColIndex = i + } else if (t === "VARCHAR") { + varcharColIndex = i + } + } + if (tsColIndex !== -1 && varcharColIndex !== -1 && data[0]) { + vizType = detectVisualizationFromData(data[0], varcharColIndex) + } + } + + // Preserve rotation state between bar/ohlc queries, reset otherwise + if (isBarQuery && prevQueryWasBar && vizType) { + vizRotated = prevVizRotated + } else if (!isBarQuery) { + vizRotated = false + } + computeHeaderWidths() computeVisibleAreaAfterDataIsSet() } @@ -2181,6 +2361,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 +2483,135 @@ export function grid(rootElement, _paginationFn, id) { } } + let rotatedContainer + + 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 || columnCount !== 2) return + + // Find which column is timestamp and which is varchar + let tsColIndex = -1 + let varcharColIndex = -1 + for (let i = 0; i < 2; i++) { + const t = columns[i].type.toUpperCase() + if (t === "TIMESTAMP" || t === "TIMESTAMP_NS") tsColIndex = i + else if (t === "VARCHAR") varcharColIndex = i + } + if (tsColIndex === -1 || varcharColIndex === -1) return + + // 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. + + // Find the max bar length to compute proportional heights + let maxBarLen = 0 + for (let i = 0; i < allRows.length; i++) { + const val = allRows[i][varcharColIndex] + if (val !== null) { + const len = [...val].length + if (len > maxBarLen) maxBarLen = len + } + } + + 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) { + if (vizType === "ohlc_bar") { + renderOhlcCell(barCell, barVal) + } else { + // For bar charts, replace fractional block chars (U+2589-U+258F) + // with full blocks - fractional blocks don't render well vertically + let cleaned = "" + for (const ch of barVal) { + const cp = ch.codePointAt(0) + cleaned += cp >= 0x2589 && cp <= 0x258f ? "\u2588" : 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 +2694,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 +2705,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..90eddfb49 100644 --- a/src/styles/_grid.scss +++ b/src/styles/_grid.scss @@ -154,6 +154,36 @@ $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-header-border { position: absolute; margin-top: 10px; @@ -363,3 +393,63 @@ $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; + direction: rtl; + white-space: pre; + font-family: "Open Sans", 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; +} From facbaa4f94c582ff4b62123b361ccf5e6fc51ec4 Mon Sep 17 00:00:00 2001 From: javier Date: Sun, 26 Apr 2026 14:25:27 +0200 Subject: [PATCH 2/9] fix: strip ohlc_bar_labels text and improve viz state reset - Strip OHLC labels (O/H/L/C values) from ohlc_bar_labels output, show them as tooltip on hover instead - Reset vizRotated in clear() to prevent stale state between queries - Always clear cell title on re-render to avoid tooltip leaks --- src/js/console/grid.js | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 14efea72e..a1870b8e6 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -90,6 +90,18 @@ function detectVisualizationType(value) { return null } +// Split ohlc_bar_labels output into bar portion and label portion. +// Labels start with " O:" (space + O + colon) after the bar characters. +const OHLC_LABEL_REGEX = /^(.*?)\s+(O:.*)$/ + +function splitOhlcLabels(value) { + const m = OHLC_LABEL_REGEX.exec(value) + if (m) { + return { bar: m[1], labels: m[2] } + } + return { bar: value, labels: null } +} + // Check at least 2 out of 3 non-null values match function detectVisualizationFromData(dataPage, varcharColIndex) { if (!dataPage || dataPage.length === 0) return null @@ -1165,6 +1177,7 @@ export function grid(rootElement, _paginationFn, id) { panelLeftWidth = 0 deferVisualsCompute = false vizType = null + vizRotated = false if (rotatedContainer) { rotatedContainer.style.display = "none" rotatedContainer.innerHTML = "" @@ -1305,10 +1318,19 @@ export function grid(rootElement, _paginationFn, id) { function renderOhlcCell(cell, value) { cell.textContent = "" + cell.title = "" + + // Strip labels from ohlc_bar_labels output, show as tooltip + const parts = splitOhlcLabels(value) + const barPart = parts.bar + if (parts.labels) { + cell.title = parts.labels + } + let currentRun = "" let currentType = null // null, 'bullish', 'bearish' - for (const ch of value) { + for (const ch of barPart) { const cp = ch.codePointAt(0) let charType = null if (cp === 0x2588) { @@ -2270,7 +2292,7 @@ export function grid(rootElement, _paginationFn, id) { function setDataPart1(_data) { const prevVizRotated = vizRotated - const prevQueryWasBar = queryContainsBarFunction(sql) + const prevSql = sql clear() sql = _data.query data.push(_data.dataset) @@ -2299,12 +2321,11 @@ export function grid(rootElement, _paginationFn, id) { } } - // Preserve rotation state between bar/ohlc queries, reset otherwise - if (isBarQuery && prevQueryWasBar && vizType) { + // Preserve rotation state between consecutive bar/ohlc queries + if (isBarQuery && queryContainsBarFunction(prevSql) && vizType) { vizRotated = prevVizRotated - } else if (!isBarQuery) { - vizRotated = false } + // Otherwise vizRotated stays false (reset by clear()) computeHeaderWidths() computeVisibleAreaAfterDataIsSet() From f666c4e5153d30377f6c77966df2ddc370b28261 Mon Sep 17 00:00:00 2001 From: javier Date: Mon, 27 Apr 2026 17:44:33 +0200 Subject: [PATCH 3/9] fix: prevent viz column truncation and clean up review issues - Add word boundary to BAR_FN_REGEX to avoid false positives - Remove maxWidth cap on viz columns so bars are never truncated - Use actual glyph-aware width calculation for Unicode block chars - Strip ohlc_bar_labels to bar-only for column width measurement - Skip stored layout deviants for viz columns - Extract findVizColumns() helper to deduplicate column detection - Move vizCellWidthMultiplier to defaults, remove magic number - Clear stale cell.title in non-OHLC setCellData path - Remove dead maxBarLen computation in renderRotatedView --- src/js/console/grid.js | 111 +++++++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index a1870b8e6..2049014fe 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -53,8 +53,9 @@ const ROTATE_ICON_SVG = '' + "" -// Regex to detect bar/ohlc function calls in SQL -const BAR_FN_REGEX = /(?:ohlc_bar_labels|ohlc_bar|bar)\s*\(/i +// Regex to detect bar/ohlc 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)\s*\(/i function queryContainsBarFunction(sqlText) { if (!sqlText) return false @@ -141,6 +142,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 @@ -279,6 +281,7 @@ export function grid(rootElement, _paginationFn, id) { // 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]] @@ -1303,6 +1306,7 @@ export function grid(rootElement, _paginationFn, id) { renderOhlcCell(cell, displayValue) } else { cell.textContent = displayValue + cell.title = "" } cell.classList.remove("qg-null") @@ -2176,6 +2180,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 @@ -2188,6 +2223,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++) { @@ -2197,12 +2233,21 @@ 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() + // For ohlc_bar_labels, measure only the bar portion + if (uncapped && vizType === "ohlc_bar") { + const parts = splitOhlcLabels(str) + str = parts.bar + } } - w = Math.min(maxWidth, Math.max(w, getCellWidth(str.length))) + // For viz columns, measure actual pixel width to handle wide Unicode glyphs + 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 @@ -2236,7 +2281,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] } @@ -2250,12 +2296,19 @@ 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() + if (uncapped && vizType === "ohlc_bar") { + const parts = splitOhlcLabels(str) + str = parts.bar + } } - 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 @@ -2305,19 +2358,10 @@ export function grid(rootElement, _paginationFn, id) { // Detect bar/ohlc visualization const isBarQuery = queryContainsBarFunction(sql) - if (isBarQuery && columnCount === 2) { - let tsColIndex = -1 - let varcharColIndex = -1 - for (let i = 0; i < 2; i++) { - const t = columns[i].type.toUpperCase() - if (t === "TIMESTAMP" || t === "TIMESTAMP_NS") { - tsColIndex = i - } else if (t === "VARCHAR") { - varcharColIndex = i - } - } - if (tsColIndex !== -1 && varcharColIndex !== -1 && data[0]) { - vizType = detectVisualizationFromData(data[0], varcharColIndex) + if (isBarQuery) { + const vizCols = findVizColumns() + if (vizCols && data[0]) { + vizType = detectVisualizationFromData(data[0], vizCols.varcharCol) } } @@ -2504,8 +2548,6 @@ export function grid(rootElement, _paginationFn, id) { } } - let rotatedContainer - function showRotatedView() { // Keep header visible so the rotate icon remains clickable viewport.style.display = "none" @@ -2526,17 +2568,12 @@ export function grid(rootElement, _paginationFn, id) { function renderRotatedView() { rotatedContainer.innerHTML = "" - if (!data || data.length === 0 || columnCount !== 2) return + if (!data || data.length === 0) return - // Find which column is timestamp and which is varchar - let tsColIndex = -1 - let varcharColIndex = -1 - for (let i = 0; i < 2; i++) { - const t = columns[i].type.toUpperCase() - if (t === "TIMESTAMP" || t === "TIMESTAMP_NS") tsColIndex = i - else if (t === "VARCHAR") varcharColIndex = i - } - if (tsColIndex === -1 || varcharColIndex === -1) return + const vizCols = findVizColumns() + if (!vizCols) return + const tsColIndex = vizCols.tsCol + const varcharColIndex = vizCols.varcharCol // Collect all rows from all data pages const allRows = [] @@ -2553,16 +2590,6 @@ export function grid(rootElement, _paginationFn, id) { // The bar string is kept intact and rotated via writing-mode so the // horizontal bar becomes vertical. Time flows left-to-right. - // Find the max bar length to compute proportional heights - let maxBarLen = 0 - for (let i = 0; i < allRows.length; i++) { - const val = allRows[i][varcharColIndex] - if (val !== null) { - const len = [...val].length - if (len > maxBarLen) maxBarLen = len - } - } - const scrollArea = document.createElement("div") addClass(scrollArea, "qg-rotated-scroll") From 69553ada030da6254e62765c3019a799f4a98ff6 Mon Sep 17 00:00:00 2001 From: javier Date: Tue, 28 Apr 2026 12:53:40 +0200 Subject: [PATCH 4/9] feat: monospace font for viz cells, OHLC label coloring, sparkline support - Use monospace font (skip Open Sans) for bar/ohlc/sparkline cells to ensure consistent character widths across Unicode block elements - Color O:/H:/L:/C: label keys in orange for ohlc_bar_labels output - Preserve separator spaces between bar and labels for alignment - Add sparkline() to query function detection regex - Support multi-column results for sparkline detection - Apply white-space:pre on viz cells to preserve space characters - Update OHLC detection for backend switch from U+2800 to regular space --- src/js/console/grid.js | 125 +++++++++++++++++++++++++++++++---------- src/styles/_grid.scss | 13 +++++ 2 files changed, 108 insertions(+), 30 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 2049014fe..5ff3043b9 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -53,9 +53,9 @@ const ROTATE_ICON_SVG = '' + "" -// Regex to detect bar/ohlc function calls in SQL. +// Regex to detect bar/ohlc/sparkline 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)\s*\(/i +const BAR_FN_REGEX = /\b(?:ohlc_bar_labels|ohlc_bar|bar|sparkline)\s*\(/i function queryContainsBarFunction(sqlText) { if (!sqlText) return false @@ -66,19 +66,29 @@ function detectVisualizationType(value) { if (!value || typeof value !== "string") return null // Step 1: ohlc_bar - if (value.includes("\u2800") || value.includes("\u2591")) { + // Bearish bodies use U+2591 (light shade), always identifies ohlc_bar. + // Legacy padding U+2800 (braille blank) also identifies ohlc_bar. + if (value.includes("\u2591") || value.includes("\u2800")) { 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 2: bar @@ -91,16 +101,16 @@ function detectVisualizationType(value) { return null } -// Split ohlc_bar_labels output into bar portion and label portion. +// Split ohlc_bar_labels output into bar portion, separator, and label portion. // Labels start with " O:" (space + O + colon) after the bar characters. -const OHLC_LABEL_REGEX = /^(.*?)\s+(O:.*)$/ +const OHLC_LABEL_REGEX = /^(.*?)(\s+)(O:.*)$/ function splitOhlcLabels(value) { const m = OHLC_LABEL_REGEX.exec(value) if (m) { - return { bar: m[1], labels: m[2] } + return { bar: m[1], sep: m[2], labels: m[3] } } - return { bar: value, labels: null } + return { bar: value, sep: null, labels: null } } // Check at least 2 out of 3 non-null values match @@ -1297,16 +1307,26 @@ export function grid(rootElement, _paginationFn, id) { getDisplayedCellValue(column, cellData, columnWidth), ) - // Apply OHLC coloring for ohlc_bar visualization columns + // Apply coloring and monospace font for visualization columns if ( - vizType === "ohlc_bar" && + vizType && column.type.toUpperCase() === "VARCHAR" && typeof displayValue === "string" ) { - renderOhlcCell(cell, displayValue) + if (vizType === "ohlc_bar") { + renderOhlcCell(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.classList.remove("qg-null") @@ -1320,21 +1340,53 @@ export function grid(rootElement, _paginationFn, id) { } } - function renderOhlcCell(cell, value) { + // Regex to match O:, H:, L:, C: prefixes in labels + const OHLC_KEY_REGEX = /([OHLC]:)/g + + function renderOhlcLabels(cell, labels) { + // Render labels with O:, H:, L:, C: colored in orange + let lastIndex = 0 + let m + OHLC_KEY_REGEX.lastIndex = 0 + while ((m = OHLC_KEY_REGEX.exec(labels)) !== null) { + // Text before the key + if (m.index > lastIndex) { + cell.appendChild(document.createTextNode(labels.slice(lastIndex, m.index))) + } + // The key itself, colored + const keySpan = document.createElement("span") + keySpan.className = "qg-ohlc-label-key" + keySpan.textContent = m[1] + cell.appendChild(keySpan) + lastIndex = OHLC_KEY_REGEX.lastIndex + } + // Remaining text + if (lastIndex < labels.length) { + cell.appendChild(document.createTextNode(labels.slice(lastIndex))) + } + } + + function renderOhlcCell(cell, value, stripLabels) { cell.textContent = "" cell.title = "" + cell.style.whiteSpace = "pre" + addClass(cell, "qg-ohlc-cell") - // Strip labels from ohlc_bar_labels output, show as tooltip + // Always split bar from labels const parts = splitOhlcLabels(value) - const barPart = parts.bar - if (parts.labels) { - cell.title = parts.labels + 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 barPart) { + for (const ch of textToRender) { const cp = ch.codePointAt(0) let charType = null if (cp === 0x2588) { @@ -1376,6 +1428,12 @@ export function grid(rootElement, _paginationFn, id) { cell.appendChild(document.createTextNode(currentRun)) } } + + // In normal view, append separator + labels with colored O:/H:/L:/C: keys + if (!stripLabels && parts.labels) { + cell.appendChild(document.createTextNode(parts.sep)) + renderOhlcLabels(cell, parts.labels) + } } function setCellDataAndAttributes(row, rowData, columnIndex) { @@ -1853,6 +1911,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) @@ -2239,13 +2302,7 @@ export function grid(rootElement, _paginationFn, id) { continue } else { str = value.toString() - // For ohlc_bar_labels, measure only the bar portion - if (uncapped && vizType === "ohlc_bar") { - const parts = splitOhlcLabels(str) - str = parts.bar - } } - // For viz columns, measure actual pixel width to handle wide Unicode glyphs const cellW = uncapped ? getVizCellWidth(str) : getCellWidth(str.length) w = uncapped ? Math.max(w, cellW) : Math.min(maxWidth, Math.max(w, cellW)) } @@ -2302,10 +2359,6 @@ export function grid(rootElement, _paginationFn, id) { continue } else { str = value.toString() - if (uncapped && vizType === "ohlc_bar") { - const parts = splitOhlcLabels(str) - str = parts.bar - } } const cellW = uncapped ? getVizCellWidth(str) : getCellWidth(str.length) w = uncapped ? Math.max(w, cellW) : Math.min(maxWidth, Math.max(w, cellW)) @@ -2356,12 +2409,24 @@ export function grid(rootElement, _paginationFn, id) { timestampIndex = ogTimestampIndex rowCount = _data.count - // Detect bar/ohlc visualization + // Detect bar/ohlc/sparkline visualization const isBarQuery = queryContainsBarFunction(sql) - if (isBarQuery) { + if (isBarQuery && data[0]) { + // For 2-column results (timestamp + varchar), use strict detection const vizCols = findVizColumns() - if (vizCols && data[0]) { + 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 + } + } + } } } @@ -2606,7 +2671,7 @@ export function grid(rootElement, _paginationFn, id) { const barVal = row[varcharColIndex] if (barVal !== null) { if (vizType === "ohlc_bar") { - renderOhlcCell(barCell, barVal) + renderOhlcCell(barCell, barVal, true) } else { // For bar charts, replace fractional block chars (U+2589-U+258F) // with full blocks - fractional blocks don't render well vertically diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss index 90eddfb49..c7624deb0 100644 --- a/src/styles/_grid.scss +++ b/src/styles/_grid.scss @@ -184,6 +184,19 @@ $drag-handle-margin: 2px; color: #ff5555; } +.qg-ohlc-cell { + font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.qg-ohlc-label-key { + color: #ffb86c; +} + +.qg-ohlc-labels { + float: right; + padding-left: 1em; +} + .qg-header-border { position: absolute; margin-top: 10px; From 4a79cfd2c401c070518325693d25794851cc6151 Mon Sep 17 00:00:00 2001 From: javier Date: Tue, 28 Apr 2026 16:54:24 +0200 Subject: [PATCH 5/9] fix: clean up dead CSS, fix rotated bar font, update U+2800 detection - Remove unused .qg-ohlc-labels CSS class (leftover from float approach) - Fix .qg-rotated-bar font stack to skip Open Sans for consistent widths - Update OHLC_LABEL_REGEX to match U+2800 braille blank as separator - Update detection comment to reflect U+2800 as primary padding char - Skip render() when rotated view is active to prevent duplicate headers - Preserve separator spaces between bar and labels for alignment --- src/js/console/grid.js | 10 +++++----- src/styles/_grid.scss | 7 +------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 5ff3043b9..130c03385 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -66,9 +66,9 @@ function detectVisualizationType(value) { if (!value || typeof value !== "string") return null // Step 1: ohlc_bar - // Bearish bodies use U+2591 (light shade), always identifies ohlc_bar. - // Legacy padding U+2800 (braille blank) also identifies ohlc_bar. - if (value.includes("\u2591") || value.includes("\u2800")) { + // 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" } @@ -102,8 +102,8 @@ function detectVisualizationType(value) { } // Split ohlc_bar_labels output into bar portion, separator, and label portion. -// Labels start with " O:" (space + O + colon) after the bar characters. -const OHLC_LABEL_REGEX = /^(.*?)(\s+)(O:.*)$/ +// Labels start with "O:" after bar characters and padding (U+2800 or spaces). +const OHLC_LABEL_REGEX = /^(.*?)([\s\u2800]+)(O:.*)$/ function splitOhlcLabels(value) { const m = OHLC_LABEL_REGEX.exec(value) diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss index c7624deb0..902e3ed72 100644 --- a/src/styles/_grid.scss +++ b/src/styles/_grid.scss @@ -192,11 +192,6 @@ $drag-handle-margin: 2px; color: #ffb86c; } -.qg-ohlc-labels { - float: right; - padding-left: 1em; -} - .qg-header-border { position: absolute; margin-top: 10px; @@ -441,7 +436,7 @@ $top-shadow: 0 2px 5px 0 rgba(23, 23, 23, 0.86); writing-mode: vertical-rl; direction: rtl; white-space: pre; - font-family: "Open Sans", Menlo, Monaco, Consolas, "Liberation Mono", + font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: #f8f8f2; line-height: 1; From e99f3c3e6d06aa3607251b0c38fb9efbe897e2fe Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 29 Apr 2026 11:01:23 +0200 Subject: [PATCH 6/9] fix: rotate icon active state, tooltip hover on bar cells - Toggle cyan active state on rotate icon when clicked - Move tooltip from barCell to parent colDiv for reliable hover - Use removeAttribute instead of empty string to avoid blocking tooltip inheritance from parent to child elements --- src/js/console/grid.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 130c03385..7a905dc89 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -1117,6 +1117,11 @@ export function grid(rootElement, _paginationFn, id) { 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) @@ -1368,7 +1373,7 @@ export function grid(rootElement, _paginationFn, id) { function renderOhlcCell(cell, value, stripLabels) { cell.textContent = "" - cell.title = "" + cell.removeAttribute("title") cell.style.whiteSpace = "pre" addClass(cell, "qg-ohlc-cell") @@ -2672,6 +2677,12 @@ export function grid(rootElement, _paginationFn, id) { if (barVal !== null) { if (vizType === "ohlc_bar") { renderOhlcCell(barCell, barVal, true) + // Move title from barCell to colDiv for reliable tooltip on hover + // (writing-mode: vertical-rl on barCell can interfere with tooltips) + if (barCell.title) { + colDiv.title = barCell.title + barCell.removeAttribute("title") + } } else { // For bar charts, replace fractional block chars (U+2589-U+258F) // with full blocks - fractional blocks don't render well vertically From 25050a30a8a3871778b14fefc6627b94ee64b783 Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 29 Apr 2026 13:25:29 +0200 Subject: [PATCH 7/9] feat: depth_chart coloring, label alignment, markdown export fix - Add depth_chart/depth_chart_labels detection (U+254E spread separator) - Color bid side green, ask side red, split on spread character - Depth chart labels (bb:, ba:, tb:, ta:) colored in orange - OHLC labels rendered as right-floated pairs for alignment - Rotate icon only shown for 2-column bar/ohlc results - Rotation state only preserved when new result supports rotation - Fix markdown export for viz cells using data-viz-raw attribute - Rename splitOhlcLabels to splitVizLabels for depth_chart support - Restore Open Sans as primary grid font, keep Menlo for viz cells only --- src/js/console/grid.js | 166 +++++++++++++++++++++++++++++++---------- src/styles/_grid.scss | 8 ++ 2 files changed, 136 insertions(+), 38 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 7a905dc89..f1f6098e8 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -53,9 +53,10 @@ const ROTATE_ICON_SVG = '' + "" -// Regex to detect bar/ohlc/sparkline function calls in SQL. +// 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)\s*\(/i +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 @@ -65,7 +66,13 @@ function queryContainsBarFunction(sqlText) { function detectVisualizationType(value) { if (!value || typeof value !== "string") return null - // Step 1: ohlc_bar + // 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")) { @@ -91,22 +98,22 @@ function detectVisualizationType(value) { return "ohlc_bar" } - // Step 2: bar + // Step 3: bar if (hasFractionalBlock) return "bar" if (hasFullBlock && !hasLowerBlock) return "bar" - // Step 3: sparkline + // Step 4: sparkline if (hasLowerBlock) return "sparkline" return null } -// Split ohlc_bar_labels output into bar portion, separator, and label portion. -// Labels start with "O:" after bar characters and padding (U+2800 or spaces). -const OHLC_LABEL_REGEX = /^(.*?)([\s\u2800]+)(O:.*)$/ +// 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 splitOhlcLabels(value) { - const m = OHLC_LABEL_REGEX.exec(value) +function splitVizLabels(value) { + const m = VIZ_LABEL_REGEX.exec(value) if (m) { return { bar: m[1], sep: m[2], labels: m[3] } } @@ -899,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 @@ -917,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) } @@ -945,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 @@ -1106,8 +1123,9 @@ export function grid(rootElement, _paginationFn, id) { addClass(hNameRow, "qg-header-name-row") hNameRow.append(hName, copyBtn) - // Add rotate icon for bar/ohlc visualization columns - if (vizType && c.type.toUpperCase() === "VARCHAR") { + // 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) { @@ -1320,6 +1338,8 @@ export function grid(rootElement, _paginationFn, id) { ) { 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 @@ -1332,6 +1352,7 @@ export function grid(rootElement, _paginationFn, id) { cell.title = "" cell.style.whiteSpace = "" removeClass(cell, "qg-ohlc-cell") + cell.removeAttribute("data-viz-raw") } cell.classList.remove("qg-null") @@ -1346,28 +1367,23 @@ export function grid(rootElement, _paginationFn, id) { } // Regex to match O:, H:, L:, C: prefixes in labels - const OHLC_KEY_REGEX = /([OHLC]:)/g + // 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(cell, labels) { - // Render labels with O:, H:, L:, C: colored in orange - let lastIndex = 0 + function renderOhlcLabels(container, labels) { let m - OHLC_KEY_REGEX.lastIndex = 0 - while ((m = OHLC_KEY_REGEX.exec(labels)) !== null) { - // Text before the key - if (m.index > lastIndex) { - cell.appendChild(document.createTextNode(labels.slice(lastIndex, m.index))) - } - // The key itself, colored + 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] - cell.appendChild(keySpan) - lastIndex = OHLC_KEY_REGEX.lastIndex - } - // Remaining text - if (lastIndex < labels.length) { - cell.appendChild(document.createTextNode(labels.slice(lastIndex))) + keySpan.textContent = m[1] + ":" + pair.appendChild(keySpan) + + pair.appendChild(document.createTextNode(m[2])) + container.appendChild(pair) } } @@ -1376,9 +1392,11 @@ export function grid(rootElement, _paginationFn, id) { 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 = splitOhlcLabels(value) + const parts = splitVizLabels(value) const textToRender = parts.bar if (stripLabels) { @@ -1434,10 +1452,79 @@ export function grid(rootElement, _paginationFn, id) { } } - // In normal view, append separator + labels with colored O:/H:/L:/C: keys + // In normal view, render labels right-aligned in a floated container if (!stripLabels && parts.labels) { - cell.appendChild(document.createTextNode(parts.sep)) - renderOhlcLabels(cell, 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) } } @@ -2435,8 +2522,11 @@ export function grid(rootElement, _paginationFn, id) { } } - // Preserve rotation state between consecutive bar/ohlc queries - if (isBarQuery && queryContainsBarFunction(prevSql) && vizType) { + // 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()) diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss index 902e3ed72..33788784c 100644 --- a/src/styles/_grid.scss +++ b/src/styles/_grid.scss @@ -188,6 +188,14 @@ $drag-handle-margin: 2px; 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; } From b7150b1800019817c15c5926f542fb70874c54e7 Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 29 Apr 2026 13:46:52 +0200 Subject: [PATCH 8/9] fix: rotated view doji positioning and label stripping - Reverse bar string in JS instead of using CSS direction:rtl to avoid bidi algorithm issues with invisible braille blank characters - Strip labels before reversing so they don't appear in rotated view - Set labels as tooltip on parent colDiv directly --- src/js/console/grid.js | 19 +++++++++++-------- src/styles/_grid.scss | 1 - 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index f1f6098e8..e8a6c27d0 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -2765,19 +2765,22 @@ export function grid(rootElement, _paginationFn, id) { 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, barVal, true) - // Move title from barCell to colDiv for reliable tooltip on hover - // (writing-mode: vertical-rl on barCell can interfere with tooltips) - if (barCell.title) { - colDiv.title = barCell.title - barCell.removeAttribute("title") - } + renderOhlcCell(barCell, reversed) } else { // For bar charts, replace fractional block chars (U+2589-U+258F) // with full blocks - fractional blocks don't render well vertically let cleaned = "" - for (const ch of barVal) { + for (const ch of reversed) { const cp = ch.codePointAt(0) cleaned += cp >= 0x2589 && cp <= 0x258f ? "\u2588" : ch } diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss index 33788784c..b3928fe6e 100644 --- a/src/styles/_grid.scss +++ b/src/styles/_grid.scss @@ -442,7 +442,6 @@ $top-shadow: 0 2px 5px 0 rgba(23, 23, 23, 0.86); align-items: flex-end; justify-content: center; writing-mode: vertical-rl; - direction: rtl; white-space: pre; font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; From 0a9decd846b0a7aad24399e60d6aabca58281a34 Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 29 Apr 2026 19:24:39 +0200 Subject: [PATCH 9/9] fixing layout issues --- src/js/console/grid.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/js/console/grid.js b/src/js/console/grid.js index e8a6c27d0..0ac03b64c 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -2777,12 +2777,21 @@ export function grid(rootElement, _paginationFn, id) { if (vizType === "ohlc_bar") { renderOhlcCell(barCell, reversed) } else { - // For bar charts, replace fractional block chars (U+2589-U+258F) - // with full blocks - fractional blocks don't render well vertically + // 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) - cleaned += cp >= 0x2589 && cp <= 0x258f ? "\u2588" : ch + 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 }