blob: 0c77a6d7df79fce17af575e1ad6e97f9d44a8607 [file] [log] [blame]
/** Shows code size statistics about a single binary. */
import { define } from '../../../elements-sk/modules/define';
import { html, TemplateResult } from 'lit-html';
import { load } from '@google-web-components/google-chart/loader';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import Fuse from 'fuse.js';
import { $$ } from '../../../infra-sk/modules/dom';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { isDarkMode } from '../../../infra-sk/modules/theme-chooser-sk/theme-chooser-sk';
import { CodesizeScaffoldSk } from '../codesize-scaffold-sk/codesize-scaffold-sk';
import {
BloatyOutputMetadata, BinaryRPCRequest, BinaryRPCResponse, TreeMapDataTableRow,
} from '../rpc_types';
import '../../../infra-sk/modules/human-date-sk';
import '@google-web-components/google-chart/';
/**
* Convert the data we get from the server into a format that can be visualized by the treemap.
* It will shorten file names by removing the path, for example "foo/bar/baz.cc" becomes "baz.cc".
* Because the name is the key to the row, we also then must shorten usages of those names later
* (in the parent column), but cannot shorten the same name more than once (e.g. we have two
* files with the same name in different subfolders).
*/
export function convertResponseToDataTable(inputRows: TreeMapDataTableRow[]): [string, string, string|number][] {
const out: [string, string, string|number][] = [];
out.push(['Name', 'Parent', 'Size']);
const shortNames = new Set<string>();
const parentsWhichWereShortened = new Set<string>();
for (const row of inputRows) {
// Shorten the file name, but only if the resulting shortened file name is unique.
const shortenedName = shortenName(row.name);
const shortenedNameIsUnique = !shortNames.has(shortenedName);
if (shortenedNameIsUnique && row.name !== shortenedName) {
shortNames.add(shortenedName);
parentsWhichWereShortened.add(row.name);
}
// If the parent was shortened, use the shortened version.
// Otherwise, it was a duplicate, so keep the full name.
out.push([
shortenedNameIsUnique ? shortenedName : row.name,
parentsWhichWereShortened.has(row.parent) ? shortenName(row.parent) : row.parent,
row.size,
]);
}
return out;
}
/**
* Shortens a file name path to be just the name by splitting the string that contains '.'
* (indicating that it is a file and not a folder or function) on '/' and taking the last element
* which should be the file name.
*/
export function shortenName(rowName: string): string {
if (!rowName) {
return '';
}
if (rowName.includes('.') && rowName.includes('/')) {
rowName = rowName.split('/').pop()!;
}
return rowName;
}
const MAX_SEARCH_RESULTS = 10;
export class BinaryPageSk extends ElementSk {
private static template = (el: BinaryPageSk) => {
if (el.metadata === null) {
return html`<p>Loading...</p>`;
}
const isTryJob = el.metadata?.patch_issue || el.metadata?.patch_set;
const commitOrCLAnchorText = isTryJob
? `Issue ${el.metadata?.patch_issue}, PS ${el.metadata?.patch_set}`
: el.metadata?.revision.substring(0, 7);
const commitOrCLAnchorHref = isTryJob
? `https://review.skia.org/${el.metadata?.patch_issue}/${el.metadata?.patch_set}`
: `https://skia.googlesource.com/skia/+/${el.metadata?.revision}`;
const compileTaskNameHref = `https://task-scheduler.skia.org/task/${el.metadata?.task_id}`;
return html`
<h2>
Code size statistics for <code>${el.metadata?.binary_name}</code>
<span class="compile-task">
(<a href="${compileTaskNameHref}">${el.metadata?.compile_task_name}</a>)
</span>
</h2>
<p>
<a href="${commitOrCLAnchorHref}">${commitOrCLAnchorText}</a>
${el.metadata?.subject}
<br/>
<span class="author-and-timestamp">
${el.metadata?.author},
<human-date-sk .date=${el.metadata?.timestamp} .diff=${true}></human-date-sk> ago.
</span>
</p>
<p class="instructions">Instructions:</p>
<ul>
<li><strong>Click</strong> on a node to navigate down the tree.</li>
<li><strong>Right click</strong> anywhere on the treemap go back up one level.</li>
<li><strong> Use the seach bar</strong> to navigate to a node within the tree</li>
</ul>
<div class="search-bar">
<input type="search" placeholder="Search for node..." aria-label="Search for node..."
autocomplete="on" @input=${el.onSearchInput} @keyup=${el.onSearchKeyUp}>
<ol id="searchSuggestions" class="search-match-list" ?hidden=${!el.listOfSearchResults.length}
@mouseover=${el.clearDefaultSelected}>
${el.listOfSearchResults.map((result, i) => el.searchResult(result, i))}
</ol>
</div>
<div id="treemap"></div>
`;
}
// Returns a <li> with the matching string. If it is the first element, mark it selected to
// give an affordance that something is auto selected and the user can hit enter to pick it.
private searchResult = (match: Fuse.FuseResult<string>, idx: number): TemplateResult => html`
<li class=${`search-match-list-item ${idx === 0 ? 'selected' : ''}`}
@click=${() => this.showElement(match)}>
${match.item}
</li>
`;
private tree: google.visualization.TreeMap | null = null;
private fuse: Fuse<string> | null = null;
private listOfSearchResults: Fuse.FuseResult<string>[] = [];
// Uses Fuse.js to match a users input to a node within the tree. Returns the top
// MAX_SEARCH_RESULTS results.
private onSearchInput(e: Event): void {
if (!this.fuse) {
return;
}
const target = e.target as HTMLInputElement;
this.listOfSearchResults = this.fuse.search(target.value).slice(0, MAX_SEARCH_RESULTS);
this._render();
}
private showElement(match: Fuse.FuseResult<string> | undefined): void {
if (match) {
// https://developers.google.com/chart/interactive/docs/events#the-select-event
// The row number should correspond to the order of which the data was passed into the
// treemap upon creation. This is the same order we passed into Fuse, and we can find
// that matching index by looking at refIndex.
const selectedIdx = match.refIndex;
this.tree!.setSelection([{ column: null, row: selectedIdx }]);
}
this.listOfSearchResults = []; // hide other results
$$<HTMLInputElement>('.search-bar input', this)!.value = ''; // clear search bar
this._render();
}
// If the user hits enter, change the selected element to the top search result.
// Otherwise, make sure the first element is highlighted to indicate this behavior.
private onSearchKeyUp(e: Event): void {
if (!this.tree) {
return;
}
const evt = (e as KeyboardEvent);
if (evt.key === 'Enter') {
// User hit enter, use the top result.
this.showElement(this.listOfSearchResults[0]);
return;
}
// Auto-highlight the first list element again (in case it was cleared via mouse over),
// thus resetting the affordance that it is the auto-picked version.
if (this.listOfSearchResults.length >= 1) {
const firstItem = $$<HTMLLIElement>('#searchSuggestions li:first-child', this);
if (firstItem) {
firstItem.classList.add('selected');
}
}
}
// If the user starts typing, then mouses over the list, we clear the default selected option.
private clearDefaultSelected(e: Event): void {
const defaultSelected = $$<HTMLLIElement>('#searchSuggestions li.selected', this);
if (defaultSelected) {
defaultSelected.classList.remove('selected');
}
}
private metadata: BloatyOutputMetadata | null = null;
constructor() {
super(BinaryPageSk.template);
}
connectedCallback(): void {
super.connectedCallback();
this._render();
// Show a loading indicator while the tree is loading.
CodesizeScaffoldSk.waitFor(this.loadTreeMap());
}
private async loadTreeMap(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const request: BinaryRPCRequest = {
commit: params.get('commit') || '',
patch_issue: params.get('patch_issue') || '',
patch_set: params.get('patch_set') || '',
binary_name: params.get('binary_name') || '',
compile_task_name: params.get('compile_task_name') || '',
};
const [, response] = await Promise.all([
load({ packages: ['treemap'] }),
fetch('/rpc/binary/v1', { method: 'POST', body: JSON.stringify(request) })
.then(jsonOrThrow)
.then((r: BinaryRPCResponse) => r),
]);
this.metadata = response.metadata;
this._render();
const rows = convertResponseToDataTable(response.rows);
const data = google.visualization.arrayToDataTable(rows);
this.tree = new google.visualization.TreeMap(this.querySelector('#treemap')!);
// For some reason the type definition for TreeMapOptions does not include the generateTooltip
// option (https://developers.google.com/chart/interactive/docs/gallery/treemap#tooltips), so
// a type assertion is necessary to keep the TypeScript compiler happy.
const treeOptions = {
generateTooltip: showTooltip,
minColor: '#E8DAFF', // We really wanted to have categorical coloring, but that appears
midColor: '#E8DAFF', // infeasible. Setting the fourth column works for leaf nodes, but not
maxColor: '#E8DAFF', // parent nodes as one would expect.
} as google.visualization.TreeMapOptions;
const searchOptions = {
isCaseSensitive: false,
// Ignore single character matches
minMatchCharLength: 1,
// At what point does the algorithm gives up (0.0 is a perfect match)
threshold: 0.6,
};
// Strip off the first row, which is the column headings.
this.fuse = new Fuse(rows.slice(1, rows.length).map((row): string => row[0]), searchOptions);
// Draw the tree and wait until the tree finishes drawing.
await new Promise((resolve) => {
google.visualization.events.addOneTimeListener(this.tree, 'ready', resolve);
this.tree!.draw(data, treeOptions);
document.addEventListener('theme-chooser-toggle', () => {
// if a user toggles the theme to/from darkmode then redraw
this.tree!.draw(data, treeOptions);
});
});
// Shows the label of the treemap cell. Returns a string with the HTML to be shown whenever.
// the user hovers over a treemap cell.
function showTooltip(row: number, size: string) {
const escapedLabel = data.getValue(row, 0)
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;');
const backgroundColor = isDarkMode() ? '#232F34' : '#FFFFFF';
return `<div class= "cell-tooltip" style="background: ${backgroundColor};">
<span>
${escapedLabel} <br/>
Size: ${size} <br/>
</span>
</div>`;
}
}
}
define('binary-page-sk', BinaryPageSk);