UNPKG

webwriter-chart

Version:

ww-chart is an interactive data visualization widget for the WebWriter tool that implements various charts and diagrams for static and exploratory data visualization.

703 lines (664 loc) 23.7 kB
import { css, html, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import "./LimitedNumberInput"; import "./Select"; import { attribute } from "../functions"; import { availableCharts, availableRegressions } from "./Charts/imports"; import { Select } from "./Select"; import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"; import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js"; import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js"; import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.component.js"; import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js"; import SlAlert from "@shoelace-style/shoelace/dist/components/alert/alert.component.js"; import { Switch } from "./Switch"; import { NumberInput } from "./LimitedNumberInput"; import IconLock from "bootstrap-icons/icons/lock.svg"; import IconUnLock from "bootstrap-icons/icons/unlock.svg"; import StateComponent from "../stateComponent"; interface TOptionInput { name: string; template: TemplateResult; locked: boolean; key: string; } @customElement("wwci-chart-settings") export class Settings extends StateComponent { @state() accessor showErrorMessage = false; @state() accessor showErrorIcon = false; static styles = css` sl-details::part(header) { padding: 0.5em 0; } sl-details::part(content) { padding: 0px; padding-left: 2px; padding-bottom: 0.5em; } .lock-button-locked { color: red; } .lock-button-unlocked { color: green; } .lock-button-locked::part(base):hover { color: red; } .lock-button-unlocked::part(base):hover { color: green; } .text { margin: 0; align-self: center; box-sizing: border-box; color: #1a1a1a; } .container { overflow: visible; /* margin: 0 0.5em; */ margin-top: 0.5em; } .row { display: flex; flex-direction: row; gap: 0.5em; } sl-details { margin-bottom: 1rem; border-bottom: 1px solid var(--sl-color-gray-300); } sl-details::part(base) { background-color: unset; border: none; } /* Ensure wwci-select is relatively positioned */ .select-container { position: relative; display: inline-block; } .triangle-container { position: absolute; bottom: 0; right: 1px; /* Keep the triangle at the bottom-left of wwci-select */ z-index: 10; transform: translateY(40%); } .alert-container { position: absolute; top: 100%; /* Place it right below the select element */ left: 0; width: 100%; /* Full width for the alert */ z-index: 1000; } .mode_switch__error_indicator { color: var(--sl-color-danger-600); } .error-alert { position: relative; width: 100%; /* Ensure it stays full width */ max-height: none; /* Allow it to grow vertically */ margin-top: 0.5em; /* Add a small gap between the select and the alert */ overflow: visible; /* Ensure content is visible when it grows */ } `; // TODO: Fix when change ChartName (no |) //TODO: Fix the locks options calculateInVisible(key: string, x_axis_type: string, y_axis_type: string) { let disabled = false; switch (key) { case "animation": if (x_axis_type === "none" && y_axis_type === "none") { disabled = true; } break; case "showGrid": if (x_axis_type !== "number" || y_axis_type !== "number") { disabled = true; } break; case "showCorrelation": if (x_axis_type !== "number" || y_axis_type !== "number") { disabled = true; } break; case "showOutliers": if (x_axis_type !== "number" || y_axis_type !== "number") { disabled = true; } break; case "listRegressionLine": if (x_axis_type !== "number" || y_axis_type !== "number") { disabled = true; } break; case "showMean": if ( (x_axis_type !== "number" && y_axis_type !== "number") || (x_axis_type === "number" && y_axis_type === "number") ) { disabled = true; } break; case "hoverTooltip": if ( (x_axis_type === "none" && y_axis_type === "none") || (x_axis_type === undefined && y_axis_type === undefined) ) { disabled = true; } break; } return disabled; } totalDataLength() { let length = 0; if (this.sharedState.scatterDatasets.sets.length !== 0) { this.sharedState.scatterDatasets.selected.dataset_indexes.forEach( (dataset_index) => { length += this.sharedState.scatterDatasets.sets[dataset_index] .dimensional_data.length; } ); } return length; } selectedDatasets_length() { return this.sharedState.scatterDatasets.selected.dataset_indexes.length; } addRegressionError(): { regressionErrorMessages: TemplateResult; regressionError: boolean; color: string; } { let regressionErrorMessages = html``; let regressionError = false; let color = ""; const regressionType = this.sharedState.regressionType; // Incase no dataset if ( this.sharedState.scatterDatasets.sets.length === 0 || this.selectedDatasets_length() === 0 || this.totalDataLength() === 0 ) { regressionError = true; regressionErrorMessages = html`Cannot perform ${regressionType} regression: Insufficient data points.`; } else { let requiredPoints = 2; // Default to 2 for most regression types switch (regressionType) { case "none": return { regressionErrorMessages, regressionError, color }; case "linear": break; case "quadratic": requiredPoints = 3; break; /*case "polynomial": requiredPoints = 4; break; case "logarithmic": break; case "power": break;*/ case "loess": break; } const regressionErrorDatasetsnameArray = this.sharedState.scatterDatasets.selected.dataset_indexes .sort() .map((dataset_index) => { const dataset = this.sharedState.scatterDatasets.sets[dataset_index]; color = dataset.color; if (dataset.dimensional_data.length < requiredPoints) { regressionError = true; return html`<span style="color: ${color};" >${dataset.name}</span >`; } return null; }) .filter((el) => el !== null); // Filter out the null values // If the array length is greater than 1, join the elements with a comma const regressionErrorDatasetsname = regressionErrorDatasetsnameArray.length > 1 ? regressionErrorDatasetsnameArray.reduce( (acc, curr, index) => [ ...acc, curr, index < regressionErrorDatasetsnameArray.length - 1 ? html`, ` : null, // Add comma except after the last item ], [] ) : regressionErrorDatasetsnameArray; // Combine all the regression error messages regressionErrorMessages = html` Cannot perform ${this.sharedState.regressionType} regression on ${regressionErrorDatasetsnameArray.length > 1 ? "datasets" : "dataset"} ${regressionErrorDatasetsname}: Insufficient data points. `; } return { regressionErrorMessages, regressionError, color }; } render() { const firstSelectedDataset = this.sharedState.scatterDatasets.sets.length > 0 ? this.sharedState.scatterDatasets.sets[ this.sharedState.scatterDatasets.selected.dataset_indexes[0] ] : undefined; const x_axis_type = this.sharedState.scatterDatasets.selected.axis.x === "None" ? "none" : firstSelectedDataset?.typeOfEachData[ firstSelectedDataset?.labels.indexOf( this.sharedState.scatterDatasets.selected.axis.x ) ]; const y_axis_type = this.sharedState.scatterDatasets.selected.axis.y === "None" ? "none" : firstSelectedDataset?.typeOfEachData[ firstSelectedDataset?.labels.indexOf( this.sharedState.scatterDatasets.selected.axis.y ) ]; // TODO: Fix this condition for regression when not enough data if ( this.sharedState.regressionType !== "none" && x_axis_type === "number" && y_axis_type === "number" && this.addRegressionError().regressionError ) { this.showErrorIcon = true; } else { this.showErrorIcon = false; } const biExclamationTriangle = html`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle" viewBox="0 0 16 16" > <!-- White Triangle Background --> <polygon fill="#fff" points="8 1 15 14 1 14 8 1" /> <path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z" /> <path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z" /> </svg>`; const { optionDefinition } = this.sharedState.chart; const optionInputs: TOptionInput[] = Object.keys(optionDefinition).map( (key) => { const { max, min, type } = optionDefinition[key]; const value = this.sharedState.chart.options[key]; const isDisabled = !( this.sharedState.editable || (this.sharedState.teacherOptions.changeOptions && !this.sharedState.chart.optionsLocked[key]) ); const option = { key, name: optionDefinition[key].name, locked: this.sharedState.chart.optionsLocked[key], template: html``, }; switch (type) { case "number": option.template = html` <wwci-limited-number-input ?disabled=${isDisabled} max=${max} min=${min} value=${value} @sl-input=${(e: any) => { const updatedOptions = { ...this.sharedState.chart.options, [key]: e.target.value, }; this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, options: updatedOptions, }, }; this.dispatchStateChange(); }} ></wwci-limited-number-input> `; break; case "boolean": option.template = html`<wwci-switch ?disabled=${isDisabled} ?checked=${value} @wwci-change=${(e: CustomEvent) => { e.stopPropagation(); e.cancelBubble = true; const updatedOptions = { ...this.sharedState.chart.options, [key]: e.detail.value, }; this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, options: updatedOptions, }, }; this.dispatchStateChange(); }} ></wwci-switch>`; break; case "array": option.template = html` <sl-button-group> <div class="select-container"> <wwci-select .value="${this.sharedState.regressionType}" ?disabled=${isDisabled} .selectOptions="${availableRegressions}" @wwci-change=${(e: CustomEvent) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState = { ...this.sharedState, regressionType: (e.target as any).value, }; this.dispatchStateChange(); }} > </wwci-select> <div class="triangle-container" style=${this.showErrorIcon ? "" : "display: none;"} > <div class="mode_switch__error_indicator" @mouseenter=${() => (this.showErrorMessage = true)} @mouseleave=${() => (this.showErrorMessage = false)} > ${biExclamationTriangle} </div> </div> ${this.showErrorMessage && this.showErrorIcon ? html` <div class="alert-container"> <sl-alert class="error-alert" variant="danger" open hoist > ${this.addRegressionError().regressionErrorMessages} </sl-alert> </div> ` : ""} </div> </sl-button-group> `; break; default: option.template = html`<p>Unknown type: ${type}</p>`; } return option; } ); const optionInputsToShow = optionInputs.filter( (input) => (this.sharedState.editable || this.sharedState.teacherOptions.showLockedOptions || !input.locked) && !this.calculateInVisible(input.key, x_axis_type, y_axis_type) ); return html` <sl-details summary="Chart Options"> <div class="container"> <div class="row"> <p class="text">Chart</p> ${this.sharedState.editable ? html`<sl-icon-button class="lock-button ${this.sharedState.chart.optionsLocked[ "chartType" ] ? "lock-button-locked" : "lock-button-unlocked"}" src=${this.sharedState.chart.optionsLocked["chartType"] ? IconLock : IconUnLock} label="Lock this option" @click=${() => { const updatedOptionsLocked = { ...this.sharedState.chart.optionsLocked, chartType: !this.sharedState.chart.optionsLocked["chartType"], }; this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, optionsLocked: updatedOptionsLocked, }, }; this.dispatchStateChange(); }} ></sl-icon-button>` : html`<div></div>`} </div> <wwci-select value="${this.sharedState.chart.selectedType}" ?disabled=${!( this.sharedState.editable || (this.sharedState.teacherOptions.changeOptions && !this.sharedState.chart.optionsLocked["chartType"]) )} selectOptions="${attribute(availableCharts)}" @wwci-change=${(e: CustomEvent) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, selectedType: (e.target as any).value, }, }; this.dispatchStateChange(); }} > </wwci-select> </div> <div class="container"> <div class="row"> <p class="text">Title</p> ${this.sharedState.editable ? html`<sl-icon-button class="lock-button ${this.sharedState.chart.optionsLocked[ "title" ] ? "lock-button-locked" : "lock-button-unlocked"}" src=${this.sharedState.chart.optionsLocked["title"] ? IconLock : IconUnLock} label="Lock this option" @click=${() => { const updatedOptionsLocked = { ...this.sharedState.chart.optionsLocked, title: !this.sharedState.chart.optionsLocked["title"], }; this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, optionsLocked: updatedOptionsLocked, }, }; this.dispatchStateChange(); }} ></sl-icon-button>` : html`<div></div>`} </div> <sl-input ?disabled=${!( this.sharedState.editable || (this.sharedState.teacherOptions.changeOptions && !this.sharedState.chart.optionsLocked["title"]) )} value=${this.sharedState.chart.selectedType === "Scatterplot" ? this.sharedState.scatterDatasets.title : this.sharedState.chart.title} @sl-input=${(e: any) => { if (this.sharedState.chart.selectedType === "Scatterplot") { this.sharedState = { ...this.sharedState, scatterDatasets: { ...this.sharedState.scatterDatasets, title: e.target.value, }, }; } else { this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, title: e.target.value, }, }; } this.dispatchStateChange(); }} ></sl-input> </div> ${optionInputsToShow.map((input) => { return html` <div class="container"> <div class="row"> <p class="text">${input.name}</p> ${this.sharedState.editable ? html`<sl-icon-button class="lock-button ${input.locked ? "lock-button-locked" : "lock-button-unlocked"}" src=${input.locked ? IconLock : IconUnLock} label="Lock this option" @click=${() => { const updatedOptionsLocked = { ...this.sharedState.chart.optionsLocked, [input.key]: !input.locked, }; this.sharedState = { ...this.sharedState, chart: { ...this.sharedState.chart, optionsLocked: updatedOptionsLocked, }, }; this.dispatchStateChange(); }} ></sl-icon-button> ` : html`<div></div>`} </div> ${input.template} </div> `; })} </sl-details> <sl-details summary="Teacher Options"> <div class="container"> <p>show options</p> <wwci-switch ?checked=${this.sharedState.teacherOptions.showOptions} @wwci-state-change=${(e: CustomEvent) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState.teacherOptions.showOptions = e.detail.value; this.dispatchStateChange(); }} ></wwci-switch> <p>show data</p> <wwci-switch ?checked=${this.sharedState.teacherOptions.showData} @wwci-state-change=${(e: any) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState.teacherOptions.showData = e.detail.value; this.dispatchStateChange(); }} ></wwci-switch> <p>show locked options</p> <wwci-switch ?checked=${this.sharedState.teacherOptions.showLockedOptions} @wwci-state-change=${(e: any) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState.teacherOptions.showLockedOptions = e.detail.value; e.stopPropagation(); e.cancelBubble = true; }} ></wwci-switch> <p>change data</p> <wwci-switch ?checked=${this.sharedState.teacherOptions.changeData} @wwci-state-change=${(e: any) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState.teacherOptions.changeData = e.detail.value; this.dispatchStateChange(); }} ></wwci-switch> <p>change options</p> <wwci-switch ?checked=${this.sharedState.teacherOptions.changeOptions} @wwci-state-change=${(e: any) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState.teacherOptions.changeOptions = e.detail.value; this.dispatchStateChange(); }} ></wwci-switch> <p>add/remove data</p> <wwci-switch ?checked=${this.sharedState.teacherOptions.addRemoveData} @wwci-state-change=${(e: any) => { e.stopPropagation(); e.cancelBubble = true; this.sharedState.teacherOptions.addRemoveData = e.detail.value; e.stopPropagation(); e.cancelBubble = true; }} ></wwci-switch> </div> </sl-details> `; } public static get scopedElements() { return { "wwci-select": Select, "sl-button": SlButton, "sl-input": SlInput, "wwci-limited-number-input": NumberInput, "wwci-switch": Switch, "sl-icon-button": SlIconButton, "sl-details": SlDetails, "sl-button-group": SlButtonGroup, "sl-alert": SlAlert, }; } } declare global { interface HTMLElementTagNameMap { "wwci-chart-settings": Settings; } }