1// Copyright (C) 2018 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import * as m from 'mithril'; 16 17import {searchSegment} from '../../base/binary_search'; 18import {assertTrue} from '../../base/logging'; 19import {Actions} from '../../common/actions'; 20import {toNs} from '../../common/time'; 21import {checkerboardExcept} from '../../frontend/checkerboard'; 22import {globals} from '../../frontend/globals'; 23import {NewTrackArgs, Track} from '../../frontend/track'; 24import {TrackButton, TrackButtonAttrs} from '../../frontend/track_panel'; 25import {trackRegistry} from '../../frontend/track_registry'; 26 27import { 28 Config, 29 COUNTER_TRACK_KIND, 30 CounterScaleOptions, 31 Data, 32} from './common'; 33 34// 0.5 Makes the horizontal lines sharp. 35const MARGIN_TOP = 3.5; 36const RECT_HEIGHT = 24.5; 37 38interface CounterScaleAttribute { 39 follower: CounterScaleOptions; 40 tooltip: string; 41 icon: string; 42} 43 44function scaleTooltip(scale?: CounterScaleOptions): string { 45 const description: CounterScaleAttribute = getCounterScaleAttribute(scale); 46 const source: string = description.tooltip; 47 const destination: string = 48 getCounterScaleAttribute(description.follower).tooltip; 49 return `Toggle scale from ${source} to ${destination}`; 50} 51 52function scaleIcon(scale?: CounterScaleOptions): string { 53 return getCounterScaleAttribute(scale).icon; 54} 55 56function nextScale(scale?: CounterScaleOptions): CounterScaleOptions { 57 return getCounterScaleAttribute(scale).follower; 58} 59 60function getCounterScaleAttribute(scale?: CounterScaleOptions): 61 CounterScaleAttribute { 62 switch (scale) { 63 case 'MIN_MAX': 64 return { 65 follower: 'DELTA_FROM_PREVIOUS', 66 tooltip: 'min/max', 67 icon: 'show_chart' 68 }; 69 case 'DELTA_FROM_PREVIOUS': 70 return {follower: 'ZERO_BASED', tooltip: 'delta', icon: 'bar_chart'}; 71 case 'ZERO_BASED': 72 default: 73 return { 74 follower: 'MIN_MAX', 75 tooltip: 'zero based', 76 icon: 'waterfall_chart' 77 }; 78 } 79} 80 81class CounterTrack extends Track<Config, Data> { 82 static readonly kind = COUNTER_TRACK_KIND; 83 static create(args: NewTrackArgs): CounterTrack { 84 return new CounterTrack(args); 85 } 86 87 private mousePos = {x: 0, y: 0}; 88 private hoveredValue: number|undefined = undefined; 89 private hoveredTs: number|undefined = undefined; 90 private hoveredTsEnd: number|undefined = undefined; 91 92 constructor(args: NewTrackArgs) { 93 super(args); 94 } 95 96 getHeight() { 97 return MARGIN_TOP + RECT_HEIGHT; 98 } 99 100 getTrackShellButtons(): Array<m.Vnode<TrackButtonAttrs>> { 101 const buttons: Array<m.Vnode<TrackButtonAttrs>> = []; 102 buttons.push(m(TrackButton, { 103 action: () => { 104 this.config.scale = nextScale(this.config.scale); 105 Actions.updateTrackConfig( 106 {id: this.trackState.id, config: this.config}); 107 globals.rafScheduler.scheduleFullRedraw(); 108 }, 109 i: scaleIcon(this.config.scale), 110 tooltip: scaleTooltip(this.config.scale), 111 showButton: !!this.config.scale && this.config.scale !== 'ZERO_BASED', 112 })); 113 return buttons; 114 } 115 116 renderCanvas(ctx: CanvasRenderingContext2D): void { 117 // TODO: fonts and colors should come from the CSS and not hardcoded here. 118 const {timeScale, visibleWindowTime} = globals.frontendLocalState; 119 const data = this.data(); 120 121 // Can't possibly draw anything. 122 if (data === undefined || data.timestamps.length === 0) { 123 return; 124 } 125 126 assertTrue(data.timestamps.length === data.minValues.length); 127 assertTrue(data.timestamps.length === data.maxValues.length); 128 assertTrue(data.timestamps.length === data.lastValues.length); 129 assertTrue(data.timestamps.length === data.totalDeltas.length); 130 131 const scale: CounterScaleOptions = this.config.scale || 'ZERO_BASED'; 132 133 let minValues = data.minValues; 134 let maxValues = data.maxValues; 135 let lastValues = data.lastValues; 136 let maximumValue = data.maximumValue; 137 let minimumValue = data.minimumValue; 138 if (scale === 'DELTA_FROM_PREVIOUS') { 139 lastValues = data.totalDeltas; 140 minValues = data.totalDeltas; 141 maxValues = data.totalDeltas; 142 maximumValue = data.maximumDelta; 143 minimumValue = data.minimumDelta; 144 } 145 146 const endPx = Math.floor(timeScale.timeToPx(visibleWindowTime.end)); 147 const zeroY = MARGIN_TOP + RECT_HEIGHT / (minimumValue < 0 ? 2 : 1); 148 149 // Quantize the Y axis to quarters of powers of tens (7.5K, 10K, 12.5K). 150 const maxValue = Math.max(maximumValue, 0); 151 152 let yMax = Math.max(Math.abs(minimumValue), maxValue); 153 const kUnits = ['', 'K', 'M', 'G', 'T', 'E']; 154 const exp = Math.ceil(Math.log10(Math.max(yMax, 1))); 155 const pow10 = Math.pow(10, exp); 156 yMax = Math.ceil(yMax / (pow10 / 4)) * (pow10 / 4); 157 let yRange = 0; 158 const unitGroup = Math.floor(exp / 3); 159 let yMin = 0; 160 let yLabel = ''; 161 if (scale === 'MIN_MAX') { 162 yRange = maximumValue - minimumValue; 163 yMin = minimumValue; 164 yLabel = 'min - max'; 165 } else { 166 yRange = minimumValue < 0 ? yMax * 2 : yMax; 167 yMin = minimumValue < 0 ? -yMax : 0; 168 yLabel = `${yMax / Math.pow(10, unitGroup * 3)} ${kUnits[unitGroup]}`; 169 if (scale === 'DELTA_FROM_PREVIOUS') { 170 yLabel += '\u0394'; 171 } 172 } 173 174 // There are 360deg of hue. We want a scale that starts at green with 175 // exp <= 3 (<= 1KB), goes orange around exp = 6 (~1MB) and red/violet 176 // around exp >= 9 (1GB). 177 // The hue scale looks like this: 178 // 0 180 360 179 // Red orange green | blue purple magenta 180 // So we want to start @ 180deg with pow=0, go down to 0deg and then wrap 181 // back from 360deg back to 180deg. 182 const expCapped = Math.min(Math.max(exp - 3), 9); 183 const hue = (180 - Math.floor(expCapped * (180 / 6)) + 360) % 360; 184 185 ctx.fillStyle = `hsl(${hue}, 45%, 75%)`; 186 ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`; 187 188 const calculateX = (ts: number) => { 189 return Math.floor(timeScale.timeToPx(ts)); 190 }; 191 const calculateY = (value: number) => { 192 return MARGIN_TOP + RECT_HEIGHT - 193 Math.round(((value - yMin) / yRange) * RECT_HEIGHT); 194 }; 195 196 ctx.beginPath(); 197 ctx.moveTo(calculateX(data.timestamps[0]), zeroY); 198 let lastDrawnY = zeroY; 199 for (let i = 0; i < data.timestamps.length; i++) { 200 const x = calculateX(data.timestamps[i]); 201 const minY = calculateY(minValues[i]); 202 const maxY = calculateY(maxValues[i]); 203 const lastY = calculateY(lastValues[i]); 204 205 ctx.lineTo(x, lastDrawnY); 206 if (minY === maxY) { 207 assertTrue(lastY === minY); 208 ctx.lineTo(x, lastY); 209 } else { 210 ctx.lineTo(x, minY); 211 ctx.lineTo(x, maxY); 212 ctx.lineTo(x, lastY); 213 } 214 lastDrawnY = lastY; 215 } 216 ctx.lineTo(endPx, lastDrawnY); 217 ctx.lineTo(endPx, zeroY); 218 ctx.closePath(); 219 ctx.fill(); 220 ctx.stroke(); 221 222 // Draw the Y=0 dashed line. 223 ctx.strokeStyle = `hsl(${hue}, 10%, 71%)`; 224 ctx.beginPath(); 225 ctx.setLineDash([2, 4]); 226 ctx.moveTo(0, zeroY); 227 ctx.lineTo(endPx, zeroY); 228 ctx.closePath(); 229 ctx.stroke(); 230 ctx.setLineDash([]); 231 232 ctx.font = '10px Roboto Condensed'; 233 234 if (this.hoveredValue !== undefined && this.hoveredTs !== undefined) { 235 // TODO(hjd): Add units. 236 let text = scale === 'DELTA_FROM_PREVIOUS' ? 'delta: ' : 'value: '; 237 text += `${this.hoveredValue.toLocaleString()}`; 238 239 ctx.fillStyle = `hsl(${hue}, 45%, 75%)`; 240 ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`; 241 242 const xStart = Math.floor(timeScale.timeToPx(this.hoveredTs)); 243 const xEnd = this.hoveredTsEnd === undefined ? 244 endPx : 245 Math.floor(timeScale.timeToPx(this.hoveredTsEnd)); 246 const y = MARGIN_TOP + RECT_HEIGHT - 247 Math.round(((this.hoveredValue - yMin) / yRange) * RECT_HEIGHT); 248 249 // Highlight line. 250 ctx.beginPath(); 251 ctx.moveTo(xStart, y); 252 ctx.lineTo(xEnd, y); 253 ctx.lineWidth = 3; 254 ctx.stroke(); 255 ctx.lineWidth = 1; 256 257 // Draw change marker. 258 ctx.beginPath(); 259 ctx.arc(xStart, y, 3 /*r*/, 0 /*start angle*/, 2 * Math.PI /*end angle*/); 260 ctx.fill(); 261 ctx.stroke(); 262 263 // Draw the tooltip. 264 this.drawTrackHoverTooltip(ctx, this.mousePos, text); 265 } 266 267 // Write the Y scale on the top left corner. 268 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; 269 ctx.fillRect(0, 0, 42, 16); 270 ctx.fillStyle = '#666'; 271 ctx.textAlign = 'left'; 272 ctx.textBaseline = 'alphabetic'; 273 ctx.fillText(`${yLabel}`, 5, 14); 274 275 // TODO(hjd): Refactor this into checkerboardExcept 276 { 277 const endPx = timeScale.timeToPx(visibleWindowTime.end); 278 const counterEndPx = 279 Math.min(timeScale.timeToPx(this.config.endTs || Infinity), endPx); 280 281 // Grey out RHS. 282 if (counterEndPx < endPx) { 283 ctx.fillStyle = '#0000001f'; 284 ctx.fillRect(counterEndPx, 0, endPx - counterEndPx, this.getHeight()); 285 } 286 } 287 288 // If the cached trace slices don't fully cover the visible time range, 289 // show a gray rectangle with a "Loading..." label. 290 checkerboardExcept( 291 ctx, 292 this.getHeight(), 293 timeScale.timeToPx(visibleWindowTime.start), 294 timeScale.timeToPx(visibleWindowTime.end), 295 timeScale.timeToPx(data.start), 296 timeScale.timeToPx(data.end)); 297 } 298 299 onMouseMove(pos: {x: number, y: number}) { 300 const data = this.data(); 301 if (data === undefined) return; 302 this.mousePos = pos; 303 const {timeScale} = globals.frontendLocalState; 304 const time = timeScale.pxToTime(pos.x); 305 306 const values = this.config.scale === 'DELTA_FROM_PREVIOUS' ? 307 data.totalDeltas : 308 data.lastValues; 309 const [left, right] = searchSegment(data.timestamps, time); 310 this.hoveredTs = left === -1 ? undefined : data.timestamps[left]; 311 this.hoveredTsEnd = right === -1 ? undefined : data.timestamps[right]; 312 this.hoveredValue = left === -1 ? undefined : values[left]; 313 } 314 315 onMouseOut() { 316 this.hoveredValue = undefined; 317 this.hoveredTs = undefined; 318 } 319 320 onMouseClick({x}: {x: number}) { 321 const data = this.data(); 322 if (data === undefined) return false; 323 const {timeScale} = globals.frontendLocalState; 324 const time = timeScale.pxToTime(x); 325 const [left, right] = searchSegment(data.timestamps, time); 326 if (left === -1) { 327 return false; 328 } else { 329 const counterId = data.lastIds[left]; 330 if (counterId === -1) return true; 331 globals.makeSelection(Actions.selectCounter({ 332 leftTs: toNs(data.timestamps[left]), 333 rightTs: right !== -1 ? toNs(data.timestamps[right]) : -1, 334 id: counterId, 335 trackId: this.trackState.id 336 })); 337 return true; 338 } 339 } 340} 341 342trackRegistry.register(CounterTrack); 343