Compare commits
10 Commits
3498e45ab2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af9f42e66 | ||
|
|
7fb60ad2ed | ||
|
|
5151fd8b73 | ||
|
|
176f68e32d | ||
|
|
71e5131aa1 | ||
|
|
8aa8932122 | ||
|
|
98f2c0436b | ||
|
|
fa8a0e4763 | ||
|
|
f3f490d960 | ||
|
|
509b765213 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -12,6 +12,12 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import js from "@eslint/js";
|
||||
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
@@ -7,18 +8,21 @@ import tseslint from "typescript-eslint";
|
||||
export default tseslint.config(
|
||||
{ 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}"],
|
||||
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 },
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
|
||||
80
package-lock.json
generated
80
package-lock.json
generated
@@ -10,13 +10,17 @@
|
||||
"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",
|
||||
"normalize-scss": "^8.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sass": "^1.89.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.81.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/react": "^19.1.8",
|
||||
@@ -2089,6 +2093,76 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/eslint-plugin-query": {
|
||||
"version": "5.81.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.81.2.tgz",
|
||||
"integrity": "sha512-h4k6P6fm5VhKP5NkK+0TTVpGGyKQdx6tk7NYYG7J7PkSu7ClpLgBihw7yzK8N3n5zPaF3IMyErxfoNiXWH/3/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.82.0.tgz",
|
||||
"integrity": "sha512-JrjoVuaajBQtnoWSg8iaPHaT4mW73lK2t+exxHNOSMqy0+13eKLqJgTKXKImLejQIfdAHQ6Un0njEhOvUtOd5w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.81.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.81.2.tgz",
|
||||
"integrity": "sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.82.0.tgz",
|
||||
"integrity": "sha512-mnk8/ofKEthFeMdhV1dV8YXRf+9HqvXAcciXkoo755d/ocfWq7N/Y9jGOzS3h7ZW9dDGwSIhs3/HANWUBsyqYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.82.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.82.0.tgz",
|
||||
"integrity": "sha512-MC05Zq3zr/59jhgF7dL6JSGPg1krbasDSizmRxjNcvxgh/sUTwRFD9CGN10YYX7LB6jq0ZpFtCjSVGdLiFrKAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.81.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.82.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
@@ -4492,6 +4566,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-scss": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-scss/-/normalize-scss-8.0.0.tgz",
|
||||
"integrity": "sha512-C6GXIxQ2LOYWrde27xWbONavmybobxp+V6TY8BiBJw5M+yMNEg2R0WjaeDtmP5JsunFYKvFOvgMAIC0/OxZuJQ==",
|
||||
"license": "(MIT OR GPL-2.0)"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -9,16 +9,23 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions"
|
||||
],
|
||||
"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",
|
||||
"normalize-scss": "^8.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sass": "^1.89.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.81.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/react": "^19.1.8",
|
||||
|
||||
112
public/placeholder-instructions.svg
Normal file
112
public/placeholder-instructions.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 110 KiB |
@@ -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 |
73
src/App.tsx
73
src/App.tsx
@@ -1,60 +1,71 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 QRCode from "./components/QRCode";
|
||||
import { QuestionGroupChart } from "./components/QuestionGroupChart";
|
||||
import { WaffleChart } from "./components/WaffleChart";
|
||||
|
||||
import { config } from "./config";
|
||||
import { CategoryMetadata, fetchCategoryMetadata } from "./lib/metadata";
|
||||
import { fetchGoogleSheet, ResponseData } from "./lib/parser";
|
||||
import { getSampleData } from "./lib/data";
|
||||
import "./styles/App.scss";
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState<ResponseData[]>([]);
|
||||
const [categoryMetadata, setCategoryMetadata] = useState<CategoryMetadata[]>(
|
||||
[]
|
||||
const metadataQuery = useQuery({
|
||||
queryKey: ["categoryMetadata"],
|
||||
queryFn: fetchCategoryMetadata,
|
||||
});
|
||||
const responseQuery = useQuery({
|
||||
queryKey: ["responses"],
|
||||
// queryFn: fetchGoogleSheet,
|
||||
queryFn: getSampleData,
|
||||
refetchInterval: config.refreshIntervalSeconds * 1000,
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleSheet().then(setData);
|
||||
// setData(getSampleData());
|
||||
|
||||
fetchCategoryMetadata().then(setCategoryMetadata);
|
||||
}, []);
|
||||
|
||||
if (!data.length) return null;
|
||||
const categoryMetadata = metadataQuery.data;
|
||||
|
||||
// Group data by question (outside the component)
|
||||
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 (
|
||||
<>
|
||||
<div className="layout">
|
||||
<div className="chart-container">
|
||||
<div className="charts">
|
||||
{questionGroups.map(([question, groupData]) => (
|
||||
<QuestionGroupChart
|
||||
<WaffleChart
|
||||
key={question}
|
||||
question={question}
|
||||
metadata={categoryMetadata.find((m) => m.category === question)}
|
||||
groupData={groupData}
|
||||
responses={sortedResponses}
|
||||
xScale={xScale}
|
||||
responses={[...new Set(responses.map((d) => d.response))]}
|
||||
latestResponseTimestamp={
|
||||
responses[responses.length - 1].timestamp
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Legend />
|
||||
</div>
|
||||
<div className="qr">
|
||||
<QRCode />
|
||||
</>
|
||||
</div>
|
||||
<div className="instructions">
|
||||
<Instructions />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
7
src/components/Instructions.tsx
Normal file
7
src/components/Instructions.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function Instructions() {
|
||||
return (
|
||||
<>
|
||||
<img width="400" src="/placeholder-instructions.svg" alt="Instructions" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { colorScheme } from "../config";
|
||||
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() {
|
||||
return (
|
||||
<div className="legend">
|
||||
<ul>
|
||||
{Object.entries(labels).map(([level, label]) => (
|
||||
{Object.entries(skills).map(([label, level]) => (
|
||||
<li key={level}>
|
||||
<span
|
||||
className="box"
|
||||
|
||||
@@ -8,8 +8,12 @@ export default function QRCode() {
|
||||
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="/qr_code_ki-skills-umfrage.png" />
|
||||
<div className="qr-code">
|
||||
<img
|
||||
className="viewfinder"
|
||||
src="/qr_code_ki-skills-umfrage.png"
|
||||
alt="QR Code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import MaterialIcon from "@mui/material/Icon";
|
||||
import * as d3 from "d3";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { colorScheme, config } from "../config";
|
||||
|
||||
import { CategoryMetadata } from "../lib/metadata";
|
||||
import { ResponseData } from "../lib/parser";
|
||||
|
||||
import "./Chart.css";
|
||||
import "../styles/Chart.scss";
|
||||
|
||||
interface QuestionGroupChartProps {
|
||||
question: string;
|
||||
metadata?: CategoryMetadata;
|
||||
groupData: { response: string }[];
|
||||
responses: string[];
|
||||
xScale: d3.ScaleBand<string>;
|
||||
groupData: ResponseData[];
|
||||
responses: number[];
|
||||
latestResponseTimestamp: number;
|
||||
}
|
||||
|
||||
function makeDot(
|
||||
g: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
dotX: number,
|
||||
dotY: number
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): d3.Selection<any, unknown, null, undefined> {
|
||||
const shape = config.dotShape;
|
||||
if (shape === "circle") {
|
||||
return g
|
||||
@@ -40,23 +42,30 @@ function makeDot(
|
||||
throw new Error(`Unsupported shape: ${shape}`);
|
||||
}
|
||||
|
||||
export function QuestionGroupChart({
|
||||
export function WaffleChart({
|
||||
question,
|
||||
metadata,
|
||||
groupData,
|
||||
responses,
|
||||
xScale,
|
||||
latestResponseTimestamp,
|
||||
}: QuestionGroupChartProps) {
|
||||
const svgRef = useRef(null);
|
||||
|
||||
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
|
||||
d3.select(svgRef.current).selectAll("*").remove();
|
||||
|
||||
// Group responses by category (within this group only)
|
||||
const responseGroups = d3.group(groupData, (d) => d.response);
|
||||
const chartHeight = config.chartHeight;
|
||||
const chartWidth = xScale.range()[1];
|
||||
|
||||
const svg = d3
|
||||
.select(svgRef.current)
|
||||
@@ -64,25 +73,45 @@ export function QuestionGroupChart({
|
||||
.attr("height", chartHeight);
|
||||
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
|
||||
responses.forEach((response) => {
|
||||
sortedResponses.forEach((response) => {
|
||||
const responseData = responseGroups.get(response) || [];
|
||||
const x = xScale(response) || 0;
|
||||
responseData.forEach((_, index) => {
|
||||
// Use xScale(response) as the group center
|
||||
const x = (xScale(response) || 0) + config.dotRadius;
|
||||
responseData.forEach((entry, 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 - (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 (
|
||||
<div className="question-group">
|
||||
<svg ref={svgRef}></svg>
|
||||
@@ -94,5 +123,3 @@ export function QuestionGroupChart({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuestionGroupChart;
|
||||
@@ -1,15 +1,17 @@
|
||||
const dotRadius = 8;
|
||||
const dotSpacing = 2;
|
||||
const dotRadius = 7;
|
||||
const dotSpacing = 1;
|
||||
const columnsPerGroup = 3;
|
||||
const groupGap = 8;
|
||||
const groupGap = dotRadius;
|
||||
|
||||
export const config = {
|
||||
dotRadius,
|
||||
dotSpacing,
|
||||
columnsPerGroup,
|
||||
groupSpacing: (2 * dotRadius + dotSpacing) * columnsPerGroup + groupGap,
|
||||
chartHeight: 150,
|
||||
chartHeight: 50,
|
||||
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
|
||||
@@ -26,3 +28,6 @@ export const colorScheme = Object.fromEntries(
|
||||
|
||||
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";
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import { fetchCategoryMetadata } from "./metadata";
|
||||
import { ResponseData } from "./parser";
|
||||
|
||||
export function getSampleData(): ResponseData[] {
|
||||
const questions = [
|
||||
"Service Quality",
|
||||
"Value for Money",
|
||||
"Ease of Use",
|
||||
"Recommendation",
|
||||
"Overall Satisfaction",
|
||||
"Customer Support",
|
||||
"Product Features",
|
||||
];
|
||||
function randInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
export async function getSampleData(): Promise<ResponseData[]> {
|
||||
// Use the actual categories
|
||||
const questions = (await fetchCategoryMetadata()).map(
|
||||
(metadata) => metadata.category
|
||||
);
|
||||
const sampleData: ResponseData[] = [];
|
||||
let id = 1;
|
||||
questions.forEach((question) => {
|
||||
const numResponses = Math.floor(Math.random() * 50) + 30;
|
||||
const numResponses = randInt(10, 20);
|
||||
for (let i = 0; i < numResponses; i++) {
|
||||
const response = Math.floor(Math.random() * 5);
|
||||
questions.forEach((question) => {
|
||||
const response = randInt(0, 4); // Likert scale response (0-4)
|
||||
sampleData.push({
|
||||
timestamp: id++,
|
||||
position: "",
|
||||
timestamp: i, // Group all responses by the same timestamp to mimic Google Forms behavior
|
||||
question: question,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
});
|
||||
return sampleData;
|
||||
}
|
||||
return Promise.resolve(sampleData);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface CategoryMetadata {
|
||||
}
|
||||
|
||||
const sampleMetadataCsv = `title,text,icon
|
||||
Allgemeines KI-Wissen,Grundlegendes Wissen über Künstliche Intelligenz und deren Anwendung in Organisationen,school
|
||||
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
|
||||
@@ -18,7 +18,7 @@ Python-Programmierung,Grundlegende Programmier-kenntnisse zur Umsetzung und Anpa
|
||||
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
|
||||
GenAI-Kenntnisse,Verständnis generativer KI-Modelle (z. B. Large Language Models) und ihrer praktischen Nutzung,auto_awesome`;
|
||||
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[] => {
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import * as d3 from "d3";
|
||||
import { responsesSheetId, responsesSheetName } from "../config";
|
||||
|
||||
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,
|
||||
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 {
|
||||
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 {
|
||||
timestamp: number;
|
||||
position: string;
|
||||
question: string;
|
||||
response: number;
|
||||
}
|
||||
@@ -40,7 +32,6 @@ export function parseCSV(csv: string): ResponseData[] {
|
||||
});
|
||||
return Object.entries(responses).flatMap(([category, response]) => ({
|
||||
timestamp: new Date(row["Timestamp"]).getTime(),
|
||||
position: row["Position"],
|
||||
question: category,
|
||||
response: response,
|
||||
}));
|
||||
@@ -50,6 +41,7 @@ export function parseCSV(csv: string): ResponseData[] {
|
||||
}
|
||||
|
||||
export function fetchGoogleSheet() {
|
||||
const url = `https://docs.google.com/spreadsheets/d/${responsesSheetId}/gviz/tq?tqx=out:csv&sheet=${responsesSheetName}`;
|
||||
return fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
import "./styles/index.scss";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools />
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
78
src/styles/App.scss
Normal file
78
src/styles/App.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
@use "shared" as *;
|
||||
|
||||
.dot {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.selected {
|
||||
fill: rgb(223 110 38);
|
||||
.latest {
|
||||
fill: rgba(255, 125, 67, 1); //$aai-orange;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
@@ -6,18 +6,21 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
@include shadow;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 16px;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
.qr-code-container {
|
||||
@include rounded;
|
||||
@include shadow;
|
||||
|
||||
background-color: $aai-orange;
|
||||
flex-basis: 20%;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
padding: 40px 25px;
|
||||
|
||||
color: #fff;
|
||||
color: $bg-grey;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -16,14 +15,92 @@
|
||||
|
||||
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;
|
||||
margin: 0 auto;
|
||||
background-color: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
width: 50%;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,47 +4,24 @@ $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: hsl(202.5deg 20% 76.5%);
|
||||
$aai-grey: #b7c6cf;
|
||||
$aai-orange: hsl(18.6deg 100% 55.1%);
|
||||
|
||||
$bg-grey: #f8f9fa;
|
||||
|
||||
$border-radius: 30px;
|
||||
@mixin rounded {
|
||||
border-radius: 16px;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
|
||||
@mixin shadow {
|
||||
filter: drop-shadow(0 16px 16px rgb($aai-grey, 80%));
|
||||
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 {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
$border-radius: 16px;
|
||||
|
||||
@mixin button {
|
||||
@include shadow;
|
||||
|
||||
background-color: $aai-action-button;
|
||||
color: #ffffff;
|
||||
|
||||
border-radius: $border-radius;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
&:active {
|
||||
@include no-shadow;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1 auto;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
font-size: 16px;
|
||||
}
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
@use "shared" as *;
|
||||
@use "normalize-scss" as normalize;
|
||||
|
||||
@include normalize.normalize();
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-family: "Work Sans", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: only light;
|
||||
color: #213547;
|
||||
color: $aai-blue-dark;
|
||||
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,
|
||||
@@ -27,8 +18,7 @@ h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -40,74 +30,3 @@ a {
|
||||
a:hover {
|
||||
color: $aai-blue-light;
|
||||
}
|
||||
|
||||
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 {
|
||||
@include rounded;
|
||||
@include shadow;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.question-group {
|
||||
@include rounded;
|
||||
@include shadow;
|
||||
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
flex: 1 1 300px;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.question-title .material-icons {
|
||||
vertical-align: top;
|
||||
margin-right: 1ex;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"allowSyntheticDefaultImports": true
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user