Visual apperance
This commit is contained in:
49
src/App.tsx
49
src/App.tsx
@@ -1,5 +1,7 @@
|
||||
import * as d3 from "d3";
|
||||
import { useEffect, useState } from "react";
|
||||
import Legend from "./components/Legend";
|
||||
import QRCode from "./components/QRCode";
|
||||
import { QuestionGroupChart } from "./components/QuestionGroupChart";
|
||||
import { config } from "./config";
|
||||
import { getSampleData } from "./lib/data";
|
||||
@@ -8,6 +10,7 @@ import { ResponseData } from "./lib/parser";
|
||||
function App() {
|
||||
const [data, setData] = useState<ResponseData[]>([]);
|
||||
useEffect(() => {
|
||||
// fetchGoogleSheet().then(setData);
|
||||
setData(getSampleData());
|
||||
}, []);
|
||||
|
||||
@@ -20,27 +23,7 @@ function App() {
|
||||
|
||||
// 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);
|
||||
});
|
||||
const sortedResponses = allResponses.sort((a, b) => a - b);
|
||||
|
||||
// Create scales
|
||||
const xScale = d3
|
||||
@@ -51,15 +34,21 @@ function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{questionGroups.map(([question, groupData]) => (
|
||||
<QuestionGroupChart
|
||||
key={question}
|
||||
question={question}
|
||||
groupData={groupData}
|
||||
responses={sortedResponses}
|
||||
xScale={xScale}
|
||||
/>
|
||||
))}
|
||||
<div className="chart-container">
|
||||
<div className="charts">
|
||||
{questionGroups.map(([question, groupData]) => (
|
||||
<QuestionGroupChart
|
||||
key={question}
|
||||
question={question}
|
||||
groupData={groupData}
|
||||
responses={sortedResponses}
|
||||
xScale={xScale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Legend />
|
||||
</div>
|
||||
<QRCode />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/components/Chart.css
Normal file
30
src/components/Chart.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.dot {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.selected {
|
||||
fill: rgb(223 110 38);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
19
src/components/Legend.css
Normal file
19
src/components/Legend.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.legend {
|
||||
.box {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
28
src/components/Legend.tsx
Normal file
28
src/components/Legend.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { colorScheme } from "../config";
|
||||
import "./Legend.css";
|
||||
|
||||
const labels = {
|
||||
0: "Keine Erfahrung",
|
||||
1: "Grundkenntnisse",
|
||||
2: "Geübte Anwendung",
|
||||
3: "Sichere Praxisanwendung",
|
||||
4: "Fachwissen und Erfahrung",
|
||||
};
|
||||
|
||||
export default function Legend() {
|
||||
return (
|
||||
<div className="legend">
|
||||
<ul>
|
||||
{Object.entries(labels).map(([level, label]) => (
|
||||
<li key={level}>
|
||||
<span
|
||||
className="box"
|
||||
style={{ backgroundColor: `${colorScheme[level]}` }}
|
||||
></span>
|
||||
<span className="label">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/QRCode.css
Normal file
22
src/components/QRCode.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.qr-code-container {
|
||||
background-color: rgb(223 110 37);
|
||||
flex-basis: 20%;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
|
||||
filter: drop-shadow(0 8px 8px hsl(202.5deg 20% 76.5%));
|
||||
|
||||
color: #fff;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
background-color: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
16
src/components/QRCode.tsx
Normal file
16
src/components/QRCode.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import "./QRCode.css";
|
||||
|
||||
export default function QRCode() {
|
||||
return (
|
||||
<div className="qr-code-container">
|
||||
<h1>Scan me!</h1>
|
||||
<p>
|
||||
Scanne den Code und zeige, welche KI-Skills du mitbringst — ganz
|
||||
egal, auf welchem Level du bist.
|
||||
</p>
|
||||
<div className="qr-code bracket-frame">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/4/41/QR_Code_Example.svg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import * as d3 from "d3";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { colorScheme, config } from "../config";
|
||||
|
||||
import "./Chart.css";
|
||||
|
||||
interface QuestionGroupChartProps {
|
||||
question: string;
|
||||
groupData: { response: string }[];
|
||||
@@ -9,6 +11,31 @@ interface QuestionGroupChartProps {
|
||||
xScale: d3.ScaleBand<string>;
|
||||
}
|
||||
|
||||
function makeDot(
|
||||
shape: "rect" | "circle",
|
||||
g: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
dotX: number,
|
||||
dotY: number
|
||||
) {
|
||||
if (shape === "circle") {
|
||||
return g
|
||||
.append("circle")
|
||||
.attr("class", "dot")
|
||||
.attr("cx", dotX)
|
||||
.attr("cy", dotY)
|
||||
.attr("r", config.dotRadius);
|
||||
} else if (shape === "rect") {
|
||||
return g
|
||||
.append("rect")
|
||||
.attr("class", "dot")
|
||||
.attr("x", dotX - config.dotRadius)
|
||||
.attr("y", dotY - config.dotRadius)
|
||||
.attr("width", config.dotRadius * 2)
|
||||
.attr("height", config.dotRadius * 2);
|
||||
}
|
||||
throw new Error(`Unsupported shape: ${shape}`);
|
||||
}
|
||||
|
||||
export function QuestionGroupChart({
|
||||
question,
|
||||
groupData,
|
||||
@@ -23,27 +50,14 @@ export function QuestionGroupChart({
|
||||
|
||||
// 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 chartHeight = 200;
|
||||
const chartWidth = xScale.range()[1];
|
||||
|
||||
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})`
|
||||
);
|
||||
const g = svg.append("g");
|
||||
|
||||
// Dots
|
||||
responses.forEach((response) => {
|
||||
@@ -58,36 +72,25 @@ export function QuestionGroupChart({
|
||||
(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);
|
||||
chartHeight - (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");
|
||||
makeDot("rect", g, dotX, dotY).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 className="question-title">{question}</div>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
||||
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
||||
commodo.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
const dotRadius = 8;
|
||||
const dotSpacing = 2;
|
||||
const columnsPerGroup = 3;
|
||||
const groupGap = 8;
|
||||
|
||||
export const config = {
|
||||
dotRadius: 5,
|
||||
dotSpacing: 2,
|
||||
columnsPerGroup: 3,
|
||||
groupSpacing: 40,
|
||||
margin: { top: 20, right: 30, bottom: 80, left: 120 },
|
||||
dotRadius,
|
||||
dotSpacing,
|
||||
columnsPerGroup,
|
||||
groupSpacing: (2 * dotRadius + dotSpacing) * columnsPerGroup + groupGap,
|
||||
};
|
||||
|
||||
// Color scheme for Likert scale responses
|
||||
type ColorScheme = {
|
||||
[key: string | number]: string;
|
||||
const aaiColors = [
|
||||
"rgb(205 208 219)",
|
||||
"rgb(177 214 217)",
|
||||
"rgb(116 178 183)",
|
||||
"rgb(81 137 141)",
|
||||
"rgb(73 101 104)",
|
||||
];
|
||||
|
||||
export const greens = {
|
||||
0: "#ECEFF2",
|
||||
1: "#DDFDFD",
|
||||
2: "#7FD0D0",
|
||||
3: "#18A5A7",
|
||||
4: "#184F57",
|
||||
};
|
||||
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",
|
||||
|
||||
export const oranges = {
|
||||
0: "#ECEFF2",
|
||||
1: "#FFEBD7",
|
||||
2: "#F8C096",
|
||||
3: "#F47533",
|
||||
4: "#9D3A10",
|
||||
};
|
||||
|
||||
export const colorScheme = Object.fromEntries(
|
||||
aaiColors.map((color, index) => [index, color])
|
||||
);
|
||||
|
||||
@@ -36,21 +36,50 @@ body {
|
||||
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);
|
||||
#root {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 16px;
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-top: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
|
||||
filter: drop-shadow(0 8px 8px hsl(202.5deg 20% 76.5%));
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.question-group {
|
||||
margin-bottom: 40px;
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
|
||||
filter: drop-shadow(0 2px 2px hsl(202.5deg 20% 76.5%));
|
||||
}
|
||||
|
||||
.question-group svg {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.question-group p {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
line-height: 1.4;
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
@@ -59,40 +88,6 @@ body {
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dot {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
.question-title::before {
|
||||
content: "💻 ";
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}*/
|
||||
|
||||
@@ -6,22 +6,19 @@ export function getSampleData(): ResponseData[] {
|
||||
"Value for Money",
|
||||
"Ease of Use",
|
||||
"Recommendation",
|
||||
];
|
||||
const responses = [
|
||||
"Strongly Disagree",
|
||||
"Disagree",
|
||||
"Neutral",
|
||||
"Agree",
|
||||
"Strongly Agree",
|
||||
"Overall Satisfaction",
|
||||
"Customer Support",
|
||||
"Product Features",
|
||||
];
|
||||
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)];
|
||||
const response = Math.floor(Math.random() * 5);
|
||||
sampleData.push({
|
||||
id: id++,
|
||||
timestamp: id++,
|
||||
position: "",
|
||||
question: question,
|
||||
response: response,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
import * as d3 from "d3";
|
||||
|
||||
export interface ResponseData {
|
||||
id: number;
|
||||
question: string;
|
||||
response: string;
|
||||
function mapSkillToNumber(skill: string): number {
|
||||
const skills: { [key: string]: number } = {
|
||||
"Gar nicht qualifiziert": 0,
|
||||
"Leicht qualifiziert": 1,
|
||||
"Mäßig qualifiziert": 2,
|
||||
"Sehr qualifiziert": 3,
|
||||
"Äußerst qualifiziert": 4,
|
||||
};
|
||||
return skills[skill] !== undefined ? skills[skill] : -1;
|
||||
}
|
||||
|
||||
export function parseCSV(csv: string): d3.DSVRowArray<string> {
|
||||
return d3.csvParse(csv);
|
||||
const sheet_id = "12pGfvJx0SQmb6mnnVygmZsEeLZ6bFrpZvq8GYw2oX9E";
|
||||
const sheet_name = "Responses";
|
||||
const url = `https://docs.google.com/spreadsheets/d/${sheet_id}/gviz/tq?tqx=out:csv&sheet=${sheet_name}`;
|
||||
|
||||
export interface ResponseData {
|
||||
timestamp: number;
|
||||
position: string;
|
||||
question: string;
|
||||
response: number;
|
||||
}
|
||||
|
||||
export function parseCSV(csv: string): ResponseData[] {
|
||||
const rowConverter = (row: d3.DSVRowString): ResponseData[] => {
|
||||
const responses: { [key: string]: number } = {};
|
||||
Object.keys(row).forEach((key) => {
|
||||
// Likert scale responses have their group name in square brackets at the end of the column name
|
||||
const m = key.match(/\[([^\]]+)\]$/);
|
||||
if (m) {
|
||||
const category = m[1];
|
||||
responses[category] = mapSkillToNumber(row[key]);
|
||||
}
|
||||
});
|
||||
return Object.entries(responses).flatMap(([category, response]) => ({
|
||||
timestamp: new Date(row["Timestamp"]).getTime(),
|
||||
position: row["Position"],
|
||||
question: category,
|
||||
response: response,
|
||||
}));
|
||||
};
|
||||
const rows = d3.csvParse(csv, rowConverter);
|
||||
console.log("Parsed rows:", rows);
|
||||
return rows.flatMap((row) => row);
|
||||
}
|
||||
|
||||
export function fetchGoogleSheet() {
|
||||
return fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((csv) => {
|
||||
return parseCSV(csv);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user