blob: ead58108fbd0231d039ac21e378d659e13fce182 [file] [log] [blame]
/**
* @module modules/side-panel-sk
* @description <h2><code>side-panel-sk</code></h2>
*
* Element for showing the legend next to plot-google-chart-sk.
* The side panel comes with a left bar that will close or open the
* side panel. This component is used by plot-google-chart-sk.
*
* When there is only one trace in the dataframe, the legend will be empty.
* When the legend is empty, the side panel will have no content.
* It is recommended to hide this module if there is no content to show.
*
* This side panel can be adapted to also show the tooltip rather
* than have the tooltip hover over the data point.
*/
import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { consume } from '@lit/context';
import { dataTableContext, DataTable, traceColorMapContext } from '../dataframe/dataframe_context';
import { getLegend } from '../dataframe/traceset';
import '@material/web/button/outlined-button.js';
import '@material/web/iconbutton/icon-button.js';
import '@material/web/icon/icon.js';
const chevronLeft = html`<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>`;
const chevronRight = html`<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor">
<path d="M8.59 7.41L10 6l6 6-6 6-1.41-1.41L13.17 12z" />
</svg>`;
const unwantedKeys = new Set(['master', 'improvement_direction', 'unit']);
export interface SidePanelToggleEventDetails {
open: boolean;
}
export interface SidePanelCheckboxClickDetails {
readonly selected: boolean;
readonly labels: string[];
}
interface LegendItem {
displayName: string;
// The original trace labels from the DataTable.
labels: string[];
color: string;
checked: boolean;
highlighted: boolean;
}
@customElement('side-panel-sk')
export class SidePanelSk extends LitElement {
static styles = css`
:host {
display: flex;
height: 100%;
border-radius: 8px;
overflow: scroll; /* legend entries can be very long */
box-shadow:
0px 2px 1px -1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14),
0px 1px 3px 0px rgba(0, 0, 0, 0.12); /* Elevation shadow */
}
.show-hide-bar {
width: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.show-hide-bar:hover {
background-color: gray;
}
.label-key-title {
color: #274878; /* Hex blue, which aligns with the graph title's color */
padding-top: 10px;
padding-left: 5px;
padding-bottom: 5px;
position: relative;
}
.delta-range {
color: #274878; /* Hex blue, which aligns with the graph title's color */
padding-top: 10px;
padding-left: 5px;
padding-bottom: 5px;
position: relative;
}
.info.closed {
display: none;
}
.select-all-checkbox {
color: #274878; /* Hex blue, which aligns with the graph title's color */
display: flex;
padding-left: 5px;
}
ul {
list-style: none; /* Remove default bullet points */
padding-left: 5px;
font-size: 15px;
margin-block-start: 3px;
}
.default {
border-style: none;
}
.highlight {
border-style: solid;
}
`;
// Manages the state of the side panel as collapsed or open.
@property({ reflect: true, type: Boolean })
opened = true;
@consume({ context: dataTableContext, subscribe: true })
@property({ attribute: false })
private data?: DataTable;
@property({ reflect: true })
deltaRaw: number | null = null;
@property({ reflect: true })
deltaPercentage: number | null = null;
// Display delta-range if the value is true, vice versa.
// Default is false, which means hide delta-range information
@property({ reflect: true })
showDelta = false;
@property({ attribute: false })
private legendItems: LegendItem[] = [];
@consume({ context: traceColorMapContext, subscribe: true })
@property({ attribute: false })
private traceColorMap = new Map<string, string>();
@property({ attribute: false, reflect: true })
private legendKeysFormat = '';
constructor() {
super();
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('traceColorMap') && this.data) {
this.updateLegendItems();
}
}
render() {
const allItemsChecked = this.legendItems.length > 0 && this.legendItems.every((i) => i.checked);
return html`
<div
class="show-hide-bar"
@click=${this.toggleSidePanel}
title=${this.opened ? 'Close panel' : 'Open panel'}>
<md-icon>${this.opened ? chevronRight : chevronLeft}</md-icon>
</div>
<div class="info ${classMap({ closed: !this.opened })}">
<div class="delta-range" ?hidden=${!this.showDelta}>
Delta: ${this.deltaRaw}<br />
Percentage: ${this.deltaPercentage}
</div>
<div class="label-key-title">
<span>${this.legendKeysFormat}</span>
</div>
<div class="select-all-checkbox">
<label>
<input
type="checkbox"
id="header-checkbox"
.checked=${allItemsChecked}
@click=${this.toggleAllCheckboxes} />
Select all
</label>
</div>
<div id="rows">
<ul>
${this.legendItems.map((item, index) => {
const handleCheck = (e: MouseEvent) => {
const checkEvent = e.target! as HTMLInputElement;
this.toggleItemChecked(item, checkEvent.checked);
};
const checkedCount = this.legendItems.filter((i) => i.checked).length;
const isLastChecked = item.checked && checkedCount === 1;
return html`
<li style="color: ${item.color}">
<label class="${item.highlighted ? 'highlight' : 'default'}">
<input
type="checkbox"
id="id-${index}"
@click=${handleCheck}
.checked=${item.checked}
?disabled=${isLastChecked}
title=${isLastChecked
? 'At least one trace must be selected.'
: 'Select/Unselect this value from the graph'} />
${item.displayName}
</label>
</li>
`;
})}
</ul>
</div>
</div>
`;
}
/**
* Toggles the checked state of a legend item and dispatches an event.
*
* @param item - The legend item to toggle.
* @param isChecked - The new checked state.
*/
private toggleItemChecked(item: LegendItem, isChecked: boolean) {
const checkedCount = this.legendItems.filter((i) => i.checked).length;
if (!isChecked && checkedCount <= 1) {
// Don't allow unchecking the last item. Re-render to revert checkbox state.
this.requestUpdate();
return;
}
item.checked = isChecked;
this.checkboxDispatchHandler(isChecked, item.labels);
// Update header checkbox
const headerCheckbox = this.renderRoot.querySelector('#header-checkbox') as HTMLInputElement;
if (headerCheckbox) {
headerCheckbox.checked = this.legendItems.every((i) => i.checked);
}
this.requestUpdate();
}
/**
* Sets the checkbox state for the given trace id.
* @param checked Whether the boxed is checked or not.
* @param traceId The trace id.
*/
public SetCheckboxForTrace(checked: boolean, traceId: string) {
const item = this.legendItems.find((i) => i.labels.includes(traceId));
if (item) {
this.toggleItemChecked(item, checked);
}
}
/**
* Sets all the checkboxes in the panel to the given checked state.
* @param checked The desired state of the checkboxes.
*/
public SetAllBoxes(checked: boolean) {
const changedLabels: string[] = [];
const itemsToChange: LegendItem[] = [];
if (checked) {
this.legendItems.forEach((item) => {
if (!item.checked) {
itemsToChange.push(item);
}
});
} else {
// Unchecking: don't uncheck the last item.
const checkedItems = this.legendItems.filter((item) => item.checked);
if (checkedItems.length > 1) {
// Uncheck all but the last one.
for (let i = 0; i < checkedItems.length - 1; i++) {
itemsToChange.push(checkedItems[i]);
}
}
}
itemsToChange.forEach((item) => {
item.checked = checked;
changedLabels.push(...item.labels);
});
if (changedLabels.length > 0) {
this.checkboxDispatchHandler(checked, changedLabels);
}
this.requestUpdate();
}
/**
* Highlight the traces on the given indices on the panel.
* @param traceIndices Indices of the traces to highlight.
*/
public HighlightTraces(traceIndices: number[]) {
this.legendItems.forEach((item) => (item.highlighted = false));
if (!this.data) {
return;
}
traceIndices.forEach((traceIndex) => {
const label = this.data!.getColumnLabel(traceIndex + 2);
const item = this.legendItems.find((i) => i.labels.includes(label));
if (item) {
item.highlighted = true;
}
});
this.requestUpdate();
}
private toggleAllCheckboxes() {
const headerCheckbox = this.renderRoot.querySelector(`#header-checkbox`) as HTMLInputElement;
const isChecked = headerCheckbox.checked;
const changedLabels: string[] = [];
const itemsToChange: LegendItem[] = [];
if (isChecked) {
this.legendItems.forEach((item) => {
if (!item.checked) {
itemsToChange.push(item);
}
});
} else {
// Unchecking: don't uncheck the last item.
const checkedItems = this.legendItems.filter((item) => item.checked);
if (checkedItems.length <= 1) {
// Revert the checkbox state since we can't uncheck the last item.
headerCheckbox.checked = true;
return;
}
// Uncheck all but the last one.
for (let i = 0; i < checkedItems.length - 1; i++) {
itemsToChange.push(checkedItems[i]);
}
}
itemsToChange.forEach((item) => {
item.checked = isChecked;
changedLabels.push(...item.labels);
});
if (changedLabels.length > 0) {
this.checkboxDispatchHandler(isChecked, changedLabels);
}
this.requestUpdate();
}
private toggleSidePanel() {
this.opened = !this.opened;
this.dispatchEvent(
new CustomEvent<SidePanelToggleEventDetails>('side-panel-toggle', {
bubbles: true,
composed: true,
detail: {
open: this.opened,
},
})
);
}
private updateLegendItems() {
if (!this.data) {
this.legendItems = [];
return;
}
const getLegendData = getLegend(this.data);
if (getLegendData.length === 0) {
this.legendItems = [];
return;
}
// Determine the ordered list of keys to display from the first legend item.
// `getLegend` ensures all items have the same keys in the same sorted order.
const allKeys = Object.keys(getLegendData[0]);
const displayKeys: string[] = [];
// Special handling for 'test' key to ensure it's always first if it exists.
if (allKeys.includes('test')) {
displayKeys.push('test');
}
// Add remaining keys, excluding unwanted ones and 'test' (if already added).
allKeys.forEach((key) => {
if (!unwantedKeys.has(key) && key !== 'test') {
displayKeys.push(key);
}
});
this.legendKeysFormat = displayKeys.join('/');
// Generate the display name for each legend item using the determined key order.
const displayLegendList = getLegendData.map((legendEntryObj) => {
const legendEntry = legendEntryObj as { [key: string]: string };
return displayKeys
.map((key) => legendEntry[key])
.filter((value) => value)
.join('/');
});
// Group trace labels by their generated display name.
const legendToLabelsMap = new Map<string, string[]>();
const numCols = this.data!.getNumberOfColumns();
for (let i = 2; i < numCols; i++) {
const label = this.data!.getColumnLabel(i);
const displayName = displayLegendList[i - 2];
if (!legendToLabelsMap.has(displayName)) {
legendToLabelsMap.set(displayName, []);
}
legendToLabelsMap.get(displayName)!.push(label);
}
// Create the final list of legend items for rendering.
const newLegendItems: LegendItem[] = [];
const uniqueDisplayNames = [...legendToLabelsMap.keys()];
uniqueDisplayNames.forEach((displayName) => {
const labels = legendToLabelsMap.get(displayName)!;
// Preserve state from existing items.
const existingItem = this.legendItems.find((item) => item.displayName === displayName);
const color = this.traceColorMap.get(labels[0]) || 'black';
newLegendItems.push({
displayName: displayName,
labels: labels,
color: color,
checked: existingItem?.checked ?? true,
highlighted: existingItem?.highlighted ?? false,
});
});
this.legendItems = newLegendItems.sort((a, b) => {
// Sort by display name, then by first label for consistent ordering.
return a.displayName.localeCompare(b.displayName) || a.labels[0].localeCompare(b.labels[0]);
});
}
private checkboxDispatchHandler(isSelected: boolean, labels: string[]): void {
const detail: SidePanelCheckboxClickDetails = {
selected: isSelected,
labels: labels,
};
this.dispatchEvent(
new CustomEvent('side-panel-selected-trace-change', {
detail,
bubbles: true,
})
);
}
}
declare global {
interface GlobalEventHandlersEventMap {
'side-panel-toggle': CustomEvent<SidePanelToggleEventDetails>;
'side-panel-selected-trace-change': CustomEvent<SidePanelCheckboxClickDetails>;
}
}