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
text/typescript
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