Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Deephaven UI table databar support #2190

Merged
merged 12 commits into from
Aug 23, 2024
3 changes: 2 additions & 1 deletion packages/components/src/theme/ThemeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,9 +558,10 @@ describe.each([undefined, document.createElement('div')])(
bbb: 'bbb',
};

jest.spyOn(window.CSS, 'supports').mockReturnValue(false);
Copy link
Member

Choose a reason for hiding this comment

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

Test case for when this is true?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should be pretty much all the other test cases. We mocked it to true by default


const actual = resolveCssVariablesInRecord(given, targetElement);

expect(computedStyle.getPropertyValue).not.toHaveBeenCalled();
expect(ColorUtils.normalizeCssColor).not.toHaveBeenCalled();
expect(actual).toEqual(given);
});
Expand Down
19 changes: 9 additions & 10 deletions packages/components/src/theme/ThemeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,21 +320,19 @@ export function resolveCssVariablesInRecord<T extends Record<string, string>>(

const result = {} as T;
recordArray.forEach(([key, value], i) => {
// only resolve if it contains a css var expression
if (!value.includes(CSS_VAR_EXPRESSION_PREFIX)) {
(result as Record<string, string>)[key] = value;
return;
}
// resolves any variables in the expression
let resolved = tempPropElComputedStyle.getPropertyValue(
`--${TMP_CSS_PROP_PREFIX}-${i}`
);

const containsCssVar = value.includes(CSS_VAR_EXPRESSION_PREFIX);
const isColor = CSS.supports('color', resolved);

if (
// skip if resolved is already hex
!/^#[0-9A-F]{6}[0-9a-f]{0,2}$/i.test(resolved) &&
// only try to normalize things that are valid colors
// only try to normalize non-hex strings that are valid colors
// otherwise non-colors will be made #00000000
CSS.supports('color', resolved)
isColor &&
!/^#[0-9A-F]{6}[0-9a-f]{0,2}$/i.test(resolved)
) {
// getting the computed background color is necessary
// because resolved can still contain a color-mix() function
Expand All @@ -344,7 +342,8 @@ export function resolveCssVariablesInRecord<T extends Record<string, string>>(
// convert color to hex, which is what monaco and plotly require
resolved = ColorUtils.normalizeCssColor(color, isAlphaOptional);
}
(result as Record<string, string>)[key] = resolved;
(result as Record<string, string>)[key] =
containsCssVar || isColor ? resolved : value;
});

// Remove the temporary div
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/theme/colorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ export function isDHColorValue(value: string): value is DHColorValue {
* ex. colorValueStyle('red') => 'red'
* ex. colorValueStyle('#F00') => '#F00'
*/
export function colorValueStyle(value: string): string;
export function colorValueStyle(value: string | undefined): string | undefined;
export function colorValueStyle(value: string | undefined): string | undefined {
if (value != null && isDHColorValue(value)) {
return `var(--dh-color-${value})`;
Expand Down
240 changes: 86 additions & 154 deletions packages/grid/src/DataBarCellRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CellRenderer from './CellRenderer';
import { isExpandableGridModel } from './ExpandableGridModel';
import { isDataBarGridModel } from './DataBarGridModel';
import { ModelIndex, VisibleIndex, VisibleToModelMap } from './GridMetrics';
import GridColorUtils, { Oklab } from './GridColorUtils';
import GridColorUtils from './GridColorUtils';
import GridUtils from './GridUtils';
import memoizeClear from './memoizeClear';
import { DEFAULT_FONT_WIDTH, GridRenderState } from './GridRendererTypes';
Expand All @@ -31,7 +31,41 @@ interface DataBarRenderMetrics {
markerXs: number[];
}
class DataBarCellRenderer extends CellRenderer {
private heightOfDigits?: number;
static getGradient = memoizeClear(
(width: number, colors: string[]): CanvasGradient => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) {
throw new Error('Failed to create canvas context');
}
if (Number.isNaN(width)) {
return ctx.createLinearGradient(0, 0, 0, 0);
}
const gradient = ctx.createLinearGradient(0, 0, width, 0);
const oklabColors = colors.map(color =>
GridColorUtils.linearSRGBToOklab(GridColorUtils.hexToRgb(color))
);
for (let i = 0; i < width; i += 1) {
const colorStop = i / width;
const colorChangeInterval = 1 / (colors.length - 1);
const leftColorIndex = Math.floor(colorStop / colorChangeInterval);
const color = GridColorUtils.lerpColor(
oklabColors[leftColorIndex],
oklabColors[leftColorIndex + 1],
(colorStop % colorChangeInterval) / colorChangeInterval
);
gradient.addColorStop(
i / width,
GridColorUtils.rgbToHex(GridColorUtils.OklabToLinearSRGB(color))
);
}
return gradient;
},
{
max: 1000,
primitive: true, // Stringify the arguments for memoization. Lets the color arrays be different arrays in memory, but still cache hit
}
);

drawCellContent(
context: CanvasRenderingContext2D,
Expand Down Expand Up @@ -87,7 +121,7 @@ class DataBarCellRenderer extends CellRenderer {
value,
} = model.dataBarOptionsForCell(modelColumn, modelRow, theme);

const hasGradient = Array.isArray(dataBarColor);
const hasGradient = Array.isArray(dataBarColor) && dataBarColor.length > 1;
if (columnMin == null || columnMax == null) {
return;
}
Expand All @@ -103,166 +137,99 @@ class DataBarCellRenderer extends CellRenderer {
dataBarWidth,
} = this.getDataBarRenderMetrics(context, state, column, row);

if (this.heightOfDigits === undefined) {
const { actualBoundingBoxAscent, actualBoundingBoxDescent } =
context.measureText('1234567890');
this.heightOfDigits = actualBoundingBoxAscent + actualBoundingBoxDescent;
}

context.save();
Copy link
Member

Choose a reason for hiding this comment

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

With the levels of context.saves(), it'd be nice if they were at the start/end of a block, extracting this info out or just enclosing it in blocks. Maybe even have our own function for it?

function withContextState(context, callback: () => undefined) {
  context.save();
  callback();
  context.restore();
}

Just musing though. Not necessary to change, but I did find it a little confusing seeing two context.restore()s close to each other below, and then tracing it back to the matching context.save()

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ya that would be neat and I agree it is a bit confusing. It's because you have to clip first and then draw, so

I added the 2nd context save/restore because I needed it for re-using the gradient across multiple parts of the grid (or even multiple grids since I made the cache static on the renderer)

I think I might be able to simplify some of it and at least put both restores near each other by using the flip/translate for everything based on LTR or RTL. Basically we don't need to calculate special positioning based on if it's LTR or RTL, but instead calculate just based on LTR. Then if it's RTL, the context scale(-1, 1) w/ translation makes it draw RTL even though the coordinates are LTR. I already did this w/ the gradient, but could also add it for the markers and axis

context.textAlign = textAlign;
if (hasGradient) {
const color =
value >= 0 ? dataBarColor[dataBarColor.length - 1] : dataBarColor[0];
context.fillStyle = color;
} else {
context.fillStyle = dataBarColor;
context.fillStyle = Array.isArray(dataBarColor)
? dataBarColor[0]
: dataBarColor;
}
context.textBaseline = 'top';
context.textBaseline = 'middle';
context.font = theme.font;

if (valuePlacement !== 'hide') {
context.fillText(
truncatedText,
textX,
rowY + (rowHeight - this.heightOfDigits) / 2
);
context.fillText(truncatedText, textX, rowY + rowHeight * 0.5);
}

context.save();
context.beginPath();
context.roundRect(dataBarX, rowY + 1, dataBarWidth, rowHeight - 2, 1);
context.clip();
context.globalAlpha = opacity;

// Draw bar
if (hasGradient) {
// Draw gradient bar

const dataBarColorsOklab: Oklab[] = dataBarColor.map(color =>
GridColorUtils.linearSRGBToOklab(GridColorUtils.hexToRgb(color))
);

let gradientWidth = 0;
let gradientX = 0;
context.save();

context.beginPath();

context.roundRect(dataBarX, dataBarY, dataBarWidth, rowHeight - 2, 1);
context.clip();

// Translate the context so its origin is at the start of the gradient
// and increasing x value moves towards the end of the gradient.
// For RTL, scale x by -1 to flip across the x-axis
if (value < 0) {
if (direction === 'LTR') {
const totalGradientWidth = Math.round(
gradientWidth = 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;
}
gradientX = Math.round(leftmostPosition);
context.translate(gradientX, 0);
} else if (direction === 'RTL') {
const totalGradientWidth = Math.round(
gradientWidth = 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;
}
gradientX = Math.round(zeroPosition);
context.translate(gradientX + gradientWidth, 0);
context.scale(-1, 1);
}
} else if (direction === 'LTR') {
// Value is greater than or equal to 0
const totalGradientWidth =
gradientWidth =
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;
}
gradientX = Math.round(zeroPosition);
context.translate(gradientX, 0);
} else if (direction === 'RTL') {
// Value is greater than or equal to 0
const totalGradientWidth = Math.round(
gradientWidth = 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;
}
gradientX = Math.round(leftmostPosition);
context.translate(gradientX + gradientWidth, 0);
context.scale(-1, 1);
}

// restore clip
context.restore();
const gradient = DataBarCellRenderer.getGradient(
gradientWidth,
dataBarColor
);
context.fillStyle = gradient;
context.fillRect(0, dataBarY, gradientWidth, rowHeight);
context.restore(); // Restore gradient translate/scale
} else {
// Draw normal bar
context.save();

context.globalAlpha = opacity;
context.beginPath();
context.roundRect(dataBarX, dataBarY, dataBarWidth, rowHeight - 2, 1);
context.roundRect(dataBarX, dataBarY, dataBarWidth, rowHeight, 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);
context.fillRect(markerX, dataBarY, 1, rowHeight);
});
}

// restore clip
context.restore();

const shouldRenderDashedLine = !(
axis === 'directional' &&
((valuePlacement === 'beside' &&
Expand Down Expand Up @@ -523,7 +490,7 @@ class DataBarCellRenderer extends CellRenderer {
return {
maxWidth,
x: dataBarX,
y: y + 1.5,
y,
zeroPosition,
leftmostPosition,
rightmostPosition,
Expand All @@ -533,46 +500,11 @@ class DataBarCellRenderer extends CellRenderer {
};
}

drawGradient(
context: CanvasRenderingContext2D,
leftColor: Oklab,
rightColor: Oklab,
x: number,
y: number,
width: number,
height: number
): void {
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
): void {
context.fillStyle = color;
context.fillRect(x, y, width, height);
}
getCachedWidth = memoizeClear(
(context: CanvasRenderingContext2D, text: string): number =>
context.measureText(text).width,
{ max: 10000 }
);

/**
* Returns the width of the widest value in pixels
Expand All @@ -590,7 +522,7 @@ class DataBarCellRenderer extends CellRenderer {
const row = visibleRows[i];
const modelRow = getOrThrow(modelRows, row);
const text = model.textForCell(column, modelRow);
widestValue = Math.max(widestValue, context.measureText(text).width);
widestValue = Math.max(widestValue, this.getCachedWidth(context, text));
}

return widestValue;
Expand Down
Loading
Loading