| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import type {Data} from './index'; |
| 11 | import type {Rect} from '../utils'; |
| 12 | import type {HostInstance} from '../../types'; |
| 13 | import type Agent from '../../agent'; |
| 14 | |
| 15 | import {isReactNativeEnvironment} from 'react-devtools-shared/src/backend/utils'; |
| 16 | |
| 17 | // Note these colors are in sync with DevTools Profiler chart colors. |
| 18 | const COLORS = [ |
| 19 | '#37afa9', |
| 20 | '#63b19e', |
| 21 | '#80b393', |
| 22 | '#97b488', |
| 23 | '#abb67d', |
| 24 | '#beb771', |
| 25 | '#cfb965', |
| 26 | '#dfba57', |
| 27 | '#efbb49', |
| 28 | '#febc38', |
| 29 | ]; |
| 30 | |
| 31 | let canvas: HTMLCanvasElement | null = null; |
| 32 | |
| 33 | function drawNative(nodeToData: Map<HostInstance, Data>, agent: Agent) { |
| 34 | const nodesToDraw = []; |
| 35 | iterateNodes(nodeToData, ({color, node}) => { |
| 36 | nodesToDraw.push({node, color}); |
| 37 | }); |
| 38 | |
| 39 | agent.emit('drawTraceUpdates', nodesToDraw); |
| 40 | |
| 41 | const mergedNodes = groupAndSortNodes(nodeToData); |
| 42 | agent.emit('drawGroupedTraceUpdatesWithNames', mergedNodes); |
| 43 | } |
| 44 | |
| 45 | function drawWeb(nodeToData: Map<HostInstance, Data>) { |
| 46 | if (canvas === null) { |
| 47 | initialize(); |
| 48 | } |
| 49 | |
| 50 | const dpr = window.devicePixelRatio || 1; |
| 51 | const canvasFlow: HTMLCanvasElement = canvas as any as HTMLCanvasElement; |
| 52 | canvasFlow.width = window.innerWidth * dpr; |
| 53 | canvasFlow.height = window.innerHeight * dpr; |
| 54 | canvasFlow.style.width = `${window.innerWidth}px`; |
| 55 | canvasFlow.style.height = `${window.innerHeight}px`; |
| 56 | |
| 57 | const context = canvasFlow.getContext('2d'); |
| 58 | context.scale(dpr, dpr); |
| 59 | |
| 60 | context.clearRect(0, 0, canvasFlow.width / dpr, canvasFlow.height / dpr); |
| 61 | |
| 62 | const mergedNodes = groupAndSortNodes(nodeToData); |
| 63 | |
| 64 | mergedNodes.forEach(group => { |
| 65 | drawGroupBorders(context, group); |
| 66 | drawGroupLabel(context, group); |
| 67 | }); |
| 68 | |
| 69 | if (canvas !== null) { |
| 70 | if (nodeToData.size === 0 && canvas.matches(':popover-open')) { |
| 71 | // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API |
| 72 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API |
| 73 | canvas.hidePopover(); |
| 74 | return; |
| 75 | } |
| 76 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API |
| 77 | if (canvas.matches(':popover-open')) { |
| 78 | // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API |
| 79 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API |
| 80 | canvas.hidePopover(); |
| 81 | } |
| 82 | // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API |
| 83 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API |
| 84 | canvas.showPopover(); |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | type GroupItem = { |
| 89 | rect: Rect, |
| 90 | color: string, |
| 91 | displayName: string | null, |
| 92 | count: number, |
| 93 | }; |
| 94 | |
| 95 | export type {GroupItem}; |
| 96 | |
| 97 | export function groupAndSortNodes( |
| 98 | nodeToData: Map<HostInstance, Data>, |
| 99 | ): Array<Array<GroupItem>> { |
| 100 | const positionGroups: Map<string, Array<GroupItem>> = new Map(); |
| 101 | |
| 102 | iterateNodes(nodeToData, ({rect, color, displayName, count}) => { |
| 103 | if (!rect) return; |
| 104 | const key = `${rect.left},${rect.top}`; |
| 105 | if (!positionGroups.has(key)) positionGroups.set(key, []); |
| 106 | positionGroups.get(key)?.push({rect, color, displayName, count}); |
| 107 | }); |
| 108 | |
| 109 | return Array.from(positionGroups.values()).sort((groupA, groupB) => { |
| 110 | const maxCountA = Math.max(...groupA.map(item => item.count)); |
| 111 | const maxCountB = Math.max(...groupB.map(item => item.count)); |
| 112 | return maxCountA - maxCountB; |
| 113 | }); |
| 114 | } |
| 115 | |
| 116 | function drawGroupBorders( |
| 117 | context: CanvasRenderingContext2D, |
| 118 | group: Array<GroupItem>, |
| 119 | ) { |
| 120 | group.forEach(({color, rect}) => { |
| 121 | context.beginPath(); |
| 122 | context.strokeStyle = color; |
| 123 | context.rect(rect.left, rect.top, rect.width - 1, rect.height - 1); |
| 124 | context.stroke(); |
| 125 | }); |
| 126 | } |
| 127 | |
| 128 | function drawGroupLabel( |
| 129 | context: CanvasRenderingContext2D, |
| 130 | group: Array<GroupItem>, |
| 131 | ) { |
| 132 | const mergedName = group |
| 133 | .map(({displayName, count}) => |
| 134 | displayName ? `${displayName}${count > 1 ? ` x${count}` : ''}` : '', |
| 135 | ) |
| 136 | .filter(Boolean) |
| 137 | .join(', '); |
| 138 | |
| 139 | if (mergedName) { |
| 140 | drawLabel(context, group[0].rect, mergedName, group[0].color); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | export function draw(nodeToData: Map<HostInstance, Data>, agent: Agent): void { |
| 145 | return isReactNativeEnvironment() |
| 146 | ? drawNative(nodeToData, agent) |
| 147 | : drawWeb(nodeToData); |
| 148 | } |
| 149 | |
| 150 | type DataWithColorAndNode = { |
| 151 | ...Data, |
| 152 | color: string, |
| 153 | node: HostInstance, |
| 154 | }; |
| 155 | |
| 156 | function iterateNodes( |
| 157 | nodeToData: Map<HostInstance, Data>, |
| 158 | execute: (data: DataWithColorAndNode) => void, |
| 159 | ) { |
| 160 | nodeToData.forEach((data, node) => { |
| 161 | const colorIndex = Math.min(COLORS.length - 1, data.count - 1); |
| 162 | const color = COLORS[colorIndex]; |
| 163 | execute({ |
| 164 | color, |
| 165 | node, |
| 166 | count: data.count, |
| 167 | displayName: data.displayName, |
| 168 | expirationTime: data.expirationTime, |
| 169 | lastMeasuredAt: data.lastMeasuredAt, |
| 170 | rect: data.rect, |
| 171 | }); |
| 172 | }); |
| 173 | } |
| 174 | |
| 175 | function drawLabel( |
| 176 | context: CanvasRenderingContext2D, |
| 177 | rect: Rect, |
| 178 | text: string, |
| 179 | color: string, |
| 180 | ): void { |
| 181 | const {left, top} = rect; |
| 182 | context.font = '10px monospace'; |
| 183 | context.textBaseline = 'middle'; |
| 184 | context.textAlign = 'center'; |
| 185 | |
| 186 | const padding = 2; |
| 187 | const textHeight = 14; |
| 188 | |
| 189 | const metrics = context.measureText(text); |
| 190 | const backgroundWidth = metrics.width + padding * 2; |
| 191 | const backgroundHeight = textHeight; |
| 192 | const labelX = left; |
| 193 | const labelY = top - backgroundHeight; |
| 194 | |
| 195 | context.fillStyle = color; |
| 196 | context.fillRect(labelX, labelY, backgroundWidth, backgroundHeight); |
| 197 | |
| 198 | context.fillStyle = '#000000'; |
| 199 | context.fillText( |
| 200 | text, |
| 201 | labelX + backgroundWidth / 2, |
| 202 | labelY + backgroundHeight / 2, |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | function destroyNative(agent: Agent) { |
| 207 | agent.emit('disableTraceUpdates'); |
| 208 | } |
| 209 | |
| 210 | function destroyWeb() { |
| 211 | if (canvas !== null) { |
| 212 | if (canvas.matches(':popover-open')) { |
| 213 | // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API |
| 214 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API |
| 215 | canvas.hidePopover(); |
| 216 | } |
| 217 | |
| 218 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API and loses canvas nullability tracking |
| 219 | if (canvas.parentNode != null) { |
| 220 | // $FlowFixMe[incompatible-type]: Flow doesn't track that canvas is non-null here |
| 221 | canvas.parentNode.removeChild(canvas); |
| 222 | } |
| 223 | canvas = null; |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | export function destroy(agent: Agent): void { |
| 228 | return isReactNativeEnvironment() ? destroyNative(agent) : destroyWeb(); |
| 229 | } |
| 230 | |
| 231 | function initialize(): void { |
| 232 | canvas = window.document.createElement('canvas'); |
| 233 | canvas.setAttribute('popover', 'manual'); |
| 234 | |
| 235 | // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API |
| 236 | canvas.style.cssText = ` |
| 237 | xx-background-color: red; |
| 238 | xx-opacity: 0.5; |
| 239 | bottom: 0; |
| 240 | left: 0; |
| 241 | pointer-events: none; |
| 242 | position: fixed; |
| 243 | right: 0; |
| 244 | top: 0; |
| 245 | background-color: transparent; |
| 246 | outline: none; |
| 247 | box-shadow: none; |
| 248 | border: none; |
| 249 | `; |
| 250 | |
| 251 | const root = window.document.documentElement; |
| 252 | root.insertBefore(canvas, root.firstChild); |
| 253 | } |