UNPKG

38.7 kBPlain TextView Raw
1// Copyright 2020 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {luminance} from '../front_end/core/common/ColorUtils.js'; // eslint-disable-line rulesdir/es_modules_import
6
7import type {AreaBounds, Bounds, Position} from './common.js';
8import {createChild} from './common.js';
9import {applyMatrixToPoint, parseHexa} from './highlight_common.js';
10
11/**
12 * There are 12 different types of arrows for labels.
13 *
14 * The first word in an arrow type corresponds to the side of the label
15 * container the arrow is on (e.g. 'left' means the arrow is on the left side of
16 * the container).
17 *
18 * The second word defines where, along that side, the arrow is (e.g. 'top' in
19 * a 'leftTop' type means the arrow is at the top of the left side of the
20 * container).
21 *
22 * Here are 2 examples to illustrate:
23 *
24 * +----+
25 * rightMid: | >
26 * +----+
27 *
28 * +----+
29 * bottomRight: | |
30 * +-- +
31 * \|
32 */
33// eslint-disable-next-line @typescript-eslint/naming-convention
34const GridArrowTypes = {
35 leftTop: 'left-top',
36 leftMid: 'left-mid',
37 leftBottom: 'left-bottom',
38 topLeft: 'top-left',
39 topMid: 'top-mid',
40 topRight: 'top-right',
41 rightTop: 'right-top',
42 rightMid: 'right-mid',
43 rightBottom: 'right-bottom',
44 bottomLeft: 'bottom-left',
45 bottomMid: 'bottom-mid',
46 bottomRight: 'bottom-right',
47};
48
49// The size (in px) of a label arrow.
50const gridArrowWidth = 3;
51// The minimum distance (in px) a label has to be from the edge of the viewport
52// to avoid being flipped inside the grid.
53const gridPageMargin = 20;
54// The minimum distance (in px) 2 labels can be to eachother. This is set to
55// allow 2 consecutive 2-digits labels to not overlap.
56const gridLabelDistance = 20;
57// The maximum number of custom line names that can be displayed in a label.
58const maxLineNamesCount = 3;
59const defaultLabelColor = '#1A73E8';
60const defaultLabelTextColor = '#121212';
61
62export interface CanvasSize {
63 canvasWidth: number;
64 canvasHeight: number;
65}
66
67interface PositionData {
68 positions: Position[];
69 hasFirst: boolean;
70 hasLast: boolean;
71 names?: string[][];
72}
73
74type PositionDataWithNames = PositionData&{
75 names: string[][],
76};
77
78interface TracksPositionData {
79 positive: PositionData;
80 negative: PositionData;
81}
82
83interface TracksPositionDataWithNames {
84 positive: PositionDataWithNames;
85 negative: PositionDataWithNames;
86}
87
88interface GridPositionNormalizedData {
89 rows: TracksPositionData;
90 columns: TracksPositionData;
91 bounds: Bounds;
92}
93
94export interface GridPositionNormalizedDataWithNames {
95 rows: TracksPositionDataWithNames;
96 columns: TracksPositionDataWithNames;
97 bounds: Bounds;
98}
99
100interface TrackSize {
101 computedSize: number;
102 authoredSize?: number;
103 x: number;
104 y: number;
105}
106
107export interface GridHighlightOptions {
108 gridBorderDash: boolean;
109 rowLineDash: boolean;
110 columnLineDash: boolean;
111 showGridExtensionLines: boolean;
112 showPositiveLineNumbers: boolean;
113 showNegativeLineNumbers: boolean;
114 rowLineColor?: string;
115 columnLineColor?: string;
116 rowHatchColor: string;
117 columnHatchColor: string;
118 showLineNames: boolean;
119}
120
121export interface GridHighlightConfig {
122 rotationAngle?: number;
123 writingMode?: string;
124 columnTrackSizes?: TrackSize[];
125 rowTrackSizes?: TrackSize[];
126 positiveRowLineNumberPositions?: Position[];
127 negativeRowLineNumberPositions?: Position[];
128 positiveColumnLineNumberPositions?: Position[];
129 negativeColumnLineNumberPositions?: Position[];
130 rowLineNameOffsets?: {name: string, x: number, y: number}[];
131 columnLineNameOffsets?: {name: string, x: number, y: number}[];
132 gridHighlightConfig?: GridHighlightOptions;
133}
134
135interface LabelSize {
136 width: number;
137 height: number;
138 mainSize: number;
139 crossSize: number;
140}
141
142export interface GridLabelState {
143 gridLayerCounter: number;
144}
145
146/**
147 * Places all of the required grid labels on the overlay. This includes row and
148 * column line number labels, and area labels.
149 */
150export function drawGridLabels(
151 config: GridHighlightConfig, gridBounds: Bounds, areaBounds: AreaBounds[], canvasSize: CanvasSize,
152 labelState: GridLabelState, emulationScaleFactor: number,
153 writingModeMatrix: DOMMatrix|undefined = new DOMMatrix()) {
154 // Find and clear the layer for the node specified in the config, or the default layer:
155 // Each node has a layer for grid labels in order to draw multiple grid highlights
156 // at once.
157 const labelContainerId = `grid-${labelState.gridLayerCounter++}-labels`;
158 let labelContainerForNode = document.getElementById(labelContainerId);
159 if (!labelContainerForNode) {
160 const mainLabelLayerContainer = document.getElementById('grid-label-container');
161 if (!mainLabelLayerContainer) {
162 throw new Error('#grid-label-container is not found');
163 }
164 labelContainerForNode = createChild(mainLabelLayerContainer, 'div');
165 labelContainerForNode.id = labelContainerId;
166 }
167
168 const rowColor = config.gridHighlightConfig && config.gridHighlightConfig.rowLineColor ?
169 config.gridHighlightConfig.rowLineColor :
170 defaultLabelColor;
171 const rowTextColor = generateLegibleTextColor(rowColor);
172
173 labelContainerForNode.style.setProperty('--row-label-color', rowColor);
174 labelContainerForNode.style.setProperty('--row-label-text-color', rowTextColor);
175
176 const columnColor = config.gridHighlightConfig && config.gridHighlightConfig.columnLineColor ?
177 config.gridHighlightConfig.columnLineColor :
178 defaultLabelColor;
179 const columnTextColor = generateLegibleTextColor(columnColor);
180
181 labelContainerForNode.style.setProperty('--column-label-color', columnColor);
182 labelContainerForNode.style.setProperty('--column-label-text-color', columnTextColor);
183
184 labelContainerForNode.innerText = '';
185
186 // Add the containers for the line and area to the node's layer
187 const areaNameContainer = createChild(labelContainerForNode, 'div', 'area-names');
188 const lineNameContainer = createChild(labelContainerForNode, 'div', 'line-names');
189 const lineNumberContainer = createChild(labelContainerForNode, 'div', 'line-numbers');
190 const trackSizesContainer = createChild(labelContainerForNode, 'div', 'track-sizes');
191
192 // Draw line numbers and names.
193 const normalizedData = normalizePositionData(config, gridBounds);
194 if (config.gridHighlightConfig && config.gridHighlightConfig.showLineNames) {
195 drawGridLineNames(
196 lineNameContainer, normalizedData as GridPositionNormalizedDataWithNames, canvasSize, emulationScaleFactor,
197 writingModeMatrix, config.writingMode);
198 } else {
199 drawGridLineNumbers(
200 lineNumberContainer, normalizedData, canvasSize, emulationScaleFactor, writingModeMatrix, config.writingMode);
201 }
202
203 // Draw area names.
204 drawGridAreaNames(areaNameContainer, areaBounds, writingModeMatrix, config.writingMode);
205
206 if (config.columnTrackSizes) {
207 // Draw column sizes.
208 drawGridTrackSizes(
209 trackSizesContainer, config.columnTrackSizes, 'column', canvasSize, emulationScaleFactor, writingModeMatrix,
210 config.writingMode);
211 }
212 if (config.rowTrackSizes) {
213 // Draw row sizes.
214 drawGridTrackSizes(
215 trackSizesContainer, config.rowTrackSizes, 'row', canvasSize, emulationScaleFactor, writingModeMatrix,
216 config.writingMode);
217 }
218}
219
220/**
221 * This is a generator function used to iterate over grid label positions in a way
222 * that skips the ones that are too close to eachother, in order to avoid overlaps.
223 */
224function* positionIterator(positions: Position[], axis: 'x'|'y'): Generator<[number, Position]> {
225 let lastEmittedPos = null;
226
227 for (const [i, pos] of positions.entries()) {
228 // Only emit the position if this is the first.
229 const isFirst = i === 0;
230 // Or if this is the last.
231 const isLast = i === positions.length - 1;
232 // Or if there is some minimum distance between the last emitted position.
233 const isFarEnoughFromPrevious =
234 Math.abs(pos[axis] - (lastEmittedPos ? lastEmittedPos[axis] : 0)) > gridLabelDistance;
235 // And if there is also some minium distance from the very last position.
236 const isFarEnoughFromLast =
237 !isLast && Math.abs(positions[positions.length - 1][axis] - pos[axis]) > gridLabelDistance;
238
239 if (isFirst || isLast || (isFarEnoughFromPrevious && isFarEnoughFromLast)) {
240 yield [i, pos];
241 lastEmittedPos = pos;
242 }
243 }
244}
245
246const last = <T>(array: T[]) => array[array.length - 1];
247const first = <T>(array: T[]) => array[0];
248
249/**
250 * Massage the list of line name positions given by the backend for easier consumption.
251 */
252function normalizeNameData(namePositions: {name: string, x: number, y: number}[]):
253 {positions: {x: number, y: number}[], names: string[][]} {
254 const positions = [];
255 const names = [];
256
257 for (const {name, x, y} of namePositions) {
258 const normalizedX = Math.round(x);
259 const normalizedY = Math.round(y);
260
261 // If the same position already exists, just add the name to the existing entry, as there can be
262 // several custom names for a single line.
263 const existingIndex = positions.findIndex(({x, y}) => x === normalizedX && y === normalizedY);
264 if (existingIndex > -1) {
265 names[existingIndex].push(name);
266 } else {
267 positions.push({x: normalizedX, y: normalizedY});
268 names.push([name]);
269 }
270 }
271
272 return {positions, names};
273}
274
275export interface NormalizePositionDataConfig {
276 positiveRowLineNumberPositions?: Position[];
277 negativeRowLineNumberPositions?: Position[];
278 positiveColumnLineNumberPositions?: Position[];
279 negativeColumnLineNumberPositions?: Position[];
280 rowLineNameOffsets?: {name: string, x: number, y: number}[];
281 columnLineNameOffsets?: {name: string, x: number, y: number}[];
282 gridHighlightConfig?: {showLineNames: boolean};
283}
284
285/**
286 * Take the highlight config and bound objects in, and spits out an object with
287 * the same information, but with 2 key differences:
288 * - the information is organized in a way that makes the rest of the code more
289 * readable
290 * - all pixel values are rounded to integers in order to safely compare
291 * positions (on high-dpi monitors floats are passed by the backend, this means
292 * checking if a position is at either edges of the container can't be done).
293 */
294export function normalizePositionData(config: NormalizePositionDataConfig, bounds: Bounds): GridPositionNormalizedData {
295 const width = Math.round(bounds.maxX - bounds.minX);
296 const height = Math.round(bounds.maxY - bounds.minY);
297
298 const data = {
299 rows: {
300 positive: {positions: [] as Position[], hasFirst: false, hasLast: false},
301 negative: {positions: [] as Position[], hasFirst: false, hasLast: false},
302 },
303 columns: {
304 positive: {positions: [] as Position[], hasFirst: false, hasLast: false},
305 negative: {positions: [] as Position[], hasFirst: false, hasLast: false},
306 },
307 bounds: {
308 minX: Math.round(bounds.minX),
309 maxX: Math.round(bounds.maxX),
310 minY: Math.round(bounds.minY),
311 maxY: Math.round(bounds.maxY),
312 allPoints: bounds.allPoints,
313 width,
314 height,
315 },
316 };
317
318 // Line numbers and line names can't be shown together at once for now.
319 // If showLineNames is set to true, then don't show line numbers, even if the
320 // data is present.
321
322 if (config.gridHighlightConfig && config.gridHighlightConfig.showLineNames) {
323 const rowData = normalizeNameData(config.rowLineNameOffsets || []);
324 const positiveRows: PositionDataWithNames = {
325 positions: rowData.positions,
326 names: rowData.names,
327 hasFirst: rowData.positions.length ? first(rowData.positions).y === data.bounds.minY : false,
328 hasLast: rowData.positions.length ? last(rowData.positions).y === data.bounds.maxY : false,
329 };
330 data.rows.positive = positiveRows;
331
332 const columnData = normalizeNameData(config.columnLineNameOffsets || []);
333 const positiveColumns: PositionDataWithNames = {
334 positions: columnData.positions,
335 names: columnData.names,
336 hasFirst: columnData.positions.length ? first(columnData.positions).x === data.bounds.minX : false,
337 hasLast: columnData.positions.length ? last(columnData.positions).x === data.bounds.maxX : false,
338 };
339 data.columns.positive = positiveColumns;
340 } else {
341 const normalizeXY = ({x, y}: {x: number, y: number}) => ({x: Math.round(x), y: Math.round(y)});
342 // TODO (alexrudenko): hasFirst & hasLast checks won't probably work for rotated grids.
343 if (config.positiveRowLineNumberPositions) {
344 data.rows.positive = {
345 positions: config.positiveRowLineNumberPositions.map(normalizeXY),
346 hasFirst: Math.round(first(config.positiveRowLineNumberPositions).y) === data.bounds.minY,
347 hasLast: Math.round(last(config.positiveRowLineNumberPositions).y) === data.bounds.maxY,
348 };
349 }
350
351 if (config.negativeRowLineNumberPositions) {
352 data.rows.negative = {
353 positions: config.negativeRowLineNumberPositions.map(normalizeXY),
354 hasFirst: Math.round(first(config.negativeRowLineNumberPositions).y) === data.bounds.minY,
355 hasLast: Math.round(last(config.negativeRowLineNumberPositions).y) === data.bounds.maxY,
356 };
357 }
358
359 if (config.positiveColumnLineNumberPositions) {
360 data.columns.positive = {
361 positions: config.positiveColumnLineNumberPositions.map(normalizeXY),
362 hasFirst: Math.round(first(config.positiveColumnLineNumberPositions).x) === data.bounds.minX,
363 hasLast: Math.round(last(config.positiveColumnLineNumberPositions).x) === data.bounds.maxX,
364 };
365 }
366
367 if (config.negativeColumnLineNumberPositions) {
368 data.columns.negative = {
369 positions: config.negativeColumnLineNumberPositions.map(normalizeXY),
370 hasFirst: Math.round(first(config.negativeColumnLineNumberPositions).x) === data.bounds.minX,
371 hasLast: Math.round(last(config.negativeColumnLineNumberPositions).x) === data.bounds.maxX,
372 };
373 }
374 }
375
376 return data;
377}
378
379/**
380 * Places the grid row and column number labels on the overlay.
381 *
382 * @param {HTMLElement} container Where to append the labels
383 * @param {GridPositionNormalizedData} data The grid line number data
384 * @param {DOMMatrix=} writingModeMatrix The transformation matrix in case a vertical writing-mode is applied, to map label positions
385 * @param {string=} writingMode The current writing-mode value
386 */
387export function drawGridLineNumbers(
388 container: HTMLElement, data: GridPositionNormalizedData, canvasSize: CanvasSize, emulationScaleFactor: number,
389 writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(), writingMode: string|undefined = 'horizontal-tb') {
390 if (!data.columns.positive.names) {
391 for (const [i, pos] of positionIterator(data.columns.positive.positions, 'x')) {
392 const element = createLabelElement(container, (i + 1).toString(), 'column');
393 placePositiveColumnLabel(
394 element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
395 }
396 }
397
398 if (!data.rows.positive.names) {
399 for (const [i, pos] of positionIterator(data.rows.positive.positions, 'y')) {
400 const element = createLabelElement(container, (i + 1).toString(), 'row');
401 placePositiveRowLabel(
402 element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
403 }
404 }
405
406 for (const [i, pos] of positionIterator(data.columns.negative.positions, 'x')) {
407 // Negative positions are sorted such that the first position corresponds to the line closest to start edge of the grid.
408 const element =
409 createLabelElement(container, (data.columns.negative.positions.length * -1 + i).toString(), 'column');
410 placeNegativeColumnLabel(
411 element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
412 }
413
414 for (const [i, pos] of positionIterator(data.rows.negative.positions, 'y')) {
415 // Negative positions are sorted such that the first position corresponds to the line closest to start edge of the grid.
416 const element = createLabelElement(container, (data.rows.negative.positions.length * -1 + i).toString(), 'row');
417 placeNegativeRowLabel(
418 element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
419 }
420}
421
422/**
423 * Places the grid track size labels on the overlay.
424 */
425export function drawGridTrackSizes(
426 container: HTMLElement, trackSizes: Array<TrackSize>, direction: 'row'|'column', canvasSize: CanvasSize,
427 emulationScaleFactor: number, writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(),
428 writingMode: string|undefined = 'horizontal-tb') {
429 const {main, cross} = getAxes(writingMode);
430 const {crossSize} = getCanvasSizes(writingMode, canvasSize);
431
432 for (const {x, y, computedSize, authoredSize} of trackSizes) {
433 const point = applyMatrixToPoint({x, y}, writingModeMatrix);
434
435 const size = computedSize.toFixed(2);
436 const formattedComputed = `${size.endsWith('.00') ? size.slice(0, -3) : size}px`;
437 const element =
438 createLabelElement(container, `${authoredSize ? authoredSize + '·' : ''}${formattedComputed}`, direction);
439 const labelSize = getLabelSize(element, writingMode);
440
441 let flipIn = point[main] - labelSize.mainSize < gridPageMargin;
442 if (direction === 'column') {
443 flipIn = writingMode === 'vertical-rl' ? crossSize - point[cross] - labelSize.crossSize < gridPageMargin :
444 point[cross] - labelSize.crossSize < gridPageMargin;
445 }
446
447 let arrowType = adaptArrowTypeForWritingMode(
448 direction === 'column' ? GridArrowTypes.bottomMid : GridArrowTypes.rightMid, writingMode);
449 arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
450
451 placeLineLabel(element, arrowType, point.x, point.y, labelSize, emulationScaleFactor);
452 }
453}
454
455/**
456 * Places the grid row and column name labels on the overlay.
457 */
458export function drawGridLineNames(
459 container: HTMLElement, data: GridPositionNormalizedDataWithNames, canvasSize: CanvasSize,
460 emulationScaleFactor: number, writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(),
461 writingMode: string|undefined = 'horizontal-tb') {
462 for (const [i, pos] of data.columns.positive.positions.entries()) {
463 const names = data.columns.positive.names[i];
464 const element = createLabelElement(container, makeLineNameLabelContent(names), 'column');
465 placePositiveColumnLabel(
466 element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
467 }
468
469 for (const [i, pos] of data.rows.positive.positions.entries()) {
470 const names = data.rows.positive.names[i];
471 const element = createLabelElement(container, makeLineNameLabelContent(names), 'row');
472 placePositiveRowLabel(
473 element, applyMatrixToPoint(pos, writingModeMatrix), data, writingMode, canvasSize, emulationScaleFactor);
474 }
475}
476
477/**
478 * Turn an array of custom line names into DOM content that can be used in a label.
479 */
480function makeLineNameLabelContent(names: string[]): HTMLElement {
481 const content = document.createElement('ul');
482 const namesToDisplay = names.slice(0, maxLineNamesCount);
483
484 for (const name of namesToDisplay) {
485 createChild(content, 'li', 'line-name').textContent = name;
486 }
487
488 return content;
489}
490
491/**
492 * Places the grid area name labels on the overlay.
493 */
494export function drawGridAreaNames(
495 container: HTMLElement, areaBounds: AreaBounds[], writingModeMatrix: DOMMatrix|undefined = new DOMMatrix(),
496 writingMode: string|undefined = 'horizontal-tb') {
497 for (const {name, bounds} of areaBounds) {
498 const element = createLabelElement(container, name, 'row');
499 const {width, height} = getLabelSize(element, writingMode);
500
501 // The list of all points comes from the path created by the backend. This path is a rectangle with its starting point being
502 // the top left corner, which is where we want to place the label (except for vertical-rl writing-mode).
503 const point = writingMode === 'vertical-rl' ? bounds.allPoints[3] : bounds.allPoints[0];
504 const corner = applyMatrixToPoint(point, writingModeMatrix);
505
506 const flipX = bounds.allPoints[1].x < bounds.allPoints[0].x;
507 const flipY = bounds.allPoints[3].y < bounds.allPoints[0].y;
508 element.style.left = (corner.x - (flipX ? width : 0)) + 'px';
509 element.style.top = (corner.y - (flipY ? height : 0)) + 'px';
510 }
511}
512
513/**
514 * Create the necessary DOM for a single label element.
515 */
516function createLabelElement(
517 container: HTMLElement, textContent: string|HTMLElement, direction: 'row'|'column'): HTMLElement {
518 const wrapper = createChild(container, 'div');
519 const element = createChild(wrapper, 'div', 'grid-label-content');
520 element.dataset.direction = direction;
521
522 if (typeof textContent === 'string') {
523 element.textContent = textContent;
524 } else {
525 element.appendChild(textContent);
526 }
527
528 return element;
529}
530
531/**
532 * Get the start and end points of the edge where labels are displayed.
533 */
534function getLabelSideEdgePoints(
535 gridBounds: Bounds, direction: string, side: string): {start: {x: number, y: number}, end: {x: number, y: number}} {
536 const [p1, p2, p3, p4] = gridBounds.allPoints;
537
538 // Here are where all the points are in standard, untransformed, horizontal-tb mode:
539 // p1 p2
540 // +----------------------+
541 // | |
542 // +----------------------+
543 // p4 p3
544
545 if (direction === 'row') {
546 return side === 'positive' ? {start: p1, end: p4} : {start: p2, end: p3};
547 }
548
549 return side === 'positive' ? {start: p1, end: p2} : {start: p4, end: p3};
550}
551
552/**
553 * Get the name of the main and cross axes depending on the writing mode.
554 * In "normal" horizonta-tb mode, the main axis is the one that goes horizontally from left to right,
555 * hence, the x axis.
556 * In vertical writing modes, the axes are swapped.
557 */
558function getAxes(writingMode: string): {main: 'x'|'y', cross: 'x'|'y'} {
559 return writingMode.startsWith('vertical') ? {main: 'y', cross: 'x'} : {main: 'x', cross: 'y'};
560}
561
562/**
563 * Get the main and cross sizes of the canvas area depending on the writing mode.
564 * In "normal" horizonta-tb mode, the main axis is the one that goes horizontally from left to right,
565 * hence, the main size of the canvas is its width, and its cross size is its height.
566 * In vertical writing modes, those sizes are swapped.
567 */
568function getCanvasSizes(writingMode: string, canvasSize: CanvasSize): {mainSize: number, crossSize: number} {
569 return writingMode.startsWith('vertical') ? {mainSize: canvasSize.canvasHeight, crossSize: canvasSize.canvasWidth} :
570 {mainSize: canvasSize.canvasWidth, crossSize: canvasSize.canvasHeight};
571}
572
573/**
574 * Determine the position of a positive row label, and place it.
575 */
576function placePositiveRowLabel(
577 element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
578 emulationScaleFactor: number) {
579 const {start, end} = getLabelSideEdgePoints(data.bounds, 'row', 'positive');
580 const {main, cross} = getAxes(writingMode);
581 const {crossSize} = getCanvasSizes(writingMode, canvasSize);
582 const labelSize = getLabelSize(element, writingMode);
583
584 const isAtSharedStartCorner = pos[cross] === start[cross] && data.columns && data.columns.positive.hasFirst;
585 const isAtSharedEndCorner = pos[cross] === end[cross] && data.columns && data.columns.negative.hasFirst;
586 const isTooCloseToViewportStart = pos[cross] < gridPageMargin;
587 const isTooCloseToViewportEnd = crossSize - pos[cross] < gridPageMargin;
588 const flipIn = pos[main] - labelSize.mainSize < gridPageMargin;
589
590 if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
591 element.classList.add('inner-shared-corner');
592 }
593
594 let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.rightMid, writingMode);
595 if (isTooCloseToViewportStart || isAtSharedStartCorner) {
596 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.rightTop, writingMode);
597 } else if (isTooCloseToViewportEnd || isAtSharedEndCorner) {
598 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.rightBottom, writingMode);
599 }
600 arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
601
602 placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
603}
604
605/**
606 * Determine the position of a negative row label, and place it.
607 */
608function placeNegativeRowLabel(
609 element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
610 emulationScaleFactor: number) {
611 const {start, end} = getLabelSideEdgePoints(data.bounds, 'row', 'negative');
612 const {main, cross} = getAxes(writingMode);
613 const {mainSize, crossSize} = getCanvasSizes(writingMode, canvasSize);
614 const labelSize = getLabelSize(element, writingMode);
615
616 const isAtSharedStartCorner = pos[cross] === start[cross] && data.columns && data.columns.positive.hasLast;
617 const isAtSharedEndCorner = pos[cross] === end[cross] && data.columns && data.columns.negative.hasLast;
618 const isTooCloseToViewportStart = pos[cross] < gridPageMargin;
619 const isTooCloseToViewportEnd = crossSize - pos[cross] < gridPageMargin;
620 const flipIn = mainSize - pos[main] - labelSize.mainSize < gridPageMargin;
621
622 if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
623 element.classList.add('inner-shared-corner');
624 }
625
626 let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.leftMid, writingMode);
627 if (isTooCloseToViewportStart || isAtSharedStartCorner) {
628 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.leftTop, writingMode);
629 } else if (isTooCloseToViewportEnd || isAtSharedEndCorner) {
630 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.leftBottom, writingMode);
631 }
632 arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
633
634 placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
635}
636
637/**
638 * Determine the position of a positive column label, and place it.
639 */
640function placePositiveColumnLabel(
641 element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
642 emulationScaleFactor: number) {
643 const {start, end} = getLabelSideEdgePoints(data.bounds, 'column', 'positive');
644 const {main, cross} = getAxes(writingMode);
645 const {mainSize, crossSize} = getCanvasSizes(writingMode, canvasSize);
646 const labelSize = getLabelSize(element, writingMode);
647
648 const isAtSharedStartCorner = pos[main] === start[main] && data.rows && data.rows.positive.hasFirst;
649 const isAtSharedEndCorner = pos[main] === end[main] && data.rows && data.rows.negative.hasFirst;
650 const isTooCloseToViewportStart = pos[main] < gridPageMargin;
651 const isTooCloseToViewportEnd = mainSize - pos[main] < gridPageMargin;
652 const flipIn = writingMode === 'vertical-rl' ? crossSize - pos[cross] - labelSize.crossSize < gridPageMargin :
653 pos[cross] - labelSize.crossSize < gridPageMargin;
654
655 if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
656 element.classList.add('inner-shared-corner');
657 }
658
659 let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.bottomMid, writingMode);
660 if (isTooCloseToViewportStart) {
661 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.bottomLeft, writingMode);
662 } else if (isTooCloseToViewportEnd) {
663 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.bottomRight, writingMode);
664 }
665
666 arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
667
668 placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
669}
670
671/**
672 * Determine the position of a negative column label, and place it.
673 */
674function placeNegativeColumnLabel(
675 element: HTMLElement, pos: Position, data: GridPositionNormalizedData, writingMode: string, canvasSize: CanvasSize,
676 emulationScaleFactor: number) {
677 const {start, end} = getLabelSideEdgePoints(data.bounds, 'column', 'negative');
678 const {main, cross} = getAxes(writingMode);
679 const {mainSize, crossSize} = getCanvasSizes(writingMode, canvasSize);
680 const labelSize = getLabelSize(element, writingMode);
681
682 const isAtSharedStartCorner = pos[main] === start[main] && data.rows && data.rows.positive.hasLast;
683 const isAtSharedEndCorner = pos[main] === end[main] && data.rows && data.rows.negative.hasLast;
684 const isTooCloseToViewportStart = pos[main] < gridPageMargin;
685 const isTooCloseToViewportEnd = mainSize - pos[main] < gridPageMargin;
686 const flipIn = writingMode === 'vertical-rl' ? pos[cross] - labelSize.crossSize < gridPageMargin :
687 crossSize - pos[cross] - labelSize.crossSize < gridPageMargin;
688
689 if (flipIn && (isAtSharedStartCorner || isAtSharedEndCorner)) {
690 element.classList.add('inner-shared-corner');
691 }
692
693 let arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.topMid, writingMode);
694 if (isTooCloseToViewportStart) {
695 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.topLeft, writingMode);
696 } else if (isTooCloseToViewportEnd) {
697 arrowType = adaptArrowTypeForWritingMode(GridArrowTypes.topRight, writingMode);
698 }
699 arrowType = flipArrowTypeIfNeeded(arrowType, flipIn);
700
701 placeLineLabel(element, arrowType, pos.x, pos.y, labelSize, emulationScaleFactor);
702}
703
704/**
705 * Correctly place a line label element in the page. The given coordinates are
706 * the ones where the arrow of the label needs to point.
707 * Therefore, the width of the text in the label, and the position of the arrow
708 * relative to the label are taken into account here to calculate the final x
709 * and y coordinates of the label DOM element.
710 */
711function placeLineLabel(
712 element: HTMLElement, arrowType: string, x: number, y: number, labelSize: LabelSize, emulationScaleFactor: number) {
713 const {contentLeft, contentTop} =
714 getLabelPositionByArrowType(arrowType, x, y, labelSize.width, labelSize.height, emulationScaleFactor);
715
716 element.classList.add(arrowType);
717 element.style.left = contentLeft + 'px';
718 element.style.top = contentTop + 'px';
719}
720
721/**
722 * Given a label element, return its width and height, as well as what the main and cross sizes are depending on
723 * the current writing mode.
724 */
725function getLabelSize(element: HTMLElement, writingMode: string): LabelSize {
726 const width = getAdjustedLabelWidth(element);
727 const height = element.getBoundingClientRect().height;
728 const mainSize = writingMode.startsWith('vertical') ? height : width;
729 const crossSize = writingMode.startsWith('vertical') ? width : height;
730
731 return {width, height, mainSize, crossSize};
732}
733
734/**
735 * Forces the width of the provided grid label element to be an even
736 * number of pixels to allow centered placement of the arrow
737 */
738function getAdjustedLabelWidth(element: HTMLElement) {
739 let labelWidth = element.getBoundingClientRect().width;
740 if (labelWidth % 2 === 1) {
741 labelWidth += 1;
742 element.style.width = labelWidth + 'px';
743 }
744 return labelWidth;
745}
746
747/**
748 * In some cases, a label doesn't fit where it's supposed to be displayed.
749 * This happens when it's too close to the edge of the viewport. When it does,
750 * the label's position is flipped so that instead of being outside the grid, it
751 * moves inside the grid.
752 *
753 * Example of a leftMid arrowType, which is by default outside the grid:
754 * -----------------------------
755 * | | +------+
756 * | | | |
757 * |-----------------------------| < |
758 * | | | |
759 * | | +------+
760 * -----------------------------
761 * When flipped, the label will be drawn inside the grid, so the arrow now needs
762 * to point the other way:
763 * -----------------------------
764 * | +------+ |
765 * | | | |
766 * |------------------| >--|
767 * | | | |
768 * | +------+ |
769 * -----------------------------
770 */
771function flipArrowTypeIfNeeded(arrowType: string, flipIn: boolean): string {
772 if (!flipIn) {
773 return arrowType;
774 }
775
776 switch (arrowType) {
777 case GridArrowTypes.leftTop:
778 return GridArrowTypes.rightTop;
779 case GridArrowTypes.leftMid:
780 return GridArrowTypes.rightMid;
781 case GridArrowTypes.leftBottom:
782 return GridArrowTypes.rightBottom;
783 case GridArrowTypes.rightTop:
784 return GridArrowTypes.leftTop;
785 case GridArrowTypes.rightMid:
786 return GridArrowTypes.leftMid;
787 case GridArrowTypes.rightBottom:
788 return GridArrowTypes.leftBottom;
789 case GridArrowTypes.topLeft:
790 return GridArrowTypes.bottomLeft;
791 case GridArrowTypes.topMid:
792 return GridArrowTypes.bottomMid;
793 case GridArrowTypes.topRight:
794 return GridArrowTypes.bottomRight;
795 case GridArrowTypes.bottomLeft:
796 return GridArrowTypes.topLeft;
797 case GridArrowTypes.bottomMid:
798 return GridArrowTypes.topMid;
799 case GridArrowTypes.bottomRight:
800 return GridArrowTypes.topRight;
801 }
802
803 return arrowType;
804}
805
806/**
807 * Given an arrow type for the standard horizontal-tb writing-mode, return the corresponding type for a differnet
808 * writing-mode.
809 */
810function adaptArrowTypeForWritingMode(arrowType: string, writingMode: string): string {
811 if (writingMode === 'vertical-lr') {
812 switch (arrowType) {
813 case GridArrowTypes.leftTop:
814 return GridArrowTypes.topLeft;
815 case GridArrowTypes.leftMid:
816 return GridArrowTypes.topMid;
817 case GridArrowTypes.leftBottom:
818 return GridArrowTypes.topRight;
819 case GridArrowTypes.topLeft:
820 return GridArrowTypes.leftTop;
821 case GridArrowTypes.topMid:
822 return GridArrowTypes.leftMid;
823 case GridArrowTypes.topRight:
824 return GridArrowTypes.leftBottom;
825 case GridArrowTypes.rightTop:
826 return GridArrowTypes.bottomRight;
827 case GridArrowTypes.rightMid:
828 return GridArrowTypes.bottomMid;
829 case GridArrowTypes.rightBottom:
830 return GridArrowTypes.bottomLeft;
831 case GridArrowTypes.bottomLeft:
832 return GridArrowTypes.rightTop;
833 case GridArrowTypes.bottomMid:
834 return GridArrowTypes.rightMid;
835 case GridArrowTypes.bottomRight:
836 return GridArrowTypes.rightBottom;
837 }
838 }
839
840 if (writingMode === 'vertical-rl') {
841 switch (arrowType) {
842 case GridArrowTypes.leftTop:
843 return GridArrowTypes.topRight;
844 case GridArrowTypes.leftMid:
845 return GridArrowTypes.topMid;
846 case GridArrowTypes.leftBottom:
847 return GridArrowTypes.topLeft;
848 case GridArrowTypes.topLeft:
849 return GridArrowTypes.rightTop;
850 case GridArrowTypes.topMid:
851 return GridArrowTypes.rightMid;
852 case GridArrowTypes.topRight:
853 return GridArrowTypes.rightBottom;
854 case GridArrowTypes.rightTop:
855 return GridArrowTypes.bottomRight;
856 case GridArrowTypes.rightMid:
857 return GridArrowTypes.bottomMid;
858 case GridArrowTypes.rightBottom:
859 return GridArrowTypes.bottomLeft;
860 case GridArrowTypes.bottomLeft:
861 return GridArrowTypes.leftTop;
862 case GridArrowTypes.bottomMid:
863 return GridArrowTypes.leftMid;
864 case GridArrowTypes.bottomRight:
865 return GridArrowTypes.leftBottom;
866 }
867 }
868
869 return arrowType;
870}
871
872/**
873 * Returns the required properties needed to place a label arrow based on the
874 * arrow type and dimensions of the label
875 */
876function getLabelPositionByArrowType(
877 arrowType: string, x: number, y: number, labelWidth: number, labelHeight: number,
878 emulationScaleFactor: number): {contentTop: number, contentLeft: number} {
879 let contentTop = 0;
880 let contentLeft = 0;
881 x *= emulationScaleFactor;
882 y *= emulationScaleFactor;
883 switch (arrowType) {
884 case GridArrowTypes.leftTop:
885 contentTop = y;
886 contentLeft = x + gridArrowWidth;
887 break;
888 case GridArrowTypes.leftMid:
889 contentTop = y - (labelHeight / 2);
890 contentLeft = x + gridArrowWidth;
891 break;
892 case GridArrowTypes.leftBottom:
893 contentTop = y - labelHeight;
894 contentLeft = x + gridArrowWidth;
895 break;
896 case GridArrowTypes.rightTop:
897 contentTop = y;
898 contentLeft = x - gridArrowWidth - labelWidth;
899 break;
900 case GridArrowTypes.rightMid:
901 contentTop = y - (labelHeight / 2);
902 contentLeft = x - gridArrowWidth - labelWidth;
903 break;
904 case GridArrowTypes.rightBottom:
905 contentTop = y - labelHeight;
906 contentLeft = x - labelWidth - gridArrowWidth;
907 break;
908 case GridArrowTypes.topLeft:
909 contentTop = y + gridArrowWidth;
910 contentLeft = x;
911 break;
912 case GridArrowTypes.topMid:
913 contentTop = y + gridArrowWidth;
914 contentLeft = x - (labelWidth / 2);
915 break;
916 case GridArrowTypes.topRight:
917 contentTop = y + gridArrowWidth;
918 contentLeft = x - labelWidth;
919 break;
920 case GridArrowTypes.bottomLeft:
921 contentTop = y - gridArrowWidth - labelHeight;
922 contentLeft = x;
923 break;
924 case GridArrowTypes.bottomMid:
925 contentTop = y - gridArrowWidth - labelHeight;
926 contentLeft = x - (labelWidth / 2);
927 break;
928 case GridArrowTypes.bottomRight:
929 contentTop = y - gridArrowWidth - labelHeight;
930 contentLeft = x - labelWidth;
931 break;
932 }
933 return {
934 contentTop,
935 contentLeft,
936 };
937}
938
939/**
940 * Given a background color, generate a color for text to be legible.
941 * This assumes the background color is given as either a "rgba(r, g, b, a)" string or a #rrggbb string.
942 * This is because colors are sent by the backend using blink::Color:Serialized() which follows the logic for
943 * serializing colors from https://html.spec.whatwg.org/#serialization-of-a-color
944 *
945 * In rgba form, the alpha channel is ignored.
946 *
947 * This is made to be small and fast and not require importing the entire Color utility from DevTools as it would make
948 * the overlay bundle unnecessarily big.
949 *
950 * This is also made to generate the defaultLabelTextColor for all of the default label colors that the
951 * OverlayColorGenerator produces.
952 */
953export function generateLegibleTextColor(backgroundColor: string) {
954 let rgb: number[] = [];
955
956 // Try to parse it as a #rrggbbaa string first
957 const rgba = parseHexa(backgroundColor + '00');
958 if (rgba.length === 4) {
959 rgb = rgba.slice(0, 3).map(c => c);
960 } else {
961 // Next try to parse as a rgba() string
962 const parsed = backgroundColor.match(/[0-9.]+/g);
963 if (!parsed) {
964 return null;
965 }
966 rgb = parsed.slice(0, 3).map(s => parseInt(s, 10) / 255);
967 }
968
969 if (!rgb.length) {
970 return null;
971 }
972
973 return luminance(rgb) > 0.2 ? defaultLabelTextColor : 'white';
974}