Skip to content

Commit

Permalink
Feat: Parallel coordinates node
Browse files Browse the repository at this point in the history
  • Loading branch information
mahesh-gfx committed Aug 27, 2024
1 parent 4e1dd21 commit df87cd7
Show file tree
Hide file tree
Showing 5 changed files with 329 additions and 8 deletions.
232 changes: 232 additions & 0 deletions packages/frontend/src/components/nodes/ParallelCoordinatesNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from "react";
import * as d3 from "d3";
import DefaultNode from "./DefaultNode";
import Modal from "react-modal";

interface DataPoint {
[key: string]: number | string;
}

const ParallelCoordinates = ({ id, data, def, type }: any) => {
const containerRef = useRef(null);
const expandedChartRef = useRef<HTMLDivElement | null>(null);
const miniChartRef = useRef<HTMLDivElement | null>(null);
const [hasOutputData, setHasOutputData] = useState(false);
const [modalIsOpen, setModalIsOpen] = useState(false);

const openModal = () => {
setModalIsOpen(true);
setTimeout(() => {
renderExpandedChart();
console.log("Rendered a scatterplot matrix expanded chart");
}, 100);
};

const closeModal = () => {
setModalIsOpen(false);
};

const downloadChart = (container: any) => {
const svg = container?.querySelector("svg");
if (svg) {
const serializer = new XMLSerializer();
const source = serializer.serializeToString(svg);
const a = document.createElement("a");
a.href = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(source);
a.download = `${data.label}-chart.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};

useEffect(() => {
if (data && data.output?.data?.json) {
setHasOutputData(!!data.output);
renderMiniChart();
console.log("Rendering a parallel coordinate plot mini");
}
}, [JSON.stringify(data)]);

const renderMiniChart = () => {
renderParallelCoordinates(
data.output?.data?.json,
data.properties?.variables,
data.properties?.colorBy,
miniChartRef.current,
600,
600
);
};
const renderExpandedChart = () => {
renderParallelCoordinates(
data.output?.data?.json,
data.properties?.variables,
data.properties?.colorBy,
expandedChartRef.current,
600,
600
);
};

const renderParallelCoordinates = (
data: DataPoint | any,
variables: any,
colorBy: any,
container: any,
renderWidth: number,
renderHeight: number
) => {
d3.select(container).selectAll("*").remove();

const margin = { top: 30, right: 10, bottom: 10, left: 10 };
const width = renderWidth - margin.left - margin.right;
const height = renderHeight - margin.top - margin.bottom;

// Determine variables to use
const variablesArray = variables
? variables
.split(",")
.map((v: any) => v.trim())
.filter((v: any) => v)
: Object.keys(data[0]);

// Use all variables if the list is empty
const varsToUse =
variablesArray.length > 0 ? variablesArray : Object.keys(data[0]);

const svg = d3
.select(container)
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("viewBox", `0 0 ${renderHeight} ${renderWidth}`)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);

const x = d3.scalePoint().range([0, width]).padding(1).domain(varsToUse);
const y: { [key: string]: d3.ScaleLinear<number, number> } = {};

varsToUse.forEach((variable: any) => {
//@ts-ignore
y[variable] = d3
.scaleLinear()
.domain(d3.extent(data, (d: any) => d[variable]) as [any, any])
.range([height, 0]);
});

const color = d3.scaleOrdinal(d3.schemeCategory10);

svg
.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("d", (d) =>
d3.line()(
varsToUse.map((variable: any) => [
x(variable),
//@ts-ignore
y[variable](d[variable] || 0),
])
)
)
.style("fill", "none")
.style("stroke", (d: any) => color(d[colorBy]))
.style("opacity", 0.7);

svg
.selectAll("g")
.data(varsToUse)
.enter()
.append("g")
.attr("transform", (d: any) => `translate(${x(d)})`)
.each(function (d: any) {
//@ts-ignore
d3.select(this).call(d3.axisLeft(y[d]));
})
.append("text")
.style("text-anchor", "middle")
.attr("y", -9)
.text((d: any) => d)
.style("fill", "black");
};

return (
<DefaultNode id={id} data={data} def={def} type={type}>
<div ref={miniChartRef} className="d3-mini-container"></div>
{hasOutputData && (
<div
style={{
display: "flex",
width: "100%",
justifyContent: "center",
alignItems: "center",
margin: "8px 0",
}}
>
<button
onClick={openModal}
className="button-small"
style={{ margin: "5px" }}
>
Expand View
</button>
<button
onClick={() => downloadChart(miniChartRef.current)}
style={{ width: "max-content" }}
className="button-small"
>
Download Chart
</button>
</div>
)}
<Modal
isOpen={modalIsOpen}
onRequestClose={closeModal}
contentLabel="Expanded Chart View"
style={{
content: {
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
width: "600px",
height: "600px",
display: "flex",
flexDirection: "column",
borderRadius: "8px",
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.15)",
},
overlay: {
background: "rgba(128, 128, 128, 0.3)",
backdropFilter: "blur(2px)",
zIndex: 20,
},
}}
>
<h2>{data.label}</h2>
<div
style={{
display: "flex",
width: "100%",
justifyContent: "flex-end",
}}
>
<button
onClick={() => downloadChart(expandedChartRef.current)}
style={{ width: "max-content" }}
className="button"
>
Download Chart
</button>
</div>
<div ref={expandedChartRef} className="d3-expanded-container"></div>
</Modal>
</DefaultNode>
);
};

export default ParallelCoordinates;
16 changes: 8 additions & 8 deletions packages/frontend/src/components/nodes/ScatterPlotMatrixNode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import * as d3 from "d3";
import DefaultNode from "./DefaultNode";
import Modal from "react-modal";
Expand Down Expand Up @@ -80,22 +80,22 @@ const ScatterPlotMatrixNode = ({ id, data, def, type }: any) => {
const varsToUse =
variablesArray.length > 0 ? variablesArray : Object.keys(data[0]);

// Adjust margins for smaller containers
const margin = { top: 10, right: 10, bottom: 30, left: 30 };
const width = renderWidth - margin.left - margin.right;
const height = renderHeight - margin.top - margin.bottom;

const svg = d3
.select(container)
.append("svg")
.attr("width", renderWidth)
.attr("height", renderHeight)
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("viewBox", `0 0 ${renderHeight} ${renderWidth}`)
.attr("preserveAspectRatio", "xMidYMid meet")
.style("background-color", "white");

const color = d3.scaleOrdinal(d3.schemeCategory10);

// Adjust margins for smaller containers
const margin = { top: 10, right: 10, bottom: 30, left: 30 };
const width = renderWidth - margin.left - margin.right;
const height = renderHeight - margin.top - margin.bottom;

// Add tooltip
const tooltip = d3
.select(container)
Expand Down
5 changes: 5 additions & 0 deletions packages/frontend/src/context/WorkflowContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { StartNode } from "@data-viz-tool/nodes";
import D3Node from "../components/nodes/D3Node";
import ButtonEdge from "../components/edges/ButtonEdge";
import ScatterPlotMatrixNode from "../components/nodes/ScatterPlotMatrixNode";
import ParallelCoordinates from "../components/nodes/ParallelCoordinatesNode";

interface NodeDefinition {
name: string;
Expand Down Expand Up @@ -169,6 +170,10 @@ const WorkflowProvider = ({ children }: any) => {
type={type}
/>
);
case "ParallelCoordinatesNode":
return (
<ParallelCoordinates id={id} data={data} def={def} type={type} />
);

default:
return <DefaultNode id={id} data={data} def={def} type={type} />;
Expand Down
1 change: 1 addition & 0 deletions packages/nodes/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export * from "./nodeTypes/DimensionalityReductionNode";
export * from "./nodeTypes/D3jsNode";
export * from "./nodeTypes/DataBinningNode";
export * from "./nodeTypes/ScatterPlotMatrixNode";
export * from "./nodeTypes/ParallelCoordinatesNode";

// Export other node types as you create them
83 changes: 83 additions & 0 deletions packages/nodes/src/nodeTypes/ParallelCoordinatesNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { BaseNode, NodeDefinition } from "../BaseNode";

export class ParallelCoordinatesNode extends BaseNode {
constructor(node: Partial<ParallelCoordinatesNode>) {
super(node);
}

static getNodeDefinition(): NodeDefinition {
return {
name: "ParallelCoordinatesNode",
displayName: "Parallel Coordinates",
description:
"Visualizes high-dimensional data using parallel coordinates",
icon: "parallel-coordinates",
color: "#BCFA1F",
inputs: ["data"],
outputs: ["data"],
properties: [
{
displayName: "Variables",
name: "variables",
type: "string",
default: "",
description:
"Comma-separated list of variables to include, or leave empty to include all",
},
{
displayName: "Color By",
name: "colorBy",
type: "string",
default: "",
description: "Variable to use for color coding lines",
},
],
version: 1,
};
}

async execute(inputs: Record<string, any>): Promise<any> {
const data = inputs.data.data.json;
const variablesInput = this.data.properties?.variables;
const colorBy = this.data.properties?.colorBy;

if (!data) {
console.error("Invalid input data");
throw new Error("Invalid input data");
}

// Determine variables to use
const variables = variablesInput
? variablesInput
.split(",")
.map((v: any) => v.trim())
.filter((v: any) => v)
: Object.keys(data[0]);

if (variables.length === 0) {
console.error("No variables selected");
throw new Error("No variables selected");
}

// Prepare data for parallel coordinates
const plotData = data.map((row: any) => {
const selectedVariables = variables.reduce((acc: any, variable: any) => {
acc[variable] = row[variable];
return acc;
}, {});

if (colorBy) {
selectedVariables[colorBy] = row[colorBy];
}

return selectedVariables;
});

return {
data: {
json: plotData,
binary: null,
},
};
}
}

0 comments on commit df87cd7

Please sign in to comment.