Public
Edited
Jan 24, 2023
2 forks
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
ldaPlot = buildPlot(data0); //
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function buildPlot(data) {
///////////////////// SETUP /////////////////////
// Create internal data array (scoped within this function)
let idCounter = 0; // To give each data point a unique id
const accesX = ([x]) => x; // accessor function for x-coordinate
const accesY = ([, y]) => y; // accessor function for y-coordinate
const accesType = ([, , type]) => type; // accessor function for type
// There is probably a more elegant way to set up data
data = [{xCoord: null, yCoord: null, type: null, id: null}];
console.log("init");
data = data.map((d) => ({
xCoord: d.xCoord,
yCoord: d.yCoord,
type: d.type,
id: idCounter++
}));
data.pop(); // make the object empty
// Store copy of initial data object
const initData = data.map((d) => ({ ...d }));

///////////////////// DRAW SVG AND AXES /////////////////////
// Draw svg
const svg = d3
.create("svg")
.attr("width", cfg.width)
.attr("height", cfg.height)
.attr("viewBox", [0, 0, cfg.width, cfg.height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.on("mousemove", mouseOver);
function mouseOver(event) {
let [x, y] = d3.pointer(event);
x = cfg.xScale.invert(x);
y = cfg.yScale.invert(y);

calculateLdaDelta(x, y, data);
d3.select("#xDisplay").text(Math.round(x*100)/100);
d3.select("#yDisplay").text(Math.round(y*100)/100);
}
// Draw Axes
drawAxes(svg)
// Set up variances svg
let varianceRects = svg
.append('g')
.attr('id', 'variancesRectGroup')
.attr('display', showParameterCheckbox.value.indexOf("Variances") !== -1 ? 'block' : 'none' )
.selectAll('rect');

// Set up means svg
let meansSvg = svg
.append("g")
.attr('id', 'meanLineGroup')
.attr('display', showParameterCheckbox.value.indexOf("Means") !== -1 ? 'block' : 'none' );

// Draw space for plot interactions
const plotRect = svg
.append("rect")
.attr("width", width)
.attr("height", height)
.attr("opacity", 0)
.on("click", addDatapointByClick);

// Set up circles svg
let circles = svg
.append("g")
.selectAll("circle");
runStartingAnimation(data);
function runStartingAnimation (data) {
// Perform opening animation
const totalTime = 3_000;
const ease = d3.easeLinear;
console.log("run starting animation");
d3.range(0, data0.length).forEach((i) => {;
setTimeout(() => {
addDatapoint(data0[i]['xCoord'], data0[i]['yCoord'], data0[i]['type']);
}, totalTime * ease(i / data0.length));
});

// from here https://dev.to/codebubb/how-to-shuffle-an-array-in-javascript-2ikj
function shuffle(array) {
const newArray = [...array]
const length = newArray.length
for (let start = 0; start < length; start++) {
const randomPosition = Math.floor((newArray.length - start) * Math.random())
const randomItem = newArray.splice(randomPosition, 1)
newArray.push(...randomItem)
}
return newArray
}
}
///////////////////// DRAG /////////////////////
// Init drag object.
const drag = d3
.drag()
.on("drag", dragged)
.on("start", dragstarted)
.on("end", dragended);
// Drag interactions for circles.
function dragstarted(event, d) {
d3.select(this).raise().attr("stroke", "black");
}
function dragged(event, d) {
d3.select(this)
// Update data point, as well as its position on the plot
.attr("cx", () => {
d.xCoord = cfg.xScale.invert(event.x);
return event.x;
})
.attr("cy", () => {
d.yCoord = cfg.yScale.invert(event.y);
return event.y;
});
varianceRects = updateVariances(data, varianceRects);
drawDecisionBoundary(data, svg);
drawMeans(data, meansSvg);
}
function dragended(event, i) {
d3.select(this).attr("stroke", 'null');
}


///////////////////// MAIN FUNCTIONS /////////////////////

// Click interaction for plot.
function addDatapointByClick(event) {
const [xm, ym] = d3.pointer(event);
console.log("add datapoint by click");
let type = -1;
if (addDataOptions.value == "Random") {
type = Math.round(d3.randomUniform()());
} else if (addDataOptions.value == "Red") {
type = 0;
} else if (addDataOptions.value == "Blue") {
type = 1;
}

addDatapoint(cfg.xScale.invert(xm), cfg.yScale.invert(ym), type);
}
// Adds a new datapoint and updates the plot
function addDatapoint(xCoord, yCoord, type) {
// Add datapoint
const newValue = { xCoord: xCoord, yCoord: yCoord, type: type, id: idCounter++ };
data = [...data, newValue];
update(data);
}

// Click interaction for circles.
function removeDatapoint(event, dCurr) {
if (event.defaultPrevented) return; // dragged

// Remove data point; faster way to do this?
data = data.filter((d) => d.id !== dCurr.id);

update(data);
}
// Resets the plot to the initial data
function reset() {
update(initData, true);
}

// Main function that updates the plot based on new data
function update(newData = data) {
// Upate local data object
data = newData.map((d) => ({ ...d }));

varianceRects = updateVariances(data, varianceRects);
circles = updateCircles(data, circles, drag, removeDatapoint);
drawDecisionBoundary(data, svg);
drawMeans(data, meansSvg);

}
return Object.assign(svg.node(), {
update,
reset
});
}
Insert cell
Insert cell
// Helper function to update circles based on new data.
function updateCircles(data, circles, drag, removeDatapoint) {
// circles: existing circles in the svg
// data: data set, which gets compared here with the existing circles
return circles
.data(data, (d) => d.id)
.join( //https://bost.ocks.org/mike/join/
(enter) =>
enter
.append("circle")
.attr("cx", (d) => cfg.xScale(d.xCoord))
.attr("cy", (d) => cfg.yScale(d.yCoord))
.attr("fill", (d) => d.type == 0 ? d3.schemeSet3[3] : d3.schemeSet3[4])
.attr("opacity", cfg.circleOpacity)
// To transition from 0 radius
.attr("r", cfg.r) // no transition
//.attr("r", 0) // transition
// Attach interactions
.call(drag)
.on("click", removeDatapoint)

// Add transition
// #* Somehow doesn't work correctly. For testing this set attr("r", 0) above
.attr("r", 0) // transition
.call((enter) =>
enter
.transition()
.duration(300)
.ease(d3.easeBackOut.overshoot(1.7))
.attr("r", cfg.r)
)
,
(update) =>
update
.transition()
.attr("cx", (d) => cfg.xScale(d.xCoord))
.attr("cy", (d) => cfg.yScale(d.yCoord)),
(exit) =>
exit
.transition()
.ease(d3.easeBackIn.overshoot(1.7))
.attr("r", 0)
.remove()
);
}
Insert cell
Insert cell
function calculateLineCoefficients (data) {

function calculatePriors() {
const type0prior = DATA_PARAMETERS.type0.numPoints/
(DATA_PARAMETERS.type0.numPoints + DATA_PARAMETERS.type1.numPoints);
const type1prior = DATA_PARAMETERS.type1.numPoints/
(DATA_PARAMETERS.type0.numPoints + DATA_PARAMETERS.type1.numPoints);
return {type0prior, type1prior}
}
function estimatedBoundary() {
// calculates the coefficients beta0 and beta1 based on estimated distribution values
const [type0Mean, type1Mean, covMatrix] = calculateLdaEstimates(data);
const [v0, v1, z0, z1, , ] = calculateIntermediateValues(type0Mean, type1Mean, covMatrix);
const [beta0, beta1] = calculateBeta(v0, v1, z0, z1);
return [beta0, beta1];
}

function bayesBoundary() {
// calculates the coefficients beta0 and beta1 based on actual distribution values
const {type0prior, type1prior} = calculatePriors();
const type0Mean = math.matrix([
[DATA_PARAMETERS.type0.x.mean],
[DATA_PARAMETERS.type0.y.mean],
]);
const type1Mean = math.matrix([
[DATA_PARAMETERS.type1.x.mean],
[DATA_PARAMETERS.type1.y.mean],
]);

const XVariance = (
DATA_PARAMETERS.type0.x.variance * type0prior +
DATA_PARAMETERS.type1.x.variance * type1prior);
const YVariance = (
DATA_PARAMETERS.type0.y.variance * type0prior +
DATA_PARAMETERS.type1.y.variance * type1prior);
const OverallXMean = DATA_PARAMETERS.type0.x.mean*type0prior + DATA_PARAMETERS.type1.x.mean*type1prior;
const OverallYMean = DATA_PARAMETERS.type0.y.mean*type0prior + DATA_PARAMETERS.type1.y.mean*type1prior;
const XYcovariance = 0;
// DATA_PARAMETERS.type0.numPoints * (DATA_PARAMETERS.type0.x.mean - OverallXMean) * (- OverallYMean) +
// DATA_PARAMETERS.type1.numPoints * ( - OverallXMean) * (- OverallYMean) ) / (DATA_PARAMETERS.type0.numPoints + DATA_PARAMETERS.type1.numPoints);
const covMatrix = math.matrix([
[XVariance, XYcovariance],
[XYcovariance, YVariance],
]);

const [v0, v1, z0, z1, , ] = calculateIntermediateValues(type0Mean, type1Mean, covMatrix);
const [beta0, beta1] = calculateBeta(v0, v1, z0, z1);
return [beta0, beta1];
}

function calculateIntermediateValues(type0Mean, type1Mean, covMatrix) {
// intermediate values for final formula
const {type0prior, type1prior} = calculatePriors();
const logPrior0 = Math.log(type0prior);
const logPrior1 = Math.log(type1prior);
if (data.length <= 2) {return [math.matrix([[0],[0]]), math.matrix([[0],[0]]), math.matrix([[0]]), math.matrix([[0]]), logPrior0, logPrior1]} // covMatrix has only 1 obs, so is 0 and inverse matrix cannot be calculated. For some reason the inverse is also not possible when length = 2
const v0 = math.multiply(math.inv(covMatrix), type0Mean);
const z0 = math.multiply(-0.5, (math.multiply(math.transpose(type0Mean), math.multiply(math.inv(covMatrix), type0Mean))));
const v1 = math.multiply(math.inv(covMatrix), type1Mean);
const z1 = math.multiply(-0.5, (math.multiply(math.transpose(type1Mean), math.multiply(math.inv(covMatrix), type1Mean))));

if (!(getDimensionOfMatrix(v0) == [2,1])
&& !(getDimensionOfMatrix(v1) == [2,1])
&& !(getDimensionOfMatrix(z0) == [1,1])
&& !(getDimensionOfMatrix(z1) == [1,1])) {
new Error('Dimension of Matrix not correct :/');
}
return [v0, v1, z0, z1, logPrior0, logPrior1];
}

function calculateBeta(v0, v1, z0, z1) {
// calculate lda line coefficients
const {type0prior, type1prior} = calculatePriors();
const logPrior0 = Math.log(type0prior);
const logPrior1 = Math.log(type1prior);

const beta0 = (((z1.get([0,0]) - z0.get([0,0]) + logPrior1 - logPrior0))/
(v0.get([1,0]) - v1.get([1,0])));
const beta1 = (v1.get([0,0]) - v0.get([0,0]))/
(v0.get([1,0]) - v1.get([1,0]));
return [beta0, beta1];
}
return {
estimatedBoundary,
bayesBoundary,
calculateIntermediateValues,
}
}

Insert cell
function calculateLdaEstimates(data){
// calculate type0Mean, type1Mean and the covariance matrix to further process in other functions
const numberOfTypes = 2; // might change that in the future
const type0Data = data.filter((d) => d.type === 0);
const type1Data = data.filter((d) => d.type === 1);
const type0prior = type0Data.length/(data.length);
const type1prior = type1Data.length/(data.length);
// Mean and variance for type 0 x-coordinates
const type0MeanX = type0Data.reduce((acc, val) => acc + val.xCoord, 0) / type0Data.length;
const type0VarianceX = type0Data.reduce((acc, val) => acc + (val.xCoord - type0MeanX) ** 2, 0) / type0Data.length;
// Mean and variance for type 0 y-coordinates
const type0MeanY = type0Data.reduce((acc, val) => acc + val.yCoord, 0) / type0Data.length;
const type0VarianceY = type0Data.reduce((acc, val) => acc + (val.yCoord - type0MeanY) ** 2, 0) / type0Data.length;
// Mean and variance for type 1 x-coordinates
const type1MeanX = type1Data.reduce((acc, val) => acc + val.xCoord, 0) / type1Data.length;
const type1VarianceX = type1Data.reduce((acc, val) => acc + (val.xCoord - type1MeanX) ** 2, 0) / type1Data.length;
// Mean and variance for type 1 y-coordinates
const type1MeanY = type1Data.reduce((acc, val) => acc + val.yCoord, 0) / type1Data.length;
const type1VarianceY = type1Data.reduce((acc, val) => acc + (val.yCoord - type1MeanY) ** 2, 0) / type1Data.length;

// Common variances across classes
// not the same as variance disregarding types (VarianceTotal)
// not the same as class-specific variances (type0/1Variance)
const XVariance = (type0Data.reduce((acc, val) => acc + (val.xCoord - type0MeanX) ** 2, 0) + type1Data.reduce((acc, val) => acc + (val.xCoord - type1MeanX) ** 2, 0)) / (data.length - numberOfTypes);
const YVariance = (type0Data.reduce((acc, val) => acc + (val.yCoord - type0MeanY) ** 2, 0) + type1Data.reduce((acc, val) => acc + (val.yCoord - type1MeanY) ** 2, 0)) / (data.length - numberOfTypes);

// Covariance
// Calculated with the mean disregarding types
const OverallXMean = d3.mean(data, d => d.xCoord);
const OverallYMean = d3.mean(data, d => d.yCoord);
const XYcovariance = data.reduce((acc, val) => acc + (val.xCoord - OverallXMean) * (val.yCoord- OverallYMean), 0) / (data.length - 0);

// Calculate correlation just for fun
// not the same as XVarianceTotal = d3.variance(data, d => d[0]);
const XVarianceTotal = (data.reduce((acc, val) => acc + (val.xCoord - OverallXMean) ** 2, 0)) / (data.length);
// not the same as YVarianceTotal = d3.variance(data, d => d[1]);
const YVarianceTotal = (data.reduce((acc, val) => acc + (val.yCoord - OverallYMean) ** 2, 0)) / (data.length);
const XYcorrelation = XYcovariance/(XVarianceTotal**0.5*YVarianceTotal**0.5);
d3.select("#cov").text(XYcovariance);
d3.select("#cor").text(XYcorrelation);

// building blocks
// covariance matrix
const covMatrix = math.matrix ([
[XVariance, XYcovariance],
[XYcovariance, YVariance], // #* this is problably wrong, need to put YVariance?
]);
const type0Mean = math.matrix([
[type0MeanX],
[type0MeanY],
]);
const type1Mean = math.matrix([
[type1MeanX],
[type1MeanY],
]);
// console.log(type0Mean._data, type1Mean._data);

// variances for drawing
const variances = {type0VarianceX, type0VarianceY, type1VarianceX, type1VarianceY, XVariance, YVariance, XVarianceTotal, YVarianceTotal};
return [type0Mean, type1Mean, covMatrix, variances];

}

Insert cell
function calculateLdaDelta(x, y, data) {
const clc = calculateLineCoefficients(data);
const [type0Mean, type1Mean, covMatrix] = calculateLdaEstimates(data);
const [v0, v1, z0, z1, logPrior0, logPrior1] = clc.calculateIntermediateValues(
type0Mean, type1Mean, covMatrix);
const mouseCoords = math.matrix([
[x],
[y],
]);

const delta0 = math.round((math.multiply(math.transpose(mouseCoords), v0)._data[0][0] + z0._data[0][0] + logPrior0)*100)/100;
const delta1 = math.round((math.multiply(math.transpose(mouseCoords), v1)._data[0][0] + z1._data[0][0] + logPrior1)*100)/100;

d3.select("#delta0").text(delta0);
d3.select("#delta1").text(delta1);
};
Insert cell
function drawDecisionBoundary(data, svg) {
let clc = calculateLineCoefficients(data);
const [beta0Estimated, beta1Estimated] = clc.estimatedBoundary();
const optionsEstimated = {
domId: 'ldalineEstimated',
stroke: "0, 0",
displayBeta0: "beta0Estimated",
displayBeta1: "beta1Estimated",
};
drawBoundary(beta0Estimated, beta1Estimated, optionsEstimated);
const [beta0Bayes, beta1Bayes] = clc.bayesBoundary();
const optionsBayes = {
domId: 'ldalineBayes',
stroke: "3, 3",
displayBeta0: "beta0Bayes",
displayBeta1: "beta1Bayes",
};
drawBoundary(beta0Bayes, beta1Bayes, optionsBayes);
function drawBoundary(beta0, beta1, options) {
const curve = d3.line().curve(d3.curveLinear);
let points = [
[cfg.xScale(0), cfg.yScale(beta0)],
[cfg.xScale(cfg.xDomain[1]), cfg.yScale(beta0 + cfg.xDomain[1]*beta1)]
];
d3.select("svg").select('#' + options['domId']).remove(); // #* add transition here
svg
.append('path')
.attr('id', options['domId'])
.attr('d', curve(points))
.attr('stroke', 'black') // d3.schemeSet3[8]
.attr("stroke-width", 2)
.style("stroke-dasharray", options['stroke'])
.attr('fill', 'none');
d3.select("#" + options['displayBeta0']).text(beta0);
d3.select("#" + options['displayBeta1']).text(beta1);
}
}
Insert cell
function drawMeans(data, meansSvg) {
let [type0Mean, type1Mean, ] = calculateLdaEstimates(data)

d3.selectAll("*").filter(function() {
return d3.select(this).attr("name") === "meanLine";
}).remove();
const curve1 = d3.line().curve(d3.curveLinear);
let type0MeanXLine = [
[cfg.xScale(type0Mean.get([0,0])), cfg.yScale(cfg.yDomain[0])],
[cfg.xScale(type0Mean.get([0,0])), cfg.yScale(cfg.yDomain[1])]
];

meansSvg
.append('path')
.attr('name', 'meanLine')
.attr('d', curve1(type0MeanXLine))
.attr('stroke', d3.schemeSet3[3])
.attr("stroke-width", 2)
.attr('fill', 'none')
.style("stroke-dasharray", ("3, 3"));


const curve2 = d3.line().curve(d3.curveLinear);
let type1MeanXLine = [
[cfg.xScale(type1Mean.get([0,0])), cfg.yScale(cfg.yDomain[0])],
[cfg.xScale(type1Mean.get([0,0])), cfg.yScale(cfg.yDomain[1])]
];

meansSvg
.append('path')
.attr('name', 'meanLine')
.attr('d', curve2(type1MeanXLine))
.attr('stroke', d3.schemeSet3[4])
.attr("stroke-width", 2)
.attr('fill', 'none')
.style("stroke-dasharray", ("3, 3"));

const curve3 = d3.line().curve(d3.curveLinear);
let type0MeanYLine = [
[cfg.xScale(cfg.xDomain[0]), cfg.yScale(type0Mean.get([1,0]))],
[cfg.xScale(cfg.xDomain[1]), cfg.yScale(type0Mean.get([1,0]))]
];

meansSvg
.append('path')
.attr('name', 'meanLine')
.attr('d', curve3(type0MeanYLine))
.attr('stroke', d3.schemeSet3[3])
.attr("stroke-width", 2)
.attr('fill', 'none')
.style("stroke-dasharray", ("3, 3"));


const curve4 = d3.line().curve(d3.curveLinear);
let type1MeanYLine = [
[cfg.xScale(cfg.xDomain[0]), cfg.yScale(type1Mean.get([1,0]))],
[cfg.xScale(cfg.xDomain[1]), cfg.yScale(type1Mean.get([1,0]))]
];

meansSvg
.append('path')
.attr('name', 'meanLine')
.attr('d', curve4(type1MeanYLine))
.attr('stroke', d3.schemeSet3[4])
.attr("stroke-width", 2)
.attr('fill', 'none')
.style("stroke-dasharray", ("3, 3"));
}
Insert cell
function getDimensionOfMatrix (matrix) {
let dimensions = [
matrix._data.length,
matrix._data.reduce((x, y) => Math.max(x, y.length), 0)
];
return dimensions
}
Insert cell
function updateVariances(data, varianceRectType0X) {
const [type0Mean, type1Mean, , variances] = calculateLdaEstimates(data); // #* this should probably be passed as arguments from buildPLot instead of being calculated once again here

const meansAndVariances = [
{
id: 'type0X',
type: 0,
axis: 'x',
mean: type0Mean._data[0][0],
variance: variances.type0VarianceX,
},
{
id: 'type0Y',
type: 0,
axis: 'y',
mean: type0Mean._data[1][0],
variance: variances.type0VarianceY,
},
{
id: 'type1X',
type: 1,
axis: 'x',
mean: type1Mean._data[0][0],
variance: variances.type1VarianceX,
},
{
id: 'type1Y',
type: 1,
axis: 'y',
mean: type1Mean._data[1][0],
variance: variances.type1VarianceY,
}
]
return varianceRectType0X
.data(meansAndVariances, (d) => d.id)
.join(
(enter) =>
enter
.append('rect')
.attr('name', 'varianceDisplay')
.attr('x', (d) => d.axis == 'x' ? cfg.xScale(d.mean - Math.abs(d.variance)) : 0)
.attr('y', (d) => d.axis == 'x' ? 0 : cfg.yScale(d.mean + Math.abs(d.variance)))
.attr('width', (d) => d.axis == 'x' ? cfg.xScale(Math.abs(d.variance*2))
- cfg.insetLeft -cfg.marginLeft : cfg.width)
.attr('height', (d) => d.axis == 'x' ? cfg.height :
Math.abs(cfg.yScale(Math.abs(d.variance*2))
- cfg.height + cfg.marginTop + cfg.insetTop))
.attr('fill', (d) => d.type == 0 ? d3.schemeSet3[3] : d3.schemeSet3[8])
.attr('opacity', 0.29)
,
(update) =>
update
.attr('x', (d) => d.axis == 'x' ? cfg.xScale(d.mean - Math.abs(d.variance)) : 0)
.attr('y', (d) => d.axis == 'x' ? 0 : cfg.yScale(d.mean + Math.abs(d.variance)))
.attr('width', (d) => d.axis == 'x' ? cfg.xScale(Math.abs(d.variance*2))
- cfg.insetLeft - cfg.marginLeft : cfg.width)
.attr('height', (d) => d.axis == 'x' ? cfg.height :
Math.abs(cfg.yScale(Math.abs(d.variance*2))
- cfg.height + cfg.marginTop + cfg.insetTop))
,
(exit) => exit.remove() // actually not relevant for variances
);
}
Insert cell
Insert cell
Insert cell
math = require('https://unpkg.com/mathjs@5.9.0/dist/math.min.js')
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more