Commit 1e8515dd authored by Tiger Oakes's avatar Tiger Oakes Committed by Commit Bot

Supersize: Adds small quality of life improvements

Changes so far:
- Improved reading of a node in a screen reader
- Added checkboxes and radios into tab flow
- Settings starts expanded instead of hidden
- Arrow immediately rotates when expanding a row
- Added "Select all" and "Select none" buttons for types
- Query parameters equal to the defaults are hidden
- Types are shown as a single string in the query
- .bss is no longer checked by default
- Fetch requests are cancelled when a new one is started

Demo:
https://notwoods.github.io/chrome-supersize-reports/monochrome-2018-07-10/

Bug: 847599
Change-Id: I366ff2746a331e846dc0c683757a2a5bfa43d96a
Reviewed-on: https://chromium-review.googlesource.com/1132132
Commit-Queue: Tiger Oakes <tigero@google.com>
Reviewed-by: default avatarEric Stevenson <estevenson@chromium.org>
Reviewed-by: default avataragrieve <agrieve@chromium.org>
Cr-Commit-Position: refs/heads/master@{#574194}
parent 07cb7d14
......@@ -3,6 +3,7 @@
* found in the LICENSE file. */
.infocards {
visibility: hidden;
position: fixed;
bottom: 8px;
left: 8px;
......@@ -14,7 +15,17 @@
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px #3c40434d, 0 1px 3px 1px #3c404326;
transform: translateY(16px);
opacity: 0;
transition: 0.3s ease transform, 0.3s ease opacity, 0.3s ease visibility;
}
.tree-container:hover ~ .infocards,
.tree-container.focused ~ .infocards {
visibility: visible;
opacity: 1;
transform: none;
}
.infocard {
display: grid;
padding: 16px;
......
......@@ -60,18 +60,39 @@ legend {
}
/** Buttons */
.icon-button {
height: 40px;
width: 40px;
.icon-button, .text-button {
cursor: pointer;
background: transparent;
border: 0;
}
.icon-button {
height: 40px;
width: 40px;
border-radius: 50%;
}
.icon-button:hover {
background: #0000001f;
}
.text-button {
padding: 0 8px;
line-height: 36px;
border-radius: 4px;
color: #1a73e8;
font-family: 'Google Sans', Arial, sans-serif;
font-weight: 500;
}
.text-button:hover {
background: #d2e3fc80;
}
.text-button:hover:focus {
background: #d2e3fc;
}
.text-button:focus, .text-button:active {
background: #d2e3fce6;
}
/** <input type='text'> or <select> elements */
.input-wrapper,
.select-wrapper {
......@@ -86,6 +107,8 @@ input[type='number'],
select {
box-sizing: border-box;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
font: inherit;
background: transparent;
border: 0;
......@@ -132,7 +155,15 @@ select:focus + .select-label::after {
/** <input type='checkbox' or 'radio'> elements */
input[type='checkbox'],
input[type='radio'] {
display: none;
position: absolute;
margin: 0;
height: 18px;
width: 18px;
opacity: 0;
}
.checkbox-wrapper,
.radio-wrapper {
position: relative;
}
.checkbox-label,
.radio-label {
......@@ -193,6 +224,11 @@ input[type='checkbox']:disabled + .checkbox-label,
input[type='radio']:disabled + .radio-label {
color: #80868b;
}
input[type='checkbox']:focus + .checkbox-label,
input[type='radio']:focus + .radio-label {
outline: #2e3436 dotted 1px;
outline: -webkit-focus-ring-color auto 5px;
}
/** Tweaks for smaller screen sizes */
@media (max-width: 700px) {
......
......@@ -83,6 +83,9 @@ const _SYMBOL_TYPE_SET = new Set('bdrtv*xmpP' + _OTHER_SYMBOL_TYPE);
/** Name used by a directory created to hold symbols with no name. */
const _NO_NAME = '(No path)';
/** Key where type is stored in the query string state. */
const _TYPE_STATE_KEY = 'type';
/**
* Returns shortName for a tree node.
* @param {TreeNode} node
......@@ -90,3 +93,18 @@ const _NO_NAME = '(No path)';
function shortName(node) {
return node.idPath.slice(node.shortNameIndex);
}
/**
* Iterate through each type in the query string. Types can be expressed as
* repeats of the same key in the query string ("type=b&type=p") or as a long
* string with multiple characters ("type=bp").
* @param {string[]} typesList All values associated with the "type" key in the
* query string.
*/
function* types(typesList) {
for (const typeOrTypes of typesList) {
for (const typeChar of typeOrTypes) {
yield typeChar;
}
}
}
......@@ -123,12 +123,21 @@ class TreeWorker {
/** Build utilities for working with the state. */
function _initState() {
const _DEFAULT_FORM = new FormData(form);
/** @type {HTMLCollectionOf<HTMLInputElement>} */
const typeCheckboxes = form.elements.namedItem(_TYPE_STATE_KEY);
/**
* State is represented in the query string and
* can be manipulated by this object. Keys in the query match with
* input names.
*/
let _filterParams = new URLSearchParams(location.search.slice(1));
const typeList = _filterParams.getAll(_TYPE_STATE_KEY);
_filterParams.delete(_TYPE_STATE_KEY);
for (const type of types(typeList)) {
_filterParams.append(_TYPE_STATE_KEY, type);
}
const state = Object.freeze({
/**
......@@ -180,6 +189,15 @@ function _initState() {
has(key) {
return _filterParams.has(key);
},
/**
* Formats the filter state as a string.
*/
toString() {
const copy = new URLSearchParams(_filterParams);
const types = [...new Set(copy.getAll(_TYPE_STATE_KEY))];
if (types.length > 0) copy.set(_TYPE_STATE_KEY, types.join(''));
return `?${copy.toString()}`;
},
});
// Update form inputs to reflect the state from URL.
......@@ -204,10 +222,48 @@ function _initState() {
}
}
/**
* Yields only entries that have been modified in
* comparison to `_DEFAULT_FORM`.
* @param {FormData} modifiedForm
*/
function* onlyChangedEntries(modifiedForm) {
// Remove default values
for (const key of modifiedForm.keys()) {
const modifiedValues = modifiedForm.getAll(key);
const defaultValues = _DEFAULT_FORM.getAll(key);
const valuesChanged =
modifiedValues.length !== defaultValues.length ||
modifiedValues.some((v, i) => v !== defaultValues[i]);
if (valuesChanged) {
for (const value of modifiedValues) {
yield [key, value];
}
}
}
}
function readFormState() {
const modifiedForm = new FormData(form);
_filterParams = new URLSearchParams(onlyChangedEntries(modifiedForm));
history.replaceState(null, null, state.toString());
}
// Update the state when the form changes.
form.addEventListener('change', event => {
_filterParams = new URLSearchParams(new FormData(event.currentTarget));
history.replaceState(null, null, '?' + _filterParams.toString());
form.addEventListener('change', readFormState);
document.getElementById('type-all').addEventListener('click', () => {
for (const checkbox of typeCheckboxes) {
checkbox.checked = true;
}
readFormState();
form.dispatchEvent(new Event('submit'));
});
document.getElementById('type-none').addEventListener('click', () => {
for (const checkbox of typeCheckboxes) {
checkbox.checked = false;
}
readFormState();
});
return state;
......@@ -216,8 +272,6 @@ function _initState() {
function _startListeners() {
const _SHOW_OPTIONS_STORAGE_KEY = 'show-options';
/** @type {HTMLSpanElement} */
const sizeHeader = document.getElementById('size-header');
/** @type {HTMLFieldSetElement} */
const typesFilterContainer = document.getElementById('types-filter');
/** @type {HTMLInputElement} */
......@@ -236,7 +290,8 @@ function _startListeners() {
for (const button of document.getElementsByClassName('toggle-options')) {
button.addEventListener('click', _toggleOptions);
}
if (localStorage.getItem(_SHOW_OPTIONS_STORAGE_KEY) === 'true') {
// Default to open if getItem returns null
if (localStorage.getItem(_SHOW_OPTIONS_STORAGE_KEY) !== 'false') {
document.body.classList.add('show-options');
}
......
......@@ -146,6 +146,8 @@ const worker = new TreeWorker('tree-worker.js');
element.setAttribute('aria-expanded', 'false');
dom.replace(group, null);
} else {
element.setAttribute('aria-expanded', 'true');
let data = _uiNodeData.get(link);
if (data == null || data.children == null) {
const idPath = link.querySelector('.symbol-name').title;
......@@ -164,7 +166,6 @@ const worker = new TreeWorker('tree-worker.js');
// Update DOM
requestAnimationFrame(() => {
group.appendChild(newElementsFragment);
element.setAttribute('aria-expanded', 'true');
});
}
}
......@@ -314,9 +315,6 @@ const worker = new TreeWorker('tree-worker.js');
if (_CONTAINER_TYPE_SET.has(type)) {
const symbolStyle = getIconStyle(data.type[1]);
icon.setAttribute('fill', symbolStyle.color);
icon.querySelector('title').textContent += ` - Mostly ${
symbolStyle.description
}`;
}
// Insert an SVG icon at the start of the link to represent type
link.insertBefore(icon, link.firstElementChild);
......@@ -359,11 +357,15 @@ const worker = new TreeWorker('tree-worker.js');
});
_symbolTree.addEventListener('keydown', _handleKeyNavigation);
_symbolTree.addEventListener('focusin', event =>
_symbolTree.addEventListener('focusin', event => {
displayInfocard(
_uiNodeData.get(event.target),
state.has('method_count') ? _getMethodCountContents : _getSizeContents
)
);
event.currentTarget.parentElement.classList.add('focused');
});
_symbolTree.addEventListener('focusout', event =>
event.currentTarget.parentElement.classList.remove('focused')
);
self.newTreeElement = newTreeElement;
......
......@@ -364,39 +364,65 @@ class TreeBuilder {
}
/**
* Transforms a binary stream into a newline delimited JSON (.ndjson) stream.
* Each yielded value corresponds to one line in the stream.
* @param {Response} response Response to convert.
* Wrapper around fetch for requesting the same resource multiple times.
*/
async function* newlineDelimtedJsonStream(response) {
// Are streams supported?
if (response.body) {
const decoder = new TextDecoder();
const decodeOptions = {stream: true};
const reader = response.body.getReader();
let buffer = '';
while (true) {
// Read values from the stream
const {done, value} = await reader.read();
if (done) break;
// Convert binary values to text chunks.
const chunk = decoder.decode(value, decodeOptions);
buffer += chunk;
// Split the chunk base on newlines, and turn each complete line into JSON
const lines = buffer.split('\n');
[buffer] = lines.splice(lines.length - 1, 1);
for (const line of lines) {
yield JSON.parse(line);
class DataFetcher {
/**
* @param {string} url URL to the resource you want to fetch.
*/
constructor(url) {
this._controller = new AbortController();
this._url = url;
}
/**
* Starts a new request and aborts the previous one.
*/
fetch() {
this._controller.abort();
this._controller = new AbortController();
return fetch(this._url, {
credentials: 'same-origin',
signal: this._controller.signal,
});
}
/**
* Transforms a binary stream into a newline delimited JSON (.ndjson) stream.
* Each yielded value corresponds to one line in the stream.
*/
async *newlineDelimtedJsonStream() {
const response = await this.fetch();
// Are streams supported?
if (response.body) {
const decoder = new TextDecoder();
const decodeOptions = {stream: true};
const reader = response.body.getReader();
let buffer = '';
while (true) {
// Read values from the stream
const {done, value} = await reader.read();
if (done) break;
// Convert binary values to text chunks.
const chunk = decoder.decode(value, decodeOptions);
buffer += chunk;
// Split the chunk base on newlines,
// and turn each complete line into JSON
const lines = buffer.split('\n');
[buffer] = lines.splice(lines.length - 1, 1);
for (const line of lines) {
yield JSON.parse(line);
}
}
} else {
// In-memory version for browsers without stream support
const text = await response.text();
for (const line of text.split('\n')) {
if (line) yield JSON.parse(line);
}
}
} else {
// In-memory version for browsers without stream support
const text = await response.text();
for (const line of text.split('\n')) {
if (line) yield JSON.parse(line);
}
}
}
......@@ -422,10 +448,14 @@ function parseOptions(options) {
/** @type {Set<string>} */
let typeFilter;
if (methodCountMode) typeFilter = new Set('m');
else {
const types = params.getAll('type');
typeFilter = new Set(types.length === 0 ? _SYMBOL_TYPE_SET : types);
if (methodCountMode) {
typeFilter = new Set('m');
} else {
typeFilter = new Set(types(params.getAll(_TYPE_STATE_KEY)));
if (typeFilter.size === 0) {
typeFilter = new Set(_SYMBOL_TYPE_SET);
typeFilter.delete('b');
}
}
/** Ensure symbol size is past the minimum */
......@@ -456,7 +486,7 @@ function parseOptions(options) {
/** @type {TreeBuilder | null} */
let builder = null;
let responsePromise = fetch('data.ndjson');
const fetcher = new DataFetcher('data.ndjson');
/**
* Assemble a tree when this worker receives a message.
......@@ -507,7 +537,7 @@ async function buildTree(options, callback) {
}
let sizeHeader = methodCountMode ? 'Methods' : 'Size';
if (meta.diff_mode) sizeHeader += ' diff';
if (meta && meta.diff_mode) sizeHeader += ' diff';
const message = {
id: 0,
......@@ -525,17 +555,9 @@ async function buildTree(options, callback) {
/** @type {number} ID from setInterval */
let interval = null;
try {
let response = await responsePromise;
if (response.bodyUsed) {
// We start the first request when the worker loads so the response is
// ready earlier. Subsequent requests (such as when filters change) must
// re-fetch the data file from the cache or network.
response = await fetch('data.ndjson');
}
// Post partial state every second
interval = setInterval(postToUi, 1000);
for await (const dataObj of newlineDelimtedJsonStream(response)) {
for await (const dataObj of fetcher.newlineDelimtedJsonStream()) {
if (meta == null) {
meta = dataObj;
postToUi();
......@@ -548,7 +570,11 @@ async function buildTree(options, callback) {
postToUi({root: builder.build(), percent: 1});
} catch (error) {
if (interval != null) clearInterval(interval);
console.error(error);
if (error.name === 'AbortError') {
console.info(error.message);
} else {
console.error(error);
}
postToUi({error});
}
}
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment