Migrate to React

This commit is contained in:
Adrian Rumpold
2025-07-09 06:51:16 +02:00
parent 50d2fa8bfe
commit 5dc583e387
23 changed files with 4791 additions and 341 deletions

67
src/App.tsx Normal file
View File

@@ -0,0 +1,67 @@
import * as d3 from "d3";
import { useEffect, useState } from "react";
import { QuestionGroupChart } from "./components/QuestionGroupChart";
import { config } from "./config";
import { getSampleData } from "./lib/data";
import { ResponseData } from "./lib/parser";
function App() {
const [data, setData] = useState<ResponseData[]>([]);
useEffect(() => {
setData(getSampleData());
}, []);
if (!data.length) return null;
// Group data by question (outside the component)
const questionGroups = Array.from(
d3.group(data, (d) => d.question).entries()
);
// Get unique response categories (sorted for consistent ordering)
const allResponses = [...new Set(data.map((d) => d.response))];
const responseOrder = [
"Strongly Disagree",
"Disagree",
"Neutral",
"Agree",
"Strongly Agree",
"1",
"2",
"3",
"4",
"5",
];
const sortedResponses = allResponses.sort((a, b) => {
const aIndex = responseOrder.indexOf(a);
const bIndex = responseOrder.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
// Create scales
const xScale = d3
.scaleBand()
.domain(sortedResponses)
.range([0, sortedResponses.length * config.groupSpacing])
.padding(0.1);
return (
<>
{questionGroups.map(([question, groupData]) => (
<QuestionGroupChart
key={question}
question={question}
groupData={groupData}
responses={sortedResponses}
xScale={xScale}
/>
))}
</>
);
}
export default App;

View File

@@ -0,0 +1,95 @@
import * as d3 from "d3";
import { useEffect, useRef } from "react";
import { colorScheme, config } from "../config";
interface QuestionGroupChartProps {
question: string;
groupData: { response: string }[];
responses: string[];
xScale: d3.ScaleBand<string>;
}
export function QuestionGroupChart({
question,
groupData,
responses,
xScale,
}: QuestionGroupChartProps) {
const svgRef = useRef(null);
useEffect(() => {
// Clear SVG
d3.select(svgRef.current).selectAll("*").remove();
// Group responses by category (within this group only)
const responseGroups = d3.group(groupData, (d) => d.response);
const maxCount =
d3.max(Array.from(responseGroups.values(), (values) => values.length)) ||
0;
const maxRows = Math.ceil(maxCount / config.columnsPerGroup);
const chartHeight =
maxRows * (config.dotRadius * 2 + config.dotSpacing) +
config.margin.bottom;
const chartWidth =
xScale.range()[1] + config.margin.left + config.margin.right;
const svg = d3
.select(svgRef.current)
.attr("width", chartWidth)
.attr("height", chartHeight);
const g = svg
.append("g")
.attr(
"transform",
`translate(${config.margin.left},${config.margin.top})`
);
// Dots
responses.forEach((response) => {
const responseData = responseGroups.get(response) || [];
const x = xScale(response) || 0;
responseData.forEach((_, index) => {
const row = Math.floor(index / config.columnsPerGroup);
const col = index % config.columnsPerGroup;
const dotX =
x +
xScale.bandwidth() / 2 +
(col - (config.columnsPerGroup - 1) / 2) *
(config.dotRadius * 2 + config.dotSpacing);
const dotY =
chartHeight -
config.margin.top -
config.margin.bottom -
(row + 1) * (config.dotRadius * 2 + config.dotSpacing);
g.append("circle")
.attr("class", "dot")
.attr("cx", dotX)
.attr("cy", dotY)
.attr("r", config.dotRadius)
.attr("fill", colorScheme[response] || "#666");
});
});
}, [groupData, responses, xScale, question]);
/*
// Calculate height for container
const responseGroups = d3.group(groupData, (d) => d.response);
const maxCount =
d3.max(Array.from(responseGroups.values(), (values) => values.length)) || 0;
const maxRows = Math.ceil(maxCount / config.columnsPerGroup);
const chartHeight =
maxRows * (config.dotRadius * 2 + config.dotSpacing) +
config.margin.top +
config.margin.bottom;
*/
return (
<div className="question-group">
<div className="question-title">{question}</div>
<svg ref={svgRef}></svg>
</div>
);
}
export default QuestionGroupChart;

24
src/config.ts Normal file
View File

@@ -0,0 +1,24 @@
export const config = {
dotRadius: 5,
dotSpacing: 2,
columnsPerGroup: 3,
groupSpacing: 40,
margin: { top: 20, right: 30, bottom: 80, left: 120 },
};
// Color scheme for Likert scale responses
type ColorScheme = {
[key: string | number]: string;
};
export const colorScheme: ColorScheme = {
"Strongly Disagree": "#d32f2f",
Disagree: "#f57c00",
Neutral: "#fbc02d",
Agree: "#689f38",
"Strongly Agree": "#388e3c",
1: "#d32f2f",
2: "#f57c00",
3: "#fbc02d",
4: "#689f38",
5: "#388e3c",
};

98
src/index.css Normal file
View File

@@ -0,0 +1,98 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: only light;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
align-items: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #747bff;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.chart-container {
margin-top: 30px;
}
.question-group {
margin-bottom: 40px;
}
.question-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
}
.dot {
stroke: #fff;
stroke-width: 1;
}
.axis-label {
font-size: 12px;
fill: #666;
}
.axis text {
font-size: 11px;
fill: #666;
}
.axis path,
.axis line {
fill: none;
stroke: #ddd;
shape-rendering: crispEdges;
}
.grid line {
stroke: #e0e0e0;
stroke-dasharray: 2, 2;
}
/*@media (prefers-color-scheme: dark) {
:root {
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
}
a:hover {
color: #535bf2;
}
}*/

31
src/lib/data.ts Normal file
View File

@@ -0,0 +1,31 @@
import { ResponseData } from "./parser";
export function getSampleData(): ResponseData[] {
const questions = [
"Service Quality",
"Value for Money",
"Ease of Use",
"Recommendation",
];
const responses = [
"Strongly Disagree",
"Disagree",
"Neutral",
"Agree",
"Strongly Agree",
];
const sampleData: ResponseData[] = [];
let id = 1;
questions.forEach((question) => {
const numResponses = Math.floor(Math.random() * 50) + 30;
for (let i = 0; i < numResponses; i++) {
const response = responses[Math.floor(Math.random() * responses.length)];
sampleData.push({
id: id++,
question: question,
response: response,
});
}
});
return sampleData;
}

11
src/lib/parser.ts Normal file
View File

@@ -0,0 +1,11 @@
import * as d3 from "d3";
export interface ResponseData {
id: number;
question: string;
response: string;
}
export function parseCSV(csv: string): d3.DSVRowArray<string> {
return d3.csvParse(csv);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />