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.

1,046 lines (1,042 loc) 117 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { html } from "lit"; import * as d3 from "d3"; import { customElement, property } from "lit/decorators.js"; import { SharedTestStateMixin } from "../../../state"; import { OptionDefinition, Definition } from "./definition"; import SlRange from "@shoelace-style/shoelace/dist/components/range/range.js"; import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js"; import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.js"; import SlPopup from "@shoelace-style/shoelace/dist/components/popup/popup.component.js"; import SlMenu from "@shoelace-style/shoelace/dist/components/menu/menu.js"; import SlMenuItem from "@shoelace-style/shoelace/dist/components/menu-item/menu-item.js"; import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js"; import IconFileBarGraph from "bootstrap-icons/icons/file-bar-graph.svg"; import IconHandIndex from "bootstrap-icons/icons/hand-index.svg"; import IconEye from "bootstrap-icons/icons/eye.svg"; import IconPencil from "bootstrap-icons/icons/pencil.svg"; import IconFilter from "bootstrap-icons/icons/filter.svg"; import { LitElementWw } from "@webwriter/lit"; import { addBrushingListeners, addDragingPointListeners, addDrawLineListeners, addGridListeners, addPointListeners, addRegressionLine, calculateCorrelation, calculateForRotation, deleteLineListeners, deletePointListeners, drawScatterplot, findIndexForLine, selectPointByClickingListeners, updateLineAndPoints, updateColorForFilter, drawScatterplot_singleDataset, drawScatterplot_singleDataset2, } from "./drawScatterplot"; import style from "./scatterplot.style"; let ScatterplotChart = class ScatterplotChart extends SharedTestStateMixin(LitElementWw) { static styles = style; #definition_accessor_storage = Definition; get definition() { return this.#definition_accessor_storage; } set definition(value) { this.#definition_accessor_storage = value; } #addNode_accessor_storage = false; get addNode() { return this.#addNode_accessor_storage; } set addNode(value) { this.#addNode_accessor_storage = value; } #selectNode_accessor_storage = false; get selectNode() { return this.#selectNode_accessor_storage; } set selectNode(value) { this.#selectNode_accessor_storage = value; } #selectByClicking_accessor_storage = false; get selectByClicking() { return this.#selectByClicking_accessor_storage; } set selectByClicking(value) { this.#selectByClicking_accessor_storage = value; } #selectByBrushing_accessor_storage = false; get selectByBrushing() { return this.#selectByBrushing_accessor_storage; } set selectByBrushing(value) { this.#selectByBrushing_accessor_storage = value; } #selectedIndextoAddNote_accessor_storage = 0; get selectedIndextoAddNote() { return this.#selectedIndextoAddNote_accessor_storage; } set selectedIndextoAddNote(value) { this.#selectedIndextoAddNote_accessor_storage = value; } #deselectByBrushing_accessor_storage = false; get deselectByBrushing() { return this.#deselectByBrushing_accessor_storage; } set deselectByBrushing(value) { this.#deselectByBrushing_accessor_storage = value; } #editShowing_accessor_storage = false; get editShowing() { return this.#editShowing_accessor_storage; } set editShowing(value) { this.#editShowing_accessor_storage = value; } #hideSelected_accessor_storage = false; get hideSelected() { return this.#hideSelected_accessor_storage; } set hideSelected(value) { this.#hideSelected_accessor_storage = value; } #hideUnselected_accessor_storage = false; get hideUnselected() { return this.#hideUnselected_accessor_storage; } set hideUnselected(value) { this.#hideUnselected_accessor_storage = value; } #deleteSelected_accessor_storage = false; get deleteSelected() { return this.#deleteSelected_accessor_storage; } set deleteSelected(value) { this.#deleteSelected_accessor_storage = value; } #deleteUnselected_accessor_storage = false; get deleteUnselected() { return this.#deleteUnselected_accessor_storage; } set deleteUnselected(value) { this.#deleteUnselected_accessor_storage = value; } #showAll_accessor_storage = true; get showAll() { return this.#showAll_accessor_storage; } set showAll(value) { this.#showAll_accessor_storage = value; } #drawLine_accessor_storage = false; get drawLine() { return this.#drawLine_accessor_storage; } set drawLine(value) { this.#drawLine_accessor_storage = value; } #filterOn_accessor_storage = false; get filterOn() { return this.#filterOn_accessor_storage; } set filterOn(value) { this.#filterOn_accessor_storage = value; } #hoverCursorChange_accessor_storage = true; get hoverCursorChange() { return this.#hoverCursorChange_accessor_storage; } set hoverCursorChange(value) { this.#hoverCursorChange_accessor_storage = value; } #hoverCursorToMove_accessor_storage = false; //TODO: Use this attribute to change the cursor to a hand with a pointing finger get hoverCursorToMove() { return this.#hoverCursorToMove_accessor_storage; } set hoverCursorToMove(value) { this.#hoverCursorToMove_accessor_storage = value; } #hoverCursorToPointer_accessor_storage = false; get hoverCursorToPointer() { return this.#hoverCursorToPointer_accessor_storage; } set hoverCursorToPointer(value) { this.#hoverCursorToPointer_accessor_storage = value; } constructor(optionsDefinition = OptionDefinition) { super(); this.sharedState.chart.optionDefinition = optionsDefinition; this.sharedState.chart.options = Object.entries(optionsDefinition).reduce((acc, [key, value]) => { acc[key] = this.sharedState.chart.options[key] ?? value.default; return acc; }, {}); } // TODO: Fix this problem when moving lines /*protected willUpdate(_changedProperties: PropertyValues) { // if no properties changes it probably was a force update -> called by state library -> this.sharedState changed if (_changedProperties.size === 0) { this.updateScatterplot(); } }*/ render() { const firstSelectedDataset = this.sharedState.scatterDatasets.sets[this.sharedState.scatterDatasets.selected.dataset_indexes[0]]; return html `<div class="scatterplot-root"> <div class="scatterplot-wrapper"> <div class="scatterplot-svg"></div> ${this.sharedState.scatterDatasets.selected.dataset_indexes.length > 1 && (!this.sharedState.scatterDatasets.selected.axis.x || !this.sharedState.scatterDatasets.selected.axis.y) ? html `<div class="no-axis">Please select x and y axis</div>` : html ``} ${this.sharedState.scatterDatasets.selected.dataset_indexes.length === 1 && !this.sharedState.scatterDatasets.selected.axis.x && !this.sharedState.scatterDatasets.selected.axis.y ? html `<div class="no-axis">Please select x or y axis</div>` : html ``} ${this.sharedState.scatterDatasets.selected.dataset_indexes.length === 0 ? html `<div class="no-axis">Please select one or more datasets</div>` : html ``} <div class="button-container"> <sl-popup placement="bottom" strategy="fixed" .active="${this.addNode}" distance="7" > <span slot="anchor"> <sl-tooltip hoist placement="top-center" content=${this.sharedState.chart.options.animation ? "To use this option, you must first disable the animation" : this.totalDataLength === 0 ? "To use this option, you must first add at least one data point to the selected dataset table(s)" : this.sharedState.scatterDatasets.selected.dataset_indexes .length === 1 && (firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x)] !== "number" || firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y)] !== "number") ? "The x and y axis must be numbers to use this draw line option" : "Add a new data point"} > <sl-button variant="default" size="small" circle id="add-point-button" @click=${(e) => { this.addNode = true; e.target.focus(); }} .disabled=${this.sharedState.chart.options.animation || this.totalDataLength === 0 || (this.sharedState.scatterDatasets.selected.dataset_indexes .length === 1 && (firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x)] !== "number" || firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y)] !== "number"))} @blur=${() => (this.addNode = false)} > <sl-icon src=${IconFileBarGraph} label="Data"></sl-icon> </sl-button> </sl-tooltip> </span> <div class="popup-content"> ${this.sharedState.scatterDatasets.selected.dataset_indexes.map((dataset_index, i) => { const dataset = this.sharedState.scatterDatasets.sets[dataset_index]; return html `<sl-button @mousedown=${(e) => { if (e.button !== 0) return; this.selectedIndextoAddNote = i; this.hoverCursorChange = false; this.updateScatterplot(); }} > Add data of ${dataset.name} </sl-button>`; })} </div> </sl-popup> <sl-popup placement="bottom" strategy="fixed" .active=${this.selectNode} distance="7" > <span slot="anchor"> <sl-tooltip hoist placement="top-center" content=${this.sharedState.chart.options.animation ? "To use this option, you must first disable the animation" : this.totalDataLength === 0 ? "To use this option, you must first add at least one data point to the selected dataset table(s)" : "(De)select data points"} > <sl-button variant="default" size="small" circle id="select-point-button" .disabled=${this.totalDataLength === 0 || this.sharedState.chart.options.animation} @click=${(e) => { this.selectNode = true; e.target.focus(); }} @blur=${() => (this.selectNode = false)} > <sl-icon src=${IconHandIndex} label="Selection"></sl-icon> </sl-button> </sl-tooltip> </span> <div class="popup-content"> <sl-button id="select-by-clicking" @mousedown=${(e) => { if (e.button !== 0) return; this.selectByClicking = true; this.selectByBrushing = false; this.deselectByBrushing = false; this.deleteSelected = false; this.deleteUnselected = false; this.hideSelected = false; this.hideUnselected = false; this.showAll = false; this.hoverCursorChange = false; this.updateScatterplot(); }} > (De)select By Clicking </sl-button> <sl-button id="select-by-brushing" @mousedown=${(e) => { if (e.button !== 0) return; this.selectByBrushing = true; this.selectByClicking = false; this.deselectByBrushing = false; this.deleteSelected = false; this.deleteUnselected = false; this.hideSelected = false; this.hideUnselected = false; this.showAll = false; this.updateScatterplot(); }} > Select By Brushing </sl-button> <sl-button id="deselect-by-brushing" .disabled=${this.selectedDataLength === 0} @mousedown=${(e) => { if (e.button !== 0) return; this.deselectByBrushing = true; this.selectByBrushing = false; this.selectByClicking = false; this.deleteSelected = false; this.deleteUnselected = false; this.hideSelected = false; this.hideUnselected = false; this.showAll = false; this.updateScatterplot(); }} > Deselect By Brushing </sl-button> </div> </sl-popup> <sl-popup placement="bottom" strategy="fixed" .active=${this.editShowing} distance="7" > <span slot="anchor"> <sl-tooltip hoist placement=${this.sharedState.chart.options.animation || this.totalDataLength === 0 ? "top-end" : "top-center"} content=${this.sharedState.chart.options.animation ? "To use this option, you must first disable the animation" : this.totalDataLength === 0 ? "To use this option, you must first add at least one data point to the selected dataset table(s)" : "Show data points"} > <sl-button variant="default" size="small" circle id="edit-button" .disabled=${this.totalDataLength === 0 || this.sharedState.chart.options.animation} @click=${(e) => { this.editShowing = true; e.target.focus(); }} @blur=${() => (this.editShowing = false)} > <sl-icon src=${IconEye} label="View"></sl-icon> </sl-button> </sl-tooltip> </span> <div class="popup-content"> <sl-menu-item type="checkbox" ?checked=${this.hideSelected} .disabled=${this.selectedDataLength === 0} @mousedown=${(e) => { if (e.button !== 0) return; this.hideSelected = !this.hideSelected; this.hideSelected ? (this.showAll = false) : null; !this.hideSelected && !this.hideUnselected ? (this.showAll = true) : null; this.updateScatterplot(); }} >Hide Selected</sl-menu-item > <sl-menu-item type="checkbox" ?checked=${this.hideUnselected} .disabled=${this.unselectedDataLength === 0} @mousedown=${(e) => { if (e.button !== 0) return; this.hideUnselected = !this.hideUnselected; this.hideUnselected ? (this.showAll = false) : null; !this.hideSelected && !this.hideUnselected ? (this.showAll = true) : null; this.updateScatterplot(); }} >Hide Unselected</sl-menu-item > <sl-menu-item type="checkbox" ?checked=${this.showAll} @mousedown=${(e) => { if (e.button !== 0) return; this.showAll = !this.showAll; if (this.showAll) { this.hideSelected = false; this.hideUnselected = false; } this.updateScatterplot(); }} >Show All</sl-menu-item > <sl-menu-item type="checkbox" ?checked=${this.deleteSelected} .disabled=${this.selectedDataLength === 0} @mousedown=${(e) => { if (e.button !== 0) return; this.deleteSelected = !this.deleteSelected; if (this.deleteSelected) { this.sharedState.scatterDatasets.sets.forEach((dataset, index) => { for (let i = dataset.dimensional_data.length - 1; i >= 0; i--) { if (this.sharedState.scatterDatasets.selected.dataset_indexes.includes(index)) { if (dataset.dimensional_data[i].selected) { dataset.dimensional_data.splice(i, 1); dataset.ids.splice(i, 1); } } } }); } this.updateScatterplot(); }} >Delete Selected</sl-menu-item > <sl-menu-item type="checkbox" ?checked=${this.deleteUnselected} .disabled=${this.unselectedDataLength === 0} @mousedown=${(e) => { if (e.button !== 0) return; this.deleteUnselected = !this.deleteUnselected; if (this.deleteUnselected) { this.sharedState.scatterDatasets.sets.forEach((dataset, index) => { for (let i = dataset.dimensional_data.length - 1; i >= 0; i--) { if (this.sharedState.scatterDatasets.selected.dataset_indexes.includes(index)) { if (!dataset.dimensional_data[i].selected) { dataset.dimensional_data.splice(i, 1); dataset.ids.splice(i, 1); } } } }); } this.updateScatterplot(); }} >Delete Unselected</sl-menu-item > </div> </sl-popup> <span slot="anchor"> <sl-tooltip hoist placement=${this.sharedState.chart.options.animation || (this.sharedState.scatterDatasets.selected.dataset_indexes .length === 1 && (firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x)] !== "number" || firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y)] !== "number")) || this.totalDataLength === 0 ? "top-end" : "top-center"} content=${this.sharedState.chart.options.animation ? "To use this option, you must first disable the animation" : this.totalDataLength === 0 ? "To use this option, you must first add at least one data point to the selected dataset table(s)" : this.sharedState.scatterDatasets.selected.dataset_indexes .length === 1 && (firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x)] !== "number" || firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y)] !== "number") ? "The x and y axis must be numbers to use this draw line option" : "Draw a line in the scatterplot"} > <sl-button variant="default" size="small" circle id="add-point-button" @click=${(e) => { if (e.button !== 0) return; this.drawLine = true; this.updateScatterplot(); }} .disabled=${this.sharedState.chart.options.animation || (this.sharedState.scatterDatasets.selected.dataset_indexes .length === 1 && (firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x)] !== "number" || firstSelectedDataset.typeOfEachData[firstSelectedDataset.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y)] !== "number")) || this.totalDataLength === 0} > <sl-icon src=${IconPencil} label="Draw"></sl-icon> </sl-button> </sl-tooltip> </span> <sl-popup placement="bottom" strategy="fixed" .active=${this.filterOn} distance="7" > <span slot="anchor"> <sl-tooltip hoist placement=${this.sharedState.chart.options.animation || this.sharedState.scatterDatasets.selected.dataset_indexes .length !== 1 || this.totalDataLength === 0 ? "top-end" : "top-center"} content=${this.sharedState.chart.options.animation ? "To use this option, you must first disable the animation" : this.sharedState.scatterDatasets.selected.dataset_indexes .length !== 1 ? "To use this option, you must first select only one dataset" : this.totalDataLength === 0 ? "To use this option, you must first add at least one data point to the dataset table" : "Filter data points"} > <sl-button variant="default" size="small" circle id="filter-button" @click=${(e) => { this.filterOn = true; e.target.focus(); }} .disabled=${this.sharedState.chart.options.animation || this.sharedState.scatterDatasets.selected.dataset_indexes .length !== 1 || this.totalDataLength === 0} @blur=${() => (this.filterOn = false)} > <sl-icon src=${IconFilter} label="Filter"></sl-icon> </sl-button> </sl-tooltip> </span> <div class="popup-content"> ${(firstSelectedDataset?.labels ?? []).map((label, i) => html ` <sl-menu-item type="checkbox" @mousedown=${(e) => { if (e.button !== 0 || !firstSelectedDataset) return; e.preventDefault(); e.stopPropagation(); const index = firstSelectedDataset.filter.label === label ? -1 : i; if (index !== -1) { firstSelectedDataset.filter.label = label; firstSelectedDataset.filter.index_in_labels = index; const type = firstSelectedDataset.typeOfEachData[index]; firstSelectedDataset.filter.type = type; updateColorForFilter(i, type, firstSelectedDataset); } else { firstSelectedDataset.filter.label = ""; firstSelectedDataset.filter.index_in_labels = -1; firstSelectedDataset.filter.type = "none"; firstSelectedDataset.dimensional_data.forEach((d) => { d.color = firstSelectedDataset.color; }); } this.updateScatterplot(); }} .checked=${firstSelectedDataset.filter.label === label} > ${label} </sl-menu-item> `)} </div> </sl-popup> <div class="table-wrapper"> <table> <thead> <tr> <th class="th_regression">Regression</th> <th class="th_correlation">R</th> </tr> </thead> <tbody class="regression-formulas"></tbody> </table> </div> <div class="table-wrapper"> <table> <thead> <tr> <th class="th_line">Line</th> </tr> </thead> <tbody class="line-formulas"></tbody> </table> </div> <div class="table-wrapper"> <table> <thead> <tr> <th class="th_color">Color</th> <th class="th_range"> ${firstSelectedDataset?.filter?.label && firstSelectedDataset.filter.type === "number" ? firstSelectedDataset.filter.label : "Label"} </th> </tr> </thead> <tbody class="color-formulas"></tbody> </table> </div> ${this.sharedState.chart.options.animation ? html `<sl-range id="animationRange" .value=${this.definition.animationTime} @sl-change=${(e) => { this.definition.animationTime = e.target.value; this.updatePlot(); }} min="1000" max="5000" step="500" label="Animation Time" ></sl-range>` : html ``} </div> </div> </div>`; } get totalDataLength() { let length = 0; this.sharedState.scatterDatasets.selected.dataset_indexes.forEach((dataset_index) => { length += this.sharedState.scatterDatasets.sets[dataset_index].dimensional_data .length; }); return length; } // Get the length of the selected data points get selectedDataLength() { let length = 0; this.sharedState.scatterDatasets.sets.forEach((dataset) => { dataset.dimensional_data.forEach((point) => { if (point.selected) { length++; } }); }); return length; } // Get the length of the unselected data points get unselectedDataLength() { let length = 0; this.sharedState.scatterDatasets.sets.forEach((dataset) => { dataset.dimensional_data.forEach((point) => { if (!point.selected) { length++; } }); }); return length; } firstUpdated() { this.addNode = false; this.selectNode = false; this.selectByBrushing = false; this.selectByClicking = false; this.deselectByBrushing = false; this.selectedIndextoAddNote = 0; this.hideSelected = false; this.hideUnselected = false; this.deleteSelected = false; this.deleteUnselected = false; this.showAll = true; this.editShowing = false; this.drawLine = false; this.filterOn = false; this.hoverCursorChange = true; this.hoverCursorToMove = false; this.hoverCursorToPointer = false; this.createScatterPlot(); // TODO: remove event listener on unmount document.addEventListener("keypress", (e) => { switch (e.key) { case "Backspace": case "Delete": } }); } //updatePlot for new animation time //TODO: Add margin right for bigger numbers and margin top // TODO: Add name for scatterplot, maybe use the text feld? // TODO: Name for each dataset unter the scatterplot // TODO: Table for statistics: r, regression folrmula, mean, etc. // TODO: Fix buttons convert and download image // TODO: Given x and calculate y (geogbra) // TODO: Show all regression line when cut // TODO: see friendly error message and user drag, delete, add points from automa (WR) // TODO: Fix edit button // TODO: dymanic and manuell scaling for x and y axis // TODO: Feedback for choose the right point // TODO: Fix the random color problem updatePlot() { const animationRange = this.shadowRoot.querySelector("#animationRange"); this.definition.animationTime = Number(animationRange.value); // delete the old scatter plot const root = this.shadowRoot.querySelector(".scatterplot-root"); d3.select(root).selectAll("svg").remove(); // delete the left over tooltip d3.select(root).selectAll(".tooltip").remove(); this.createScatterPlot(); // Redraw the scatter plot with the new animation time } // i is index of the selected label to filter //Update the scatterplot after dragging a point updateScatterplot() { if (!this.hideSelected && !this.hideUnselected) { this.showAll = true; } const root = this.shadowRoot.querySelector(".scatterplot-root"); d3.select(root).selectAll("svg").remove(); d3.select(root).selectAll(".tooltip").remove(); d3.select(root).selectAll("path.line").remove(); this.createScatterPlot(); } // !IMPORTANT: TODO: Fix the drag behavior out of scatterplot area ? createScatterPlot() { const selectedDatasets = this.sharedState.scatterDatasets.sets.filter((_, i) => this.sharedState.scatterDatasets.selected.dataset_indexes.includes(i)); const x_axis_type = this.sharedState.scatterDatasets.selected.axis.x === undefined ? "none" : selectedDatasets[0].typeOfEachData[selectedDatasets[0].labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x)]; const y_axis_type = this.sharedState.scatterDatasets.selected.axis.y === undefined ? "none" : selectedDatasets[0].typeOfEachData[selectedDatasets[0].labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y)]; const root = this.shadowRoot.querySelector(".scatterplot-svg"); if (x_axis_type === "number" && y_axis_type === "number") { const axisLabels = { x: this.sharedState.scatterDatasets.selected.axis.x, y: this.sharedState.scatterDatasets.selected.axis.y, }; const datasets = this.sharedState.scatterDatasets.selected.dataset_indexes.map((dataset_index) => { const set = this.sharedState.scatterDatasets.sets[dataset_index]; const index_x = set.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.x); const index_y = set.labels.indexOf(this.sharedState.scatterDatasets.selected.axis.y); return set.dimensional_data.map((dimensional_data, i) => { const d = { x: dimensional_data.data[index_x], y: dimensional_data.data[index_y], selected: dimensional_data.selected, color: selectedDatasets.length === 1 && dimensional_data.color ? dimensional_data.color : set.color, id: set.ids[i], scatter_titel: set.name, }; // !IMPORTANT: Keep the reference to the original dataset to update data when updating points directly! return d; }); }); const lineDatasetsWithCenterPoint = this.sharedState.lineDatasets.sets.length > 0 ? this.sharedState.lineDatasets.sets.map((dataset) => dataset.data.concat([ { x: (dataset.data[0].x + dataset.data[1].x) / 2, y: (dataset.data[0].y + dataset.data[1].y) / 2, }, ])) : []; const { svg, scales, scatter, dimensions, tooltip } = drawScatterplot(root, datasets, axisLabels, { hoverTooltip: this.sharedState.chart.options.hoverTooltip, showOutliers: this.sharedState.chart.options.showOutliers, hoverCursorChange: this.hoverCursorChange, animation: this.sharedState.chart.options.animation, }, this.sharedState.lineDatasets); let animationTime = Number(this.definition.animationTime); // Handle recht click to remove point if (this.deletingPointByClicking) { datasets.forEach((_, index) => { svg .selectAll(`circle.dataset-${index}`) .on("contextmenu", (event) => { deletePointListeners(event, svg, datasets, this.sharedState.scatterDatasets, scales); //Re-render this.updateScatterplot(); }); }); } // Handle adding a new point if (this.addNode && this.selectedIndextoAddNote !== null && this.selectedIndextoAddNote !== undefined && this.selectedIndextoAddNote >= 0 && this.selectedIndextoAddNote < this.sharedState.scatterDatasets.selected.dataset_indexes.length) { // Change cursor to a circle with the color of the selected dataset let color; this.sharedState.scatterDatasets.sets.forEach((set, index) => { if (this.sharedState.scatterDatasets.selected.dataset_indexes.indexOf(index) === this.selectedIndextoAddNote) { color = set.color; } }); let circleCursorSVG = ` <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="${color}" class="bi bi-circle-fill" viewBox="0 0 16 16"> <circle cx="8" cy="8" r="7"/> </svg> `; let circleCursorURL = `data:image/svg+xml;base64,${btoa(circleCursorSVG)}`; // Set the cursor style to the circle with the specified color svg.style("cursor", `url(${circleCursorURL}) 8 8, auto`); // Centered cursor svg.on("click", (event) => { addPointListeners(event, svg, scales, this.sharedState.scatterDatasets, this.selectedIndextoAddNote); this.addNode = false; this.updateScatterplot(); }); this.hoverCursorChange = true; } // Clicking for selecting points if (this.selectNode && this.selectByClicking && !this.selectByBrushing && !this.deselectByBrushing) { // Change cursor to a hand with a pointing finger svg.style("cursor", "pointer"); datasets.forEach((_, index) => { svg.selectAll(`circle.dataset-${index}`).on("click", (event) => { selectPointByClickingListeners(event, svg, datasets, scales, this.sharedState.scatterDatasets); // Re-render this.selectByClicking = false; this.updateScatterplot(); }); }); this.hoverCursorChange = true; } // Dragging points to change their position //TODO: Fix the problem with yelow marks when dragging (maybe focus problem) //TODO: yellow marks appear after (eg. selectwith brush). The setting disappears if (!(this.selectNode || this.selectByClicking || this.addNode)) { addDragingPointListeners(svg, scatter, scales, this.sharedState.scatterDatasets, datasets, () => { this.updateScatterplot(); }); } // Handle brushing to select points (Select all points if they have the same coordinates) // Handle deselecting points by brushing (deselect all points if they have the same coordinates) if (this.selectNode && !this.selectByClicking) { if (this.selectByBrushing || this.deselectByBrushing) { addBrushingListeners(svg, scatter, dimensions, scales, this.sharedState.scatterDatasets, () => { this.selectByBrushing = false; this.deselectByBrushing = false; this.updateScatterplot(); }, this.selectByBrushing ? "select" : "deselect"); } } //Hide selected points if (this.hideSelected) { scatter.selectAll("circle").each(function (d) { if (d.selected) { d3.select(this).style("display", "none"); } }); } //Hide unselected points if (this.hideUnselected) { scatter.selectAll("circle").each(function (d) { if (!d.selected) { d3.select(this).style("display", "none"); } }); } // Show all points if (this.showAll) { scatter.selectAll("circle").each(function (_) { d3.select(this).style("display", "block"); }); } // Draw a line in scatter plot if (this.drawLine) { // change cursor to pencil // Define the pencil SVG as a string let pencilCursorSVG = ` <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16"> <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/> </svg> `; // Encode the SVG to a Base64 data URL let pencilCursorURL = `data:image/svg+xml;base64,${btoa(pencilCursorSVG)}`; svg.style("cursor", `url(${pencilCursorURL}) 0 16, auto`); addDrawLineListeners(svg, scales, (line) => { this.sharedState.lineDatasets.sets.push({ data: line }); this.updateScatterplot(); }); this.drawLine = false; } //TODO: Limit the line to the scatterplot area // Handle dragging lines to new positions // Define the drag behavior for the center point of the line to move the entire line up down // Define the drag behavior for the center point let index_center = -1; const dragCenter = d3 .drag() .on("start", (event) => { d3.select(event.sourceEvent.target).classed("selected-rect", true); }) .on("drag", (event, d) => { // Find index i of the line index_center = findIndexForLine(scales, d, this.sharedState.lineDatasets, index_center); if (index_center === -1) { return; } const indexForEndPointsLeft = d[0].x <= d[1].x ? 0 : 1; const indexForEndPointsRight = d[0].x <= d[1].x ? 1 : 0; const [newX, newY] = d3.pointer(event, svg.node()); const dy = newY - (d[0].y + d[1].y) / 2; const dx = newX - (d[0].x + d[1].x) / 2; d[0].y += dy; d[1].y += dy; d[0].x += dx; d[1].x += dx; // Update the visual position of the line and points updateLineAndPoints(svg, scales, d, index_center, indexForEndPointsLeft, indexForEndPointsRight, false, this.sharedState.lineDatasets); }) .on("end", () => { this.updateScatterplot(); }); let index_right = -1; // Define the drag behavior for the end points of the line to rotate the line const dragEndPointsRight = d3 .drag() .on("start", (event, _) => { d3.select(event.sourceEvent.target).classed("selected-rect", true); }) .on("drag", (event, d) => { // Find index i of the line index_right = findIndexForLine(scales, d, this.sharedState.lineDatasets, index_right); if (index_right === -1) { return; } // Rotate the line around the center point const [newX, newY] = d3.pointer(event, svg.node()); // Calculate the new position of the line d = calculateForRotation(d, newX, newY); // Update the visual position of the line and points updateLineAndPoints(svg, scales, d, index_right, 0, 1, false, this.sharedState.lineDatasets); }) .on("end", () => { this.updateScatterplot(); }); let index_left = -1; const dragEndPointsLeft = d3 .drag() .on("start", (event, _) => { d3.select(event.sourceEvent.target).classed("selected-rect", true); }) .on("drag", (event, d) => { // Find index i of the line index_left = findIndexForLine(scales, d, this.sharedState.lineDatasets, index_left); if (index_left === -1) { return; } // Rotate the line around the center point const [newX, newY] = d3.pointer(event, svg.node()); // Calculate the new position of the line d = calculateForRotation(d, newX, newY); // Update the visual position of the line and points updateLineAndPoints(svg, scales, d, index_left, 1, 0, true, this.sharedState.lineDatasets); }) .on("end", () => { this.updateScatterplot(); }); // TODO: Delete the line by right click // Append the line and points this.sharedState.lineDatasets.sets.forEach((dataset, i) => { const x1 = scales.x(dataset.data[0].x); const y1 = scales.y(dataset.data[0].y); const x2 = scales.x(dataset.data[1].x); const y2 = scales.y(dataset.data[1].y); // Define the end points of the line const endPointsLeft = { x: x1, y: y1 }; const endPointsRight = { x: x2, y: y2 }; // Append the line svg .append("line") .attr("class", `line-${i}`) .attr("x1", x1) .attr("y1", y1) .attr("x2", x2) .attr("y2", y2) .attr("stroke", "#69b3a2") .attr("stroke-width", 2); svg.select(`.line-${i}`).on("contextmenu", (_) => { this.sharedState.lineDatasets.sets.splice(i, 1); this.updateScatterplot(); }); // Define the center point of the line const center = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 }; // Append the center point svg .append("rect") .datum([endPointsLeft, endPointsRight]) .attr("class", `center-point-${i}`) .attr("x", center.x - 5) .attr("y", center.y - 5) .attr("width", 10) // Width of the rectangle .attr("height", 10) // Height of the rectangle .attr("fill", "white") // Fill color of the rectangle .attr("stroke", "black") .attr("stroke-width", 1.5) .call(dragCenter); svg .select(`.center-point-${i}`) .on("contextmenu", (event) => { deleteLineListeners(event, svg, lineDatasetsWithCenterPoint, scales, this.sharedState.lineDatasets); this.updateScatterplot(); }) .on("mouseover", () => { svg.style("cursor", "move"); }) .on("mouseleave", () => { svg.style("cursor", "default"); }); // Append the end point left const end_point_left = svg .append("rect") .datum([endPointsLeft, endPointsRight]) .attr("class", `end-point-left-${i}`) .attr("x", endPointsLeft.x - 5) // Position of the rectangle .attr("y", endPointsLeft.y - 5) // 5 is half of the width and height of the rectangle .attr("width", 10) // Width of the rectangle .attr("height", 10) // Height of the rectangle .attr("fill", "white") // Fill color of the rectangle .attr("stroke", "black") .attr("stroke-width", 1.5); svg .select(`.end-point-left-${i}`) .on("contextmenu", (event) => { deleteLineListeners(event, svg, lineDatasetsWithCenterPoint, scales, this.sharedState.lineDatasets); this.updateScatterplot(); }) .on("mouseover", () => { svg.style("cursor", "move"); }) .on("mouseleave", () => { svg.style("cursor", "default"); }); // Append the end point right const end_point_right = svg .append("rect") .datum([endPointsLeft, endPointsRight]) .attr("class", `end-point-right-${i}`) .attr("x", endPointsRight.x - 5) .attr("y", endPointsRight.y - 5) .attr("width", 10) // Width of the rectangle .attr("height", 10) // Height of the rectangle .attr("fill", "white") .attr("stroke", "black") .attr("stroke-width", 1.5); svg .select(`.end-point-right-${i}`) .on("contextmenu", (event) => { deleteLineListeners(event, svg, lineDatasetsWithCenterPoint, scales, this.sharedState.lineDatasets); this.updateScatterplot(); }) .on("mouseover", () => { svg.style("cursor", "move"); }) .on("mouseleave", () => { svg.style("cursor", "default"); }); end_point_left.call(dragEndPointsLeft); end_point_right.call(dragEndPointsRight); }); // For line formulas const lineFormulaRoot = this.shadowRoot.querySelector(".line-formulas"); lineFormulaRoot.children ? [...lineFormulaRoot.children].forEach((child) => child.remove()) : null; this.sharedState.lineDatasets.sets.forEach((dataset) => { const tr = document.createElement("tr"); const td = document.createElement("td"); tr.appendChild(td); const lineSpan = document.createElement("span"); td.appendChild(lineSpan); lineFormulaRoot.appendChild(tr); const lineFormula = `y = ${((dataset.data[1].y - dataset.data[0].y) / (dataset.data[1].x - dataset.data[0].x)).toFixed(2)}x + ${(dataset.data[0].y - ((dataset.data[1].y - dataset.data[0].y) / (dataset.data[1].x - dataset.data[0].x)) * dataset.data[0].x).toFixed(2)}`; lineSpan.innerText = lineFormula; }); // For scatter plot with regression const regressionFormulaRoot = this.shadowRoot.querySelector(".regression-formulas"); [...regressionFormulaRoot.children].forEach((child) => child.remove()); datasets.forEach((dataset) =>