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,588 lines (1,518 loc) 84.5 kB
import * as d3 from "d3"; import * as d3regression from "d3-regression"; import { OneDimentionalPoint, PointOnlyNumbers, Point, OneDimentionalNumberPoint, } from "./scatterplot"; import { LineDataSets, ScatterplotDataSets, ScatterDatasets, ScatterDataset, } from "../../../interfaces"; import { generateColorWheel, randomColor } from "../../../functions"; interface Dimensions { height: number; width: number; margin: { top: number; right: number; bottom: number; left: number; }; min: { x: number; y: number }; max: { x: number; y: number }; ticks: { x: number; y: number }; } interface Scales { x: d3.ScaleLinear<number, number>; y: d3.ScaleLinear<number, number>; } interface Scales_singleDataset { x_num: d3.ScaleLinear<number, number>; y_num: d3.ScaleLinear<number, number>; x_band: d3.ScaleBand<string>; y_band: d3.ScaleBand<string>; x_none: d3.ScaleLinear<number, number>; y_none: d3.ScaleLinear<number, number>; } function getScatterplotDataSets(scatterdatasets: ScatterDatasets) { const scatterplotdatasets: ScatterplotDataSets = { axisLabels: { x: scatterdatasets.selected.axis.x, y: scatterdatasets.selected.axis.y, }, sets: scatterdatasets.selected.dataset_indexes.map((index) => { const set = scatterdatasets.sets[index]; const index_x = set.labels.indexOf(scatterdatasets.selected.axis.x); const index_y = set.labels.indexOf(scatterdatasets.selected.axis.y); const index_in_scatterdatasets = index; const dimensional_data = set.dimensional_data.map((dimensional_data) => { return { x: dimensional_data.data[index_x] as number, y: dimensional_data.data[index_y] as number, selected: dimensional_data.selected, }; }); return { scatter_color: set.color, scatter_titel: set.name, ids: set.ids, dimensional_data: dimensional_data, index_in_scatterdatasets: index_in_scatterdatasets, }; }), }; return scatterplotdatasets; } function checkDatasetForScatterplot( datasets: PointOnlyNumbers[][], line_datasets: LineDataSets ) { let x_axis_data: | "only_positive" | "only_negative" | "both" | "no_data" | undefined = undefined; let y_axis_data: | "only_positive" | "only_negative" | "both" | "no_data" | undefined = undefined; if (datasets.length === 0) { x_axis_data = "no_data"; y_axis_data = "no_data"; } for (let i = 0; i < datasets.length; i++) { const dataset = datasets[i]; for (let j = 0; j < dataset.length; j++) { const x = dataset[j].x; const y = dataset[j].y; // Check x-axis if (x < 0) { if (x_axis_data === undefined) { x_axis_data = "only_negative"; } else if (x_axis_data === "only_positive") { x_axis_data = "both"; } } else if (x > 0) { if (x_axis_data === undefined) { x_axis_data = "only_positive"; } else if (x_axis_data === "only_negative") { x_axis_data = "both"; } } // Check y-axis if (y < 0) { if (y_axis_data === undefined) { y_axis_data = "only_negative"; } else if (y_axis_data === "only_positive") { y_axis_data = "both"; } } else if (y > 0) { if (y_axis_data === undefined) { y_axis_data = "only_positive"; } else if (y_axis_data === "only_negative") { y_axis_data = "both"; } } } } // Check line datasets line_datasets.sets.forEach((line_dataset) => { line_dataset.data.forEach((d) => { const x = d.x; const y = d.y; // Check x-axis if (x < 0) { if (x_axis_data === undefined) { x_axis_data = "only_negative"; } else if (x_axis_data === "only_positive") { x_axis_data = "both"; } } else if (x > 0) { if (x_axis_data === undefined) { x_axis_data = "only_positive"; } else if (x_axis_data === "only_negative") { x_axis_data = "both"; } } // Check y-axis if (y < 0) { if (y_axis_data === undefined) { y_axis_data = "only_negative"; } else if (y_axis_data === "only_positive") { y_axis_data = "both"; } } else if (y > 0) { if (y_axis_data === undefined) { y_axis_data = "only_positive"; } else if (y_axis_data === "only_negative") { y_axis_data = "both"; } } }); }); return { x_axis_data, y_axis_data }; } function calculateSVGDimensions( data: PointOnlyNumbers[][], line_datasets: LineDataSets, datasets_count: number, lineDatasets_count: number ): Dimensions { let maxX = 0; let maxY = 0; let minX = 0; let minY = 0; let result = checkDatasetForScatterplot(data, line_datasets); if (datasets_count > 0) { data.forEach((dataset) => { maxX = Math.max(...dataset.map((d) => d.x), maxX); maxY = Math.max(...dataset.map((d) => d.y), maxY); minX = Math.min(...dataset.map((d) => d.x), minX); minY = Math.min(...dataset.map((d) => d.y), minY); }); } if (lineDatasets_count > 0) { line_datasets.sets.forEach((line_dataset) => { maxX = Math.max(...line_dataset.data.map((d) => d.x), maxX); maxY = Math.max(...line_dataset.data.map((d) => d.y), maxY); minX = Math.min(...line_dataset.data.map((d) => d.x), minX); minY = Math.min(...line_dataset.data.map((d) => d.y), minY); }); } if (maxX > 0) { const roundingFactorX = 10 ** Math.floor(Math.log10(maxX)); maxX = Math.ceil(maxX / roundingFactorX) * roundingFactorX; } if (maxY > 0) { const roundingFactorY = 10 ** Math.floor(Math.log10(maxY)); maxY = Math.ceil(maxY / roundingFactorY) * roundingFactorY; } // Round the min if the min is negative if (minX < 0) { const roundingFactorX = 10 ** Math.floor(Math.log10(Math.abs(minX))); minX = Math.ceil(Math.abs(minX) / roundingFactorX) * roundingFactorX * -1; } if (minY < 0) { const roundingFactorY = 10 ** Math.floor(Math.log10(Math.abs(minY))); minY = Math.ceil(Math.abs(minY) / roundingFactorY) * roundingFactorY * -1; } if (minX === 0 && maxX === 0) { maxX = 1; } if (minY === 0 && maxY === 0) { maxY = 1; } // set the dimensions and margins of the graph const margin = { top: 50, right: 55, bottom: 50, left: 70 }, width = 575 - margin.left - margin.right, height = 420 - margin.top - margin.bottom; let tick_x = -1; let tick_y = -1; if (result.x_axis_data === "only_negative") { const scales_x = d3.scaleLinear().domain([minX, maxX]).range([0, width]); let tickValues: number[] = scales_x.ticks(); if (minX < -1 * 10 ** 3 && minX > -1 * 10 ** 7) { tick_x = 5; tickValues = scales_x.ticks(tick_x); } else if (minX <= -1 * 10 ** 7 && minX > -1 * 10 ** 100) { tick_x = 6; tickValues = scales_x.ticks(tick_x); } else if (minX <= -1 * 10 ** 100) { tick_x = 5; tickValues = scales_x.ticks(tick_x); } // Find the biggest tick value that is not 0 const maxTickValue = tickValues.filter((d) => d !== 0).pop(); if (maxTickValue !== undefined && maxTickValue < 0) { maxX = maxTickValue * -1; } } if (result.x_axis_data === "only_positive") { if (maxX > 10 ** 3 && maxX < 10 ** 7) { tick_x = 5; } else if (maxX >= 10 ** 7 && maxX < 10 ** 100) { tick_x = 6; } else if (maxX >= 10 ** 100) { tick_x = 5; } } if (result.x_axis_data === "both") { if (maxX - minX > 10 ** 3 && maxX - minX < 10 ** 7) { tick_x = 5; } else if (maxX - minX >= 10 ** 7 && maxX - minX < 10 ** 100) { tick_x = 6; } else if (maxX - minX >= 10 ** 100) { tick_x = 5; } } if (result.y_axis_data === "only_negative") { const scales_y = d3.scaleLinear().domain([minY, maxY]).range([height, 0]); let tickValues: number[] = scales_y.ticks(); if (minY < -1 * 10 ** 3 && minY > -1 * 10 ** 5) { tick_y = 5; tickValues = scales_y.ticks(tick_y); } else if (minY <= -1 * 10 ** 5 && minY > -1 * 10 ** 100) { tick_y = 6; tickValues = scales_y.ticks(tick_y); } else if (minY <= -1 * 10 ** 100) { tick_y = 5; tickValues = scales_y.ticks(tick_y); } // Find the biggest tick value that is not 0 const maxTickValue = tickValues.filter((d) => d !== 0).pop(); if (maxTickValue !== undefined && maxTickValue < 0) { maxY = maxTickValue * -1; } } if (result.y_axis_data === "only_positive") { if (maxY > 10 ** 3 && maxY < 10 ** 5) { tick_y = 5; } else if (maxY >= 10 ** 5 && maxY < 10 ** 100) { tick_y = 6; } else if (maxY >= 10 ** 100) { tick_y = 5; } } if (result.y_axis_data === "both") { if (maxY - minY > 10 ** 3 && maxY - minY < 10 ** 5) { tick_y = 5; } else if (maxY - minY >= 10 ** 5 && maxY - minY < 10 ** 100) { tick_y = 6; } else if (maxY - minY >= 10 ** 100) { tick_y = 5; } } return { height, width, margin, min: { x: minX, y: minY }, max: { x: maxX, y: maxY }, ticks: { x: tick_x, y: tick_y }, }; } function createSVG( parent: Element, width: number, height: number, margin: { top: number; right: number; bottom: number; left: number; } ) { // append the svg object to the body of the page const svg = d3 .select(parent) .append("svg") .attr( "viewBox", `0 0 ${width + margin.left + margin.right} ${ height + margin.top + margin.bottom }` ) .attr("preserveAspectRatio", "xMidYMid meet") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); // Add a transparent rectangle to capture click events svg .append("rect") .attr("width", width) .attr("height", height) .style("fill", "none") .style("pointer-events", "all"); return svg; } // Custom tick format for x-axis when the max value > 10^7 or min value < -10^7 // Custom tick format for y-axis when the max value > 10^5 or min value < -10^5 function customTickFormatForAxis(d) { if (d !== 0) { const exponent = Math.floor(Math.log10(Math.abs(d))); const mantissa = (d / Math.pow(10, exponent)).toFixed(1); // 1 decimal places return `${mantissa}e${exponent}`; } else { return d; } } function drawAxis( svg: d3.Selection<SVGGElement, unknown, null, undefined>, height: number, width: number, margin: { bottom: number; }, axisLabels: { x: string; y: string }, scales: Scales, maxX: number, maxY: number, minX: number, minY: number, ticksX: number, ticksY: number, datasets_count: number, lineDatasets_count: number, x_axis_type: string, y_axis_type: string, title: string ) { const gx = svg .append("g") .attr("transform", "translate(0," + height + ")") .style("color", "#a29e9e"); const gy = svg.append("g").style("color", "#a29e9e"); // count the points in datasets if ( ((x_axis_type === "noSelectedDataset" && y_axis_type === "noSelectedDataset") || (x_axis_type === "noData" && y_axis_type === "noData") || (x_axis_type === "noDataInSelectedDatasets" && y_axis_type === "noDataInSelectedDatasets")) && lineDatasets_count === 0 ) { if ( x_axis_type === "noSelectedDataset" && y_axis_type === "noSelectedDataset" ) { svg .append("text") .attr("x", width / 2) .attr("y", height / 2) .attr("text-anchor", "middle") .text("Please select at least one dataset"); } else if (x_axis_type === "noData" && y_axis_type === "noData") { svg .append("text") .attr("x", width / 2) .attr("y", height / 2) .attr("text-anchor", "middle") .text("Please add dataset"); } else { svg .append("text") .attr("x", width / 2) .attr("y", height / 2) .attr("text-anchor", "middle") .text("Please add data to the selected dataset(s)"); } } else if ( (x_axis_type === "none" || y_axis_type === "none") && lineDatasets_count === 0 ) { svg .append("text") .attr("x", width / 2) .attr("y", height / 2) .attr("text-anchor", "middle") .text("Please select x and y axis"); } else if (datasets_count === 0 && lineDatasets_count === 0) { svg .append("text") .attr("x", width / 2) .attr("y", height / 2) .attr("text-anchor", "middle") .text("Please add data to the selected dataset(s)"); } else { // Get the ticks from the scale directly const tickValues_x = ticksX === -1 ? scales.x.ticks() : scales.x.ticks(ticksX); // Case only negative values or both if (minX < 0 && maxX >= 0) { // Ensure 0 is in the tick values // Ensure 0 is included in the tick values if (!tickValues_x.includes(0)) { tickValues_x.push(0); } // Sort the tick values to maintain the correct order tickValues_x.sort((a, b) => a - b); if (maxX === 0) { // Apply the axis with the custom tick values minX >= -1 * 10 ** 7 ? gx.call(d3.axisBottom(scales.x).tickValues(tickValues_x)) : gx.call( d3 .axisBottom(scales.x) .tickFormat(customTickFormatForAxis) .tickValues(tickValues_x) ); // Use the custom tick format } else { // Apply the axis with the custom tick values maxX - minX < 1 * 10 ** 7 ? gx.call(d3.axisBottom(scales.x).tickValues(tickValues_x)) : gx.call( d3 .axisBottom(scales.x) .tickFormat(customTickFormatForAxis) .tickValues(tickValues_x) ); // Use the custom tick format } svg .append("line") .attr("x1", scales.x(0)) .attr("y1", scales.y(minY)) .attr("x2", scales.x(0)) .attr("y2", scales.y(maxY)) .attr("class", "line-x") .style("stroke", "#000000") .style("stroke-width", 0.7) .style("opacity", 1); } else if (minX === 0 && maxX > 0) { // only positive values maxX < 10 ** 7 ? gx.call(d3.axisBottom(scales.x).tickValues(tickValues_x)) : gx.call( d3 .axisBottom(scales.x) .tickFormat(customTickFormatForAxis) .tickValues(tickValues_x) ); // Use the custom tick format } else if (minX === 0 && maxX === 0) { // only 0 gx.call(d3.axisBottom(scales.x)); } svg .append("text") .attr("class", "x label") .attr("text-anchor", "middle") .attr("x", width / 2) .attr("y", height + 40) .attr("style", "text-align: center;") .text(axisLabels.x ? axisLabels.x : "X-axis"); // Add Y axis // Get the ticks from the scale directly const tickValues_y = ticksY === -1 ? scales.y.ticks() : scales.y.ticks(ticksY); // Case only negative values or both if (minY < 0 && maxY >= 0) { // Ensure 0 is in the tick values // Ensure 0 is included in the tick values if (!tickValues_y.includes(0)) { tickValues_y.push(0); } // Sort the tick values to maintain the correct order tickValues_y.sort((a, b) => a - b); if (maxY === 0) { // Apply the axis with the custom tick values minY >= -1 * 10 ** 5 ? gy.call(d3.axisLeft(scales.y).tickValues(tickValues_y)) : gy.call( d3 .axisLeft(scales.y) .tickFormat(customTickFormatForAxis) .tickValues(tickValues_y) ); // Use the custom tick format } else { // Apply the axis with the custom tick values maxY - minY < 1 * 10 ** 5 ? gy.call(d3.axisLeft(scales.y).tickValues(tickValues_y)) : gy.call( d3 .axisLeft(scales.y) .tickFormat(customTickFormatForAxis) .tickValues(tickValues_y) ); // Use the custom tick format } svg .append("line") .attr("x1", scales.x(minX)) .attr("y1", scales.y(0)) .attr("x2", scales.x(maxX)) .attr("y2", scales.y(0)) .attr("class", "line-y") .style("stroke", "#080808") .style("stroke-width", 0.7) .style("opacity", 1); } else if (minY === 0 && maxY > 0) { // only positive values maxY < 10 ** 5 ? gy.call(d3.axisLeft(scales.y).tickValues(tickValues_y)) : gy.call( d3 .axisLeft(scales.y) .tickFormat(customTickFormatForAxis) .tickValues(tickValues_y) ); // Use the custom tick format } else if (minY === 0 && maxY === 0) { // only 0 gy.call(d3.axisLeft(scales.y)); } svg .append("text") .attr("class", "y label") .attr("text-anchor", "middle") .attr("x", -((height + margin.bottom) / 2)) // to my x axis .attr("y", maxY - minY <= 1999 ? -50 : maxY - minY <= 9999 ? -52 : -57) // to my y axis .attr("transform", "rotate(-90)") .text(axisLabels.y ? axisLabels.y : "Y-axis"); } svg .append("text") .attr("x", width / 2) .attr("y", -20) .attr("text-anchor", "middle") .text(title); return { gx, gy }; } function createTooltip(parent: Element) { // Add tooltip for data points return d3 .select(parent) .append("div") .style("opacity", 0) .attr("class", "tooltip") .style("position", "fixed") .style("background-color", "white") .style("border", "solid") .style("border-width", "1px") .style("border-radius", "5px") .style("padding", "10px"); } function createNewTooltip(parent: Element) { // This tooltip is used for the dragging return d3 .select(parent) .append("div") .style("opacity", 0) .attr("class", "new-tooltip") .style("position", "fixed") .style("background-color", "white") .style("border", "solid") .style("border-width", "1px") .style("border-radius", "5px") .style("padding", "10px"); } // Custom tick format for x-axis when the max value > 10^7 or min value < -10^7 function customFormatForBigNumber(d, decimal: number) { if (d !== 0) { const exponent = Math.floor(Math.log10(Math.abs(d))); const mantissa = roundNumber(d / Math.pow(10, exponent), decimal); // 4 decimal places return `${mantissa}e${exponent}`; } else { return d; } } function drawScatterplot( root: Element, datasets: PointOnlyNumbers[][], axisLabels: { x: string; y: string }, fullAxisLabels: { x: string; y: string }, options: { hoverTooltip: boolean; showOutliers: boolean; hoverCursorChange: boolean; drawLine: boolean; isDragging: boolean; }, line_datasets: LineDataSets, datasets_count: number, lineDatasets_count: number, x_axis_type: string, y_axis_type: string, title: string ) { const dimensions = calculateSVGDimensions( datasets, line_datasets, datasets_count, lineDatasets_count ); const svg = createSVG( root, dimensions.width, dimensions.height, dimensions.margin ); const scales = { x: d3 .scaleLinear() .domain([dimensions.min.x, dimensions.max.x]) .range([0, dimensions.width]), y: d3 .scaleLinear() .domain([dimensions.min.y, dimensions.max.y]) .range([dimensions.height, 0]), }; drawAxis( svg, dimensions.height, dimensions.width, dimensions.margin, axisLabels, scales, dimensions.max.x, dimensions.max.y, dimensions.min.x, dimensions.min.y, dimensions.ticks.x, dimensions.ticks.y, datasets_count, lineDatasets_count, x_axis_type, y_axis_type, title ); const tooltip = createTooltip(root); // For normal scatter plot const scatter = svg.append("g"); // Draw points in the scatter plot if ( datasets_count > 0 && x_axis_type === "number" && y_axis_type === "number" ) { for (let i = 0; i < datasets.length; i++) { const dataset = datasets[i]; drawPoints(scatter, dataset, i, scales); // Hover points to make them bigger and show tooltip scatter .selectAll(`circle.dataset-${i}`) .style("stroke", "black") .style("stroke-width", 0.5) .on("mouseover", function (_e, d: PointOnlyNumbers) { if (options.hoverCursorChange) { d3.select(this).attr("r", 8); // Change the cursor to move d3.select(this).style("cursor", "move"); } const dx: number = Math.abs(d.x) < 10 ** 7 ? roundNumber(d.x, 4) : customFormatForBigNumber(d.x, 4); const dy: number = Math.abs(d.y) < 10 ** 5 ? roundNumber(d.y, 4) : customFormatForBigNumber(d.y, 4); // Show tooltip if ( options.hoverTooltip && !options.isDragging && !options.drawLine ) { tooltip .style("opacity", 1) .html( "ID: " + d.id + "<br/>" + fullAxisLabels.x + ": " + dx + " " + fullAxisLabels.y + ": " + dy ); } }) .on("mousemove", (e) => { tooltip.style("left", e.x + 10 + "px").style("top", e.y + 10 + "px"); }) .on("mouseleave", function () { d3.select(this).attr("r", 7); // Change the cursor back to normal d3.select(this).style("cursor", "default"); // Hide tooltip if ( options.hoverTooltip && !options.isDragging && !options.drawLine ) { tooltip.transition().duration(200).style("opacity", 0); } }); // Show outliers for each dataset if (options.showOutliers) { const datasetLabel = dataset[0].scatter_titel; // Find outliers using the IQR method const q1_y = d3.quantile( dataset.map((d) => d.y).sort(d3.ascending), 0.25 ); const q3_y = d3.quantile( dataset.map((d) => d.y).sort(d3.ascending), 0.75 ); const iqr_y = q3_y - q1_y; const min_y = q1_y - 1.5 * iqr_y; const max_y = q3_y + 1.5 * iqr_y; const outliers_y = dataset.filter((d) => d.y < min_y || d.y > max_y); const q1_x = d3.quantile( dataset.map((d) => d.x).sort(d3.ascending), 0.25 ); const q3_x = d3.quantile( dataset.map((d) => d.x).sort(d3.ascending), 0.75 ); const iqr_x = q3_x - q1_x; const min_x = q1_x - 1.5 * iqr_x; const max_x = q3_x + 1.5 * iqr_x; const outliers_x = dataset.filter((d) => d.x < min_x || d.x > max_x); const outliers = outliers_x.concat(outliers_y); scatter .selectAll(`circle.dataset-${i}`) .data(outliers, (d: PointOnlyNumbers) => `${d.x}-${d.y}`) // Assuming x and y uniquely identify each circle .style("fill", "#f9080c") .style("opacity", 1) .on("mouseover", (event, d) => { const dx: number = Math.abs(d.x) < 10 ** 7 ? roundNumber(d.x, 4) : customFormatForBigNumber(d.x, 4); const dy: number = Math.abs(d.y) < 10 ** 5 ? roundNumber(d.y, 4) : customFormatForBigNumber(d.y, 4); if (!options.isDragging && !options.drawLine) { tooltip .style("opacity", 1) .html( `${ datasetLabel ? "Outlier of " + datasetLabel : "Outlier" }<br>${fullAxisLabels.x}: ${dx} ${fullAxisLabels.y}: ${dy}` ) .style("left", `${event.pageX + 10}px`) .style("top", `${event.pageY + 10}px`); } }) .on("mouseleave", () => { if (!options.isDragging && !options.drawLine) { tooltip.transition().duration(200).style("opacity", 0); } }); } } } return { svg, scales, scatter, dimensions, tooltip }; } function findIndexForPoint( d: PointOnlyNumbers, scatterdatasets: ScatterDatasets, compareSelected: boolean ) { // Iterate over scatterdatasets.sets in reverse order for (let i = scatterdatasets.sets.length - 1; i >= 0; i--) { if (scatterdatasets.selected.dataset_indexes.includes(i)) { const set = scatterdatasets.sets[i]; const index_x = set.labels.indexOf(scatterdatasets.selected.axis.x); const index_y = set.labels.indexOf(scatterdatasets.selected.axis.y); // Iterate over set.dimensional_data in reverse order for (let j = set.dimensional_data.length - 1; j >= 0; j--) { let dimensional_data = set.dimensional_data[j]; if ( dimensional_data.data[index_x] === d.x && dimensional_data.data[index_y] === d.y && (compareSelected ? dimensional_data.selected === d.selected : true) ) { return { index_in_set: i, index_in_dimensional_data: j, index_in_data_x: index_x, index_in_data_y: index_y, }; } } } } return { index_in_set: -1, index_in_dimensional_data: -1, index_in_data_x: -1, index_in_data_y: -1, }; // Return -1 if no match is found } function drawPoints( parent: d3.Selection<SVGGElement, unknown, null, undefined>, dataset: PointOnlyNumbers[], index: number, scales: Scales ) { parent .selectAll(`circle.dataset-${index}`) .data(dataset) .enter() .append("circle") .attr("class", `dataset-${index}`) .attr("cx", (d) => scales.x(d.x)) .attr("cy", (d) => scales.y(d.y)) .attr("r", 7) .style("fill", (d) => d.color ?? "#417ca1") .style("opacity", 1) .style("stroke", "back") .style("stroke-width", 0.5) .classed("selected", (d) => d.selected); } // Function to check if a point is brushed function isBrushed( brush_coords: [[number, number], [number, number]], cx: number, cy: number ) { const x0 = brush_coords[0][0], x1 = brush_coords[1][0], y0 = brush_coords[0][1], y1 = brush_coords[1][1]; return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1; } function addDrawLineListeners( svg: d3.Selection<SVGElement, unknown, null, undefined>, scales: Scales, onFinish: (line: [{ x: number; y: number }, { x: number; y: number }]) => void ) { let isDrawing = false; let line: d3.Selection<SVGGElement, unknown, null, undefined>; let x1: number, y1: number, x2: number, y2: number; svg.on("mousedown", (event) => { if (!isDrawing) { isDrawing = true; [x1, y1] = d3.pointer(event, svg.node()); [x2, y2] = [x1, y1]; // Start with both points at the mouse position // Append a new line on mousedown line = svg .append("line") .attr("x1", x1) .attr("y1", y1) .attr("x2", x2) .attr("y2", y2) .attr("stroke", "black") .attr("stroke-width", 2) .attr("stroke-dasharray", "5,5") .style("cursor", "move"); svg.on("mousemove", drawLine); svg.on("mouseup", () => { saveLine(); // Save the line data to shared state const line = [ { x: scales.x.invert(x1), y: scales.y.invert(y1) }, { x: scales.x.invert(x2), y: scales.y.invert(y2) }, ] as [{ x: number; y: number }, { x: number; y: number }]; onFinish(line); }); } }); function drawLine(event) { [x2, y2] = d3.pointer(event, svg.node()); line.attr("x2", x2).attr("y2", y2); } function saveLine() { if (isDrawing) { isDrawing = false; svg.on("mousemove", null); svg.on("mouseup", null); } } } function addBrushingListeners( svg: d3.Selection<SVGGElement, unknown, null, undefined>, scatter: d3.Selection<SVGGElement, unknown, null, undefined>, dimensions: Dimensions, scales: Scales, scatterdatasets: ScatterDatasets, onFinish: () => void, type: "select" | "deselect" ) { let brushingEnabled = true; // Flag to track if brushing is enabled // Initialize brush behavior const brush = d3 .brush() .extent([ [0, 0], [dimensions.width, dimensions.height], ]) .on("start brush", updateChartForSelection) .on("end", (event) => { finalizeSelection(event, scatterdatasets); onFinish(); }); svg.call(brush); function updateChartForSelection(event) { if (!brushingEnabled) return; // Exit if brushing is disabled const extent = event.selection; scatter.selectAll("circle").each(function (d: PointOnlyNumbers) { // Reset to current selected state during brushing d3.select(this).classed("selected", d.selected); if (extent) { if (isBrushed(extent, scales.x(d.x), scales.y(d.y))) { // Temporarily add the selected class for brushed points d3.select(this).classed("selected", type === "select"); } } }); } function finalizeSelection(event, scatterdatasets: ScatterDatasets) { if (!brushingEnabled) return; // Exit if brushing is disabled const extent = event.selection; if (extent) { scatter.selectAll("circle").each(function (d: PointOnlyNumbers) { if (isBrushed(extent, scales.x(d.x), scales.y(d.y))) { d.selected = type === "select"; // Definitively select the point if it is brushed } // Update the class based on final selection d3.select(this).classed("selected", d.selected); const point: PointOnlyNumbers = { x: d.x, y: d.y, }; // Update the scatterdatasets const index_set = findIndexForPoint(point, scatterdatasets, false); scatterdatasets.sets[index_set.index_in_set].dimensional_data[ index_set.index_in_dimensional_data ].selected = d.selected; }); } brushingEnabled = false; // Disable brushing after the first brush // delete the brush svg.call(brush.move, null); // Clear the brush selection svg.on(".brush", null); // Remove brush event listeners svg.selectAll(".overlay").remove(); // Remove any overlay elements created by brushing } } function findTheClosestPoint( datasets: PointOnlyNumbers[][], click_X: number, click_Y: number ) { let minDistance = Infinity; let closestPoint: PointOnlyNumbers = null; const flattenedDatasets = datasets.flat(); for (let i = flattenedDatasets.length - 1; i >= 0; i--) { const point = flattenedDatasets[i]; const diff_x = Math.abs(point.x - click_X); const diff_y = Math.abs(point.y - click_Y); const distance = diff_x + diff_y; if (distance < minDistance) { minDistance = distance; closestPoint = point; } } return { closestPoint, minDistance }; } // Iterate through the scatterplot datasets and return the last match index function findTheLastMatchIndexInSPDatasets( closestPoint: PointOnlyNumbers, datasets: ScatterplotDataSets ) { let datasetIndex = -1; for (let i = datasets.sets.length - 1; i >= 0; i--) { const dataset = datasets.sets[i]; if ( (closestPoint.scatter_titel !== "" ? dataset.scatter_titel === closestPoint.scatter_titel : true) && (closestPoint.id ? dataset.ids.includes(closestPoint.id) : true) && dataset.dimensional_data.some( (point) => point.x === closestPoint.x && point.y === closestPoint.y && point.selected === closestPoint.selected ) ) { datasetIndex = i; break; // Optional: stop at the first match from the end } } return datasetIndex; } // Iterate through the scatterplot dataset and return the last match index function findTheLastMatchIndexInSPDataset( closestPoint: PointOnlyNumbers, dimensionalData: { x: number; y: number; selected: boolean }[] ) { //Find the index of the point in the dataset let index = -1; for (let i = dimensionalData.length - 1; i >= 0; i--) { const point = dimensionalData[i]; if ( point.x === closestPoint.x && point.y === closestPoint.y && point.selected === closestPoint.selected ) { index = i; break; // Optional: stop at the first match from the end } } return index; } function addDragingPointListeners( root: Element, svg: d3.Selection<SVGElement, unknown, null, undefined>, scatter: d3.Selection<SVGGElement, unknown, null, undefined>, scales: Scales, scatterdatasets: ScatterDatasets, datasets: PointOnlyNumbers[][], onFinish: () => void, axisLabels: { x: string; y: string } ) { // create tooltip that separates from the tooltip of the chart let index_set = { index_in_set: -1, index_in_dimensional_data: -1, index_in_data_x: -1, index_in_data_y: -1, }; const new_tooltip = createNewTooltip(root); const drag = d3 .drag() .on("start", (event, d: PointOnlyNumbers) => { d3.select(event.sourceEvent.target).classed("selected", true); index_set = findIndexForPoint(d, scatterdatasets, true); // Delete the tooltip d3.select(root).selectAll(".tooltip").remove(); }) .on("drag", (event, d: PointOnlyNumbers) => { // Get new coordinates relative to the SVG container const [newX, newY] = d3.pointer(event, svg.node()); d.x = scales.x.invert(newX); d.y = scales.y.invert(newY); // Update the visual position of the circle (this is true) scatter.selectAll("circle").each(function (d: PointOnlyNumbers) { d3.select(this).attr("cx", scales.x(d.x)).attr("cy", scales.y(d.y)); }); // show the tooltip with the new values const dx: number = Math.abs(d.x) < 10 ** 7 ? roundNumber(d.x, 4) : customFormatForBigNumber(d.x, 4); const dy: number = Math.abs(d.y) < 10 ** 5 ? roundNumber(d.y, 4) : customFormatForBigNumber(d.y, 4); new_tooltip .style("opacity", 1) .html( `ID: ${d.id}<br/> ${axisLabels.x}: ${dx} ${axisLabels.y}: ${dy}` ) .style("left", `${event.sourceEvent.pageX + 10}px`) .style("top", `${event.sourceEvent.pageY + 10}px`); }) .on("end", (event, d: PointOnlyNumbers) => { // Delete the new tooltip d3.select(root).selectAll(".new-tooltip").remove(); const selected = d.selected; // Update the data point in the scatterdatasets scatterdatasets.sets[index_set.index_in_set].dimensional_data[ index_set.index_in_dimensional_data ].data[index_set.index_in_data_x] = d.x; scatterdatasets.sets[index_set.index_in_set].dimensional_data[ index_set.index_in_dimensional_data ].data[index_set.index_in_data_y] = d.y; // Update the visual position of the circle d3.select(event.sourceEvent.target).classed("selected", selected); // Re-render onFinish(); }); datasets.forEach((_, index) => { scatter.selectAll(`circle.dataset-${index}`).call(drag); }); } function addGridListeners( svg: d3.Selection<SVGGElement, unknown, null, undefined>, scales: Scales, dimensions: Dimensions ) { const grid_y = svg .append("g") .attr("class", "grid") .call( d3 .axisLeft(scales.y) .tickSize(-dimensions.width) .tickFormat(() => "") ) .style("stroke", "#0a0909") .style("stroke-opacity", "0.15") .style("shape-rendering", "crispEdges"); const grid_x = svg .append("g") .attr("class", "grid") .attr("transform", "translate(0," + dimensions.height + ")") .call( d3 .axisBottom(scales.x) .tickSize(-dimensions.height) .tickFormat(() => "") ) .style("stroke", "#0a0909") .style("stroke-opacity", "0.15") .style("shape-rendering", "crispEdges"); grid_x.selectAll(".domain").remove(); // Important, since the activities on point are not working grid_y.selectAll(".domain").remove(); } // 10^7 for x-axis and 10^5 for y-axis function roundNumber(num: number, decimal: number): number { // if the number with less than 4 decimal places, return the number const [_, decimalPart] = num.toString().split("."); // If there is no decimal part or it's length is less than or equal to 4, return the original number if (!decimalPart || decimalPart.length <= 2) { return num; } // else fix the number to .. decimal places return (num.toFixed(decimal) as unknown as number) > 0 ? Math.abs(num.toFixed(decimal) as unknown as number) : -Math.abs(num.toFixed(decimal) as unknown as number); } //Must delete the exponential form scince the library is not working correctly // Only linear regression and LOESS are supported, scince the runtime for other regression types is too long with many datapoints function addRegressionLine( svg: d3.Selection<SVGGElement, unknown, null, undefined>, scales: Scales, dataset: PointOnlyNumbers[], regressionType: string, color: string ): { regressionFormula: string } { let regression: d3regression.Regression | undefined; let regressionFormula = ""; let requiredPoints = 2; // Default to 2 for most regression types switch (regressionType) { case "none": return { regressionFormula }; case "linear": regression = d3regression.regressionLinear(); break; case "quadratic": requiredPoints = 3; regression = d3regression.regressionQuad(); break; /*case "polynomial": requiredPoints = 4; regression = d3regression.regressionPoly().order(3); break; case "logarithmic": regression = d3regression.regressionLog(); break; case "power": regression = d3regression.regressionPow(); break;*/ case "loess": regression = d3regression.regressionLoess().bandwidth(0.2); break; } if (!regression || dataset.length < requiredPoints) { return { regressionFormula }; } else { regression = regression.x((d) => d.x).y((d) => d.y); // Render the regression line const line = d3 .line() .x((d) => scales.x(d[0])) .y((d) => scales.y(d[1])); const regressionData = regression(dataset); svg .append("path") .attr("class", "regression-line") .datum(regressionData) .attr("fill", "none") .attr("stroke", color) .attr("stroke-width", 1.5) .attr("d", line); // Generate the regression formula string switch (regressionType) { case "linear": regressionFormula = `y = ${ Math.abs(regressionData.a) > 10 ** 7 ? customFormatForBigNumber(regressionData.a, 4) : roundNumber(regressionData.a, 4) }x ${ roundNumber(regressionData.b, 4) > 0 ? ` + ${ Math.abs(regressionData.b) > 10 ** 7 ? customFormatForBigNumber(regressionData.b, 4) : roundNumber(regressionData.b, 4) }` : ` - ${ Math.abs(regressionData.b) > 10 ** 7 ? customFormatForBigNumber(Math.abs(regressionData.b), 4) : roundNumber(Math.abs(regressionData.b), 4) }` }`; break; /*case "quadratic": regressionFormula = `y = ${roundNumber(regressionData.a, 4)}x^2 ${ roundNumber(regressionData.b, 4) > 0 ? ` + ${roundNumber(regressionData.b, 4)}x` : ` - ${Math.abs(roundNumber(regressionData.b, 4))}x` } ${ roundNumber(regressionData.c, 4) > 0 ? ` + ${roundNumber(regressionData.c, 4)}` : ` - ${Math.abs(roundNumber(regressionData.c, 4))}` }`; break; case "polynomial": regressionFormula = regressionData.coefficients .reverse() .map((coeff, i) => { const sign = coeff >= 0 ? "+" : "-"; const formattedCoeff = Math.abs(roundNumber(coeff, 4)); // Use absolute value for the coefficient const exponent = 3 - i; if (i < 3) { // Construct the term with the correct sign return ` ${exponent === 3 ? "" : sign} ${ exponent === 3 ? roundNumber(coeff, 4) : formattedCoeff }x^${exponent}`; } else { return `${sign} ${formattedCoeff}`; } }) .join(" "); regressionFormula = `y = ${regressionFormula}`; break; case "logarithmic": if ( roundNumber(regressionData.a, 4) && roundNumber(regressionData.b, 4) ) { regressionFormula = `y = ${roundNumber(regressionData.a, 4)} ln(x) ${ roundNumber(regressionData.b, 4) > 0 ? ` + ${roundNumber(regressionData.b, 4)}` : ` - ${Math.abs(roundNumber(regressionData.b, 4))}` }`; } else { regressionFormula = `y = ? ln(x) + ?`; } break; case "power": if (regressionData.a && regressionData.b) { regressionFormula = `y = ${roundNumber( regressionData.a, 4 )} x^${roundNumber(regressionData.b, 4)}`; } else { regressionFormula = `y = ? x^?`; } break;*/ case "loess": regressionFormula = `LOESS curve`; break; } } return { regressionFormula }; } function calculateCorrelation(dataset: PointOnlyNumbers[]) { const n = dataset.length; const x_mean = d3.mean(dataset.map((d) => d.x)); const y_mean = d3.mean(dataset.map((d) => d.y)); const x_std = d3.deviation(dataset.map((d) => d.x)); const y_std = d3.deviation(dataset.map((d) => d.y)); const correlation = d3.sum(dataset.map((d) => (d.x - x_mean) * (d.y - y_mean))) / (n * x_std * y_std); return correlation; } // Update the visual position of the line and points function updateLineAndPoints( svg: d3.Selection<SVGGElement, unknown, null, undefined>, scales: Scales, d: PointOnlyNumbers, index: number, indexForEndPointsLeft: number, indexForEndPointsRight: number, inverse: boolean, lineDatasets: LineDataSets ) { svg .select(`.end-point-left-${index}`) .attr("x", d[indexForEndPointsLeft].x - 5) .attr("y", d[indexForEndPointsLeft].y - 5); svg .select(`.end-point-right-${index}`) .attr("x", d[indexForEndPointsRight].x - 5) .attr("y", d[indexForEndPointsRight].y - 5); svg .select(`.center-point-${index}`) .attr("x", (d[0].x + d[1].x) / 2 - 5) .attr("y", (d[0].y + d[1].y) / 2 - 5); // Update the coordinates of the line if (!inverse) { svg .select(`.line-${index}`) .attr("x1", d[0].x) .attr("y1", d[0].y) .attr("x2", d[1].x) .attr("y2", d[1].y); lineDatasets.sets[index].data[0].x = scales.x.invert(d[0].x); lineDatasets.sets[index].data[0].y = scales.y.invert(d[0].y); lineDatasets.sets[index].data[1].x = scales.x.invert(d[1].x); lineDatasets.sets[index].data[1].y = scales.y.invert(d[1].y); } else { svg .select(`.line-${index}`) .attr("x1", d[1].x) .attr("y1", d[1].y) .attr("x2", d[0].x) .attr("y2", d[0].y); lineDatasets.sets[index].data[0].x = scales.x.invert(d[1].x); lineDatasets.sets[index].data[0].y = scales.y.invert(d[1].y); lineDatasets.sets[index].data[1].x = scales.x.invert(d[0].x); lineDatasets.sets[index].data[1].y = scales.y.invert(d[0].y); } } // Find index i of the line function findIndexForLine( scales: Scales, d: PointOnlyNumbers, lineDatasets: LineDataSets, index: number ) { for (let i = lineDatasets.sets.length - 1; i >= 0; i--) { const dataset = lineDatasets.sets[i]; if ( Math.abs(dataset.data[0].x - scales.x.invert(d[0].x)) < 0.01 && Math.abs(dataset.data[0].y - scales.y.invert(d[0].y)) < 0.01 && Math.abs(dataset.data[1].x - scales.x.invert(d[1].x)) < 0.01 && Math.abs(dataset.data[1].y - scales.y.invert(d[1].y)) < 0.01 ) { index = i; break; // stop at the first match from the end } } return index; } function calculateForRotation(d: PointOnlyNumbers, newX: number, newY: number) { // Calculate the center of the line segment const centerX = (d[0].x + d[1].x) / 2; const centerY = (d[0].y + d[1].y) / 2; // Calculate the radius and angle const dx = newX - centerX; const dy = newY - centerY; const radius = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx); // Determine the current endpoint being dragged const endIndex = d.x === d[0].x && d.y === d[0].y ? 0 : 1; const otherIndex = 1 - endIndex; // Update the dragged endpoint position d[endIndex].x = centerX + radius * Math.cos(angle); d[endIndex].y = centerY + radius * Math.sin(angle); // Update the other endpoint to maintain the length of the line d[otherIndex].x = centerX - radius * Math.cos(angle); d[otherIndex].y = centerY - radius * Math.sin(angle); return d; } // Delete the line function deleteLineListeners( event: MouseEvent, svg: d3.Selection<SVGGElement, unknown, null, undefined>, lineDatasetsWithCenterPoint: PointOnlyNumbers[][], scales: Scales, lineDatasets: LineDataSets ) { event.preventDefault(); const [clickX, clickY] = d3.pointer(event, svg.node()); const click_X = scales.x.invert(clickX); const click_Y = scales.y.invert(clickY); // Find the closest point to the recht clicked position const result = findTheClosestPoint( lineDatasetsWithCenterPoint, click_X, click_Y ); if (result.minDistance && result.closestPoint) { // Find the index i of the line let index = -1; for (let i = lineDatasetsWithCenterPoint.length - 1; i >= 0; i--) { const dataset = lineDatasetsWithCenterPoint[i]; if ( (dataset[0].x === result.closestPoint.x && dataset[0].y === result.closestPoint.y) || (dataset[1].x === result.closestPoint.x && dataset[1].y === result.closestPoint.y) || (dataset[2].x === result.closestPoint.x && dataset[2].y === result.closestPoint.y) ) { index = i; break; // stop at the first match from the END } } if (index !== -1) { lineDatasets.sets.splice(index, 1); } } } function deletePointListeners( event: MouseEvent, svg: d3.Selection<SVGGElement, unknown, null, undefined>, datasets: PointOnlyNumbers[][], scatterdatasets: ScatterDatasets, scales: Scales ) { event.preventDefault(); const scatterplotdatasets = getScatterplotDataSets(scatterdatasets); const [clickX, clickY] = d3.pointer(event, svg.node()); const click_X = scales.x.invert(clickX); const click_Y = scales.y.invert(clickY); // Find the closest point to the recht clicked position const result = findTheClosestPoint(datasets, click_X, click_Y); if (result.minDistance && result.closestPoint) { // Find the index of the scatterplot dataset let datasetIndex = findTheLastMatchIndexInSPDatasets( result.closestPoint, scatterplotdatasets ); if (datasetIndex !== -1) { const dimensionalData = scatterplotdatasets.sets[datasetIndex].dimensional_data; //Find the index of the point in the dataset let index_in_dimensional_data = findTheLastMatchIndexInSPDataset( result.closestPoint, dimensionalData ); if (index_in_dimensional_data !== -1) { // Remove the point from the original scatterdatasets scatterdatasets.sets.forEach((set, index) => { if ( index === scatterplotdatasets.sets[datasetIndex].index_in_scatterdatasets ) { const index_x = set.labels.indexOf(scatterdatasets.selected.axis.x); const index_y = set.labels.indexOf(scatterdatasets.selected.axis.y); set.dimensional_data = set.dimensional_data.filter( (dimensional_data) => dimensional_data.data[index_x] !== scatterplotdatasets.sets[datasetIndex].dimensional_data[ index_in_dimensional_data ].x || dimensional_data.data[index_y] !== scatterplotdatasets.sets[datasetIndex].dimensional_data[ index_in_dimensional_data ].y ); set.ids = set.ids.filter( (id) => id !== scatterplotdatasets.sets[datasetIndex].ids[ index_in_dimensional_data ] ); } }); } } } } function addPointListeners( event: MouseEvent, svg: d3.Selection<SVGGElement, unknown, null, undefined>, scales: Scales, scatterdatasets: ScatterDatasets, selectedIndextoAddNode: number ) { //const scatterplotdatasets = getScatterplotDataSets(scatterdatasets); const [clickX, clickY] = d3.pointer(event, svg.node()); const newX = scales.x.invert(clickX); const newY = scales.y.invert(clickY); // Add the new point to the dataset scatterdatasets.sets.forEach((set, index) => { if (index === selectedIndextoAddNode) { const index_x = set.labels.indexOf(scatterdatasets.selected.axis.x); const index_y = set.labels.indexOf(scatterdatasets.selected.axis.y); // Prepare the new data point for the original dataset co