blob: 36ce436af7f8aa91e17a38de9ebe195eec359563 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {luminance} from '../front_end/core/common/ColorUtils.js'; // eslint-disable-line rulesdir/es_modules_import
import {createChild, type AreaBounds, type Bounds, type Position} from './common.js';
import {applyMatrixToPoint, parseHexa} from './highlight_common.js';
/**
* There are 12 different types of arrows for labels.
*
* The first word in an arrow type corresponds to the side of the label
* container the arrow is on (e.g. 'left' means the arrow is on the left side of
* the container).
*
* The second word defines where, along that side, the arrow is (e.g. 'top' in
* a 'leftTop' type means the arrow is at the top of the left side of the
* container).
*
* Here are 2 examples to illustrate:
*
* +----+
* rightMid: | >
* +----+
*
* +----+
* bottomRight: | |
* +-- +
* \|
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
const GridArrowTypes = {
leftTop: 'left-top',
leftMid: 'left-mid',
leftBottom: 'left-bottom',
topLeft: 'top-left',
topMid: 'top-mid',
topRight: 'top-right',
rightTop: 'right-top',
rightMid: 'right-mid',
rightBottom: 'right-bottom',
bottomLeft: 'bottom-left',
bottomMid: 'bottom-mid',
bottomRight: 'bottom-right',
};
// The size (in px) of a label arrow.
const gridArrowWidth = 3;
// The minimum distance (in px) a label has to be from the edge of the viewport
// to avoid being flipped inside the grid.
const gridPageMargin = 20;
// The minimum distance (in px) 2 labels can be to eachother. This is set to
// allow 2 consecutive 2-digits labels to not overlap.
const gridLabelDistance = 20;
// The maximum number of custom line names that can be displayed in a label.
const maxLineNamesCount = 3;
const defaultLabelColor = '#1A73E8';
const defaultLabelTextColor = '#121212';
export interface CanvasSize {
canvasWidth: number;
canvasHeight: number;
}
interface PositionData {
positions: Position[];
hasFirst: boolean;
hasLast: boolean;
names?: string[][];
}
type PositionDataWithNames = PositionData&{
names: string[][],
};
interface TracksPositionData {
positive: PositionData;
negative: PositionData;
}
interface TracksPositionDataWithNames {
positive: PositionDataWithNames;
negative: PositionDataWithNames;
}
interface GridPositionNormalizedData {
rows: TracksPositionData;
columns: TracksPositionData;
bounds: Bounds;
}
export interface GridPositionNormalizedDataWithNames {
rows: TracksPositionDataWithNames;
columns: TracksPositionDataWithNames;
bounds: Bounds;
}
interface TrackSize {
computedSize: number;
authoredSize?: number;
x: number;
y: number;
}
export interface GridHighlightOptions {
gridBorderDash: boolean;
rowLineDash: boolean;
columnLineDash: boolean;
showGridExtensionLines: boolean;
showPositiveLineNumbers: boolean;
showNegativeLineNumbers: boolean;
rowLineColor?: string;
columnLineColor?: string;
rowHatchColor: string;
columnHatchColor: string;
showLineNames: boolean;
}
export interface GridHighlightConfig {
rotationAngle?: number;
writingMode?: string;
columnTrackSizes?: TrackSize[];
rowTrackSizes?: TrackSize[];
positiveRowLineNumberPositions?: Position[];
negativeRowLineNumberPositions?: Position[];
positiveColumnLineNumberPositions?: Position[];
negativeColumnLineNumberPositions?: Position[];
rowLineNameOffsets?: {name: string, x: number, y: number}[];
columnLineNameOffsets?: {name: string, x: number, y: number}[];
gridHighlightConfig?: GridHighlightOptions;
}
interface LabelSize {
width: number;
height: number;
mainSize: number;
crossSize: number;
}
export interface GridLabelState {
gridLayerCounter: number;
}
/**
* Places all of the required grid labels on the overlay. This includes row and
* column line number labels, and area labels.
*/
export function drawGridLabels(
config: GridHighlightConfig, gridBounds: Bounds, areaBounds: AreaBounds[], canvasSize: CanvasSize,
labelState: GridLabelState, emulationScaleFactor: number,
writingModeMatrix: DOMMatrix|undefined = new DOMMatrix()) {
// Find and clear the layer for the node specified in the config, or the default layer:
// Each node has a layer for grid labels in order to draw multiple grid highlights
// at once.
const labelContainerId = `grid-${labelState.gridLayerCounter++}-labels`;
let labelContainerForNode = document.getElementById(labelContainerId);
if (!labelContainerForNode) {
const mainLabelLayerContainer = document.getElementById('grid-label-container');
if (!mainLabelLayerContainer) {
throw new Error('#grid-label-container is not found');
}
labelContainerForNode = createChild(mainLabelLayerContainer, 'div');
labelContainerForNode.id = labelContainerId;
}
const rowColor = config.gridHighlightConfig && config.gridHighlightConfig.rowLineColor ?
config.gridHighlightConfig.rowLineColor :
defaultLabelColor;
const rowTextColor = generateLegibleTextColor(rowColor);
labelContainerForNode.style.setProperty('--row-label-color', rowColor);
labelContainerForNode.style.setProperty('--row-label-text-color', rowTextColor);
const columnColor = config.gridHighlightConfig && config.gridHighlightConfig.columnLineColor ?
config.gridHighlightConfig.columnLineColor :
defaultLabelColor;
const columnTextColor = generateLegibleTextColor(columnColor);
labelContainerForNode.style.setProperty('--column-label-color', columnColor);
labelContainerForNode.style.setProperty('--column-label-text-color', columnTextColor);
labelContainerForNode.innerText = '';
// Add the containers for the line and area to the node's layer
const areaNameContainer = createChild(labelContainerForNode, 'div', 'area-names');
const lineNameContainer = createChild(labelContainerForNode, 'div', 'line-names');
const lineNumberContainer = createChild(labelContainerForNode, 'div', 'line-numbers');
const trackSizesContainer = createChild(labelContainerForNode, 'div', 'track-sizes');
// Draw line numbers and names.
const normalizedData = normalizePositionData(config, gridBounds);
if (config.gridHighlightConfig && config.gridHighlightConfig.showLineNames) {
drawGridLineNames(
lineNameContainer, normalizedData as GridPositionNormalizedDataWithNames, canvasSize, emulationScaleFactor,
writingModeMatrix, config.writingMode);
} else {
drawGridLineNumbers(
lineNumberContainer, normalizedData, canvasSize, emulationScaleFactor, writingModeMatrix, config.writingMode);
}
// Draw area names.
drawGridAreaNames(areaNameContainer, areaBounds, writingModeMatrix, config.writingMode);
if (config.columnTrackSizes) {
// Draw column sizes.
drawGridTrackSizes(
trackSizesContainer, config.columnTrackSizes, 'column', canvasSize, emulationScaleFactor, writingModeMatrix,
config.writingMode);
}
if (config.rowTrackSizes) {
// Draw row sizes.
drawGridTrackSizes(
trackSizesContainer, config.rowTrackSizes, 'row', canvasSize, emulationScaleFactor, writingModeMatrix,
config.writingMode);
}
}
/**
* This is a generator function used to iterate over grid label positions in a way
* that skips the ones that are too close to eachother, in order to avoid overlaps.
*/
function* positionIterator(positions: Position[], axis: 'x'|'y'): Generator<[number, Position]> {
let lastEmittedPos = null;
for (const [i, pos] of positions.entries()) {
// Only emit the position if this is the first.
const isFirst = i === 0;
// Or if this is the last.
const isLast = i === positions.length - 1;
// Or if there is some minimum distance between the last emitted position.
const isFarEnoughFromPrevious =
Math.abs(pos[axis] - (lastEmittedPos ? lastEmittedPos[axis] : 0)) > gridLabelDistance;
// And if there is also some minium distance from the very last position.
const isFarEnoughFromLast =
!isLast && Math.abs(positions[positions.length - 1][axis] - pos[axis]) > gridLabelDistance;
if (isFirst || isLast || (isFarEnoughFromPrevious && isFarEnoughFromLast)) {
yield [i, pos];
lastEmittedPos = pos;
}
}
}
const last = <T>(array: T[]) => array[array.length - 1];
const first = <T>(array: T[]) => array[0];
/**
* Massage the list of line name positions given by the backend for easier consumption.
*/
function normalizeNameData(namePositions: {name: string, x: number, y: number}[]):
{positions: {x: number, y: number}[], names: string[][]} {
const positions = [];
const names = [];
for (const {name, x, y} of namePositions) {
const normalizedX = Math.round(x);
const normalizedY = Math.round(y);
// If the same position already exists, just add the name to the existing entry, as there can be
// several custom names for a single line.
const existingIndex = positions.findIndex(({x, y}) => x === normalizedX && y === normalizedY);
if (existingIndex > -1) {
names[existingIndex].push(name);
} else {
positions.push({x: normalizedX, y: normalizedY});
names.push([name]);
}
}
return {positions, names};
}
export interface NormalizePositionDataConfig {
positiveRowLineNumberPositions?: Position[];
negativeRowLineNumberPositions?: Position[];
positiveColumnLineNumberPositions?: Position[];
negativeColumnLineNumberPositions?: Position[];
rowLineNameOffsets?: {name: string, x: number, y: number}[];
columnLineNameOffsets?: {name: string, x: number, y: number}[];
gridHighlightConfig?: {showLineNames: boolean};
}
/**
* Take the highlight config and bound objects in, and spits out an object with
* the same information, but with 2 key differences:
* - the information is organized in a way that makes the rest of the code more
* readable
* - all pixel values are rounded to integers in order to safely compare
* positions (on high-dpi monitors floats are passed by the backend, this means
* checking if a position is at either edges of the container can't be done).
*/
export function normalizePositionData(config: NormalizePositionDataConfig, bounds: Bounds): GridPositionNormalizedData {
const width = Math.round(bounds.maxX - bounds.minX);
const height = Math.round(bounds.maxY - bounds.minY);
const data = {
rows: {
positive: {positions: [] as Position[], hasFirst: false, hasLast: false},
negative: {positions: [] as Position[], hasFirst: false, hasLast: false},
},
columns: {
positive: {positions: [] as Position[], hasFirst: false, hasLast: false},
negative: {positions: [] as Position[], hasFirst: false, hasLast: false},
},
bounds: {
minX: Math.round(bounds.minX),
maxX: Math.round(bounds.maxX),
minY: Math.round(bounds.minY),
maxY: Math.round(bounds.maxY),
allPoints: bounds.allPoints,
width,
height,
},
};
// Line numbers and line names can't be shown together at once for now.
// If showLineNames is set to true, then don't show line numbers, even if the
// data is present.
if (config.gridHighlightConfig && config.gridHighlightConfig.showLineNames) {
const rowData = normalizeNameData(config.rowLineNameOffsets || []);
const positiveRows: PositionDataWithNames = {
positions: rowData.positions,
names: rowData.names,
hasFirst: rowData.positions.length ? first(rowData.positions).y === data.bounds.minY : false,
hasLast: rowData.positions.length ? last(rowData.positions).y === data.bounds.maxY : false,
};
data.rows.positive = positiveRows;
const columnData = normalizeNameData(config.columnLineNameOffsets || []);
const positiveColumns: PositionDataWithNames = {
positions: columnData.positions,
names: columnData.names,
hasFirst: columnData.positions.length ? first(columnData.positions).x === data.bounds.minX : false,
hasLast: columnData.positions.length ? last(columnData.positions).x === data.bounds.maxX : false,
};
data.columns.positive = positiveColumns;
} else {
const normalizeXY = ({x, y}: {x: number, y: number}) => ({x: Math.round(x), y: Math.round(y)});
// TODO (alexrudenko): hasFirst & hasLast checks won't probably work for rotated grids.
if (config.positiveRowLineNumberPositions) {
data.rows.positive = {
positions: config.positiveRowLineNumberPositions.map(normalizeXY),
hasFirst: Math.round(first(config.positiveRowLineNumberPositions).y) === data.bounds.minY,
hasLast: Math.round(last(config.positiveRowLineNumberPositions).y) === data.bounds.maxY,
};
}
if (config.negativeRowLineNumberPositions) {
data.rows.negative = {
positions: config.negativeRowLineNumberPositions.map(normalizeXY),
hasFirst: Math.round(first(config.negativeRowLineNumberPositions).y) === data.bounds.minY,
hasLast: Math.round(last(config.negativeRowLineNumberPositions).y) === data.bounds.maxY,
};
}
if (config.positiveColumnLineNumberPositions) {
data.columns.positive = {
positions: config.positiveColumnLineNumberPositions.map(normalizeXY),
hasFirst: Math.round(first(config.positiveColumnLineNumberPositions).x) === data.bounds.minX,
hasLast: Math.round(last(config.positiveColumnLineNumberPositions).x) === data.bounds.maxX,
};
}
if (config.negativeColumnLineNumberPositions) {
data.columns.negative = {
positions: config.negativeColumnLineNumberPositions.map(normalizeXY),
hasFirst: Math.round(first(config.negativeColumnLineNumberPositions).x) === data.bounds.minX,
hasLast: Math.round(last(config.negativeColumnLineNumberPositions).x) === data.bounds.maxX,
};
}
}
return data;
}
/**
* Places the grid row and column number labels on the overlay.
*
* @param {HTMLElement} container Where to append the labels
* @param {GridPositionNormalizedData} data The grid line number data
* @param {DOMMatrix=} writingModeMatrix The transformation matrix in case a vertical writing-mode is applied, to map label positions
* @param {string=} writingMode The current writing-mode value
*/
export function drawGridLineNumbers(
container: HTMLElement, data: GridPositionNormalizedData, canvasSize: CanvasSize, emulationScaleFactor: number,
writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(), writingMode: string|undefined = 'horizontal-tb') {
if (!data.columns.positive.names) {
for (const [i, pos] of positionIterator(data.columns.positive.positions, 'x')) {
const element = createLabelElement(container, (i + 1).toString(), 'column');
placePositiveColumnLabel(
element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
}
}
if (!data.rows.positive.names) {
for (const [i, pos] of positionIterator(data.rows.positive.positions, 'y')) {
const element = createLabelElement(container, (i + 1).toString(), 'row');
placePositiveRowLabel(
element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
}
}
for (const [i, pos] of positionIterator(data.columns.negative.positions, 'x')) {
// Negative positions are sorted such that the first position corresponds to the line closest to start edge of the grid.
const element =
createLabelElement(container, (data.columns.negative.positions.length * -1 + i).toString(), 'column');
placeNegativeColumnLabel(
element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
}
for (const [i, pos] of positionIterator(data.rows.negative.positions, 'y')) {
// Negative positions are sorted such that the first position corresponds to the line closest to start edge of the grid.
const element = createLabelElement(container, (data.rows.negative.positions.length * -1 + i).toString(), 'row');
placeNegativeRowLabel(
element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
}
}
/**
* Places the grid track size labels on the overlay.
*/
export function drawGridTrackSizes(
container: HTMLElement, trackSizes: Array<TrackSize>, direction: 'row'|'column', canvasSize: CanvasSize,
emulationScaleFactor: number, writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(),
writingMode: string|undefined = 'horizontal-tb') {
const {main, cross} = getAxes(writingMode);
const {crossSize} = getCanvasSizes(writingMode, canvasSize);
for (const {x, y, computedSize, authoredSize} of trackSizes) {
const point = applyMatrixToPoint({x, y}, writingModeMatrix);
const size = computedSize.toFixed(2);
const formattedComputed = `${size.endsWith('.00') ? size.slice(0, -3) : size}px`;
const element =
createLabelElement(container, `${authoredSize ? authoredSize + '·' : ''}${formattedComputed}`, direction);
const labelSize = getLabelSize(element, writingMode);
let flipIn = point[main] - labelSize.mainSize < gridPageMargin;
if (direction === 'column') {
flipIn = writingMode === 'vertical-rl' ? crossSize - point[cross] - labelSize.crossSize < gridPageMargin :
point[cross] - labelSize.crossSize < gridPageMargin;
}
let arrowType = adaptArrowTypeForWritingMode(
direction === 'column' ? GridArrowTypes.bottomMid : GridArrowTypes.rightMid, writingMode);
arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
placeLineLabel(element, arrowType, point.x, point.y, labelSize, emulationScaleFactor);
}
}
/**
* Places the grid row and column name labels on the overlay.
*/
export function drawGridLineNames(
container: HTMLElement, data: GridPositionNormalizedDataWithNames, canvasSize: CanvasSize,
emulationScaleFactor: number, writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(),
writingMode: string|undefined = 'horizontal-tb') {
for (const [i, pos] of data.columns.positive.positions.entries()) {
const names = data.columns.positive.names[i];
const element = createLabelElement(container, makeLineNameLabelContent(names), 'column');
placePositiveColumnLabel(
element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
}
for (const [i, pos] of data.rows.positive.positions.entries()) {
const names = data.rows.positive.names[i];
const element = createLabelElement(container, makeLineNameLabelContent(names), 'row');
placePositiveRowLabel(
element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
}
}
/**
* Turn an array of custom line names into DOM content that can be used in a label.
*/
function makeLineNameLabelContent(names: string[]): HTMLElement {
const content = document.createElement('ul');
const namesToDisplay = names.slice(0, maxLineNamesCount);
for (const name of namesToDisplay) {
createChild(content, 'li', 'line-name').textContent = name;
}
return content;
}
/**
* Places the grid area name labels on the overlay.
*/
export function drawGridAreaNames(
container: HTMLElement, areaBounds: AreaBounds[], writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(),
writingMode: string|undefined = 'horizontal-tb') {
for (const {name, bounds} of areaBounds) {
const element = createLabelElement(container, name, 'row');
const {width, height} = getLabelSize(element, writingMode);
// The list of all points comes from the path created by the backend. This path is a rectangle with its starting point being
// the top left corner, which is where we want to place the label (except for vertical-rl writing-mode).
const point = writingMode === 'vertical-rl' ? bounds.allPoints[3] : bounds.allPoints[0];
const corner = applyMatrixToPoint(point, writingModeMatrix);
const flipX = bounds.allPoints[1].x < bounds.allPoints[0].x;
const flipY = bounds.allPoints[3].y < bounds.allPoints[0].y;
element.style.left = (corner.x - (flipX ? width : 0)) + 'px';
element.style.top = (corner.y - (flipY ? height : 0)) + 'px';
}
}
/**
* Create the necessary DOM for a single label element.
*/
function createLabelElement(
container: HTMLElement, textContent: string|HTMLElement, direction: 'row'|'column'): HTMLElement {
const wrapper = createChild(container, 'div');
const element = createChild(wrapper, 'div', 'grid-label-content');
element.dataset.direction = direction;
if (typeof textContent === 'string') {
element.textContent = textContent;
} else {
element.appendChild(textContent);
}
return element;
}
/**
* Get the start and end points of the edge where labels are displayed.
*/
function getLabelSideEdgePoints(
gridBounds: Bounds, direction: string, side: string): {start: {x: number, y: number}, end: {x: number, y: number}} {
const [p1, p2, p3, p4] = gridBounds.allPoints;
// Here are where all the points are in standard, untransformed, horizontal-tb mode:
// p1 p2
// +----------------------+
// | |
// +----------------------+
// p4 p3
if (direction === 'row') {
return side === 'positive' ? {start: p1, end: p4} : {start: p2, end: p3};
}
return side === 'positive' ? {start: p1, end: p2} : {start: p4, end: p3};
}
/**
* Get the name of the main and cross axes depending on the writing mode.
* In "normal" horizonta-tb mode, the main axis is the one that goes horizontally from left to right,
* hence, the x axis.
* In vertical writing modes, the axes are swapped.
*/
function getAxes(writingMode: string): {main: 'x'|'y', cross: 'x'|'y'} {
return writingMode.startsWith('vertical') ? {main: 'y', cross: 'x'} : {main: 'x', cross: 'y'};
}
/**
* Get the main and cross sizes of the canvas area depending on the writing mode.
* In "normal" horizonta-tb mode, the main axis is the one that goes horizontally from left to right,
* hence, the main size of the canvas is its width, and its cross size is its height.
* In vertical writing modes, those sizes are swapped.
*/
function getCanvasSizes(writingMode: string, canvasSize: CanvasSize): {mainSize: number, crossSize: number} {
return writingMode.startsWith('vertical') ? {mainSize: canvasSize.canvasHeight, crossSize: canvasSize.canvasWidth} :
{mainSize: canvasSize.canvasWidth, crossSize: canvasSize.canvasHeight};
}
/**
* Determine the position of a positive row label, and place it.
*/
function placePositiveRowLabel(
element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
emulationScaleFactor: number) {
const {start, end} = getLabelSideEdgePoints(data.bounds, 'row', 'positive');
const {main, cross} = getAxes(writingMode);
const {crossSize} = getCanvasSizes(writingMode, canvasSize);
const labelSize = getLabelSize(element, writingMode);
const isAtSharedStartCorner = pos[cross] === start[cross] && data.columns && data.columns.positive.hasFirst;
const isAtSharedEndCorner = pos[cross] === end[cross] && data.columns && data.columns.negative.hasFirst;
const isTooCloseToViewportStart = pos[cross] < gridPageMargin;
const isTooCloseToViewportEnd = crossSize - pos[cross] < gridPageMargin;
const flipIn = pos[main] - labelSize.mainSize < gridPageMargin;
if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
element.classList.add('inner-shared-corner');
}
let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.rightMid, writingMode);
if (isTooCloseToViewportStart || isAtSharedStartCorner) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.rightTop, writingMode);
} else if (isTooCloseToViewportEnd || isAtSharedEndCorner) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.rightBottom, writingMode);
}
arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
}
/**
* Determine the position of a negative row label, and place it.
*/
function placeNegativeRowLabel(
element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
emulationScaleFactor: number) {
const {start, end} = getLabelSideEdgePoints(data.bounds, 'row', 'negative');
const {main, cross} = getAxes(writingMode);
const {mainSize, crossSize} = getCanvasSizes(writingMode, canvasSize);
const labelSize = getLabelSize(element, writingMode);
const isAtSharedStartCorner = pos[cross] === start[cross] && data.columns && data.columns.positive.hasLast;
const isAtSharedEndCorner = pos[cross] === end[cross] && data.columns && data.columns.negative.hasLast;
const isTooCloseToViewportStart = pos[cross] < gridPageMargin;
const isTooCloseToViewportEnd = crossSize - pos[cross] < gridPageMargin;
const flipIn = mainSize - pos[main] - labelSize.mainSize < gridPageMargin;
if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
element.classList.add('inner-shared-corner');
}
let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.leftMid, writingMode);
if (isTooCloseToViewportStart || isAtSharedStartCorner) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.leftTop, writingMode);
} else if (isTooCloseToViewportEnd || isAtSharedEndCorner) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.leftBottom, writingMode);
}
arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
}
/**
* Determine the position of a positive column label, and place it.
*/
function placePositiveColumnLabel(
element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
emulationScaleFactor: number) {
const {start, end} = getLabelSideEdgePoints(data.bounds, 'column', 'positive');
const {main, cross} = getAxes(writingMode);
const {mainSize, crossSize} = getCanvasSizes(writingMode, canvasSize);
const labelSize = getLabelSize(element, writingMode);
const isAtSharedStartCorner = pos[main] === start[main] && data.rows && data.rows.positive.hasFirst;
const isAtSharedEndCorner = pos[main] === end[main] && data.rows && data.rows.negative.hasFirst;
const isTooCloseToViewportStart = pos[main] < gridPageMargin;
const isTooCloseToViewportEnd = mainSize - pos[main] < gridPageMargin;
const flipIn = writingMode === 'vertical-rl' ? crossSize - pos[cross] - labelSize.crossSize < gridPageMargin :
pos[cross] - labelSize.crossSize < gridPageMargin;
if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
element.classList.add('inner-shared-corner');
}
let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.bottomMid, writingMode);
if (isTooCloseToViewportStart) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.bottomLeft, writingMode);
} else if (isTooCloseToViewportEnd) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.bottomRight, writingMode);
}
arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
}
/**
* Determine the position of a negative column label, and place it.
*/
function placeNegativeColumnLabel(
element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
emulationScaleFactor: number) {
const {start, end} = getLabelSideEdgePoints(data.bounds, 'column', 'negative');
const {main, cross} = getAxes(writingMode);
const {mainSize, crossSize} = getCanvasSizes(writingMode, canvasSize);
const labelSize = getLabelSize(element, writingMode);
const isAtSharedStartCorner = pos[main] === start[main] && data.rows && data.rows.positive.hasLast;
const isAtSharedEndCorner = pos[main] === end[main] && data.rows && data.rows.negative.hasLast;
const isTooCloseToViewportStart = pos[main] < gridPageMargin;
const isTooCloseToViewportEnd = mainSize - pos[main] < gridPageMargin;
const flipIn = writingMode === 'vertical-rl' ? pos[cross] - labelSize.crossSize < gridPageMargin :
crossSize - pos[cross] - labelSize.crossSize < gridPageMargin;
if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
element.classList.add('inner-shared-corner');
}
let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.topMid, writingMode);
if (isTooCloseToViewportStart) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.topLeft, writingMode);
} else if (isTooCloseToViewportEnd) {
arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.topRight, writingMode);
}
arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
}
/**
* Correctly place a line label element in the page. The given coordinates are
* the ones where the arrow of the label needs to point.
* Therefore, the width of the text in the label, and the position of the arrow
* relative to the label are taken into account here to calculate the final x
* and y coordinates of the label DOM element.
*/
function placeLineLabel(
element: HTMLElement, arrowType: string, x: number, y: number, labelSize: LabelSize, emulationScaleFactor: number) {
const {contentLeft, contentTop} =
getLabelPositionByArrowType(arrowType, x, y, labelSize.width, labelSize.height, emulationScaleFactor);
element.classList.add(arrowType);
element.style.left = contentLeft + 'px';
element.style.top = contentTop + 'px';
}
/**
* Given a label element, return its width and height, as well as what the main and cross sizes are depending on
* the current writing mode.
*/
function getLabelSize(element: HTMLElement, writingMode: string): LabelSize {
const width = getAdjustedLabelWidth(element);
const height = element.getBoundingClientRect().height;
const mainSize = writingMode.startsWith('vertical') ? height : width;
const crossSize = writingMode.startsWith('vertical') ? width : height;
return {width, height, mainSize, crossSize};
}
/**
* Forces the width of the provided grid label element to be an even
* number of pixels to allow centered placement of the arrow
*/
function getAdjustedLabelWidth(element: HTMLElement) {
let labelWidth = element.getBoundingClientRect().width;
if (labelWidth % 2 === 1) {
labelWidth += 1;
element.style.width = labelWidth + 'px';
}
return labelWidth;
}
/**
* In some cases, a label doesn't fit where it's supposed to be displayed.
* This happens when it's too close to the edge of the viewport. When it does,
* the label's position is flipped so that instead of being outside the grid, it
* moves inside the grid.
*
* Example of a leftMid arrowType, which is by default outside the grid:
* -----------------------------
* | | +------+
* | | | |
* |-----------------------------| < |
* | | | |
* | | +------+
* -----------------------------
* When flipped, the label will be drawn inside the grid, so the arrow now needs
* to point the other way:
* -----------------------------
* | +------+ |
* | | | |
* |------------------| >--|
* | | | |
* | +------+ |
* -----------------------------
*/
function flipArrowTypeIfNeeded(arrowType: string, flipIn: boolean): string {
if (!flipIn) {
return arrowType;
}
switch (arrowType) {
case GridArrowTypes.leftTop:
return GridArrowTypes.rightTop;
case GridArrowTypes.leftMid:
return GridArrowTypes.rightMid;
case GridArrowTypes.leftBottom:
return GridArrowTypes.rightBottom;
case GridArrowTypes.rightTop:
return GridArrowTypes.leftTop;
case GridArrowTypes.rightMid:
return GridArrowTypes.leftMid;
case GridArrowTypes.rightBottom:
return GridArrowTypes.leftBottom;
case GridArrowTypes.topLeft:
return GridArrowTypes.bottomLeft;
case GridArrowTypes.topMid:
return GridArrowTypes.bottomMid;
case GridArrowTypes.topRight:
return GridArrowTypes.bottomRight;
case GridArrowTypes.bottomLeft:
return GridArrowTypes.topLeft;
case GridArrowTypes.bottomMid:
return GridArrowTypes.topMid;
case GridArrowTypes.bottomRight:
return GridArrowTypes.topRight;
}
return arrowType;
}
/**
* Given an arrow type for the standard horizontal-tb writing-mode, return the corresponding type for a differnet
* writing-mode.
*/
function adaptArrowTypeForWritingMode(arrowType: string, writingMode: string): string {
if (writingMode === 'vertical-lr') {
switch (arrowType) {
case GridArrowTypes.leftTop:
return GridArrowTypes.topLeft;
case GridArrowTypes.leftMid:
return GridArrowTypes.topMid;
case GridArrowTypes.leftBottom:
return GridArrowTypes.topRight;
case GridArrowTypes.topLeft:
return GridArrowTypes.leftTop;
case GridArrowTypes.topMid:
return GridArrowTypes.leftMid;
case GridArrowTypes.topRight:
return GridArrowTypes.leftBottom;
case GridArrowTypes.rightTop:
return GridArrowTypes.bottomRight;
case GridArrowTypes.rightMid:
return GridArrowTypes.bottomMid;
case GridArrowTypes.rightBottom:
return GridArrowTypes.bottomLeft;
case GridArrowTypes.bottomLeft:
return GridArrowTypes.rightTop;
case GridArrowTypes.bottomMid:
return GridArrowTypes.rightMid;
case GridArrowTypes.bottomRight:
return GridArrowTypes.rightBottom;
}
}
if (writingMode === 'vertical-rl') {
switch (arrowType) {
case GridArrowTypes.leftTop:
return GridArrowTypes.topRight;
case GridArrowTypes.leftMid:
return GridArrowTypes.topMid;
case GridArrowTypes.leftBottom:
return GridArrowTypes.topLeft;
case GridArrowTypes.topLeft:
return GridArrowTypes.rightTop;
case GridArrowTypes.topMid:
return GridArrowTypes.rightMid;
case GridArrowTypes.topRight:
return GridArrowTypes.rightBottom;
case GridArrowTypes.rightTop:
return GridArrowTypes.bottomRight;
case GridArrowTypes.rightMid:
return GridArrowTypes.bottomMid;
case GridArrowTypes.rightBottom:
return GridArrowTypes.bottomLeft;
case GridArrowTypes.bottomLeft:
return GridArrowTypes.leftTop;
case GridArrowTypes.bottomMid:
return GridArrowTypes.leftMid;
case GridArrowTypes.bottomRight:
return GridArrowTypes.leftBottom;
}
}
return arrowType;
}
/**
* Returns the required properties needed to place a label arrow based on the
* arrow type and dimensions of the label
*/
function getLabelPositionByArrowType(
arrowType: string, x: number, y: number, labelWidth: number, labelHeight: number,
emulationScaleFactor: number): {contentTop: number, contentLeft: number} {
let contentTop = 0;
let contentLeft = 0;
x *= emulationScaleFactor;
y *= emulationScaleFactor;
switch (arrowType) {
case GridArrowTypes.leftTop:
contentTop = y;
contentLeft = x + gridArrowWidth;
break;
case GridArrowTypes.leftMid:
contentTop = y - (labelHeight / 2);
contentLeft = x + gridArrowWidth;
break;
case GridArrowTypes.leftBottom:
contentTop = y - labelHeight;
contentLeft = x + gridArrowWidth;
break;
case GridArrowTypes.rightTop:
contentTop = y;
contentLeft = x - gridArrowWidth - labelWidth;
break;
case GridArrowTypes.rightMid:
contentTop = y - (labelHeight / 2);
contentLeft = x - gridArrowWidth - labelWidth;
break;
case GridArrowTypes.rightBottom:
contentTop = y - labelHeight;
contentLeft = x - labelWidth - gridArrowWidth;
break;
case GridArrowTypes.topLeft:
contentTop = y + gridArrowWidth;
contentLeft = x;
break;
case GridArrowTypes.topMid:
contentTop = y + gridArrowWidth;
contentLeft = x - (labelWidth / 2);
break;
case GridArrowTypes.topRight:
contentTop = y + gridArrowWidth;
contentLeft = x - labelWidth;
break;
case GridArrowTypes.bottomLeft:
contentTop = y - gridArrowWidth - labelHeight;
contentLeft = x;
break;
case GridArrowTypes.bottomMid:
contentTop = y - gridArrowWidth - labelHeight;
contentLeft = x - (labelWidth / 2);
break;
case GridArrowTypes.bottomRight:
contentTop = y - gridArrowWidth - labelHeight;
contentLeft = x - labelWidth;
break;
}
return {
contentTop,
contentLeft,
};
}
/**
* Given a background color, generate a color for text to be legible.
* This assumes the background color is given as either a "rgba(r, g, b, a)" string or a #rrggbb string.
* This is because colors are sent by the backend using blink::Color:Serialized() which follows the logic for
* serializing colors from https://html.spec.whatwg.org/#serialization-of-a-color
*
* In rgba form, the alpha channel is ignored.
*
* This is made to be small and fast and not require importing the entire Color utility from DevTools as it would make
* the overlay bundle unnecessarily big.
*
* This is also made to generate the defaultLabelTextColor for all of the default label colors that the
* OverlayColorGenerator produces.
*/
export function generateLegibleTextColor(backgroundColor: string) {
let rgb: number[] = [];
// Try to parse it as a #rrggbbaa string first
const rgba = parseHexa(backgroundColor + '00');
if (rgba.length === 4) {
rgb = rgba.slice(0, 3).map(c => c);
} else {
// Next try to parse as a rgba() string
const parsed = backgroundColor.match(/[0-9.]+/g);
if (!parsed) {
return null;
}
rgb = parsed.slice(0, 3).map(s => parseInt(s, 10) / 255);
}
if (!rgb.length) {
return null;
}
return luminance(rgb) > 0.2 ? defaultLabelTextColor : 'white';
}