diff --git a/CMakeLists.txt b/CMakeLists.txt index 39daef98348177ba4f33506209a005aaff59aaa9..335be2c5c2a136f16b5f07d47975f5767803a4ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.21) project( bewertunggemischterklausuren - VERSION 0.5.0 + VERSION 0.6.0 LANGUAGES CXX ) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ccaff218e14e66ed6574a2a9a8e1cfce6b16ef2a..65ef49f5f7beb95e446a4fa77946ec5116bdf593 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -41,14 +41,19 @@ set(PROJECT_SOURCES util/defaults.hpp util/enums.hpp + util/exception.hpp + util/font.hpp + util/font.cpp util/grade.hpp util/grade_boundary.cpp util/grade_boundary.hpp util/grade_boundary_step.hpp + util/heuristics.cpp util/heuristics.hpp util/points.cpp util/points.hpp util/storage_helper.hpp + util/strings.hpp view/boundary_window/boundary_tab.cpp view/boundary_window/boundary_tab.hpp diff --git a/src/data/bonus_points.cpp b/src/data/bonus_points.cpp index 736fdd6d935303fccbbab7f03aa90412548fed87..b974b934d8158eaa34956dd15a8dd6fd4cfdb20c 100644 --- a/src/data/bonus_points.cpp +++ b/src/data/bonus_points.cpp @@ -1,13 +1,37 @@ #include "bonus_points.hpp" +#include "util/exception.hpp" +#include "util/heuristics.hpp" +#include "util/strings.hpp" + #include <algorithm> +#include <optional> #include <ranges> #include <stdexcept> #include <string> BonusPoints::BonusPoints(const CsvDump &c) noexcept(false) : mBonusPoints(c) { if (c.data.empty()) - throw std::invalid_argument("Read bonus-CSV: Empty file"); + throw BonusParseException(str::error::empty); + + if (c.data[0].empty()) + throw BonusParseException(str::error::noHeader); + + // Quick check: reading wrong file type? + { + const auto fileType = + getApproximateFileType(QString::fromStdString(c.data[0][0])); + + switch (fileType) { + case FileType::dynexite: + throw BonusParseException(str::error::unexpectedDynexite); + case FileType::rwth: + throw BonusParseException(str::error::unexpectedRwthOnline); + default: + // the file might still be invalid, but that will be decided later + break; + } + } const auto nrRows = c.data.size(); @@ -28,23 +52,45 @@ BonusPoints::BonusPoints(const CsvDump &c) noexcept(false) : mBonusPoints(c) { // Throw if relevant columns not found if (registrationNrColumn >= c.data[0].size()) - throw std::invalid_argument( - "Read bonus-CSV: No registration nr column found"); + throw BonusParseException(str::error::noIdColumn); if (bonusPointColumn >= c.data[0].size()) - throw std::invalid_argument( - "Read bonus-CSV: No bonus point column found"); + throw BonusParseException(str::error::noBonusPointColumn); // Insert relevant data into lists for (std::size_t i = 1; i < nrRows; ++i) { + // Registration number column sometimes contains an email address // instead of a registration number. Todo: Try to match them up via name + std::optional<int> registrationNr; + std::optional<Points> bonusPoints; + + // If stoi fails, registrationNr is empty try { - mRegistrationNr.emplace_back( - std::stoi(c.data[i][registrationNrColumn])); + registrationNr = std::stoi(c.data[i][registrationNrColumn]); } catch (...) { - continue; } - mNrBonusPoints.emplace_back( - stringToPoints(c.data[i][bonusPointColumn])); + + // If stringToPoints fails, bonusPoints is empty + try { + bonusPoints = stringToPoints(c.data[i][bonusPointColumn]); + } catch (...) { + } + + // Only add ID and points to the lists if both can be successfully + // parsed + if (bonusPoints && registrationNr) { + mRegistrationNr.emplace_back(*registrationNr); + mNrBonusPoints.emplace_back(*bonusPoints); + } + // Write the student to the invalid registration number list + // instead, to generate a warning later on + // Ignore the error if the student got no bonus points anyway + else if (!bonusPoints || (!registrationNr && bonusPoints->p != 0.)) { + mInvalidRegNrs.emplace_back(c.data[i][registrationNrColumn], + c.data[i][bonusPointColumn]); + } } + + if (mRegistrationNr.size() != mNrBonusPoints.size()) + throw BonusParseException(str::error::bonusArraySizeMismatch); } diff --git a/src/data/bonus_points.hpp b/src/data/bonus_points.hpp index 3d3ba76964bda4f7cbee9a5a7d617e0deb65273b..311b600b92dc8f949b9b12f71961cb88b7ad7055 100644 --- a/src/data/bonus_points.hpp +++ b/src/data/bonus_points.hpp @@ -4,6 +4,9 @@ #include "util/points.hpp" #include <QList> +#include <QPair> + +#include <string> struct BonusPoints { BonusPoints() = default; @@ -12,6 +15,10 @@ struct BonusPoints { QList<int> mRegistrationNr{}; QList<Points> mNrBonusPoints{}; + // A list of all students with invalid (non-numerical) registration numbers + // and their respective bonus point count + QList<QPair<std::string, std::string>> mInvalidRegNrs{}; + // Store the entire csv dump so that we can more easily export it later CsvDump mBonusPoints; }; diff --git a/src/data/nodes/dynexite_exam.cpp b/src/data/nodes/dynexite_exam.cpp index f5ad45bbd0491e96354d86e04a11cacdc4b94fd4..21644115c10bd110e153b5a48cd7b78d6aac6f5f 100644 --- a/src/data/nodes/dynexite_exam.cpp +++ b/src/data/nodes/dynexite_exam.cpp @@ -1,6 +1,9 @@ #include "dynexite_exam.hpp" #include "util/enums.hpp" +#include "util/exception.hpp" +#include "util/heuristics.hpp" +#include "util/strings.hpp" #include <algorithm> #include <ranges> @@ -10,18 +13,17 @@ static void checkHeader(const std::string &found, const std::string &expected) noexcept(false) { if (found != expected) { - throw std::invalid_argument( - "Read dynexite-CSV: Header validation error: expected \"" + - expected + "\", found \"" + found + "\""); + throw DynexiteParseException("Header validation error: expected \"" + + expected + "\", found \"" + found + "\""); } } static void checkHeaderEndsWith(const std::string &found, const std::string &ending) noexcept(false) { if (!found.ends_with(ending)) { - throw std::invalid_argument( - "Read dynexite-CSV: Header validation error: expected ending \"" + - ending + "\", found string \"" + found + "\""); + throw DynexiteParseException( + "Header validation error: expected ending \"" + ending + + "\", found string \"" + found + "\""); } } @@ -33,8 +35,7 @@ static void validateHeaders(const CsvDump &c, // Do headers exist at all? if (c.data.empty()) - throw std::invalid_argument( - "Read dynexite-CSV: Header validation: no headers found"); + throw DynexiteParseException(str::error::empty); const auto &headers = c.data[0]; const auto nrColumns = c.data[0].size(); @@ -65,14 +66,33 @@ static void validateHeaders(const CsvDump &c, DynexiteExam::DynexiteExam(const CsvDump &c) noexcept(false) : mDynexite(c) { if (c.data.empty()) - throw std::invalid_argument("Read dynexite-CSV: Empty file"); + throw DynexiteParseException(str::error::empty); + + if (c.data[0].empty()) + throw DynexiteParseException(str::error::noHeader); + + // Quick check: reading wrong file type? + { + const auto fileType = + getApproximateFileType(QString::fromStdString(c.data[0][0])); + + switch (fileType) { + case FileType::bonus: + throw DynexiteParseException(str::error::unexpectedBonus); + case FileType::rwth: + throw DynexiteParseException(str::error::unexpectedRwthOnline); + default: + // the file might still be invalid, but that will be decided later + break; + } + } const auto nrRows = c.data.size(); const auto nrColumns = c.data[0].size(); // No data aside from header? if (nrRows < 2) - throw std::invalid_argument("Read dynexite-CSV: No data rows"); + throw DynexiteParseException(str::error::noData); // Non-attending students are listed as lines in the csv-file, but the lines // are empty aside from column 1, especially they are empty in line 0 @@ -83,14 +103,14 @@ DynexiteExam::DynexiteExam(const CsvDump &c) noexcept(false) : mDynexite(c) { // No-one attended? if (nrStudents <= 0) - throw std::invalid_argument("Read dynexite-CSV: No student attended?"); + throw DynexiteParseException(str::error::noStudents); // Assert that all necessary columns are there and the number of columns // makes sense (4 columns per task, 2 extra columns to the left and 12 extra // columns to the right) if (!(nrColumns > 14 && (nrColumns - 14) % 4 == 0)) - throw std::invalid_argument( - "Read dynexite-CSV: Unexpected number of columns"); + throw DynexiteParseException(std::string(str::error::invalidColumnNr) + + std::to_string(nrColumns)); const auto nrTasks = (nrColumns - 14) / 4; @@ -101,7 +121,7 @@ DynexiteExam::DynexiteExam(const CsvDump &c) noexcept(false) : mDynexite(c) { validateHeaders(c, nrTasks); if (!insertData(c, nrTasks)) { - throw std::invalid_argument("Read dynexite-CSV: Inserting data failed"); + throw DynexiteParseException(str::error::insertFailed); } /* todo: run some data sanity checks: diff --git a/src/data/nodes/student_data.cpp b/src/data/nodes/student_data.cpp index 8e926630001dba97c4b782e8e287899d51b4af46..503d1569478a7f2fb637af99bf8dc976755d92ae 100644 --- a/src/data/nodes/student_data.cpp +++ b/src/data/nodes/student_data.cpp @@ -1,5 +1,9 @@ #include "student_data.hpp" +#include "util/exception.hpp" +#include "util/heuristics.hpp" +#include "util/strings.hpp" + #include <algorithm> #include <array> #include <ranges> @@ -13,9 +17,9 @@ [[maybe_unused]] static void checkHeader(const std::string &found, const std::string &expected) { if (found != expected) { - throw std::invalid_argument( - "Read rwthonline-CSV: Header validation error: expected \"" + - expected + "\", found \"" + found + "\""); + throw RwthOnlineParseException("Header validation error: expected \"" + + expected + "\", found \"" + found + + "\""); } } @@ -26,8 +30,7 @@ static std::pair<std::size_t, std::size_t> validateHeaders(CsvDump &c) { // Do headers exist at all? if (c.data.empty()) - throw std::invalid_argument( - "Read rwthonline-CSV: Header validation: no headers found"); + throw RwthOnlineParseException(str::error::empty); auto &headers = c.data[0]; @@ -40,9 +43,11 @@ static std::pair<std::size_t, std::size_t> validateHeaders(CsvDump &c) { // ignore all other header names. #if !RWTHONLINE_PARSER_PERMISSIVE if (c.data[0].size() != 24) { - throw std::invalid_argument("Read rwthonline-CSV: Header validation: " - "Unexpected number of columns: " + - std::to_string(c.data[0].size())); + throw RwthOnlineParseException("Header validation: " + "Unexpected number of columns: " + + std::to_string(c.data[0].size()) + + " (To disable this check, set " + "RWTHONLINE_PARSER_PERMISSIVE to true)"); } static constexpr std::array headerStrings = {"STUDY_PROGRAMME", @@ -85,12 +90,10 @@ static std::pair<std::size_t, std::size_t> validateHeaders(CsvDump &c) { std::ranges::begin(c.data[0])); if (registrationNrIdx >= c.data[0].size()) { - throw std::invalid_argument( - "Read rwthonline-CSV: No registration number column found"); + throw RwthOnlineParseException(str::error::noIdColumn); } if (attemptNrIdx >= c.data[0].size()) { - throw std::invalid_argument( - "Read rwthonline-CSV: No attempt number column found"); + throw RwthOnlineParseException(str::error::noAttemptNrColumn); } return {registrationNrIdx, attemptNrIdx}; #endif @@ -98,13 +101,32 @@ static std::pair<std::size_t, std::size_t> validateHeaders(CsvDump &c) { StudentData::StudentData(CsvDump &c) noexcept(false) : mRwthOnline(c) { if (c.data.empty()) - throw std::invalid_argument("Read rwthonline-CSV: Empty file"); + throw RwthOnlineParseException(str::error::empty); + + if (c.data[0].empty()) + throw RwthOnlineParseException(str::error::noHeader); + + // Quick check: reading wrong file type? + { + const auto fileType = + getApproximateFileType(QString::fromStdString(c.data[0][0])); + + switch (fileType) { + case FileType::bonus: + throw RwthOnlineParseException(str::error::unexpectedBonus); + case FileType::dynexite: + throw RwthOnlineParseException(str::error::unexpectedDynexite); + default: + // the file might still be invalid, but that will be decided later + break; + } + } const auto nrRows = c.data.size(); // No data aside from header? if (nrRows < 2) - throw std::invalid_argument("Read rwthonline-CSV: No data rows"); + throw RwthOnlineParseException(str::error::noData); const auto nrStudents = nrRows - 1; reserveArrays(static_cast<qsizetype>(nrStudents)); @@ -119,7 +141,6 @@ void StudentData::reserveArrays(const qsizetype nrStudents) noexcept { mAttemptNr.reserve(nrStudents); } -// std::stoi might throw void StudentData::insertData(CsvDump &c, const std::size_t regIdx, const std::size_t attemptIdx) noexcept(false) { @@ -130,7 +151,25 @@ void StudentData::insertData(CsvDump &c, const std::size_t regIdx, std::erase(line[regIdx], '"'); std::erase(line[attemptIdx], '"'); - mRegistrationNr.emplaceBack(std::stoi(line[regIdx])); - mAttemptNr.emplaceBack(std::stoi(line[attemptIdx])); + try { + mRegistrationNr.emplaceBack(std::stoi(line[regIdx])); + } catch (...) { + throw RwthOnlineParseException( + std::string("Failed to parse registration number ") + + line[regIdx]); + } + + try { + mAttemptNr.emplaceBack(std::stoi(line[attemptIdx])); + } catch (...) { + throw RwthOnlineParseException( + std::string("Failed to parse attempt number ") + + line[attemptIdx]); + } + + // Assert that the two arrays don't get out of sync + if (mRegistrationNr.size() != mAttemptNr.size()) { + throw RwthOnlineParseException(str::error::rwthArraySizeMismatch); + } } } diff --git a/src/data/nodes/student_data.hpp b/src/data/nodes/student_data.hpp index ec5f7cedcd2fa25c5042e1c426e68f4da6cbecbe..7229212341ebd1754b2f6442e5d4282853731654 100644 --- a/src/data/nodes/student_data.hpp +++ b/src/data/nodes/student_data.hpp @@ -12,7 +12,7 @@ struct StudentData : Node<> { using Node<>::Node; /** - * \brief Interprets the CsvDump as a RWTHOnline-csv-file and copies all + * \brief Interprets the CsvDump as an RWTHOnline-csv-file and copies all * necessary info. Also runs sanity checks on the given data and throws on * error. * diff --git a/src/file_io/csv_parser.cpp b/src/file_io/csv_parser.cpp index f06ac35c36d9b4735e3773c18196c255e61785ce..7efa4365341a90d04d3998ea2350b28eb44de2d8 100644 --- a/src/file_io/csv_parser.cpp +++ b/src/file_io/csv_parser.cpp @@ -6,6 +6,8 @@ #include "csv_parser.hpp" +#include "util/strings.hpp" + #include <algorithm> #include <array> #include <cassert> @@ -168,7 +170,7 @@ std::unique_ptr<CsvDump> readCsv(const std::string &s) { } catch (std::runtime_error &) { } } - throw std::runtime_error("No valid delimiter found"); + throw std::runtime_error(str::error::csvDelimiter); } std::unique_ptr<CsvDump> readCsv(std::istream &in) { diff --git a/src/file_io/yml_parser.cpp b/src/file_io/yml_parser.cpp index a1eae9d8d2ffbf32ad60261971f327acfd802e79..a8e72a4fbfb8233c6e28f262f83c2f3d9bc44e89 100644 --- a/src/file_io/yml_parser.cpp +++ b/src/file_io/yml_parser.cpp @@ -3,6 +3,8 @@ #include "csv_parser.hpp" #include "data/data_manager.hpp" #include "util/enums.hpp" +#include "util/exception.hpp" +#include "util/strings.hpp" #include <algorithm> #include <fstream> @@ -264,7 +266,7 @@ std::unique_ptr<ExamSave> readYml(const std::filesystem::path &p) { std::string str; std::getline(infile, str, '\n'); if (str != "---") - throw std::invalid_argument{"Invalid yaml header"}; + throw D2RParseException{str::error::ymlHeader}; } // Read key-by-key until EOF diff --git a/src/model/input_model.cpp b/src/model/input_model.cpp index 739ad6c55a2d7a0d31d3f29946097bb91e987a5f..3373fd5474499a824fc03e6d95738e31e2f20b86 100644 --- a/src/model/input_model.cpp +++ b/src/model/input_model.cpp @@ -1,15 +1,23 @@ #include "input_model.hpp" #include "file_io/csv_parser.hpp" +#include "util/defaults.hpp" +#include "util/exception.hpp" +#include "util/strings.hpp" + +#include <QList> +#include <QMessageBox> +#include <QString> InputModel::InputModel(const FileType t) : mType(t) {} -bool InputModel::loadCsv(const std::filesystem::path &p) { +FileOpenError InputModel::loadCsv(const std::filesystem::path &p) { // We could move this to a separate thread to keep the interface // responsive, but it only takes 1ms on my machine, so let's not // bother. try { + FileOpenError error; if (mType == FileType::d2r) { mLoadedData.emplace<std::unique_ptr<ExamSave>>(readYml(p)); @@ -32,20 +40,45 @@ bool InputModel::loadCsv(const std::filesystem::path &p) { case FileType::bonus: mLoadedData.emplace<std::unique_ptr<BonusPoints>>( new BonusPoints(*dump)); + for (const auto ®Pair : + std::get<std::unique_ptr<BonusPoints>>(mLoadedData) + ->mInvalidRegNrs) { + error.mWarnings.append( + QString("Failed to add ") + + QString::fromStdString(regPair.second) + + " bonus points to student with registration number " + + QString::fromStdString(regPair.first)); + } break; } } - return true; - } catch ([[maybe_unused]] const std::exception &e) { - // csv file read or object instantiation failed! - // todo: look at error more closely, display what went wrong - return false; + return error; + } catch (const std::exception &e) { + switch (mType) { + default: + assert(false); + [[fallthrough]]; + case FileType::dynexite: + return FileOpenError{FileOpenErrorCode::UnknownError, + QString(str::error::defaultDynexite) + + e.what()}; + case FileType::rwth: + return FileOpenError{FileOpenErrorCode::UnknownError, + QString(str::error::defaultRwthOnline) + + e.what()}; + case FileType::bonus: + return FileOpenError{FileOpenErrorCode::UnknownError, + QString(str::error::defaultBonus) + e.what()}; + case FileType::d2r: + return FileOpenError{FileOpenErrorCode::UnknownError, + QString(str::error::defaultD2R) + e.what()}; + } } } std::variant<DynexiteExam *, StudentData *, BonusPoints *, ExamSave *> -InputModel::read() { +InputModel::read() const { std::variant<DynexiteExam *, StudentData *, BonusPoints *, ExamSave *> ret; if (std::holds_alternative<std::unique_ptr<DynexiteExam>>(mLoadedData)) { @@ -61,3 +94,90 @@ InputModel::read() { } return ret; } + +void warnIfLowStudentIntersection(QWidget *parent, + const InputModel &dynexiteModel, + const InputModel &rwthModel, + const InputModel &bonusModel, + const FileSelectState &bonusState) noexcept { + auto dynexiteStudents = + std::get<DynexiteExam *>(dynexiteModel.read())->mIdentifier; + auto rwthOnlineStudents = + std::get<StudentData *>(rwthModel.read())->mRegistrationNr; + auto bonusStudents = + bonusState == FileSelectState::ok + ? std::get<BonusPoints *>(bonusModel.read())->mRegistrationNr + : QList<int>{}; + + std::ranges::sort(dynexiteStudents); + std::ranges::sort(rwthOnlineStudents); + std::ranges::sort(bonusStudents); + + QList<int> dbIntersection, drIntersection; + + // Find out number of students that are both in the dynexite file and the + // RWTHonline/bonus file + std::ranges::set_intersection(dynexiteStudents, bonusStudents, + std::back_inserter(dbIntersection)); + std::ranges::set_intersection(dynexiteStudents, rwthOnlineStudents, + std::back_inserter(drIntersection)); + + QString warning = str::error::lowStudentIntersection; + QList<QString> singleWarnings; + + // Display warning if few students in dynexite are also in RWTHonline + if (static_cast<double>(drIntersection.size()) < + defaults::studentIntersectWarningThreshold * + static_cast<double>(dynexiteStudents.size())) { + const auto percentage = static_cast<double>(drIntersection.size()) / + static_cast<double>(dynexiteStudents.size()) * + 100.; + + if (percentage > 0.) { + singleWarnings.append( + "\n\nOnly " + QString::number(percentage, 'f', 2) + "% (" + + QString::number(drIntersection.size()) + " of " + + QString::number(dynexiteStudents.size()) + + ") of students in the dynexite file can be found in the " + "RWTHonline file."); + } else { + // Special string for no intersection + singleWarnings.append( + "\n\nNone of the students in the dynexite file can be found in " + "the RWTHonline file."); + } + } + + // Display warning if few students in dynexite are also in the bonus point + // file, but ignore this if no students are in the bonus point file since it + // is optional + if (!bonusStudents.empty() && + static_cast<double>(dbIntersection.size()) < + defaults::studentIntersectWarningThreshold * + static_cast<double>(dynexiteStudents.size())) { + const auto percentage = static_cast<double>(dbIntersection.size()) / + static_cast<double>(dynexiteStudents.size()) * + 100.; + + if (percentage > 0.) { + singleWarnings.append( + "\n\nOnly " + QString::number(percentage, 'f', 2) + "% (" + + QString::number(dbIntersection.size()) + " of " + + QString::number(dynexiteStudents.size()) + + ") of students in the dynexite file can be found in the bonus " + "point file."); + } else { + singleWarnings.append( + "\n\nNone of the students in the dynexite file can be found in " + "the bonus point file."); + } + } + + // Display all accumulated warnings with a standard prefix + if (!singleWarnings.empty()) { + for (const auto &s : singleWarnings) { + warning += s; + } + QMessageBox::warning(parent, "Reading file warnings", warning); + } +} diff --git a/src/model/input_model.hpp b/src/model/input_model.hpp index 4f84c587063ad21379afb72c7b7d5427d0abc2c2..2b1d0568b0b95b69446b08baf65755aa624b043a 100644 --- a/src/model/input_model.hpp +++ b/src/model/input_model.hpp @@ -6,11 +6,29 @@ #include "file_io/yml_parser.hpp" #include "util/enums.hpp" +#include <QList> +#include <QWidget> + #include <filesystem> #include <memory> #include <utility> #include <variant> +enum class FileOpenErrorCode { + Success, + OsError, + CsvParseError, + YmlParseError, + UnknownError +}; + +struct FileOpenError { + FileOpenErrorCode mEc = FileOpenErrorCode::Success; + QString mDetails{}; + + QList<QString> mWarnings{}; +}; + class InputModel { public: /** @@ -27,7 +45,7 @@ public: * * \return Success */ - bool loadCsv(const std::filesystem::path &p); + FileOpenError loadCsv(const std::filesystem::path &p); /** * \brief Which file type this model stores. @@ -42,10 +60,18 @@ public: [[nodiscard]] auto get() { return std::move(mLoadedData); } [[nodiscard]] std::variant<DynexiteExam *, StudentData *, BonusPoints *, ExamSave *> - read(); + read() const; private: std::variant<std::unique_ptr<DynexiteExam>, std::unique_ptr<StudentData>, std::unique_ptr<BonusPoints>, std::unique_ptr<ExamSave>> mLoadedData{}; }; + +// Display a warning pop-up if too low an amount of students in the dynexite +// file are also in the RWTHonline file todo: noexcept? +void warnIfLowStudentIntersection(QWidget *parent, + const InputModel &dynexiteModel, + const InputModel &rwthModel, + const InputModel &bonusModel, + const FileSelectState &bonusState) noexcept; diff --git a/src/util/defaults.hpp b/src/util/defaults.hpp index c4d2892b3002020d0598a24e549992a68a74c571..dc0ea09b89b53e369dde50132ddeec5e0e143a5d 100644 --- a/src/util/defaults.hpp +++ b/src/util/defaults.hpp @@ -25,6 +25,12 @@ inline constexpr bool requirePassAllParts = false; //----------------------------------------------------------------------- +/** \brief Percentage of window width of the grade tab, which is given to the + * grade histogram. Between 0-100. */ +inline constexpr int gradeTabHistogramWidthPercentage = 70; + +//----------------------------------------------------------------------- + /** \brief Maximum selectable open points passing limit. */ inline constexpr double openPointsPassingLimit = 0.5; @@ -40,4 +46,16 @@ inline constexpr double maxBucketSize = 5.; /** \brief Point histogram bucket size selector step size. */ inline constexpr double bucketStep = 0.25; +//----------------------------------------------------------------------- + +/** \brief The factor by which the small font size is smaller than the large + * font size. The small font size is never smaller than the default font size.*/ +inline constexpr double smallFontSizeRatio = 0.5; + +//----------------------------------------------------------------------- + +/** \brief If less than this share of dynexite students are in the RWTHonline or + * bonus point file, issue a warning */ +inline constexpr double studentIntersectWarningThreshold = 0.5; + } // namespace defaults diff --git a/src/util/exception.hpp b/src/util/exception.hpp new file mode 100644 index 0000000000000000000000000000000000000000..bcd81faf6c067547ce9d1322f15b6f5f1ffd0c3c --- /dev/null +++ b/src/util/exception.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include <stdexcept> + +struct DynexiteParseException : public std::invalid_argument { + using std::invalid_argument::invalid_argument; +}; +struct RwthOnlineParseException : public std::invalid_argument { + using std::invalid_argument::invalid_argument; +}; +struct BonusParseException : public std::invalid_argument { + using std::invalid_argument::invalid_argument; +}; +struct D2RParseException : public std::invalid_argument { + using std::invalid_argument::invalid_argument; +}; diff --git a/src/util/font.cpp b/src/util/font.cpp new file mode 100644 index 0000000000000000000000000000000000000000..41db0f02fe8165173774a103224b1587a8c8955f --- /dev/null +++ b/src/util/font.cpp @@ -0,0 +1,50 @@ +#include "font.hpp" + +#include <QGuiApplication> + +#include <cassert> +#include <qscreen.h> + +qreal FontSize::getPixelSize() const noexcept { + + if (unit == FontSizeUnit::Pixel) { + return size; + } else { + return ptToPx(size, + QGuiApplication::primaryScreen()->logicalDotsPerInch()); + } +} + +std::partial_ordering FontSize::operator<=>(const FontSize &that) const { + + if (unit == that.unit) { + return size <=> that.size; + } else if (unit == FontSizeUnit::Pixel) { + return pxToPt( + size, + QGuiApplication::primaryScreen()->logicalDotsPerInch()) <=> + that.size; + } else { + return size <=> + pxToPt(that.size, + QGuiApplication::primaryScreen()->logicalDotsPerInch()); + } +} + +QDebug operator<<(QDebug debug, const FontSize &object) { + debug.nospace() << object.size + << (object.unit == FontSizeUnit::Pixel ? "px" : "pt"); + return debug.maybeSpace(); +} + +void setFontSize(QFont &f, const FontSize size) noexcept { + + // point / pixel size must be greater than zero + assert(size.size > 0); + + if (size.unit == FontSizeUnit::Pixel) { + f.setPixelSize(static_cast<int>(size.size)); + } else { + f.setPointSizeF(size.size); + } +} diff --git a/src/util/font.hpp b/src/util/font.hpp new file mode 100644 index 0000000000000000000000000000000000000000..12ef934feaf9b540bf9f914c5537b1e6a6a3c5e8 --- /dev/null +++ b/src/util/font.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include <QDebug> +#include <QFont> + +enum class FontSizeUnit { Pixel, Point }; + +struct FontSize { + qreal size; + FontSizeUnit unit; + + [[nodiscard]] qreal getPixelSize() const noexcept; + + std::partial_ordering operator<=>(const FontSize &that) const; + + template <typename T> + constexpr FontSize &operator*=(const T &rhs) noexcept { + size *= rhs; + return *this; + } +}; + +template <typename T> +[[nodiscard]] constexpr FontSize operator*(FontSize lhs, + const T &rhs) noexcept { + lhs *= rhs; + return lhs; +} + +QDebug operator<<(QDebug debug, const FontSize &object); + +// A point is 1/72 of an inch +[[nodiscard]] constexpr double ptToPx(const double pt, + const double dpi) noexcept { + return pt / 72 * dpi; +} + +[[nodiscard]] constexpr double pxToPt(const double px, + const double dpi) noexcept { + return px * 72 / dpi; +} + +void setFontSize(QFont &f, FontSize size) noexcept; diff --git a/src/util/heuristics.cpp b/src/util/heuristics.cpp new file mode 100644 index 0000000000000000000000000000000000000000..05b4feb7b996f089594de03628664dc775deb5e3 --- /dev/null +++ b/src/util/heuristics.cpp @@ -0,0 +1,17 @@ +#include "heuristics.hpp" + +#include "util/enums.hpp" + +FileType getApproximateFileType(const QString &s) noexcept { + if (QString::compare(s, QString("STUDY_PROGRAMME"), Qt::CaseInsensitive) == + 0) { + return FileType::rwth; + } + if (QString::compare(s, QString("AttemptID"), Qt::CaseInsensitive) == 0) { + return FileType::dynexite; + } + if (QString::compare(s, QString("Vorname"), Qt::CaseInsensitive) == 0) { + return FileType::bonus; + } + return FileType::NR_VALS; +} diff --git a/src/util/heuristics.hpp b/src/util/heuristics.hpp index 72f46c7ef50f228686fe62f8cbf4762646feabd0..c3a959d3b03a4f549b3281932b9942994befab3e 100644 --- a/src/util/heuristics.hpp +++ b/src/util/heuristics.hpp @@ -1,10 +1,10 @@ #pragma once +#include "data/nodes/dynexite_exam.hpp" #include "util/grade_boundary_step.hpp" #include "util/points.hpp" -#include "data/nodes/dynexite_exam.hpp" - +#include <QString> #include <QtGlobal> #include <algorithm> @@ -50,6 +50,9 @@ guessGradeBoundaryStepSize(const DynexiteExam &exam) noexcept { exam.mTaskMaxPoints); } +// Guess by the left-most header what file type this is +[[nodiscard]] FileType getApproximateFileType(const QString &s) noexcept; + //----------------------------------------------------------------------- // IMPLEMENTATION //----------------------------------------------------------------------- diff --git a/src/util/points.cpp b/src/util/points.cpp index 29bef39b0e780618a478a5f162ff2d54441d1660..99bfdc44d66132fc5b3d977578f03340d14e16e7 100644 --- a/src/util/points.cpp +++ b/src/util/points.cpp @@ -2,6 +2,7 @@ #include <algorithm> #include <ranges> +#include <stdexcept> /** * \brief Call std::stod, but interpret an empty string or "-" as 0. @@ -12,5 +13,12 @@ Points stringToPoints(std::string s) noexcept(false) { return Points{0.}; // dynexite file might use dot or comma as decimal separator std::ranges::replace(s, ',', '.'); - return Points{std::stod(s)}; + + try { + + return Points{std::stod(s)}; + } catch (const std::invalid_argument &) { + throw std::invalid_argument{"Could not convert string \"" + s + + "\" to a number."}; + } } diff --git a/src/util/strings.hpp b/src/util/strings.hpp new file mode 100644 index 0000000000000000000000000000000000000000..41a311b4c141b933c9369418148d5eae0489cda8 --- /dev/null +++ b/src/util/strings.hpp @@ -0,0 +1,64 @@ +#pragma once + +namespace str { +namespace error { +// Error prefix for any error when parsing a dynexite file +constexpr auto defaultDynexite = + "Please download the CSV results file from the \"Download the " + "results\" section from dynexite.\n\n"; + +// Error prefix for any error when parsing an RWTH Online file +constexpr auto defaultRwthOnline = + "Please download the export from RWTH Online as a CSV file with " + "encoding-setting ISO 8859-1.\n\n"; + +// Error prefix for any error when parsing a bonus point file +constexpr auto defaultBonus = + "Please use a properly formatted bonus point file with at " + "least the columns \"Matrikelnummer\" and \"Bonuspunkte " + "(Punkte)\" from moodle.\n\n"; + +// Error prefix for any error when parsing a d2r file +constexpr auto defaultD2R = + "The selected file is not a d2r file. Please select a file that you " + "previously exported with this app.\n\n"; + +// Error when parsing an empty or non-text file +constexpr auto empty = "File is empty or of the wrong file type."; + +// Error when there are no header columns +constexpr auto noHeader = "No header."; + +// Error when there are no rows beside the header +constexpr auto noData = "No data rows."; + +// Error when there are no rows beside the header in a dynexite file with +// content besides the identifier +constexpr auto noStudents = "No student attended?"; + +constexpr auto unexpectedDynexite = "You selected a dynexite file."; +constexpr auto unexpectedRwthOnline = "You selected an RWTH Online file."; +constexpr auto unexpectedBonus = "You selected a bonus point file."; + +constexpr auto invalidColumnNr = "Unexpected number of columns: "; + +constexpr auto insertFailed = "Inserting data failed."; + +constexpr auto noIdColumn = "No registration number column found."; +constexpr auto noBonusPointColumn = "No bonus point column found."; +constexpr auto noAttemptNrColumn = "No attempt number column found."; + +constexpr auto bonusArraySizeMismatch = + "Internal error while reading bonus points (array size mismatch)."; +constexpr auto rwthArraySizeMismatch = + "Internal error while reading student info (array size mismatch)."; + +constexpr auto lowStudentIntersection = + "Are you sure you selected files from the same exam?"; + +constexpr auto ymlHeader = "Invalid yaml header."; + +constexpr auto csvDelimiter = + "Error while parsing the CSV file: No valid delimiter found"; +} // namespace error +} // namespace str diff --git a/src/view/gui/custom_slider.cpp b/src/view/gui/custom_slider.cpp index 94788b8514b1e3960f33c533477430bd91268c0d..5dfcc78f0fe73c939c497768f52c49e31dfde2cb 100644 --- a/src/view/gui/custom_slider.cpp +++ b/src/view/gui/custom_slider.cpp @@ -28,9 +28,9 @@ public: * We could solve that problem with a map of DirectClickStyles, but that would * be overkill here. We only use the default native style anyways. */ -static DirectClickStyle *getDefaultStyle() { - static auto style = new DirectClickStyle(); - return style; +static DirectClickStyle *getDefaultStyle() { + static auto style = new DirectClickStyle(); + return style; } CustomSlider::CustomSlider(QWidget *parent) diff --git a/src/view/input_window/input_tab_csv_single_cohort.cpp b/src/view/input_window/input_tab_csv_single_cohort.cpp index 46b4d95af5f95f3c872106287a2aa5e74ce5d0fa..8af1c4d982717e48e20cd05db0fed25523174bac 100644 --- a/src/view/input_window/input_tab_csv_single_cohort.cpp +++ b/src/view/input_window/input_tab_csv_single_cohort.cpp @@ -112,6 +112,11 @@ void InputTabCsvSingleCohort::fileSelectChange() { // Update clickability of import button and grade boundary steps if (mExamFileView.mFileState == FileSelectState::ok && mStudentFileView.mFileState == FileSelectState::ok) { + // Display warning if the selected files don't reference the same + // students + warnIfLowStudentIntersection( + this, mExamFileView.model(), mStudentFileView.model(), + mBonusFileView.model(), mBonusFileView.mFileState); // Enable the import button mImportButton.setEnabled(true); diff --git a/src/view/input_window/input_window.cpp b/src/view/input_window/input_window.cpp index 3c27d069451105be9ec3ae22fb8bb313485d842b..fde0263deaa1f4400f38ab1dd0c356bc952573c0 100644 --- a/src/view/input_window/input_window.cpp +++ b/src/view/input_window/input_window.cpp @@ -6,11 +6,13 @@ inline constexpr int csvTabIdx = 0; InputWindow::InputWindow(const MainWindow &mainWindow, QWidget *parent) : QWidget(parent), - mHandreichungLink( - "<a " - "href=\"https://www.rwth-aachen.de/global/" - "show_document.asp?id=aaaaaaaaacxybai\">Document that details how to " - "grade multiple choice exams (in German)</a>"), + mHandreichungLink("Document that details how to grade multiple choice " + "exams: <a " + "href=\"https://www.rwth-aachen.de/global/" + "show_document.asp?id=aaaaaaaaacxybai\">German</a> / " + "<a " + "href=\"https://www.rwth-aachen.de/global/" + "show_document.asp?id=aaaaaaaaaejylxi\">English</a>"), mLayout(this) { setWindowTitle("Select files..."); diff --git a/src/view/input_window/load_file_view.cpp b/src/view/input_window/load_file_view.cpp index c48f6331acf3e9a2707e9b05f0882d412d3c0272..f807898f20e0e4c91af7e3508aea26d2c95abc17 100644 --- a/src/view/input_window/load_file_view.cpp +++ b/src/view/input_window/load_file_view.cpp @@ -4,6 +4,7 @@ #include <QFileDialog> #include <QFrame> +#include <QMessageBox> #include <QStyle> #define HOME_MACHINE 1 @@ -111,9 +112,25 @@ void LoadFileView::handleBrowseButton() { // file type mFileNameLabel.setText(fileName); - if (mInputModel.loadCsv(mFileNameLabel.text().toStdWString())) { + const auto error = + mInputModel.loadCsv(mFileNameLabel.text().toStdWString()); + if (error.mEc == FileOpenErrorCode::Success) { + + // Display warnings if there are any + if (!error.mWarnings.empty()) { + QString errorText = + "Reading the file generated the following warnings:\n"; + for (const auto &s : error.mWarnings) { + errorText += '\n' + s; + } + QMessageBox::warning(this, "Reading file warnings", errorText); + } + setFileStatus(FileSelectState::ok); } else { + + QMessageBox::warning(this, "Reading file failed", error.mDetails); + setFileStatus(FileSelectState::error); } } @@ -139,7 +156,8 @@ void LoadFileView::tempSetDeveloperPaths() { mFileNameLabel.setText(RWTHONLINE_EXAMPLE_FILE_PATH); } - if (mInputModel.loadCsv(mFileNameLabel.text().toStdWString())) { + if (mInputModel.loadCsv(mFileNameLabel.text().toStdWString()).mEc == + FileOpenErrorCode::Success) { setFileStatus(FileSelectState::ok); } else { setFileStatus(FileSelectState::error); diff --git a/src/view/input_window/load_file_view.hpp b/src/view/input_window/load_file_view.hpp index 4f1d58272a032d0499ea579bfe75843873c171d5..2c4df16482a24b9dc4f28705726bbb6ff6d69360 100644 --- a/src/view/input_window/load_file_view.hpp +++ b/src/view/input_window/load_file_view.hpp @@ -27,6 +27,8 @@ public: } [[nodiscard]] auto read() { return mInputModel.read(); } + [[nodiscard]] const InputModel &model() noexcept { return mInputModel; }; + /** \brief State of each file selection. */ FileSelectState mFileState = FileSelectState::empty; diff --git a/src/view/main_window/grade_histogram.cpp b/src/view/main_window/grade_histogram.cpp index 74d61d6598a4bd7b7bf785dcecff17ab7bcf75ef..04ed81fbeb155d29f51dae846159f79862cd8225 100644 --- a/src/view/main_window/grade_histogram.cpp +++ b/src/view/main_window/grade_histogram.cpp @@ -1,6 +1,7 @@ #include "grade_histogram.hpp" #include "data/data_manager.hpp" +#include "util/defaults.hpp" #include "util/enums.hpp" #include "util/grade.hpp" @@ -10,11 +11,14 @@ #include <QBarSet> #include <QChart> #include <QColor> +#include <QDebug> #include <QEasingCurve> +#include <QFontMetrics> #include <QGraphicsLayout> #include <QLegendMarker> #include <QStringList> #include <QTimer> +#include <QToolTip> #include <QValueAxis> #include <algorithm> @@ -58,7 +62,7 @@ GradeHistogram::GradeHistogram(const QList<GradingType> &gradingTypes, auto font = mBarSets.back()->labelFont(); font.setBold(true); - font.setPointSize(font.pointSize() * 2); + // font.setPointSizeF(font.pointSizeF() * 2); // placeholder mBarSets.back()->setLabelFont(font); series->append(mBarSets.back()); @@ -134,14 +138,46 @@ GradeHistogram::GradeHistogram(const QList<GradingType> &gradingTypes, // receive update when grades change connect(&DataManager::getInstance(), &DataManager::gradesChanged, this, &GradeHistogram::gradesChanged); + + // show bar value when bar set is hovered (wip) + connect(series, &QBarSeries::hovered, this, &GradeHistogram::showHoverText); } -void GradeHistogram::handleResize(const QRectF plotArea) { +void GradeHistogram::handleResize(const QRectF plotArea, + const FontSize largeFontSize, + const FontSize smallFontSize) { mPlotArea = plotArea; // adjust bar label font size - updateBarLabelSize(); + updateBarLabelSize(largeFontSize); + + // adjust x/y axis font size + { + auto xaxes = chart()->axes(Qt::Orientation::Horizontal); + for (auto &xax : xaxes) { + + auto font = xax->labelsFont(); + setFontSize(font, largeFontSize); + xax->setLabelsFont(font); + } + } + { + auto font = mAxisY->labelsFont(); + setFontSize(font, largeFontSize); + mAxisY->setLabelsFont(font); + } + { + auto font = chart()->legend()->font(); + setFontSize(font, smallFontSize); + chart()->legend()->setFont(font); + } + + // Round down + mMaxNrYLabels = static_cast<int>(mPlotArea.height() / + (2 * largeFontSize.getPixelSize())); + + updateYAxis(); } // todo: QBarSet, QBarSeries probably don't have to be recreated @@ -164,19 +200,22 @@ void GradeHistogram::gradesChanged(const EnumStorage<bool> &types) { } updateYAxis(); - updateBarLabelSize(); } -void niceYAxis(QValueAxis *axis, const int maxBarHeight) { +static void niceYAxis(QValueAxis *axis, const int maxBarHeight, + const int maxNrYLabels) { /* TODO: use values proportional to total number of grades */ auto newYAxisMax = 10; - auto tickDiff = 2; + auto tickDiff = maxNrYLabels < 6 ? 5 : 2; if (maxBarHeight > 10) { tickDiff = 5; while (true) { - if (maxBarHeight <= tickDiff * 10) { + if (tickDiff == 0) { + qDebug() << "wat"; + } + if (maxBarHeight <= tickDiff * std::max(1, maxNrYLabels - 1)) { newYAxisMax = static_cast<int>( std::ceil(static_cast<double>(maxBarHeight) / tickDiff) * tickDiff); @@ -202,7 +241,7 @@ void GradeHistogram::updateYAxis() { *std::ranges::max_element(model->mGradeCounts)); } - niceYAxis(mAxisY, largestBar); + niceYAxis(mAxisY, largestBar, mMaxNrYLabels); // update y axis scale // #if GRADE_HISTOGRAM_DYNAMIC_SCALE @@ -250,9 +289,76 @@ void GradeHistogram::updateYAxis() { // #endif } -void GradeHistogram::updateBarLabelSize() { +FontSize GradeHistogram::getFontSize(const QRectF plotArea) { + + mPlotArea = plotArea; + + const auto cur = DataManager::getCurrent(); + + // default value + if (!cur) { + return {16., FontSizeUnit::Pixel}; + } + + // Calculate the maximum number of digits the largest label on a bar will + // probably have + const auto nrStudents = cur->getGradeList(GradingType::Combined).size(); + int threshold = 2; + int numberDigits = 0; + while (nrStudents >= threshold) { + ++numberDigits; + threshold *= 10; + } + + // get width of single digit + auto font = mBarSets.back()->labelFont(); + font.setPixelSize(16); + const QFontMetrics metrics(font); + const auto digitPixelWidth = metrics.horizontalAdvance("0"); + const auto heightWidthRatio = + font.pixelSize() / static_cast<double>(digitPixelWidth); + + // Set the font size so that a number with numberDigits of digits fits + // horizontally into a combined bar Note that the combined bar is narrower, + // if both closed and open points exist + const bool hasClosed = + cur->mExam.mPointsMax[GradingType::Closed] > Points{0.}; + const bool hasOpen = cur->mExam.mPointsMax[GradingType::Open] > Points{0.}; + const bool hasCombined = hasClosed && hasOpen; + + // Width of the bar as percentage of the space reserved for one bar + const auto barWidthPortion = hasCombined ? 0.75 : 0.5; + const auto realPlotAreaWidth = mPlotArea.width(); + + const auto barSpaceWidth = + realPlotAreaWidth / static_cast<double>(Grade::NR_VALS); + + // If we have a combined bar chart, there are multiple bar sets, resulting + // in thinner bars + const auto barWidth = + barSpaceWidth * barWidthPortion / (hasCombined ? 3 : 1); + + const auto pxPerDigit = barWidth / numberDigits; + + return {std::floor(pxPerDigit * heightWidthRatio), FontSizeUnit::Pixel}; +} + +void GradeHistogram::showHoverText(const bool status, const int index, + QBarSet * /*barset*/) { + qDebug() << "showHoverText called with status =" << status + << "and index =" << index; + + if (status) { + QToolTip::showText(QCursor::pos(), "text"); + } else { + QToolTip::hideText(); + } +} + +void GradeHistogram::updateBarLabelSize(const FontSize largeFontSize) { // todo: plot area of combined plot smaller? +#if 0 double xSmallestBarPoints, ySmallestBarPoints; { @@ -308,4 +414,19 @@ void GradeHistogram::updateBarLabelSize() { for (auto &bs : mBarSets) { bs->setLabelFont(font); } +#elif 0 + for (auto &bs : mBarSets) { + auto f = bs->labelFont(); + f.setPointSize(defaults::fontSizeBig); + bs->setLabelFont(f); + } +#else + auto font = mBarSets.back()->labelFont(); + font.setBold(true); + setFontSize(font, largeFontSize); + for (auto &bs : mBarSets) { + bs->setLabelFont(font); + } + +#endif } diff --git a/src/view/main_window/grade_histogram.hpp b/src/view/main_window/grade_histogram.hpp index ba83e1b936c83a0a10f8fd5e8949626cb5348bca..b7f73823831e18314acc99e9c37af43f2fb2b05c 100644 --- a/src/view/main_window/grade_histogram.hpp +++ b/src/view/main_window/grade_histogram.hpp @@ -2,6 +2,7 @@ #include "data/nodes/dynexite_exam.hpp" #include "model/grade_model.hpp" +#include "util/font.hpp" #include "util/storage_helper.hpp" #include <QChartView> @@ -16,15 +17,20 @@ public: explicit GradeHistogram(const QList<GradingType> &gradingTypes, QWidget *parent = nullptr); - void handleResize(QRectF plotArea); + void handleResize(QRectF plotArea, FontSize largeFontSize, + FontSize smallFontSize); + + FontSize getFontSize(QRectF plotArea); public slots: void gradesChanged(const EnumStorage<bool> &types); + void showHoverText(bool status, int index, QBarSet *barset); + private: void updateYAxis(); - void updateBarLabelSize(); + void updateBarLabelSize(FontSize largeFontSize); /* todo: if it's already a QList, might as well make them member objects * instead of pointers */ @@ -35,4 +41,6 @@ private: QValueAxis *mAxisY; QRectF mPlotArea; + + int mMaxNrYLabels = 6; }; diff --git a/src/view/main_window/grade_tab.cpp b/src/view/main_window/grade_tab.cpp index 0659e45a1f5e07d286fde307b2f50773c0b73d70..a207a56b7b9f757bb69bf4a50d63c2e62648b16d 100644 --- a/src/view/main_window/grade_tab.cpp +++ b/src/view/main_window/grade_tab.cpp @@ -27,9 +27,13 @@ GradeTab::GradeTab(const ViewSettingsWindow &viewSettings, QWidget *parent) createGradeHistograms(); createPointHistograms(viewSettings); - // 30% - 70% split - mLayout->setColumnStretch(0, 3); - mLayout->setColumnStretch(1, 7); + // 30% - 70% split (specified in defaults::gradeTabHistogramWidthPercentage) + static_assert(defaults::gradeTabHistogramWidthPercentage >= 0); + static_assert(defaults::gradeTabHistogramWidthPercentage <= 100); + + mLayout->setColumnStretch(0, + 100 - defaults::gradeTabHistogramWidthPercentage); + mLayout->setColumnStretch(1, defaults::gradeTabHistogramWidthPercentage); mLayout->setRowStretch(0, 1); mLayout->setRowStretch(1, 1); @@ -37,14 +41,16 @@ GradeTab::GradeTab(const ViewSettingsWindow &viewSettings, QWidget *parent) &GradeTab::pointsChanged); } -void GradeTab::handleResize() const { +void GradeTab::handleResize(const FontSize largeFontSize, + const FontSize smallFontSize) const { const auto plotAreaGradeHistos = mGradeHistos[static_cast<std::size_t>(mGradeHistoTabs->currentIndex())] ->chart() ->plotArea(); for (const auto &gradeHisto : mGradeHistos) { - gradeHisto->handleResize(plotAreaGradeHistos); + gradeHisto->handleResize(plotAreaGradeHistos, largeFontSize, + smallFontSize); } const auto plotAreaPointHistos = @@ -52,8 +58,36 @@ void GradeTab::handleResize() const { ->chart() ->plotArea(); for (const auto &pointHisto : mPointHistos) { - pointHisto->handleResize(plotAreaPointHistos); + pointHisto->handleResize(plotAreaPointHistos, largeFontSize, + smallFontSize); } + + mScrews->changeFontSize(largeFontSize); +} + +FontSize GradeTab::getFontPxSize(const QSize windowSize) const { + +#if 0 + const auto plotAreaGradeHistos = + mGradeHistos[static_cast<std::size_t>(mGradeHistoTabs->currentIndex())] + ->chart() + ->plotArea(); + + // Guess plot area size + // We can't just read out the plot area size, because it is not updated if a + // different tab is loaded and it is updated wrong if the user restores the + // window size after a maximize + + return mGradeHistos[0]->getFontSize(plotAreaGradeHistos); +#else + qDebug() << ""; + qDebug() << "Half of windowSize: " << windowSize * 0.5; + // qDebug() << "cellRect: " << mLayout->cellRect(0, 1); + // qDebug() << "mGradeHistoTabs: " << mGradeHistoTabs->size(); + + return mGradeHistos[0]->getFontSize( + QRectF(QPointF(0., 0.), QSizeF(windowSize * 0.5) /* todo */)); +#endif } void GradeTab::pointsChanged() { diff --git a/src/view/main_window/grade_tab.hpp b/src/view/main_window/grade_tab.hpp index 3a3b132298cef804140dddc05bc93f94cb37c238..45bb7173d3668d4efcda9e89882b0cf1e4c4f97b 100644 --- a/src/view/main_window/grade_tab.hpp +++ b/src/view/main_window/grade_tab.hpp @@ -19,7 +19,8 @@ public: explicit GradeTab(const ViewSettingsWindow &viewSettings, QWidget *parent = nullptr); - void handleResize() const; + void handleResize(FontSize largeFontSize, FontSize smallFontSize) const; + [[nodiscard]] FontSize getFontPxSize(QSize windowSize) const; public slots: void pointsChanged(); diff --git a/src/view/main_window/main_window.cpp b/src/view/main_window/main_window.cpp index 8e1a0b96e1310e5a19098e33ba7ebe400b2769d7..0d28fbc9df04f9ea315a6f680e0b917a1f3b3b91 100644 --- a/src/view/main_window/main_window.cpp +++ b/src/view/main_window/main_window.cpp @@ -9,19 +9,27 @@ #include "data/data_manager.hpp" #include "file_io/yml_parser.hpp" +#include "util/font.hpp" #include "util/grade.hpp" #include "util/grade_boundary.hpp" #include "util/heuristics.hpp" #include <QAction> +#include <QApplication> #include <QClipboard> +#include <QDesktopServices> #include <QFileDialog> #include <QGuiApplication> #include <QMenuBar> +#include <QTimer> #include <algorithm> #include <cmath> +#include <QGraphicsProxyWidget> +#include <QGraphicsScene> +#include <QGraphicsWidget> + // Surround the given string evenly with spaces, until it has a size of `size` QString padToSize(QString str, const int size) { const auto spacesLeft = static_cast<int>( @@ -162,6 +170,8 @@ MainWindow::MainWindow(QWidget *parent) mInputWindow.show(); createMenuBar(); + + connect(&mTabs, &QTabWidget::currentChanged, this, &MainWindow::tabChanged); } void MainWindow::resetToInputFiles(const DynexiteExam &exam, @@ -180,6 +190,9 @@ void MainWindow::resetToInputFiles(const DynexiteExam &exam, showMaximized(); updateMenuItems(); + + // Change font size to adapt to new bar sizes + handleResize(); } } @@ -191,6 +204,12 @@ void MainWindow::menuOpenViewSettings() { mViewSettings.show(); } void MainWindow::menuOpenStatistics() { mStatisticsWindow.show(); } +void MainWindow::tabChanged(int) { + // handle resize here again, because sizes of non-displayed tabs are not + // calculated correctly + handleResize(); +} + void MainWindow::closeEvent(QCloseEvent *event) { for (const auto &window : mSubWindowPtrs) { window->hide(); @@ -202,7 +221,95 @@ void MainWindow::closeEvent(QCloseEvent *event) { void MainWindow::resizeEvent(QResizeEvent *event) { QMainWindow::resizeEvent(event); - mGradeTab->handleResize(); + handleResize(); + + qDebug() << "Is maximized? " << windowState().testFlag(Qt::WindowMaximized); + + // auto oldSize = this->size(); + // QTimer::singleShot(1000, this, + // [this, oldSize]() { this->resize(oldSize); }); + + // static bool wasMaximized = windowState().testFlag(Qt::WindowMaximized); + // const bool isMaximized = windowState().testFlag(Qt::WindowMaximized); + + // if (wasMaximized && !isMaximized) { + // // update flag before call to resize + // wasMaximized = isMaximized; + + // auto oldSize = this->size(); + // oldSize.setWidth(oldSize.width() - 1); + // this->resize(oldSize); + //} + // wasMaximized = isMaximized; + + // If the flag "WindowMaximized" is set here, that means the window WAS + // maximized before the current resize, so it isn't anymore + // if (windowState().testFlag(Qt::WindowMaximized)) { + + // // todo: explain this madness + // auto mySize = this->size(); + // mySize.setWidth(mySize.width() - 1); + // QTimer::singleShot(0, this, [this, mySize]() { this->resize(mySize); + // }); + //} + + // static bool doubleExecution = true; + // if (doubleExecution) { + // auto oldSize = this->size(); + // if (oldSize.width() < 2300) { + + // doubleExecution = false; + // oldSize.setWidth(oldSize.width() - 1); + // QTimer::singleShot(0, this, + // [this, oldSize]() { this->resize(oldSize); }); + // } + + //} else { + // doubleExecution = true; + //} +} + +void MainWindow::handleResize() { + + static FontSize defaultFontSize; + + static bool firstExecution = true; + if (firstExecution) { + + // Get default font + QLabel l; + + // Default font size might be given in points or in pixels + if (l.font().pixelSize() > 0) { + defaultFontSize = {static_cast<qreal>(l.font().pixelSize()), + FontSizeUnit::Pixel}; + } else { + defaultFontSize = {l.font().pointSizeF(), FontSizeUnit::Point}; + } + + firstExecution = false; + } + + // Find out new font size (not smaller than default font) + const auto possibleLargeFontSize = mGradeTab->getFontPxSize(this->size()); + + // Large font size may not be smaller than the default font size + const auto largeFontSize = std::max(possibleLargeFontSize, defaultFontSize); + + // Small font size is a fixed ratio smaller than the large font size, but + // also not smaller than defaultFontSize + const auto smallFontSize = + std::max(largeFontSize * defaults::smallFontSizeRatio, defaultFontSize); + + // Set font size of all labels, text boxes etc + auto defaultFont = QApplication::font(); + setFontSize(defaultFont, smallFontSize); + QApplication::setFont(defaultFont); + + // Set font size for legends and adapt axis spacing + mGradeTab->handleResize(largeFontSize, smallFontSize); + + mTaskTab->handleResize(this->size(), largeFontSize); } void MainWindow::updateMenuItems() const { @@ -265,7 +372,7 @@ void MainWindow::createMenuBar() { } /**************** Export *************/ - mExportMenu = menuBar()->addMenu("&Export"); + mExportMenu = menuBar()->addMenu("E&xport"); // Export grading scheme to clipboard { @@ -305,6 +412,46 @@ void MainWindow::createMenuBar() { connect(exportD2RAct, &QAction::triggered, [this] { exportD2R(this); }); mExportMenu->addAction(exportD2RAct); } + + /**************** Help *************/ + mHelpMenu = menuBar()->addMenu("&Help"); + + // Open Handreichung / Guidelines + { + mOpenGuidelinesMenu = mHelpMenu->addMenu("Open grading guidelines"); + + // German + { + const auto mOpenGuidelinesGerman = new QAction("German", this); + connect(mOpenGuidelinesGerman, &QAction::triggered, [] { + QDesktopServices::openUrl( + QUrl("https://www.rwth-aachen.de/global/" + "show_document.asp?id=aaaaaaaaacxybai")); + }); + mOpenGuidelinesMenu->addAction(mOpenGuidelinesGerman); + } + + // English + { + const auto mOpenGuidelinesEnglish = new QAction("English", this); + connect(mOpenGuidelinesEnglish, &QAction::triggered, [] { + QDesktopServices::openUrl( + QUrl("https://www.rwth-aachen.de/global/" + "show_document.asp?id=aaaaaaaaaejylxi")); + }); + mOpenGuidelinesMenu->addAction(mOpenGuidelinesEnglish); + } + } + + // "Version": show version (directly in the menu item, not in a popup + // window) + { + const auto versionAct = + new QAction(QString("Version: ") + D2R_VERSION, this); + connect(versionAct, &QAction::triggered, this, [] {}); + versionAct->setDisabled(true); + mHelpMenu->addAction(versionAct); + } } /* void MainWindow::handleCommandLineArgs() const { diff --git a/src/view/main_window/main_window.hpp b/src/view/main_window/main_window.hpp index 43877fe9f5d247e7b5a055f5d2c7f3ef5409ad1e..4c2e55713c5210e9bba9d92741d4d640cba4f9db 100644 --- a/src/view/main_window/main_window.hpp +++ b/src/view/main_window/main_window.hpp @@ -56,6 +56,7 @@ private slots: void menuOpenBoundaries(); void menuOpenViewSettings(); void menuOpenStatistics(); + void tabChanged(int index); private: /** @@ -69,6 +70,8 @@ private: void createMenuBar(); + void handleResize(); + private: /** * Automatically input .d2r file if given @@ -81,7 +84,7 @@ private: InputWindow mInputWindow; /** - * \brief The window where the user selects input files. + * \brief The window where the user selects options. */ BoundaryWindow mBoundaryWindow; @@ -117,6 +120,8 @@ private: QMenu *mExportMenu; QMenu *mExportGradingMenu; QMenu *mExportStudentsMenu; + QMenu *mHelpMenu; + QMenu *mOpenGuidelinesMenu; QAction *mExportUnknownStudentsAct; FRIEND_TEST(GuiDagTest, IntegrationTest); diff --git a/src/view/main_window/min_max_point_screws.cpp b/src/view/main_window/min_max_point_screws.cpp index 55ad673a9cf71322c6ad54458c9676a01246fe22..918d1bcc05b65d4f0cadf113a880a2809b830f1a 100644 --- a/src/view/main_window/min_max_point_screws.cpp +++ b/src/view/main_window/min_max_point_screws.cpp @@ -2,7 +2,13 @@ #include "data/data_manager.hpp" +#include "util/storage_helper.hpp" +#include <QFileDialog> +#include <QFrame> +#include <QMessageBox> #include <QSignalBlocker> +#include <QStyle> +#include <QWidget> #include <algorithm> #include <cmath> @@ -24,6 +30,8 @@ MinMaxPointScrewColumn::MinMaxPointScrewColumn(const GradingType grading, createLabel(grading, 1, "Minimal Points" + labelExt, 2, mParent); createSlider(grading, 1, 3, mParent); + createQuestionIcon(grading, 3, mParent); + // receive update when points change connect(&DataManager::getInstance(), &DataManager::gradesChanged, this, &MinMaxPointScrewColumn::gradesChanged); @@ -95,6 +103,20 @@ void MinMaxPointScrewColumn::createSlider(const GradingType grading, addLayout(mHLayouts[row]); } +void MinMaxPointScrewColumn::createQuestionIcon(const GradingType grading, + const int row, + QWidget *parent) { + if (grading == GradingType::Closed) { + mQuestionIcon = new QLabel(parent); + mQuestionIcon->setPixmap( + mQuestionIcon->style() + ->standardIcon(QStyle::SP_MessageBoxQuestion) + .pixmap(16)); + + mHLayouts[row]->addWidget(mQuestionIcon); + } +} + void MinMaxPointScrewColumn::setSpinboxRange( const int row, const GradeBoundary min, const GradeBoundary max, const GradeBoundary start, const GradeBoundaryStepSize minPointStep) const { @@ -176,6 +198,28 @@ void MinMaxPointScrewColumn::gradesChanged() { totalPoints); setSliderRange(3, 1, GradeBoundary{0}, boundary4_0, currentBoundary4_0, currentTotalPoints); + + // Question icon hover text + if (mGrading == GradingType::Closed) { + const QString questionIconToolTip = + "Normally, the passing mark (Bestehensgrenze) of an exam should\n" + "not be set above 50% of the obtainable points. However, since it\n" + "can be assumed that a candidate who has the required knowledge\n" + "can guess the remaining questions and hence gain additional\n" + "points. Therefore the passing mark in the closed part should not\n" + "exceed 60% of the achievable points."; + + if (mSpinBoxes[3]->maximum() > 59) { + mQuestionIcon->setToolTip(questionIconToolTip); + } else { + mQuestionIcon->setToolTip( + questionIconToolTip + + "\nHere, as a result of the legal situation (see Guidelines " + "on\n" + "Multiple-Choice Exams), the passing mark should not exceed " + + QString::number(mSpinBoxes[3]->maximum()) + "%."); + } + } } void MinMaxPointScrewColumn::sliderChange(const GradingType grading, @@ -290,7 +334,10 @@ inline constexpr int nrPassedRow = 2; inline constexpr int textColumn = 0; inline constexpr int avgNumberColumn = 1; -MinMaxPointScrews::MinMaxPointScrews(QWidget *parent) : QWidget(parent) { +MinMaxPointScrews::MinMaxPointScrews(QWidget *parent) + : QWidget(parent), mAvgLabel("Average grade (Attended):"), + mAvgPassedLabel("Average grade (Passed):"), + mNrPassedLabel("Attended students passed:") { // Even space above and below the screw columns, avg at the very bottom mVLayout.addItem( @@ -318,30 +365,31 @@ void MinMaxPointScrews::setEnabled(const GradingType type, } void MinMaxPointScrews::gradesChanged() { + const auto cur = DataManager::getCurrent(); if (!cur) { - mAverageGrade.setText("# -"); - mAverageGradePassed.setText("# -"); - mNrPassed.setText("# - of -"); + mAverageGrade.setText("-"); + mAverageGradePassed.setText("-"); + mNrPassed.setText("- of -"); return; } // Number of decimal places for average grade is always 2, independent of // the grade boundary step size mAverageGrade.setText( - "# " + QLocale().toString(cur->mGradeAverage.mAverageGrade, 'f', 2)); + "" + QLocale().toString(cur->mGradeAverage.mAverageGrade, 'f', 2)); mAverageGradePassed.setText( - "# " + + "" + QLocale().toString(cur->mGradeAverage.mAverageGradePassed, 'f', 2)); { const auto &nrPassed = cur->mGradeAverage.mNrPassed; const auto &nrAttended = cur->mGrades.mGrades.size(); if (nrAttended == 0) { - mNrPassed.setText("# - of -"); + mNrPassed.setText("- of -"); } else { mNrPassed.setText( - "# " + QLocale().toString(nrPassed) + " of " + + "" + QLocale().toString(nrPassed) + " of " + QLocale().toString(nrAttended) + " (" + QLocale().toString(static_cast<double>(nrPassed) / static_cast<double>(nrAttended) * 100., @@ -370,33 +418,27 @@ void MinMaxPointScrews::createScrewColumns() { mHLayout.addStretch(1); } +void MinMaxPointScrews::changeFontSize(const FontSize newFontSize) { + + auto font = mAverageGrade.font(); + setFontSize(font, newFontSize); + + for (const auto &label : {&mAverageGrade, &mAverageGradePassed, &mNrPassed, + &mAvgLabel, &mAvgPassedLabel, &mNrPassedLabel}) { + label->setFont(font); + } +} + void MinMaxPointScrews::createAvgLabels() { mVLayout.addLayout(&mAvgLayout); // average text labels - { - const auto avgLabel = new QLabel("# Average grade (Attended):"); - avgLabel->setTextFormat(Qt::MarkdownText); - mAvgLayout.addWidget(avgLabel, avgRow, textColumn); - } - { - const auto avgPassedLabel = new QLabel("# Average grade (Passed):"); - avgPassedLabel->setTextFormat(Qt::MarkdownText); - mAvgLayout.addWidget(avgPassedLabel, avgPassedRow, textColumn); - } - { - const auto nrPassedLabel = new QLabel("# Attended students passed:"); - nrPassedLabel->setTextFormat(Qt::MarkdownText); - mAvgLayout.addWidget(nrPassedLabel, nrPassedRow, textColumn); - } + mAvgLayout.addWidget(&mAvgLabel, avgRow, textColumn); + mAvgLayout.addWidget(&mAvgPassedLabel, avgPassedRow, textColumn); + mAvgLayout.addWidget(&mNrPassedLabel, nrPassedRow, textColumn); // average value labels - mAverageGrade.setTextFormat(Qt::MarkdownText); mAvgLayout.addWidget(&mAverageGrade, avgRow, avgNumberColumn); - - mAverageGradePassed.setTextFormat(Qt::MarkdownText); mAvgLayout.addWidget(&mAverageGradePassed, avgPassedRow, avgNumberColumn); - - mNrPassed.setTextFormat(Qt::MarkdownText); mAvgLayout.addWidget(&mNrPassed, nrPassedRow, avgNumberColumn); } diff --git a/src/view/main_window/min_max_point_screws.hpp b/src/view/main_window/min_max_point_screws.hpp index ca99e5a150ca7c7e83d07d4684ac03c9886ff46f..72b77d87b0d393ced85e19de8109a650158424f9 100644 --- a/src/view/main_window/min_max_point_screws.hpp +++ b/src/view/main_window/min_max_point_screws.hpp @@ -3,10 +3,10 @@ #include "data/nodes/dynexite_exam.hpp" #include "util/enums.hpp" +#include "util/font.hpp" #include "util/grade_boundary.hpp" #include "util/grade_boundary_step.hpp" #include "util/storage_helper.hpp" - // #include "view/gui/custom_double_spin_box.hpp" #include "view/gui/custom_slider.hpp" @@ -39,6 +39,8 @@ private: QDoubleSpinBox *mSpinBoxes[4]{}; CustomSlider *mSliders[2]{}; + QLabel *mQuestionIcon; + void setSpinboxRange(int row, GradeBoundary min, GradeBoundary max, GradeBoundary start, GradeBoundaryStepSize minPointStep) const; @@ -51,6 +53,8 @@ private: void createSlider(GradingType grading, int sliderIdx, int row, QWidget *parent); + void createQuestionIcon(GradingType grading, int row, QWidget *parent); + private slots: void gradesChanged(); void sliderChange(GradingType grading, int sliderIdx, int value); @@ -65,10 +69,13 @@ public: void setEnabled(GradingType type, bool enabled) const; + void changeFontSize(FontSize newFontSize); + private slots: void gradesChanged(); -private: + // private: +public: QVBoxLayout mVLayout{this}; QHBoxLayout mHLayout{}; QGridLayout mAvgLayout{}; @@ -77,6 +84,7 @@ private: MinMaxPointScrewColumn *mOpenColumn = nullptr; QLabel mAverageGrade{}, mAverageGradePassed{}, mNrPassed{}; + QLabel mAvgLabel, mAvgPassedLabel, mNrPassedLabel; void createScrewColumns(); void createAvgLabels(); diff --git a/src/view/main_window/point_histogram.cpp b/src/view/main_window/point_histogram.cpp index 77e16ee9d5ab484359579217f70f420523449b6d..b572b0b926f65e7398dad3eb4b4a306551efecf2 100644 --- a/src/view/main_window/point_histogram.cpp +++ b/src/view/main_window/point_histogram.cpp @@ -62,11 +62,27 @@ PointHistogram::PointHistogram(const GradingType gradingType, mAxisX->setTickType(QValueAxis::TicksDynamic); mAxisX->setTickAnchor(0); + //// font + //{ + // auto font = mAxisX->labelsFont(); + // font.setPointSize(defaults::fontSizeBig); + // mAxisX->setLabelsFont(font); + //} + // y-axis mAxisY->setLabelFormat("%d"); mAxisY->setTickType(QValueAxis::TicksDynamic); mAxisY->setTickAnchor(0); + //// font + //{ + // auto font = mAxisY->labelsFont(); + // font.setPointSize(defaults::fontSizeBig); + // mAxisY->setLabelsFont(font); + //} + + setMinimumHeight(380); + // Stacked bar series // Bar width of one = no space between adjacent bars mSeries->setBarWidth(1.); @@ -115,11 +131,43 @@ PointHistogram::PointHistogram(const GradingType gradingType, this, &PointHistogram::pointBucketSizeChanged); } -void PointHistogram::handleResize(const QRectF plotArea) { +void PointHistogram::handleResize(const QRectF plotArea, + const FontSize largeFontSize, + const FontSize smallFontSize) { mPlotArea = plotArea; +#if OLDYLABELS rescaleXLabels(); rescaleYLabels(); +#endif + + // axis font size + { + auto font = mAxisX->labelsFont(); + setFontSize(font, largeFontSize); + mAxisX->setLabelsFont(font); + } + { + auto font = mAxisY->labelsFont(); + setFontSize(font, largeFontSize); + mAxisY->setLabelsFont(font); + } + { + auto font = chart()->legend()->font(); + setFontSize(font, smallFontSize); + chart()->legend()->setFont(font); + } + + // Round down + mMaxNrYLabels = static_cast<int>(mPlotArea.height() / + (2 * largeFontSize.getPixelSize())); + + // todo +#if OLDYLABELS +#else + rescaleXLabels(); + rescaleYLabels(); +#endif } void PointHistogram::pointsChanged() { @@ -307,17 +355,30 @@ void PointHistogram::rescaleXLabels() const { const auto labelStep = std::ceil(static_cast<double>(minLabelStep) / 5.) * 5.; - mAxisX->setTickInterval(labelStep); + mAxisX->setTickInterval(labelStep * 2); } void PointHistogram::rescaleYLabels() const { +#if OLDYLABELS const auto maxNrLabels = std::max(2, static_cast<int>(mPlotArea.height()) / (mAxisLabelCharHeight * 2)); + qDebug() << "maxNrLabels:" << maxNrLabels; +#else + qDebug() << "maxNrLabels:" << mMaxNrYLabels; +#endif + // We need an extra label for 0 - const auto minLabelStep = static_cast<int>( - std::ceil(mAxisY->max() / static_cast<qreal>(maxNrLabels - 1))); + const auto minLabelStep = + static_cast<int>(std::ceil(mAxisY->max() / static_cast<qreal>( +#if OLDYLABELS + maxNrLabels + +#else + mMaxNrYLabels +#endif + - 1))); - mAxisY->setTickInterval(minLabelStep); + mAxisY->setTickInterval(std::max(minLabelStep, 1)); } diff --git a/src/view/main_window/point_histogram.hpp b/src/view/main_window/point_histogram.hpp index d30d1c7713ae6d62c2744f8a49201c27a6715f05..ce01a7888cdc0859fd0ff77ea30ad90791830756 100644 --- a/src/view/main_window/point_histogram.hpp +++ b/src/view/main_window/point_histogram.hpp @@ -5,6 +5,7 @@ #pragma once #include "util/enums.hpp" +#include "util/font.hpp" #include "util/storage_helper.hpp" #include "util/testing.hpp" @@ -17,6 +18,8 @@ class QValueAxis; class QBarSet; class QStackedBarSeries; +#define OLDYLABELS 0 + namespace impl { /** * \brief Get the bar set index for the given point value. @@ -41,7 +44,8 @@ public: const ViewSettingsWindow &viewSettings, QWidget *parent = nullptr); - void handleResize(QRectF plotArea); + void handleResize(QRectF plotArea, FontSize largeFontSize, + FontSize smallFontSize); public slots: /** @@ -96,6 +100,7 @@ private: * spacing between axis labels. */ int mAxisLabelCharWidth, mAxisLabelCharHeight; + int mMaxNrYLabels = 6; /** * \brief Correct plot area (the one returned by chart()->plotArea might be diff --git a/src/view/main_window/task_tab.cpp b/src/view/main_window/task_tab.cpp index b63d95f69040769967a5dd06d5caa12f7e0bda55..7db13465970e9bde98b7dc849f6682b61f1295d8 100644 --- a/src/view/main_window/task_tab.cpp +++ b/src/view/main_window/task_tab.cpp @@ -11,6 +11,7 @@ #include <QCategoryAxis> #include <QChart> #include <QChartView> +#include <QFontMetrics> #include <QLabel> #include <QValueAxis> @@ -31,6 +32,316 @@ TaskTab::TaskTab(QWidget *parent) : QScrollArea(parent) { &TaskTab::pointsChanged); } +void TaskTab::handleResize(const QSize windowSize, + const FontSize largeFontSize) { + changeFontSize(largeFontSize); + correctAxisLabels(windowSize, largeFontSize); +} + +void TaskTab::changeFontSize(const FontSize largeFontSize) { + + for (auto &pchart : mPointCharts) { + // adjust x/y axis font size + { + auto xaxes = pchart->axes(Qt::Orientation::Horizontal); + for (auto &xax : xaxes) { + + auto font = xax->labelsFont(); + setFontSize(font, largeFontSize); + xax->setLabelsFont(font); + } + } + { + auto yaxes = pchart->axes(Qt::Orientation::Vertical); + for (auto &yax : yaxes) { + + auto font = yax->labelsFont(); + setFontSize(font, largeFontSize); + yax->setLabelsFont(font); + } + } + } + + // resize the invisible axis of the box chart, which exists for alignment + // of the two charts + for (auto &bchart : mBoxCharts) { + { + auto axes = bchart->axes(Qt::Orientation::Horizontal); + for (auto &ax : axes) { + + auto font = ax->labelsFont(); + setFontSize(font, largeFontSize); + ax->setLabelsFont(font); + } + } + } +} + +// todo: combine with function from gradeHistogram +static void niceYAxis(QValueAxis *axis, const int maxBarHeight, + const int maxNrYLabels) { + /* TODO: use values proportional to total number of grades */ + + // TODO: update pointChartYMax + + if (maxNrYLabels < 2) { + axis->setTickInterval(maxBarHeight); + axis->setMax(maxBarHeight); + } else + + if (maxBarHeight < maxNrYLabels) { + + axis->setTickInterval(1); + axis->setMax(maxBarHeight); + + } else { + auto newYAxisMax = 10; + auto tickDiff = maxNrYLabels < 6 ? 5 : 2; + + qDebug() << "niceYAxis tasktab: maxBarHeight:" << maxBarHeight + << ", maxNrYLabels:" << maxNrYLabels; + + if (maxBarHeight > 10) { + tickDiff = 5; + while (true) { + if (tickDiff == 0) { + qDebug() << "wat"; + } + if (maxBarHeight <= tickDiff * std::max(1, maxNrYLabels - 1)) { + newYAxisMax = static_cast<int>( + std::ceil(static_cast<double>(maxBarHeight) / + tickDiff) * + tickDiff); + break; + } else { + tickDiff *= 2; + } + } + } + + if (axis->tickInterval() != tickDiff) + axis->setTickInterval(tickDiff); + if (axis->max() != newYAxisMax) + axis->setMax(newYAxisMax); + } +} + +static void niceXAxis(QValueAxis *axis, const int maxNrLabels, + const double stepSize) { + + const auto largestValue = axis->max(); + + if (maxNrLabels < 2) { + axis->setTickInterval(largestValue); + return; + } + + double interval = stepSize; + + while (largestValue / interval + 1 > maxNrLabels) { + interval *= 2; + } + + axis->setTickInterval(interval); +} + +void TaskTab::correctAxisLabels(const QSize windowSize, + const FontSize largeFontSize) { + + qDebug() << "Cell rect 0,0:" << mLayout->cellRect(0, 0); + + const auto cur = DataManager::getCurrent(); + if (!cur) { + return; + } + + int yAxisHeightPx = 200; + + for (int i = 0; i < mPointCharts.size(); ++i) { + // y axis +#if 0 + const auto maxNrYLabels = + static_cast<int>(pchart->plotArea().height() / (2 * largeFontSize)); + qDebug() << "Plot area height: " << pchart->plotArea().height(); +#else + const auto maxNrYLabels = static_cast<int>( + yAxisHeightPx / (2 * largeFontSize.getPixelSize())); +#endif + + // find largest bar + int largestBar = 0; + auto seriesList = mPointCharts[i]->series(); + if (seriesList.size() != 1) { + qDebug() << "Wrong number of series in a task point chart"; + assert(false); + continue; + } + + auto series = static_cast<QAbstractBarSeries *>(seriesList[0]); + + auto barSetList = series->barSets(); + if (barSetList.size() != 1) { + qDebug() << "Wrong number of bar sets in a task point bar set"; + assert(false); + continue; + } + + auto barSet = barSetList[0]; + + for (int barSetIdx = 0; barSetIdx < barSet->count(); ++barSetIdx) { + if ((*barSet)[barSetIdx] > largestBar) { + largestBar = static_cast<int>((*barSet)[barSetIdx]); + } + } + + { + auto yaxes = mPointCharts[i]->axes(Qt::Orientation::Vertical); + for (auto &yax : yaxes) { + + niceYAxis(static_cast<QValueAxis *>(yax), largestBar, + maxNrYLabels); + if (mBoxCharts.size() > i) { + + auto boxChartAxisList = mBoxCharts[i]->axes(Qt::Horizontal); + if (boxChartAxisList.size() != 1) { + qDebug() << "Wrong size of axis list of box chart:" + << boxChartAxisList.size(); + assert(false); + } else { + auto boxChartAxis = + static_cast<QCategoryAxis *>(boxChartAxisList[0]); + auto labels = boxChartAxis->categoriesLabels(); + for (auto &l : labels) { + boxChartAxis->remove(l); + } + boxChartAxis->append( + QString::number(static_cast<int>( + static_cast<QValueAxis *>(yax)->max())), + 0); + } + // axisY->append(QString::number(pointChartYMax), 0); + } else { + qDebug() + << "No matching box chart found at index" << i << "!"; + assert(false); + } + } + } + + // x axis + { + auto xaxes = mPointCharts[i]->axes(Qt::Orientation::Horizontal); + for (auto &xax : xaxes) { + + // get number of characters of maximum label + auto xValueAx = static_cast<QValueAxis *>(xax); + const auto maxLabel = QString::number( + xValueAx->max(), 'f', + nrDecimalPlaces( + cur->mUserPointConfig.mGradeBoundaryStepSize)); + qDebug() << "Max label is" << maxLabel; + + auto labelFont = xValueAx->labelsFont(); + QFontMetrics fm(labelFont); + const auto maxLabelWidth = fm.horizontalAdvance(maxLabel); + + // get width of x-ax + const auto xAxWidth = static_cast<int>( + (windowSize.width() - mLayout->cellRect(0, 0).width()) * + 0.8); + + const auto maxNrLabels = + static_cast<int>(xAxWidth / maxLabelWidth / 1.2); + qDebug() << "Max nr labels:" << maxNrLabels; + + niceXAxis( + xValueAx, maxNrLabels, + as<double>(cur->mUserPointConfig.mGradeBoundaryStepSize)); + } + } + } + + for (auto &pchart : mPointCharts) { + + // y axis +#if 0 + const auto maxNrYLabels = + static_cast<int>(pchart->plotArea().height() / (2 * largeFontSize)); + qDebug() << "Plot area height: " << pchart->plotArea().height(); +#else + const auto maxNrYLabels = static_cast<int>( + yAxisHeightPx / (2 * largeFontSize.getPixelSize())); +#endif + + // find largest bar + int largestBar = 0; + auto seriesList = pchart->series(); + if (seriesList.size() != 1) { + qDebug() << "Wrong number of series in a task point chart"; + assert(false); + continue; + } + + auto series = static_cast<QAbstractBarSeries *>(seriesList[0]); + + auto barSetList = series->barSets(); + if (barSetList.size() != 1) { + qDebug() << "Wrong number of bar sets in a task point bar set"; + assert(false); + continue; + } + + auto barSet = barSetList[0]; + + for (int i = 0; i < barSet->count(); ++i) { + if ((*barSet)[i] > largestBar) { + largestBar = static_cast<int>((*barSet)[i]); + } + } + + { + auto yaxes = pchart->axes(Qt::Orientation::Vertical); + for (auto &yax : yaxes) { + + niceYAxis(static_cast<QValueAxis *>(yax), largestBar, + maxNrYLabels); + } + } + + // x axis + { + auto xaxes = pchart->axes(Qt::Orientation::Horizontal); + for (auto &xax : xaxes) { + + // get number of characters of maximum label + auto xValueAx = static_cast<QValueAxis *>(xax); + const auto maxLabel = QString::number( + xValueAx->max(), 'f', + nrDecimalPlaces( + cur->mUserPointConfig.mGradeBoundaryStepSize)); + qDebug() << "Max label is" << maxLabel; + + auto labelFont = xValueAx->labelsFont(); + QFontMetrics fm(labelFont); + const auto maxLabelWidth = fm.horizontalAdvance(maxLabel); + + // get width of x-ax + const auto xAxWidth = + (windowSize.width() - mLayout->cellRect(0, 0).width()) * + 0.8; + + const auto maxNrLabels = + static_cast<int>(xAxWidth / maxLabelWidth / 1.2); + qDebug() << "Max nr labels:" << maxNrLabels; + + niceXAxis( + xValueAx, maxNrLabels, + as<double>(cur->mUserPointConfig.mGradeBoundaryStepSize)); + } + } + } +} + void TaskTab::pointsChanged() { // Delete previous charts @@ -39,6 +350,8 @@ void TaskTab::pointsChanged() { // The following deletes all child widgets, i.e. the charts and // chartViews delete takeWidget(); + mPointCharts.clear(); + mBoxCharts.clear(); const auto contents = new QWidget(this); mLayout = new QGridLayout(contents); @@ -98,6 +411,7 @@ void TaskTab::pointsChanged() { // Point bucket plot { auto pointChart = new QChart; + mPointCharts.push_back(pointChart); // For each possible point value, count how many students // got that many points @@ -118,10 +432,9 @@ void TaskTab::pointsChanged() { taskSeries->append(set); - pointChart->addSeries(taskSeries); - // x-axis { +#if 0 const auto axisX = new QBarCategoryAxis(); for (int j = 0; j < nrBuckets; ++j) { @@ -133,8 +446,29 @@ void TaskTab::pointsChanged() { << (j * static_cast<double>(stepSize)); axisX->append(QString::fromStdString(category.str())); } +#else + const auto axisX = new QValueAxis(); + axisX->setTickType(QValueAxis::TicksDynamic); + axisX->setTickAnchor(0); + axisX->setTickInterval(1); + axisX->setMin(0 - static_cast<double>(stepSize) / 2.); + axisX->setMax(nrBuckets * static_cast<double>(stepSize) - + static_cast<double>(stepSize) / 2.); + axisX->setLabelFormat( + QString("%.") + + QString::number(nrDecimalPlaces( + cur->mUserPointConfig.mGradeBoundaryStepSize)) + + "f"); +#endif + + //// font + //{ + // auto font = axisX->labelsFont(); + // font.setPointSize(defaults::fontSizeBig); + // axisX->setLabelsFont(font); + //} + pointChart->addAxis(axisX, Qt::AlignBottom); - taskSeries->attachAxis(axisX); } // y-axis @@ -188,7 +522,15 @@ void TaskTab::pointsChanged() { axisY->setMax(pointChartYMax); } + //// font + //{ + // auto font = axisY->labelsFont(); + // font.setPointSize(defaults::fontSizeBig - 6); + // axisY->setLabelsFont(font); + //} + pointChart->addAxis(axisY, Qt::AlignLeft); + pointChart->addSeries(taskSeries); taskSeries->attachAxis(axisY); } @@ -200,19 +542,20 @@ void TaskTab::pointsChanged() { chartView->setInteractive(false); mLayout->addWidget(chartView, i * 3, 1); + + qDebug() << "PointChart Margins:" << pointChart->margins(); } // BoxPlot { auto boxChart = new QChart; + mBoxCharts.push_back(boxChart); auto *taskSeries = new QBoxPlotSeries; auto *set = new QBoxSet; // Make sure the different plot elements don't overlap each // other - // const qreal minDistance = static_cast<qreal>(nrBuckets) * - // 0.005; const qreal minDistance = stepSize * 0.1; const qreal median = sortedPoints[nrStudents / 2].p; @@ -250,6 +593,13 @@ void TaskTab::pointsChanged() { boxChart->addAxis(axisY, Qt::AlignBottom); taskSeries->attachAxis(axisY); + + //// font + //{ + // auto font = axisY->labelsFont(); + // font.setPointSize(defaults::fontSizeBig - 6); + // axisY->setLabelsFont(font); + //} } // add x axis to specify min and max @@ -286,7 +636,7 @@ void TaskTab::pointsChanged() { // Furthermore, reduce the left and right because of // our rotation, above and below) margin to have less space // between box plot and bar chart. - boxChart->setMargins(QMargins(5, 14, 0, 20)); + boxChart->setMargins(QMargins(5, 30, 0, 20)); } } diff --git a/src/view/main_window/task_tab.hpp b/src/view/main_window/task_tab.hpp index 8920e4bf46db89529c9cd2554ce89ad634402ee6..cc7f55c4f67b31611d76361c949f7a6c5d600219 100644 --- a/src/view/main_window/task_tab.hpp +++ b/src/view/main_window/task_tab.hpp @@ -1,6 +1,10 @@ #pragma once +#include "util/font.hpp" + +#include <QChart> #include <QGridLayout> +#include <QList> #include <QScrollArea> class TaskTab : public QScrollArea { @@ -9,6 +13,10 @@ class TaskTab : public QScrollArea { public: explicit TaskTab(QWidget *parent = nullptr); + // Change the font size of the axis labels and potentially change the number + // of axis labels + void handleResize(QSize windowSize, FontSize largeFontSize); + public slots: /** * \brief Redraw all charts. @@ -16,5 +24,11 @@ public slots: void pointsChanged(); private: + void changeFontSize(FontSize largeFontSize); + void correctAxisLabels(QSize windowSize, FontSize largeFontSize); + QGridLayout *mLayout = nullptr; + + QList<QChart *> mPointCharts; + QList<QChart *> mBoxCharts; }; diff --git a/src/view/statistic_window/statistic_window.cpp b/src/view/statistic_window/statistic_window.cpp index c8a22e0fc1a0631f305ab658e069b18f92849b79..ac2005d3c484fb18464ee01cd7d4db0b0d597fcd 100644 --- a/src/view/statistic_window/statistic_window.cpp +++ b/src/view/statistic_window/statistic_window.cpp @@ -61,7 +61,8 @@ void StatisticWindow::pointsChanged() { const auto nrAttended = attendedStudents.size(); const auto nrRegistered = registeredStudents.size(); - const auto nrRegisteredAttended = static_cast<qsizetype>(intersection.size()); + const auto nrRegisteredAttended = + static_cast<qsizetype>(intersection.size()); mNrAttended.setText(QLocale().toString(nrAttended)); mNrAttendedRegistered.setText(QLocale().toString(nrRegisteredAttended));