Migrate to React
This commit is contained in:
67
src/App.tsx
Normal file
67
src/App.tsx
Normal 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;
|
||||
95
src/components/QuestionGroupChart.tsx
Normal file
95
src/components/QuestionGroupChart.tsx
Normal 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
24
src/config.ts
Normal 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
98
src/index.css
Normal 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
31
src/lib/data.ts
Normal 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
11
src/lib/parser.ts
Normal 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
10
src/main.tsx
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user