| // 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'; |
| } |