diff --git a/src/App.tsx b/src/App.tsx index 66323e5..e7f4009 100644 --- a/src/App.tsx +++ b/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([]); 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]) => ( - - ))} +
+
+ {questionGroups.map(([question, groupData]) => ( + + ))} +
+ +
+ ); } diff --git a/src/components/Chart.css b/src/components/Chart.css new file mode 100644 index 0000000..d5eda82 --- /dev/null +++ b/src/components/Chart.css @@ -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; +} diff --git a/src/components/Legend.css b/src/components/Legend.css new file mode 100644 index 0000000..bcf5b22 --- /dev/null +++ b/src/components/Legend.css @@ -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; + } +} diff --git a/src/components/Legend.tsx b/src/components/Legend.tsx new file mode 100644 index 0000000..0e8bde4 --- /dev/null +++ b/src/components/Legend.tsx @@ -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 ( +
+
    + {Object.entries(labels).map(([level, label]) => ( +
  • + + {label} +
  • + ))} +
+
+ ); +} diff --git a/src/components/QRCode.css b/src/components/QRCode.css new file mode 100644 index 0000000..3487907 --- /dev/null +++ b/src/components/QRCode.css @@ -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%; + } +} diff --git a/src/components/QRCode.tsx b/src/components/QRCode.tsx new file mode 100644 index 0000000..e02f3f6 --- /dev/null +++ b/src/components/QRCode.tsx @@ -0,0 +1,16 @@ +import "./QRCode.css"; + +export default function QRCode() { + return ( +
+

Scan me!

+

+ Scanne den Code und zeige, welche KI-Skills du mitbringst — ganz + egal, auf welchem Level du bist. +

+
+ +
+
+ ); +} diff --git a/src/components/QuestionGroupChart.tsx b/src/components/QuestionGroupChart.tsx index 7e4e6b9..d751cea 100644 --- a/src/components/QuestionGroupChart.tsx +++ b/src/components/QuestionGroupChart.tsx @@ -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; } +function makeDot( + shape: "rect" | "circle", + g: d3.Selection, + 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 (
-
{question}
+
{question}
+

+ 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. +

); } diff --git a/src/config.ts b/src/config.ts index 192b224..61e8b7b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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]) +); diff --git a/src/index.css b/src/index.css index 12edc5a..8c2bd94 100644 --- a/src/index.css +++ b/src/index.css @@ -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; - } -}*/ diff --git a/src/lib/data.ts b/src/lib/data.ts index 30383f5..9758b83 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -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, }); diff --git a/src/lib/parser.ts b/src/lib/parser.ts index a7642e2..8d27cfb 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -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 { - 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); + }); }