Compare commits

..

12 Commits

Author SHA1 Message Date
Adrian Rumpold
3af9f42e66 Better configurability 2025-07-10 12:47:35 +02:00
Adrian Rumpold
7fb60ad2ed Align legend with survey response options 2025-07-10 12:44:54 +02:00
Adrian Rumpold
5151fd8b73 Make response data refresh interval configurable 2025-07-10 12:35:05 +02:00
Adrian Rumpold
176f68e32d Generate realistic sample data 2025-07-10 12:32:31 +02:00
Adrian Rumpold
71e5131aa1 Align layout with Figma design 2025-07-10 12:19:07 +02:00
Adrian Rumpold
8aa8932122 Grid layout, instructions (placeholder) 2025-07-10 10:30:44 +02:00
Adrian Rumpold
98f2c0436b Improve parallel React Query handling 2025-07-10 09:25:15 +02:00
Adrian Rumpold
fa8a0e4763 Fix types in QuestionGroupChart component 2025-07-10 09:25:15 +02:00
Adrian Rumpold
f3f490d960 Use React Query for data loading 2025-07-10 09:09:15 +02:00
Adrian Rumpold
509b765213 Fix invalid whitespace 2025-07-10 09:08:03 +02:00
Adrian Rumpold
3498e45ab2 Migrate styles to SCSS 2025-07-10 08:33:50 +02:00
Adrian Rumpold
a690718192 Category metadata / icon support 2025-07-10 08:33:42 +02:00
30 changed files with 1821 additions and 299 deletions

6
.gitignore vendored
View File

@@ -12,6 +12,12 @@ dist
dist-ssr dist-ssr
*.local *.local
# TypeScript cache
*.tsbuildinfo
# Optional eslint cache
.eslintcache
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

@@ -1,4 +1,5 @@
import js from "@eslint/js"; import js from "@eslint/js";
import pluginQuery from "@tanstack/eslint-plugin-query";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals"; import globals from "globals";
@@ -7,18 +8,21 @@ import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist"] }, { ignores: ["dist"] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [
js.configs.recommended,
reactHooks.configs["recommended-latest"],
...tseslint.configs.recommended,
...pluginQuery.configs["flat/recommended"],
],
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
plugins: { plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh, "react-refresh": reactRefresh,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [ "react-refresh/only-export-components": [
"warn", "warn",
{ allowConstantExport: true }, { allowConstantExport: true },

View File

@@ -2,17 +2,21 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/aai-favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link <link
href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap" href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>Vite + React</title> <!-- Material Icons font -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<title>AI Skills Framework</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

0
main.html Normal file
View File

1245
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,23 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"browserslist": [
"last 2 versions"
],
"dependencies": { "dependencies": {
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-query-devtools": "^5.82.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"normalize-scss": "^8.0.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"sass": "^1.89.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.1", "@eslint/js": "^9.30.1",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/node": "^24.0.12", "@types/node": "^24.0.12",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",

BIN
public/aai-favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,53 +1,71 @@
import { useQuery } from "@tanstack/react-query";
import * as d3 from "d3"; import * as d3 from "d3";
import { useEffect, useState } from "react";
import { fetchCategoryMetadata } from "./lib/metadata";
import { Instructions } from "./components/Instructions";
import Legend from "./components/Legend"; import Legend from "./components/Legend";
import QRCode from "./components/QRCode"; import QRCode from "./components/QRCode";
import { QuestionGroupChart } from "./components/QuestionGroupChart"; import { WaffleChart } from "./components/WaffleChart";
import { config } from "./config"; import { config } from "./config";
import { getSampleData } from "./lib/data"; import { getSampleData } from "./lib/data";
import { ResponseData } from "./lib/parser"; import "./styles/App.scss";
function App() { function App() {
const [data, setData] = useState<ResponseData[]>([]); const metadataQuery = useQuery({
useEffect(() => { queryKey: ["categoryMetadata"],
// fetchGoogleSheet().then(setData); queryFn: fetchCategoryMetadata,
setData(getSampleData()); });
}, []); const responseQuery = useQuery({
queryKey: ["responses"],
// queryFn: fetchGoogleSheet,
queryFn: getSampleData,
refetchInterval: config.refreshIntervalSeconds * 1000,
});
if (!data.length) return null; if (metadataQuery.isPending || responseQuery.isPending)
return <div>Loading...</div>;
if (metadataQuery.isError || responseQuery.isError)
return <div>Error loading data</div>;
// Sort responses by timestamp to easily find the latest response
const responses = [...responseQuery.data].sort(
(a, b) => a.timestamp - b.timestamp
);
const categoryMetadata = metadataQuery.data;
// Group data by question (outside the component) // Group data by question (outside the component)
const questionGroups = Array.from( const questionGroups = Array.from(
d3.group(data, (d) => d.question).entries() d3.group(responses, (d) => d.question).entries()
); );
// Get unique response categories (sorted for consistent ordering)
const allResponses = [...new Set(data.map((d) => d.response))];
const sortedResponses = allResponses.sort((a, b) => a - b);
// Create scales
const xScale = d3
.scaleBand()
.domain(sortedResponses)
.range([0, sortedResponses.length * config.groupSpacing]);
return ( return (
<> <div className="layout">
<div className="chart-container"> <div className="chart-container">
<div className="charts"> <div className="charts">
{questionGroups.map(([question, groupData]) => ( {questionGroups.map(([question, groupData]) => (
<QuestionGroupChart <WaffleChart
key={question} key={question}
question={question} question={question}
metadata={categoryMetadata.find((m) => m.category === question)}
groupData={groupData} groupData={groupData}
responses={sortedResponses} responses={[...new Set(responses.map((d) => d.response))]}
xScale={xScale} latestResponseTimestamp={
responses[responses.length - 1].timestamp
}
/> />
))} ))}
</div> </div>
<Legend /> <Legend />
</div> </div>
<QRCode /> <div className="qr">
</> <QRCode />
</div>
<div className="instructions">
<Instructions />
</div>
</div>
); );
} }

View File

@@ -0,0 +1,7 @@
export function Instructions() {
return (
<>
<img width="400" src="/placeholder-instructions.svg" alt="Instructions" />
</>
);
}

View File

@@ -1,19 +1,12 @@
import { colorScheme } from "../config"; import { colorScheme } from "../config";
import "./Legend.css"; import { skills } from "../lib/parser";
import "../styles/Legend.scss";
const labels = {
0: "Keine Erfahrung",
1: "Grundkenntnisse",
2: "Geübte Anwendung",
3: "Sichere Praxisanwendung",
4: "Fachwissen und Erfahrung",
};
export default function Legend() { export default function Legend() {
return ( return (
<div className="legend"> <div className="legend">
<ul> <ul>
{Object.entries(labels).map(([level, label]) => ( {Object.entries(skills).map(([label, level]) => (
<li key={level}> <li key={level}>
<span <span
className="box" className="box"

View File

@@ -1,28 +0,0 @@
.qr-code-container {
background-color: hsl(18.6deg 100% 55.1%);
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;
p {
text-align: center;
}
img {
display: block;
margin: 0 auto;
background-color: #fff;
border-radius: 16px;
padding: 16px;
width: 50%;
}
}

View File

@@ -1,4 +1,4 @@
import "./QRCode.css"; import "../styles/QRCode.scss";
export default function QRCode() { export default function QRCode() {
return ( return (
@@ -8,8 +8,12 @@ export default function QRCode() {
Scanne den Code und zeige, welche KI-Skills du mitbringst &mdash; ganz Scanne den Code und zeige, welche KI-Skills du mitbringst &mdash; ganz
egal, auf welchem Level du bist. egal, auf welchem Level du bist.
</p> </p>
<div className="qr-code bracket-frame"> <div className="qr-code">
<img src="https://upload.wikimedia.org/wikipedia/commons/4/41/QR_Code_Example.svg" /> <img
className="viewfinder"
src="/qr_code_ki-skills-umfrage.png"
alt="QR Code"
/>
</div> </div>
</div> </div>
); );

View File

@@ -1,21 +1,27 @@
import MaterialIcon from "@mui/material/Icon";
import * as d3 from "d3"; import * as d3 from "d3";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { colorScheme, config } from "../config";
import "./Chart.css"; import { colorScheme, config } from "../config";
import { CategoryMetadata } from "../lib/metadata";
import { ResponseData } from "../lib/parser";
import "../styles/Chart.scss";
interface QuestionGroupChartProps { interface QuestionGroupChartProps {
question: string; question: string;
groupData: { response: string }[]; metadata?: CategoryMetadata;
responses: string[]; groupData: ResponseData[];
xScale: d3.ScaleBand<string>; responses: number[];
latestResponseTimestamp: number;
} }
function makeDot( function makeDot(
g: d3.Selection<SVGGElement, unknown, null, undefined>, g: d3.Selection<SVGGElement, unknown, null, undefined>,
dotX: number, dotX: number,
dotY: number dotY: number
) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
): d3.Selection<any, unknown, null, undefined> {
const shape = config.dotShape; const shape = config.dotShape;
if (shape === "circle") { if (shape === "circle") {
return g return g
@@ -36,22 +42,30 @@ function makeDot(
throw new Error(`Unsupported shape: ${shape}`); throw new Error(`Unsupported shape: ${shape}`);
} }
export function QuestionGroupChart({ export function WaffleChart({
question, question,
metadata,
groupData, groupData,
responses, responses,
xScale, latestResponseTimestamp,
}: QuestionGroupChartProps) { }: QuestionGroupChartProps) {
const svgRef = useRef(null); const svgRef = useRef(null);
useEffect(() => { useEffect(() => {
const sortedResponses = [...responses].sort((a, b) => a - b);
const chartHeight = config.chartHeight;
const chartWidth = responses.length * config.groupSpacing;
const xScale = d3
.scaleBand<number>()
.domain([0, 1, 2, 3, 4])
.range([0, chartWidth]);
// Clear SVG // Clear SVG
d3.select(svgRef.current).selectAll("*").remove(); d3.select(svgRef.current).selectAll("*").remove();
// Group responses by category (within this group only) // Group responses by category (within this group only)
const responseGroups = d3.group(groupData, (d) => d.response); const responseGroups = d3.group(groupData, (d) => d.response);
const chartHeight = config.chartHeight;
const chartWidth = xScale.range()[1];
const svg = d3 const svg = d3
.select(svgRef.current) .select(svgRef.current)
@@ -59,37 +73,53 @@ export function QuestionGroupChart({
.attr("height", chartHeight); .attr("height", chartHeight);
const g = svg.append("g"); const g = svg.append("g");
// Create x-axis
if (config.renderXAxis) {
const axisY = chartHeight - config.dotRadius - config.dotSpacing;
g.append("line")
.attr("x1", 0)
.attr("y1", axisY)
.attr("x2", chartWidth)
.attr("y2", axisY)
.attr("stroke", "#000")
.attr("stroke-width", 0.5)
.attr("stroke-dasharray", "2,2");
}
// Dots // Dots
responses.forEach((response) => { sortedResponses.forEach((response) => {
const responseData = responseGroups.get(response) || []; const responseData = responseGroups.get(response) || [];
const x = xScale(response) || 0; // Use xScale(response) as the group center
responseData.forEach((_, index) => { const x = (xScale(response) || 0) + config.dotRadius;
responseData.forEach((entry, index) => {
const row = Math.floor(index / config.columnsPerGroup); const row = Math.floor(index / config.columnsPerGroup);
const col = index % config.columnsPerGroup; const col = index % config.columnsPerGroup;
const dotX = const dotX =
x + x +
xScale.bandwidth() / 2 +
(col - (config.columnsPerGroup - 1) / 2) * (col - (config.columnsPerGroup - 1) / 2) *
(config.dotRadius * 2 + config.dotSpacing); (config.dotRadius * 2 + config.dotSpacing);
const dotY = const dotY =
chartHeight - (row + 1) * (config.dotRadius * 2 + config.dotSpacing); chartHeight - (row + 1) * (config.dotRadius * 2 + config.dotSpacing);
makeDot(g, dotX, dotY).attr("fill", colorScheme[response] || "#666"); const dot = makeDot(g, dotX, dotY).attr(
"fill",
colorScheme[response] || "#666"
);
const isLatest = entry.timestamp === latestResponseTimestamp;
if (isLatest) {
dot.attr("class", "latest");
}
}); });
}); });
}, [groupData, responses, xScale, question]); }, [groupData, responses, question, latestResponseTimestamp]);
return ( return (
<div className="question-group"> <div className="question-group">
<svg ref={svgRef}></svg> <svg ref={svgRef}></svg>
<div className="question-title">{question}</div> <div className="question-title">
<p> {metadata?.icon && <MaterialIcon>{metadata.icon}</MaterialIcon>}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod {question}
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim </div>
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea <p>{metadata?.description || question}</p>
commodo.
</p>
</div> </div>
); );
} }
export default QuestionGroupChart;

View File

@@ -1,15 +1,17 @@
const dotRadius = 8; const dotRadius = 7;
const dotSpacing = 2; const dotSpacing = 1;
const columnsPerGroup = 3; const columnsPerGroup = 3;
const groupGap = 8; const groupGap = dotRadius;
export const config = { export const config = {
dotRadius, dotRadius,
dotSpacing, dotSpacing,
columnsPerGroup, columnsPerGroup,
groupSpacing: (2 * dotRadius + dotSpacing) * columnsPerGroup + groupGap, groupSpacing: (2 * dotRadius + dotSpacing) * columnsPerGroup + groupGap,
chartHeight: 150, chartHeight: 50,
dotShape: "rect", // "circle" or "rect" dotShape: "rect", // "circle" or "rect"
renderXAxis: true, // Whether to render the x-axis
refreshIntervalSeconds: 1, // Refresh interval for response data in seconds
}; };
// Color scheme for Likert scale responses // Color scheme for Likert scale responses
@@ -23,3 +25,9 @@ const aaiColors = [
export const colorScheme = Object.fromEntries( export const colorScheme = Object.fromEntries(
aaiColors.map((color, index) => [index, color]) aaiColors.map((color, index) => [index, color])
); );
export const categoryMetadataUrl =
"https://docs.google.com/spreadsheets/d/e/2PACX-1vT6FQoV_8ET_pmEB5LGlI_ST9AAhsfiZrWydFwIB80G0Lr_kGwVJUzjM6fRPP9Yrx6iVZYMVAPTnLKq/pub?gid=0&single=true&output=csv";
export const responsesSheetId = "12pGfvJx0SQmb6mnnVygmZsEeLZ6bFrpZvq8GYw2oX9E";
export const responsesSheetName = "Responses";

View File

@@ -1,108 +0,0 @@
:root {
font-family: Inter, 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;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Work Sans", sans-serif;
font-weight: 800;
margin: 0;
}
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;
}
#root {
display: flex;
gap: 16px;
margin: 16px;
flex-direction: row;
align-items: start;
}
.chart-container {
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 {
background-color: #ffffff;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
filter: drop-shadow(0 2px 2px hsl(202.5deg 20% 76.5%));
display: flex;
flex-direction: column;
align-items: start;
gap: 8px;
}
.question-group svg {
align-self: center;
margin-bottom: 16px;
}
.question-group p {
font-size: 14px;
color: #666;
line-height: 1.4;
max-width: 40ch;
}
.question-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.question-title::before {
content: "💻 ";
}

View File

@@ -1,28 +1,26 @@
import { fetchCategoryMetadata } from "./metadata";
import { ResponseData } from "./parser"; import { ResponseData } from "./parser";
export function getSampleData(): ResponseData[] { function randInt(min: number, max: number): number {
const questions = [ return Math.floor(Math.random() * (max - min + 1)) + min;
"Service Quality", }
"Value for Money",
"Ease of Use", export async function getSampleData(): Promise<ResponseData[]> {
"Recommendation", // Use the actual categories
"Overall Satisfaction", const questions = (await fetchCategoryMetadata()).map(
"Customer Support", (metadata) => metadata.category
"Product Features", );
];
const sampleData: ResponseData[] = []; const sampleData: ResponseData[] = [];
let id = 1; const numResponses = randInt(10, 20);
questions.forEach((question) => { for (let i = 0; i < numResponses; i++) {
const numResponses = Math.floor(Math.random() * 50) + 30; questions.forEach((question) => {
for (let i = 0; i < numResponses; i++) { const response = randInt(0, 4); // Likert scale response (0-4)
const response = Math.floor(Math.random() * 5);
sampleData.push({ sampleData.push({
timestamp: id++, timestamp: i, // Group all responses by the same timestamp to mimic Google Forms behavior
position: "",
question: question, question: question,
response: response, response: response,
}); });
} });
}); }
return sampleData; return Promise.resolve(sampleData);
} }

37
src/lib/metadata.ts Normal file
View File

@@ -0,0 +1,37 @@
import * as d3 from "d3";
export interface CategoryMetadata {
category: string;
description: string;
icon: string;
}
const sampleMetadataCsv = `title,text,icon
Generelles KI-Wissen,Grundlegendes Wissen über Künstliche Intelligenz und deren Anwendung in Organisationen,school
KI-Innovation,"Fähigkeiten zur Entwicklung, Bewertung und Förderung von KI-Innovationen im Unternehmen",science
KI-Geschäftsstrategie,"Verstehen, wie KI strategisch in Geschäftsmodelle integriert und eingesetzt werden kann",business
Stakeholder-Landschaft,"Fähigkeit, relevante Stakeholder für KI-Initiativen zu identifizieren, einzubinden und zu koordinieren",people_alt
KI-Ethik,Kenntnisse über ethische Fragestellungen und verantwortungsvollen KI-Einsatz,local_police
KI-Regulation,Verständnis rechtlicher Rahmenbedingungen und Regulierungen rund um KI,gavel
Datenkompetenz,"Fähigkeit, Daten kritisch zu beurteilen, aufzubereiten und für KI nutzbar zu machen",equalizer
Python-Programmierung,Grundlegende Programmier-kenntnisse zur Umsetzung und Anpassung von KI-Lösungen,data_object
Software Design,"Gestaltung robuster, skalierbarer und wartbarer Softwarelösungen mit KI-Komponenten",code
Maschinelles Lernen,Kenntnisse in maschinellem Lernen zur Entwicklung datengetriebener Modelle,model_training
MLOps / Infrastruktur,Fähigkeiten zum produktiven Einsatz und Betrieb von KI-Systemen in Unternehmen,all_inclusive
Generative KI,Verständnis generativer KI-Modelle (z. B. Large Language Models) und ihrer praktischen Nutzung,auto_awesome`;
export function fetchCategoryMetadata(): Promise<CategoryMetadata[]> {
const parseCsv = (csv: string): CategoryMetadata[] => {
const parsed = d3.csvParse(csv);
return parsed.map((row) => ({
category: row.title,
description: row.text,
icon: row.icon,
}));
};
/*return fetch(categoryMetadataUrl)
.then((response) => response.text())
.then(parseCsv);*/
return Promise.resolve(parseCsv(sampleMetadataCsv));
}

View File

@@ -1,28 +1,20 @@
import * as d3 from "d3"; import * as d3 from "d3";
import { responsesSheetId, responsesSheetName } from "../config";
export const skills: { [key: string]: number } = {
"Keine Kenntnisse": 0,
"Geringe Kenntnisse": 1,
"Grundlegende Kenntnisse": 2,
"Gute Kenntnisse": 3,
"Sehr fundierte Kenntnisse": 4,
};
function mapSkillToNumber(skill: string): number { 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,
"Keine Kenntnisse": 0,
"Geringe Kenntnisse": 1,
"Grundlegende Kenntnisse": 2,
"Gute Kenntnisse": 3,
"Sehr fundierte Kenntnisse": 4,
};
return skills[skill] !== undefined ? skills[skill] : -1; return skills[skill] !== undefined ? skills[skill] : -1;
} }
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 { export interface ResponseData {
timestamp: number; timestamp: number;
position: string;
question: string; question: string;
response: number; response: number;
} }
@@ -40,7 +32,6 @@ export function parseCSV(csv: string): ResponseData[] {
}); });
return Object.entries(responses).flatMap(([category, response]) => ({ return Object.entries(responses).flatMap(([category, response]) => ({
timestamp: new Date(row["Timestamp"]).getTime(), timestamp: new Date(row["Timestamp"]).getTime(),
position: row["Position"],
question: category, question: category,
response: response, response: response,
})); }));
@@ -50,6 +41,7 @@ export function parseCSV(csv: string): ResponseData[] {
} }
export function fetchGoogleSheet() { export function fetchGoogleSheet() {
const url = `https://docs.google.com/spreadsheets/d/${responsesSheetId}/gviz/tq?tqx=out:csv&sheet=${responsesSheetName}`;
return fetch(url) return fetch(url)
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {

View File

@@ -1,10 +1,19 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./index.css";
import "./styles/index.scss";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<App />
</QueryClientProvider>
</StrictMode> </StrictMode>
); );

78
src/styles/App.scss Normal file
View File

@@ -0,0 +1,78 @@
@use "shared" as *;
.layout {
display: grid;
grid-template-areas:
"main qr"
"main instructions";
grid-template-columns: auto 400px;
grid-template-rows: 1fr 1fr;
grid-gap: 36px;
margin: 75px 50px;
}
.chart-container {
@include rounded;
@include shadow;
grid-area: main;
display: flex;
flex-direction: column;
align-items: center;
padding: 75px 50px;
margin: 0;
gap: 50px;
background-color: $bg-grey;
}
.charts {
display: flex;
flex-wrap: wrap;
gap: 25px;
}
.qr {
grid-area: qr;
}
.instructions {
grid-area: instructions;
}
.question-group {
@include shadow-small;
background-color: #ffffff;
border-radius: 4px;
padding: 28px 14px;
flex: 0 1 250px;
display: flex;
flex-direction: column;
align-items: start;
svg {
align-self: center;
}
p {
margin: 0;
}
.question-title {
margin-block: 13px;
font-size: 15px;
font-weight: 600;
.material-icons {
vertical-align: middle;
margin-right: 0.5rem;
}
}
}

View File

@@ -1,10 +1,12 @@
@use "shared" as *;
.dot { .dot {
stroke: #fff; stroke: #fff;
stroke-width: 1; stroke-width: 1;
} }
.selected { .latest {
fill: rgb(223 110 38); fill: rgba(255, 125, 67, 1); //$aai-orange;
} }
.axis-label { .axis-label {

View File

@@ -1,19 +1,26 @@
@use "shared" as *;
.legend { .legend {
.box { .box {
display: inline-block; display: inline-block;
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 4px; border-radius: 4px;
margin-right: 8px; margin-right: 5px;
vertical-align: middle; vertical-align: middle;
@include shadow;
} }
ul { ul {
list-style: none; list-style: none;
margin: 16px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px; gap: 20px;
padding: 0;
margin: 0;
} }
} }

106
src/styles/QRCode.scss Normal file
View File

@@ -0,0 +1,106 @@
@use "shared" as *;
.qr-code-container {
@include rounded;
@include shadow;
background-color: $aai-orange;
padding: 40px 25px;
color: $bg-grey;
display: flex;
flex-direction: column;
align-items: center;
p {
text-align: center;
font-size: 20px;
}
.qr-code {
display: flex;
justify-content: center;
background-color: #fff;
border-radius: $border-radius;
padding: 25px;
img {
display: block;
width: 150px;
}
}
}
.viewfinder {
$viewfinder-size: 24px;
$viewfinder-color: $aai-orange;
$viewfinder-width: 4px;
padding: $viewfinder-width;
background:
/* Top-left corner */ linear-gradient(
to right,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
linear-gradient(
to bottom,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
/* Top-right corner */
linear-gradient(
to left,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
linear-gradient(
to bottom,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
/* Bottom-left corner */
linear-gradient(
to right,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
linear-gradient(
to top,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
/* Bottom-right corner */
linear-gradient(
to left,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
),
linear-gradient(
to top,
$viewfinder-color 0%,
$viewfinder-color $viewfinder-size,
transparent $viewfinder-size
);
background-size:
/* Top-left */ 100% $viewfinder-width,
$viewfinder-width 100%, /* Top-right */ 100% $viewfinder-width,
$viewfinder-width 100%, /* Bottom-left */ 100% $viewfinder-width,
$viewfinder-width 100%, /* Bottom-right */ 100% $viewfinder-width,
$viewfinder-width 100%;
background-position:
/* Top-left */ 0 0, 0 0,
/* Top-right */ 100% 0, 100% 0, /* Bottom-left */ 0 100%, 0 100%,
/* Bottom-right */ 100% 100%, 100% 100%;
background-repeat: no-repeat;
}

27
src/styles/_shared.scss Normal file
View File

@@ -0,0 +1,27 @@
$aai-blue-dark: hsl(198.5deg 83.5% 19%);
$aai-blue-light: hsl(196.8deg 63% 55.5%);
$aai-green-dark: hsl(181deg 75% 37%);
$aai-green-light: hsl(127.5deg 100% 87.5%);
$aai-grey: #b7c6cf;
$aai-orange: hsl(18.6deg 100% 55.1%);
$bg-grey: #f8f9fa;
$border-radius: 30px;
@mixin rounded {
border-radius: $border-radius;
}
@mixin shadow {
box-shadow: 0px 4px 4px 0 rgba(0, 0, 0, 0.25);
}
@mixin shadow-small {
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.25);
}
@mixin no-shadow {
box-shadow: none;
}

32
src/styles/index.scss Normal file
View File

@@ -0,0 +1,32 @@
@use "shared" as *;
@use "normalize-scss" as normalize;
@include normalize.normalize();
:root {
font-family: "Work Sans", system-ui, Avenir, Helvetica, Arial, sans-serif;
font-weight: 400;
color-scheme: only light;
color: $aai-blue-dark;
background-color: #ffffff;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
margin: 0;
}
a {
font-weight: 500;
color: $aai-blue-dark;
text-decoration: inherit;
}
a:hover {
color: $aai-blue-light;
}

View File

@@ -1,9 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "nodenext", "skipLibCheck": true,
"moduleResolution": "nodenext", "module": "ESNext",
"allowSyntheticDefaultImports": true "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import react from "@vitejs/plugin-react";
import react from '@vitejs/plugin-react' import { defineConfig } from "vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) });