| /** |
| * @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(); |
| } |
| }); |