blob: 1d730349ec5021a6111bf4eb2eb01a54551ed640 [file] [log] [blame]
/**
* @module module/cluster-page-sk
* @description <h2><code>cluster-page-sk</code></h2>
*
* The cluster-page-sk shows many digests and clusters them based on how similar they are. This
* can help identify incorrectly triaged images or other interesting patterns.
*
* It is a top level element.
*/
import { $$ } from 'common-sk/modules/dom';
import { define } from 'elements-sk/define';
import { html } from 'lit-html';
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
import { stateReflector } from 'common-sk/modules/stateReflector';
import { fromParamSet, fromObject, ParamSet } from 'common-sk/modules/query';
import { sendBeginTask, sendEndTask, sendFetchError } from '../common';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { SearchCriteriaToHintableObject, SearchCriteriaFromHintableObject } from '../search-controls-sk';
import '../cluster-digests-sk';
import '../digest-details-sk';
import '../../../infra-sk/modules/paramset-sk';
const template = (ele) => {
if (!ele._grouping) {
return html`<h1>Need a test to cluster by</h1>`;
}
return html`
<div class=page-container>
<search-controls-sk .corpora=${ele._corpora}
.paramSet=${ele._paramset}
.searchCriteria=${ele._searchCriteria}
@search-controls-sk-change=${ele._searchControlsChanged}></search-controls-sk>
<cluster-digests-sk @selection-changed=${ele._selectionChanged}></cluster-digests-sk>
${infoPanel(ele)}
</div>
`;
};
const infoPanel = (ele) => {
if (!ele._selectedDigests.length) {
return html`
<div>Click on one digest or shift click multiple digests to see more specific information.
Use A/Z to Zoom In/Out and S/X to increase/decrease node distance.</div>
<paramset-sk clickable .paramsets=${[ele._paramsetOfAllDigests]}
@paramset-key-click=${ele._paramKeyClicked} @paramset-key-value-click=${ele._paramValueClicked}>
</paramset-sk>`;
}
if (ele._selectedDigests.length === 1) {
if (ele._digestDetails) {
return html`<digest-details-sk .details=${ele._digestDetails.digest}
.commits=${ele._digestDetails.commits}></digest-details-sk>`;
}
return html`<h2>Loading digest details</h2>`;
}
if (ele._selectedDigests.length === 2) {
if (ele._diffDetails) {
return html`<digest-details-sk .details=${ele._diffDetails.left}
.right=${ele._diffDetails.right}></digest-details-sk>`;
}
return html`<h2>Loading diff details</h2>`;
}
const selectedDigestParamset = {};
for (const digest of ele._selectedDigests) {
mergeParamsets(selectedDigestParamset, ele._paramsetsByDigest[digest]);
}
sortParamset(selectedDigestParamset);
return html`
<div>Summary of ${ele._selectedDigests.length} digests</div>
<paramset-sk clickable .paramsets=${[selectedDigestParamset]}
@paramset-key-click=${ele._paramKeyClicked} @paramset-key-value-click=${ele._paramValueClicked}>
</paramset-sk>
`;
};
function mergeParamsets(base, extra) {
for (const key in extra) {
const values = base[key] || [];
for (const value of extra[key]) {
if (!values.includes(value)) {
values.push(value);
}
}
base[key] = values;
}
}
function sortParamset(ps) {
for (const key in ps) {
ps[key].sort();
}
}
define('cluster-page-sk', class extends ElementSk {
constructor() {
super(template);
this._corpora = [];
this._paramset = {};
this._searchCriteria = {
corpus: '',
leftHandTraceFilter: {},
rightHandTraceFilter: {},
includePositiveDigests: false,
includeNegativeDigests: false,
includeUntriagedDigests: false,
includeDigestsNotAtHead: false,
includeIgnoredDigests: false,
minRGBADelta: 0,
maxRGBADelta: 0,
mustHaveReferenceImage: false,
sortOrder: 'descending',
};
this._grouping = '';
this._changeListID = '';
this._crs = '';
this._stateChanged = stateReflector(
/* getState */() => {
const state = SearchCriteriaToHintableObject(this._searchCriteria);
state.grouping = this._grouping;
state.changeListID = this._changeListID;
state.crs = this._crs;
return state;
},
/* setState */(newState) => {
if (!this._connected) {
return;
}
this._searchCriteria = SearchCriteriaFromHintableObject(newState);
this._grouping = newState.grouping;
this._changeListID = newState.changeListID;
this._crs = newState.crs;
this._fetchClusterData();
this._render();
},
);
// Keeps track of the digests the user has selected.
this._selectedDigests = [];
// The combined paramset of all digests we loaded and displayed.
this._paramsetOfAllDigests = {};
// A map of digest -> paramset. Useful for showing the params of the selected digests.
this._paramsetsByDigest = {};
// These are the nodes and links that are drawn in the cluster-digests-sk. Holding onto them
// lets us update them (e.g. their labels) and easily re-layout the diagram.
this._renderedNodes = [];
this._renderedLinks = [];
// Allows us to abort fetches if we fetch again.
this._fetchController = null;
this._keyEventHandler = (e) => this._keyPressed(e);
}
connectedCallback() {
super.connectedCallback();
this._render();
// This assumes that there is only one multi-zoom-sk rendered on the page at a time (if there
// are multiple, they may all respond to keypresses at once).
document.addEventListener('keydown', this._keyEventHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this._keyEventHandler);
}
/**
* Creates the RPC URL for fetching the data about clustering within this test (aka grouping).
* @return {string}
*/
_clusterURL() {
if (!this._grouping) {
return '';
}
const sc = this._searchCriteria;
const query = { ...sc.leftHandTraceFilter };
query.name = [this._grouping];
const queryObj = {
source_type: sc.corpus,
query: fromParamSet(query),
pos: sc.includePositiveDigests,
neg: sc.includeNegativeDigests,
unt: sc.includeUntriagedDigests,
head: !sc.includeDigestsNotAtHead,
include: sc.includeIgnoredDigests,
};
return `/json/v1/clusterdiff?${fromObject(queryObj)}`;
}
_fetchClusterData() {
const url = this._clusterURL();
if (!url) {
console.warn('no grouping/test was specified.');
return;
}
const extra = this._prefetch();
sendBeginTask(this);
sendBeginTask(this);
fetch(url, extra)
.then(jsonOrThrow)
.then((json) => {
this._renderedNodes = json.nodes;
this._renderedLinks = json.links;
this._layoutCluster();
// TODO(kjlubick) remove json.test from the RPC value ( we have it in this._grouping)
this._paramsetOfAllDigests = json.paramsetsUnion;
this._paramsetsByDigest = json.paramsetByDigest;
this._render();
sendEndTask(this);
})
.catch((e) => sendFetchError(this, e, 'clusterdiff'));
fetch('/json/v1/paramset', extra)
.then(jsonOrThrow)
.then((paramset) => {
// We split the paramset into a list of corpora...
this._corpora = paramset.source_type || [];
// ...and the rest of the keys. This is to make it so the layout is
// consistent with other pages (e.g. the search page, the by blame page, etc).
delete paramset.source_type;
// This cluster page is locked into the specific grouping (aka test name); We shouldn't
// support clustering across tests unless we absolutely need to. Doing so would probably
// require some backend changes.
delete paramset.name;
this._paramset = paramset;
this._render();
sendEndTask(this);
})
.catch((e) => sendFetchError(this, e, 'paramset'));
}
_fetchDetails(digest) {
const extra = this._prefetch();
sendBeginTask(this);
const urlObj = {
corpus: [this._searchCriteria.corpus],
test: [this._grouping],
digest: [digest],
};
if (this._changeListID) {
urlObj.changelist_id = [this._changeListID];
urlObj.crs = [this._crs];
}
const url = `/json/v1/details?${fromObject(urlObj)}`;
fetch(url, extra)
.then(jsonOrThrow)
.then((json) => {
this._digestDetails = json;
this._render();
sendEndTask(this);
})
.catch((e) => sendFetchError(this, e, 'digest details'));
}
_fetchDiff(leftDigest, rightDigest) {
const extra = this._prefetch();
sendBeginTask(this);
const urlObj = {
corpus: [this._searchCriteria.corpus],
test: [this._grouping],
left: [leftDigest],
right: [rightDigest],
};
if (this._changeListID) {
urlObj.changelist_id = [this._changeListID];
urlObj.crs = [this._crs];
}
const url = `/json/v1/diff?${fromObject(urlObj)}`;
fetch(url, extra)
.then(jsonOrThrow)
.then((json) => {
this._diffDetails = json;
this._render();
sendEndTask(this);
})
.catch((e) => sendFetchError(this, e, 'diff details'));
}
_keyPressed(e) {
// Advice taken from https://medium.com/@uistephen/keyboardevent-key-for-cross-browser-key-press-check-61dbad0a067a
const cluster = $$('cluster-digests-sk', this);
if (!cluster) {
return;
}
const key = e.key || e.keyCode;
switch (key) {
case 'z': case 90: // Zoom in (loosen links)
cluster.changeLinkTightness(false);
break;
case 'a': case 65: // Zoom out (tighten links)
cluster.changeLinkTightness(true);
break;
case 's': case 83: // Increase distance between nodes
cluster.changeNodeRepulsion(true);
break;
case 'x': case 88: // Decrease distance between nodes
cluster.changeNodeRepulsion(false);
break;
default:
return;
}
// If we captured the key event, stop it from propagating.
e.stopPropagation();
}
_layoutCluster() {
$$('cluster-digests-sk', this).setData(this._renderedNodes, this._renderedLinks);
}
_paramKeyClicked(e) {
const keyClicked = e.detail.key;
for (const node of this._renderedNodes) {
const ps = this._paramsetsByDigest[node.name];
node.label = ps[keyClicked] || '';
}
this._layoutCluster();
}
_paramValueClicked(e) {
const keyClicked = e.detail.key;
const valueClicked = e.detail.value;
for (const node of this._renderedNodes) {
const ps = this._paramsetsByDigest[node.name];
if (ps[keyClicked].includes(valueClicked)) {
node.label = ps[keyClicked];
} else {
node.label = '';
}
}
this._layoutCluster();
}
_prefetch() {
if (this._fetchController) {
// Kill any outstanding requests
this._fetchController.abort();
}
// Make a fresh abort controller for each set of fetches.
// They cannot be re-used once aborted.
this._fetchController = new AbortController();
return {
signal: this._fetchController.signal,
};
}
_render() {
super._render();
// Make the cluster draw to the full width.
const cluster = $$('cluster-digests-sk', this);
if (cluster) {
cluster.setWidth(cluster.offsetWidth);
}
}
_searchControlsChanged(e) {
this._searchCriteria = e.detail;
this._stateChanged();
this._fetchClusterData();
// Reset selection
this._digestDetails = null;
this._diffDetails = null;
this._selectedDigests = [];
this._render();
}
_selectionChanged(e) {
this._selectedDigests = e.detail;
const numDigests = this._selectedDigests.length;
this._digestDetails = null;
this._diffDetails = null;
if (numDigests === 1) {
this._fetchDetails(this._selectedDigests[0]);
} else if (numDigests === 2) {
this._fetchDiff(this._selectedDigests[0], this._selectedDigests[1]);
}
this._render();
}
});