Migrate to React
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
341
main.html
341
main.html
@@ -1,341 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Likert Survey Visualization</title>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-input-section {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 2px dashed #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-input-section input[type="file"] {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-button {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-button:hover {
|
|
||||||
background: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
color: white;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Likert Survey Response Visualization</h1>
|
|
||||||
|
|
||||||
<div class="file-input-section">
|
|
||||||
<p>
|
|
||||||
<strong>Upload CSV File:</strong> Expected format:
|
|
||||||
id,question,response
|
|
||||||
</p>
|
|
||||||
<input type="file" id="csvFile" accept=".csv" />
|
|
||||||
<button class="sample-button" onclick="loadSampleData()">
|
|
||||||
Load Sample Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-container" id="chartContainer"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Color scheme for Likert scale responses
|
|
||||||
const colorScheme = {
|
|
||||||
"Strongly Disagree": "#d32f2f",
|
|
||||||
Disagree: "#f57c00",
|
|
||||||
Neutral: "#fbc02d",
|
|
||||||
Agree: "#689f38",
|
|
||||||
"Strongly Agree": "#388e3c",
|
|
||||||
1: "#d32f2f",
|
|
||||||
2: "#f57c00",
|
|
||||||
3: "#fbc02d",
|
|
||||||
4: "#689f38",
|
|
||||||
5: "#388e3c",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const config = {
|
|
||||||
dotRadius: 4,
|
|
||||||
dotSpacing: 2,
|
|
||||||
columnsPerGroup: 4,
|
|
||||||
groupSpacing: 60,
|
|
||||||
questionSpacing: 80,
|
|
||||||
margin: { top: 20, right: 30, bottom: 80, left: 120 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create tooltip
|
|
||||||
const tooltip = d3
|
|
||||||
.select("body")
|
|
||||||
.append("div")
|
|
||||||
.attr("class", "tooltip")
|
|
||||||
.style("opacity", 0);
|
|
||||||
|
|
||||||
// Sample data generator
|
|
||||||
function loadSampleData() {
|
|
||||||
const questions = [
|
|
||||||
"Service Quality",
|
|
||||||
"Value for Money",
|
|
||||||
"Ease of Use",
|
|
||||||
"Recommendation",
|
|
||||||
];
|
|
||||||
const responses = [
|
|
||||||
"Strongly Disagree",
|
|
||||||
"Disagree",
|
|
||||||
"Neutral",
|
|
||||||
"Agree",
|
|
||||||
"Strongly Agree",
|
|
||||||
];
|
|
||||||
|
|
||||||
let sampleData = [];
|
|
||||||
let id = 1;
|
|
||||||
|
|
||||||
questions.forEach((question) => {
|
|
||||||
// Generate random responses for each question
|
|
||||||
const numResponses = Math.floor(Math.random() * 50) + 30; // 30-80 responses per question
|
|
||||||
for (let i = 0; i < numResponses; i++) {
|
|
||||||
const response =
|
|
||||||
responses[Math.floor(Math.random() * responses.length)];
|
|
||||||
sampleData.push({
|
|
||||||
id: id++,
|
|
||||||
question: question,
|
|
||||||
response: response,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
visualizeData(sampleData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// File input handler
|
|
||||||
document
|
|
||||||
.getElementById("csvFile")
|
|
||||||
.addEventListener("change", function (event) {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = function (e) {
|
|
||||||
const csv = e.target.result;
|
|
||||||
const data = d3.csvParse(csv);
|
|
||||||
visualizeData(data);
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function visualizeData(data) {
|
|
||||||
// Clear existing chart
|
|
||||||
d3.select("#chartContainer").selectAll("*").remove();
|
|
||||||
|
|
||||||
// Group data by question
|
|
||||||
const questionGroups = d3.group(data, (d) => d.question);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Create visualization for each question
|
|
||||||
questionGroups.forEach((questionData, question) => {
|
|
||||||
createQuestionChart(questionData, question, sortedResponses, xScale);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createQuestionChart(data, question, responses, xScale) {
|
|
||||||
const container = d3.select("#chartContainer");
|
|
||||||
|
|
||||||
// Group responses by category
|
|
||||||
const responseGroups = d3.group(data, (d) => d.response);
|
|
||||||
|
|
||||||
// Calculate maximum dots in any group for height calculation
|
|
||||||
const maxCount = d3.max(
|
|
||||||
responseGroups,
|
|
||||||
([key, values]) => values.length
|
|
||||||
);
|
|
||||||
const maxRows = Math.ceil(maxCount / config.columnsPerGroup);
|
|
||||||
const chartHeight =
|
|
||||||
maxRows * (config.dotRadius * 2 + config.dotSpacing) +
|
|
||||||
config.margin.top +
|
|
||||||
config.margin.bottom;
|
|
||||||
|
|
||||||
// Create SVG for this question
|
|
||||||
const svg = container
|
|
||||||
.append("div")
|
|
||||||
.attr("class", "question-group")
|
|
||||||
.append("svg")
|
|
||||||
.attr(
|
|
||||||
"width",
|
|
||||||
xScale.range()[1] + config.margin.left + config.margin.right
|
|
||||||
)
|
|
||||||
.attr("height", chartHeight);
|
|
||||||
|
|
||||||
const g = svg
|
|
||||||
.append("g")
|
|
||||||
.attr(
|
|
||||||
"transform",
|
|
||||||
`translate(${config.margin.left},${config.margin.top})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create dots for each response group
|
|
||||||
responses.forEach((response) => {
|
|
||||||
const responseData = responseGroups.get(response) || [];
|
|
||||||
const x = xScale(response);
|
|
||||||
|
|
||||||
responseData.forEach((d, 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")
|
|
||||||
.on("mouseover", function (event) {
|
|
||||||
tooltip.transition().duration(200).style("opacity", 0.9);
|
|
||||||
tooltip
|
|
||||||
.html(
|
|
||||||
"Question: " +
|
|
||||||
question +
|
|
||||||
"<br/>Response: " +
|
|
||||||
response +
|
|
||||||
"<br/>ID: " +
|
|
||||||
d.id
|
|
||||||
)
|
|
||||||
.style("left", event.pageX + 10 + "px")
|
|
||||||
.style("top", event.pageY - 28 + "px");
|
|
||||||
})
|
|
||||||
.on("mouseout", function () {
|
|
||||||
tooltip.transition().duration(500).style("opacity", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load sample data on page load
|
|
||||||
loadSampleData();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
4285
package-lock.json
generated
Normal file
4285
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "skills-profile-demonstrator",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.30.1",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
|
"@types/node": "^24.0.12",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"eslint": "^9.30.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.36.0",
|
||||||
|
"vite": "^7.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
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" />
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
tsconfig.node.json
Normal file
9
tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user