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,192 lines 57.7 kB
import * as d3 from "d3"; import * as d3regression from "d3-regression"; import { generateColorWheel, randomColor } from "../../../functions"; function getScatterplotDataSets(scatterdatasets) { const 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], y: dimensional_data.data[index_y], 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 calculateSVGDimensions(data, line_datasets) { // get the max value of the dataset let maxX = 0; let maxY = 0; let minX = 0; let minY = 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); }); 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); }); const roundingFactorX = 10 ** Math.floor(Math.log10(maxX)); maxX = Math.ceil(maxX / roundingFactorX) * roundingFactorX; const roundingFactorY = 10 ** Math.floor(Math.log10(maxY)); maxY = Math.ceil(maxY / roundingFactorY) * roundingFactorY; // Round the min if the min is negative const roundingFactorMinX = 10 ** Math.floor(Math.log10(Math.abs(minX))); const roundingFactorMinY = 10 ** Math.floor(Math.log10(Math.abs(minY))); minX < 0 ? (minX = Math.floor(minX / roundingFactorMinX) * roundingFactorMinX) : null; minY < 0 ? (minY = Math.floor(minY / roundingFactorMinY) * roundingFactorMinY) : null; // set the dimensions and margins of the graph const margin = { top: 10, right: 30, bottom: 50, left: 70 }, width = 550 - margin.left - margin.right, height = 400 - margin.top - margin.bottom; return { height, width, margin, min: { x: minX, y: minY }, max: { x: maxX, y: maxY }, }; } function createSVG(parent, width, height, margin) { // 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 + ")"); // TODO: check if this is used / needed // 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; } function drawAxis(svg, height, width, margin, axisLabels, scales, maxX, maxY, minX, minY) { // Add X axis // 3: < 10^12 // 4: from 10^6 to 8*10^9 // 5: from 10^3 to 10^6 let s_x = -1; //TODO: idea for y axis let gx = svg.append("g").attr("transform", "translate(0," + height + ")"); if (maxX - minX > 10 ** 3 && maxX - minX <= 10 ** 4) { s_x = 5; } else if (maxX - minX > 10 ** 4 && maxX - minX <= 8 * 10 ** 9) { s_x = 4; } else if (maxX - minX > 8 * 10 ** 9 && maxX - minX <= 10 ** 12) { s_x = 3; } else if (maxX - minX > 10 ** 12 && maxX - minX <= 4 * 10 ** 16) { s_x = 2; } else if (maxX - minX > 4 * 10 ** 16 && maxX - minX <= 10 ** 17) { s_x = 1; } else if (maxX - minX > 10 ** 17 && maxX - minX <= 10 ** 18) { s_x = 2; } else if (maxX - minX > 10 ** 18) { s_x = 1.5; } s_x === -1 ? gx.call(d3.axisBottom(scales.x)) : gx.call(d3.axisBottom(scales.x).tickArguments([s_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); // Add Y axis const gy = svg.append("g"); let s_y = -1; if (maxY - minY > 10 ** 3 && maxY - minY <= 10 ** 4) { s_y = 5; } else if (maxY - minY > 10 ** 4 && maxY - minY <= 8 * 10 ** 9) { s_y = 4; } else if (maxY - minY > 8 * 10 ** 9 && maxY - minY <= 10 ** 12) { s_y = 3; } else if (maxY - minY > 10 ** 12 && maxY - minY <= 4 * 10 ** 16) { s_y = 2; } else if (maxY - minY > 4 * 10 ** 16 && maxY - minY <= 10 ** 17) { s_y = 1; } else if (maxY - minY > 10 ** 17 && maxY - minY <= 10 ** 18) { s_y = 2; } else if (maxY - minY > 10 ** 18) { s_y = 1.5; } s_y === -1 ? gy.call(d3.axisLeft(scales.y)) : gy.call(d3.axisLeft(scales.y).tickArguments([s_y])); svg .append("text") .attr("class", "y label") .attr("text-anchor", "middle") .attr("x", -((height + margin.bottom) / 2)) // to my x axis .attr("y", -40) // to my y axis .attr("transform", "rotate(-90)") .text(axisLabels.y); return { gx, gy }; } function createTooltip(parent) { // 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 drawScatterplot(root, datasets, axisLabels, options, line_datasets) { const dimensions = calculateSVGDimensions(datasets, line_datasets); 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); const tooltip = createTooltip(root); // For normal scatter plot const scatter = svg.append("g"); // Draw points in the scatter plot 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}`) .on("mouseover", function (_e, d) { if (options.hoverCursorChange) { d3.select(this).attr("r", 8); // Change the cursor to move d3.select(this).style("cursor", "move"); } else if (options.animation) { options.hoverTooltip ? d3.select(this).attr("r", 8) : null; } // Show tooltip if (options.hoverTooltip) { tooltip .style("opacity", 1) .html("ID: " + d.id + "<br/>" + axisLabels.x + ": " + d.x + " " + axisLabels.y + ": " + d.y); } }) .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 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) => `${d.x}-${d.y}`) // Assuming x and y uniquely identify each circle .style("fill", "#f9080c") .style("opacity", 1) .on("mouseover", (event, d) => { tooltip .style("opacity", 1) .html(`${datasetLabel ? "Outlier of " + datasetLabel : "Outlier"}<br>${axisLabels.x}: ${d.x} ${axisLabels.y}: ${d.y}`) .style("left", `${event.pageX + 10}px`) .style("top", `${event.pageY + 10}px`); }) .on("mouseleave", () => { tooltip.transition().duration(200).style("opacity", 0); }); } } return { svg, scales, scatter, dimensions, tooltip }; } function findIndexForPoint(d, scatterdatasets, compareSelected) { // 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, dataset, index, 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("stroke", "back") .style("stroke-width", 0.5) .style("opacity", 1) .classed("selected", (d) => d.selected); } // Function to check if a point is brushed function isBrushed(brush_coords, cx, cy) { 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, scales, onFinish) { let isDrawing = false; let line; let x1, y1, x2, y2; 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) }, ]; 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, scatter, dimensions, scales, scatterdatasets, onFinish, type) { 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) { // 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) { if (!brushingEnabled) return; // Exit if brushing is disabled const extent = event.selection; if (extent) { scatter.selectAll("circle").each(function (d) { 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 = { 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, click_X, click_Y) { let minDistance = Infinity; let closestPoint = null; const flattenedDatasets = datasets.flat(); for (let i = flattenedDatasets.length - 1; i >= 0; i--) { const point = flattenedDatasets[i]; const distance = Math.sqrt((point.x - click_X) ** 2 + (point.y - click_Y) ** 2); if (distance < minDistance) { minDistance = distance; closestPoint = point; } } return { closestPoint, minDistance }; } // Iterate through the scatterplot datasets and return the last match index function findTheLastMatchIndexInSPDatasets(closestPoint, datasets) { let datasetIndex = -1; for (let i = datasets.sets.length - 1; i >= 0; i--) { const dataset = datasets.sets[i]; if (dataset.scatter_titel === closestPoint.scatter_titel && dataset.ids.includes(closestPoint.id) && 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, dimensionalData) { //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(svg, scatter, scales, scatterdatasets, datasets, onFinish) { let index_set = { index_in_set: -1, index_in_dimensional_data: -1, index_in_data_x: -1, index_in_data_y: -1, }; const drag = d3 .drag() .on("start", (event, d) => { d3.select(event.sourceEvent.target).classed("selected", true); index_set = findIndexForPoint(d, scatterdatasets, true); }) .on("drag", (event, d) => { // 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) { d3.select(this).attr("cx", scales.x(d.x)).attr("cy", scales.y(d.y)); }); }) .on("end", (event, d) => { 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, scales, 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(); } //TODO: Fix any type function addRegressionLine(svg, scales, dataset, regressionType) { let regression; let regressionFormula = ""; let regressionError = false; switch (regressionType) { case "none": break; case "linear": regression = d3regression.regressionLinear(); break; case "quadratic": regression = d3regression.regressionQuad(); break; case "exponential": regression = d3regression.regressionExp(); break; case "polynomial": 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; } regression = regression.x((d) => d.x).y((d) => d.y); const line = d3 .line() .x((d) => scales.x(d[0])) .y((d) => scales.y(d[1])); svg .append("path") .datum(regression(dataset)) .attr("fill", "none") .attr("stroke", dataset[0]?.color ?? "#69b3a2") .attr("stroke-width", 1.5) .attr("d", line); const regressionData = regression(dataset); if (regressionData) { switch (regressionType) { case "none": break; case "linear": regressionError = dataset.length < 2; if (regressionError) regressionFormula = "Cannot perform linear regression: Insufficient data points."; else regressionFormula = `y = ${regressionData.a.toFixed(2)}x + ${regressionData.b.toFixed(2)}`; break; case "quadratic": regressionError = dataset.length < 3; if (regressionError) regressionFormula = "Cannot perform quadratic regression: Insufficient data points."; else regressionFormula = `y = ${regressionData.a.toFixed(2)}x^2 + ${regressionData.b.toFixed(2)}x + ${regressionData.c.toFixed(2)}`; break; case "exponential": regressionError = dataset.length < 2; if (regressionError) regressionFormula = "Cannot perform exponential regression: Insufficient data points."; else regressionFormula = `y = ${regressionData.a.toFixed(2)}e^(${regressionData.b.toFixed(2)}x)`; break; case "polynomial": regressionError = dataset.length < 4; if (regressionError) { regressionFormula = "Cannot perform polynomial regression: Insufficient data points."; break; } let coeffients = regressionData.coefficients; let formula = "y = "; for (let i = 0; i < coeffients.length; i++) { formula += coeffients[i].toFixed(2) + "x^" + (3 - i) + " + "; } regressionFormula = formula.slice(0, -2); break; case "logarithmic": regressionError = dataset.length < 2; if (regressionError) regressionFormula = "Cannot perform logarithmic regression: Insufficient data points."; else regressionFormula = `y = ${regressionData.a.toFixed(2)}ln(x) + ${regressionData.b.toFixed(2)}`; break; case "power": regressionError = dataset.length < 2; if (regressionError) regressionFormula = "Cannot perform power regression: Insufficient data points."; else regressionFormula = `y = ${regressionData.a.toFixed(2)}x^${regressionData.b.toFixed(2)}`; break; case "loess": regressionError = dataset.length < 2; if (regressionError) regressionFormula = "Cannot perform loess regression: Insufficient data points."; else regressionFormula = `LOESS curve`; break; } } return { regressionFormula, regressionError }; } function calculateCorrelation(dataset) { 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, scales, d, index, indexForEndPointsLeft, indexForEndPointsRight, inverse, 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, d, lineDatasets, index) { 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, newX, newY) { // 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, svg, lineDatasetsWithCenterPoint, scales, 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, svg, datasets, scatterdatasets, 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); } }); } } } } function addPointListeners(event, svg, scales, scatterdatasets, selectedIndextoAddNote) { //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 /*scatterplotdatasets.sets[selectedIndextoAddNote].dimensional_data.push({ x: newX, y: newY, selected: false, }); //TODO: Fix this scatterplotdatasets.sets[selectedIndextoAddNote].ids.push(1);*/ // Add the new point to the original scatterdatasets scatterdatasets.sets.forEach((set, index) => { if (scatterdatasets.selected.dataset_indexes.indexOf(index) === selectedIndextoAddNote) { 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 const newPointData = set.typeOfEachData.map((label, index) => { if (index === index_x) { return newX; } else if (index === index_y) { return newY; } else if (label === "number") { return 0; // or some default value } else { return "string"; // or some default value } }); set.dimensional_data.push({ data: newPointData, selected: false, }); set.ids.push(set.ids.length + 1); } }); } function selectPointByClickingListeners(event, svg, datasets, scales, scatterDatasets) { 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) { 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.map((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 ? (dimensional_data.selected = !dimensional_data.selected) : null); } }); } } } } function updateColorForFilter(i, type, selectedDataset) { const listofstringsforlabels = []; const labelsandcolors = []; const rangesandcolors = []; // find the min and max of the data let min = Infinity; let max = -Infinity; let ranges = []; let parts = 5; // find the labels and colors for the data (5 colors) between min and max if (type === "number") { selectedDataset.dimensional_data.forEach((d) => { if (Math.floor(d.data[i]) < min) { min = Math.floor(d.data[i]); } if (Math.ceil(d.data[i]) > max) { max = Math.ceil(d.data[i]); } }); const range_value = max - min; if (range_value < 5) { parts = range_value; for (let i = 0; i < parts + 2; i++) { ranges.push(min + i); } } else { let step = Math.ceil(range_value / parts); for (let i = 0; i < parts + 1; i++) { ranges.push(min + i * step); } } const colors = generateColorWheel(5); for (let i = 0; i < ranges.length - 1; i++) { rangesandcolors.push({ range: { min: ranges[i], max: ranges[i + 1] }, color: colors[i], }); } selectedDataset.filter.rangesandcolors = rangesandcolors; selectedDataset.dimensional_data.forEach((d) => { selectedDataset.filter.rangesandcolors.forEach((rangeandcolor, index) => { if (index === rangesandcolors.length - 1 ? d.data[i] >= rangeandcolor.range.min && d.data[i] <= rangeandcolor.range.max : d.data[i] >= rangeandcolor.range.min && d.data[i] < rangeandcolor.range.max) { d.color = rangeandcolor.color; } }); }); } if (type === "string") { selectedDataset.dimensional_data.forEach((d) => { if (!listofstringsforlabels.includes(d.data[i])) { listofstringsforlabels.push(d.data[i]); labelsandcolors.push({ label: d.data[i], color: randomColor(), }); } }); const colorss = generateColorWheel(labelsandcolors.length); labelsandcolors.forEach((labelandcolor, index) => { labelandcolor.color = colorss[index]; }); selectedDataset.filter.labelsandcolors = labelsandcolors; selectedDataset.dimensional_data.forEach((d) => { selectedDataset.filter.labelsandcolors.forEach((labelandcolor) => { if (d.data[i] === labelandcolor.label) { d.color = labelandcolor.color; } }); }); } } /* */ // get the max value of the dataset function calculateSVGDimensions_singleDataset(data_for_x_axis_copy, data_for_y_axis_copy, x_axis_type, y_axis_type) { let maxX = 0; let maxY = 0; let minX = 0; let minY = 0; if (x_axis_type === "number") { data_for_x_axis_copy.forEach((dataset) => { maxX = Math.max(maxX, Number(dataset.x)); minX = Math.min(minX, Number(dataset.x)); }); } if (y_axis_type === "number") { data_for_y_axis_copy.forEach((dataset) => { maxY = Math.max(maxY, Number(dataset.y)); minY = Math.min(minY, Number(dataset.y)); }); } const roundingFactorX = 10 ** Math.floor(Math.log10(maxX)); maxX = Math.ceil(maxX / roundingFactorX) * roundingFactorX; const roundingFactorY = 10 ** Math.floor(Math.log10(maxY)); maxY = Math.ceil(maxY / roundingFactorY) * roundingFactorY; // Round the min if the min is negative const roundingFactorMinX = 10 ** Math.floor(Math.log10(Math.abs(minX))); const roundingFactorMinY = 10 ** Math.floor(Math.log10(Math.abs(minY))); minX < 0 ? (minX = Math.floor(minX / roundingFactorMinX) * roundingFactorMinX) : null; minY < 0 ? (minY = Math.floor(minY / roundingFactorMinY) * roundingFactorMinY) : null; // set the dimensions and margins of the graph const margin = { top: 10, right: 30, bottom: 50, left: 70 }, width = 550 - margin.left - margin.right, height = 400 - margin.top - margin.bottom; return { height, width, margin, min: { x: minX, y: minY }, max: { x: maxX, y: maxY }, }; } function createSVG_SingleDataset(parent, width, height, margin) { // 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 + ")"); return svg; } function drawAxis_singleDataset(svg, height, width, margin, x_axis_type, y_axis_type, data_for_x_axis_copy, data_for_y_axis_copy, minX, minY, maxX, maxY, axisLabels) { // Add Gridlines const addGrid = (axis, scale, dimension, transform, offset) => { const grid = svg .append("g") .attr("class", "grid") .attr("transform", transform) .call(axis(scale) .tickSize(-dimension) .tickFormat(() => "") .tickSizeOuter(0)); grid .selectAll(".tick line") .attr("transform", `translate(${offset})`) .attr("stroke", "lightgrey"); grid.selectAll(".domain").remove(); }; // Add X axis let x_bandwidth = 0; let y_bandwidth = 0; let x; if (x_axis_type !== "none" && data_for_x_axis_copy.length > 0) { x = x_axis_type === "number" ? d3.scaleLinear().domain([minX, maxX]).range([0, width]) : d3 .scaleBand() .domain(data_for_x_axis_copy.map((d) => d.x)) .range([0, width]) .align(0) .paddingOuter(0) .paddingInner(0); if (x_axis_type === "number") { svg .append("g") .attr("transform", "translate(0," + height + ")") .call(d3.axisBottom(x)); } else { let axis_x = svg .append("g") .attr("transform", "translate(0," + height + ")") .call(d3.axisBottom(x)); x_bandwidth = x.bandwidth(); if (y_axis_type !== "none") { if (x_axis_type === "string" && y_axis_type === "number") { axis_x .selectAll(".tick text") .attr("transform", `translate(${-x.bandwidth() / 2}, 0)`); svg.selectAll(".tick line").remove(); } } } 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); // Add Gridlines if (!(x_axis_type === "number")) { addGrid(d3.axisBottom, x, height, `translate(0, ${height})`, `${-x.bandwidth() / 2}, 0`); } } // Add Y axis let y; if (!(y_axis_type === "none") && data_for_y_axis_copy.length > 0) { y = y_axis_type === "number" ? d3.scaleLinear().domain([minY, maxY]).range([height, 0]) : d3 .scaleBand() .domain(data_for_y_axis_copy.map((d) => d.y)) .range([height, 0]) .padding(0) .paddingOuter(0); if (y_axis_type === "number") { svg.append("g").call(d3.axisLeft(y)); } else { let axis_y = svg.append("g").call(d3.axisLeft(y)); y_bandwidth = y.bandwidth(); if (y_axis_type === "string" && x_axis_type === "number") { axis_y .selectAll(".tick text") .attr("transform", `translate( 0, ${y.bandwidth() / 2})`); svg.selectAll(".tick line").remove(); } } svg .append("text") .attr("class", "y label") .attr("text-anchor", "middle") .attr("x", -((height + margin.bottom) / 2)) // to my x axis .attr("y", -40) // to my y axis .attr("transform", "rotate(-90)") .text(axisLabels.y); // Add Gridlines if (!(y_axis_type === "number")) { addGrid(d3.axisLeft, y, width, null, `0, ${y.bandwidth() / 2}`); } } if (!(x_axis_type === "none") && !(y_axis_type === "none")) { if (y_axis_type === "string" && x_axis_type === "string") { x_bandwidth = x.bandwidth(); y_bandwidth = y.bandwidth(); addGrid(d3.axisLeft, y, width, null, `0, ${y.bandwidth() / 2}`); addGrid(d3.axisBottom, x, height, `translate(0, ${height})`, `${-x.bandwidth() / 2}, 0`); } } svg.selectAll(".domain, .tick line").attr("stroke", "lightgrey"); return { x_bandwidth, y_bandwidth }; } function drawScatterplot_singleDataset(root, data_for_x_axis_copy, data_for_y_axis_copy, x_axis_type, y_axis_type, axisLabels) { const dimensions = calculateSVGDimensions_singleDataset(data_for_x_axis_copy, data_for_y_axis_copy, x_axis_type, y_axis_type); const svg = createSVG_SingleDataset(root, dimensions.width, dimensions.height, dimensions.margin); const scales = { x_num: d3 .scaleLinear() .domain([dimensions.min.x, dimensions.max.x]) .range([0, dimensions.width]), y_num: d3 .scaleLinear() .domain([dimensions.min.y, dimensions.max.y]) .range([dimensions.height, 0]), x_band: d3 .scaleBand() .domain(data_for_x_axis_copy.map((d) => d.x)) .range([0, dimensions.width]), y_band: d3 .scaleBand() .domain(data_for_y_axis_copy.map((d) => d.y)) .range([dimensions.height, 0]), x_none: d3 .scaleLinear() .domain([0, dimensions.width]) .range([0, dimensions.width]), y_none: d3 .scaleLinear() .domain([0, dimensions.height]) .range([dimensions.height, 0]), }; const bandwidth_result = drawAxis_singleDataset(svg, dimensions.height, dimensions.width, dimensions.margin, x_axis_type, y_axis_type, data_for_x_axis_copy, data_for_y_axis_copy, dimensions.min.x, dimensions.min.y, dimensions.max.x, dimensions.max.y, axisLabels); console.log("Wnn" + bandwidth_result.x_bandwidth + bandwidth_result.y_bandwidth); return { svg, scales, dimensions, bandwidth_result }; } function drawScatterplot_singleDataset2(root, svg, x_axis_type, y_axis_type, datasets, singeledatasets, scales, dimensions, bandwidth_result, options, axisLabels) { const tooltip = createTooltip(root); const scatter = svg.append("g"); // if one of the axis is string and there are multiple value of the other axis, those values are the same, then the circles will be on top of each other if (!(x_axis_type === "none") && !(y_axis_type === "none")) { datasets.forEach((d, i) => { scatter .append("circle") .attr("class", `dataset-${i}`) .attr("cx", x_axis_type === "number" ? scales.x_num(d.x) : y_axis_type === "number" ? scales.x_band(d.x) + 6 + d.offset : scales.x_band(d.x) + bandwidth_result.x_bandwidth / 2) // 6 is the radius of the circle .attr("cy", y_axis_type === "number" ? scales.y_num(d.y) : scales.y_band(d.y) - 6 - d.offset + bandwidth_result.y_bandwidth) .attr("r", 6) .style("fill", d.color ?? "#417ca1") .style("opacity", 1) .style("stroke", "black") .style("stroke-width", 0.5) .classed("selected", d.selected ?? false); scatter .selectAll(`circle.dataset-${i}`) .on("mouseover", function () { if (options.hoverCursorChange && !(x_axis_type === "string" && y_axis_type === "string")) { d3.select(this).attr("r", 7); // Change the cursor to move d3.select(this).style("cursor", "move"); } // Show tooltip if (options.hoverTooltip) { tooltip .style("opacity", 1) .html("ID: " + d.id + "<br>" + axisLabels