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
*.local
# TypeScript cache
*.tsbuildinfo
# Optional eslint cache
.eslintcache
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

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

View File

@@ -2,17 +2,21 @@
<html lang="en">
<head>
<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" />
<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"
/>
<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>
<body>
<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 .",
"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"
"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",

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 { 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 { getSampleData } from "./lib/data";
import { ResponseData } from "./lib/parser";
import "./styles/App.scss";
function App() {
const [data, setData] = useState<ResponseData[]>([]);
useEffect(() => {
// fetchGoogleSheet().then(setData);
setData(getSampleData());
}, []);
const metadataQuery = useQuery({
queryKey: ["categoryMetadata"],
queryFn: fetchCategoryMetadata,
});
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)
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>
<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 "./Legend.css";
const labels = {
0: "Keine Erfahrung",
1: "Grundkenntnisse",
2: "Geübte Anwendung",
3: "Sichere Praxisanwendung",
4: "Fachwissen und Erfahrung",
};
import { skills } from "../lib/parser";
import "../styles/Legend.scss";
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"

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() {
return (
@@ -8,8 +8,12 @@ export default function QRCode() {
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 className="qr-code">
<img
className="viewfinder"
src="/qr_code_ki-skills-umfrage.png"
alt="QR Code"
/>
</div>
</div>
);

View File

@@ -1,21 +1,27 @@
import MaterialIcon from "@mui/material/Icon";
import * as d3 from "d3";
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 {
question: string;
groupData: { response: string }[];
responses: string[];
xScale: d3.ScaleBand<string>;
metadata?: CategoryMetadata;
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
@@ -36,22 +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)
@@ -59,37 +73,53 @@ 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>
<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 className="question-title">
{metadata?.icon && <MaterialIcon>{metadata.icon}</MaterialIcon>}
{question}
</div>
<p>{metadata?.description || question}</p>
</div>
);
}
export default QuestionGroupChart;

View File

@@ -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
@@ -23,3 +25,9 @@ const aaiColors = [
export const colorScheme = Object.fromEntries(
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";
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;
for (let i = 0; i < numResponses; i++) {
const response = Math.floor(Math.random() * 5);
const numResponses = randInt(10, 20);
for (let i = 0; i < numResponses; i++) {
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);
}

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

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 { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
import "./styles/index.scss";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<App />
</QueryClientProvider>
</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 {
stroke: #fff;
stroke-width: 1;
}
.selected {
fill: rgb(223 110 38);
.latest {
fill: rgba(255, 125, 67, 1); //$aai-orange;
}
.axis-label {

View File

@@ -1,19 +1,26 @@
@use "shared" as *;
.legend {
.box {
display: inline-block;
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;
}
}

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": {
"composite": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"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/
export default defineConfig({
plugins: [react()],
})
});