diff --git a/packages/code-studio/src/styleguide/Grids.tsx b/packages/code-studio/src/styleguide/Grids.tsx index aaee64e089..51b81c9a27 100644 --- a/packages/code-studio/src/styleguide/Grids.tsx +++ b/packages/code-studio/src/styleguide/Grids.tsx @@ -12,6 +12,7 @@ import StaticExample from './grid-examples/StaticExample'; import QuadrillionExample from './grid-examples/QuadrillionExample'; import TreeExample from './grid-examples/TreeExample'; import AsyncExample from './grid-examples/AsyncExample'; +import DataBarExample from './grid-examples/DataBarExample'; type GridsState = { irisGridModel: MockIrisGridTreeModel; @@ -63,6 +64,10 @@ class Grids extends PureComponent, GridsState> {
+

Data Bar

+
+ +
); } diff --git a/packages/code-studio/src/styleguide/grid-examples/DataBarExample.tsx b/packages/code-studio/src/styleguide/grid-examples/DataBarExample.tsx new file mode 100644 index 0000000000..9718b2399d --- /dev/null +++ b/packages/code-studio/src/styleguide/grid-examples/DataBarExample.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { Grid, MockDataBarGridModel } from '@deephaven/grid'; +import { ColorMap } from 'packages/grid/src/DataBarGridModel'; + +function DataBarExample() { + const columnData = [100, 50, 20, 10, -10, -20, -50, -30, 100, 0, 1]; + const data: number[][] = []; + const columnAxes = new Map([ + [0, 'proportional'], + [1, 'middle'], + [2, 'directional'], + [6, 'directional'], + [7, 'directional'], + [8, 'directional'], + [9, 'directional'], + [10, 'directional'], + ]); + const positiveColors: ColorMap = new Map([ + [3, '#72d7df'], + [4, '#ac9cf4'], + ]); + positiveColors.set(5, ['#f3cd5b', '#9edc6f']); + positiveColors.set(19, ['#42f54b', '#42b9f5', '#352aa8']); + + const negativeColors: ColorMap = new Map([ + [3, '#f3cd5b'], + [4, '#ac9cf4'], + ]); + negativeColors.set(5, ['#f95d84', '#f3cd5b']); + negativeColors.set(19, ['#e05536', '#e607de', '#e6e207']); + + const valuePlacements = new Map([ + [6, 'hide'], + [7, 'overlap'], + [8, 'overlap'], + [9, 'overlap'], + ]); + const opacities = new Map([ + [7, 0.5], + [8, 0.5], + [9, 0.5], + ]); + const directions = new Map([ + [8, 'RTL'], + [10, 'RTL'], + [16, 'RTL'], + [19, 'RTL'], + ]); + const textAlignments = new Map([ + [9, 'left'], + [11, 'left'], + ]); + const markers = new Map([ + [ + 12, + [ + { column: 13, color: 'white' }, + { column: 14, color: 'gray' }, + ], + ], + ]); + for (let i = 0; i < 13; i += 1) { + data.push(columnData.slice()); + } + data.push([70, 60, 30, 20, -10, -30, -20, -50, 80, 50, 10]); + data.push([50, 20, 10, 0, 0, -10, -30, 10, 90, 20, 40]); + data.push([-100, -90, -80, -70, -60, -50, -40, -30, -20, -10, 0]); + data.push(columnData.slice()); + // Decimals + data.push([ + 100, + 10.5, + 11.234, + -20.5, + -50, + -2.5, + -15.1234, + 94.254, + 25, + 44.4444, + -50.5, + ]); + + // Big values + data.push([ + 1000000, + 10, + 200, + -20000, + -2000000, + -25, + -900000, + 800000, + 100000, + 450000, + 1, + ]); + + // RTL gradient with multiple colors + data.push(columnData.slice()); + + // Both data bar and text + data.push(columnData.slice()); + data.push(columnData.slice()); + const [model] = useState( + () => + new MockDataBarGridModel( + data, + columnAxes, + positiveColors, + negativeColors, + valuePlacements, + opacities, + directions, + textAlignments, + markers + ) + ); + + return ; +} + +export default DataBarExample; diff --git a/packages/grid/src/CellRenderer.ts b/packages/grid/src/CellRenderer.ts new file mode 100644 index 0000000000..0326a9442d --- /dev/null +++ b/packages/grid/src/CellRenderer.ts @@ -0,0 +1,104 @@ +/* eslint-disable class-methods-use-this */ +import { getOrThrow } from '@deephaven/utils'; +import { isExpandableGridModel } from './ExpandableGridModel'; +import { VisibleIndex, Coordinate, BoxCoordinates } from './GridMetrics'; +import GridRenderer from './GridRenderer'; +import { GridRenderState } from './GridRendererTypes'; +import { GridColor } from './GridTheme'; +import memoizeClear from './memoizeClear'; + +export type CellRenderType = 'text' | 'dataBar'; + +abstract class CellRenderer { + abstract drawCellContent( + context: CanvasRenderingContext2D, + state: GridRenderState, + column: VisibleIndex, + row: VisibleIndex + ): void; + + drawCellRowTreeMarker( + context: CanvasRenderingContext2D, + state: GridRenderState, + row: VisibleIndex + ): void { + const { metrics, model, mouseX, mouseY, theme } = state; + const { + firstColumn, + gridX, + gridY, + allColumnXs, + allColumnWidths, + allRowYs, + allRowHeights, + visibleRowTreeBoxes, + } = metrics; + const { treeMarkerColor, treeMarkerHoverColor } = theme; + const columnX = getOrThrow(allColumnXs, firstColumn); + const columnWidth = getOrThrow(allColumnWidths, firstColumn); + const rowY = getOrThrow(allRowYs, row); + const rowHeight = getOrThrow(allRowHeights, row); + if (!isExpandableGridModel(model) || !model.isRowExpandable(row)) { + return; + } + + const treeBox = getOrThrow(visibleRowTreeBoxes, row); + const color = + mouseX != null && + mouseY != null && + mouseX >= gridX + columnX && + mouseX <= gridX + columnX + columnWidth && + mouseY >= gridY + rowY && + mouseY <= gridY + rowY + rowHeight + ? treeMarkerHoverColor + : treeMarkerColor; + + this.drawTreeMarker( + context, + state, + columnX, + rowY, + treeBox, + color, + model.isRowExpanded(row) + ); + } + + drawTreeMarker( + context: CanvasRenderingContext2D, + state: GridRenderState, + columnX: Coordinate, + rowY: Coordinate, + treeBox: BoxCoordinates, + color: GridColor, + isExpanded: boolean + ): void { + const { x1, y1, x2, y2 } = treeBox; + const markerText = isExpanded ? '⊟' : '⊞'; + const textX = columnX + (x1 + x2) * 0.5 + 0.5; + const textY = rowY + (y1 + y2) * 0.5 + 0.5; + context.fillStyle = color; + context.textAlign = 'center'; + context.fillText(markerText, textX, textY); + } + + getCachedTruncatedString = memoizeClear( + ( + context: CanvasRenderingContext2D, + text: string, + width: number, + fontWidth: number, + truncationChar?: string + ): string => + GridRenderer.truncateToWidth( + context, + text, + width, + fontWidth, + truncationChar + ), + { max: 10000 } + ); +} + +export default CellRenderer; diff --git a/packages/grid/src/DataBarCellRenderer.ts b/packages/grid/src/DataBarCellRenderer.ts new file mode 100644 index 0000000000..0b47b0258a --- /dev/null +++ b/packages/grid/src/DataBarCellRenderer.ts @@ -0,0 +1,610 @@ +/* eslint-disable class-methods-use-this */ +import { getOrThrow } from '@deephaven/utils'; +import CellRenderer from './CellRenderer'; +import { isExpandableGridModel } from './ExpandableGridModel'; +import { isDataBarGridModel } from './DataBarGridModel'; +import { ModelIndex, VisibleIndex, VisibleToModelMap } from './GridMetrics'; +import GridColorUtils, { Oklab } from './GridColorUtils'; +import GridUtils from './GridUtils'; +import memoizeClear from './memoizeClear'; +import { DEFAULT_FONT_WIDTH, GridRenderState } from './GridRendererTypes'; +import GridModel from './GridModel'; + +interface DataBarRenderMetrics { + /** The total width the entire bar from the min to max value can take up (rightmostPosition - leftmostPosition) */ + maxWidth: number; + /** The x coordinate of the bar (the left) */ + x: number; + /** The y coordinate of the bar (the top) */ + y: number; + /** The position of the zero line */ + zeroPosition: number; + /** The position of the leftmost point */ + leftmostPosition: number; + /** The position of the rightmost point */ + rightmostPosition: number; + /** The range of values (e.g. max of 100 and min of -50 means range of 150) */ + totalValueRange: number; + /** The width of the databar */ + dataBarWidth: number; + /** The x coordinates of the markers (the left) */ + markerXs: number[]; +} +class DataBarCellRenderer extends CellRenderer { + private heightOfDigits?: number; + + drawCellContent( + context: CanvasRenderingContext2D, + state: GridRenderState, + column: VisibleIndex, + row: VisibleIndex + ) { + const { metrics, model, theme } = state; + if (!isDataBarGridModel(model)) { + return; + } + const { + modelColumns, + modelRows, + allRowHeights, + allRowYs, + firstColumn, + fontWidths, + } = metrics; + + const isFirstColumn = column === firstColumn; + const rowHeight = getOrThrow(allRowHeights, row); + const modelRow = getOrThrow(modelRows, row); + const modelColumn = getOrThrow(modelColumns, column); + const rowY = getOrThrow(allRowYs, row); + const textAlign = model.textAlignForCell(modelColumn, modelRow); + const text = model.textForCell(modelColumn, modelRow); + const { x: textX, width: textWidth } = GridUtils.getTextRenderMetrics( + state, + column, + row + ); + + const fontWidth = fontWidths?.get(context.font) ?? DEFAULT_FONT_WIDTH; + const truncationChar = model.truncationCharForCell(modelColumn, modelRow); + const truncatedText = this.getCachedTruncatedString( + context, + text, + textWidth, + fontWidth, + truncationChar + ); + + const { + columnMin, + columnMax, + axis, + color: dataBarColor, + valuePlacement, + opacity, + markers, + direction, + value, + } = model.dataBarOptionsForCell(modelColumn, modelRow); + + const hasGradient = Array.isArray(dataBarColor); + if (columnMin == null || columnMax == null) { + return; + } + + const { + maxWidth, + x: dataBarX, + y: dataBarY, + zeroPosition, + leftmostPosition, + markerXs, + totalValueRange, + dataBarWidth, + } = this.getDataBarRenderMetrics(context, state, column, row); + + if (this.heightOfDigits === undefined) { + const { + actualBoundingBoxAscent, + actualBoundingBoxDescent, + } = context.measureText('1234567890'); + this.heightOfDigits = actualBoundingBoxAscent + actualBoundingBoxDescent; + } + + context.save(); + context.textAlign = textAlign; + if (hasGradient) { + const color = + value >= 0 ? dataBarColor[dataBarColor.length - 1] : dataBarColor[0]; + context.fillStyle = color; + } else { + context.fillStyle = dataBarColor; + } + context.textBaseline = 'top'; + context.font = theme.font; + + if (valuePlacement !== 'hide') { + context.fillText( + truncatedText, + textX, + rowY + (rowHeight - this.heightOfDigits) / 2 + ); + } + + // Draw bar + if (hasGradient) { + // Draw gradient bar + + const dataBarColorsOklab: Oklab[] = dataBarColor.map(color => + GridColorUtils.linearSRGBToOklab(GridColorUtils.hexToRgb(color)) + ); + + context.save(); + + context.beginPath(); + + context.roundRect(dataBarX, dataBarY, dataBarWidth, rowHeight - 2, 1); + context.clip(); + + if (value < 0) { + if (direction === 'LTR') { + const totalGradientWidth = Math.round( + (Math.abs(columnMin) / totalValueRange) * maxWidth + ); + const partGradientWidth = + totalGradientWidth / (dataBarColor.length - 1); + let gradientX = Math.round(leftmostPosition); + for (let i = 0; i < dataBarColor.length - 1; i += 1) { + const leftColor = dataBarColorsOklab[i]; + const rightColor = dataBarColorsOklab[i + 1]; + this.drawGradient( + context, + leftColor, + rightColor, + gradientX, + rowY + 1, + partGradientWidth, + rowHeight + ); + + gradientX += partGradientWidth; + } + } else if (direction === 'RTL') { + const totalGradientWidth = Math.round( + maxWidth - (Math.abs(columnMax) / totalValueRange) * maxWidth + ); + const partGradientWidth = + totalGradientWidth / (dataBarColor.length - 1); + let gradientX = Math.round(zeroPosition); + for (let i = dataBarColor.length - 1; i > 0; i -= 1) { + const leftColor = dataBarColorsOklab[i]; + const rightColor = dataBarColorsOklab[i - 1]; + this.drawGradient( + context, + leftColor, + rightColor, + gradientX, + rowY + 1, + partGradientWidth, + rowHeight + ); + + gradientX += partGradientWidth; + } + } + } else if (direction === 'LTR') { + // Value is greater than or equal to 0 + const totalGradientWidth = + Math.round( + maxWidth - (Math.abs(columnMin) / totalValueRange) * maxWidth + ) - 1; + const partGradientWidth = + totalGradientWidth / (dataBarColor.length - 1); + let gradientX = Math.round(zeroPosition); + + for (let i = 0; i < dataBarColor.length - 1; i += 1) { + const leftColor = dataBarColorsOklab[i]; + const rightColor = dataBarColorsOklab[i + 1]; + this.drawGradient( + context, + leftColor, + rightColor, + gradientX, + rowY + 1, + partGradientWidth, + rowHeight - 2 + ); + + gradientX += partGradientWidth; + } + } else if (direction === 'RTL') { + // Value is greater than or equal to 0 + const totalGradientWidth = Math.round( + (Math.abs(columnMax) / totalValueRange) * maxWidth + ); + const partGradientWidth = + totalGradientWidth / (dataBarColor.length - 1); + let gradientX = Math.round(leftmostPosition); + + for (let i = dataBarColor.length - 1; i > 0; i -= 1) { + const leftColor = dataBarColorsOklab[i]; + const rightColor = dataBarColorsOklab[i - 1]; + this.drawGradient( + context, + leftColor, + rightColor, + gradientX, + rowY + 1, + partGradientWidth, + rowHeight - 2 + ); + + gradientX += partGradientWidth; + } + } + + // restore clip + context.restore(); + } else { + // Draw normal bar + context.save(); + + context.globalAlpha = opacity; + context.beginPath(); + context.roundRect(dataBarX, dataBarY, dataBarWidth, rowHeight - 2, 1); + context.fill(); + + context.restore(); + } + + // Draw markers + if (maxWidth > 0) { + markerXs.forEach((markerX, index) => { + context.fillStyle = markers[index].color; + context.fillRect(markerX, dataBarY, 1, rowHeight - 2); + }); + } + + const shouldRenderDashedLine = !( + axis === 'directional' && + ((valuePlacement === 'beside' && + textAlign === 'right' && + direction === 'LTR') || + (valuePlacement === 'beside' && + textAlign === 'left' && + direction === 'RTL') || + valuePlacement !== 'beside') + ); + + // Draw dashed line + if (shouldRenderDashedLine) { + context.strokeStyle = theme.zeroLineColor; + context.beginPath(); + context.setLineDash([2, 1]); + context.moveTo(zeroPosition, rowY); + context.lineTo(zeroPosition, rowY + rowHeight); + context.stroke(); + } + + context.restore(); + + // Draw tree marker + if ( + isFirstColumn && + isExpandableGridModel(model) && + model.hasExpandableRows + ) { + this.drawCellRowTreeMarker(context, state, row); + } + } + + getDataBarRenderMetrics( + context: CanvasRenderingContext2D, + state: GridRenderState, + column: VisibleIndex, + row: VisibleIndex + ): DataBarRenderMetrics { + const { metrics, model, theme } = state; + if (!isDataBarGridModel(model)) { + throw new Error('Grid model is not a data bar grid model'); + } + const { + firstColumn, + allColumnXs, + allColumnWidths, + allRowYs, + modelColumns, + modelRows, + visibleRows, + } = metrics; + const { + cellHorizontalPadding, + treeDepthIndent, + treeHorizontalPadding, + } = theme; + + const modelColumn = getOrThrow(modelColumns, column); + const modelRow = getOrThrow(modelRows, row); + const x = getOrThrow(allColumnXs, column); + const y = getOrThrow(allRowYs, row); + const columnWidth = getOrThrow(allColumnWidths, column); + const isFirstColumn = column === firstColumn; + let treeIndent = 0; + if ( + isExpandableGridModel(model) && + model.hasExpandableRows && + isFirstColumn + ) { + treeIndent = + treeDepthIndent * (model.depthForRow(row) + 1) + treeHorizontalPadding; + } + + const textAlign = model.textAlignForCell(modelColumn, modelRow); + const { + columnMin, + columnMax, + axis, + valuePlacement, + markers, + direction, + value, + } = model.dataBarOptionsForCell(modelColumn, modelRow); + const longestValueWidth = this.getCachedWidestValueForColumn( + context, + visibleRows, + modelRows, + model, + modelColumn + ); + + const leftPadding = 2; + const rightPadding = + valuePlacement === 'beside' && textAlign === 'right' ? 2 : 1; + + // The value of the total range (e.g. max - column) + let totalValueRange = columnMax - columnMin; + // If min and max are both positive or min and max are equal, the max length is columnMax + if ((columnMax >= 0 && columnMin >= 0) || columnMin === columnMax) { + totalValueRange = columnMax; + } else if (columnMax <= 0 && columnMin <= 0) { + // If min and max are both negative, the max length is the absolute value of columnMin + totalValueRange = Math.abs(columnMin); + } + + let maxWidth = columnWidth - treeIndent - rightPadding - leftPadding; + if (valuePlacement === 'beside') { + maxWidth = maxWidth - cellHorizontalPadding - longestValueWidth; + } + + if (maxWidth < 0) { + maxWidth = 0; + } + + const columnLongest = Math.max(Math.abs(columnMin), Math.abs(columnMax)); + // If axis is proportional, totalValueRange is proportional to maxWidth + let dataBarWidth = (Math.abs(value) / totalValueRange) * maxWidth; + + if (maxWidth === 0) { + dataBarWidth = 0; + } else if (axis === 'middle') { + // The longest bar is proportional to half of the maxWidth + dataBarWidth = (Math.abs(value) / columnLongest) * (maxWidth / 2); + } else if (axis === 'directional') { + // The longest bar is proportional to the maxWidth + dataBarWidth = (Math.abs(value) / columnLongest) * maxWidth; + } + + // Default: proportional, beside, LTR, right text align + // All positions are assuming the left side is 0 and the right side is maxWidth + let zeroPosition = + columnMin >= 0 ? 0 : (Math.abs(columnMin) / totalValueRange) * maxWidth; + let dataBarX = + value >= 0 + ? zeroPosition + : zeroPosition - (Math.abs(value) / totalValueRange) * maxWidth; + let markerXs = markers.map(marker => { + const { column: markerColumn } = marker; + const markerValue = Number(model.textForCell(markerColumn, modelRow)); + return markerValue >= 0 + ? zeroPosition + (Math.abs(markerValue) / totalValueRange) * maxWidth + : zeroPosition - (Math.abs(markerValue) / totalValueRange) * maxWidth; + }); + let leftmostPosition = + valuePlacement === 'beside' && textAlign === 'left' + ? cellHorizontalPadding + longestValueWidth + leftPadding + : leftPadding; + let rightmostPosition = + valuePlacement === 'beside' && textAlign === 'right' + ? columnWidth - cellHorizontalPadding - longestValueWidth - rightPadding + : rightPadding; + + // Proportional, RTL + if (direction === 'RTL') { + zeroPosition = + columnMin >= 0 + ? columnWidth + : columnWidth - (Math.abs(columnMin) / totalValueRange) * maxWidth; + dataBarX = + value >= 0 + ? zeroPosition - (value / totalValueRange) * maxWidth + : zeroPosition; + markerXs = markers.map(marker => { + const { column: markerColumn } = marker; + const markerValue = Number(model.textForCell(markerColumn, modelRow)); + return markerValue >= 0 + ? zeroPosition - (Math.abs(markerValue) / totalValueRange) * maxWidth + : zeroPosition + (Math.abs(markerValue) / totalValueRange) * maxWidth; + }); + } + + if (axis === 'middle') { + zeroPosition = maxWidth / 2; + if (direction === 'LTR') { + // Middle, LTR + dataBarX = + value >= 0 + ? zeroPosition + : zeroPosition - (Math.abs(value) / columnLongest) * (maxWidth / 2); + markerXs = markers.map(marker => { + const { column: markerColumn } = marker; + const markerValue = Number(model.textForCell(markerColumn, modelRow)); + return markerValue >= 0 + ? zeroPosition + + (Math.abs(markerValue) / columnLongest) * (maxWidth / 2) + : zeroPosition - + (Math.abs(markerValue) / columnLongest) * (maxWidth / 2); + }); + } else if (direction === 'RTL') { + // Middle, RTL + dataBarX = + value <= 0 + ? zeroPosition + : zeroPosition - (Math.abs(value) / columnLongest) * (maxWidth / 2); + markerXs = markers.map(marker => { + const { column: markerColumn } = marker; + const markerValue = Number(model.textForCell(markerColumn, modelRow)); + return markerValue <= 0 + ? zeroPosition + + (Math.abs(markerValue) / columnLongest) * (maxWidth / 2) + : zeroPosition - + (Math.abs(markerValue) / columnLongest) * (maxWidth / 2); + }); + } + } else if (axis === 'directional') { + if (direction === 'LTR') { + // Directional, LTR + zeroPosition = 0; + dataBarX = zeroPosition; + markerXs = markers.map(marker => { + const { column: markerColumn } = marker; + const markerValue = Number(model.textForCell(markerColumn, modelRow)); + return ( + zeroPosition + (Math.abs(markerValue) / columnLongest) * maxWidth + ); + }); + } else if (direction === 'RTL') { + // Directional, RTL + zeroPosition = columnWidth; + dataBarX = zeroPosition - (Math.abs(value) / columnLongest) * maxWidth; + markerXs = markers.map(marker => { + const { column: markerColumn } = marker; + const markerValue = Number(model.textForCell(markerColumn, modelRow)); + return ( + zeroPosition - (Math.abs(markerValue) / columnLongest) * maxWidth + ); + }); + } + } + + // Offset all values by the actual x value and padding + if (direction === 'LTR') { + zeroPosition += x + leftPadding + treeIndent; + dataBarX += x + leftPadding + treeIndent; + markerXs = markerXs.map( + markerX => markerX + x + leftPadding + treeIndent + ); + + if (valuePlacement === 'beside' && textAlign === 'left') { + zeroPosition += longestValueWidth + cellHorizontalPadding; + dataBarX += longestValueWidth + cellHorizontalPadding; + markerXs = markerXs.map( + markerX => markerX + longestValueWidth + cellHorizontalPadding + ); + } + } else if (direction === 'RTL') { + zeroPosition = zeroPosition + x - rightPadding; + dataBarX = dataBarX + x - rightPadding; + markerXs = markerXs.map(markerX => markerX + x - rightPadding); + + if (valuePlacement === 'beside' && textAlign === 'right') { + zeroPosition = zeroPosition - cellHorizontalPadding - longestValueWidth; + dataBarX = dataBarX - cellHorizontalPadding - longestValueWidth; + markerXs = markerXs.map( + markerX => markerX - cellHorizontalPadding - longestValueWidth + ); + } + } + + leftmostPosition += x + treeIndent; + rightmostPosition += x; + + return { + maxWidth, + x: dataBarX, + y: y + 1.5, + zeroPosition, + leftmostPosition, + rightmostPosition, + totalValueRange, + dataBarWidth, + markerXs, + }; + } + + drawGradient( + context: CanvasRenderingContext2D, + leftColor: Oklab, + rightColor: Oklab, + x: number, + y: number, + width: number, + height: number + ) { + let currentColor = leftColor; + // Increase by 0.5 because half-pixel will render weird on different zooms + for (let currentX = x; currentX <= x + width; currentX += 0.5) { + this.drawGradientPart( + context, + currentX, + y, + 1, + height, + GridColorUtils.rgbToHex(GridColorUtils.OklabToLinearSRGB(currentColor)) + ); + + currentColor = GridColorUtils.lerpColor( + leftColor, + rightColor, + (currentX - x) / width + ); + } + } + + drawGradientPart( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + color: string + ) { + context.fillStyle = color; + context.fillRect(x, y, width, height); + } + + /** + * Returns the width of the widest value in pixels + */ + getCachedWidestValueForColumn = memoizeClear( + ( + context: CanvasRenderingContext2D, + visibleRows: readonly VisibleIndex[], + modelRows: VisibleToModelMap, + model: GridModel, + column: ModelIndex + ): number => { + let widestValue = 0; + for (let i = 0; i < visibleRows.length; i += 1) { + const row = visibleRows[i]; + const modelRow = getOrThrow(modelRows, row); + const text = model.textForCell(column, modelRow); + widestValue = Math.max(widestValue, context.measureText(text).width); + } + + return widestValue; + }, + { max: 1000 } + ); +} + +export default DataBarCellRenderer; diff --git a/packages/grid/src/DataBarGridModel.ts b/packages/grid/src/DataBarGridModel.ts new file mode 100644 index 0000000000..e7a3dcab72 --- /dev/null +++ b/packages/grid/src/DataBarGridModel.ts @@ -0,0 +1,53 @@ +import { ModelIndex } from './GridMetrics'; +import GridModel from './GridModel'; +import { GridColor } from './GridTheme'; + +export type Marker = { column: ModelIndex; color: string }; +export type AxisOption = 'proportional' | 'middle' | 'directional'; +export type ValuePlacementOption = 'beside' | 'overlap' | 'hide'; +export type DirectionOption = 'LTR' | 'RTL'; +/** Map from ModelIndex to the axis option of the column */ +export type ColumnAxisMap = Map; +/** Map from ModelIndex to a color or an array of colors + * If given an array, then the bar will be a gradient + * The colors should be given left to right (e.g. it should be like ['yellow', 'green'] for positive color and ['red', 'yellow'] for negative color) + */ +export type ColorMap = Map; +/** Map from ModelIndex to the value placement option of the column */ +export type ValuePlacementMap = Map; +/** Map from ModelIndex to the opacity of the column */ +export type OpacityMap = Map; +/** Map from ModelIndex to the direction of the column */ +export type DirectionMap = Map; +/** Map from ModelIndex to the text alignment of the column */ +export type TextAlignmentMap = Map; +/** Map from column to the columns its markers are from */ +export type MarkerMap = Map; +/** Map from column to whether the bar has a gradient */ +export type GradientMap = Map; +// Map from ModelIndex to the minimum number in the column +export type MinMap = Map; +// Map from ModelIndex to the maximum number in the column +export type MaxMap = Map; + +export interface DataBarOptions { + columnMin: number; + columnMax: number; + axis: AxisOption; + color: GridColor | GridColor[]; + valuePlacement: ValuePlacementOption; + opacity: number; + markers: Marker[]; + direction: DirectionOption; + value: number; +} + +export function isDataBarGridModel( + model: GridModel +): model is DataBarGridModel { + return (model as DataBarGridModel)?.dataBarOptionsForCell !== undefined; +} + +export interface DataBarGridModel extends GridModel { + dataBarOptionsForCell(column: ModelIndex, row: ModelIndex): DataBarOptions; +} diff --git a/packages/grid/src/GridColorUtils.ts b/packages/grid/src/GridColorUtils.ts index 6301ebc70d..4a653611a7 100644 --- a/packages/grid/src/GridColorUtils.ts +++ b/packages/grid/src/GridColorUtils.ts @@ -1,7 +1,11 @@ import convert from 'color-convert'; import { HEX } from 'color-convert/conversions'; +import clamp from 'lodash.clamp'; import { GridColor } from './GridTheme'; +export type RGB = { r: number; g: number; b: number }; +export type Oklab = { L: number; a: number; b: number }; + /** * Darken the provided colour * @param color Color in hex format to convert (with #) @@ -30,4 +34,117 @@ export function colorWithAlpha(color: HEX, alpha: number): GridColor { return `rgba(${r}, ${g}, ${b}, ${alpha})`; } -export default { colorWithAlpha, darkenForDepth }; +/** + * Converts a color in RGB to Oklab + * Formula provided here: https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab + * @param color An RGB color + * @returns The color but respresented as an Oklab color + */ +const linearSRGBToOklab = (color: RGB): Oklab => { + const { r, g, b } = color; + + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + const l2 = Math.cbrt(l); + const m2 = Math.cbrt(m); + const s2 = Math.cbrt(s); + + return { + L: 0.2104542553 * l2 + 0.793617785 * m2 - 0.0040720468 * s2, + a: 1.9779984951 * l2 - 2.428592205 * m2 + 0.4505937099 * s2, + b: 0.0259040371 * l2 + 0.7827717662 * m2 - 0.808675766 * s2, + }; +}; + +/** + * Converts an Oklab color to RGB + * Formula provided here: https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab + * @param color An Oklab color + * @returns The given color but represented as a RGB color + */ +const OklabToLinearSRGB = (color: Oklab): RGB => { + const { L, a, b } = color; + + const l2 = L + 0.3963377774 * a + 0.2158037573 * b; + const m2 = L - 0.1055613458 * a - 0.0638541728 * b; + const s2 = L - 0.0894841775 * a - 1.291485548 * b; + + const l = l2 * l2 * l2; + const m = m2 * m2 * m2; + const s = s2 * s2 * s2; + + return { + r: clamp(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, 0, 255), + g: clamp(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, 0, 255), + b: clamp(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, 0, 255), + }; +}; + +/** + * Converts a hex color to RGB + * Algorithm from https://stackoverflow.com/a/39077686/20005358 + * @param hex A hex color + * @returns The RGB representation of the given color + */ +const hexToRgb = (hex: string): RGB => { + const rgbArray = hex + .replace( + /^#?([a-f\d])([a-f\d])([a-f\d])$/i, + (m: string, r: string, g: string, b: string) => + `#${r}${r}${g}${g}${b}${b}` + ) + .substring(1) + .match(/.{2}/g) + ?.map((x: string) => parseInt(x, 16)) ?? [0, 0, 0]; + + return { r: rgbArray[0], g: rgbArray[1], b: rgbArray[2] }; +}; + +/** + * Converts a RGB color to hex + * Algorithm from https://stackoverflow.com/a/39077686/20005358 + * @param color A RGB color + * @returns The hexcode of the given color + */ +const rgbToHex = (color: RGB): string => { + const r = Math.round(color.r); + const g = Math.round(color.g); + const b = Math.round(color.b); + + return `#${[r, g, b] + .map(x => { + const hex = x.toString(16); + return hex.length === 1 ? `0${hex}` : hex; + }) + .join('')}`; +}; + +/** + * Calculates a color given an interpolation factor between two given colors + * @param color1 Color on one end + * @param color2 Color on other end + * @param factor The interpolation factor (0 to 1, 0 will be color1 while 1 will be color2) + * @returns The color determined by the interpolation factor between the two colors + */ +const lerpColor = (color1: Oklab, color2: Oklab, factor: number): Oklab => { + const { L: L1, a: a1, b: b1 } = color1; + const { L: L2, a: a2, b: b2 } = color2; + + const L = L1 + (L2 - L1) * factor; + const a = a1 + (a2 - a1) * factor; + const b = b1 + (b2 - b1) * factor; + + return { L, a, b }; +}; + +export default { + colorWithAlpha, + darkenForDepth, + linearSRGBToOklab, + OklabToLinearSRGB, + hexToRgb, + rgbToHex, + lerpColor, +}; diff --git a/packages/grid/src/GridMetricCalculator.ts b/packages/grid/src/GridMetricCalculator.ts index 782c7fb1e6..bc49d116dd 100644 --- a/packages/grid/src/GridMetricCalculator.ts +++ b/packages/grid/src/GridMetricCalculator.ts @@ -1728,6 +1728,7 @@ export class GridMetricCalculator { rowHeaderWidth, rowFooterWidth, scrollBarSize, + dataBarHorizontalPadding, } = theme; let columnWidth = 0; @@ -1744,13 +1745,19 @@ export class GridMetricCalculator { row => { const modelRow = this.getModelRow(row, state); const text = model.textForCell(modelColumn, modelRow); + const cellRenderType = model.renderTypeForCell(modelColumn, modelRow); + + let cellWidth = 0; if (text) { const cellPadding = cellHorizontalPadding * 2; - columnWidth = Math.max( - columnWidth, - text.length * fontWidth + cellPadding - ); + cellWidth = text.length * fontWidth + cellPadding; + } + + if (cellRenderType === 'dataBar') { + cellWidth += dataBarHorizontalPadding; } + + columnWidth = Math.max(columnWidth, cellWidth); } ); diff --git a/packages/grid/src/GridModel.ts b/packages/grid/src/GridModel.ts index 81bcacad5c..3f0dda5f7e 100644 --- a/packages/grid/src/GridModel.ts +++ b/packages/grid/src/GridModel.ts @@ -4,6 +4,7 @@ import { ModelIndex } from './GridMetrics'; import { GridColor, GridTheme, NullableGridColor } from './GridTheme'; import memoizeClear from './memoizeClear'; import GridUtils, { Token } from './GridUtils'; +import { CellRenderType } from './CellRenderer'; const LINK_TRUNCATION_LENGTH = 5000; @@ -226,6 +227,10 @@ abstract class GridModel< return GridUtils.findTokensWithProtocolInText(contentToCheckForLinks); } ); + + renderTypeForCell(column: ModelIndex, row: ModelIndex): CellRenderType { + return 'text'; + } } export default GridModel; diff --git a/packages/grid/src/GridRenderer.test.tsx b/packages/grid/src/GridRenderer.test.tsx index e5ba6efc57..5d4891ceba 100644 --- a/packages/grid/src/GridRenderer.test.tsx +++ b/packages/grid/src/GridRenderer.test.tsx @@ -3,6 +3,7 @@ import GridModel from './GridModel'; import GridRenderer from './GridRenderer'; import MockGridModel from './MockGridModel'; import GridTheme from './GridTheme'; +import TextCellRenderer from './TextCellRenderer'; import { LinkToken } from './GridUtils'; import { GridRenderState } from './GridRendererTypes'; @@ -116,7 +117,10 @@ describe('getTokenBoxesForVisibleCell', () => { }), }); - renderer.getCachedTruncatedString = jest.fn( + const textCellRenderer = renderer.getCellRenderer( + 'text' + ) as TextCellRenderer; + textCellRenderer.getCachedTruncatedString = jest.fn( ( context: CanvasRenderingContext2D, text: string, @@ -128,7 +132,14 @@ describe('getTokenBoxesForVisibleCell', () => { }); it('should return tokens that are visible in the cell', () => { - const tokens = renderer.getTokenBoxesForVisibleCell(0, 0, renderState); + const textCellRenderer = renderer.getCellRenderer( + 'text' + ) as TextCellRenderer; + const tokens = textCellRenderer.getTokenBoxesForVisibleCell( + 0, + 0, + renderState + ); const expectedValue: LinkToken = { type: 'url', @@ -144,7 +155,14 @@ describe('getTokenBoxesForVisibleCell', () => { }); it('should return multiple tokens', () => { - const tokens = renderer.getTokenBoxesForVisibleCell(0, 2, renderState); + const textCellRenderer = renderer.getCellRenderer( + 'text' + ) as TextCellRenderer; + const tokens = textCellRenderer.getTokenBoxesForVisibleCell( + 0, + 2, + renderState + ); const expectedValue: LinkToken[] = [ { @@ -173,7 +191,14 @@ describe('getTokenBoxesForVisibleCell', () => { }); it('should return empty array if there are no tokens', () => { - const tokens = renderer.getTokenBoxesForVisibleCell(0, 1, renderState); + const textCellRenderer = renderer.getCellRenderer( + 'text' + ) as TextCellRenderer; + const tokens = textCellRenderer.getTokenBoxesForVisibleCell( + 0, + 1, + renderState + ); expect(tokens).toHaveLength(0); }); @@ -181,7 +206,14 @@ describe('getTokenBoxesForVisibleCell', () => { it('should return empty array if context or metrics is null', () => { // @ts-expect-error metrics and context usually can't be null renderState = makeMockGridRenderState({ metrics: null, context: null }); - const tokens = renderer.getTokenBoxesForVisibleCell(0, 0, renderState); + const textCellRenderer = renderer.getCellRenderer( + 'text' + ) as TextCellRenderer; + const tokens = textCellRenderer.getTokenBoxesForVisibleCell( + 0, + 0, + renderState + ); expect(tokens).toHaveLength(0); }); diff --git a/packages/grid/src/GridRenderer.ts b/packages/grid/src/GridRenderer.ts index e8744dafc2..38b142ea9e 100644 --- a/packages/grid/src/GridRenderer.ts +++ b/packages/grid/src/GridRenderer.ts @@ -1,15 +1,18 @@ import clamp from 'lodash.clamp'; -import { ColorUtils, EMPTY_ARRAY, getOrThrow } from '@deephaven/utils'; +import { ColorUtils, getOrThrow } from '@deephaven/utils'; import memoizeClear from './memoizeClear'; -import GridUtils, { Token, TokenBox } from './GridUtils'; +import GridUtils from './GridUtils'; import GridColorUtils from './GridColorUtils'; import { isExpandableGridModel } from './ExpandableGridModel'; import { GridColor, GridColorWay, NullableGridColor } from './GridTheme'; -import { BoxCoordinates, Coordinate, VisibleIndex } from './GridMetrics'; +import { Coordinate, VisibleIndex } from './GridMetrics'; import { isEditableGridModel } from './EditableGridModel'; import GridColumnSeparatorMouseHandler from './mouse-handlers/GridColumnSeparatorMouseHandler'; import { BoundedAxisRange } from './GridAxisRange'; -import { GridRenderState } from './GridRendererTypes'; +import { DEFAULT_FONT_WIDTH, GridRenderState } from './GridRendererTypes'; +import CellRenderer, { CellRenderType } from './CellRenderer'; +import DataBarCellRenderer from './DataBarCellRenderer'; +import TextCellRenderer from './TextCellRenderer'; type NoneNullColumnRange = { startColumn: number; endColumn: number }; @@ -24,15 +27,16 @@ type NoneNullRowRange = { startRow: number; endRow: number }; * your own methods to customize drawing of the grid (eg. Draw icons or special features) */ export class GridRenderer { - // Default font width in pixels if it cannot be retrieved from the context - static DEFAULT_FONT_WIDTH = 10; - // Default radius in pixels for corners for some elements (like the active cell) static DEFAULT_EDGE_RADIUS = 2; // Default width in pixels for the border of the active cell static ACTIVE_CELL_BORDER_WIDTH = 2; + protected textCellRenderer = new TextCellRenderer(); + + protected dataBarCellRenderer = new DataBarCellRenderer(); + /** * Truncate a string to the specified length and add ellipses if necessary * @param str The string to truncate @@ -118,7 +122,7 @@ export class GridRenderer { context: CanvasRenderingContext2D, str: string, width: number, - fontWidth = GridRenderer.DEFAULT_FONT_WIDTH, + fontWidth = DEFAULT_FONT_WIDTH, truncationChar?: string ): string { if (width <= 0 || str.length <= 0) { @@ -993,242 +997,28 @@ export class GridRenderer { context.restore(); } - /** - * Gets textWidth and X-Y position for a specific cell - * The textWidth returned is the width that the text can occupy accounting for any other cell markings - * The width accounts for tree table indents and cell padding, so it is the width the text may consume - * - * @param state GridRenderState to get the text metrics for - * @param column Column of cell to get text metrics for - * @param row Row of cell to get text metrics for - * @returns Object with width, x, and y of the text - */ - getTextRenderMetrics( - state: GridRenderState, - column: VisibleIndex, - row: VisibleIndex - ): { - width: number; - x: number; - y: number; - } { - const { metrics, model, theme } = state; - const { - firstColumn, - allColumnXs, - allColumnWidths, - allRowYs, - allRowHeights, - modelRows, - modelColumns, - } = metrics; - const { - cellHorizontalPadding, - treeDepthIndent, - treeHorizontalPadding, - } = theme; - - const modelRow = getOrThrow(modelRows, row); - const modelColumn = getOrThrow(modelColumns, column); - const textAlign = model.textAlignForCell(modelColumn, modelRow); - const x = getOrThrow(allColumnXs, column); - const y = getOrThrow(allRowYs, row); - const columnWidth = getOrThrow(allColumnWidths, column); - const rowHeight = getOrThrow(allRowHeights, row); - const isFirstColumn = column === firstColumn; - let treeIndent = 0; - if ( - isExpandableGridModel(model) && - model.hasExpandableRows && - isFirstColumn - ) { - treeIndent = - treeDepthIndent * (model.depthForRow(row) + 1) + treeHorizontalPadding; - } - const textWidth = columnWidth - treeIndent; - let textX = x + cellHorizontalPadding; - const textY = y + rowHeight * 0.5; - if (textAlign === 'right') { - textX = x + textWidth - cellHorizontalPadding; - } else if (textAlign === 'center') { - textX = x + textWidth * 0.5; - } - textX += treeIndent; - - return { - width: textWidth - cellHorizontalPadding * 2, - x: textX, - y: textY, - }; - } - drawCellContent( context: CanvasRenderingContext2D, state: GridRenderState, column: VisibleIndex, - row: VisibleIndex, - textOverride?: string + row: VisibleIndex ): void { - const { metrics, model, theme } = state; - const { - firstColumn, - fontWidths, - modelColumns, - modelRows, - allRowHeights, - } = metrics; - const { textColor } = theme; - const rowHeight = getOrThrow(allRowHeights, row); + const { metrics, model } = state; + const { modelColumns, modelRows } = metrics; const modelRow = getOrThrow(modelRows, row); const modelColumn = getOrThrow(modelColumns, column); - const text = textOverride ?? model.textForCell(modelColumn, modelRow); - const truncationChar = model.truncationCharForCell(modelColumn, modelRow); - const isFirstColumn = column === firstColumn; - - if (text && rowHeight > 0) { - const textAlign = model.textAlignForCell(modelColumn, modelRow) || 'left'; - context.textAlign = textAlign; - - const color = - model.colorForCell(modelColumn, modelRow, theme) || textColor; - context.fillStyle = color; - - context.save(); - - const { - width: textWidth, - x: textX, - y: textY, - } = this.getTextRenderMetrics(state, column, row); - - const fontWidth = - fontWidths.get(context.font) ?? GridRenderer.DEFAULT_FONT_WIDTH; - const truncatedText = this.getCachedTruncatedString( - context, - text, - textWidth, - fontWidth, - truncationChar - ); - - const tokens = model.tokensForCell( - modelColumn, - modelRow, - truncatedText.length - ); - - if (truncatedText) { - let tokenIndex = 0; - let textStart = 0; - let left = textX; - const { actualBoundingBoxDescent } = context.measureText(truncatedText); - - while (textStart < truncatedText.length) { - const nextToken = tokens[tokenIndex]; - const token = textStart === nextToken?.start ? nextToken : null; - const textEnd = - token?.end ?? nextToken?.start ?? truncatedText.length; - const value = truncatedText.substring(textStart, textEnd); - const { width } = context.measureText(value); - const widthOfUnderline = value.endsWith('…') - ? context.measureText(value.substring(0, value.length - 1)).width - : width; - - // Set the styling based on the token, then draw the text - if (token != null) { - context.fillStyle = theme.hyperlinkColor; - context.fillText(value, left, textY); - context.fillRect( - left, - textY + actualBoundingBoxDescent, - widthOfUnderline, - 1 - ); - } else { - context.fillStyle = color; - context.fillText(value, left, textY); - } - - left += width; - textStart = textEnd; - if (token != null) tokenIndex += 1; - } - } - context.restore(); - } - - if ( - isFirstColumn && - isExpandableGridModel(model) && - model.hasExpandableRows - ) { - this.drawCellRowTreeMarker(context, state, row); - } + const renderType = model.renderTypeForCell(modelColumn, modelRow); + const cellRenderer = this.getCellRenderer(renderType); + cellRenderer.drawCellContent(context, state, column, row); } - drawCellRowTreeMarker( - context: CanvasRenderingContext2D, - state: GridRenderState, - row: VisibleIndex - ): void { - const { metrics, model, mouseX, mouseY, theme } = state; - const { - firstColumn, - gridX, - gridY, - allColumnXs, - allColumnWidths, - allRowYs, - allRowHeights, - visibleRowTreeBoxes, - } = metrics; - const { treeMarkerColor, treeMarkerHoverColor } = theme; - const columnX = getOrThrow(allColumnXs, firstColumn); - const columnWidth = getOrThrow(allColumnWidths, firstColumn); - const rowY = getOrThrow(allRowYs, row); - const rowHeight = getOrThrow(allRowHeights, row); - if (!isExpandableGridModel(model) || !model.isRowExpandable(row)) { - return; + getCellRenderer(renderType: CellRenderType): CellRenderer { + switch (renderType) { + case 'dataBar': + return this.dataBarCellRenderer; + default: + return this.textCellRenderer; } - - const treeBox = getOrThrow(visibleRowTreeBoxes, row); - const color = - mouseX != null && - mouseY != null && - mouseX >= gridX + columnX && - mouseX <= gridX + columnX + columnWidth && - mouseY >= gridY + rowY && - mouseY <= gridY + rowY + rowHeight - ? treeMarkerHoverColor - : treeMarkerColor; - - this.drawTreeMarker( - context, - state, - columnX, - rowY, - treeBox, - color, - model.isRowExpanded(row) - ); - } - - drawTreeMarker( - context: CanvasRenderingContext2D, - state: GridRenderState, - columnX: Coordinate, - rowY: Coordinate, - treeBox: BoxCoordinates, - color: GridColor, - isExpanded: boolean - ): void { - const { x1, y1, x2, y2 } = treeBox; - const markerText = isExpanded ? '⊟' : '⊞'; - const textX = columnX + (x1 + x2) * 0.5 + 0.5; - const textY = rowY + (y1 + y2) * 0.5 + 0.5; - context.fillStyle = color; - context.textAlign = 'center'; - context.fillText(markerText, textX, textY); } drawCellRowTreeDepthLines( @@ -1295,24 +1085,6 @@ export class GridRenderer { } } - getCachedTruncatedString = memoizeClear( - ( - context: CanvasRenderingContext2D, - text: string, - width: number, - fontWidth: number, - truncationChar?: string - ): string => - GridRenderer.truncateToWidth( - context, - text, - width, - fontWidth, - truncationChar - ), - { max: 10000 } - ); - getCachedBackgroundColors = memoizeClear( (backgroundColors: GridColorWay, maxDepth: number): GridColor[][] => backgroundColors.split(' ').map(color => { @@ -1765,8 +1537,7 @@ export class GridRenderer { white, } = theme; const { fontWidths, width } = metrics; - const fontWidth = - fontWidths.get(context.font) ?? GridRenderer.DEFAULT_FONT_WIDTH; + const fontWidth = fontWidths.get(context.font) ?? DEFAULT_FONT_WIDTH; const maxWidth = columnWidth - headerHorizontalPadding * 2; const maxLength = maxWidth / fontWidth; @@ -2980,153 +2751,6 @@ export class GridRenderer { context.translate(-barLeft, -barTop); } - - /** - * Gets the token boxes that are visible in the cell - * @param column The visible column - * @param row The visible row - * @param state The GridRenderState - * @returns An array of TokenBox of visible tokens or empty array with coordinates relative to gridX and gridY - */ - getTokenBoxesForVisibleCell( - column: VisibleIndex, - row: VisibleIndex, - state: GridRenderState - ): TokenBox[] { - const { metrics, context, model, theme } = state; - - if (context == null || metrics == null) { - return (EMPTY_ARRAY as unknown) as TokenBox[]; - } - - const { modelRows, modelColumns } = metrics; - const modelRow = getOrThrow(modelRows, row); - const modelColumn = getOrThrow(modelColumns, column); - - const text = model.textForCell(modelColumn, modelRow); - const { width: textWidth, x: textX, y: textY } = this.getTextRenderMetrics( - state, - column, - row - ); - - const { fontWidths } = metrics; - - // Set the font and baseline and change it back after - context.save(); - this.configureContext(context, state); - - const fontWidth = - fontWidths?.get(context.font) ?? GridRenderer.DEFAULT_FONT_WIDTH; - const truncationChar = model.truncationCharForCell(modelColumn, modelRow); - const truncatedText = this.getCachedTruncatedString( - context, - text, - textWidth, - fontWidth, - truncationChar - ); - - const { - actualBoundingBoxAscent, - actualBoundingBoxDescent, - } = context.measureText(truncatedText); - const textHeight = actualBoundingBoxAscent + actualBoundingBoxDescent; - - const tokens = model.tokensForCell( - modelColumn, - modelRow, - truncatedText.length - ); - - // Check if the truncated text contains a link - if (tokens.length === 0) { - context.restore(); - return (EMPTY_ARRAY as unknown) as TokenBox[]; - } - - const cachedTokenBoxes = this.getCachedTokenBoxesForVisibleCell( - truncatedText, - tokens, - theme.font, - 'middle', - textHeight, - context - ).map(tokenBox => ({ - x1: tokenBox.x1 + textX, - y1: tokenBox.y1 + (textY - actualBoundingBoxAscent), - x2: tokenBox.x2 + textX, - y2: tokenBox.y2 + (textY - actualBoundingBoxAscent), - token: tokenBox.token, - })); - - context.restore(); - - return cachedTokenBoxes; - } - - /** - * Returns an array of token boxes with the coordinates relative to the top left corner of the text - */ - getCachedTokenBoxesForVisibleCell = memoizeClear( - ( - truncatedText: string, - tokens: Token[], - // _font and _baseline are passed in so value is re-calculated when they change - // They should already be set on the `context`, so they are not used in this method - _font: string, - _baseline: CanvasTextBaseline, - textHeight: number, - context: CanvasRenderingContext2D - ): TokenBox[] => { - const top = 0; - const bottom = textHeight; - - const tokenBoxes: TokenBox[] = []; - - // The index where the last token ended - let lastTokenEnd = 0; - // The width of the text preceding the current token - let currentTextWidth = 0; - // Loop through array and push them to array - for (let i = 0; i < tokens.length; i += 1) { - const token = tokens[i]; - const { start, end } = token; - // The last token value is calculated based on the full text so the value needs to be truncated - const value = - end > truncatedText.length - ? truncatedText.substring(start) - : token.value; - - // Add the width of the text in between this token and the last token - currentTextWidth += context.measureText( - truncatedText.substring(lastTokenEnd, start) - ).width; - const tokenWidth = context.measureText(value).width; - - // Check if the x position is less than the grid x, then tokenWidth should be shifted by gridX - startX - - const left = currentTextWidth; - const right = left + tokenWidth; - - const newTokenBox: TokenBox = { - x1: left, - y1: top, - x2: right, - y2: bottom, - token, - }; - - tokenBoxes.push(newTokenBox); - - lastTokenEnd = end; - currentTextWidth += tokenWidth; - } - - return tokenBoxes; - }, - { max: 10000 } - ); } export default GridRenderer; diff --git a/packages/grid/src/GridRendererTypes.ts b/packages/grid/src/GridRendererTypes.ts index 8b2ddc2f8d..fecf47c366 100644 --- a/packages/grid/src/GridRendererTypes.ts +++ b/packages/grid/src/GridRendererTypes.ts @@ -5,6 +5,9 @@ import { GridTheme } from './GridTheme'; import { DraggingColumn } from './mouse-handlers/GridColumnMoveMouseHandler'; import { GridSeparator } from './mouse-handlers/GridSeparatorMouseHandler'; +// Default font width in pixels if it cannot be retrieved from the context +export const DEFAULT_FONT_WIDTH = 10; + export type EditingCellTextSelectionRange = [start: number, end: number]; export type EditingCell = { diff --git a/packages/grid/src/GridTheme.ts b/packages/grid/src/GridTheme.ts index cde1afae61..741e2c11ab 100644 --- a/packages/grid/src/GridTheme.ts +++ b/packages/grid/src/GridTheme.ts @@ -132,6 +132,12 @@ export type GridTheme = { // Divider colors between the floating parts and the grid floatingDividerOuterColor: GridColor; floatingDividerInnerColor: GridColor; + + zeroLineColor: GridColor; + positiveBarColor: GridColor; + negativeBarColor: GridColor; + + dataBarHorizontalPadding: number; }; /** @@ -221,6 +227,13 @@ const defaultTheme: GridTheme = Object.freeze({ // Divider colors between the floating parts and the grid floatingDividerOuterColor: '#000000', floatingDividerInnerColor: '#cccccc', + + // Databar + zeroLineColor: '#888888', + positiveBarColor: '#00ff00', + negativeBarColor: '#ff0000', + + dataBarHorizontalPadding: 90, }); export default defaultTheme; diff --git a/packages/grid/src/GridUtils.ts b/packages/grid/src/GridUtils.ts index 5c6a216e0c..46eb59b3ae 100644 --- a/packages/grid/src/GridUtils.ts +++ b/packages/grid/src/GridUtils.ts @@ -1,7 +1,7 @@ import React from 'react'; import clamp from 'lodash.clamp'; import { find as linkifyFind } from 'linkifyjs'; -import { EMPTY_ARRAY } from '@deephaven/utils'; +import { EMPTY_ARRAY, getOrThrow } from '@deephaven/utils'; import GridRange, { GridRangeIndex } from './GridRange'; import { BoxCoordinates, @@ -23,6 +23,8 @@ import { isBoundedAxisRange, Range, } from './GridAxisRange'; +import { isExpandableGridModel } from './ExpandableGridModel'; +import { GridRenderState } from './GridRendererTypes'; export type GridPoint = { x: Coordinate; @@ -1441,6 +1443,75 @@ export class GridUtils { }; } + /** + * Gets textWidth and X-Y position for a specific cell + * The textWidth returned is the width that the text can occupy accounting for any other cell markings + * The width accounts for tree table indents and cell padding, so it is the width the text may consume + * + * @param state GridRenderState to get the text metrics for + * @param column Column of cell to get text metrics for + * @param row Row of cell to get text metrics for + * @returns Object with width, x, and y of the text + */ + static getTextRenderMetrics( + state: GridRenderState, + column: VisibleIndex, + row: VisibleIndex + ): { + width: number; + x: number; + y: number; + } { + const { metrics, model, theme } = state; + const { + firstColumn, + allColumnXs, + allColumnWidths, + allRowYs, + allRowHeights, + modelRows, + modelColumns, + } = metrics; + const { + cellHorizontalPadding, + treeDepthIndent, + treeHorizontalPadding, + } = theme; + + const modelRow = getOrThrow(modelRows, row); + const modelColumn = getOrThrow(modelColumns, column); + const textAlign = model.textAlignForCell(modelColumn, modelRow); + const x = getOrThrow(allColumnXs, column); + const y = getOrThrow(allRowYs, row); + const columnWidth = getOrThrow(allColumnWidths, column); + const rowHeight = getOrThrow(allRowHeights, row); + const isFirstColumn = column === firstColumn; + let treeIndent = 0; + if ( + isExpandableGridModel(model) && + model.hasExpandableRows && + isFirstColumn + ) { + treeIndent = + treeDepthIndent * (model.depthForRow(row) + 1) + treeHorizontalPadding; + } + const textWidth = columnWidth - treeIndent; + let textX = x + cellHorizontalPadding; + const textY = y + rowHeight * 0.5; + if (textAlign === 'right') { + textX = x + textWidth - cellHorizontalPadding; + } else if (textAlign === 'center') { + textX = x + textWidth * 0.5; + } + textX += treeIndent; + + return { + width: textWidth - cellHorizontalPadding * 2, + x: textX, + y: textY, + }; + } + /** * Finds tokens in text (urls, emails) that start with https:// or http:// * @param text The text to search in diff --git a/packages/grid/src/MockDataBarGridModel.ts b/packages/grid/src/MockDataBarGridModel.ts new file mode 100644 index 0000000000..c7d246c33f --- /dev/null +++ b/packages/grid/src/MockDataBarGridModel.ts @@ -0,0 +1,160 @@ +/* eslint-disable class-methods-use-this */ +import { getOrThrow } from '@deephaven/utils'; +import { CellRenderType } from './CellRenderer'; +import { + AxisOption, + ColorMap, + ColumnAxisMap, + DataBarGridModel, + DataBarOptions, + DirectionMap, + MarkerMap, + MaxMap, + MinMap, + OpacityMap, + TextAlignmentMap, + ValuePlacementMap, +} from './DataBarGridModel'; +import { ModelIndex } from './GridMetrics'; +import GridModel from './GridModel'; +import GridTheme from './GridTheme'; + +const DEFAULT_AXIS: AxisOption = 'proportional'; +const DEFAULT_POSITIVE_COLOR = GridTheme.positiveBarColor; +const DEFAULT_NEGATIVE_COLOR = GridTheme.negativeBarColor; +const DEFAULT_VALUE_PLACEMENT = 'beside'; +const DEFAULT_DIRECTION = 'LTR'; +const DEFAULT_TEXT_ALIGNMENT = 'right'; + +function isArrayOfNumbers(value: unknown): value is number[] { + return Array.isArray(value) && value.every(item => typeof item === 'number'); +} + +class MockDataBarGridModel extends GridModel implements DataBarGridModel { + private numberOfColumns; + + private numberOfRows; + + private data: unknown[][]; + + columnMins: MinMap; + + columnMaxs: MaxMap; + + columnAxes: ColumnAxisMap; + + valuePlacements: ValuePlacementMap; + + directions: DirectionMap; + + positiveColors: ColorMap; + + negativeColors: ColorMap; + + // Opacities should be between 0 and 1 + opacities: OpacityMap; + + textAlignments: TextAlignmentMap; + + markers: MarkerMap; + + constructor( + data: unknown[][], + columnAxes = new Map(), + positiveColors = new Map(), + negativeColors = new Map(), + valuePlacements = new Map(), + opacities = new Map(), + directions = new Map(), + textAlignments = new Map(), + markers: MarkerMap = new Map() + ) { + super(); + + this.positiveColors = positiveColors; + this.negativeColors = negativeColors; + this.data = data; + this.columnAxes = columnAxes; + this.valuePlacements = valuePlacements; + this.opacities = opacities; + this.directions = directions; + this.textAlignments = textAlignments; + this.markers = markers; + this.numberOfRows = Math.max(...data.map(row => row.length)); + this.numberOfColumns = data.length; + this.columnMins = new Map(); + this.columnMaxs = new Map(); + + for (let i = 0; i < data.length; i += 1) { + const column = data[i]; + if (isArrayOfNumbers(column)) { + this.columnMins.set(i, Math.min(...column)); + this.columnMaxs.set(i, Math.max(...column)); + } + } + } + + get rowCount() { + return this.numberOfRows; + } + + get columnCount() { + return this.numberOfColumns; + } + + textForCell(column: number, row: number): string { + return `${this.data[column]?.[row]}`; + } + + textForColumnHeader(column: number): string { + return `${column}`; + } + + textAlignForCell(column: number, row: number): CanvasTextAlign { + return this.textAlignments.get(column) ?? DEFAULT_TEXT_ALIGNMENT; + } + + renderTypeForCell(column: ModelIndex, row: ModelIndex): CellRenderType { + if (column < 20) { + return 'dataBar'; + } + return column % 2 === row % 2 ? 'dataBar' : 'text'; + } + + dataBarOptionsForCell(column: ModelIndex, row: ModelIndex): DataBarOptions { + const columnMin = getOrThrow(this.columnMins, column); + const columnMax = getOrThrow(this.columnMaxs, column); + const axis = this.columnAxes.get(column) ?? DEFAULT_AXIS; + const valuePlacement = + this.valuePlacements.get(column) ?? DEFAULT_VALUE_PLACEMENT; + let opacity = this.opacities.get(column); + if (opacity == null || opacity > 1) { + opacity = 1; + } else if (opacity < 0) { + opacity = 0; + } + const direction = this.directions.get(column) ?? DEFAULT_DIRECTION; + const positiveColor = + this.positiveColors.get(column) ?? DEFAULT_POSITIVE_COLOR; + const negativeColor = + this.negativeColors.get(column) ?? DEFAULT_NEGATIVE_COLOR; + + const value = Number(this.data[column]?.[row]); + const color = value >= 0 ? positiveColor : negativeColor; + const markers = this.markers.get(column) ?? []; + + return { + columnMin, + columnMax, + axis, + color, + valuePlacement, + opacity, + markers, + direction, + value, + }; + } +} + +export default MockDataBarGridModel; diff --git a/packages/grid/src/TextCellRenderer.ts b/packages/grid/src/TextCellRenderer.ts new file mode 100644 index 0000000000..685d237dba --- /dev/null +++ b/packages/grid/src/TextCellRenderer.ts @@ -0,0 +1,271 @@ +/* eslint-disable class-methods-use-this */ +import { EMPTY_ARRAY, getOrThrow } from '@deephaven/utils'; +import CellRenderer from './CellRenderer'; +import { isExpandableGridModel } from './ExpandableGridModel'; +import { VisibleIndex } from './GridMetrics'; +import { DEFAULT_FONT_WIDTH, GridRenderState } from './GridRendererTypes'; +import GridUtils, { TokenBox, Token } from './GridUtils'; +import memoizeClear from './memoizeClear'; +import TokenBoxCellRenderer from './TokenBoxCellRenderer'; + +class TextCellRenderer extends CellRenderer implements TokenBoxCellRenderer { + drawCellContent( + context: CanvasRenderingContext2D, + state: GridRenderState, + column: VisibleIndex, + row: VisibleIndex + ): void { + const { metrics, model, theme } = state; + const { + fontWidths, + modelColumns, + modelRows, + allRowHeights, + firstColumn, + } = metrics; + const isFirstColumn = column === firstColumn; + const { textColor } = theme; + const rowHeight = getOrThrow(allRowHeights, row); + const modelRow = getOrThrow(modelRows, row); + const modelColumn = getOrThrow(modelColumns, column); + const text = model.textForCell(modelColumn, modelRow); + const truncationChar = model.truncationCharForCell(modelColumn, modelRow); + + if (text && rowHeight > 0) { + const textAlign = model.textAlignForCell(modelColumn, modelRow) || 'left'; + context.textAlign = textAlign; + + const color = + model.colorForCell(modelColumn, modelRow, theme) || textColor; + context.fillStyle = color; + + context.save(); + + const { + width: textWidth, + x: textX, + y: textY, + } = GridUtils.getTextRenderMetrics(state, column, row); + + const fontWidth = fontWidths.get(context.font) ?? DEFAULT_FONT_WIDTH; + const truncatedText = this.getCachedTruncatedString( + context, + text, + textWidth, + fontWidth, + truncationChar + ); + + const tokens = model.tokensForCell( + modelColumn, + modelRow, + truncatedText.length + ); + + if (truncatedText) { + let tokenIndex = 0; + let textStart = 0; + let left = textX; + const { actualBoundingBoxDescent } = context.measureText(truncatedText); + + while (textStart < truncatedText.length) { + const nextToken = tokens[tokenIndex]; + const token = textStart === nextToken?.start ? nextToken : null; + const textEnd = + token?.end ?? nextToken?.start ?? truncatedText.length; + const value = truncatedText.substring(textStart, textEnd); + const { width } = context.measureText(value); + const widthOfUnderline = value.endsWith('…') + ? context.measureText(value.substring(0, value.length - 1)).width + : width; + + // Set the styling based on the token, then draw the text + if (token != null) { + context.fillStyle = theme.hyperlinkColor; + context.fillText(value, left, textY); + context.fillRect( + left, + textY + actualBoundingBoxDescent, + widthOfUnderline, + 1 + ); + } else { + context.fillStyle = color; + context.fillText(value, left, textY); + } + + left += width; + textStart = textEnd; + if (token != null) tokenIndex += 1; + } + } + context.restore(); + } + + if ( + isFirstColumn && + isExpandableGridModel(model) && + model.hasExpandableRows + ) { + this.drawCellRowTreeMarker(context, state, row); + } + } + + /** + * Gets the token boxes that are visible in the cell + * @param column The visible column + * @param row The visible row + * @param state The GridRenderState + * @returns An array of TokenBox of visible tokens or empty array with coordinates relative to gridX and gridY + */ + getTokenBoxesForVisibleCell( + column: VisibleIndex, + row: VisibleIndex, + state: GridRenderState + ): TokenBox[] { + const { metrics, context, model, theme } = state; + + if (context == null || metrics == null) { + return (EMPTY_ARRAY as unknown) as TokenBox[]; + } + + const { modelRows, modelColumns } = metrics; + const modelRow = getOrThrow(modelRows, row); + const modelColumn = getOrThrow(modelColumns, column); + + const text = model.textForCell(modelColumn, modelRow); + const { + width: textWidth, + x: textX, + y: textY, + } = GridUtils.getTextRenderMetrics(state, column, row); + + const { fontWidths } = metrics; + + // Set the font and baseline and change it back after + context.save(); + this.configureContext(context, state); + + const fontWidth = fontWidths?.get(context.font) ?? DEFAULT_FONT_WIDTH; + const truncationChar = model.truncationCharForCell(modelColumn, modelRow); + const truncatedText = this.getCachedTruncatedString( + context, + text, + textWidth, + fontWidth, + truncationChar + ); + + const { + actualBoundingBoxAscent, + actualBoundingBoxDescent, + } = context.measureText(truncatedText); + const textHeight = actualBoundingBoxAscent + actualBoundingBoxDescent; + + const tokens = model.tokensForCell( + modelColumn, + modelRow, + truncatedText.length + ); + + // Check if the truncated text contains a link + if (tokens.length === 0) { + context.restore(); + return (EMPTY_ARRAY as unknown) as TokenBox[]; + } + + const cachedTokenBoxes = this.getCachedTokenBoxesForVisibleCell( + truncatedText, + tokens, + theme.font, + 'middle', + textHeight, + context + ).map(tokenBox => ({ + x1: tokenBox.x1 + textX, + y1: tokenBox.y1 + (textY - actualBoundingBoxAscent), + x2: tokenBox.x2 + textX, + y2: tokenBox.y2 + (textY - actualBoundingBoxAscent), + token: tokenBox.token, + })); + + context.restore(); + + return cachedTokenBoxes; + } + + configureContext( + context: CanvasRenderingContext2D, + state: GridRenderState + ): void { + const { theme } = state; + context.font = theme.font; + context.textBaseline = 'middle'; + context.lineCap = 'butt'; + } + + /** + * Returns an array of token boxes with the coordinates relative to the top left corner of the text + */ + getCachedTokenBoxesForVisibleCell = memoizeClear( + ( + truncatedText: string, + tokens: Token[], + // _font and _baseline are passed in so value is re-calculated when they change + // They should already be set on the `context`, so they are not used in this method + _font: string, + _baseline: CanvasTextBaseline, + textHeight: number, + context: CanvasRenderingContext2D + ): TokenBox[] => { + const top = 0; + const bottom = textHeight; + + const tokenBoxes: TokenBox[] = []; + + // The index where the last token ended + let lastTokenEnd = 0; + // The width of the text preceding the current token + let currentTextWidth = 0; + // Loop through array and push them to array + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + const { start, end } = token; + // The last token value is calculated based on the full text so the value needs to be truncated + const value = + end > truncatedText.length + ? truncatedText.substring(start) + : token.value; + + // Add the width of the text in between this token and the last token + currentTextWidth += context.measureText( + truncatedText.substring(lastTokenEnd, start) + ).width; + const tokenWidth = context.measureText(value).width; + + // Check if the x position is less than the grid x, then tokenWidth should be shifted by gridX - startX + + const left = currentTextWidth; + const right = left + tokenWidth; + + const newTokenBox: TokenBox = { + x1: left, + y1: top, + x2: right, + y2: bottom, + token, + }; + + tokenBoxes.push(newTokenBox); + + lastTokenEnd = end; + currentTextWidth += tokenWidth; + } + + return tokenBoxes; + }, + { max: 10000 } + ); +} + +export default TextCellRenderer; diff --git a/packages/grid/src/TokenBoxCellRenderer.ts b/packages/grid/src/TokenBoxCellRenderer.ts new file mode 100644 index 0000000000..834205e82e --- /dev/null +++ b/packages/grid/src/TokenBoxCellRenderer.ts @@ -0,0 +1,23 @@ +import CellRenderer from './CellRenderer'; +import { VisibleIndex } from './GridMetrics'; +import { GridRenderState } from './GridRendererTypes'; +import { TokenBox } from './GridUtils'; + +export function isTokenBoxCellRenderer( + cellRenderer: CellRenderer +): cellRenderer is TokenBoxCellRenderer { + return ( + (cellRenderer as TokenBoxCellRenderer)?.getTokenBoxesForVisibleCell !== + undefined + ); +} + +interface TokenBoxCellRenderer extends CellRenderer { + getTokenBoxesForVisibleCell( + column: VisibleIndex, + row: VisibleIndex, + state: GridRenderState + ): TokenBox[]; +} + +export default TokenBoxCellRenderer; diff --git a/packages/grid/src/index.ts b/packages/grid/src/index.ts index 8ea272b666..0ba0217031 100644 --- a/packages/grid/src/index.ts +++ b/packages/grid/src/index.ts @@ -20,9 +20,14 @@ export { default as MockTreeGridModel } from './MockTreeGridModel'; export { default as memoizeClear } from './memoizeClear'; export { default as StaticDataGridModel } from './StaticDataGridModel'; export { default as ViewportDataGridModel } from './ViewportDataGridModel'; +export { default as MockDataBarGridModel } from './MockDataBarGridModel'; export * from './key-handlers'; export * from './mouse-handlers'; export * from './errors'; export * from './EventHandlerResult'; export { default as ThemeContext } from './ThemeContext'; +export type { default as CellRenderer, CellRenderType } from './CellRenderer'; +export { default as TextCellRenderer } from './TextCellRenderer'; +export { default as DataBarCellRenderer } from './DataBarCellRenderer'; +export * from './TokenBoxCellRenderer'; export * from './GridRendererTypes'; diff --git a/packages/grid/src/mouse-handlers/GridTokenMouseHandler.ts b/packages/grid/src/mouse-handlers/GridTokenMouseHandler.ts index 09ed028a89..648aa5aee4 100644 --- a/packages/grid/src/mouse-handlers/GridTokenMouseHandler.ts +++ b/packages/grid/src/mouse-handlers/GridTokenMouseHandler.ts @@ -1,11 +1,13 @@ /* eslint class-methods-use-this: "off" */ +import { getOrThrow } from '@deephaven/utils'; import { isEditableGridModel } from '../EditableGridModel'; import { EventHandlerResult } from '../EventHandlerResult'; import Grid from '../Grid'; import GridMouseHandler, { GridMouseEvent } from '../GridMouseHandler'; import GridRange from '../GridRange'; import GridUtils, { GridPoint, isLinkToken, TokenBox } from '../GridUtils'; +import { isTokenBoxCellRenderer } from '../TokenBoxCellRenderer'; class GridTokenMouseHandler extends GridMouseHandler { timeoutId?: ReturnType; @@ -21,13 +23,24 @@ class GridTokenMouseHandler extends GridMouseHandler { isHoveringLink(gridPoint: GridPoint, grid: Grid): boolean { const { column, row, x, y } = gridPoint; - const { renderer, metrics } = grid; + const { renderer, metrics, props } = grid; + const { model } = props; if (column == null || row == null || metrics == null) { this.currentLinkBox = undefined; return false; } + const { modelRows, modelColumns } = metrics; + const modelRow = getOrThrow(modelRows, row); + const modelColumn = getOrThrow(modelColumns, column); + + const renderType = model.renderTypeForCell(modelColumn, modelRow); + const cellRenderer = renderer.getCellRenderer(renderType); + if (!isTokenBoxCellRenderer(cellRenderer)) { + return false; + } + if (this.currentLinkBox != null) { const { x1: left, y1: top, x2: right, y2: bottom } = this.currentLinkBox; if (x >= left && x <= right && y >= top && y <= bottom) { @@ -36,7 +49,8 @@ class GridTokenMouseHandler extends GridMouseHandler { } const renderState = grid.updateRenderState(); - const tokensInCell = renderer.getTokenBoxesForVisibleCell( + + const tokensInCell = cellRenderer.getTokenBoxesForVisibleCell( column, row, renderState diff --git a/packages/iris-grid/src/IrisGridCellRendererUtils.ts b/packages/iris-grid/src/IrisGridCellRendererUtils.ts new file mode 100644 index 0000000000..3b322fd99a --- /dev/null +++ b/packages/iris-grid/src/IrisGridCellRendererUtils.ts @@ -0,0 +1,31 @@ +import { BoxCoordinates, Coordinate } from '@deephaven/grid'; +import { getIcon } from './IrisGridIcons'; +import { IrisGridRenderState } from './IrisGridRenderer'; + +class IrisGridCellRendererUtils { + static drawTreeMarker( + context: CanvasRenderingContext2D, + state: IrisGridRenderState, + columnX: Coordinate, + rowY: Coordinate, + treeBox: BoxCoordinates, + color: string, + isExpanded: boolean + ): void { + context.save(); + const { x1, y1 } = treeBox; + const markerIcon = isExpanded + ? getIcon('caretDown') + : getIcon('caretRight'); + const iconX = columnX + x1 - 2; + const iconY = rowY + y1 + 2.5; + + context.fillStyle = color; + context.textAlign = 'center'; + context.translate(iconX, iconY); + context.fill(markerIcon); + context.restore(); + } +} + +export default IrisGridCellRendererUtils; diff --git a/packages/iris-grid/src/IrisGridDataBarCellRenderer.ts b/packages/iris-grid/src/IrisGridDataBarCellRenderer.ts new file mode 100644 index 0000000000..d70239491e --- /dev/null +++ b/packages/iris-grid/src/IrisGridDataBarCellRenderer.ts @@ -0,0 +1,32 @@ +/* eslint-disable class-methods-use-this */ +import { + BoxCoordinates, + Coordinate, + DataBarCellRenderer, +} from '@deephaven/grid'; +import { IrisGridRenderState } from './IrisGridRenderer'; +import IrisGridCellRendererUtils from './IrisGridCellRendererUtils'; + +class IrisGridDataBarCellRenderer extends DataBarCellRenderer { + drawTreeMarker( + context: CanvasRenderingContext2D, + state: IrisGridRenderState, + columnX: Coordinate, + rowY: Coordinate, + treeBox: BoxCoordinates, + color: string, + isExpanded: boolean + ): void { + IrisGridCellRendererUtils.drawTreeMarker( + context, + state, + columnX, + rowY, + treeBox, + color, + isExpanded + ); + } +} + +export default IrisGridDataBarCellRenderer; diff --git a/packages/iris-grid/src/IrisGridIcons.ts b/packages/iris-grid/src/IrisGridIcons.ts new file mode 100644 index 0000000000..9dcd58a46f --- /dev/null +++ b/packages/iris-grid/src/IrisGridIcons.ts @@ -0,0 +1,52 @@ +import { memoizeClear } from '@deephaven/grid'; +import { + dhSortDown, + dhSortUp, + vsTriangleDown, + vsTriangleRight, + vsLinkExternal, + IconDefinition, +} from '@deephaven/icons'; + +export const ICON_SIZE = 16; + +export type IconName = + | 'sortUp' + | 'sortDown' + | 'caretDown' + | 'caretRight' + | 'cellOverflow'; + +const iconMap = new Map([ + ['sortUp', dhSortUp], + ['sortDown', dhSortDown], + ['caretDown', vsTriangleDown], + ['caretRight', vsTriangleRight], + ['cellOverflow', vsLinkExternal], +]); + +const makeIcon = memoizeClear( + (name: IconName) => { + const faIcon = iconMap.get(name); + if (faIcon === undefined) { + throw new Error('Icon is undefined'); + } + + const path = Array.isArray(faIcon.icon[4]) + ? faIcon.icon[4][0] + : faIcon.icon[4]; + const icon = new Path2D(path); + const scaledIcon = new Path2D(); + const scaleMatrix = { + a: ICON_SIZE / faIcon.icon[0], + d: ICON_SIZE / faIcon.icon[1], + }; + scaledIcon.addPath(icon, scaleMatrix); + return scaledIcon; + }, + { max: 1000 } +); + +export function getIcon(name: IconName): Path2D { + return makeIcon(name); +} diff --git a/packages/iris-grid/src/IrisGridRenderer.ts b/packages/iris-grid/src/IrisGridRenderer.ts index 96927440b9..fa12dc155d 100644 --- a/packages/iris-grid/src/IrisGridRenderer.ts +++ b/packages/iris-grid/src/IrisGridRenderer.ts @@ -1,18 +1,10 @@ /* eslint react/destructuring-assignment: "off" */ /* eslint class-methods-use-this: "off" */ /* eslint no-param-reassign: "off" */ -import { - dhSortDown, - dhSortUp, - vsTriangleDown, - vsTriangleRight, - vsLinkExternal, - IconDefinition, -} from '@deephaven/icons'; import { BoundedAxisRange, - BoxCoordinates, Coordinate, + DEFAULT_FONT_WIDTH, GridMetrics, GridRangeIndex, GridRenderer, @@ -32,6 +24,9 @@ import { } from './CommonTypes'; import { IrisGridThemeType } from './IrisGridTheme'; import IrisGridModel from './IrisGridModel'; +import IrisGridTextCellRenderer from './IrisGridTextCellRenderer'; +import IrisGridDataBarCellRenderer from './IrisGridDataBarCellRenderer'; +import { getIcon } from './IrisGridIcons'; const ICON_NAMES = Object.freeze({ SORT_UP: 'sortUp', @@ -42,7 +37,6 @@ const ICON_NAMES = Object.freeze({ }); const EXPAND_ICON_SIZE = 10; -const ICON_SIZE = 16; export type IrisGridRenderState = GridRenderState & { model: IrisGridModel; @@ -71,51 +65,19 @@ class IrisGridRenderer extends GridRenderer { return isAdvancedFilterValid && isQuickFilterValid; } - constructor() { - super(); - this.icons = {}; - - this.initIcons(); - } - - icons: Record; - - initIcons(): void { - this.setIcon(ICON_NAMES.SORT_UP, dhSortUp); - this.setIcon(ICON_NAMES.SORT_DOWN, dhSortDown); - this.setIcon(ICON_NAMES.CARET_DOWN, vsTriangleDown); - this.setIcon(ICON_NAMES.CARET_RIGHT, vsTriangleRight); - this.setIcon(ICON_NAMES.CELL_OVERFLOW, vsLinkExternal); - } - - // Scales the icon to be square and match the global ICON_SIZE - setIcon(name: string, faIcon: IconDefinition): void { - const path = Array.isArray(faIcon.icon[4]) - ? faIcon.icon[4][0] - : faIcon.icon[4]; - const icon = new Path2D(path); - const scaledIcon = new Path2D(); - const scaleMatrix = { - a: ICON_SIZE / faIcon.icon[0], - d: ICON_SIZE / faIcon.icon[1], - }; - scaledIcon.addPath(icon, scaleMatrix); - this.icons[name] = scaledIcon; - } + protected textCellRenderer = new IrisGridTextCellRenderer(); - getIcon(name: string): Path2D { - return this.icons[name]; - } + protected dataBarCellRenderer = new IrisGridDataBarCellRenderer(); getSortIcon(sort: Sort | null): Path2D | null { if (!sort) { return null; } if (sort.direction === TableUtils.sortDirection.ascending) { - return this.getIcon(ICON_NAMES.SORT_UP); + return getIcon(ICON_NAMES.SORT_UP); } if (sort.direction === TableUtils.sortDirection.descending) { - return this.getIcon(ICON_NAMES.SORT_DOWN); + return getIcon(ICON_NAMES.SORT_DOWN); } return null; } @@ -153,23 +115,89 @@ class IrisGridRenderer extends GridRenderer { ): void { const { metrics, model } = state; const { modelColumns, modelRows } = metrics; - const modelRow = getOrThrow(modelRows, row); const modelColumn = modelColumns.get(column); + const modelRow = getOrThrow(modelRows, row); if (modelColumn === undefined) { return; } - const value = model.valueForCell(modelColumn, modelRow); - if (TableUtils.isTextType(model.columns[modelColumn]?.type)) { - if (value === null || value === '') { - const originalFont = context.font; - context.font = `italic ${originalFont}`; - const displayValue = value === null ? 'null' : 'empty'; - super.drawCellContent(context, state, column, row, displayValue); - context.font = originalFont; - return; - } + + const renderType = model.renderTypeForCell(modelColumn, modelRow); + const cellRenderer = this.getCellRenderer(renderType); + cellRenderer.drawCellContent(context, state, column, row); + } + + getCellOverflowButtonPosition({ + mouseX, + mouseY, + metrics, + theme, + }: { + mouseX: Coordinate | null; + mouseY: Coordinate | null; + metrics: GridMetrics | undefined; + theme: GridThemeType; + }): { + left: Coordinate | null; + top: Coordinate | null; + width: number | null; + height: number | null; + } { + return this.textCellRenderer.getCellOverflowButtonPosition( + mouseX, + mouseY, + metrics, + theme + ); + } + + shouldRenderOverflowButton(state: IrisGridRenderState): boolean { + return this.textCellRenderer.shouldRenderOverflowButton(state); + } + + drawCellOverflowButton(state: IrisGridRenderState): void { + const { context, mouseX, mouseY, theme } = state; + if (mouseX == null || mouseY == null) return; + + if (!this.shouldRenderOverflowButton(state)) { + return; + } + + const { + left: buttonLeft, + top: buttonTop, + width: buttonWidth, + height: buttonHeight, + } = this.getCellOverflowButtonPosition(state); + + const { + cellHorizontalPadding, + overflowButtonColor, + overflowButtonHoverColor, + } = theme; + + context.save(); + if ( + overflowButtonHoverColor != null && + buttonLeft != null && + buttonWidth != null && + buttonTop != null && + buttonHeight != null && + mouseX >= buttonLeft && + mouseX <= buttonLeft + buttonWidth && + mouseY >= buttonTop && + mouseY <= buttonTop + buttonHeight + ) { + context.fillStyle = overflowButtonHoverColor; + } else if (overflowButtonColor != null) { + context.fillStyle = overflowButtonColor; + } + const icon = getIcon(ICON_NAMES.CELL_OVERFLOW); + if (buttonLeft != null && buttonTop != null) { + context.translate(buttonLeft + cellHorizontalPadding, buttonTop + 2); } - super.drawCellContent(context, state, column, row); + context.fill(icon); + + context.restore(); } drawGroupedColumnLine( @@ -473,8 +501,7 @@ class IrisGridRenderer extends GridRenderer { return; } - const fontWidth = - fontWidths.get(context.font) ?? GridRenderer.DEFAULT_FONT_WIDTH; + const fontWidth = fontWidths.get(context.font) ?? DEFAULT_FONT_WIDTH; assertNotNull(fontWidth); const textWidth = text.length * fontWidth; const textRight = gridX + columnX + textWidth + headerHorizontalPadding; @@ -783,30 +810,6 @@ class IrisGridRenderer extends GridRenderer { context.restore(); } - drawTreeMarker( - context: CanvasRenderingContext2D, - state: IrisGridRenderState, - columnX: Coordinate, - rowY: Coordinate, - treeBox: BoxCoordinates, - color: string, - isExpanded: boolean - ): void { - context.save(); - const { x1, y1 } = treeBox; - const markerIcon = isExpanded - ? this.getIcon(ICON_NAMES.CARET_DOWN) - : this.getIcon(ICON_NAMES.CARET_RIGHT); - const iconX = columnX + x1 - 2; - const iconY = rowY + y1 + 2.5; - - context.fillStyle = color; - context.textAlign = 'center'; - context.translate(iconX, iconY); - context.fill(markerIcon); - context.restore(); - } - drawRowFooters( context: CanvasRenderingContext2D, state: IrisGridRenderState @@ -912,174 +915,6 @@ class IrisGridRenderer extends GridRenderer { context.translate(-gridX, -gridY); } - // This will shrink the size the text may take when the overflow button is rendered - // The text will truncate to a smaller width and won't overlap the button - getTextRenderMetrics( - state: IrisGridRenderState, - column: VisibleIndex, - row: VisibleIndex - ): { - width: number; - x: Coordinate; - y: Coordinate; - } { - const textMetrics = super.getTextRenderMetrics(state, column, row); - - const { mouseX, mouseY, metrics } = state; - - if (mouseX == null || mouseY == null) { - return textMetrics; - } - - const { column: mouseColumn, row: mouseRow } = GridUtils.getGridPointFromXY( - mouseX, - mouseY, - metrics - ); - - if (column === mouseColumn && row === mouseRow) { - const { left } = this.getCellOverflowButtonPosition(state); - if (this.shouldRenderOverflowButton(state) && left != null) { - textMetrics.width = left - metrics.gridX - textMetrics.x; - } - } - return textMetrics; - } - - shouldRenderOverflowButton(state: IrisGridRenderState): boolean { - const { context, mouseX, mouseY, metrics, model, theme } = state; - if (mouseX == null || mouseY == null) { - return false; - } - - const { row, column, modelRow, modelColumn } = GridUtils.getCellInfoFromXY( - mouseX, - mouseY, - metrics - ); - - if ( - row == null || - column == null || - modelRow == null || - modelColumn == null || - !TableUtils.isStringType(model.columns[modelColumn].type) - ) { - return false; - } - - const text = model.textForCell(modelColumn, modelRow) ?? ''; - const { width: textWidth } = super.getTextRenderMetrics(state, column, row); - const fontWidth = - metrics.fontWidths.get(theme.font) ?? IrisGridRenderer.DEFAULT_FONT_WIDTH; - - context.save(); - context.font = theme.font; - - const truncatedText = this.getCachedTruncatedString( - context, - text, - textWidth, - fontWidth, - model.truncationCharForCell(modelColumn, modelRow) - ); - context.restore(); - - return text !== '' && truncatedText !== text; - } - - getCellOverflowButtonPosition({ - mouseX, - mouseY, - metrics, - theme, - }: { - mouseX: Coordinate | null; - mouseY: Coordinate | null; - metrics: GridMetrics | undefined; - theme: GridThemeType; - }): { - left: Coordinate | null; - top: Coordinate | null; - width: number | null; - height: number | null; - } { - const NULL_POSITION = { left: null, top: null, width: null, height: null }; - if (mouseX == null || mouseY == null || metrics == null) { - return NULL_POSITION; - } - const { rowHeight, columnWidth, left, top } = GridUtils.getCellInfoFromXY( - mouseX, - mouseY, - metrics - ); - - if (left == null || columnWidth == null || top == null) { - return NULL_POSITION; - } - - const { width: gridWidth, verticalBarWidth } = metrics; - const { cellHorizontalPadding } = theme; - - const width = ICON_SIZE + 2 * cellHorizontalPadding; - const height = rowHeight; - // Right edge of column or of visible grid, whichever is smaller - const right = Math.min( - metrics.gridX + left + columnWidth, - gridWidth - verticalBarWidth - ); - const buttonLeft = right - width; - const buttonTop = metrics.gridY + top; - - return { left: buttonLeft, top: buttonTop, width, height }; - } - - drawCellOverflowButton(state: IrisGridRenderState): void { - const { context, mouseX, mouseY, theme } = state; - if (mouseX == null || mouseY == null) return; - - if (!this.shouldRenderOverflowButton(state)) { - return; - } - - const { - left: buttonLeft, - top: buttonTop, - width: buttonWidth, - height: buttonHeight, - } = this.getCellOverflowButtonPosition(state); - - const { - cellHorizontalPadding, - overflowButtonColor, - overflowButtonHoverColor, - } = theme; - - context.save(); - if ( - overflowButtonHoverColor != null && - buttonLeft != null && - buttonWidth != null && - buttonTop != null && - buttonHeight != null && - mouseX >= buttonLeft && - mouseX <= buttonLeft + buttonWidth && - mouseY >= buttonTop && - mouseY <= buttonTop + buttonHeight - ) { - context.fillStyle = overflowButtonHoverColor; - } else if (overflowButtonColor != null) { - context.fillStyle = overflowButtonColor; - } - const icon = this.getIcon(ICON_NAMES.CELL_OVERFLOW); - if (buttonLeft != null && buttonTop != null) { - context.translate(buttonLeft + cellHorizontalPadding, buttonTop + 2); - } - context.fill(icon); - - context.restore(); - } - getExpandButtonPosition( { mouseX, diff --git a/packages/iris-grid/src/IrisGridTableModelTemplate.ts b/packages/iris-grid/src/IrisGridTableModelTemplate.ts index df075fbae3..65940255b4 100644 --- a/packages/iris-grid/src/IrisGridTableModelTemplate.ts +++ b/packages/iris-grid/src/IrisGridTableModelTemplate.ts @@ -525,6 +525,17 @@ class IrisGridTableModelTemplate< return '*'; } } + + if (TableUtils.isTextType(this.columns[x]?.type)) { + if (text === null) { + return 'null'; + } + + if (text === '') { + return 'empty'; + } + } + return text ?? ''; } diff --git a/packages/iris-grid/src/IrisGridTextCellRenderer.ts b/packages/iris-grid/src/IrisGridTextCellRenderer.ts new file mode 100644 index 0000000000..e243b258e8 --- /dev/null +++ b/packages/iris-grid/src/IrisGridTextCellRenderer.ts @@ -0,0 +1,191 @@ +/* eslint-disable class-methods-use-this */ +import { + BoxCoordinates, + Coordinate, + DEFAULT_FONT_WIDTH, + getOrThrow, + GridMetrics, + GridThemeType, + GridUtils, + TextCellRenderer, + VisibleIndex, +} from '@deephaven/grid'; +import { TableUtils } from '@deephaven/jsapi-utils'; +import { IrisGridRenderState } from './IrisGridRenderer'; +import { ICON_SIZE } from './IrisGridIcons'; +import IrisGridCellRendererUtils from './IrisGridCellRendererUtils'; + +class IrisGridTextCellRenderer extends TextCellRenderer { + drawCellContent( + context: CanvasRenderingContext2D, + state: IrisGridRenderState, + column: VisibleIndex, + row: VisibleIndex + ): void { + const { metrics, model } = state; + const { modelColumns, modelRows } = metrics; + const modelRow = getOrThrow(modelRows, row); + const modelColumn = modelColumns.get(column); + if (modelColumn === undefined) { + return; + } + const value = model.valueForCell(modelColumn, modelRow); + if (TableUtils.isTextType(model.columns[modelColumn]?.type)) { + if (value === null || value === '') { + const originalFont = context.font; + context.font = `italic ${originalFont}`; + super.drawCellContent(context, state, column, row); + context.font = originalFont; + return; + } + } + super.drawCellContent(context, state, column, row); + } + + // This will shrink the size the text may take when the overflow button is rendered + // The text will truncate to a smaller width and won't overlap the button + getTextRenderMetrics( + state: IrisGridRenderState, + column: VisibleIndex, + row: VisibleIndex + ): { + width: number; + x: Coordinate; + y: Coordinate; + } { + const textMetrics = GridUtils.getTextRenderMetrics(state, column, row); + + const { mouseX, mouseY, metrics, theme } = state; + + if (mouseX == null || mouseY == null) { + return textMetrics; + } + + const { column: mouseColumn, row: mouseRow } = GridUtils.getGridPointFromXY( + mouseX, + mouseY, + metrics + ); + + if (column === mouseColumn && row === mouseRow) { + const { left } = this.getCellOverflowButtonPosition( + mouseX, + mouseY, + metrics, + theme + ); + if (this.shouldRenderOverflowButton(state) && left != null) { + textMetrics.width = left - metrics.gridX - textMetrics.x; + } + } + return textMetrics; + } + + getCellOverflowButtonPosition( + mouseX: Coordinate | null, + mouseY: Coordinate | null, + metrics: GridMetrics | undefined, + theme: GridThemeType + ): { + left: Coordinate | null; + top: Coordinate | null; + width: number | null; + height: number | null; + } { + const NULL_POSITION = { left: null, top: null, width: null, height: null }; + if (mouseX == null || mouseY == null || metrics == null) { + return NULL_POSITION; + } + const { rowHeight, columnWidth, left, top } = GridUtils.getCellInfoFromXY( + mouseX, + mouseY, + metrics + ); + + if (left == null || columnWidth == null || top == null) { + return NULL_POSITION; + } + + const { width: gridWidth, verticalBarWidth } = metrics; + const { cellHorizontalPadding } = theme; + + const width = ICON_SIZE + 2 * cellHorizontalPadding; + const height = rowHeight; + // Right edge of column or of visible grid, whichever is smaller + const right = Math.min( + metrics.gridX + left + columnWidth, + gridWidth - verticalBarWidth + ); + const buttonLeft = right - width; + const buttonTop = metrics.gridY + top; + + return { left: buttonLeft, top: buttonTop, width, height }; + } + + shouldRenderOverflowButton(state: IrisGridRenderState): boolean { + const { context, mouseX, mouseY, metrics, model, theme } = state; + if (mouseX == null || mouseY == null) { + return false; + } + + const { row, column, modelRow, modelColumn } = GridUtils.getCellInfoFromXY( + mouseX, + mouseY, + metrics + ); + + if ( + row == null || + column == null || + modelRow == null || + modelColumn == null || + !TableUtils.isStringType(model.columns[modelColumn].type) + ) { + return false; + } + + const text = model.textForCell(modelColumn, modelRow) ?? ''; + const { width: textWidth } = GridUtils.getTextRenderMetrics( + state, + column, + row + ); + const fontWidth = metrics.fontWidths.get(theme.font) ?? DEFAULT_FONT_WIDTH; + + context.save(); + context.font = theme.font; + + const truncatedText = this.getCachedTruncatedString( + context, + text, + textWidth, + fontWidth, + model.truncationCharForCell(modelColumn, modelRow) + ); + context.restore(); + + return text !== '' && truncatedText !== text; + } + + drawTreeMarker( + context: CanvasRenderingContext2D, + state: IrisGridRenderState, + columnX: Coordinate, + rowY: Coordinate, + treeBox: BoxCoordinates, + color: string, + isExpanded: boolean + ): void { + IrisGridCellRendererUtils.drawTreeMarker( + context, + state, + columnX, + rowY, + treeBox, + color, + isExpanded + ); + } +} + +export default IrisGridTextCellRenderer; diff --git a/packages/iris-grid/src/IrisGridTheme.module.scss b/packages/iris-grid/src/IrisGridTheme.module.scss index 06972a20f2..54781e9aea 100644 --- a/packages/iris-grid/src/IrisGridTheme.module.scss +++ b/packages/iris-grid/src/IrisGridTheme.module.scss @@ -91,4 +91,8 @@ $header-height: 30px; overflow-button-color: $gray-300; overflow-button-hover-color: $gray-100; + + zero-line-color: $gray-500; + positive-bar-color: $green; + negative-bar-color: $red; } diff --git a/packages/iris-grid/src/IrisGridTheme.ts b/packages/iris-grid/src/IrisGridTheme.ts index 5e6d3b8658..32f736da4d 100644 --- a/packages/iris-grid/src/IrisGridTheme.ts +++ b/packages/iris-grid/src/IrisGridTheme.ts @@ -135,6 +135,10 @@ const theme: Partial = Object.freeze({ overflowButtonColor: IrisGridTheme['overflow-button-color'], overflowButtonHoverColor: IrisGridTheme['overflow-button-hover-color'], + + zeroLineColor: IrisGridTheme['zero-line-color'], + positiveBarColor: IrisGridTheme['positive-bar-color'], + negativeBarColor: IrisGridTheme['negative-bar-color'], }); export default theme; diff --git a/packages/iris-grid/src/mousehandlers/IrisGridTokenMouseHandler.ts b/packages/iris-grid/src/mousehandlers/IrisGridTokenMouseHandler.ts index 7e1a132a21..3c97c4538e 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridTokenMouseHandler.ts +++ b/packages/iris-grid/src/mousehandlers/IrisGridTokenMouseHandler.ts @@ -1,11 +1,13 @@ import { EventHandlerResult, + getOrThrow, Grid, GridMouseHandler, GridPoint, GridUtils, isLinkToken, TokenBox, + isTokenBoxCellRenderer, } from '@deephaven/grid'; import deepEqual from 'deep-equal'; import IrisGrid from '../IrisGrid'; @@ -28,13 +30,24 @@ class IrisGridTokenMouseHandler extends GridMouseHandler { isHoveringLink(gridPoint: GridPoint, grid: Grid): boolean { const { column, row, x, y } = gridPoint; - const { renderer, metrics } = grid; + const { renderer, metrics, props } = grid; + const { model } = props; if (column == null || row == null || metrics == null) { this.currentLinkBox = undefined; return false; } + const { modelRows, modelColumns } = metrics; + const modelRow = getOrThrow(modelRows, row); + const modelColumn = getOrThrow(modelColumns, column); + + const renderType = model.renderTypeForCell(modelColumn, modelRow); + const cellRenderer = renderer.getCellRenderer(renderType); + if (!isTokenBoxCellRenderer(cellRenderer)) { + return false; + } + if (this.currentLinkBox != null) { const { x1: left, y1: top, x2: right, y2: bottom } = this.currentLinkBox; if (x >= left && x <= right && y >= top && y <= bottom) { @@ -43,7 +56,7 @@ class IrisGridTokenMouseHandler extends GridMouseHandler { } const renderState = grid.updateRenderState(); - const tokensInCell = renderer.getTokenBoxesForVisibleCell( + const tokensInCell = cellRenderer.getTokenBoxesForVisibleCell( column, row, renderState