Visual apperance

This commit is contained in:
Adrian Rumpold
2025-07-09 11:51:32 +02:00
parent 5dc583e387
commit 86688519f5
11 changed files with 310 additions and 147 deletions

View File

@@ -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
View 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
View 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
View 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
View 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
View 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 &mdash; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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])
);

View File

@@ -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;
}
}*/

View File

@@ -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,
});

View File

@@ -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);
});
}