commit 38701a301790e1c1814f369781bde3b2087e74a0 Author: georgew Date: Sun Jan 25 15:56:01 2026 +0000 initial commit diff --git a/.cache/v/cache/lastfailed b/.cache/v/cache/lastfailed new file mode 100644 index 0000000..522a71a --- /dev/null +++ b/.cache/v/cache/lastfailed @@ -0,0 +1,3 @@ +{ + "site/test_surveyapp.py": true +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69393bd --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +SECRET_KEY=yourSecretKey +MONGO_URI=yourMongoDbUri +EMAIL_USERNAME=email@example.com +EMAIL_PASSWORD=yourEmailPassword diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3676d70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +node_modules +__pycache__ +site/surveyapp/uploads/* +!site/surveyapp/uploads/.gitkeep +.env +site/.pytest_cache/* +site/test_surveyapp.py +site/testfiles + +# Python artifacts +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +env/ +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a78d9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM --platform=linux/amd64 python:3.7-bullseye + +# 1. System tools +RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 2. Build tools +RUN pip install --upgrade pip setuptools wheel + +# 3. Math stack (Your hard-won binaries) +RUN pip install numpy==1.19.5 scipy==1.5.4 pandas==1.1.5 statsmodels==0.12.2 + +# 4. THE COMPATIBILITY LAYER: Fix the 2022-2026 breaking changes +# We add pandas-flavor and pingouin pins here +RUN pip install "itsdangerous<2.1.0" "Jinja2<3.1.0" "Werkzeug<2.1.0" "MarkupSafe<2.1.0" +RUN pip install "pandas-flavor==0.2.0" "pingouin==0.3.8" + +# 5. Remaining Pipenv app +COPY Pipfile Pipfile.lock ./ +RUN pip install pipenv && pipenv install --system --skip-lock + +COPY . . + +ENV FLASK_APP=site/run.py +ENV FLASK_DEBUG=1 +ENV OUTDATED_IGNORE=1 + +EXPOSE 5000 + +CMD ["flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..fc80ae1 --- /dev/null +++ b/Pipfile @@ -0,0 +1,26 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +flask = "*" +flask-wtf = "*" +email-validator = "*" +flask-dropzone = "*" +flask-pymongo = "*" +flask-bcrypt = "*" +flask-login = "*" +pandas = "*" +python-dotenv = "*" +xlrd = "*" +scipy = "*" +pingouin = "*" +flask-mail = "*" +flask-jsglue = "*" +xlsxwriter = "*" + +[requires] +python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..dd9d6a3 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,654 @@ +{ + "_meta": { + "hash": { + "sha256": "47dea41b9cc45dde547eac773b61d066150e66bff8ecae482909c69776d573bb" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "bcrypt": { + "hashes": [ + "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29", + "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7", + "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34", + "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55", + "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6", + "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1", + "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d" + ], + "version": "==3.2.0" + }, + "blinker": { + "hashes": [ + "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" + ], + "version": "==1.4" + }, + "certifi": { + "hashes": [ + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + ], + "version": "==2020.6.20" + }, + "cffi": { + "hashes": [ + "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", + "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", + "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", + "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", + "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", + "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", + "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", + "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", + "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", + "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", + "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", + "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", + "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", + "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", + "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", + "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", + "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", + "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", + "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", + "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", + "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", + "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", + "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", + "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", + "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", + "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", + "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", + "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" + ], + "version": "==1.14.2" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "version": "==7.1.2" + }, + "cycler": { + "hashes": [ + "sha256:1d8a5ae1ff6c5cf9b93e8811e581232ad8920aeec647c37316ceac982b08cb2d", + "sha256:cd7b2d1018258d7247a71425e9f26463dfb444d411c39569972f4ce586b0c9d8" + ], + "version": "==0.10.0" + }, + "dnspython": { + "hashes": [ + "sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7", + "sha256:40bb3c24b9d4ec12500f0124288a65df232a3aa749bb0c39734b782873a2544d" + ], + "version": "==2.0.0" + }, + "email-validator": { + "hashes": [ + "sha256:5f246ae8d81ce3000eade06595b7bb55a4cf350d559e890182a1466a21f25067", + "sha256:63094045c3e802c3d3d575b18b004a531c36243ca8d1cec785ff6bfcb04185bb" + ], + "index": "pypi", + "version": "==1.1.1" + }, + "flask": { + "hashes": [ + "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", + "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" + ], + "index": "pypi", + "version": "==1.1.2" + }, + "flask-bcrypt": { + "hashes": [ + "sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f" + ], + "index": "pypi", + "version": "==0.7.1" + }, + "flask-dropzone": { + "hashes": [ + "sha256:e5e3d4740d407807aa99d7b6438aad812a1ce01e1b07b0f409462ff078386709", + "sha256:fddeb963aef31da81e7bc39cad740e8778a8c59d96ef76c7d5ed362fc626a73a" + ], + "index": "pypi", + "version": "==1.5.4" + }, + "flask-jsglue": { + "hashes": [ + "sha256:915e010ee228c711d56530bb5fbbefd1ca8bbd7e0fabfed49c85301288b45df9" + ], + "index": "pypi", + "version": "==0.3.1" + }, + "flask-login": { + "hashes": [ + "sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b", + "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0" + ], + "index": "pypi", + "version": "==0.5.0" + }, + "flask-mail": { + "hashes": [ + "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41" + ], + "index": "pypi", + "version": "==0.9.1" + }, + "flask-pymongo": { + "hashes": [ + "sha256:620eb02dc8808a5fcb90f26cab6cba9d6bf497b15032ae3ca99df80366e33314", + "sha256:8a9577a2c6d00b49f21cb5a5a8d72561730364a2d745551a85349ab02f86fc73" + ], + "index": "pypi", + "version": "==2.3.0" + }, + "flask-wtf": { + "hashes": [ + "sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2", + "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720" + ], + "index": "pypi", + "version": "==0.14.3" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "version": "==2.10" + }, + "itsdangerous": { + "hashes": [ + "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", + "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" + ], + "version": "==1.1.0" + }, + "jinja2": { + "hashes": [ + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + ], + "version": "==2.11.2" + }, + "joblib": { + "hashes": [ + "sha256:8f52bf24c64b608bf0b2563e0e47d6fcf516abc8cfafe10cfd98ad66d94f92d6", + "sha256:d348c5d4ae31496b2aa060d6d9b787864dd204f9480baaa52d18850cb43e9f49" + ], + "version": "==0.16.0" + }, + "kiwisolver": { + "hashes": [ + "sha256:03662cbd3e6729f341a97dd2690b271e51a67a68322affab12a5b011344b973c", + "sha256:18d749f3e56c0480dccd1714230da0f328e6e4accf188dd4e6884bdd06bf02dd", + "sha256:247800260cd38160c362d211dcaf4ed0f7816afb5efe56544748b21d6ad6d17f", + "sha256:38d05c9ecb24eee1246391820ed7137ac42a50209c203c908154782fced90e44", + "sha256:443c2320520eda0a5b930b2725b26f6175ca4453c61f739fef7a5847bd262f74", + "sha256:4eadb361baf3069f278b055e3bb53fa189cea2fd02cb2c353b7a99ebb4477ef1", + "sha256:556da0a5f60f6486ec4969abbc1dd83cf9b5c2deadc8288508e55c0f5f87d29c", + "sha256:603162139684ee56bcd57acc74035fceed7dd8d732f38c0959c8bd157f913fec", + "sha256:60a78858580761fe611d22127868f3dc9f98871e6fdf0a15cc4203ed9ba6179b", + "sha256:63f55f490b958b6299e4e5bdac66ac988c3d11b7fafa522800359075d4fa56d1", + "sha256:7cc095a4661bdd8a5742aaf7c10ea9fac142d76ff1770a0f84394038126d8fc7", + "sha256:be046da49fbc3aa9491cc7296db7e8d27bcf0c3d5d1a40259c10471b014e4e0c", + "sha256:c31bc3c8e903d60a1ea31a754c72559398d91b5929fcb329b1c3a3d3f6e72113", + "sha256:c955791d80e464da3b471ab41eb65cf5a40c15ce9b001fdc5bbc241170de58ec", + "sha256:d069ef4b20b1e6b19f790d00097a5d5d2c50871b66d10075dab78938dc2ee2cf", + "sha256:d52b989dc23cdaa92582ceb4af8d5bcc94d74b2c3e64cd6785558ec6a879793e", + "sha256:e586b28354d7b6584d8973656a7954b1c69c93f708c0c07b77884f91640b7657", + "sha256:efcf3397ae1e3c3a4a0a0636542bcad5adad3b1dd3e8e629d0b6e201347176c8", + "sha256:fccefc0d36a38c57b7bd233a9b485e2f1eb71903ca7ad7adacad6c28a56d62d2" + ], + "version": "==1.2.0" + }, + "littleutils": { + "hashes": [ + "sha256:e6cae3a4203e530d51c9667ed310ffe3b1948f2876e3d69605b3de4b7d96916f" + ], + "version": "==0.2.2" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + ], + "version": "==1.1.1" + }, + "matplotlib": { + "hashes": [ + "sha256:0dc15e1ad84ec06bf0c315e6c4c2cced13a21ce4c2b4955bb75097064a4b1e92", + "sha256:1507c2a8e4662f6fa1d3ecc760782b158df8a3244ecc21c1d8dbb1cd0b3f872e", + "sha256:1f9cf2b8500b833714a193cb24281153f5072d55b2e486009f1e81f0b7da3410", + "sha256:233bef5e3b3494f3b7057595ca814f23ba0ce67a03632ddf677be5132128b3db", + "sha256:2375f039b8c6ad6c1d03f01bf31f086bbbf997bf25e246f3b67f69969cde3d98", + "sha256:24392ac1a382ed753505286f1a1483bcfd67ed0c72d51be10c4c2013e386d0b7", + "sha256:282f8a077a1217f9f2ac178596f27c1ae94abbc6e7b785e1b8f25e83918e9199", + "sha256:2c3619ec2a5ead430a4536ebf8c77ea55d8ce36418919f831d35bc657ed5f27e", + "sha256:5a42c84264a1acbbf01c073a7bd05a0e80d99f94f10020d613b1b0526af9dcc2", + "sha256:636c6330a7dcb18bac114dbeaff314fbbb0c11682f9a9601de69a50e331d18d7", + "sha256:6739b6cd9278d5cb337df0bd4400ad37bbd04c6dc7aa2c65e1e83a02bc4cc6fd", + "sha256:6d0f03079f655ca0a2d2e0bf49c28e1ec43d9d544c33d8da1a88765f23018ecc", + "sha256:73a493e340064e8fe03207d9333b68baca30d9f0da543ae4af6b6b4f13f0fe05", + "sha256:79f0c4730ad422ecb6bda814c9a9b375df36d6bd5a49eaa14e92e5f5e3e95ac3", + "sha256:83ae7261f4d5ab387be2caee29c4f499b1566f31c8ac97a0b8ab61afd9e3da92", + "sha256:87f53bcce90772f942c2db56736788b39332d552461a5cb13f05ff45c1680f0e", + "sha256:88c6ab4a32a7447dad236b8371612aaba5c967d632ff11999e0478dd687f2c58", + "sha256:96a5e667308dbf45670370d9dffb974e73b15bac0df0b5f3fb0b0ac7a572290e", + "sha256:9703bc00a94a94c4e94b2ea0fbfbc9d2bb21159733134639fd931b6606c5c47e", + "sha256:bc978374b43737f2bbc4a6ec48e52ae8c92be6278a80d0e2ce92f0eb0841f15c", + "sha256:bd8fceaa3494b531d43b6206966ba15705638137fc2dc5da5ee560cf9476867b", + "sha256:c4ffb25b9855bdb6cdaf21bbd4ab2c229be539248304ac5215b94c816ea6e32e", + "sha256:cc2d6b47c8fee89da982a312b54949ec0cd6a7976a8cafb5b62dea6c9883a14d", + "sha256:e4d6d3afc454b4afc0d9d0ed52a8fa40a1b0d8f33c8e143e49a5833a7e32266b", + "sha256:f62c0b9a5d38c26673a8862cbae4d26cffcda260848e4278246b4e00f5a95eaf", + "sha256:fab11637734eb14affb9c5e20d44d69429c18b49595d6e67c69295de24827fc4", + "sha256:ffbae66e2db70dc330cb3299525f97e1c0efdfc763e04e1a4e08f968c7ad21f0" + ], + "version": "==3.3.1" + }, + "numpy": { + "hashes": [ + "sha256:082f8d4dd69b6b688f64f509b91d482362124986d98dc7dc5f5e9f9b9c3bb983", + "sha256:1bc0145999e8cb8aed9d4e65dd8b139adf1919e521177f198529687dbf613065", + "sha256:309cbcfaa103fc9a33ec16d2d62569d541b79f828c382556ff072442226d1968", + "sha256:3673c8b2b29077f1b7b3a848794f8e11f401ba0b71c49fbd26fb40b71788b132", + "sha256:480fdd4dbda4dd6b638d3863da3be82873bba6d32d1fc12ea1b8486ac7b8d129", + "sha256:56ef7f56470c24bb67fb43dae442e946a6ce172f97c69f8d067ff8550cf782ff", + "sha256:5a936fd51049541d86ccdeef2833cc89a18e4d3808fe58a8abeb802665c5af93", + "sha256:5b6885c12784a27e957294b60f97e8b5b4174c7504665333c5e94fbf41ae5d6a", + "sha256:667c07063940e934287993366ad5f56766bc009017b4a0fe91dbd07960d0aba7", + "sha256:7ed448ff4eaffeb01094959b19cbaf998ecdee9ef9932381420d514e446601cd", + "sha256:8343bf67c72e09cfabfab55ad4a43ce3f6bf6e6ced7acf70f45ded9ebb425055", + "sha256:92feb989b47f83ebef246adabc7ff3b9a59ac30601c3f6819f8913458610bdcc", + "sha256:935c27ae2760c21cd7354402546f6be21d3d0c806fffe967f745d5f2de5005a7", + "sha256:aaf42a04b472d12515debc621c31cf16c215e332242e7a9f56403d814c744624", + "sha256:b12e639378c741add21fbffd16ba5ad25c0a1a17cf2b6fe4288feeb65144f35b", + "sha256:b1cca51512299841bf69add3b75361779962f9cee7d9ee3bb446d5982e925b69", + "sha256:b8456987b637232602ceb4d663cb34106f7eb780e247d51a260b84760fd8f491", + "sha256:b9792b0ac0130b277536ab8944e7b754c69560dac0415dd4b2dbd16b902c8954", + "sha256:c9591886fc9cbe5532d5df85cb8e0cc3b44ba8ce4367bd4cf1b93dc19713da72", + "sha256:cf1347450c0b7644ea142712619533553f02ef23f92f781312f6a3553d031fc7", + "sha256:de8b4a9b56255797cbddb93281ed92acbc510fb7b15df3f01bd28f46ebc4edae", + "sha256:e1b1dc0372f530f26a03578ac75d5e51b3868b9b76cd2facba4c9ee0eb252ab1", + "sha256:e45f8e981a0ab47103181773cc0a54e650b2aef8c7b6cd07405d0fa8d869444a", + "sha256:e4f6d3c53911a9d103d8ec9518190e52a8b945bab021745af4939cfc7c0d4a9e", + "sha256:ed8a311493cf5480a2ebc597d1e177231984c818a86875126cfd004241a73c3e", + "sha256:ef71a1d4fd4858596ae80ad1ec76404ad29701f8ca7cdcebc50300178db14dfc" + ], + "version": "==1.19.1" + }, + "outdated": { + "hashes": [ + "sha256:bcb145e0e372ba467e998c327d3d1ba72a134b0d5a729749729df6c6244ce643" + ], + "version": "==0.2.0" + }, + "pandas": { + "hashes": [ + "sha256:01b1e536eb960822c5e6b58357cad8c4b492a336f4a5630bf0b598566462a578", + "sha256:0246c67cbaaaac8d25fed8d4cf2d8897bd858f0e540e8528a75281cee9ac516d", + "sha256:0366150fe8ee37ef89a45d3093e05026b5f895e42bbce3902ce3b6427f1b8471", + "sha256:16ae070c47474008769fc443ac765ffd88c3506b4a82966e7a605592978896f9", + "sha256:1acc2bd7fc95e5408a4456897c2c2a1ae7c6acefe108d90479ab6d98d34fcc3d", + "sha256:391db82ebeb886143b96b9c6c6166686c9a272d00020e4e39ad63b792542d9e2", + "sha256:41675323d4fcdd15abde068607cad150dfe17f7d32290ee128e5fea98442bd09", + "sha256:53328284a7bb046e2e885fd1b8c078bd896d7fc4575b915d4936f54984a2ba67", + "sha256:57c5f6be49259cde8e6f71c2bf240a26b071569cabc04c751358495d09419e56", + "sha256:84c101d0f7bbf0d9f1be9a2f29f6fcc12415442558d067164e50a56edfb732b4", + "sha256:88930c74f69e97b17703600233c0eaf1f4f4dd10c14633d522724c5c1b963ec4", + "sha256:8c9ec12c480c4d915e23ee9c8a2d8eba8509986f35f307771045c1294a2e5b73", + "sha256:a81c4bf9c59010aa3efddbb6b9fc84a9b76dc0b4da2c2c2d50f06a9ef6ac0004", + "sha256:d9644ac996149b2a51325d48d77e25c911e01aa6d39dc1b64be679cd71f683ec", + "sha256:e4b6c98f45695799990da328e6fd7d6187be32752ed64c2f22326ad66762d179", + "sha256:fe6f1623376b616e03d51f0dd95afd862cf9a33c18cf55ce0ed4bbe1c4444391" + ], + "index": "pypi", + "version": "==1.1.1" + }, + "pandas-flavor": { + "hashes": [ + "sha256:7871655816de22dc766e916697ccc67449e1863c090ef5fd40d4d0fbd056e489", + "sha256:ce4d3640a89435c27eb2369305455865f043464ee5ae450e5388f4fb30eae241" + ], + "version": "==0.2.0" + }, + "patsy": { + "hashes": [ + "sha256:5465be1c0e670c3a965355ec09e9a502bf2c4cbe4875e8528b0221190a8a5d40", + "sha256:f115cec4201e1465cd58b9866b0b0e7b941caafec129869057405bfe5b5e3991" + ], + "version": "==0.5.1" + }, + "pillow": { + "hashes": [ + "sha256:0295442429645fa16d05bd567ef5cff178482439c9aad0411d3f0ce9b88b3a6f", + "sha256:06aba4169e78c439d528fdeb34762c3b61a70813527a2c57f0540541e9f433a8", + "sha256:09d7f9e64289cb40c2c8d7ad674b2ed6105f55dc3b09aa8e4918e20a0311e7ad", + "sha256:0a80dd307a5d8440b0a08bd7b81617e04d870e40a3e46a32d9c246e54705e86f", + "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae", + "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d", + "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5", + "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b", + "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8", + "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233", + "sha256:6d7741e65835716ceea0fd13a7d0192961212fd59e741a46bbed7a473c634ed6", + "sha256:6edb5446f44d901e8683ffb25ebdfc26988ee813da3bf91e12252b57ac163727", + "sha256:725aa6cfc66ce2857d585f06e9519a1cc0ef6d13f186ff3447ab6dff0a09bc7f", + "sha256:8dad18b69f710bf3a001d2bf3afab7c432785d94fcf819c16b5207b1cfd17d38", + "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4", + "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626", + "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d", + "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6", + "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6", + "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63", + "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f", + "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41", + "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1", + "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117", + "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d", + "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9", + "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a", + "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce" + ], + "version": "==7.2.0" + }, + "pingouin": { + "hashes": [ + "sha256:32f1cedce4b34d52b89465426a57e29b1da233a94e52d8a16aa0720b0cf94493" + ], + "index": "pypi", + "version": "==0.3.7" + }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "version": "==2.20" + }, + "pymongo": { + "hashes": [ + "sha256:03dc64a9aa7a5d405aea5c56db95835f6a2fa31b3502c5af1760e0e99210be30", + "sha256:05fcc6f9c60e6efe5219fbb5a30258adb3d3e5cbd317068f3d73c09727f2abb6", + "sha256:076a7f2f7c251635cf6116ac8e45eefac77758ee5a77ab7bd2f63999e957613b", + "sha256:137e6fa718c7eff270dbd2fc4b90d94b1a69c9e9eb3f3de9e850a7fd33c822dc", + "sha256:1f865b1d1c191d785106f54df9abdc7d2f45a946b45fd1ea0a641b4f982a2a77", + "sha256:213c445fe7e654621c6309e874627c35354b46ef3ee807f5a1927dc4b30e1a67", + "sha256:25e617daf47d8dfd4e152c880cd0741cbdb48e51f54b8de9ddbfe74ecd87dd16", + "sha256:3d9bb1ba935a90ec4809a8031efd988bdb13cdba05d9e9a3e9bf151bf759ecde", + "sha256:40696a9a53faa7d85aaa6fd7bef1cae08f7882640bad08c350fb59dee7ad069b", + "sha256:421aa1b92c291c429668bd8d8d8ec2bd00f183483a756928e3afbf2b6f941f00", + "sha256:4437300eb3a5e9cc1a73b07d22c77302f872f339caca97e9bf8cf45eca8fa0d2", + "sha256:455f4deb00158d5ec8b1d3092df6abb681b225774ab8a59b3510293b4c8530e3", + "sha256:475a34a0745c456ceffaec4ce86b7e0983478f1b6140890dff7b161e7bcd895b", + "sha256:4797c0080f41eba90404335e5ded3aa66731d303293a675ff097ce4ea3025bb9", + "sha256:4ae23fbbe9eadf61279a26eba866bbf161a6f7e2ffad14a42cf20e9cb8e94166", + "sha256:4b32744901ee9990aa8cd488ec85634f443526def1e5190a407dc107148249d7", + "sha256:50127b13b38e8e586d5e97d342689405edbd74ad0bd891d97ee126a8c7b6e45f", + "sha256:50531caa7b4be1c4ed5e2d5793a4e51cc9bd62a919a6fd3299ef7c902e206eab", + "sha256:68220b81850de8e966d4667d5c325a96c6ac0d6adb3d18935d6e3d325d441f48", + "sha256:689142dc0c150e9cb7c012d84cac2c346d40beb891323afb6caf18ec4caafae0", + "sha256:6a15e2bee5c4188369a87ed6f02de804651152634a46cca91966a11c8abd2550", + "sha256:7122ffe597b531fb065d3314e704a6fe152b81820ca5f38543e70ffcc95ecfd4", + "sha256:7307024b18266b302f4265da84bb1effb5d18999ef35b30d17592959568d5c0a", + "sha256:7a4a6f5b818988a3917ec4baa91d1143242bdfece8d38305020463955961266a", + "sha256:83c5a3ecd96a9f3f11cfe6dfcbcec7323265340eb24cc996acaecea129865a3a", + "sha256:890b0f1e18dbd898aeb0ab9eae1ab159c6bcbe87f0abb065b0044581d8614062", + "sha256:8deda1f7b4c03242f2a8037706d9584e703f3d8c74d6d9cac5833db36fe16c42", + "sha256:8ea13d0348b4c96b437d944d7068d59ed4a6c98aaa6c40d8537a2981313f1c66", + "sha256:91e96bf85b7c07c827d339a386e8a3cf2e90ef098c42595227f729922d0851df", + "sha256:96782ebb3c9e91e174c333208b272ea144ed2a684413afb1038e3b3342230d72", + "sha256:9755c726aa6788f076114dfdc03b92b03ff8860316cca00902cce88bcdb5fedd", + "sha256:9dbab90c348c512e03f146e93a5e2610acec76df391043ecd46b6b775d5397e6", + "sha256:9ee0eef254e340cc11c379f797af3977992a7f2c176f1a658740c94bf677e13c", + "sha256:9fc17fdac8f1973850d42e51e8ba6149d93b1993ed6768a24f352f926dd3d587", + "sha256:a2787319dc69854acdfd6452e6a8ba8f929aeb20843c7f090e04159fc18e6245", + "sha256:b7c522292407fa04d8195032493aac937e253ad9ae524aab43b9d9d242571f03", + "sha256:bd312794f51e37dcf77f013d40650fe4fbb211dd55ef2863839c37480bd44369", + "sha256:c0d660a186e36c526366edf8a64391874fe53cf8b7039224137aee0163c046df", + "sha256:c4869141e20769b65d2d72686e7a7eb141ce9f3168106bed3e7dcced54eb2422", + "sha256:cc4057f692ac35bbe82a0a908d42ce3a281c9e913290fac37d7fa3bd01307dfb", + "sha256:cccf1e7806f12300e3a3b48f219e111000c2538483e85c869c35c1ae591e6ce9", + "sha256:ce208f80f398522e49d9db789065c8ad2cd37b21bd6b23d30053474b7416af11", + "sha256:d0565481dc196986c484a7fb13214fc6402201f7fb55c65fd215b3324962fe6c", + "sha256:d1b3366329c45a474b3bbc9b9c95d4c686e03f35da7fd12bc144626d1f2a7c04", + "sha256:d226e0d4b9192d95079a9a29c04dd81816b1ce8903b8c174a39224fe978547cb", + "sha256:d38b35f6eef4237b1d0d8e845fc1546dad85c55eba447e28c211da8c7ef9697c", + "sha256:d64c98277ea80e4484f1332ab107e8dfd173a7dcf1bdbf10a9cccc97aaab145f", + "sha256:d9de8427a5601799784eb0e7fa1b031aa64086ce04de29df775a8ca37eedac41", + "sha256:e6a15cf8f887d9f578dd49c6fb3a99d53e1d922fdd67a245a67488d77bf56eb2", + "sha256:e8c446882cbb3774cd78c738c9f58220606b702b7c1655f1423357dc51674054", + "sha256:e8d188ee39bd0ffe76603da887706e4e7b471f613625899ddf1e27867dc6a0d3", + "sha256:ef76535776c0708a85258f6dc51d36a2df12633c735f6d197ed7dfcaa7449b99", + "sha256:f6efca006a81e1197b925a7d7b16b8f61980697bb6746587aad8842865233218" + ], + "version": "==3.11.0" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "version": "==2.4.7" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "version": "==2.8.1" + }, + "python-dotenv": { + "hashes": [ + "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d", + "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423" + ], + "index": "pypi", + "version": "==0.14.0" + }, + "pytz": { + "hashes": [ + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + ], + "version": "==2020.1" + }, + "requests": { + "hashes": [ + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" + ], + "version": "==2.24.0" + }, + "scikit-learn": { + "hashes": [ + "sha256:0a127cc70990d4c15b1019680bfedc7fec6c23d14d3719fdf9b64b22d37cdeca", + "sha256:0d39748e7c9669ba648acf40fb3ce96b8a07b240db6888563a7cb76e05e0d9cc", + "sha256:1b8a391de95f6285a2f9adffb7db0892718950954b7149a70c783dc848f104ea", + "sha256:20766f515e6cd6f954554387dfae705d93c7b544ec0e6c6a5d8e006f6f7ef480", + "sha256:2aa95c2f17d2f80534156215c87bee72b6aa314a7f8b8fe92a2d71f47280570d", + "sha256:5ce7a8021c9defc2b75620571b350acc4a7d9763c25b7593621ef50f3bd019a2", + "sha256:6c28a1d00aae7c3c9568f61aafeaad813f0f01c729bee4fd9479e2132b215c1d", + "sha256:7671bbeddd7f4f9a6968f3b5442dac5f22bf1ba06709ef888cc9132ad354a9ab", + "sha256:914ac2b45a058d3f1338d7736200f7f3b094857758895f8667be8a81ff443b5b", + "sha256:98508723f44c61896a4e15894b2016762a55555fbf09365a0bb1870ecbd442de", + "sha256:a64817b050efd50f9abcfd311870073e500ae11b299683a519fbb52d85e08d25", + "sha256:cb3e76380312e1f86abd20340ab1d5b3cc46a26f6593d3c33c9ea3e4c7134028", + "sha256:d0dcaa54263307075cb93d0bee3ceb02821093b1b3d25f66021987d305d01dce", + "sha256:d9a1ce5f099f29c7c33181cc4386660e0ba891b21a60dc036bf369e3a3ee3aec", + "sha256:da8e7c302003dd765d92a5616678e591f347460ac7b53e53d667be7dfe6d1b10", + "sha256:daf276c465c38ef736a79bd79fc80a249f746bcbcae50c40945428f7ece074f8" + ], + "version": "==0.23.2" + }, + "scipy": { + "hashes": [ + "sha256:066c513d90eb3fd7567a9e150828d39111ebd88d3e924cdfc9f8ce19ab6f90c9", + "sha256:07e52b316b40a4f001667d1ad4eb5f2318738de34597bd91537851365b6c61f1", + "sha256:0a0e9a4e58a4734c2eba917f834b25b7e3b6dc333901ce7784fd31aefbd37b2f", + "sha256:1c7564a4810c1cd77fcdee7fa726d7d39d4e2695ad252d7c86c3ea9d85b7fb8f", + "sha256:315aa2165aca31375f4e26c230188db192ed901761390be908c9b21d8b07df62", + "sha256:6e86c873fe1335d88b7a4bfa09d021f27a9e753758fd75f3f92d714aa4093768", + "sha256:8e28e74b97fc8d6aa0454989db3b5d36fc27e69cef39a7ee5eaf8174ca1123cb", + "sha256:92eb04041d371fea828858e4fff182453c25ae3eaa8782d9b6c32b25857d23bc", + "sha256:a0afbb967fd2c98efad5f4c24439a640d39463282040a88e8e928db647d8ac3d", + "sha256:a785409c0fa51764766840185a34f96a0a93527a0ff0230484d33a8ed085c8f8", + "sha256:cca9fce15109a36a0a9f9cfc64f870f1c140cb235ddf27fe0328e6afb44dfed0", + "sha256:d56b10d8ed72ec1be76bf10508446df60954f08a41c2d40778bc29a3a9ad9bce", + "sha256:dac09281a0eacd59974e24525a3bc90fa39b4e95177e638a31b14db60d3fa806", + "sha256:ec5fe57e46828d034775b00cd625c4a7b5c7d2e354c3b258d820c6c72212a6ec", + "sha256:eecf40fa87eeda53e8e11d265ff2254729d04000cd40bae648e76ff268885d66", + "sha256:fc98f3eac993b9bfdd392e675dfe19850cc8c7246a8fd2b42443e506344be7d9" + ], + "index": "pypi", + "version": "==1.5.2" + }, + "seaborn": { + "hashes": [ + "sha256:2d1a0c9d6bd1bc3cadb0364b8f06540f51322a670cf8438d0fde1c1c7317adc0", + "sha256:c901ce494541fb4714cfa7db79d0232dc3f4c4dfd3f273bacf17816084df5b53" + ], + "version": "==0.10.1" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "version": "==1.15.0" + }, + "statsmodels": { + "hashes": [ + "sha256:20e275f63e7e4c79133af444043a6ea95846b6165ecb21c7a4983fa7dbaf5396", + "sha256:5c7d6707ad3112b67f564abaf1845d3c02ecc7174c2d990d539f45c37e98ad35", + "sha256:5d93e7650632ffb05bd407248a673cc8b4a5dfc47bf6def4066c502a331fb5f4", + "sha256:63126117af7b402b500742df39b3e5fec2dc3c9084a71852f9c52ac8bfa4c035", + "sha256:6aa45c4182cd80926222fcff851850ff02778b16c0fb1381e04c1cf1cfbd4a8d", + "sha256:6ef6b8c26ea3ab45ed4f5dce3e79ea725ab8896c15ed6ac405f619e33fa321da", + "sha256:6f795ba042f0f183e60d0177da4fb85ebad6fe90f1c0ce2c4ed20336253aacf2", + "sha256:8a555397609e01e7802393dedff19a8811a5fd0d2b177b88dd8a2e156824bbd3", + "sha256:8cf730e37c5f21d9dabfb9af144fb9654d1211ec88eb6aa771ed96d814f7398d", + "sha256:9ada3ddf13e60a5728304e6ca176e6ad8ca83b80c85db593087d853c5c6d4a98", + "sha256:9e9845db4fcd06272da5db95c75a2e30366d3116260a6e559881a1c9d9bccfba", + "sha256:abb266fb5273fea512a9dac2097e66cbd574d119d162f1c7eab392ae069ee640", + "sha256:b4d549d8502b349e8e3bdd19ab424b1c5a5cd0b2e14e9aa2156e99d7396276a3", + "sha256:c8eb0f602e92e59b480001d4f3edac96736f47130a0d4485245cfc168e0ab116", + "sha256:cb317ab297b4196ac16d4ab671854f2e029916210ab6c93a642b7b94686327fc", + "sha256:e2c513846ffeecf38f901005b06c596e9b115e7c631b43bb5354339de5ee8e95" + ], + "version": "==0.12.0" + }, + "tabulate": { + "hashes": [ + "sha256:ac64cb76d53b1231d364babcd72abbb16855adac7de6665122f97b593f1eb2ba", + "sha256:db2723a20d04bcda8522165c73eea7c300eda74e0ce852d9022e0159d7895007" + ], + "version": "==0.8.7" + }, + "threadpoolctl": { + "hashes": [ + "sha256:38b74ca20ff3bb42caca8b00055111d74159ee95c4370882bbff2b93d24da725", + "sha256:ddc57c96a38beb63db45d6c159b5ab07b6bced12c45a1f07b2b92f272aebfa6b" + ], + "version": "==2.1.0" + }, + "urllib3": { + "hashes": [ + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" + ], + "version": "==1.25.10" + }, + "werkzeug": { + "hashes": [ + "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", + "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" + ], + "version": "==1.0.1" + }, + "wtforms": { + "hashes": [ + "sha256:7b504fc724d0d1d4d5d5c114e778ec88c37ea53144683e084215eed5155ada4c", + "sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c" + ], + "version": "==2.3.3" + }, + "xarray": { + "hashes": [ + "sha256:665de4253fcf951e7a6954313b77164d7efca2bd820f0a518b7c25ea46ee43cb", + "sha256:e38452dbbb9f7b1938ae23b81ca7d66d1109818040048ec42e59c0c738cbe8f3" + ], + "version": "==0.16.0" + }, + "xlrd": { + "hashes": [ + "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2", + "sha256:e551fb498759fa3a5384a94ccd4c3c02eb7c00ea424426e212ac0c57be9dfbde" + ], + "index": "pypi", + "version": "==1.2.0" + }, + "xlsxwriter": { + "hashes": [ + "sha256:830cad0a88f0f95e5a8945ee082182aa68ab89e7d9725d0c32c196207634244b", + "sha256:88ab71d464e8f6c3923335cc92d6035260aa9e7c8b27fcbb3a5bc07e2c671d22" + ], + "index": "pypi", + "version": "==1.3.3" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f62aa61 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# surveyApp +A web application to aid non-experts in processing their survey data through creating data visualisations and carrying out statistical tests. + +* [Documentation](./documentation.md) +* [Project Journal](./journal.md) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..d56e408 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + web: + build: . + container_name: surveyapp-web + restart: always + env_file: + - .env + environment: + - FLASK_APP=site/run.py + - FLASK_ENV=development + - PYTHONUNBUFFERED=1 + networks: + - web_traffic + +networks: + web_traffic: + external: true diff --git a/documentation.md b/documentation.md new file mode 100644 index 0000000..c8a3e7f --- /dev/null +++ b/documentation.md @@ -0,0 +1,232 @@ +## Setup guide +To start running the application, first carry out the installations listed below. + +### MacOS + +Instructions assume [Homebrew](https://brew.sh/) is already installed on your OSX host. + +### python 3 + +Used as the backend language for the project, handling server connections and database connections and for processing data used in the application. + +``` +brew install python +``` + + +### MongoDB + +The NoSQL database used for the application. + +If not already tapped 'MongoDB Homebrew Tap': + +``` +brew tap mongodb/brew +``` + +Now it is possible to install MongoDB Community Edition. + +``` +brew install mongodb-community@4.2 +``` + +### D3.js + +D3.js is used to create the different data visualisations used in the app, such as bar charts and line graphs. To install using [NPM](https://www.npmjs.com/get-npm), first initialise an NPM. + +``` +npm init +``` + +Then install D3.js and all of its dependencies. + +``` +npm install --save d3 +``` + +And then include the following line in the HTML. + +``` + +``` + +For the creation of geographical maps. + +``` +npm install datamap +``` + +For saving graphs produced by D3, I use 'saveSvgAsPng' and 'canvg' + +``` +npm install save-svg-as-png +npm install canvg +``` + +Alternatively to the above, you can include the scripts directly for D3 and canvg: + +``` + + +``` + +NOTE: DataMap requires version 3 of D3 and therefore you will need to include it separately (along with topojson): + +``` + + +``` + +### Python packages + +For installing our python packages we will be using [pipenv](https://pypi.org/project/pipenv/) which will also handle our virtual environment for us. To do this, run the following command (Note: it must be this latest version.) + +``` +pip install 'pipenv==2018.11.26' +``` + +Clone the git repository and navigate into the directory. +Then run the following command to install all the relevant packages. + +``` +pipenv install +``` + +If you would like to also install the dev dependencies, including 'pytest' for testing, then instead run: + +``` +pipenv install --dev +``` + +With this method there is no need to create a virtual environment, it is handled for you automatically by pipenv. +Following the installations, you can then enter that virtual environment by using: + +``` +pipenv shell +``` + +Then to run the application, navigate to the 'site' directory and run 'flask run' + +``` +cd site +flask run +``` + +The flask application knows which file needs to be run when starting the app from the environment variables set in ['.flaskenv'](./site/.flaskenv). At the moment, the .flaskenv file specifies the app to run in developer mode. Simply edit the '.flasenv' file for running in a different environment. + +### Full packages list + +All the python packages used are listed below for your information (along with the pip command for installing if you would rather do it that way instead.) + +#### Flask and its extensions + +Flask is a microframework of python that provides many of the tools required for building a web application, such as templating, routing and Web Server Gateway Interface (WSGI). +As it is a 'microframework', it is often also required to install several extensions to get more functionality. +The dependencies that are included are: + +- jinja2 - the templating engine. +- Werkzeug - a WSGI utility library. + +``` +pip install Flask +``` + +##### Flask-WTF + +Flask-WTF is a wrapper around the WTForms package, including CSRF, file upload, and reCAPTCHA. + +``` +pip install flask-wtf +``` + +##### email_validator + +It may also be necessary to install email_validator if not included in your version of Flask-WTF. + +``` +pip install email_validator +``` + +##### Flask-Dropzone + +For dragging and dropping of files onto the webpage for uploading. + +``` +pip install Flask-Dropzone +``` +##### Flask-PyMongo + +For connecting and interacting with a MongoDB database + +``` +pip install Flask-PyMongo +``` +##### python-dotenv + +For managing data within .env files + +``` +pip install python-dotenv +``` + +##### Flask-Bcrypt + +For hashing passwords to store in a database. + +``` +pip install flask-bcrypt +``` + +##### Flask-Login + +Handles user session for flask applications, including logging in, logging out and remember user sessions over time. + +``` +pip install flask-login +``` + + +##### Flask-JSGlue + +Allows for building flask-like URLs in javascript. Useful for Jquery POSTs (using flask synteax such as url_for). + +``` +pip install Flask-JSGlue +``` + +### Other + +##### pytest + +For carrying out unit testing of my code. + +``` +pip install pytest +``` + + +##### pandas and xlrd + +For easy installation of all packages (xlrd allows for parsing Excel files). + +``` +pip install pandas +pip install xlrd +``` + +##### SciPy and Pingouin + +Used for carrying out the statistical tests on the data. + +``` +pip install scipy +pip install pingouin +``` + +##### XlsxWriter + +For writing to excel files for users to export statistical tests + +``` +pip install XlsxWriter +``` diff --git a/images/DataTable.png b/images/DataTable.png new file mode 100644 index 0000000..7933d5c Binary files /dev/null and b/images/DataTable.png differ diff --git a/images/GraphTable.png b/images/GraphTable.png new file mode 100644 index 0000000..ececea9 Binary files /dev/null and b/images/GraphTable.png differ diff --git a/images/StatisticalTestTable.png b/images/StatisticalTestTable.png new file mode 100644 index 0000000..d1fcfbd Binary files /dev/null and b/images/StatisticalTestTable.png differ diff --git a/images/UserTable.png b/images/UserTable.png new file mode 100644 index 0000000..d3bc692 Binary files /dev/null and b/images/UserTable.png differ diff --git a/site/.cache/v/cache/lastfailed b/site/.cache/v/cache/lastfailed new file mode 100644 index 0000000..9bf46c8 --- /dev/null +++ b/site/.cache/v/cache/lastfailed @@ -0,0 +1,3 @@ +{ + "test_surveyapp.py": true +} \ No newline at end of file diff --git a/site/.flaskenv b/site/.flaskenv new file mode 100644 index 0000000..8133997 --- /dev/null +++ b/site/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=run.py +FLASK_ENV=development diff --git a/site/run.py b/site/run.py new file mode 100644 index 0000000..af493cb --- /dev/null +++ b/site/run.py @@ -0,0 +1,8 @@ +# Imports from the __init__.py file inside the surveyApp package +from surveyapp import create_app + +# Creates instantiation of the app +app = create_app("dev") + +if __name__ == '__main__': + app.run(debug=True) diff --git a/site/surveyapp/__init__.py b/site/surveyapp/__init__.py new file mode 100644 index 0000000..7826c76 --- /dev/null +++ b/site/surveyapp/__init__.py @@ -0,0 +1,57 @@ +from flask import Flask +from surveyapp.config import config_by_name + +from flask_bcrypt import Bcrypt +from flask_pymongo import PyMongo +from flask_login import LoginManager +from flask_mail import Mail +from flask_jsglue import JSGlue + + +# extensions created outside the create_app function (but initialised inside the function) +# for database handling +mongo = PyMongo() +# for hashing passwords +bcrypt = Bcrypt() +# login_manager provides tools such as checking if user is logged in, logging in and out of session etc. +login_manager = LoginManager() +# redirects users who are not logged in back to the users.login page +login_manager.login_view = "users.login" +# adds a CSS class to the message that is displayed when attempting to access pages when not logged in +login_manager.login_message_category = "error" + +mail = Mail() + +jsglue = JSGlue() + +# moving the app creation into a function allows for multiple instances of the app to made +# furthermore it allows for testing (as different testing instances can be made) +# this is following the flask factory pattern +def create_app(config_name): + app = Flask(__name__) + app.config.from_object(config_by_name[config_name]) + + # initialise extensions + mongo.init_app(app) + bcrypt.init_app(app) + login_manager.init_app(app) + mail.init_app(app) + jsglue.init_app(app) + + + # Import the blueprints and register them with out app (so it knows where to look for the routes) + from surveyapp.users.routes import users + from surveyapp.graphs.routes import graphs + from surveyapp.surveys.routes import surveys + from surveyapp.analysis.routes import analysis + from surveyapp.main.routes import main + from surveyapp.errors.handlers import errors + # Register the blueprint routes + app.register_blueprint(users) + app.register_blueprint(graphs) + app.register_blueprint(surveys) + app.register_blueprint(analysis) + app.register_blueprint(main) + app.register_blueprint(errors) + + return app diff --git a/site/surveyapp/analysis/__init__.py b/site/surveyapp/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/analysis/forms.py b/site/surveyapp/analysis/forms.py new file mode 100644 index 0000000..2ac2814 --- /dev/null +++ b/site/surveyapp/analysis/forms.py @@ -0,0 +1,25 @@ +from flask_wtf import FlaskForm +from wtforms import Form, StringField, SubmitField, SelectField, IntegerField, FormField, FieldList +from wtforms.validators import DataRequired, NumberRange + +class StatisticalTestForm(FlaskForm): + # We will append choices to drop down depending on the data and what is selected by the upser + # as survey takes an objectId as the value, we need to initialise it and also tell it to coerce ObjectIds + test = SelectField(choices=[("", " -- select an option -- "), ("Kruskall Wallis Test", "Kruskall Wallis Test"), ("Mann-Whitney U Test", "Mann-Whitney U Test"), ("Chi-Square Test", "Chi-Square Test"), ("Chi-Square goodness of fit", "Chi-Square goodness of fit")], validators=[DataRequired()]) + independent_variable = SelectField(choices=[("", " -- select an option -- ")], validators=[DataRequired()]) + # Having a second variable is optional in some tests (that only require a single variable) therefore have not included DataRequired() + dependent_variable = SelectField(choices=[("", " -- select an option -- ")]) + submit = SubmitField("Continue") + + +class ChiGoodnessEntryForm(Form): + key = StringField() + expected = IntegerField(validators=[NumberRange(min=0, max=100)]) + + +class ChiGoodnessForm(FlaskForm): + field = FieldList(FormField(ChiGoodnessEntryForm)) + submit = SubmitField("Continue") + +class SaveTestForm(FlaskForm): + submit = SubmitField("Save to dashboard") diff --git a/site/surveyapp/analysis/routes.py b/site/surveyapp/analysis/routes.py new file mode 100644 index 0000000..6a19fc4 --- /dev/null +++ b/site/surveyapp/analysis/routes.py @@ -0,0 +1,249 @@ +import pandas as pd +from pandas.api.types import is_string_dtype +from scipy.stats import chi2_contingency, chisquare +from pingouin import kruskal, mwu +from surveyapp import mongo +from flask import Flask, render_template, url_for, request, Blueprint, flash, redirect, abort, send_file +from flask_login import login_required, current_user +from surveyapp.analysis.forms import StatisticalTestForm, ChiGoodnessEntryForm, ChiGoodnessForm +from surveyapp.surveys.forms import EditForm +from bson.objectid import ObjectId + +import tempfile +from xlsxwriter import Workbook + +from surveyapp.surveys.utils import parse_data, read_file +from surveyapp.analysis.utils import tests_to_excel + + +analysis = Blueprint("analysis", __name__) + + +# Analyse data sets +# In this function, after failing validation I have chosen to render the template fresh +# rather than redirecting the user back to this route. This is so that the form fields +# remain filled in and the user doesn't have to re-enter their choices. +@analysis.route("/analyse/", methods=['GET', 'POST']) +@login_required +def analyse(survey_id): + form = StatisticalTestForm() + survey = mongo.db.surveys.find_one_or_404({"_id": ObjectId(survey_id)}) + if survey["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + df = read_file(survey["fileName"]) + # Populate the select options in the form with all the variables + for variable in list(df.columns.values): + form.independent_variable.choices.append((variable, variable)) + form.dependent_variable.choices.append((variable, variable)) + if form.validate_on_submit(): + # Get the dataset, and save the variables in python variables + independent_variable = form.independent_variable.data + dependent_variable = form.dependent_variable.data + # Ensure the user hasn't selected the same variable for both + if independent_variable == dependent_variable: + flash("You can't select the same variable for both.", "danger") + return render_template("analysis/analysedata.html", form=form) + test = form.test.data + # If the user selects Chi-Square goodness fit then they are redirected to a separate URL + if test == "Chi-Square goodness of fit": + # Chi-square goodness of fit needs an additional page where user fills in their expected distribution + return redirect(url_for('analysis.chi_goodness', variable=independent_variable, survey_id=survey_id)) + # The other tests all require a dependent variable + if dependent_variable == "": + flash("You must select a dependent variable for this test.", "danger") + return render_template("analysis/analysedata.html", form=form) + if test == "Kruskall Wallis Test": + return kruskall_wallis(survey_id, df, independent_variable, dependent_variable, form) + # AT THE MOMENT, THIS TEST IS 2 TAILED. MAY WANT TO ADD OPTIONS FOR 1 TAILED TESTS + elif test == "Mann-Whitney U Test": + return mann_whitney(survey_id, df, independent_variable, dependent_variable, form) + elif test == "Chi-Square Test": + return chi_square(survey_id, df, independent_variable, dependent_variable) + return render_template("analysis/analysedata.html", form=form) + + + + + +def kruskall_wallis(survey_id, df, independent_variable, dependent_variable, form): + if is_string_dtype(df[dependent_variable]): + flash("Dependent Variable '" + dependent_variable + "' is not numeric.", "danger") + return render_template("analysis/analysedata.html", form=form) + kruskal_result = kruskal(data=df, dv=dependent_variable, between=independent_variable) + # get the p-value (p-unc) from the kruskal test and convert to 4 decimal places only + p_value = "%.4f" % kruskal_result["p-unc"][0] + return redirect(url_for('analysis.result', + survey=survey_id, + test="Kruskall Wallis Test", + p_value=p_value, + independent_variable=independent_variable, + dependent_variable=dependent_variable)) + + +def mann_whitney(survey_id, df, independent_variable, dependent_variable, form): + if is_string_dtype(df[dependent_variable]): + flash("Dependent Variable '" + dependent_variable + "' is not numeric.", "danger") + return render_template("analysis/analysedata.html", form=form) + group_by = df.groupby(independent_variable) + group_array = [group_by.get_group(x) for x in group_by.groups] + if len(group_array) != 2: + flash("Independent variable '" + independent_variable + "' has too many groups, only 2 allowed for Mann-Whitney U Test.", "danger") + return render_template("analysis/analysedata.html", form=form) + x = group_array[0][dependent_variable].values + y = group_array[1][dependent_variable].values + mwu_result = mwu(x, y) + p_value = "%.4f" % mwu_result.at["MWU", "p-val"] + return redirect(url_for('analysis.result', + survey=survey_id, + test="Mann-Whitney U Test", + p_value=p_value, + independent_variable=independent_variable, + dependent_variable=dependent_variable)) + + +def chi_square(survey_id, df, independent_variable, dependent_variable): + contingency_table = pd.crosstab(df[independent_variable], df[dependent_variable]) + _, p_value, _, _ = chi2_contingency(contingency_table, correction=False) + return redirect(url_for('analysis.result', + survey=survey_id, + test="Chi-Square Test", + p_value=p_value, + independent_variable=independent_variable, + dependent_variable=dependent_variable)) + + + +# Chi goodness of fit - extra form for expected values +@analysis.route("/chi//", methods=['GET', 'POST']) +@login_required +def chi_goodness(survey_id, variable): + # Get survey object and datafram + survey = mongo.db.surveys.find_one_or_404({"_id": ObjectId(survey_id)}) + df = read_file(survey["fileName"]) + group_by = df.groupby(variable) + keys = list(group_by.groups.keys()) + # Populate the form with unique groups in the given variable + key_list = [] + # Get the total count, so that we can check the expected distribution matches + total_count = len(df.index) + # Populate the keys objects, initialising "expected" to 0 + for key in keys: + key_list.append({"expected": 0, "key": key}) + form = ChiGoodnessForm(field=key_list) + if form.validate_on_submit(): + # Initialise lists for actual and expected ditributions in the data + actual_distribution = [] + expected_distribution = [] + for key in keys: + # For each group, we get the count in the data and append it to our list + key_count = df[df[variable] == key].shape[0] + actual_distribution.append(key_count) + for input in form.field.data: + if key == input['key']: + # Now we populate the expected count from the form data + expected_distribution.append(input['expected']) + if sum(expected_distribution) == 0: + _, p_value = chisquare(actual_distribution) + else: + _, p_value = chisquare(actual_distribution, expected_distribution) + return redirect(url_for('analysis.result', + survey=survey_id, + test="Chi-Square goodness of fit", + p_value=p_value, + independent_variable=variable,)) + return render_template("analysis/chisquare.html", form=form, keys=keys, total=total_count) + + + + +# Results from stats test +@analysis.route("/result", methods=['GET', 'POST']) +@login_required +def result(): + form = EditForm() + # Set a default alpha value 0.05 to compare the p value to + alpha=0.05 + # cast string to float so it can be compared with the alpha value + p_value=float(request.args.get("p_value")) + test=request.args.get("test") + independent_variable=request.args.get("independent_variable") + dependent_variable=request.args.get("dependent_variable") + # Chi goodness does not have a dependent_variable + if not dependent_variable: + dependent_variable = "" + # Get the survey variable so the test result can be saved and reference the survey + survey_id=request.args.get("survey") + test_id=request.args.get("test_id") + if form.validate_on_submit(): + # 'upsert' creates entry if it does not yet exist + mongo.db.tests.update_one({"_id": ObjectId(test_id)}, + {"$set":{"surveyId" : survey_id, + "user" : current_user._id, + "title" : form.title.data, + "test" : test, + "independentVariable" : independent_variable, + "dependentVariable" : dependent_variable, + "p" : p_value}}, upsert=True) + flash("Statistical test saved.", "success") + return redirect(url_for('surveys.dashboard', title="Dashboard", survey_id=survey_id)) + title=request.args.get("title") + if title: + # i.e. if test already exists and user is clicking to view/edit it + form.title.data = title + else: + # Set the default title. Users can change this + form.title.data = independent_variable + "/" + dependent_variable + ": " + test + result = {"test":test, "p":p_value, "alpha":alpha, "iv":independent_variable, "dv":dependent_variable} + return render_template("analysis/result.html", result=result, form=form, survey_id=survey_id) + + + +# DELETE A statistical test +@analysis.route("/analyse///delete", methods=['POST']) +@login_required +def delete_test(survey_id, test_id): + test_obj = mongo.db.tests.find_one_or_404({"_id":ObjectId(test_id)}) + if test_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + mongo.db.tests.delete_one(test_obj) + flash("Test deleted", "success") + return redirect(url_for('surveys.dashboard', survey_id=survey_id)) + + +# Give the user a quick overview of stats on the survey data +@analysis.route("/quickstats/", methods=['GET']) +@login_required +def quick_stats(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + df = read_file(file_obj["fileName"]) + rows = len(df.index) + cols = len(df.columns) + column_info = parse_data(df); + return render_template("analysis/quickstats.html", rows=rows, cols=cols, column_info=column_info, survey_id=survey_id, survey_title=file_obj["title"] ) + +# Give the user a quick overview of stats on the survey data +@analysis.route("/export_tests/", methods=['GET']) +@login_required +def export_tests(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that survey", "danger") + abort(403) + tests = mongo.db.tests.find({"surveyId":survey_id}) + if tests.count() == 0: + flash("You do not yet have any statistical tests for this survey!", "danger") + return redirect(url_for('surveys.dashboard', survey_id=survey_id)) + # Use a temp file so that it can be deleted after + with tempfile.NamedTemporaryFile() as f: + # Create a new excel workbook + wb = Workbook(f.name) + # grab the active worksheet + ws = wb.add_worksheet("Statistical tests") + tests_to_excel(ws, tests) + wb.close() + return send_file(f.name, attachment_filename=file_obj["title"] + ".xlsx", as_attachment=True) diff --git a/site/surveyapp/analysis/routes2.py b/site/surveyapp/analysis/routes2.py new file mode 100644 index 0000000..f042f60 --- /dev/null +++ b/site/surveyapp/analysis/routes2.py @@ -0,0 +1,221 @@ +import pandas as pd +from pandas.api.types import is_string_dtype +from scipy.stats import chi2_contingency, chisquare +from pingouin import kruskal, mwu +from surveyapp import mongo +from flask import Flask, render_template, url_for, request, Blueprint, flash, redirect, abort, send_file +from flask_login import login_required, current_user +from surveyapp.analysis.forms import StatisticalTestForm, ChiGoodnessEntryForm, ChiGoodnessForm +from surveyapp.surveys.forms import EditForm +from bson.objectid import ObjectId + +import tempfile +from xlsxwriter import Workbook + +from surveyapp.surveys.utils import parse_data, read_file +from surveyapp.analysis.utils import tests_to_excel + + +analysis = Blueprint("analysis", __name__) + + +# Analyse data sets +# In this function, after failing validation I have chosen to render the template fresh +# rather than redirecting the user back to this route. This is so that the form fields +# remain filled in and the user doesn't have to re-enter their choices. +@analysis.route("/analyse/", methods=['GET', 'POST']) +@login_required +def analyse(survey_id): + form = StatisticalTestForm() + survey = mongo.db.surveys.find_one_or_404({"_id": ObjectId(survey_id)}) + if survey["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + df = read_file(survey["fileName"]) + # Populate the select options in the form with all the variables + for variable in list(df.columns.values): + form.independent_variable.choices.append((variable, variable)) + form.dependent_variable.choices.append((variable, variable)) + if form.validate_on_submit(): + # Get the dataset, and save the variables in python variables + independent_variable = form.independent_variable.data + dependent_variable = form.dependent_variable.data + # Ensure the user hasn't selected the same variable for both + if independent_variable == dependent_variable: + flash("You can't select the same variable for both.", "danger") + return render_template("analysis/analysedata.html", form=form) + test = form.test.data + # If the user selects Chi-Square goodness fit then they are redirected to a separate URL + if test == "Chi-Square goodness of fit": + return redirect(url_for('analysis.chi_goodness', variable=independent_variable, survey_id=survey_id)) + # The other tests all require a dependent variable + if dependent_variable == "": + flash("You must select a dependent variable for this test.", "danger") + return render_template("analysis/analysedata.html", form=form) + if test == "Kruskall Wallis Test": + if is_string_dtype(df[dependent_variable]): + flash("Dependent Variable '" + dependent_variable + "' is not numeric.", "danger") + return render_template("analysis/analysedata.html", form=form) + kruskal_result = kruskal(data=df, dv=dependent_variable, between=independent_variable) + # get the p-value (p-unc) from the kruskal test and convert to 4 decimal places only + p_value = "%.4f" % kruskal_result["p-unc"][0] + # AT THE MOMENT, THIS TEST IS 2 TAILED. MAY WANT TO ADD OPTIONS FOR 1 TAILED TESTS + elif test == "Mann-Whitney U Test": + if is_string_dtype(df[dependent_variable]): + flash("Dependent Variable '" + dependent_variable + "' is not numeric.", "danger") + return render_template("analysis/analysedata.html", form=form) + group_by = df.groupby(independent_variable) + group_array = [group_by.get_group(x) for x in group_by.groups] + if len(group_array) != 2: + flash("Independent variable '" + independent_variable + "' has too many groups, only 2 allowed for Mann-Whitney U Test.", "danger") + return render_template("analysis/analysedata.html", form=form) + x = group_array[0][dependent_variable].values + y = group_array[1][dependent_variable].values + mwu_result = mwu(x, y) + p_value = "%.4f" % mwu_result['p-val'].values[0] + elif test == "Chi-Square Test": + contingency_table = pd.crosstab(df[independent_variable], df[dependent_variable]) + _, p_value, _, _ = chi2_contingency(contingency_table, correction=False) + + return redirect(url_for('analysis.result', + survey=survey_id, + test=test, + p_value=p_value, + independent_variable=independent_variable, + dependent_variable=dependent_variable)) + return render_template("analysis/analysedata.html", form=form) + + +# Chi goodness of fit - extra form for expected values +@analysis.route("/chi//", methods=['GET', 'POST']) +@login_required +def chi_goodness(survey_id, variable): + # Get survey object and datafram + survey = mongo.db.surveys.find_one_or_404({"_id": ObjectId(survey_id)}) + df = read_file(survey["fileName"]) + group_by = df.groupby(variable) + keys = list(group_by.groups.keys()) + # Populate the form with unique groups in the given variable + key_list = [] + # Get the total count, so that we can check the expected distribution matches + total_count = len(df.index) + # Populate the keys objects, initialising "expected" to 0 + for key in keys: + key_list.append({"expected": 0, "key": key}) + form = ChiGoodnessForm(field=key_list) + if form.validate_on_submit(): + # Initialise lists for actual and expected ditributions in the data + actual_distribution = [] + expected_distribution = [] + for key in keys: + # For each group, we get the count in the data and append it to our list + key_count = df[df[variable] == key].shape[0] + actual_distribution.append(key_count) + for input in form.field.data: + if key == input['key']: + # Now we populate the expected count from the form data + expected_distribution.append(input['expected']) + if sum(expected_distribution) == 0: + _, p_value = chisquare(actual_distribution) + else: + _, p_value = chisquare(actual_distribution, expected_distribution) + return redirect(url_for('analysis.result', + survey=survey_id, + test="Chi-Square goodness of fit", + p_value=p_value, + independent_variable=variable,)) + return render_template("analysis/chisquare.html", form=form, keys=keys, total=total_count) + + + + +# Results from stats test +@analysis.route("/result", methods=['GET', 'POST']) +@login_required +def result(): + form = EditForm() + # Set a default alpha value 0.05 to compare the p value to + alpha=0.05 + # cast string to float so it can be compared with the alpha value + p_value=float(request.args.get("p_value")) + test=request.args.get("test") + independent_variable=request.args.get("independent_variable") + dependent_variable=request.args.get("dependent_variable") + # Chi goodness does not have a dependent_variable + if not dependent_variable: + dependent_variable = "" + # Get the survey variable so the test result can be saved and reference the survey + survey_id=request.args.get("survey") + test_id=request.args.get("test_id") + if form.validate_on_submit(): + # 'upsert' creates entry if it does not yet exist + mongo.db.tests.update_one({"_id": ObjectId(test_id)}, + {"$set":{"surveyId" : survey_id, + "user" : current_user._id, + "title" : form.title.data, + "test" : test, + "independentVariable" : independent_variable, + "dependentVariable" : dependent_variable, + "p" : p_value}}, upsert=True) + flash("Statistical test saved.", "success") + return redirect(url_for('surveys.dashboard', title="Dashboard", survey_id=survey_id)) + title=request.args.get("title") + if title: + # i.e. if test already exists and user is clicking to view/edit it + form.title.data = title + else: + # Set the default title. Users can change this + form.title.data = independent_variable + "/" + dependent_variable + ": " + test + result = {"test":test, "p":p_value, "alpha":alpha, "iv":independent_variable, "dv":dependent_variable} + return render_template("analysis/result.html", result=result, form=form, survey_id=survey_id) + + + +# DELETE A statistical test +@analysis.route("/analyse///delete", methods=['POST']) +@login_required +def delete_test(survey_id, test_id): + test_obj = mongo.db.tests.find_one_or_404({"_id":ObjectId(test_id)}) + if test_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + mongo.db.tests.delete_one(test_obj) + flash("Test deleted", "success") + return redirect(url_for('surveys.dashboard', survey_id=survey_id)) + + +# Give the user a quick overview of stats on the survey data +@analysis.route("/quickstats/", methods=['GET']) +@login_required +def quick_stats(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + df = read_file(file_obj["fileName"]) + rows = len(df.index) + cols = len(df.columns) + column_info = parse_data(df); + return render_template("analysis/quickstats.html", rows=rows, cols=cols, column_info=column_info, survey_id=survey_id, survey_title=file_obj["title"] ) + +# Give the user a quick overview of stats on the survey data +@analysis.route("/export_tests/", methods=['GET']) +@login_required +def export_tests(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that survey", "danger") + abort(403) + tests = mongo.db.tests.find({"surveyId":survey_id}) + if tests.count() == 0: + flash("You do not yet have any statistical tests for this survey!", "danger") + return redirect(url_for('surveys.dashboard', survey_id=survey_id)) + # Use a temp file so that it can be deleted after + with tempfile.NamedTemporaryFile() as f: + # Create a new excel workbook + wb = Workbook(f.name) + # grab the active worksheet + ws = wb.add_worksheet("Statistical tests") + tests_to_excel(ws, tests) + wb.close() + return send_file(f.name, attachment_filename=file_obj["title"] + ".xlsx", as_attachment=True) diff --git a/site/surveyapp/analysis/utils.py b/site/surveyapp/analysis/utils.py new file mode 100644 index 0000000..7610b21 --- /dev/null +++ b/site/surveyapp/analysis/utils.py @@ -0,0 +1,230 @@ +import pandas as pd +from surveyapp import mongo +# from flask import Flask, current_app +from bson.objectid import ObjectId +# For carrying out statistical test +from scipy.stats import chi2_contingency, chisquare +from pingouin import kruskal, mwu +from surveyapp.surveys.utils import parse_data, read_file + +import time + +# This is a function that will automatically run when the user uploads a file. It will parse the data and +# run some statistical tests based on the type of data in each column. It will not run all tests (for example, +# non-parametric tests will only be run on definite categorical data - i.e. data that is string, object or +# boolean. Ordinal data with numeric values, such as likert scale, will not be tested as this data will be +# identified as numeric.) Furthermore, the results of tests will have to be checked by the user, to check the +# data passes the assumptions of the test. +# Likewise, I do not perform mann whitney U and kruskal wallis on the same variables, even though it is in fact +# possible to perform kruskal wallis on 2 variables. This is to avoid conflicting results and subsequent increasing +# risk of false positives +def run_all_tests(survey_id, user_id, app): + with app.app_context(): + run_tests(survey_id, user_id) + +def run_tests(survey_id, user_id): + file_obj = mongo.db.surveys.find_one({"_id":ObjectId(survey_id)}) + df = read_file(file_obj["fileName"]) + column_info = parse_data(df) + test_results = [] + for column_1 in column_info: + if column_1["data_type"] == "categorical" or column_1["data_type"] == "true/false": + # Chi square goodness of fit only takes one, non-parametric variable + p_value, result = chi_goodness(df, column_1["title"]) + if p_value < 0.05: + test_results.append(result) + # Now loop through again from the start, checking second variable against the first + for column_2 in column_info: + # If the columns are the same then we can contnue with next iteration + if column_2["title"] == column_1["title"]: + continue + elif column_2["data_type"] == "categorical" or column_2["data_type"] == "true/false": + # Chi square needs 2 categorical variables + p_value, result = chi_square(df, column_1["title"], column_2["title"]) + # As Chi square can be done twice (with variable swapping places) + # we need to check that it has not yet been done + if p_value < 0.05 and not test_done(test_results, result): + test_results.append(result) + elif column_2["data_type"] == "numerical": + if column_1["num_unique"] == 2 and column_2["num_unique"] > 1: + # We perform mann-whitney U test + p_value, result = mann_whitney(df, column_1["title"], column_2["title"]) + elif column_1["num_unique"] > 2 and column_2["num_unique"] > 1: + # We perform kruskal wallis test + p_value, result = kruskal_wallis(df, column_1["title"], column_2["title"]) + if p_value < 0.05: + test_results.append(result) + # Now we can loop through the statistical tests, adding significant ones to + # a temporary database. This will be presented to the user through a notficiation + # on the home page. + for result in test_results: + mongo.db.temp_results.insert_one({ + "user": user_id, + "survey_id" : survey_id, + "result" : result}) + +# When adding chisquare test of independence, we need to check the test hasn't +# already been carried out (with the variables the opposite way round) +def test_done(previous_results, current_result): + for result in previous_results: + if current_result["variable_1"] == result["variable_2"] and current_result["variable_2"] == result["variable_1"]: + return True + return False + + +def kruskal_wallis(df, independent_variable, dependent_variable): + kruskal_result = kruskal(data=df, dv=dependent_variable, between=independent_variable) + # get the p-value (p-unc) from the kruskal test and convert to 4 decimal places only + p_value = float("%.4f" % kruskal_result["p-unc"][0]) + # p_value = kruskal_result["p-unc"][0] + result = {"test": "Kruskall Wallis Test", + "p_value": p_value, + "variable_1": independent_variable, + "variable_2": dependent_variable, + "null": f"The distribution of '{dependent_variable}' is the same across groups of '{independent_variable}'", + "info": """Assumes that dependent variable ('{0}') is ordinal or continuous, + that the independent variable ('{1}') consists of more than 2 groups + and that these groups follow the same distribution (the shape on a histogram).\n + NOTE: It is also possible to perform this test on categories containing just 2 groups, + however we have not done so as it could conflict with results from Mann-Whitney U test + (performed on categories with 2 groups only).""".format(dependent_variable, independent_variable)} + return p_value, result + + +def mann_whitney(df, independent_variable, dependent_variable): + # Group the data by the independent_variable + group_by = df.groupby(independent_variable) + # Convert to an array of groups + group_array = [group_by.get_group(x) for x in group_by.groups] + # Get the values of groups 1 and 2 from the array + x = group_array[0][dependent_variable].values + y = group_array[1][dependent_variable].values + keys = list(group_by.groups.keys()) + # Get the distinct keys (we have already checked there are only 2) and save them in variables + group_1 = keys[0] + group_2 = keys[1] + # Perform test + mwu_result = mwu(x, y) + # Get the p_value from the result and format to 4 decimals + p_value = float("%.4f" % mwu_result['p-val'].values[0]) + result = {"test": "Mann-Whitney U Test", + "p_value": p_value, + "variable_1": independent_variable, + "variable_2": dependent_variable, + "null": f"The distribution of '{dependent_variable}' is the same across groups of '{independent_variable}'", + "info": """Assumes that the dependent variable ('{0}') is ordinal or continuous, + that the independent variable ('{1}') consists of just 2 groups + ('{2}' and '{3}') and that these groups follow the same distribution (the shape + on a histogram).""".format(dependent_variable, independent_variable, group_1, group_2)} + return p_value, result + + + +def chi_square(df, variable_1, variable_2): + # 80% of groups must have a frequency of atleast 5. + if not five_or_more(df, variable_1) or not five_or_more(df, variable_2): + # If not, we can return 2, which is an impossible p-value and will be rejected. + return 2, {} + contingency_table = pd.crosstab(df[variable_1], df[variable_2]) + _, p_value, _, _ = chi2_contingency(contingency_table, correction=False) + p_value = float("%.4f" % p_value) + result = {"test": "Chi-Square test for independence", + "p_value": p_value, + "variable_1": variable_1, + "variable_2": variable_2, + "null": f"There is no relationship or association between '{variable_1}' and '{variable_2}'", + "info": """Assumes that both variables are ordinal or nominal, + with each variable consisting of 2 or more groups. Also + assumes that 80% of the groups contain 5 or more counts."""} + return p_value, result + +# This checks if each category contains groups with at least a frequency of 5 in each group +# (e.g. If 'apple' is a result for 'favourite food' then this function checks if there are at at +# 5 responses with 'apple'). The chi-square independence test requires that 80% of groups contain +# a frequency of 5 or more. +def five_or_more(df, variable): + group_by = df.groupby(variable) + # We get the list of unique categories + keys = list(group_by.groups.keys()) + count_over_5 = 0 + total_count = 0 + for key in keys: + total_count += 1 + # Get the length (or count) of that category + key_count = df[df[variable] == key].shape[0] + if key_count >= 5: + count_over_5 += 1 + if count_over_5/total_count < 0.8: + return False + else: + return True + + + +def chi_goodness(df, variable): + # We first group the column by unique categories + group_by = df.groupby(variable) + # We get the list of unique categories + keys = list(group_by.groups.keys()) + actual_distribution = [] + # Loop through each unique category + for key in keys: + # Get the length (or count) of that category + key_count = df[df[variable] == key].shape[0] + if key_count <= 5: + # Each group must have a frequency of atleast 5. If not, we can return 2, + # which is an impossible p-value and will be rejected. + return 2, {} + # And add it to our list + actual_distribution.append(key_count) + # we will assume expected even distribution and only pass the actual distribution + _, p_value = chisquare(actual_distribution) + # Convert to 4 decimal places + p_value = float("%.4f" % p_value) + result = {"test": "Chi-Square goodness of fit", + "p_value": p_value, + "variable_1": variable, + "variable_2": "", + "null": f"Groups of '{variable}' are evenly distributed", + "info": """Assumes that the expected distribution is even accross groups, + that each group is mutually exclusive from the next and each group + contains at least 5 subjects."""} + return p_value, result + +# Takes all the tests from the database and writes them to the the excel work sheet +def tests_to_excel(worksheet, tests): + # Create a table for the data. end of table will be the number of tests +1 for the column headers + end_of_table = tests.count() + 1 + if end_of_table > 1: + table_size = "A1:E" + str(end_of_table) + # Set column headers + worksheet.add_table(table_size, {'columns': [{'header': "Null Hypothesis"}, + {'header': "Statistical Test"}, + {'header': "Significance Value"}, + {'header': "P-Value"}, + {'header': "Conclusion"}]}) + # Row number is 1 since the first row 0 is the header + row_number = 1 + # Loop through all tests and write them to the worksheet table + for test in tests: + if float(test["p"]) < 0.05: + conclusion = "Reject the null hypothesis." + else: + conclusion = "Accept the null hypothesis." + worksheet.write(row_number, 0, get_null_hypothesis(test["test"], test["independentVariable"], test["dependentVariable"])) + worksheet.write(row_number, 1, test["test"]) + worksheet.write(row_number, 2, 0.05) + worksheet.write(row_number, 3, test["p"]) + worksheet.write(row_number, 4, conclusion) + row_number += 1 + + + +# gets the null hypothesis, depending on the type of test +def get_null_hypothesis(test, variable_1, variable_2): + if test == "Chi-Square goodness of fit": + return "There is no significant difference between the expected distribution of " + variable_1 + " and the observed distribution." + elif test == "Chi-Square Test": + return "There is no association between " + variable_1 + " and " + variable_2 + else: + return "The distribution of " + variable_1 + " is the same across groups of " + variable_2 diff --git a/site/surveyapp/config.py b/site/surveyapp/config.py new file mode 100644 index 0000000..cac930f --- /dev/null +++ b/site/surveyapp/config.py @@ -0,0 +1,33 @@ +import os + +class Config(object): + SECRET_KEY = os.environ.get("SECRET_KEY") or 'you-will-never-guess' + # MongoDB configurations + MONGO_URI = os.environ.get("MONGO_URI") + # Email configurations + MAIL_SERVER = 'smtp.googlemail.com' + MAIL_PORT = 587 + MAIL_USE_TLS = True + MAIL_DEBUG = True + MAIL_USERNAME = os.environ.get('EMAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD') + + + + +class TestingConfig(object): + DEBUG = True + TESTING = True + PRESERVE_CONTEXT_ON_EXCEPTION = False + MONGO_URI = "mongodb://localhost:27017/surveyDatabaseTest" + SECRET_KEY = 'test!' + # disabled so we can test login/registration + WTF_CSRF_ENABLED = False + # LOGIN_DISABLED = True + +config_by_name = dict( + dev=Config, + test=TestingConfig +) + +key = Config.SECRET_KEY diff --git a/site/surveyapp/errors/__init__.py b/site/surveyapp/errors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/errors/handlers.py b/site/surveyapp/errors/handlers.py new file mode 100644 index 0000000..4f930c5 --- /dev/null +++ b/site/surveyapp/errors/handlers.py @@ -0,0 +1,24 @@ +from flask import Blueprint, render_template + +errors = Blueprint('errors', __name__) + +# Error handlers are initialised with '.app_errorhandler'. If you just want for this +# blueprint, can use 'errorhandler' instead. However I want for the full application. + +# For a 404 error +@errors.app_errorhandler(404) +# Needs to be passed an error parameter +def error_404(error): + return render_template('errors/404.html'), 404 + + +# For a 403 error +@errors.app_errorhandler(403) +def error_403(error): + return render_template('errors/403.html'), 403 + + +# For a 500 error +@errors.app_errorhandler(500) +def error_500(error): + return render_template('errors/500.html'), 500 diff --git a/site/surveyapp/graphs/__init__.py b/site/surveyapp/graphs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/graphs/forms.py b/site/surveyapp/graphs/forms.py new file mode 100644 index 0000000..44f900e --- /dev/null +++ b/site/surveyapp/graphs/forms.py @@ -0,0 +1,64 @@ +from flask_wtf import FlaskForm +# from flask_wtf.file import FileField, FileAllowed +from wtforms import Form, StringField, SubmitField, SelectField, BooleanField, IntegerField, FormField, FieldList +from wtforms.validators import DataRequired, NumberRange + + +# Form used for pie charts and bar charts +class BarPieForm(FlaskForm): + title = StringField(validators=[DataRequired()]) + # We will append variables to x/y axis choices based on the data + x_axis = SelectField("Choose a variable:", choices=[("", " -- select an option -- ")]) + # Bar chart will default to "Amount" on y axis. Will also append all numerical variable types from the data set. + y_axis = SelectField("Choose a variable:", choices=[("Amount", "Amount")]) + y_axis_agg = SelectField("Aggregation:", choices=[("Average", "Average"), ("Highest", "Highest"), ("Lowest", "Lowest"), ("Sum", "Sum")]) + submit = SubmitField("Save to dashboard") + +# Scatter charts form needs x-axis and y-axis variables, as well as possible ranges +class ScatterchartForm(FlaskForm): + title = StringField(validators=[DataRequired()]) + # We will append variables to x/y axis choices based on the data + x_axis = SelectField("Choose a variable:", choices=[("", " -- select an option -- ")]) + x_axis_from = IntegerField("From") + x_axis_to = IntegerField("To") + # Bar chart will default to "Amount" on y axis. Will also append all numerical variable types from the data set. + y_axis = SelectField("Choose a variable:", choices=[("", " -- select an option -- ")]) + y_axis_from = IntegerField("From") + y_axis_to = IntegerField("To") + line = BooleanField("Add connecting line: ") + submit = SubmitField("Save to dashboard") + + +# Histograms only need x-axis variable. However they also need a range and also group size +class HistogramForm(FlaskForm): + title = StringField(validators=[DataRequired()]) + # We will append variables to x/y axis choices based on the data + x_axis = SelectField("Choose a variable:", choices=[("", " -- select an option -- ")]) + x_axis_from = IntegerField("From") + x_axis_to = IntegerField("To") + group_count = IntegerField("Number of groups") + submit = SubmitField("Save to dashboard") + + +class MapForm(FlaskForm): + title = StringField(validators=[DataRequired()]) + variable = SelectField("Choose a variable:", choices=[("", " -- select an option -- ")]) + scope = SelectField("Choose a scope:", choices=[("World", "World"), ("Europe", "Europe"), ("Africa", "Africa"), ("South America", "South America"), ("Asia", "Asia"), ("Australia/Oceania", "Australia/Oceania"), ("North America", "North America"), ("United States of America", "United States of America")]) + submit = SubmitField("Save to dashboard") + + +# Form used for box and whisker plots +class BoxForm(FlaskForm): + title = StringField(validators=[DataRequired()]) + # We will append variables to x/y axis choices based on the data + # The x-axis is optional in a box-whisker plot. Users can select it if they want + # to see distribution over a particular group of respondents + x_axis = SelectField("Choose a variable:", choices=[("", " -- optional -- ")]) + # Unlike with bar charts, the y-axis will not have an 'Amount'. It instead must + # be a numerical type data from the users data + y_axis = SelectField("Choose a variable:", choices=[("", " -- select an option -- ")]) + submit = SubmitField("Save to dashboard") + + + +# FileAllowed([".xls", ".xlt", ".xla", ".xlsx", ".xltx", ".xlsb", ".xlsm", ".xltm", ".xlam", ".csv"], message="Only CSV files or Excel Spreadsheets allowed.") diff --git a/site/surveyapp/graphs/routes.py b/site/surveyapp/graphs/routes.py new file mode 100644 index 0000000..323888d --- /dev/null +++ b/site/surveyapp/graphs/routes.py @@ -0,0 +1,271 @@ +import json +from surveyapp import mongo +from flask import Flask, render_template, url_for, request, Blueprint, flash, redirect, abort +from flask_login import login_required, current_user +from surveyapp.graphs.forms import BarPieForm, ScatterchartForm, HistogramForm, MapForm, BoxForm +from bson.objectid import ObjectId + +from surveyapp.graphs.utils import save_image, delete_image +from surveyapp.surveys.utils import parse_data, read_file + + +graphs = Blueprint("graphs", __name__) + + +class JSONEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, ObjectId): + return str(o) + return json.JSONEncoder.default(self, o) + + +# Page where user chooses the type of graph they would like to create for their survey +@graphs.route('/choosegraph/', methods=['GET']) +@login_required +def choose_graph(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + return redirect(url_for("main.index")) + return render_template("graphs/choosegraph.html", title="Select Graph", survey_id=survey_id) + + + +@graphs.route('/graph/', methods=['GET', 'POST']) +@login_required +def graph(survey_id): + # Get the file object so that we can load the data + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + return redirect(url_for("main.index")) + # Get the id of the graph (if it exists yet) + graph_id = request.args.get("graph_id") + graph_obj = mongo.db.graphs.find_one({"_id":ObjectId(graph_id)}) + # i.e. if user is choosing to edit an existing graph then it already has a type + if graph_obj: + chart_type = graph_obj["type"] + # Else user is creating a new graph of a chosen type + else: + chart_type = request.args.get("chart_type") + # Read the csv file in + df = read_file(file_obj["fileName"]) + # parse the columns to get information regarding type of data + column_info = parse_data(df) + # Convert the dataframe to a dict of records to be handled by D3.js on the client side. + chart_data = df.to_dict(orient='records') + # ----------SAME ROUTE USED FOR BAR AND PIE CHART---------- + if chart_type == "Bar chart" or chart_type == "Pie chart": + return pie_bar_chart(survey_id, column_info, chart_data, graph_id, file_obj["title"], chart_type) + # ----------SCATTER CHART---------- + elif chart_type == "Scatter chart": + return scatter_chart(survey_id, column_info, chart_data, graph_id, file_obj["title"]) + # ----------HISTOGRAM---------- + elif chart_type == "Histogram": + return histogram(survey_id, column_info, chart_data, graph_id, file_obj["title"]) + # ----------MAP CHART---------- + elif chart_type == "Map": + return map_chart(survey_id, column_info, chart_data, graph_id, file_obj["title"]) + # ----------Box and whisker CHART---------- + elif chart_type == "Box and whisker": + return box_chart(survey_id, column_info, chart_data, graph_id, file_obj["title"]) + else: + flash("something went wrong", "danger") + abort(404) + + + + +# Function that renders the box-chart page +def box_chart(survey_id, column_info, chart_data, graph_id, title): + form = BoxForm() + # Populate the form options. A box chart can take any data type for x-axis but y-axis must be numerical + for column in column_info: + form.x_axis.choices.append((column["title"], column["title"])) + if column["data_type"] == "numerical": + # We insert a tuple, The first is the 'value' of the select, the second is the text displayed + form.y_axis.choices.append((column["title"], column["title"])) + # Now we have specified the 'select' options for the form, we can check 'form.validate_on_submit' + if form.validate_on_submit(): + image_data = request.form["image"] + file_name = save_image(image_data, graph_id) + # setting upsert=true in the update will create the entry if it doesn't yet exist, else it updates + mongo.db.graphs.update_one({"_id": ObjectId(graph_id)}, + {"$set": {"title" : form.title.data, + "surveyId": survey_id, + "user" : current_user._id, + "type" : "Box and whisker", + "xAxis" : form.x_axis.data, + "yAxis": form.y_axis.data, + "image": file_name}}, upsert=True) + # If we are editing the graph instead of creating new, we want to prepopulate the fields + graph_obj = mongo.db.graphs.find_one({"_id":ObjectId(graph_id)}) + if graph_obj: + form.x_axis.data = graph_obj["xAxis"] + form.y_axis.data = graph_obj["yAxis"] + form.title.data = graph_obj["title"] + data = {"chart_data": chart_data, "title": title, "column_info" : column_info} + return render_template("graphs/boxchart.html", data=data, form=form, survey_id=survey_id, graph_id=graph_id, chart_type="Box and whisker") + + + +# Function that renders the map page +def map_chart(survey_id, column_info, chart_data, graph_id, title): + form = MapForm() + # Populate the form options. + for column in column_info: + form.variable.choices.append((column["title"], column["title"])) + # Now we have specified the 'select' options for the form, we can check 'form.validate_on_submit' + if form.validate_on_submit(): + image_data = request.form["image"] + file_name = save_image(image_data, graph_id) + # setting upsert=true in the update will create the entry if it doesn't yet exist, else it updates + mongo.db.graphs.update_one({"_id": ObjectId(graph_id)}, + {"$set": {"title" : form.title.data, + "surveyId": survey_id, + "user" : current_user._id, + "type" : "Map", + "variable" : form.variable.data, + "scope" : form.scope.data, + "image": file_name}}, upsert=True) + + # If we are editing the graph instead of creating new, we want to prepopulate the fields + graph_obj = mongo.db.graphs.find_one({"_id":ObjectId(graph_id)}) + if graph_obj: + form.variable.data = graph_obj["variable"] + form.scope.data = graph_obj["scope"] + form.title.data = graph_obj["title"] + data = {"chart_data": chart_data, "title": title, "column_info" : column_info} + return render_template("graphs/map.html", data=data, form=form, survey_id=survey_id, graph_id=graph_id, chart_type="Map") + + + +# Function that renders the bar-chart and pie chart pages +def pie_bar_chart(survey_id, column_info, chart_data, graph_id, title, chart_type): + form = BarPieForm() + # Populate the form options. A bar/pie chart can take any data type for x-axis but y-axis must be numerical + for column in column_info: + form.x_axis.choices.append((column["title"], column["title"])) + if column["data_type"] == "numerical": + # We insert a tuple, The first is the 'value' of the select, the second is the text displayed + form.y_axis.choices.append((column["title"], column["title"])) + # Now we have specified the 'select' options for the form, we can check 'form.validate_on_submit' + if form.validate_on_submit(): + image_data = request.form["image"] + file_name = save_image(image_data, graph_id) + # setting upsert=true in the update will create the entry if it doesn't yet exist, else it updates + mongo.db.graphs.update_one({"_id": ObjectId(graph_id)}, + {"$set": {"title" : form.title.data, + "surveyId": survey_id, + "user" : current_user._id, + "type" : chart_type, + "xAxis" : form.x_axis.data, + "yAxis": form.y_axis.data, + "yAggregation": form.y_axis_agg.data, + "image": file_name}}, upsert=True) + + # If we are editing the graph instead of creating new, we want to prepopulate the fields + graph_obj = mongo.db.graphs.find_one({"_id":ObjectId(graph_id)}) + if graph_obj: + form.x_axis.data = graph_obj["xAxis"] + form.y_axis.data = graph_obj["yAxis"] + form.y_axis_agg.data = graph_obj["yAggregation"] + form.title.data = graph_obj["title"] + data = {"chart_data": chart_data, "title": title, "column_info" : column_info} + if chart_type == "Bar chart": + return render_template("graphs/barchart.html", data=data, form=form, survey_id=survey_id, graph_id=graph_id, chart_type="Bar chart") + else: + return render_template("graphs/piechart.html", data=data, form=form, survey_id=survey_id, graph_id=graph_id, chart_type="Pie chart") + + + +def scatter_chart(survey_id, column_info, chart_data, graph_id, title): + form = ScatterchartForm() + for column in column_info: + # Scatter charts require both x and y axis to have some numerical value (i.e. ordinal/interval but not categorical) + if column["data_type"] == "numerical": + # We insert a tuple, The first is the 'value' of the select, the second is the text displayed + form.x_axis.choices.append((column["title"], column["title"])) + form.y_axis.choices.append((column["title"], column["title"])) + # Now we have specified the 'select' options for the form, we can prevalidate for 'form.validate_on_submit' + if form.validate_on_submit(): + image_data = request.form["image"] + file_name = save_image(image_data, graph_id) + # setting upsert=true in the update will create the entry if it doesn't yet exist, else it updates + mongo.db.graphs.update_one({"_id": ObjectId(graph_id)}, + {"$set": {"title" : form.title.data, + "surveyId": survey_id, + "user" : current_user._id, + "type" : "Scatter chart", + "xAxis" : form.x_axis.data, + "xAxisFrom" : form.x_axis_from.data, + "xAxisTo" : form.x_axis_to.data, + "yAxis": form.y_axis.data, + "yAxisFrom": form.y_axis_from.data, + "yAxisTo": form.y_axis_to.data, + "line": form.line.data, + "image": file_name}}, upsert=True) + + # If we are editing the graph instead of creating new, we want to prepopulate the fields + graph_obj = mongo.db.graphs.find_one({"_id":ObjectId(graph_id)}) + if graph_obj: + form.x_axis.data = graph_obj["xAxis"] + form.x_axis_from.data = graph_obj["xAxisFrom"] + form.x_axis_to.data = graph_obj["xAxisTo"] + form.y_axis.data = graph_obj["yAxis"] + form.y_axis_from.data = graph_obj["yAxisFrom"] + form.y_axis_to.data = graph_obj["yAxisTo"] + form.line.data = graph_obj["line"] + form.title.data = graph_obj["title"] + data = {"chart_data": chart_data, "title": title, "column_info" : column_info} + return render_template("graphs/scatterchart.html", data=data, form=form, survey_id=survey_id, graph_id=graph_id, chart_type="Scatter chart") + + + + +def histogram(survey_id, column_info, chart_data, graph_id, title): + form = HistogramForm() + for column in column_info: + # Scatter charts require both x and y axis to have some numerical value (i.e. ordinal/interval but not categorical) + if column["data_type"] == "numerical": + # We insert a tuple, The first is the 'value' of the select, the second is the text displayed + form.x_axis.choices.append((column["title"], column["title"])) + # Now we have specified the 'select' options for the form, we can prevalidate for 'form.validate_on_submit' + if form.validate_on_submit(): + image_data = request.form["image"] + file_name = save_image(image_data, graph_id) + # setting upsert=true in the update will create the entry if it doesn't yet exist, else it updates + mongo.db.graphs.update_one({"_id": ObjectId(graph_id)}, + {"$set": {"title" : form.title.data, + "surveyId": survey_id, + "user" : current_user._id, + "type" : "Histogram", + "xAxis" : form.x_axis.data, + "xAxisFrom" : form.x_axis_from.data, + "xAxisTo" : form.x_axis_to.data, + "groupSize" : form.group_count.data, + "image": file_name}}, upsert=True) + + # If we are editing the graph instead of creating new, we want to prepopulate the fields + graph_obj = mongo.db.graphs.find_one({"_id":ObjectId(graph_id)}) + if graph_obj: + form.x_axis.data = graph_obj["xAxis"] + form.x_axis_from.data = graph_obj["xAxisFrom"] + form.x_axis_to.data = graph_obj["xAxisTo"] + form.group_count.data = graph_obj["groupSize"] + form.title.data = graph_obj["title"] + data = {"chart_data": chart_data, "title": title, "column_info" : column_info} + return render_template("graphs/histogram.html", data=data, form=form, survey_id=survey_id, graph_id=graph_id, chart_type="Histogram") + + +# DELETE A Graph +@graphs.route("/home///delete", methods=['POST']) +@login_required +def delete_graph(graph_id, survey_id): + graph_obj = mongo.db.graphs.find_one_or_404({"_id":ObjectId(graph_id)}) + if graph_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + delete_image(graph_obj["image"]) + mongo.db.graphs.delete_one(graph_obj) + return redirect(url_for('surveys.dashboard', survey_id=survey_id)) diff --git a/site/surveyapp/graphs/tests.json b/site/surveyapp/graphs/tests.json new file mode 100644 index 0000000..6067c64 --- /dev/null +++ b/site/surveyapp/graphs/tests.json @@ -0,0 +1,53 @@ +[ + { + "name": "Kruskall Wallis Test", + "variable1": { + "name": "independent variable", + "type": [nominal, ordinal] + }, + "variable2": { + "name": "dependent variable", + "type": [ordinal, interval, ratio] + }, + "info": "" + }, + { + "name": "Mann-Whitney U Test", + "variable1": { + "name": "independent variable", + "type": [nominal, ordinal] + }, + "variable2": { + "name": "dependent variable", + "type": [ordinal, interval, ratio] + }, + "info": "The Mann-Whitney U test requires that the independent variable consists of just 2 + categorical groups (e.g. questions with yes/no answers). If your independent variable + contains more groups then the Kruskall Wallis test should be used." + }, + { + "name": "Chi-Square Test", + "variable1": { + "name": "first variable", + "type": [nominal, ordinal] + }, + "variable2": { + "name": "second variable", + "type": [nominal, ordinal] + }, + "info": "The Chi Square test requires that both variables be categorical (i.e. nominal or ordinal). + Both variables should contain 2 or more distinct categorical groups (e.g. + 2 groups: yes/no answers, 3 groups: low/medium/high income) Furthermore, these groups must + be independent (i.e. no subjects are in more than one group)." + }, + { + "name": "Chi-Square goodness of fit", + "variable1": { + "name": "first variable", + "type": [nominal, ordinal] + }, + "info": "The Chi Square goodness of fit takes one categorical variable. It is used to see if the + different categories in that variable follow the same distribution that you would expect.", + "nullHypothesis": "There is no significant difference between the observed and the expected value." + } +] diff --git a/site/surveyapp/graphs/utils.py b/site/surveyapp/graphs/utils.py new file mode 100644 index 0000000..85d9803 --- /dev/null +++ b/site/surveyapp/graphs/utils.py @@ -0,0 +1,40 @@ +import os +import secrets +from surveyapp import mongo +from flask import Flask, current_app +from flask_login import current_user +from bson.objectid import ObjectId +# For converting image base 64 data URI +import urllib.parse + +# Saves a graph image file to the server. Called after user saves a graph (which automatically +# uploads an image of their graph so that it can be displayed on a card) +def save_image(data, graph_id): + graph = mongo.db.graphs.find_one({"_id": ObjectId(graph_id)}) + if graph: + delete_image(graph["image"]) + response = urllib.request.urlopen(data) + # generate a random hex for the filename + random_hex = secrets.token_hex(8) + file_name = random_hex + ".png" + file = os.path.join(current_app.root_path, "static/images/graphimages", file_name) + with open(file, 'wb') as image_to_write: + image_to_write.write(response.file.read()) + return file_name + +def get_image(name): + return os.path.join(current_app.root_path, "static/images/graphimages", name) + +def delete_image(name): + image = os.path.join(current_app.root_path, "static/images/graphimages", name) + os.remove(image) + +def graphs_to_excel(worksheet, graphs): + # start at row number 0 + row_number = 0 + # Loop through all tests and write them to the worksheet + for graph in graphs: + image = get_image(graph["image"]) + worksheet.insert_image(row_number, 0, image) + # Add 30 to the rows as this is the size it takes up in excel + row_number += 30 diff --git a/site/surveyapp/main/__init__.py b/site/surveyapp/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/main/forms.py b/site/surveyapp/main/forms.py new file mode 100644 index 0000000..a94fadd --- /dev/null +++ b/site/surveyapp/main/forms.py @@ -0,0 +1,41 @@ +from flask_wtf import FlaskForm +from wtforms import SubmitField, TextAreaField, RadioField +from wtforms.validators import DataRequired + +# Original form used to gather user feedback for sprints 1-3 +class FeedbackForm(FlaskForm): + user_interface = RadioField('How would you rate the user interface? (1 = bad, 10 = good)', + validators=[DataRequired()], choices=[("1", "1"), ("2", "2"), ("3", "3"), ("4", "4"), ("5", "5"), ("6", "6"), ("7", "7"), ("8", "8"), ("9", "9"), ("10", "10")]) + functionality = RadioField('How well does the app do what you want it to? (1 = not well, 10 = well)', + validators=[DataRequired()], choices=[("1", "1"), ("2", "2"), ("3", "3"), ("4", "4"), ("5", "5"), ("6", "6"), ("7", "7"), ("8", "8"), ("9", "9"), ("10", "10")]) + comments = TextAreaField('Any additional comments?') + submit = SubmitField('Submit') + +# Form used for final user feedback, with more questions +class FeedbackForm(FlaskForm): + enough_graphs = RadioField('Do you feel there are enough graphs to visualise the different types of data you might have?', + validators=[DataRequired()], choices=[("1", "Strongly disagree"), ("2", "Disagree"), ("3", "Neutral"), ("4", "Agree"), ("5", "Strongly agree")]) + good_graphs = RadioField('Do you feel that the graphs visualise the data as you expected?', + validators=[DataRequired()], choices=[("1", "Strongly disagree"), ("2", "Disagree"), ("3", "Neutral"), ("4", "Agree"), ("5", "Strongly agree")]) + enough_tests = RadioField('Do you feel there are enough statistical tests to derive meaning from your data?', + validators=[DataRequired()], choices=[("1", "Strongly disagree"), ("2", "Disagree"), ("3", "Neutral"), ("4", "Agree"), ("5", "Strongly agree")]) + auto_tests = RadioField('Was the auto-test feature helpful (automatic generation of statistical tests with significant results after form upload)?', + validators=[DataRequired()], choices=[("1", "Strongly disagree"), ("2", "Disagree"), ("3", "Neutral"), ("4", "Agree"), ("5", "Strongly agree")]) + navigation = RadioField('How easy is it to navigate the website?', + validators=[DataRequired()], choices=[("1", "Very difficult"), ("2", "Difficult"), ("3", "Neutral"), ("4", "Easy"), ("5", "Very easy")]) + data_input = RadioField('Was uploading or entering and editing data manually easy?', + validators=[DataRequired()], choices=[("1", "Very difficult"), ("2", "Difficult"), ("3", "Neutral"), ("4", "Easy"), ("5", "Very easy")]) + # KEEP THIS ONE???? + export = RadioField('You were able to export your graphs easily.', + validators=[DataRequired()], choices=[("1", "Strongly disagree"), ("2", "Disagree"), ("3", "Neutral"), ("4", "Agree"), ("5", "Stronlgy agree")]) + # ------ + effort = RadioField('How much effort did you have to personally put in to achieve what you wanted?', + validators=[DataRequired()], choices=[ ("1", "Very much effort"), ("2", "Much effort"), ("3", "Medium effort"), ("4", "little effort"), ("5", "Very little effort")]) + future_use = RadioField('How likely would you be to use this application in the future?', + validators=[DataRequired()], choices=[("1", "Very unlikely"), ("2", "Unlikely"), ("3", "Neutral"), ("4", "Likely"), ("5", "Very likely")]) + user_interface = RadioField('How would you rate the user interface as a whole? (1 = bad, 10 = good)', + validators=[DataRequired()], choices=[("1", "1"), ("2", "2"), ("3", "3"), ("4", "4"), ("5", "5"), ("6", "6"), ("7", "7"), ("8", "8"), ("9", "9"), ("10", "10")]) + functionality = RadioField('How well does the app do what you want it to as a whole? (1 = not well, 10 = well)', + validators=[DataRequired()], choices=[("1", "1"), ("2", "2"), ("3", "3"), ("4", "4"), ("5", "5"), ("6", "6"), ("7", "7"), ("8", "8"), ("9", "9"), ("10", "10")]) + comments = TextAreaField('Comments (if any)') + submit = SubmitField('Submit') diff --git a/site/surveyapp/main/routes.py b/site/surveyapp/main/routes.py new file mode 100644 index 0000000..36ac1ed --- /dev/null +++ b/site/surveyapp/main/routes.py @@ -0,0 +1,37 @@ +from surveyapp import mongo +from flask import render_template, request, Blueprint, url_for, redirect, flash + +# Imports specifically for the feedback form +from surveyapp.main.forms import FeedbackForm +from flask_login import login_required, current_user + +main = Blueprint("main", __name__) + +@main.route('/') +@main.route('/index') +def index(): + return render_template("main/index.html") + +# Feedback route to gather user feedback during development +@main.route('/feedback', methods=["GET", "POST"]) +@login_required +def feedback(): + form = FeedbackForm() + if form.validate_on_submit(): + mongo.db.feedback.insert_one({\ + "enough_graphs" : form.enough_graphs.data,\ + "good_graphs" : form.good_graphs.data,\ + "enough_tests" : form.enough_tests.data,\ + "auto_tests" : form.auto_tests.data,\ + "navigation" : form.navigation.data,\ + "data_input" : form.data_input.data,\ + "export" : form.export.data,\ + "effort" : form.effort.data,\ + "future_use" : form.future_use.data,\ + "UI_3" : form.user_interface.data,\ + "functionality3" : form.functionality.data,\ + "comments3" : form.comments.data,\ + "user" : current_user._id}) + flash("Thank you for your feedback.", "success") + return redirect(url_for("main.index")) + return render_template("main/feedback.html", title = "Feedback", form = form) diff --git a/site/surveyapp/models.py b/site/surveyapp/models.py new file mode 100644 index 0000000..d4e446e --- /dev/null +++ b/site/surveyapp/models.py @@ -0,0 +1,54 @@ +from surveyapp import mongo, login_manager +from flask import current_app +from itsdangerous import TimedJSONWebSignatureSerializer as Serializer + +# methods inside this class allow for login_manager assistance +# when a user is logged in then it creates an instance of the session (called 'current_user') +# the static methods can then be called for various checks +# (e.g. "is_authenticated" allows me to check if a user is logged in or not and carry out appropriate redirects) +class User: + def __init__(self, email, first_name, last_name, _id): + self.email = email + self.first_name = first_name + self.last_name = last_name + self._id = _id + + # My own version of the methods provided by UserMixin, adapted for use with MongoDB + # https://flask-login.readthedocs.io/en/latest/#flask_login.UserMixin + @staticmethod + def is_authenticated(): + return True + + @staticmethod + def is_active(): + return True + + @staticmethod + def is_anonymous(): + return False + + def get_id(self): + return self.email + + # taken from the flask-login documentation https://flask-login.readthedocs.io/en/latest/ + # used to reload a user object from the user id stored in the session + @login_manager.user_loader + def load_user(email): + user = mongo.db.users.find_one({"email" : email}) + if not user: + return None + return User(email=user["email"], first_name=user["firstName"], last_name=user["lastName"], _id=user["_id"]) + + + def get_reset_token(self, expires=1800): + serializer = Serializer(current_app.config['SECRET_KEY'], expires) + return serializer.dumps({'user_email': self.email}).decode('utf-8') + + @staticmethod + def verify_reset_token(token): + serializer = Serializer(current_app.config['SECRET_KEY']) + try: + email = serializer.loads(token)['user_email'] + except: + return None + return mongo.db.users.find_one({"email" : email}) diff --git a/site/surveyapp/static/d3.css b/site/surveyapp/static/d3.css new file mode 100644 index 0000000..99a67ee --- /dev/null +++ b/site/surveyapp/static/d3.css @@ -0,0 +1,18 @@ +/* D3 styles set using CSS, so that I can quickly change it across all graph types */ +/* Set size of axis tick font */ +g .xAxis { + font-size:1.2em; +} + +g .yAxis { + font-size:1.2em; +} + +/* Set size of axis labels */ +.label { + font-size:1.2em; +} +/* Colours are specified in the javascript files as constants. This is so that they +can be accessed during mouse-over/mouse-out affects. The function d3.select(this).style('fill') +could also be used to select the current colour, however as there is a slight delay +it means that very fast mouse movements over the elements will not select the right colour. */ diff --git a/site/surveyapp/static/favicon/android-chrome-192x192.png b/site/surveyapp/static/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..a466ebb Binary files /dev/null and b/site/surveyapp/static/favicon/android-chrome-192x192.png differ diff --git a/site/surveyapp/static/favicon/android-chrome-512x512.png b/site/surveyapp/static/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..5b6a48a Binary files /dev/null and b/site/surveyapp/static/favicon/android-chrome-512x512.png differ diff --git a/site/surveyapp/static/favicon/apple-touch-icon.png b/site/surveyapp/static/favicon/apple-touch-icon.png new file mode 100644 index 0000000..5753e0f Binary files /dev/null and b/site/surveyapp/static/favicon/apple-touch-icon.png differ diff --git a/site/surveyapp/static/favicon/browserconfig.xml b/site/surveyapp/static/favicon/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/site/surveyapp/static/favicon/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/site/surveyapp/static/favicon/favicon-16x16.png b/site/surveyapp/static/favicon/favicon-16x16.png new file mode 100644 index 0000000..ef57593 Binary files /dev/null and b/site/surveyapp/static/favicon/favicon-16x16.png differ diff --git a/site/surveyapp/static/favicon/favicon-32x32.png b/site/surveyapp/static/favicon/favicon-32x32.png new file mode 100644 index 0000000..102011f Binary files /dev/null and b/site/surveyapp/static/favicon/favicon-32x32.png differ diff --git a/site/surveyapp/static/favicon/favicon.ico b/site/surveyapp/static/favicon/favicon.ico new file mode 100644 index 0000000..c453f3d Binary files /dev/null and b/site/surveyapp/static/favicon/favicon.ico differ diff --git a/site/surveyapp/static/favicon/mstile-150x150.png b/site/surveyapp/static/favicon/mstile-150x150.png new file mode 100644 index 0000000..eca785a Binary files /dev/null and b/site/surveyapp/static/favicon/mstile-150x150.png differ diff --git a/site/surveyapp/static/favicon/safari-pinned-tab.svg b/site/surveyapp/static/favicon/safari-pinned-tab.svg new file mode 100644 index 0000000..9c87b0b --- /dev/null +++ b/site/surveyapp/static/favicon/safari-pinned-tab.svg @@ -0,0 +1,37 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/site/surveyapp/static/favicon/site.webmanifest b/site/surveyapp/static/favicon/site.webmanifest new file mode 100644 index 0000000..975c8a8 --- /dev/null +++ b/site/surveyapp/static/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Datasaur", + "short_name": "Datasaur", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/site/surveyapp/static/graphscripts/barchart.js b/site/surveyapp/static/graphscripts/barchart.js new file mode 100644 index 0000000..9fff702 --- /dev/null +++ b/site/surveyapp/static/graphscripts/barchart.js @@ -0,0 +1,271 @@ +// ------VARIABLE DECLARATIONS------ +const xAxisSelect = document.querySelector(".x-axis-value") +const yAxisSelect = document.querySelector(".y-axis-value") +const yAxisDetails = document.querySelector(".y-axis-details") +const yAxisAggDom = document.querySelector(".y-axis-aggregation") +const aggregate = document.querySelector(".aggregate") +const emptyGraph = document.querySelector(".empty-graph") +const exportButton = document.querySelector(".export") + +// Get the DOM elements for all of the axes, so that we can add event listeners for when they are changed +const axesSettings = document.querySelectorAll(".axis-setting") + +// Colours for our graph +const fill = "steelblue" +const hoverFill = "#2D4053" + +// Get the graph data +const data = graphData["chart_data"] + +// Get the DOM element that will hold the SVG +const graphDOM = document.getElementById('graph') +// Get the width and height of the SVG on the client screen +let width = graphDOM.clientWidth; +let height = graphDOM.clientHeight; +// Re-set the width and height on window resize +window.onresize = function(){ + width = graphDOM.clientWidth; + height = graphDOM.clientHeight; + svg.attr('width', width).attr('height', height); +} +// Set margins around graph for axis and labels +const margin = { top: 20, right: 20, bottom: 60, left: 80 }; +// Set the graph width and height to account for margins +const gWidth = width - margin.left - margin.right; +const gHeight = height - margin.top - margin.bottom; + +// Create SVG ready for graph +const svg = d3.select('#graph') + .append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", "0 0 " + width + " " + height) + .attr("preserveAspectRatio", "none") + +// Add the graph area to the SVG, factoring in the margin dimensions +let graph = svg.append('g').attr("transform", `translate(${margin.left}, ${margin.top})`) + +// ------END OF VARIABLE DECLARATIONS------ + +// ------EVENT LISTENERS------ +// When the axes are altered, we need to re-group the data depending on the variables set +axesSettings.forEach(setting => { + setting.onchange = function(){ + axisChange() + } +}) + +// If the value is not equal to an empty string on page load (i.e. user is editing graph) +if(xAxisSelect.options[xAxisSelect.selectedIndex].value != ''){ + axisChange() +} + +// Export button that allows user to export and download the SVG as a PNG image +exportButton.addEventListener("click", () => { + let title = document.querySelector(".title").value + let exportTitle = title == "" ? "plot.png": `${title}.png` + saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); +}) + + +// ------FUNCTIONS------ +function axisChange (){ + // Get the selected values + let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; + let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; + let yAxisAgg = yAxisAggDom.options[yAxisAggDom.selectedIndex].value; + // Hide the overlay if it is still present + emptyGraph.classList.remove("visible"); + emptyGraph.classList.add("invisible"); + + // Remove the ' -- select an option -- ' option + xAxisSelect.firstChild.hidden = true; + // Reveal the y-axis variable for the user to select + yAxisDetails.classList.remove('hidden-down') + + // If the chosen y variable is equal to 'Amount' then we don't want to give the user the option to perform data aggregations + if(yAxisValue != 'Amount'){ + aggregate.classList.remove('hidden-down') + aggregate.classList.add('visible') + } else{ + aggregate.classList.remove('visible') + aggregate.classList.add('hidden-down') + } + // Get the grouped data based on the chose variables + let groupedData = groupData(xAxisValue, yAxisValue); + + // draw the graph with the chosen variables + render(groupedData, xAxisValue, yAxisValue, yAxisAgg); +} + + +// Data grouping function. Called whenever an axis setting changes +function groupData(xAxisValue, yAxisValue){ + // We can create a 'nested' D3 object, with the key as the chosen x-axis variable + let nestedData = d3.nest().key(function(d) { return d[xAxisValue]; }) + + // If the y axis is just to count the values, we can group and perform a roll up on the calculation of the length + if(yAxisValue == "Amount"){ + return nestedData + .rollup(function(v) { return v.length; }) + .entries(data) + } + // Else, we need to see which y-axis aggregation was chosen + let yAxisAgg = yAxisAggDom.options[yAxisAggDom.selectedIndex].value; + if(yAxisAgg == "Average"){ + return nestedData + .rollup(function(v) { return d3.mean(v, function(d) { return d[yAxisValue]; }); }) + .entries(data) + } + if(yAxisAgg == "Highest"){ + return nestedData + .rollup(function(v) { return d3.max(v, function(d) { return d[yAxisValue]; }); }) + .entries(data) + } + if(yAxisAgg == "Lowest"){ + return nestedData + .rollup(function(v) { return d3.min(v, function(d) { return d[yAxisValue]; }); }) + .entries(data) + } + if(yAxisAgg == "Sum"){ + return nestedData + .rollup(function(v) { return d3.sum(v, function(d) { return d[yAxisValue]; }); }) + .entries(data) + } +} + + +// Function that draws the graph +function render(groupedData, xAxisValue, yAxisValue, yAxisAgg){ + // Specify the x-axis values and the y-axis valus + const xValues = d => d.key; + const yValues = d => d.value; + + // Remove old axes labels (if they exist) + d3.selectAll('.label').remove(); + + // sort the grouped data keys in ascending order (so the x-axis is in numerical order) + groupedData.sort(function(a, b) { return d3.ascending(parseInt(a.key), parseInt(b.key))}); + // Set the scale for the x-axis (domain is the range of our data, range is the physical width of the graph) + const xScale = d3.scaleBand() + .domain(groupedData.map(xValues)) + .range([0, gWidth]) + .paddingInner(0.1) + + // Set the scale for the y-axis + const yScale = d3.scaleLinear() + .domain([0, d3.max(groupedData, yValues)]).nice() + .range([gHeight, 0]) + + // Select the axes (if they exist) + let yAxis = d3.selectAll(".yAxis") + let xAxis = d3.selectAll(".xAxis") + + // If they dont exist, we create them. If they do, we update them + if (yAxis.empty() && xAxis.empty()){ + // For the x-axis we create an axisBottom and 'translate' it so it appears on the bottom of the graph + graph.append('g').attr("class", "xAxis").call(d3.axisBottom(xScale)) + .attr("transform", `translate(0, ${gHeight})`) + // For y axis we do not need to translate it, as the default is on the left + graph.append('g').attr("class", "yAxis").call(d3.axisLeft(yScale)) + } else { + // Adjust the x-axis according the x-axis variable data + xAxis.transition().duration(1000).call(d3.axisBottom(xScale)) + // Adjust the y-axis according the y-axis variable data + yAxis.transition().duration(1000).call(d3.axisLeft(yScale)) + } + + // If the yAxis is 'Amount' we can leave the label as it is, otherwise we need to add the type of aggregation + let yAxisLabel = yAxisValue == 'Amount' ? 'Amount' : `${yAxisAgg}: ${yAxisValue}` + + // Add y axis label + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("class", "label") + .attr("y", 0) + .attr("x",0 - (gHeight / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yAxisLabel); + + // Add x axis label + svg.append("text") + .attr("transform",`translate(${width/2}, ${gHeight + margin.top + 55})`) + .attr("class", "label") + .style("text-anchor", "middle") + .text(xAxisValue); + + // Select all 'rect' DOM elements (if they exist) + let rect = graph.selectAll('rect').data(groupedData) + + // D3 'exit()' is what happens to DOM elements that no longer have data bound to them + // Given a transition that shrinks them down to the x-axis before removing + rect.exit().transition() + .duration(1000) + .attr("y", yScale(0)) + .attr('height', 0) + .remove() + + // D3 'enter()' is the creation of DOM elements bound to the data + // At this stage, the bars are all flat along the x-axis + let bar = rect.enter() + .append('rect') + .attr("y", yScale(0)) + .attr('x', d => xScale(xValues(d))) + .attr('width', xScale.bandwidth()) // band width is width of a single bar + .style('fill', fill) + + // Tooltip needs to be set before merging????? + setTooltip(bar, xValues, yValues) + + // Finally, we merge the newly entered bars with the existing ones. + // (i.e. merges the 'enter and 'update' groups) + bar.merge(rect) // 'merge' merges the 'enter' and 'update' groups + .transition() + .delay(d => xScale(xValues(d))/2 ) + .duration(1000) + .attr('height', d => gHeight - yScale(yValues(d))) + .attr('y', d=> yScale(yValues(d))) + .attr('x', d => xScale(xValues(d))) + .attr('width', xScale.bandwidth()) // band width is width of a single bar +} + +// Function that sets tooltip over each bar when hovered over +function setTooltip(bar, xValues, yValues){ + bar.on('mouseenter', function(d) { + d3.select(this) + .transition() + .duration(100) + .style('fill', hoverFill) + let tooltip = d3.select(".graph-tooltip") + // 80 chosen to position the tooltip above bars + let tooltipOffset = (d3.select(this).attr("width") - 80)/2; + + // To position the tool tip when the user hovers. Use the window and calculate the offset + let position = this.getScreenCTM() + .translate(+ this.getAttribute("x"), + this.getAttribute("y")); + + // Now give the tooltip the data it needs to show and the position it should be. + tooltip.html(xValues(d)+": " + yValues(d)) + .style("left", (window.pageXOffset + position.e + tooltipOffset) + "px") // Center it horizontally over the bar + .style("top", (window.pageYOffset + position.f - 50) + "px"); // Shift it 50 px above the bar + // Finally remove the 'hidden' class + tooltip.classed("tooltip-hidden", false) + }).on('mouseout', function() { + d3.select(this) + .transition() + .duration(100) + .style('fill', fill) + // When the mouse is removed from the bar we can add the hidden class to the tooltip again + d3.select(".graph-tooltip").classed("tooltip-hidden", true); + }) +} + + +// When the form is submitted, we want to get a jpg image of the svg +$('form').submit(function (e) { + // prevent default form submission + e.preventDefault(); + // call function to post form (separate js file) + postgraph(width, height) +}); diff --git a/site/surveyapp/static/graphscripts/boxchart.js b/site/surveyapp/static/graphscripts/boxchart.js new file mode 100644 index 0000000..fea3ba0 --- /dev/null +++ b/site/surveyapp/static/graphscripts/boxchart.js @@ -0,0 +1,360 @@ +const xAxisSelect = document.querySelector(".x-axis-value") +const yAxisSelect = document.querySelector(".y-axis-value") +const xAxisDetails = document.querySelector(".x-axis-details") +const emptyGraph = document.querySelector(".empty-graph") +const exportButton = document.querySelector(".export") + +// Get the DOM elements for all of the axes, so that we can add event listeners for when they are changed +const axesSettings = document.querySelectorAll(".axis-setting") + +// Colours for our graph +const fill = "steelblue" +const hoverFill = "#2D4053" + +// Get the graph data +const data = graphData["chart_data"] + + +// Set graph dimensions +var width = document.getElementById('graph').clientWidth; +var height = document.getElementById('graph').clientHeight; +// Re set dimensions on window resize +window.onresize = function(){ + width = document.getElementById('graph').clientWidth; + height = document.getElementById('graph').clientHeight; + svg.attr('width', width).attr('height', height); +} +// Set margins around graph for axis and labels +const margin = { top: 20, right: 20, bottom: 60, left: 80 }; +// Set the graph width and height to account for axes +const gWidth = width - margin.left - margin.right; +const gHeight = height - margin.top - margin.bottom; +// Create SVG ready for graph +const svg = d3.select('#graph').append("svg").attr("width", width).attr("height", height).attr("viewBox", "0 0 " + width + " " + height).attr("preserveAspectRatio", "none") +// Add the graph area to the SVG, factoring in the margin dimensions +var graph = svg.append('g').attr("transform", `translate(${margin.left}, ${margin.top})`) + + +// EVENT LISTENERS +// When the axes are altered, we need to re-group the data depending on the variables set +axesSettings.forEach(setting => { + setting.onchange = function(){ + axisChange() + } +}) + +// If the value is not equal to an empty string on page load (i.e. user is editing graph) +if(xAxisSelect.options[xAxisSelect.selectedIndex].value != ''){ + axisChange() +} + +// Export button that allows user to export and download the SVG as a PNG image +exportButton.addEventListener("click", () => { + let title = document.querySelector(".title").value + let exportTitle = title == "" ? "plot.png": `${title}.png` + saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); +}) + +function axisChange (){ + let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; + let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; + + // Hide the overlay + emptyGraph.classList.remove("visible"); + emptyGraph.classList.add("invisible"); + + // Remove the ' -- select an option -- ' option + yAxisSelect.firstChild.hidden = true; + // Reveal the y-axis variable for the user to select + xAxisDetails.classList.remove('hidden-down') + + let dataStats = getData(yAxisValue, xAxisValue) + // re-draw the graph with the chosen variables + render(dataStats); +} + + +// Function used for getting different stats needed for box-whisker plot and for each group +// Returns an function that can calculate the stats for each x-axis group. +function getData(yAxisValue, xAxisValue){ + + // We use our nested data to calculate the first quartile, third quartile, median, min and max + var dataStats = d3.nest() // nest function allows to group the calculation per level of a factor + // Depending if user selects a x-axis variable, we group the data + .key(function(d) { return xAxisValue == "" ? 1 : d[xAxisValue]}) + .rollup(function(d) { + let + orderedArray = d.map(g => g[yAxisValue]).sort(d3.ascending) + q1 = d3.quantile(orderedArray,.25) + median = d3.quantile(orderedArray,.5) + q3 = d3.quantile(orderedArray,.75) + interQuantileRange = q3 - q1 + // Whiskers can have multiple different meanings. In mine, I have decided to + // go with the commonly used highest-value and lowest-values + // min = q1 - 1.5 * interQuantileRange + // max = q3 + 1.5 * interQuantileRange + min = d3.min(orderedArray) + max = d3.max(orderedArray) + return({q1: q1, median: median, q3: q3, interQuantileRange: interQuantileRange, min: min, max: max}) + }) + .entries(data) + return dataStats +} + + +function render(dataStats){ + let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; + let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; + + // Specify the x-axis values and the y-axis valus + const xValues = d => d.key; + const hoverText = d => ` + Q1: ${d.value.q1} + Q3: ${d.value.q3} + Median: ${d.value.median} + Max: ${d.value.max} + Min: ${d.value.min} + `; + + // Remove old axes labels (if they exist) + d3.selectAll('.label').remove(); + + + + dataStats.sort(function(a, b) { return d3.ascending(parseInt(a.key), parseInt(b.key))}); + + // Get the highest value from our objects so we can base the y-axis on that. + // I could instead find the highest value in that column. However, doing it this way + // it means I can change the definition of the whiskers without needing to change + // in both places. See above. + let yMax = Math.max.apply(Math, dataStats.map(function(object) { return object.value.max})) + // Set the scale for the y-axis + const yScale = d3.scaleLinear() + .domain([0, yMax]).nice() + .range([gHeight, 0]) + + // Set the scale for the x-axis + const xScale = d3.scaleBand() + .domain(dataStats.map(xValues)) + .range([0, gWidth]) + .paddingInner(.3) + .paddingOuter(.1) + + + // Select the axes (if they exist) + var yAxis = d3.selectAll(".yAxis") + var xAxis = d3.selectAll(".xAxis") + + // If they dont exist, we create them. If they do, we update them + if (yAxis.empty() && xAxis.empty()){ + // For the x-axis we create an axisBottom and 'translate' it so it appears on the bottom of the graph + graph.append('g').attr("class", "xAxis").call(d3.axisBottom(xScale)) + .attr("transform", `translate(0, ${gHeight})`) + // For y axis we do not need to translate it, as the default is on the left + graph.append('g').attr("class", "yAxis").call(d3.axisLeft(yScale)) + } else { + // Adjust the x-axis according the x-axis variable data + xAxis.transition() + .duration(1000) + .call(d3.axisBottom(xScale)) + // Adjust the y-axis according the y-axis variable data + yAxis.transition() + .duration(1000) + .call(d3.axisLeft(yScale)) + } + + let yAxisLabel = yAxisValue + + // Add y axis label + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("class", "label") + .attr("y", 0) + .attr("x",0 - (gHeight / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yAxisLabel); + + // Add x axis label + svg.append("text") + .attr("transform",`translate(${width/2}, ${gHeight + margin.top + 55})`) + .attr("class", "label") + .style("text-anchor", "middle") + .text(xAxisValue); + + + let boxDom = graph.selectAll("rect").data(dataStats) + let medianDom = graph.selectAll(".medianLine").data(dataStats) + let vertDom = graph.selectAll(".vertLine").data(dataStats) + let minDom = graph.selectAll(".minLine").data(dataStats) + let maxDom = graph.selectAll(".maxLine").data(dataStats) + + // Exit functions, for when the SVG elements are no longer linked with data elements + medianDom.exit().remove() + vertDom.exit().remove() + minDom.exit().remove() + maxDom.exit().remove() + boxDom.exit() + .transition() + .duration(600) + .attr("height", 0) + .attr("width", 0) + .remove() + + // ------Vertical line enter, merge and exit functions------ + let vertical = vertDom + .enter() + .append("line") + .attr("class", "vertLine") + .attr("x1", d => xScale(xValues(d)) + xScale.bandwidth()/2) + .attr("x2", d => xScale(xValues(d)) + xScale.bandwidth()/2) + .attr("y1", d => yScale(d.value.median)) + .attr("y2", d => yScale(d.value.median)) + .attr("stroke", "black") + .style("width", "40") + + vertical.merge(vertDom) + .transition() + .delay(d => xScale(xValues(d))/2 ) + .duration(300) + .attr("x1", d => xScale(xValues(d)) + xScale.bandwidth()/2) + .attr("x2", d => xScale(xValues(d)) + xScale.bandwidth()/2) + .attr("y1", d => {return yScale(d.value.min)}) + .attr("y2", d => {return yScale(d.value.max)}) + + // ------Box enter, merge and exit functions------ + let box = boxDom + .enter() + .append("rect") + .attr("x", d => xScale(d.key) + xScale.bandwidth()/2) + .attr("y", d => yScale(d.value.median)) + .attr("height", 0) + .attr("width", 0) + .attr("stroke", "black") + .style('fill', fill) + + setTooltip(box, hoverText, xValues) + + box.merge(boxDom) // 'merge' merges the 'enter' and 'update' groups + .transition() + .delay(d => xScale(xValues(d))/2 ) + .duration(600) + .attr("x", d => {return xScale(xValues(d))}) + .attr("y", d => {return yScale(d.value.q3) }) + .attr("width", xScale.bandwidth()) + .attr("height", d => {return (yScale(d.value.q1)-yScale(d.value.q3)) }) + + + // ------Median enter, merge and exit functions------ + let median = medianDom + .enter() + .append("line") + .attr("class", "medianLine") + .attr("y1", d => yScale(d.value.median)) + .attr("y2", d => yScale(d.value.median)) + .attr("x1", d => xScale(d.key)+xScale.bandwidth()/2) + .attr("x2", d => xScale(d.key)+xScale.bandwidth()/2) + .attr("stroke", "black") + .style("width", 80) + + median.merge(medianDom) // 'merge' merges the 'enter' and 'update' groups + .transition() + .delay(d => xScale(xValues(d))/2 ) + .duration(600) + .attr("y1", d => yScale(d.value.median)) + .attr("y2", d => yScale(d.value.median)) + .attr("x1", d => xScale(d.key)) + .attr("x2", d => xScale(d.key)+xScale.bandwidth()) + + + // ------Min line enter, merge and exit functions------ + let min = minDom + .enter() + .append("line") + .attr("class", "minLine") + .attr("y1", d => yScale(d.value.min)) + .attr("y2", d => yScale(d.value.min)) + .attr("x1", d => xScale(d.key)+xScale.bandwidth()/2) + .attr("x2", d => xScale(d.key)+xScale.bandwidth()/2) + .attr("stroke", "black") + .style("width", 80) + + min.merge(minDom) // 'merge' merges the 'enter' and 'update' groups + .transition() + .delay(d => 300 + xScale(xValues(d))/2 ) + .duration(600) + .attr("y1", d => yScale(d.value.min)) + .attr("y2", d => yScale(d.value.min)) + .attr("x1", d => xScale(d.key)+xScale.bandwidth()/4) + .attr("x2", d => xScale(d.key)+xScale.bandwidth()*3/4) + + // ------Min line enter, merge and exit functions------ + let max = maxDom + .enter() + .append("line") + .attr("class", "maxLine") + .attr("y1", d => yScale(d.value.max)) + .attr("y2", d => yScale(d.value.max)) + .attr("x1", d => xScale(d.key)+xScale.bandwidth()/2) + .attr("x2", d => xScale(d.key)+xScale.bandwidth()/2) + .attr("stroke", "black") + .style("width", 80) + + max.merge(maxDom) // 'merge' merges the 'enter' and 'update' groups + .transition() + .delay(d => 300 + xScale(xValues(d))/2 ) + .duration(600) + .attr("y1", d => yScale(d.value.max)) + .attr("y2", d => yScale(d.value.max)) + .attr("x1", d => xScale(d.key)+xScale.bandwidth()/4) + .attr("x2", d => xScale(d.key)+xScale.bandwidth()*3/4) + +} + +// Function that sets tooltip over each box and whisker when hovered over +function setTooltip(box, hoverText, xValues){ + box.on('mouseenter', function(d) { + d3.select(this) + .transition() + .duration(100) + .style('fill', hoverFill) + + let tooltip = d3.select(".graph-tooltip") + // 80 chosen as it positions the tooltip in an appropriate location to the boxes + let tooltipOffset = (d3.select(this).attr("width") - 80)/2; + + // To position the tool tip when the user hovers. Use the window and calculate the offset + let position = this.getScreenCTM() + .translate(+ this.getAttribute("x"), + this.getAttribute("y")); + + // Now give the tooltip the data it needs to show and the position it should be. + tooltip + .html(xValues(d) + "
" + hoverText(d)) + .style("left", (window.pageXOffset + position.e + tooltipOffset) + "px") // Center it horizontally over the box + .style("top", (window.pageYOffset + position.f - 80) + "px"); // Shift it 80 px above the box + + // Finally remove the 'hidden' class + tooltip.classed("tooltip-hidden", false) + }).on('mouseout', function() { + d3.select(this) + .transition() + .duration(100) + .style('fill', fill) + // When the mouse is removed from the box we can add the hidden class to the tooltip again + d3.select(".graph-tooltip").classed("tooltip-hidden", true); + }) +} + + +// Function required to activate the 'help' tooltip on the axis +$(function () { + $("[data-toggle='help']").tooltip(); +}); + +// When the form is submitted, we want to get a jpg image of the svg +$('form').submit(function (e) { + // prevent default form submission + e.preventDefault(); + // call function to post form (separate js file) + postgraph(width, height) +}); diff --git a/site/surveyapp/static/graphscripts/histogram.js b/site/surveyapp/static/graphscripts/histogram.js new file mode 100644 index 0000000..fbe1016 --- /dev/null +++ b/site/surveyapp/static/graphscripts/histogram.js @@ -0,0 +1,267 @@ +// ------VARIABLE DECLARATIONS------ +const xAxisSelect = document.querySelector(".x-axis-value") +const emptyGraph = document.querySelector(".empty-graph") //overlay +const exportButton = document.querySelector(".export") +const settingsGroup = document.querySelector(".extra-settings-group") +const extraSettings = document.querySelectorAll(".extra-setting") +// x-axis range +const xFrom = document.querySelector(".x-from") +const xTo = document.querySelector(".x-to") +const numberOfGroups = document.querySelector(".number-groups") +// Get the DOM elements for all of the axes, so that we can add event listeners for when they are changed +const axisSettings = document.querySelector(".axis-setting") + +// Colours for our graph +const fill = "steelblue" +const hoverFill = "#2D4053" + +// Get the graph data +const data = graphData["chart_data"] + +// Get the width and height of the SVG on the client screen +let width = document.getElementById('graph').clientWidth; +let height = document.getElementById('graph').clientHeight; +// Re-set the width and height on window resize +window.onresize = function(){ + width = document.getElementById('graph').clientWidth; + height = document.getElementById('graph').clientHeight; + svg.attr('width', width).attr('height', height); +} +// Set margins around graph for axis and labels +const margin = { top: 20, right: 20, bottom: 60, left: 80 }; +// Set the graph width and height to account for margins +const gWidth = width - margin.left - margin.right; +const gHeight = height - margin.top - margin.bottom; + +// Create SVG ready for graph +const svg = d3.select('#graph') + .append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", "0 0 " + width + " " + height) + .attr("preserveAspectRatio", "none") + +// Add the graph area to the SVG, factoring in the margin dimensions +let graph = svg.append('g').attr("transform", `translate(${margin.left}, ${margin.top})`) +// ------END OF VARIABLE DECLARATIONS------ + +// ------EVENT LISTENERS------ +// When the axis is altered, we trigger the graph rendering +axisSettings.onchange = function(){ + // Reset the axis range and number of groups when user selects a new variable + xFrom.value = "" + xTo.value = "" + numberOfGroups.value = "" + axisChange() +} + +// If the x-axis is not an empty string on page load (i.e. if user is editing a graph) +if(xAxisSelect.options[xAxisSelect.selectedIndex].value != ''){ + axisChange() +} + +// When the x-axis range is altered or the group size is altered, we need to re-render the table +// We do not need to call 'axisChange' as the variables themselves haven't changed +extraSettings.forEach(input => { + input.onchange = function() { + render(data) + } +}) + +// Export button that allows user to export and download the SVG as a PNG image +exportButton.addEventListener("click", () => { + let title = document.querySelector(".title").value + let exportTitle = title == "" ? "plot.png": `${title}.png` + saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); +}) + +// ------FUNCTIONS FOR DRAWING GRAPH------ +// Resets some options and handles DOM elements visibility +function axisChange (){ + // Remove the ' -- select an option -- ' option + xAxisSelect.firstChild.hidden = true; + // Reveal the extra settings + settingsGroup.classList.remove('hidden-down') + + // Make the overlay hidden + emptyGraph.classList.remove("visible"); + emptyGraph.classList.add("invisible"); + + // re-draw the graph with the chosen variables + render(data); +} + +// Draws the graph with the chosen variable +function render(data){ + let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; + + // Specify the x-axis values and the y-axis values + const xValues = d => d[xAxisValue]; + const yValues = d => d.length; + + // Remove old axes labels (if they exist) + d3.selectAll('.label').remove(); + + // sort the data keys in ascending order (i.e. so the x-axis is in numerical order) + // IS THIS NEEDED? + // data.sort(function(a, b) { return d3.ascending(parseInt(a.key), parseInt(b.key))}); + + // set the input fields for the domain (i.e. range of values) if not yet set + if(xFrom.value == "") xFrom.value = d3.min(data, xValues) + if(xTo.value == "") xTo.value = d3.max(data, xValues) + + // Now extract the range from the values (if they are specifed by user) + // If the values specified by the user are outside the range of the data, increase the range + // else use the range of the data as default. + let xFromValue = xFrom.value = Math.min(d3.min(data, xValues), xFrom.value) + let xToValue = xTo.value = Math.max(d3.max(data, xValues), xTo.value) + + // Set the scale for the x-axis + const xScale = d3.scaleLinear() + .domain([xFromValue, xToValue]).nice() + .range([0, gWidth]) + + let groups; + // If the user hasn't specified the number of groups, we will use the default of xScale.ticks() + if(numberOfGroups.value == ""){ + groups = xScale.ticks() + numberOfGroups.value = xScale.ticks().length-1 + }else{ + groups = numberOfGroups.value + } + + let histogram = d3.histogram() + .value(xValues) + .domain(xScale.domain()) + .thresholds(groups) + + let bins = histogram(data) + + // Set the scale for the y-axis based on the size of the biggest bin + const yScale = d3.scaleLinear() + .domain([0, d3.max(bins, d => d.length)]).nice() + .range([gHeight, 0]) + + // Select the axes (if they exist) + let yAxis = d3.selectAll(".yAxis") + let xAxis = d3.selectAll(".xAxis") + + // Get the position of the y-axis (as it will shift with negative x-axis data) + let yPosition = (0 > xFromValue && 0 < xToValue) ? xScale(0) : 0 + + // If they dont exist, we create them. If they do, we update them + if (yAxis.empty() && xAxis.empty()){ + // For the x-axis we create an axisBottom and 'translate' it so it appears on the bottom of the graph + graph.append('g').attr("class", "xAxis").call(d3.axisBottom(xScale)) + .attr("transform", `translate(0, ${gHeight})`) + // For why axis we do not need to translate it, as the default is on the left + graph.append('g').attr("class", "yAxis").call(d3.axisLeft(yScale)) + .attr("transform", `translate(${yPosition}, 0)`) + } else { + // Adjust the x-axis according the x-axis variable data + xAxis.transition() + .duration(1000) + .call(d3.axisBottom(xScale)) + // Adjust the y-axis according the y-axis variable data + yAxis.transition() + .duration(1000) + .call(d3.axisLeft(yScale)) + .attr("transform", `translate(${yPosition}, 0)`) + } + + // Add y axis label + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("class", "label") + .attr("y", 0) + .attr("x",0 - (gHeight / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text("Frequency"); + + // Add x axis label (again, translated to the correct position) + svg.append("text") + .attr("transform",`translate(${width/2}, ${gHeight + margin.top + 55})`) + .attr("class", "label") + .style("text-anchor", "middle") + .text(xAxisValue); + + let rect = graph.selectAll('rect').data(bins) + // D3 'exit()' is what happens to DOM elements that no longer have data bound to them + // Given a transition that shrinks them down to the x-axis + rect.exit().transition() + .duration(1000) + .attr("y", yScale(0)) + .attr('height', 0) + .remove() + + // D3 'enter()' is the creation of DOM elements bound to the data + let bar = rect.enter() + .append('rect') + .attr("y", yScale(0)) + .attr('x', d => xScale(d.x0)) + .attr('width', d => Math.max(0, xScale(d.x1) - xScale(d.x0) - 1)) // width of a single bar + .style('fill', fill) + + setTooltip(bar, yValues) + + bar.merge(rect) // 'merge' merges the 'enter' and 'update' groups + .transition() + .duration(1000) + .attr('height', d => yScale(0) - yScale(d.length)) + .attr('y', d => yScale(d.length)) + .attr('x', d => xScale(d.x0)) + .attr('width', d => Math.max(0, xScale(d.x1) - xScale(d.x0) - 1)) // band width is width of a single bar +} + + +// Function that sets tooltip over each bar when hovered over +function setTooltip(bar, yValues){ + bar.on('mouseenter', function(d) { + d3.select(this) + .transition() + .duration(100) + .style('fill', hoverFill) + + let tooltip = d3.select(".graph-tooltip") + let tooltipOffset = (d3.select(this).attr("width") - 80)/2; + // To position the tool tip when the user hovers. Use the window and calculate the offset + let position = this.getScreenCTM() + .translate(+ this.getAttribute("x"), + this.getAttribute("y")); + + // Now give the tooltip the data it needs to show and the position it should be. + tooltip.html(yValues(d)) + .style("left", (window.pageXOffset + position.e + tooltipOffset) + "px") // Center it horizontally over the bar + .style("top", (window.pageYOffset + position.f - 50) + "px"); // Shift it 50 px above the bar + tooltip.classed("tooltip-hidden", false) + + }).on('mouseout', function() { + d3.select(this) + .transition() + .duration(100) + .style('fill', fill) + // Hide the tooltip + d3.select(".graph-tooltip").classed("tooltip-hidden", true); + }) +} + +// JQUERY functions +// Function that will confirm user input when they press enter, without submitting the form +$('body').on('keydown', 'input, select', function(e) { + if (e.key === "Enter") { + $(this).blur() + } +}); + +// Function required to activate the 'help' tooltip on the axis +$(function () { + $("[data-toggle='help']").tooltip(); +}); + +// When the form is submitted, we want to get a jpg image of the svg +$('form').submit(function (e) { + // prevent default form submission + e.preventDefault(); + // call function to post form (separate js file) + postgraph(width, height) +}); diff --git a/site/surveyapp/static/graphscripts/map.js b/site/surveyapp/static/graphscripts/map.js new file mode 100644 index 0000000..b24874b --- /dev/null +++ b/site/surveyapp/static/graphscripts/map.js @@ -0,0 +1,248 @@ +// ------VARIABLE DECLARATIONS------ +const settings = document.querySelectorAll(".axis-setting") +const variable = document.querySelector(".x-axis-value") +const scope = document.querySelector(".scope") +const exportButton = document.querySelector(".export") +// Get the graph data +const data = graphData["chart_data"] + +// Width/height needed for saving image to dashboard +const width = document.getElementById('graph').clientWidth; +const height = document.getElementById('graph').clientHeight; + +// Get the iso code for all the countries and pair them with country names for later access +let iso = {} +const countries = Datamap.prototype.worldTopo.objects.world.geometries; +for (let i = 0, j = countries.length; i < j; i++) { + iso[countries[i].properties.name] = countries[i].id +} + +// Set the projections for various parts of the world +const africa = function(element) { + let projection = d3.geo.equirectangular() + .center([19, 0]) + .rotate([4.4, 0]) + .scale(400) + .translate([element.offsetWidth / 2, element.offsetHeight / 2]); + let path = d3.geo.path() + .projection(projection); + return {path: path, projection: projection}; +} + +const europe = function(element) { + let projection = d3.geo.mercator() + .center([20, 56]) + .rotate([0, 0]) + .scale(490) + .translate([element.offsetWidth / 2, element.offsetHeight / 2]); + let path = d3.geo.path() + .projection(projection); + return {path: path, projection: projection}; +} + +const southAmerica = function(element) { + let projection = d3.geo.mercator() + .center([-65, -24]) + .rotate([0, 0]) + .scale(350) + .translate([element.offsetWidth / 2, element.offsetHeight / 2]); + let path = d3.geo.path() + .projection(projection); + return {path: path, projection: projection}; +} + +const northAmerica = function(element) { + let projection = d3.geo.mercator() + .center([-90, 45]) + .rotate([0, 0]) + .scale(340) + .translate([element.offsetWidth / 2, element.offsetHeight / 2]); + let path = d3.geo.path() + .projection(projection); + return {path: path, projection: projection}; +} + +const asia = function(element) { + let projection = d3.geo.mercator() + .center([100, 35]) + .rotate([0, 0]) + .scale(340) + .translate([element.offsetWidth / 2, element.offsetHeight / 2]); + let path = d3.geo.path() + .projection(projection); + return {path: path, projection: projection}; +} + +const oceania = function(element) { + let projection = d3.geo.mercator() + .center([130, -20]) + .rotate([0, 0]) + .scale(400) + .translate([element.offsetWidth / 2, element.offsetHeight / 2]); + let path = d3.geo.path() + .projection(projection); + return {path: path, projection: projection}; +} + +// ------END OF VARIABLE DECLARATIONS------ +// ------SET EVENT LISTENERS------ +// Add event listener for any axis changes +settings.forEach(setting => { + setting.onchange = function(){ + render(variable.options[variable.selectedIndex].value); + } +}) + +// Runs if variables already set (i.e. if user is choosing to edit rather than create) +if(variable.options[variable.selectedIndex].value != ''){ + render(variable.options[variable.selectedIndex].value) +} + +// Export button that allows user to export and download the SVG as a PNG image +exportButton.addEventListener("click", () => { + let title = document.querySelector(".title").value + let exportTitle = title == "" ? "plot.png": `${title}.png` + saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); +}) + +// ------FUNCTIONS FOR DRAWING THE GRAPH------ +function render(chosenVariable){ + // Needed to delete the old graph (if present) + $("#graph").parent().append( "
"); + $("#graph").remove(); + // Get the grouped data + let groupedData = group(chosenVariable); + // Get the most common country value so that we can base our colour scale of that + let max = getMax(groupedData) + + // set the legend variables, based on the maximum value. We have 5 in total, + // so each bin comprises a 5th of the maximum value + let veryHigh = `- Between ${(max/5)*4} and ${max}` + let high = `- Between ${(max/5)*3} and ${(max/5)*4 - 1}` + let medium = `- Between ${(max/5)*2} and ${(max/5)*3 - 1}` + let low = `- Between ${max/5} and ${(max/5)*2 - 1}` + let veryLow = `- Less than ${max/5}` + + // Get our colour scale and link it to the values in our legend + let colourScale = getColourScale(max, veryHigh, high, medium, veryLow, low) + + let result = {}; + // Loop through our data, setting the colour for each + groupedData.forEach(country => { + let colour = 'defaultFill' + if(country.values < max) colour = veryHigh + if(country.values < (max / 5)*4) colour = high + if(country.values < (max / 5)*3) colour = medium + if(country.values < (max / 5)*2) colour = low + if(country.values < max / 5) colour = veryLow + let fill = { + "fillKey": colour, + "value": country.values + } + // Convert the country to 3 letter ISO code (if needed) + let countryCode = iso[country.key] == undefined ? country.key : iso[country.key] + result[countryCode] = fill + }) + // Get the projection and scope of the graph + // Scope relates to whether it is focused on states of America or countries of the World + let chosenProjection = getProjection() + let usa_world = getScope() + // Draw our map with our colourscale and data + let map = new Datamap({ + element: document.getElementById('graph'), + scope: usa_world, + geographyConfig: { + // Set the border colour to same colour as the default fill + highlightBorderColor: '#FC8D59', + // Customise popup to also display the values/counts if they exist + popupTemplate: function(geography, data) { + if(data == null){ + return '
' + geography.properties.name + '
'; + } + return '
' + geography.properties.name + ': ' + data.value + '
'; + } + }, + projection: 'mercator', + setProjection: chosenProjection, + fills: colourScale, + data: result + }); + // Add legend to the map + map.legend(); +} + +// Groups the data on the chosenVariable/column +function group(chosenVariable){ + // We can create a 'nested' D3 object, with the key as the chosen x-axis variable + let nestedData = d3.nest().key(function(d) { return d[chosenVariable]; }) + return nestedData + .rollup(function(v) { return v.length; }) + .entries(data) +} + +// Gets the maximum value and then increases it so it is divisible by 5 (for the 5 bins) +function getMax(groupedData){ + let max = 0 + for(let i = 0; i < groupedData.length; i++){ + if(groupedData[i].values > max){ + max = groupedData[i].values + } + } + // we now increase the maximum until it is directly divisble by 5 (as we have 5 bins) + max++; + while(max % 5 != 0){ + max++; + } + return max +} + +// Sets the colour scale of the map +function getColourScale(max, veryHigh, high, medium, veryLow, low){ + // Save as string so that we can insert in javascript variables + let colourScale = `{ + "${veryLow}": "#DEEDCF", + "${low}": "#74C67A", + "${medium}": "#1D9A6C", + "${high}": "#137177", + "${veryHigh}": "#0A2F51", + "defaultFill": "#dae4eb" + }` + // Convert to JSON object to be used in the map + return JSON.parse(colourScale) +} + +// Gets the projection of the map, depending on the chosen user input +function getProjection(){ + if(scope.options[scope.selectedIndex].value == "Europe"){ + return europe + }else if(scope.options[scope.selectedIndex].value == "Africa"){ + return africa + }else if(scope.options[scope.selectedIndex].value == "Asia"){ + return asia + }else if(scope.options[scope.selectedIndex].value == "South America"){ + return southAmerica + }else if(scope.options[scope.selectedIndex].value == "Australia/Oceania"){ + return oceania + }else if(scope.options[scope.selectedIndex].value == "North America"){ + return northAmerica + }else{ + return null + } +} + +// Gets the scope (either countries of the world or states of america) +function getScope(){ + if(scope.options[scope.selectedIndex].value == "United States of America"){ + return "usa" + }else{ + return "world" + } +} + +// Ajax call used to post, as we are also sending the image of the graph (not in form) +$('form').submit(function (e) { + // prevent default form submission + e.preventDefault(); + // call function to post form (separate js file) + postgraph(width, height) +}); diff --git a/site/surveyapp/static/graphscripts/piechart.js b/site/surveyapp/static/graphscripts/piechart.js new file mode 100644 index 0000000..ad45940 --- /dev/null +++ b/site/surveyapp/static/graphscripts/piechart.js @@ -0,0 +1,323 @@ +// ------VARIABLE DECLARATIONS------ +const variableSelect = document.querySelector(".x-axis-value") +const againstSelect = document.querySelector(".y-axis-value") +const againstSection = document.querySelector(".y-axis-details") +const againstAggDOM = document.querySelector(".y-axis-aggregation") +const aggregate = document.querySelector(".aggregate") +const emptyGraph = document.querySelector(".empty-graph") +const exportButton = document.querySelector(".export") + +// Get the DOM elements for all of the axes, so that we can add event listeners for when they are changed +const axesSettings = document.querySelectorAll(".axis-setting") + +// Get the graph data +const data = graphData["chart_data"] + +// Set graph dimensions +let width = document.getElementById('graph').clientWidth; +let height = document.getElementById('graph').clientHeight; + +// Re set the width and height when the window resizes +window.onresize = function(){ + width = document.getElementById('graph').clientWidth; + height = document.getElementById('graph').clientHeight; + svg.attr('width', width).attr('height', height); +} + +const svg = d3.select('#graph').append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", "0 0 " + width + " " + height) + .attr("preserveAspectRatio", "none") + +// Add a 'graph' group to the svg +const graph = svg.append("g") + .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")"); + +// Add 'labels' group to the graph +graph.append("g") + .attr("class", "labels") + +// Define margins around graph for labels +const margin = { top: 20, right: 20, bottom: 20, left: 20 }; +// Define the graph width and height to account for margin +const gWidth = width - margin.left - margin.right; +const gHeight = height - margin.top - margin.bottom; + +// Define the radius of the pie_chart (half the graph) +const radius = Math.min(gWidth, gHeight) / 2 + +// Define the 'arc' (i.e. the curve/radius of the pie) +const arc = d3.arc() + .outerRadius(radius * 0.8) + .innerRadius(radius * 0.5); + +// The 'labels' will be on a circle with a greater radius than the pie (i.e. just outside) +const labelArc = d3.arc() + .innerRadius(radius) + .outerRadius(radius); + + +// ------EVENT LISTENERS------ +// When the axes are altered, we need to re-group the data depending on the variables set +axesSettings.forEach(setting => { + setting.onchange = function(){ + axisChange() + } +}) + +// If the variable is not an empty string on page load (when user editing a graph) +if(variableSelect.options[variableSelect.selectedIndex].value != ''){ + axisChange() +} + +// Export button that allows user to export and download the SVG as a PNG image +exportButton.addEventListener("click", () => { + let title = document.querySelector(".title").value + let exportTitle = title == "" ? "plot.png": `${title}.png` + saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); +}) + +// Called whenever the axis variables change. Handles display of DOM elements before drawing graph +function axisChange (){ + let variableValue = variableSelect.options[variableSelect.selectedIndex].value; + let againstValue = againstSelect.options[againstSelect.selectedIndex].value; + let againstAgg = againstAggDOM.options[againstAggDOM.selectedIndex].value; + + // Hide the overlay + emptyGraph.classList.remove("visible"); + emptyGraph.classList.add("invisible"); + + // Remove the ' -- select an option -- ' option + variableSelect.firstChild.hidden = true; + // Reveal the y-axis variable for the user to select + againstSection.classList.remove('hidden-down') + + + // If the chosen y variable is equal to 'Amount' then we don't want to give the user the option to perform data aggregations + if(againstValue != 'Amount'){ + aggregate.classList.remove('hidden-down') + aggregate.classList.add('visible') + } else{ + aggregate.classList.remove('visible') + aggregate.classList.add('hidden-down') + } + // A function that carries ou the grouping, based on the chosen settings + let groupedData = groupData(variableValue, againstValue); + + // re-draw the graph with the chosen variables + render(groupedData, variableValue, againstValue, againstAgg); +} + + + +// Data grouping function. Called when an axis variable is changed +function groupData(variableValue, againstValue){ + // We can create a 'nested' D3 object, with the key as the chosen x-axis variable + let nestedData = d3.nest().key(function(d) { return d[variableValue]; }) + + // If the y axis is just to count the values, we can group and perform a roll up on the calculation of the length + if(againstValue == "Amount"){ + return nestedData + .rollup(function(v) { return v.length; }) + .entries(data) + } + // Else, we need to see which y-axis aggregation was chosen + let againstAgg = againstAggDOM.options[againstAggDOM.selectedIndex].value; + if(againstAgg == "Average"){ + return nestedData + .rollup(function(v) { return d3.mean(v, function(d) { return d[againstValue]; }); }) + .entries(data) + } + if(againstAgg == "Highest"){ + return nestedData + .rollup(function(v) { return d3.max(v, function(d) { return d[againstValue]; }); }) + .entries(data) + } + if(againstAgg == "Lowest"){ + return nestedData + .rollup(function(v) { return d3.min(v, function(d) { return d[againstValue]; }); }) + .entries(data) + } + if(againstAgg == "Sum"){ + return nestedData + .rollup(function(v) { return d3.sum(v, function(d) { return d[againstValue]; }); }) + .entries(data) + } +} + +// Function that draws the pie chart +function render(groupedData, variableValue, againstValue, againstAgg) { + // Specify the values and keys to be used by the graph + const keys = d => d.data.data.key; + const values = d => d.value; + + // set the colour scale, using D3 spectral scheme + let colour = d3.scaleOrdinal(d3.schemeSet3) + .domain(groupedData) + + // Compute the position of each group on the pie: + let pie = d3.pie() + .value(values) + .sort(null); + + let pieData = pie(groupedData) + + // Add percentages to pieData + let total = d3.sum(pieData, values); + const percentage = d => Math.round((d.value / total) * 100) + "%"; + + // map the segments to the data + let segments = graph.selectAll("path") + .data(pieData) + + // Initialise each segment + let initSegment = segments + .enter() + .append('path') + + setTooltip(initSegment) + + // Finally merge the segments + initSegment.merge(segments) + .transition() + .duration(750) + // Custom function for transitioning, needed for pie charts due to the 'sweep-flag' and 'large-arc-flag' + // See stackoverflow here https://stackoverflow.com/questions/21285385/d3-pie-chart-arc-is-invisible-in-transition-to-180 + .attrTween("d", function(d) { + let transition = d3.interpolate(this.angle, d); + this.angle = transition(0); + return function(t) { + return arc(transition(t)); + }; + }) + .attr('fill', function(d){ return(colour(d.data.key)) }) + .attr("stroke", "white") + .style("stroke-width", "4px") + .style("opacity", 1) + + // remove the groups that are not present anymore + segments + .exit() + .remove() + + // Add the labels next to the piechart + let text = graph.select(".labels").selectAll("text") + .data(pie(pieData)); + + text.enter() + .append("text") + .style("font-size", "0.8rem") + .merge(text) + .transition() + .duration(750) + .text(percentage) + .attr("transform", function(d) {return "translate(" + labelArc.centroid(d) + ")"; }) + .style("text-anchor", "middle") + + text.exit() + .remove(); + + // And now add the legend title + let legendTitle = againstValue == 'Amount' ? variableValue : `${againstAgg} ${againstValue} of ${variableValue}` + + // Add the legend with the specified title and associated colours + addLegend(legendTitle, colour, pieData, percentage) +} + + + +function setTooltip(initSegment){ + // Then add the tooltip on hover affect + initSegment.on("mouseenter", function(d){ + d3.select(this) + .transition() + .duration(200) + .style('opacity', '0.5') + + d3.select(".graph-tooltip") + .style("left", d3.event.pageX + 20 + "px") + .style("top", d3.event.pageY + "px") + .style("opacity", 1) + // .classed("tooltip-hidden", false) + .select(".tooltip-value") + .text(d.data.key + ": " + d.value) + }) + .on('mouseout', function() { + d3.select(this) + .transition() + .duration(200) + .style('opacity', '1') + + d3.select(".graph-tooltip") + .style("opacity", 0) + }) +} + + +// Add the legend, corresponding to the pie chart +function addLegend(legendTitle, colour, pieData, percentage){ + // Define size for the legend. These numbers fit nicely on the chart + let legendRectSize = 18; + let legendSpacing = 4; + let legendFontsize = "1rem"; + + // THE FIRST ELEMENT IN COLOUR.DOMAIN() IS AN UNWANTED OBJECT SO IT IS REMOVED + let legendData = colour.domain().slice(1) + // If there are lots of elements in the pie chart (22 is cutoff that can fit) then + // half the size of the legend elements + if(legendData.length > 22){ + legendRectSize = legendRectSize/2 + legendSpacing = legendSpacing/2 + legendFontsize = "0.5rem" + } + // Remove the legend and title before redrawing it + svg.selectAll(".legend").remove() + svg.selectAll(".legend-title").remove() + + let legend = graph.selectAll("#graph") + .data(legendData) + .enter() + .append('g') + .attr('class', 'legend') + .attr('transform', function(d, i) { + let height = legendRectSize + legendSpacing; + // The vertical position is distance from the center + // I also add 1 x 'height' onto the value to make space for legend title + let vert = height + (i * height - (gHeight/2)); + return 'translate(' + (-gWidth/2) + ',' + vert + ')'; + }); + + // Add the title for the legend + graph.append("text") + .attr("class", "legend-title") + .attr("x", -gWidth / 2) + .attr("y", -gHeight / 2) + .attr("text-anchor", "left") + .style("font-size", "1rem") + .style("text-decoration", "underline") + .text(legendTitle); + + // Add the 'rect' for the coloured squares + legend.append('rect') + .attr('width', legendRectSize) + .attr('height', legendRectSize) + .style('fill', colour) + .style('stroke', colour) + + // Add the text for each variable in the legend + legend.append('text') + .attr('x', legendRectSize + legendSpacing) + .attr('y', legendRectSize - legendSpacing) + .style("font-size", legendFontsize) + .text(d => d) +} + + +// When the form is submitted, we want to get a jpg image of the svg +$('form').submit(function (e) { + // prevent default form submission + e.preventDefault(); + // call function to post form (separate js file) + postgraph(width, height) +}); diff --git a/site/surveyapp/static/graphscripts/postgraph.js b/site/surveyapp/static/graphscripts/postgraph.js new file mode 100644 index 0000000..f8d5ca5 --- /dev/null +++ b/site/surveyapp/static/graphscripts/postgraph.js @@ -0,0 +1,51 @@ + + +function postgraph(width, height){ + const svgNode = document.getElementsByTagName("svg")[0] + + const doctype = '' + + ''; + // serialise the graph svg + const source = (new XMLSerializer()).serializeToString(svgNode); + // convert to Blob + const blob = new Blob([ doctype + source], { type: 'image/svg+xml' }); + const imageURL = window.URL.createObjectURL(blob); + const img = new Image(); + + img.onload = async function(){ + let canvas = d3.select('body').append('canvas').node(); + canvas.width = width; + canvas.height = height; + + let ctx = canvas.getContext('2d'); + + // draw image on canvas + ctx.drawImage(img, 0, 0, width, height); + + let glContextAttributes = { preserveDrawingBuffer: true }; + let gl = canvas.getContext("experimental-webgl", glContextAttributes); + + + let imgData = await canvas.toDataURL("image/png"); + canvas.remove(); + // ajax call to send canvas(base64) url to server. + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrf) + } + } + }) + let postData = $('form').serializeArray() + postData.push({name: "image", value: imgData}) + $.ajax({ + type: "POST", + url: url, + data: postData, + success: function () { + window.location.href = redirectUrl; + } + }); + } + img.src = imageURL; +} diff --git a/site/surveyapp/static/graphscripts/saveSvgAsPng.js b/site/surveyapp/static/graphscripts/saveSvgAsPng.js new file mode 100644 index 0000000..73842ba --- /dev/null +++ b/site/surveyapp/static/graphscripts/saveSvgAsPng.js @@ -0,0 +1,408 @@ +(function() { + const out$ = typeof exports != 'undefined' && exports || typeof define != 'undefined' && {} || this || window; + if (typeof define !== 'undefined') define('save-svg-as-png', [], () => out$); + out$.default = out$; + + const xmlNs = 'http://www.w3.org/2000/xmlns/'; + const xhtmlNs = 'http://www.w3.org/1999/xhtml'; + const svgNs = 'http://www.w3.org/2000/svg'; + const doctype = ']>'; + const urlRegex = /url\(["']?(.+?)["']?\)/; + const fontFormats = { + woff2: 'font/woff2', + woff: 'font/woff', + otf: 'application/x-font-opentype', + ttf: 'application/x-font-ttf', + eot: 'application/vnd.ms-fontobject', + sfnt: 'application/font-sfnt', + svg: 'image/svg+xml' + }; + + const isElement = obj => obj instanceof HTMLElement || obj instanceof SVGElement; + const requireDomNode = el => { + if (!isElement(el)) throw new Error(`an HTMLElement or SVGElement is required; got ${el}`); + }; + const requireDomNodePromise = el => + new Promise((resolve, reject) => { + if (isElement(el)) resolve(el) + else reject(new Error(`an HTMLElement or SVGElement is required; got ${el}`)); + }) + const isExternal = url => url && url.lastIndexOf('http',0) === 0 && url.lastIndexOf(window.location.host) === -1; + + const getFontMimeTypeFromUrl = fontUrl => { + const formats = Object.keys(fontFormats) + .filter(extension => fontUrl.indexOf(`.${extension}`) > 0) + .map(extension => fontFormats[extension]); + if (formats) return formats[0]; + console.error(`Unknown font format for ${fontUrl}. Fonts may not be working correctly.`); + return 'application/octet-stream'; + }; + + const arrayBufferToBase64 = buffer => { + let binary = ''; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return window.btoa(binary); + } + + const getDimension = (el, clone, dim) => { + const v = + (el.viewBox && el.viewBox.baseVal && el.viewBox.baseVal[dim]) || + (clone.getAttribute(dim) !== null && !clone.getAttribute(dim).match(/%$/) && parseInt(clone.getAttribute(dim))) || + el.getBoundingClientRect()[dim] || + parseInt(clone.style[dim]) || + parseInt(window.getComputedStyle(el).getPropertyValue(dim)); + return typeof v === 'undefined' || v === null || isNaN(parseFloat(v)) ? 0 : v; + }; + + const getDimensions = (el, clone, width, height) => { + if (el.tagName === 'svg') return { + width: width || getDimension(el, clone, 'width'), + height: height || getDimension(el, clone, 'height') + }; + else if (el.getBBox) { + const {x, y, width, height} = el.getBBox(); + return { + width: x + width, + height: y + height + }; + } + }; + + const reEncode = data => + decodeURIComponent( + encodeURIComponent(data) + .replace(/%([0-9A-F]{2})/g, (match, p1) => { + const c = String.fromCharCode(`0x${p1}`); + return c === '%' ? '%25' : c; + }) + ); + + const uriToBlob = uri => { + const byteString = window.atob(uri.split(',')[1]); + const mimeString = uri.split(',')[0].split(':')[1].split(';')[0] + const buffer = new ArrayBuffer(byteString.length); + const intArray = new Uint8Array(buffer); + for (let i = 0; i < byteString.length; i++) { + intArray[i] = byteString.charCodeAt(i); + } + return new Blob([buffer], {type: mimeString}); + }; + + const query = (el, selector) => { + if (!selector) return; + try { + return el.querySelector(selector) || el.parentNode && el.parentNode.querySelector(selector); + } catch(err) { + console.warn(`Invalid CSS selector "${selector}"`, err); + } + }; + + const detectCssFont = (rule, href) => { + // Match CSS font-face rules to external links. + // @font-face { + // src: local('Abel'), url(https://fonts.gstatic.com/s/abel/v6/UzN-iejR1VoXU2Oc-7LsbvesZW2xOQ-xsNqO47m55DA.woff2); + // } + const match = rule.cssText.match(urlRegex); + const url = (match && match[1]) || ''; + if (!url || url.match(/^data:/) || url === 'about:blank') return; + const fullUrl = + url.startsWith('../') ? `${href}/../${url}` + : url.startsWith('./') ? `${href}/.${url}` + : url; + return { + text: rule.cssText, + format: getFontMimeTypeFromUrl(fullUrl), + url: fullUrl + }; + }; + + const inlineImages = el => Promise.all( + Array.from(el.querySelectorAll('image')).map(image => { + let href = image.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || image.getAttribute('href'); + if (!href) return Promise.resolve(null); + if (isExternal(href)) { + href += (href.indexOf('?') === -1 ? '?' : '&') + 't=' + new Date().valueOf(); + } + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.src = href; + img.onerror = () => reject(new Error(`Could not load ${href}`)); + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext('2d').drawImage(img, 0, 0); + image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', canvas.toDataURL('image/png')); + resolve(true); + }; + }); + }) + ); + + const cachedFonts = {}; + const inlineFonts = fonts => Promise.all( + fonts.map(font => + new Promise((resolve, reject) => { + if (cachedFonts[font.url]) return resolve(cachedFonts[font.url]); + + const req = new XMLHttpRequest(); + req.addEventListener('load', () => { + // TODO: it may also be worth it to wait until fonts are fully loaded before + // attempting to rasterize them. (e.g. use https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet) + const fontInBase64 = arrayBufferToBase64(req.response); + const fontUri = font.text.replace(urlRegex, `url("data:${font.format};base64,${fontInBase64}")`)+'\n'; + cachedFonts[font.url] = fontUri; + resolve(fontUri); + }); + req.addEventListener('error', e => { + console.warn(`Failed to load font from: ${font.url}`, e); + cachedFonts[font.url] = null; + resolve(null); + }); + req.addEventListener('abort', e => { + console.warn(`Aborted loading font from: ${font.url}`, e); + resolve(null); + }); + req.open('GET', font.url); + req.responseType = 'arraybuffer'; + req.send(); + }) + ) + ).then(fontCss => fontCss.filter(x => x).join('')); + + let cachedRules = null; + const styleSheetRules = () => { + if (cachedRules) return cachedRules; + return cachedRules = Array.from(document.styleSheets).map(sheet => { + try { + return {rules: sheet.cssRules, href: sheet.href}; + } catch (e) { + console.warn(`Stylesheet could not be loaded: ${sheet.href}`, e); + return {}; + } + }); + }; + + const inlineCss = (el, options) => { + const { + selectorRemap, + modifyStyle, + modifyCss, + fonts, + excludeUnusedCss + } = options || {}; + const generateCss = modifyCss || ((selector, properties) => { + const sel = selectorRemap ? selectorRemap(selector) : selector; + const props = modifyStyle ? modifyStyle(properties) : properties; + return `${sel}{${props}}\n`; + }); + const css = []; + const detectFonts = typeof fonts === 'undefined'; + const fontList = fonts || []; + styleSheetRules().forEach(({rules, href}) => { + if (!rules) return; + Array.from(rules).forEach(rule => { + if (typeof rule.style != 'undefined') { + if (query(el, rule.selectorText)) css.push(generateCss(rule.selectorText, rule.style.cssText)); + else if (detectFonts && rule.cssText.match(/^@font-face/)) { + const font = detectCssFont(rule, href); + if (font) fontList.push(font); + } else if (!excludeUnusedCss) { + css.push(rule.cssText); + } + } + }); + }); + + return inlineFonts(fontList).then(fontCss => css.join('\n') + fontCss); + }; + + const downloadOptions = () => { + if (!navigator.msSaveOrOpenBlob && !('download' in document.createElement('a'))) { + return {popup: window.open()}; + } + }; + + out$.prepareSvg = (el, options, done) => { + requireDomNode(el); + const { + left = 0, + top = 0, + width: w, + height: h, + scale = 1, + responsive = false, + excludeCss = false, + } = options || {}; + + return inlineImages(el).then(() => { + let clone = el.cloneNode(true); + clone.style.backgroundColor = (options || {}).backgroundColor || el.style.backgroundColor; + const {width, height} = getDimensions(el, clone, w, h); + + if (el.tagName !== 'svg') { + if (el.getBBox) { + if (clone.getAttribute('transform') != null) { + clone.setAttribute('transform', clone.getAttribute('transform').replace(/translate\(.*?\)/, '')); + } + const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); + svg.appendChild(clone); + clone = svg; + } else { + console.error('Attempted to render non-SVG element', el); + return; + } + } + + clone.setAttribute('version', '1.1'); + clone.setAttribute('viewBox', [left, top, width, height].join(' ')); + if (!clone.getAttribute('xmlns')) clone.setAttributeNS(xmlNs, 'xmlns', svgNs); + if (!clone.getAttribute('xmlns:xlink')) clone.setAttributeNS(xmlNs, 'xmlns:xlink', 'http://www.w3.org/1999/xlink'); + + if (responsive) { + clone.removeAttribute('width'); + clone.removeAttribute('height'); + clone.setAttribute('preserveAspectRatio', 'xMinYMin meet'); + } else { + clone.setAttribute('width', width * scale); + clone.setAttribute('height', height * scale); + } + + Array.from(clone.querySelectorAll('foreignObject > *')).forEach(foreignObject => { + foreignObject.setAttributeNS(xmlNs, 'xmlns', foreignObject.tagName === 'svg' ? svgNs : xhtmlNs); + }); + + if (excludeCss) { + const outer = document.createElement('div'); + outer.appendChild(clone); + const src = outer.innerHTML; + if (typeof done === 'function') done(src, width, height); + else return {src, width, height}; + } else { + return inlineCss(el, options).then(css => { + const style = document.createElement('style'); + style.setAttribute('type', 'text/css'); + style.innerHTML = ``; + + const defs = document.createElement('defs'); + defs.appendChild(style); + clone.insertBefore(defs, clone.firstChild); + + const outer = document.createElement('div'); + outer.appendChild(clone); + const src = outer.innerHTML.replace(/NS\d+:href/gi, 'xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href'); + + if (typeof done === 'function') done(src, width, height); + else return {src, width, height}; + }); + } + }); + }; + + out$.svgAsDataUri = (el, options, done) => { + requireDomNode(el); + return out$.prepareSvg(el, options) + .then(({src, width, height}) => { + const svgXml = `data:image/svg+xml;base64,${window.btoa(reEncode(doctype+src))}`; + if (typeof done === 'function') { + done(svgXml, width, height); + } + return svgXml; + }); + }; + + out$.svgAsPngUri = (el, options, done) => { + requireDomNode(el); + const { + encoderType = 'image/png', + encoderOptions = 0.8, + canvg + } = options || {}; + + const convertToPng = ({src, width, height}) => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + const pixelRatio = window.devicePixelRatio || 1; + + canvas.width = width * pixelRatio; + canvas.height = height * pixelRatio; + canvas.style.width = `${canvas.width}px`; + canvas.style.height = `${canvas.height}px`; + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + + if (canvg) canvg(canvas, src); + else context.drawImage(src, 0, 0); + + let png; + try { + png = canvas.toDataURL(encoderType, encoderOptions); + } catch (e) { + if ((typeof SecurityError !== 'undefined' && e instanceof SecurityError) || e.name === 'SecurityError') { + console.error('Rendered SVG images cannot be downloaded in this browser.'); + return; + } else throw e; + } + if (typeof done === 'function') done(png, canvas.width, canvas.height); + return Promise.resolve(png); + } + + if (canvg) return out$.prepareSvg(el, options).then(convertToPng); + else return out$.svgAsDataUri(el, options).then(uri => { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(convertToPng({ + src: image, + width: image.width, + height: image.height + })); + image.onerror = () => { + reject(`There was an error loading the data URI as an image on the following SVG\n${window.atob(uri.slice(26))}Open the following link to see browser's diagnosis\n${uri}`); + } + image.src = uri; + }) + }); + }; + + out$.download = (name, uri, options) => { + if (navigator.msSaveOrOpenBlob) navigator.msSaveOrOpenBlob(uriToBlob(uri), name); + else { + const saveLink = document.createElement('a'); + if ('download' in saveLink) { + saveLink.download = name; + saveLink.style.display = 'none'; + document.body.appendChild(saveLink); + try { + const blob = uriToBlob(uri); + const url = URL.createObjectURL(blob); + saveLink.href = url; + saveLink.onclick = () => requestAnimationFrame(() => URL.revokeObjectURL(url)); + } catch (e) { + console.error(e); + console.warn('Error while getting object URL. Falling back to string URL.'); + saveLink.href = uri; + } + saveLink.click(); + document.body.removeChild(saveLink); + } else if (options && options.popup) { + options.popup.document.title = name; + options.popup.location.replace(uri); + } + } + }; + + out$.saveSvg = (el, name, options) => { + const downloadOpts = downloadOptions(); // don't inline, can't be async + return requireDomNodePromise(el) + .then(el => out$.svgAsDataUri(el, options || {})) + .then(uri => out$.download(name, uri, downloadOpts)); + }; + + out$.saveSvgAsPng = (el, name, options) => { + const downloadOpts = downloadOptions(); // don't inline, can't be async + return requireDomNodePromise(el) + .then(el => out$.svgAsPngUri(el, options || {})) + .then(uri => out$.download(name, uri, downloadOpts)); + }; +})(); diff --git a/site/surveyapp/static/graphscripts/scatterchart.js b/site/surveyapp/static/graphscripts/scatterchart.js new file mode 100644 index 0000000..2b16263 --- /dev/null +++ b/site/surveyapp/static/graphscripts/scatterchart.js @@ -0,0 +1,324 @@ +// VARiABLE DECLARATIONS +// Get the DOM elements for all of the axes, so that we can add event listeners for when they are changed +const axesSettings = document.querySelectorAll(".axis-setting") +const xAxisSelect = document.querySelector(".x-axis-value") +const yAxisSelect = document.querySelector(".y-axis-value") +const yAxisDetails = document.querySelector(".y-axis-details") +const emptyGraph = document.querySelector(".empty-graph") +const exportButton = document.querySelector(".export") +// Connecting line (if user wants to make a line graph) +const addLine = document.querySelector(".add-line") + +const axesRange = document.querySelectorAll(".axis-range") +// x and y axis ranges +const xFrom = document.querySelector(".x-from") +const yFrom = document.querySelector(".y-from") +const xTo = document.querySelector(".x-to") +const yTo = document.querySelector(".y-to") +// Colours for our graph +const fill = "steelblue" +const hoverFill = "#2D4053" +const stroke = "steelblue" + +// Get the graph data +const data = graphData["chart_data"] + + + + +// Set graph dimensions +var width = document.getElementById('graph').clientWidth; +var height = document.getElementById('graph').clientHeight; +// Re set graph size when window changes size +window.onresize = function(){ + width = document.getElementById('graph').clientWidth; + height = document.getElementById('graph').clientHeight; + svg.attr('width', width).attr('height', height); +} +// Set margins around graph for axis and labels +const margin = { top: 20, right: 20, bottom: 60, left: 80 }; +// Set the graph width and height to account for axes +const gWidth = width - margin.left - margin.right; +const gHeight = height - margin.top - margin.bottom; +// Create SVG ready for graph +const svg = d3.select('#graph').append("svg").attr("width", width).attr("height", height).attr("viewBox", "0 0 " + width + " " + height).attr("preserveAspectRatio", "none") +// Add the graph area to the SVG, factoring in the margin dimensions +var graph = svg.append('g').attr("transform", `translate(${margin.left}, ${margin.top})`) + + + +// EVENT LISTENERS +// Function required to activate the 'help' tooltip on the axis +$(function () { + $("[data-toggle='help']").tooltip(); +}); + +// If the x-axis is not empty (i.e. if user is editing graph) then call function immediately +if(xAxisSelect.options[xAxisSelect.selectedIndex].value != ''){ + axisChange() +} + +// Export button that allows user to export and download the SVG as a PNG image +exportButton.addEventListener("click", () => { + let title = document.querySelector(".title").value + let exportTitle = title == "" ? "plot.png": `${title}.png` + saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); +}) + + +// When the axes are altered, we need to re-group the data depending on the variables set +axesSettings.forEach(setting => { + setting.onchange = function(){ + axisChange() + } +}) + +// Whenever the range changes we want to draw the graph (without having to re-get the data) +axesRange.forEach(input => { + input.onchange = function() { + render(data) + } +}) + +// Event listener for adding a connecting line +addLine.addEventListener("change", function(){ + if(this.checked){ + d3.selectAll('.graph-line').transition().duration(1000).style("visibility", "visible"); + } else { + d3.selectAll('.graph-line').transition().duration(1000).style("visibility", "hidden"); + } + render(data) +}) + + +// FUNCTIONS FOR RENDERING GRAPH +function axisChange (){ + let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; + let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; + + // Remove the ' -- select an option -- ' option + xAxisSelect.firstChild.hidden = true; + yAxisSelect.firstChild.hidden = true; + + // Reveal the y-axis variable for the user to select + yAxisDetails.classList.remove('hidden-down') + + // If the user has selected variables for both the x and the y axes + if (xAxisValue != "" && yAxisValue != ""){ + // Make the overlay hidden + emptyGraph.classList.remove("visible"); + emptyGraph.classList.add("invisible"); + addLine.disabled = false; + // re-draw the graph with the chosen variables + render(data); + } +} + + +function render(data){ + let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; + let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; + + // Specify the x-axis values and the y-axis values + const xValues = d => d[xAxisValue]; + const yValues = d => d[yAxisValue]; + + // Remove old axes labels (if they exist) + d3.selectAll('.label').remove(); + + // sort the grouped data keys in ascending order (i.e. so the x-axis is in numerical order) + // data.sort(function(a, b) { return d3.ascending(parseInt(a.key), parseInt(b.key))}); + data.sort(function(a, b) { + return d3.ascending(parseInt(a[xAxisValue]), parseInt(b[xAxisValue])) + }); + + // set the input fields for the domain (i.e. range of values) if not yet set + if(xFrom.value == "") xFrom.value = d3.min(data, xValues) + if(xTo.value == "") xTo.value = d3.max(data, xValues) + if(yFrom.value == "") yFrom.value = d3.min(data, yValues) + if(yTo.value == "") yTo.value = d3.max(data, yValues) + + // Now extract the range from the values (if they are specifed by user) + // If the values specified by the user are outside the range of the data, increase the range + // else use the range of the data as default. + xFromValue = xFrom.value = Math.min(d3.min(data, xValues), xFrom.value) + xToValue = xTo.value = Math.max(d3.max(data, xValues), xTo.value) + yFromValue = yFrom.value = Math.min(d3.min(data, yValues), yFrom.value) + yToValue = yTo.value = Math.max(d3.max(data, yValues), yTo.value) + + // Reveal the 'add-line' select option + document.querySelector(".form-add-line").classList.remove("invisible") + + // Set the scale for the x-axis + const xScale = d3.scaleLinear() + .domain([xFromValue, xToValue]).nice() + .range([0, gWidth]) + + // Set the scale for the y-axis + const yScale = d3.scaleLinear() + .domain([yFromValue, yToValue]) + .range([gHeight, 0]) + + + // Select the axes (if they exist) + var yAxis = d3.selectAll(".yAxis") + var xAxis = d3.selectAll(".xAxis") + + // Get the position of the axes. Either set to 0 or set to the far left/bottom + xPosition = (0 > yFromValue && 0 < yToValue) ? yScale(0) : gHeight + yPosition = (0 > xFromValue && 0 < xToValue) ? xScale(0) : 0 + + + // If they dont exist, we create them. If they do, we update them + if (yAxis.empty() && xAxis.empty()){ + // For the x-axis we create an axisBottom and 'translate' it so it appears on the bottom of the graph + graph.append('g').attr("class", "xAxis").call(d3.axisBottom(xScale)) + .attr("transform", `translate(0, ${xPosition})`) + // For why axis we do not need to translate it, as the default is on the left + graph.append('g').attr("class", "yAxis").call(d3.axisLeft(yScale)) + .attr("transform", `translate(${yPosition}, 0)`) + } else { + // Adjust the x-axis according the x-axis variable data + xAxis.transition() + .duration(1000) + .call(d3.axisBottom(xScale)) + .attr("transform", `translate(0, ${xPosition})`) + // Adjust the y-axis according the y-axis variable data + yAxis.transition() + .duration(1000) + .call(d3.axisLeft(yScale)) + .attr("transform", `translate(${yPosition}, 0)`) + } + + + // Add y axis label + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("class", "label") + .attr("y", 0) + .attr("x",0 - (gHeight / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yAxisValue); + + + + // Add x axis label + svg.append("text") + .attr("transform",`translate(${width/2}, ${gHeight + margin.top + 55})`) + .attr("class", "label") + .style("text-anchor", "middle") + .text(xAxisValue); + + + var line = d3.line() + .x(d => xScale(xValues(d))) + .y(d => yScale(yValues(d))) + // .curve(d3.curveCatmullRom.alpha(0.5)); + + var startingLine = d3.line() + .x(d => xScale(xValues(d))) + .y(d => yScale(0)) + + + var path = graph.selectAll('.graph-line').data(data) + + if(addLine.checked == true){ + path + .enter() + .append("path") + .attr("class","graph-line") + .merge(path) + .transition() + .duration(1000) + .attr("d", line(data)) + .attr("fill", "none") + .attr("stroke", stroke) + .attr("stroke-width", 2.5) + } + + + + // Select all 'circle' DOM elements (if they exist) + var circle = graph.selectAll('circle').data(data) + + // D3 'exit()' is what happens to DOM elements that no longer have data bound to them + // Given a transition that shrinks them down to the x-axis + circle.exit().transition() + .duration(1000) + .attr("cy", yScale(0)) + .remove() + + // D3 'enter()' is the creation of DOM elements bound to the data + var plot = circle.enter() + .append('circle') + .attr("cy", yScale(0)) + .attr('cx', d => xScale(xValues(d))) + .attr("r", 3) + .style('fill', fill) + + setTooltip(plot, yValues) + + plot.merge(circle) // 'merge' merges the 'enter' and 'update' groups + .transition() + .delay(d => xScale(xValues(d))/2 ) + .duration(1000) + .attr('cy', d=> yScale(yValues(d))) + .attr('cx', d => xScale(xValues(d))) + +} + +function setTooltip(plot, yValues){ + plot.on('mouseenter', function(d) { + d3.select(this) + .transition() + .duration(100) + .style('fill', hoverFill) + + var tooltipOffset = (d3.select(this).attr("width") - 80)/2; + + var tooltip = d3.select(".graph-tooltip") + + // To position the tool tip when the user hovers. Use the window and calculate the offset + var position = this.getScreenCTM() + .translate(+ this.getAttribute("cx"), + this.getAttribute("cy")); + + // Now give the tooltip the data it needs to show and the position it should be. + tooltip.html(yValues(d)) + .style("left", (window.pageXOffset + position.e + tooltipOffset) + "px") // Center it horizontally over the plot + .style("top", (window.pageYOffset + position.f - 80) + "px"); // Shift it 40 px above the plot + + tooltip.classed("tooltip-hidden", false) + + }).on('mouseout', function() { + d3.select(this) + .transition() + .duration(100) + .style('fill', fill) + + d3.select(".graph-tooltip").classed("tooltip-hidden", true); + }) + +} + + + + + + +// JQUERY functions +// Function that will confirm user input when they press enter, without submitting the form +$('body').on('keydown', 'input, select', function(e) { + if (e.key === "Enter") { + $(this).blur() + } +}); + + +// When the form is submitted, we want to get a jpg image of the svg +$('form').submit(function (e) { + // prevent default form submission + e.preventDefault(); + // call function to post form (separate js file) + postgraph(width, height) +}); diff --git a/site/surveyapp/static/home.js b/site/surveyapp/static/home.js new file mode 100644 index 0000000..ffa1089 --- /dev/null +++ b/site/surveyapp/static/home.js @@ -0,0 +1,26 @@ +"use strict"; + +const surveys = document.querySelectorAll(".survey"); +const search = document.getElementById("search"); + +// Search bar +search.addEventListener('keyup', function(){ + filterSurveys(search.value.toLowerCase()) +}); + +function filterSurveys(searchValue){ + surveys.forEach(survey => { + let name = survey.dataset.name.toLowerCase() + if(name.includes(searchValue)){ + survey.style.display = "block"; + }else{ + survey.style.display = "none"; + } + }) +} + + +// Using Jquery, initialise Popper.js tooltips +$(function () { + $("[data-toggle='tooltip']").tooltip(); +}); diff --git a/site/surveyapp/static/images/0d3647173cc2db3b8f8c4e8eded4dcad.jpg b/site/surveyapp/static/images/0d3647173cc2db3b8f8c4e8eded4dcad.jpg new file mode 100644 index 0000000..5bd5df3 Binary files /dev/null and b/site/surveyapp/static/images/0d3647173cc2db3b8f8c4e8eded4dcad.jpg differ diff --git a/site/surveyapp/static/images/graphimages/.gitkeep b/site/surveyapp/static/images/graphimages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/static/images/graphimages/224a92293ef30246.png b/site/surveyapp/static/images/graphimages/224a92293ef30246.png new file mode 100644 index 0000000..24d3cad Binary files /dev/null and b/site/surveyapp/static/images/graphimages/224a92293ef30246.png differ diff --git a/site/surveyapp/static/images/graphimages/7b330761be6bd797.png b/site/surveyapp/static/images/graphimages/7b330761be6bd797.png new file mode 100644 index 0000000..0b04cf6 Binary files /dev/null and b/site/surveyapp/static/images/graphimages/7b330761be6bd797.png differ diff --git a/site/surveyapp/static/images/graphimages/ce215585871c14cd.png b/site/surveyapp/static/images/graphimages/ce215585871c14cd.png new file mode 100644 index 0000000..d0d969d Binary files /dev/null and b/site/surveyapp/static/images/graphimages/ce215585871c14cd.png differ diff --git a/site/surveyapp/static/images/graphimages/e43c10e56a08a902.png b/site/surveyapp/static/images/graphimages/e43c10e56a08a902.png new file mode 100644 index 0000000..89f5d62 Binary files /dev/null and b/site/surveyapp/static/images/graphimages/e43c10e56a08a902.png differ diff --git a/site/surveyapp/static/images/graphimages/fec14876222e8931.png b/site/surveyapp/static/images/graphimages/fec14876222e8931.png new file mode 100644 index 0000000..76c290a Binary files /dev/null and b/site/surveyapp/static/images/graphimages/fec14876222e8931.png differ diff --git a/site/surveyapp/static/images/siteimages/barchart.png b/site/surveyapp/static/images/siteimages/barchart.png new file mode 100644 index 0000000..5e60e97 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/barchart.png differ diff --git a/site/surveyapp/static/images/siteimages/boxchart.png b/site/surveyapp/static/images/siteimages/boxchart.png new file mode 100644 index 0000000..312be69 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/boxchart.png differ diff --git a/site/surveyapp/static/images/siteimages/favicon.png b/site/surveyapp/static/images/siteimages/favicon.png new file mode 100644 index 0000000..e1fe2f3 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/favicon.png differ diff --git a/site/surveyapp/static/images/siteimages/histogram.png b/site/surveyapp/static/images/siteimages/histogram.png new file mode 100644 index 0000000..1b5c225 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/histogram.png differ diff --git a/site/surveyapp/static/images/siteimages/landing2.png b/site/surveyapp/static/images/siteimages/landing2.png new file mode 100644 index 0000000..28a2714 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/landing2.png differ diff --git a/site/surveyapp/static/images/siteimages/landing3.png b/site/surveyapp/static/images/siteimages/landing3.png new file mode 100644 index 0000000..588fb89 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/landing3.png differ diff --git a/site/surveyapp/static/images/siteimages/landing4.png b/site/surveyapp/static/images/siteimages/landing4.png new file mode 100644 index 0000000..aea8680 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/landing4.png differ diff --git a/site/surveyapp/static/images/siteimages/landing5.png b/site/surveyapp/static/images/siteimages/landing5.png new file mode 100644 index 0000000..e3215bf Binary files /dev/null and b/site/surveyapp/static/images/siteimages/landing5.png differ diff --git a/site/surveyapp/static/images/siteimages/logo.png b/site/surveyapp/static/images/siteimages/logo.png new file mode 100644 index 0000000..4c42beb Binary files /dev/null and b/site/surveyapp/static/images/siteimages/logo.png differ diff --git a/site/surveyapp/static/images/siteimages/logo2.png b/site/surveyapp/static/images/siteimages/logo2.png new file mode 100644 index 0000000..a320bd3 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/logo2.png differ diff --git a/site/surveyapp/static/images/siteimages/map.png b/site/surveyapp/static/images/siteimages/map.png new file mode 100644 index 0000000..da3f739 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/map.png differ diff --git a/site/surveyapp/static/images/siteimages/piechart.png b/site/surveyapp/static/images/siteimages/piechart.png new file mode 100644 index 0000000..b3d92ec Binary files /dev/null and b/site/surveyapp/static/images/siteimages/piechart.png differ diff --git a/site/surveyapp/static/images/siteimages/scatterchart.png b/site/surveyapp/static/images/siteimages/scatterchart.png new file mode 100644 index 0000000..490cfc4 Binary files /dev/null and b/site/surveyapp/static/images/siteimages/scatterchart.png differ diff --git a/site/surveyapp/static/input.js b/site/surveyapp/static/input.js new file mode 100644 index 0000000..650fd20 --- /dev/null +++ b/site/surveyapp/static/input.js @@ -0,0 +1,227 @@ +const button = document.querySelector(".save") +const infoRow = document.querySelector(".info-row") +const info = document.querySelector(".table-guide") +const container = document.querySelector(".handsontable-container") +const table = document.getElementById('handsontable'); +const addVariable = document.querySelector(".add-variable") +const inputOverlay = document.querySelector(".input-overlay") +const addColumn = document.querySelector(".add-column") + +const newVariable = document.querySelector(".heading") + +const proceed = document.querySelector(".proceed") + + + +let values = data["values"] +let headers = data["headers"] +let hot; +let savedChanges = True; + + +// If the table has not yet been created then we want to hide the DOM elements +if(headers.length == 0){ + container.classList.add("invisible") + infoRow.classList.add("invisible") +} else{ + // Edge case: handsontable needs a 2d array. If 'values' is empty it needs converting + if (values.length == 0){ + values = [[]] + } + inputOverlay.classList.add("invisible") + renderTable(values, headers) +} + +addColumn.addEventListener('click', function () { + triggerModal() +}); + + +function triggerModal(){ + // Allow user to submit a column field simply by pressing enter + $(document).unbind("keyup").keyup(function(e){ + // Get the key code + let code = e.which; + // If it is equal to 13 then click the confirm button + if(code==13) + { + $("#confirm").click(); + } + }); + + if(newVariable.value == ""){ + // If the user tries to enter in an empty string, modal remains open with warning class + newVariable.classList.add("is-invalid") + }else{ + // Remove the warning box class if the user has previously tried to enter an empty string + newVariable.classList.remove("is-invalid") + // Add new column heading + headers.push(newVariable.value) + // Close the modal and reset the input box to be empty + $('#new-column-modal').modal('toggle'); + newVariable.value = "" + // If the table does not yet exist, we need to remove the overlay and make the table visible + if(hot == undefined){ + container.classList.remove("invisible") + infoRow.classList.remove("invisible") + inputOverlay.classList.add("invisible") + // Intialise empty 2d array to represent the cells in the table + values=[[]] + // Render the table + renderTable(values, headers) + // If table already exists we can simply update it + }else{ + hot.alter('insert_col', headers.length, 1) + // Necessary, since 'insert_col' will add an an 'undefined' header to the header list + // (which is not needed since we have already added the header with the specified name) + headers.pop() + } + } +} + + + +function columnData(headers){ + let columns = [] + headers.forEach(header => { + columns.push({data: header}) + }) + return columns +} + + +function renderTable(values, headers){ + hot = new Handsontable(table, { + data: values, + // 23 rows will fill up the view for the user + minRows: 23, + minCols: 1, + stretchH: 'all', + rowHeaders: true, + colHeaders: true, + minSpareRows: 1, + allowEmpty: false, + filters: true, + colHeaders: headers, + licenseKey: 'non-commercial-and-evaluation', + columnSorting: true, + dropdownMenu: { + items: [ + { + key:'rename_column', + name: 'Rename column', + callback: (key, option) => { + cellIndex = option[0].end.col + var newColHeader = prompt('Pick new name'); + if(newColHeader){ + headers[cellIndex] = newColHeader + hot.render() + } + } + }, + '---------', + 'remove_col', + '---------', + 'col_left', + '---------', + 'col_right' + ] + }, + afterChange: function (change, source) { + if (source !== 'loadData') { + info.innerHTML = "You have unsaved changes" + info.style.color = "red" + savedChanges = False; + } + } + }); +} + + +function removeEmptyRows(){ + let data = hot.getData() + let emptyRows = [] + data.forEach((row, i) => { + if(hot.isEmptyRow(i)){ + emptyRows.push([i, 1]) + } + }) + let newHot = hot.alter("remove_row", emptyRows, 1) + return newHot +} + + + + +// Put all of our standalone jquery function inside a document ready check +$(document).ready(function(){ + // A function that prompts the user to save if they have not done so before moving to home + $('#proceed').click(function(event) { + if(!savedChanges){ + event.preventDefault(); + $('#save-changes-modal').modal('toggle'); + } + }); + + + // Prevent form auto submitting when a user presses Enter + $(document).on("keydown", "form", function(event) { + return event.key != "Enter"; + }); + + + // Function responsible for form submission + $('form').submit(function (e) { + // Remove any extra rows before saving (so they are not also submitted) + hot.updateSettings({ + minSpareRows: 0, + minRows:0 + }) + // Remove empty rows (if any) + let newHot = removeEmptyRows(); + + // Get the export plugin on the newHot (if it exists) else on the old hot + let exportPlugin = newHot != undefined ? newHot.getPlugin('exportFile') : hot.getPlugin('exportFile') + + const string = exportPlugin.exportAsString('csv', { + columnHeaders: true, + }); + // Post the data has a string to the server + postData(string) + // Prevent default form submission + e.preventDefault(); + + // Change spare rows back to 1 and min back to 23 if user wants to continue editing + hot.updateSettings({ + minSpareRows: 1, + minRows:23 + }) + // Update the html to display that the changes have been submitted + info.innerHTML = "Up to date" + info.style.color = "green" + savedChanges = True; + }); +}); + + +// Post the table as a string to the server (with the title) +function postData(dataString){ + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrf) + } + } + }) + var postData = $('form').serializeArray() + postData.push({name: "table", value: dataString}) + $.ajax({ + type: "POST", + url: url, + data: postData, + success: function (data) { + // The server returns the id of the survey (if it is new) so we can update our URL + url = Flask.url_for("surveys.input", {"survey_id": data}) + } + }); +} diff --git a/site/surveyapp/static/package-lock.json b/site/surveyapp/static/package-lock.json new file mode 100644 index 0000000..e59aa0e --- /dev/null +++ b/site/surveyapp/static/package-lock.json @@ -0,0 +1,811 @@ +{ + "name": "webapp", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/runtime": { + "version": "7.10.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz", + "integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@types/d3": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-3.5.38.tgz", + "integrity": "sha1-dvjy6RWa5WKWWy+g5vvuGqZDobw=" + }, + "@types/raf": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.0.tgz", + "integrity": "sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==" + }, + "acorn": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz", + "integrity": "sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA==" + }, + "brfs": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/brfs/-/brfs-1.6.1.tgz", + "integrity": "sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ==", + "requires": { + "quote-stream": "^1.0.1", + "resolve": "^1.1.5", + "static-module": "^2.2.0", + "through2": "^2.0.0" + } + }, + "buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=" + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "canvg": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.6.tgz", + "integrity": "sha512-eFUy8R/4DgocR93LF8lr+YUxW4PYblUe/Q1gz2osk/cI5n8AsYdassvln0D9QPhLXQ6Lx7l8hwtT8FLvOn2Ihg==", + "requires": { + "@babel/runtime": "^7.6.3", + "@types/raf": "^3.4.0", + "core-js": "3", + "raf": "^3.4.1", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0" + } + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "core-js": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", + "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "d3": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz", + "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==", + "requires": { + "d3-array": "1", + "d3-axis": "1", + "d3-brush": "1", + "d3-chord": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-contour": "1", + "d3-dispatch": "1", + "d3-drag": "1", + "d3-dsv": "1", + "d3-ease": "1", + "d3-fetch": "1", + "d3-force": "1", + "d3-format": "1", + "d3-geo": "1", + "d3-hierarchy": "1", + "d3-interpolate": "1", + "d3-path": "1", + "d3-polygon": "1", + "d3-quadtree": "1", + "d3-random": "1", + "d3-scale": "2", + "d3-scale-chromatic": "1", + "d3-selection": "1", + "d3-shape": "1", + "d3-time": "1", + "d3-time-format": "2", + "d3-timer": "1", + "d3-transition": "1", + "d3-voronoi": "1", + "d3-zoom": "1" + } + }, + "d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "d3-axis": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", + "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==" + }, + "d3-brush": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.5.tgz", + "integrity": "sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "d3-chord": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", + "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", + "requires": { + "d3-array": "1", + "d3-path": "1" + } + }, + "d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, + "d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "d3-contour": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", + "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", + "requires": { + "d3-array": "^1.1.1" + } + }, + "d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" + }, + "d3-drag": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", + "requires": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "d3-dsv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", + "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", + "requires": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + } + }, + "d3-ease": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.6.tgz", + "integrity": "sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ==" + }, + "d3-fetch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.1.2.tgz", + "integrity": "sha512-S2loaQCV/ZeyTyIF2oP8D1K9Z4QizUzW7cWeAOAS4U88qOt3Ucf6GsmgthuYSdyB2HyEm4CeGvkQxWsmInsIVA==", + "requires": { + "d3-dsv": "1" + } + }, + "d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "d3-format": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.4.tgz", + "integrity": "sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw==" + }, + "d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "requires": { + "d3-array": "1" + } + }, + "d3-geo-projection": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-0.2.16.tgz", + "integrity": "sha1-SZTs0QM92xUztsTFUoocgdzClCc=", + "requires": { + "brfs": "^1.3.0" + } + }, + "d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==" + }, + "d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "requires": { + "d3-color": "1" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-polygon": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", + "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==" + }, + "d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" + }, + "d3-queue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/d3-queue/-/d3-queue-2.0.3.tgz", + "integrity": "sha1-B/vaOsrlNYqcUpmq+ICt8JU+0sI=" + }, + "d3-random": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", + "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==" + }, + "d3-scale": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", + "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", + "requires": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "requires": { + "d3-color": "1", + "d3-interpolate": "1" + } + }, + "d3-selection": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.1.tgz", + "integrity": "sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA==" + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" + }, + "d3-time-format": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.3.tgz", + "integrity": "sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==", + "requires": { + "d3-time": "1" + } + }, + "d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" + }, + "d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "d3-zoom": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", + "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "datamaps": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/datamaps/-/datamaps-0.5.9.tgz", + "integrity": "sha512-GUXpO713URNzaExVUgBtqA5fr2UuxUG/fVitI04zEFHVL2FHSjd672alHq8E16oQqRNzF0m1bmx8WlTnDrGSqQ==", + "requires": { + "@types/d3": "3.5.38", + "d3": "^3.5.6", + "topojson": "^1.6.19" + }, + "dependencies": { + "d3": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", + "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=" + } + } + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "requires": { + "readable-stream": "^2.0.2" + } + }, + "escodegen": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.9.1.tgz", + "integrity": "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q==", + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "falafel": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.4.tgz", + "integrity": "sha512-0HXjo8XASWRmsS0X1EkhwEMZaD3Qvp7FfURwjLKjG1ghfRm/MGZl2r4cWUTv41KdNghTw4OUMmVtdGQp3+H+uQ==", + "requires": { + "acorn": "^7.1.1", + "foreach": "^2.0.5", + "isarray": "^2.0.1", + "object-keys": "^1.0.6" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + } + } + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "magic-string": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", + "integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==", + "requires": { + "vlq": "^0.2.2" + } + }, + "merge-source-map": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", + "integrity": "sha1-pd5GU42uhNQRTMXqArR3KmNGcB8=", + "requires": { + "source-map": "^0.5.6" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + } + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "object-inspect": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.4.1.tgz", + "integrity": "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "optimist": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", + "integrity": "sha1-yQlBrVnkJzMokjB00s8ufLxuwNk=", + "requires": { + "wordwrap": "~0.0.2" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "quote-stream": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz", + "integrity": "sha1-hJY/jJwmuULhU/7rU6rnRlK34LI=", + "requires": { + "buffer-equal": "0.0.1", + "minimist": "^1.1.3", + "through2": "^2.0.0" + } + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "requires": { + "performance-now": "^2.1.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "requires": { + "path-parse": "^1.0.6" + } + }, + "rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha1-1lBezbMEplldom+ktDMHMGd1lF0=" + }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "save-svg-as-png": { + "version": "1.4.17", + "resolved": "https://registry.npmjs.org/save-svg-as-png/-/save-svg-as-png-1.4.17.tgz", + "integrity": "sha512-7QDaqJsVhdFPwviCxkgHiGm9omeaMBe1VKbHySWU6oFB2LtnGCcYS13eVoslUgq6VZC6Tjq/HddBd1K6p2PGpA==" + }, + "shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha1-QV9CcC1z2BAzApLMXuhurhoRoXA=" + }, + "shapefile": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.3.1.tgz", + "integrity": "sha1-m7mkKb1ghqDPsDli0Uz99CD/uhI=", + "requires": { + "d3-queue": "1", + "iconv-lite": "0.2", + "optimist": "0.3" + }, + "dependencies": { + "d3-queue": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/d3-queue/-/d3-queue-1.2.3.tgz", + "integrity": "sha1-FDpwHPpl/gISkvMhwQ0U6Yq9SRs=" + }, + "iconv-lite": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz", + "integrity": "sha1-HOYKOleGSiktEyH/RgnKS7llrcg=" + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + }, + "stackblur-canvas": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.3.0.tgz", + "integrity": "sha512-3ZHJv+43D8YttgumssIxkfs3hBXW7XaMS5Ux65fOBhKDYMjbG5hF8Ey8a90RiiJ58aQnAhWbGilPzZ9rkIlWgQ==" + }, + "static-eval": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.0.tgz", + "integrity": "sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw==", + "requires": { + "escodegen": "^1.11.1" + }, + "dependencies": { + "escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + } + } + }, + "static-module": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/static-module/-/static-module-2.2.5.tgz", + "integrity": "sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ==", + "requires": { + "concat-stream": "~1.6.0", + "convert-source-map": "^1.5.1", + "duplexer2": "~0.1.4", + "escodegen": "~1.9.0", + "falafel": "^2.1.0", + "has": "^1.0.1", + "magic-string": "^0.22.4", + "merge-source-map": "1.0.4", + "object-inspect": "~1.4.0", + "quote-stream": "~1.0.2", + "readable-stream": "~2.3.3", + "shallow-copy": "~0.0.1", + "static-eval": "^2.0.0", + "through2": "~2.0.3" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "topojson": { + "version": "1.6.27", + "resolved": "https://registry.npmjs.org/topojson/-/topojson-1.6.27.tgz", + "integrity": "sha1-rb4zpn4vFnPTON8SZErSD8ILQu0=", + "requires": { + "d3": "3", + "d3-geo-projection": "0.2", + "d3-queue": "2", + "optimist": "0.3", + "rw": "1", + "shapefile": "0.3" + }, + "dependencies": { + "d3": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", + "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=" + } + } + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "vlq": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", + "integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==" + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + } + } +} diff --git a/site/surveyapp/static/package.json b/site/surveyapp/static/package.json new file mode 100644 index 0000000..c786887 --- /dev/null +++ b/site/surveyapp/static/package.json @@ -0,0 +1,25 @@ +{ + "name": "webapp", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/geoseal/surveyApp.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/geoseal/surveyApp/issues" + }, + "homepage": "https://github.com/geoseal/surveyApp#readme", + "dependencies": { + "canvg": "^3.0.6", + "d3": "^5.16.0", + "datamaps": "^0.5.9", + "save-svg-as-png": "^1.4.17" + } +} diff --git a/site/surveyapp/static/statscripts/chi.js b/site/surveyapp/static/statscripts/chi.js new file mode 100644 index 0000000..8275744 --- /dev/null +++ b/site/surveyapp/static/statscripts/chi.js @@ -0,0 +1,40 @@ +"use strict"; + +// Select DOM elements +const total = document.querySelector(".chi-total") +const inputs = document.querySelectorAll("input[type=text]") +const submit = document.querySelector(".analyse-continue") + + +inputs.forEach(input => { + input.addEventListener('keyup', function(){ + checkTotal() + }); +}) + + +function getSumOfInputs(){ + let total = 0; + inputs.forEach(input => { + let expected = parseInt(input.value) + if (Number.isNaN(expected)){ + expected=0 + } + total += expected + }) + return total +} + +function checkTotal(){ + let sum = getSumOfInputs() + total.innerHTML = sum; + if(sum == totalChi || sum == 0){ + total.style.color = "Green" + submit.classList.remove("hidden-down") + }else{ + total.style.color = "Red" + submit.classList.add("hidden-down") + } +} + +checkTotal() diff --git a/site/surveyapp/static/statscripts/quickstats.js b/site/surveyapp/static/statscripts/quickstats.js new file mode 100644 index 0000000..195be20 --- /dev/null +++ b/site/surveyapp/static/statscripts/quickstats.js @@ -0,0 +1,11 @@ +"use strict"; + +// Function that auto creates dynamic grid depending on grid element size +var container = document.querySelector('.stats-grid'); +var msnry = new Masonry( container, { + + columnWidth: '.grid-element', + itemSelector: '.grid-element', + gutter: 10, + percentPosition: true +}); diff --git a/site/surveyapp/static/statscripts/statistics.js b/site/surveyapp/static/statscripts/statistics.js new file mode 100644 index 0000000..346737f --- /dev/null +++ b/site/surveyapp/static/statscripts/statistics.js @@ -0,0 +1,204 @@ +"use strict"; + +// The DOM elements representing the different sections +const independentVariables = document.querySelector(".independent-variables") +const dependentVariables = document.querySelector(".dependent-variables") +const continueButton = document.querySelector(".analyse-continue") + +// The DOM elements representing the question text +const firstQuestion = document.querySelector(".first-variable-question") +const secondQuestion = document.querySelector(".second-variable-question") + + +// The DOM elements representing the select fields +const independentVariableList = document.querySelector(".independent-variable") +const dependentVariableList = document.querySelector(".dependent-variable") +const statisticalTestList = document.querySelector(".statistical-test") + +// The DOM elements representing different information to be given to the user, depending on what test they pick +const testInfo = document.querySelector(".test-info") +const independentInfo = document.querySelector(".iv-info") +const dependentInfo = document.querySelector(".dv-info") + +// A boolean flag, to keep track of how many variables in the chosen statistical test +let one_variable = false + + +// Information boxes for different data types +const nominal = ` +Nominal` +const ordinal = ` +Ordinal` +const interval = ` +Interval` +const ratio = ` +Ratio` + + +const tests = [ + { + "name": "Kruskall Wallis Test", + "variable1": { + "name": "independent variable", + "type": [nominal, ordinal] + }, + "variable2": { + "name": "dependent variable", + "type": [ordinal, interval, ratio] + }, + "info": `Kruskal Wallis test is commonly used to test the null hypothesis that the samples (groups) are from + the same population. It tests one categorical variable against a measurable variable. + The dependent variable must be ordinal, interval or ratio. Typically, this test is used to test when you have + 3 or more different groups in your independent variable, but can also be used for just 2 groups (examples could be + 3 groups: low/medium/high income, 2 groups: yes/no answers).` + }, + { + "name": "Mann-Whitney U Test", + "variable1": { + "name": "independent variable", + "type": [nominal, ordinal] + }, + "variable2": { + "name": "dependent variable", + "type": [ordinal, interval, ratio] + }, + "info": `The Mann-Whitney U test is used to check if observations in one sample are larger + than observations in the other sample. It requires that the independent variable + consists of just 2 categorical groups (e.g. questions with yes/no answers). If your + independent variable contains more groups then the Kruskall Wallis test should be used.` + }, + { + "name": "Chi-Square Test", + "variable1": { + "name": "first variable", + "type": [nominal, ordinal] + }, + "variable2": { + "name": "second variable", + "type": [nominal, ordinal] + }, + "info": `The Chi Square test requires that both variables be categorical (i.e. nominal or ordinal). + Both variables should contain 2 or more distinct categorical groups (e.g. + 2 groups: yes/no answers, 3 groups: low/medium/high income) Furthermore, these groups must + be independent (i.e. no subjects are in more than one group).` + }, + { + "name": "Chi-Square goodness of fit", + "variable1": { + "name": "first variable", + "type": [nominal, ordinal] + }, + "info": `The Chi Square goodness of fit takes one categorical variable. It is used to see if the + different categories in that variable follow the same distribution that you would expect. + Assumes that the expected distribution is even accross groups, that each group is mutually + exclusive from the next and each group contains at least 5 subjects.` + } +] + + +if(statisticalTestList.value != ""){ + // Using Jquery, initialise Popper.js tooltips + $(function () { + $("[data-toggle='tooltip']").tooltip(); + }); + populateInfo(); + // Add event listeners that re-sets the options whenever one is changed + independentVariableList.onchange = function(){ + setSelectOptions(independentVariableList, dependentVariableList); + } + dependentVariableList.onchange = function(){ + setSelectOptions(dependentVariableList, independentVariableList); + } +} + + +statisticalTestList.onchange = function (){ + // Using Jquery, initialise Popper.js tooltips + $(function () { + $("[data-toggle='tooltip']").tooltip(); + }); + populateInfo(); + // Add event listeners that re-sets the options whenever one is changed + independentVariableList.onchange = function(){ + if(one_variable == true){ + revealHtml(independentVariableList, continueButton) + } + setSelectOptions(independentVariableList, dependentVariableList); + } + dependentVariableList.onchange = function(){ + setSelectOptions(dependentVariableList, independentVariableList); + revealHtml(dependentVariableList, continueButton) + } +} + + +// A function that hides some select options, preventing user from picking the same option for both variables +function setSelectOptions(currentSelect, otherSelect){ + let variable = currentSelect.value + for (var i=0; i < otherSelect.length; i++) { + if (otherSelect.options[i].value == variable){ + otherSelect.options[i].hidden = true; + } else { + otherSelect.options[i].hidden = false; + } + } +} + +// Function that reveals the next section of HTML, after the user selects an option in the current section +function revealHtml(currentSelect, nextSection){ + if(currentSelect.value != ""){ + nextSection.classList.remove("hidden-down"); + } +} + +// Function that populates the html with information regarding the statistical test chosen and the variables required +function populateInfo(){ + let test = getTest(statisticalTestList.value) + if(test){ + // Populate the question titles, information and data types based on the selected test + // Question 1 + firstQuestion.innerHTML = test.variable1.name + testInfo.innerHTML = test.info; + let ivTypes = `For this test the independent variable can be:` + test.variable1.type.forEach(variable => { + ivTypes += variable + }) + independentInfo.innerHTML = ivTypes + if(test.variable2 == undefined){ + one_variable = true; + independentVariables.classList.remove("hidden-down"); + dependentVariables.classList.add("hidden-down"); + revealHtml(independentVariableList, continueButton) + }else{ + independentVariables.classList.remove("hidden-down"); + dependentVariables.classList.remove("hidden-down"); + revealHtml(dependentVariableList, continueButton) + one_variable = false; + // Question 2 + secondQuestion.innerHTML = test.variable2.name + let dvTypes = `For this test the dependent variable can be:` + test.variable2.type.forEach(variable => { + dvTypes += variable + }) + dependentInfo.innerHTML = dvTypes + } + } +} + + +function getTest(name){ + let result + tests.forEach(test => { + if(test.name == name){ + result = test; + } + }) + return result +} + + +// setEventListeners() diff --git a/site/surveyapp/static/styles.css b/site/surveyapp/static/styles.css new file mode 100644 index 0000000..9953b6a --- /dev/null +++ b/site/surveyapp/static/styles.css @@ -0,0 +1,271 @@ +@import "d3.css"; + +/* Variables set so can easily be changed and subsequently alter the appearance of the site */ +:root { + --primary-colour: #243b55; + --primary-gradient: linear-gradient(to left, #141e30, #243b55); + --grey: #a2a4a6; + --background-colour: #f7f7f7; + --primary-transition: 0.3s all ease; + --main-font: "Ubuntu", sans-serif; + --landing-image: url('/static/images/siteimages/landing2.png'); +} + +body { + background: var(--background-colour) !important; + position: relative; + font-family: var(--main-font); + min-height: 100vh; + padding: 0; + margin: 0; +} + +/* General CSS classes */ +.primary-colour{ + color: var(--primary-colour); +} + +.primary-colour-bg { + background: var(--primary-colour); +} + +.bg-gradient { + background: var(--primary-gradient); +} + +.hidden-down{ + opacity: 0; + transform: translateY(50px); +} + +.primary-transition{ + transition: var(--primary-transition); +} + +.hover-shadow:hover { + box-shadow: 5px 5px 5px var(--grey); +} + +.help{ + cursor: help; +} + +.pointer { + cursor: pointer; +} + +.vh-75 { + height: 75vh; +} + +/* Css class that removes default styles of button elements */ +.no-btn { + background: none; + border: none; + padding: 0; + width: 100%; + text-align: left; +} + + +/* ------LANDING PAGE CSS------ */ + +.masthead{ + height: calc(100vh - 50px); + min-height: 500px; + background-image: var(--landing-image); + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.landing-logo { + font-size: 8rem; + margin: 1rem; + background: var(--primary-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.logo { + height: 35px; +} + +.logo-2 { + width: 60%; +} + +/* CSS relating to the statistical tests on the dashboard page */ +.dashboard-test{ + box-sizing: border-box; + display: flex; + justify-content: space-between; + font-size: 1rem; + padding: 0.5rem; +} + +.dashboard-test:hover a{ + color: white; +} + +.delete-icon { + border: 0; + padding: 0; + margin: 0; + background: none; + color: darkred; + cursor: pointer; +} + +.dashboard-test:hover{ + background-color: var(--grey); + padding-left: 1rem; + cursor: pointer; +} +.dashboard-test:hover .delete-icon{ + color: white; +} + + +/* Additional CSS given to bootstrap cards */ +.card { + width: 18rem; + margin: 0 auto; + float: none; +} + +.card-img-top { + height: 10rem; +} + + +/* ------GRAPH PAGE CSS------ */ +.graph-container{ + min-height: 85vh; + margin: 1rem; + background-color: white; + border-radius: 10px; +} + +.graph-height { + min-height: 30vh; +} + +.x-axis-details { + display: flex; + flex-direction: column; + width: 100%; +} + +.axis-variable { + display: flex; + flex-direction: column; + padding: 1rem 0; +} + +.empty-graph { + box-sizing: border-box; + position: absolute; + display: flex; + justify-content: center; + align-items: center; + border: 3px dashed var(--primary-colour); + color: var(--primary-colour); + border-radius: 10px; +} + +/* ------ANALYSE DATA PAGE------ */ + +.full-bar{ + width: 100%; + background-color: white; + height: 1px; +} + +/* ------QUICK STATS------ */ + +.grid-element { + margin-bottom: 10px; + width: calc(25% - 10px); + min-width: 220px; +} +@media only screen and (max-width: 800px){ + .grid-element { + margin-bottom: 10px; + width: calc(50% - 10px); + min-width: 220px; + } +} +@media only screen and (max-width: 500px){ + .grid-element { + margin-bottom: 10px; + width: calc(100%); + min-width: 220px; + } +} + +.graph-tooltip { + text-align: center; + width: 50; + position: fixed; + padding: 0.5rem 2rem; + color: var(--primary-colour); + background-color: white; + border-radius: 10px; + box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4); +} + +.tooltip-hidden { + opacity: 0; +} + +/* ------DATA INPUT PAGE------ */ + +.input-overlay{ + position: absolute; + width: 90%; + height: 80vh; + top: 80px; + z-index: 200; + display: flex; + justify-content: center; + align-items: center; +} + +/* Wobble affect on notification */ + +@-webkit-keyframes wobble +{ + 0% {-webkit-transform: rotateZ(10deg);} + 4% {-webkit-transform: rotateZ(-10deg);} + 8% {-webkit-transform: rotateZ(10deg);} + 12% {-webkit-transform: rotateZ(0deg);} +} +@-moz-keyframes wobble +{ + 0% {-moz-transform: rotateZ(10deg);} + 4% {-moz-transform: rotateZ(-10deg);} + 8% {-moz-transform: rotateZ(10deg);} + 12% {-moz-transform: rotateZ(0deg);} +} +@-o-keyframes wobble +{ + 0% {-o-transform: rotateZ(10deg);} + 4% {-o-transform: rotateZ(-10deg);} + 8% {-o-transform: rotateZ(10deg);} + 12% {-o-transform: rotateZ(0deg);} +} +@keyframes wobble +{ + 0% {transform: rotateZ(10deg);} + 4% {transform: rotateZ(-10deg);} + 8% {transform: rotateZ(10deg);} + 12% {transform: rotateZ(0deg);} +} + +.wobble { + -webkit-animation: wobble 2.5s ease infinite; + -moz-animation: wobble 2.5s ease infinite; + -o-animation: wobble 2.5s ease infinite; + animation: wobble 2.5s ease infinite; +} diff --git a/site/surveyapp/static/video/site_video.mov b/site/surveyapp/static/video/site_video.mov new file mode 100644 index 0000000..0abee1a Binary files /dev/null and b/site/surveyapp/static/video/site_video.mov differ diff --git a/site/surveyapp/surveys/__init__.py b/site/surveyapp/surveys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/surveys/forms.py b/site/surveyapp/surveys/forms.py new file mode 100644 index 0000000..7cdfd43 --- /dev/null +++ b/site/surveyapp/surveys/forms.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from wtforms import StringField, SubmitField, FileField +from wtforms.validators import DataRequired + + +class UploadForm(FlaskForm): + title = StringField("Enter a title for this data.", validators=[DataRequired()]) + file = FileField("Choose file to upload", validators=[DataRequired(), FileAllowed(["xls", "xlt", "xla", "xlsx", "xltx", "xlsb", "xlsm", "xltm", "xlam", "csv"], message="Only CSV files or Excel Spreadsheets allowed.")]) + submit = SubmitField("Save and proceed") + +# General form, used for editing a title. Used on survey input page and stat result page. +class EditForm(FlaskForm): + title = StringField("Title", validators=[DataRequired()]) + submit = SubmitField("Save") diff --git a/site/surveyapp/surveys/routes.py b/site/surveyapp/surveys/routes.py new file mode 100644 index 0000000..afb779c --- /dev/null +++ b/site/surveyapp/surveys/routes.py @@ -0,0 +1,248 @@ +import threading +import secrets +import os +import tempfile +import pandas as pd +from flask import Flask, render_template, url_for, request, Blueprint, flash, redirect, abort, current_app, send_file +from flask_login import login_required, current_user +from surveyapp.surveys.forms import UploadForm, EditForm +from surveyapp import mongo +from bson.objectid import ObjectId + +from surveyapp.graphs.utils import delete_image, graphs_to_excel +from surveyapp.analysis.utils import run_all_tests, tests_to_excel +from surveyapp.surveys.utils import save_file, read_file, delete_file, generate_filepath + +from xlsxwriter import Workbook + +surveys = Blueprint("surveys", __name__) + +# Home page, displaying all the user's surveys as well as notifications +@surveys.route('/home', methods=['GET']) +@login_required +def home(): + # Get all surveys related to the current_user + surveys=mongo.db.surveys.find({"user":current_user._id}) + # Initialise an empty list that will contain the number of graphs and tests for each survey + survey_list = [] + # Loop through each survey, counting the number of graphs and tests + for survey in surveys: + graphs = mongo.db.graphs.count_documents({"surveyId":str(survey["_id"])}) + tests = mongo.db.tests.count_documents({"surveyId":str(survey["_id"])}) + survey_list.append({"title": survey["title"],\ + "_id": survey["_id"],\ + "numGraphs": graphs,\ + "numTests": tests}) + # Get the number of notifications (if any) + notifications = mongo.db.temp_results.count_documents({"user": current_user._id}) + return render_template("surveys/home.html", title="Home", surveys=survey_list, notifications=notifications) + + +# Dasboard page for each survey +# Renders a page with all graphs and surveys relating to the chosen survey +@surveys.route('/home/', methods=['GET']) +@login_required +def dashboard(survey_id): + # Get the current survey + survey = mongo.db.surveys.find_one_or_404({"_id": ObjectId(survey_id)}) + # Get the graphs and tests associated with that survey + graphs = mongo.db.graphs.find({"surveyId":survey_id}) + tests = mongo.db.tests.find({"surveyId":survey_id}) + return render_template("surveys/dashboard.html", title="Dashboard", graphs=list(graphs), tests=list(tests), survey=survey) + + +# Importing a file (CSV or Excel spreadsheet) +@surveys.route('/import', methods=['GET', 'POST']) +@login_required +def import_file(): + form = UploadForm() + # Checks validation when form is submitted with submit button + if form.validate_on_submit(): + # Save the file + file_name = save_file(form.file.data) + # And add to the database + survey_id = mongo.db.surveys.insert_one({ + "fileName" : file_name, + "user" : current_user._id, + "title" : form.title.data}).inserted_id # Get the id of the survey just inserted + flash("File uploaded successfully!", "success") + # Running all the statistical tests on the data can take a lot of time. Therefore I + # carry it out using a python thread. It is important to pass the current application, + # so that the threaded function can be carried out from the current application context + thread = threading.Thread(target=run_all_tests, args=(str(survey_id), current_user._id, current_app._get_current_object()), daemon=True) + thread.start() + # Redirect user to the input/table page so they can view their uploaded data in tabular form + return redirect(url_for("surveys.input", survey_id=survey_id)) + return render_template("surveys/import.html", title = "Import", form=form) + + + + +# # This page displays data in tabular form. It can be used for entering data from +# # scratch or for editing a survey that is already uploaded +@surveys.route('/input', methods=['GET', 'POST']) +@login_required +def input(): + # Initialise variables for handsontable (2d array for values, 1d array for column headers) + value_list = [[]] + header_list = [] + survey_id = request.args.get("survey_id") + form = EditForm() + # Handsontable data cannot be posted using WTForms POST methods - Post needs to be from combined WTForm and javascript AJAX + if form.validate_on_submit(): + # get the file_obj (if one exists yet) + file_obj = mongo.db.surveys.find_one({"_id":ObjectId(survey_id)}) + # if file already exists we can simply get the name of the file + if file_obj: + file_name = file_obj["fileName"] + file = os.path.join(current_app.root_path, "uploads", file_name) + # Else we need to generate a new filename with a new random hex. + else: + # Generate a random hex to be the new filename + file_name = generate_filepath() + file = os.path.join(current_app.root_path, "uploads", file_name) + # write/overwrite the table values to the file + with open(file, "w") as file_to_write: + file_to_write.write(request.form["table"]) + # Update/insert into the database + survey = mongo.db.surveys.update_one({"_id": ObjectId(survey_id)},\ + {"$set": {"fileName" : file_name,\ + "user" : current_user._id,\ + "title" : form.title.data}}, upsert=True) + if not survey_id: + survey_id = survey.upserted_id + # Respond to the jquery POST with the survey_id. This is so that if the survey was new, it + # can now be incorporated into subsequent POST requests to avoid multiple surveys being saved + return str(survey_id) + # If GET request and the survey already exists (i.e. editing an existing survey) + elif request.method == "GET" and survey_id: + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + return redirect(url_for("main.index")) + # Read the file and extract the cell values and column headers + df = read_file(file_obj["fileName"]) + value_list = df.values.tolist() + header_list = df.columns.values.tolist() + form.title.data = file_obj["title"] + data = {"values": value_list, "headers": header_list} + return render_template("surveys/input.html", title="Input", data=data, survey_id=survey_id, form=form) + + +# Helper function called by client javascript. Will run all statistical tests on their +# chosen survey and save significant findings in their findings/notification page +@surveys.route('/home/run_tests/', methods=['GET', 'POST']) +@login_required +def run_tests(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that survey", "danger") + abort(403) + # Running all the statistical tests on the data can take a lot of time. Therefore I + # carry it out using a python thread. It is important to pass the current application, + # so that the threaded function can be carried out from the current application context + thread = threading.Thread(target=run_all_tests, args=(str(survey_id), current_user._id, current_app._get_current_object()), daemon=True) + thread.start() + # Redirect user to the input/table page so they can view their uploaded data in tabular form + flash("Depending on the size of your survey, running all statistical tests may take some time. \ + Try refreshing the page in a bit or come back again later.", "success") + return redirect(url_for("surveys.home")) + + + + +@surveys.route('/findings', methods=['GET', 'POST']) +@login_required +def findings(): + form = EditForm() + notifications = mongo.db.temp_results.find({"user": current_user._id}) + # If user chooses to save a test + if form.validate_on_submit(): + result_id = request.args.get("result_id") + mongo.db.tests.insert_one({ + "surveyId" : request.args.get("survey_id"), + "user" : current_user._id, + "title" : form.title.data, + "test" : request.args.get("test"), + "independentVariable" : request.args.get("independent_variable"), + "dependentVariable" : request.args.get("dependent_variable"), + "p" : request.args.get("p")}) + mongo.db.temp_results.delete_one({'_id': ObjectId(result_id)}) + notifications = mongo.db.temp_results.find({"user": current_user._id}) + flash("Statistical test saved to your survey dashboard", "success") + return redirect(url_for("surveys.findings")) + return render_template("surveys/findings.html", title="Findings", form=form, notifications=notifications, count=notifications.count()) + + +# Delete a temporary result +@surveys.route('/findings//delete', methods=['POST']) +@login_required +def delete_temp_result(result_id): + file_obj = mongo.db.temp_results.find_one_or_404({"_id":ObjectId(result_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + mongo.db.temp_results.delete_one(file_obj) + return redirect(url_for('surveys.findings')) + + + +# Delete all temporary results +@surveys.route('/findings/delete', methods=['POST']) +@login_required +def delete_findings(): + mongo.db.temp_results.delete_many({"user":current_user._id}) + return redirect(url_for('surveys.findings')) + + +# DELETE A SURVEY +@surveys.route("/survey//delete", methods=['POST']) +@login_required +def delete_survey(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that page", "danger") + abort(403) + # First loop through all graphs, tests and remp results associated with that survey and delete them + graphs = mongo.db.graphs.find({"surveyId":survey_id}) + # Loop through and delete all associated graphs and tests + for graph in graphs: + delete_image(graph["image"]) + mongo.db.graphs.delete_one(graph) + mongo.db.tests.delete_many({"surveyId":survey_id}) + mongo.db.temp_results.delete_many({"survey_id":survey_id}) + # Delete the file + delete_file(file_obj["fileName"]) + # finally delete from the surveys database + mongo.db.surveys.delete_one(file_obj) + return redirect(url_for('surveys.home')) + + +# EXPORT A SURVEY +@surveys.route("/survey//export", methods=['GET']) +@login_required +def export_survey(survey_id): + file_obj = mongo.db.surveys.find_one_or_404({"_id":ObjectId(survey_id)}) + if file_obj["user"] != current_user._id: + flash("You do not have access to that survey", "danger") + abort(403) + # Get all graphs and tests relating to this file + tests = mongo.db.tests.find({"surveyId":survey_id}) + graphs = mongo.db.graphs.find({"surveyId":survey_id}) + # Use a temp file so that it can be deleted after + with tempfile.NamedTemporaryFile() as f: + # Create a Pandas Excel writer using XlsxWriter as the engine. + writer = pd.ExcelWriter(f.name, engine='xlsxwriter') + # Convert the dataframe to an XlsxWriter Excel object. + df = read_file(file_obj["fileName"]) + df.to_excel(writer, sheet_name='Survey data') + wb = writer.book + # Get a worksheet for the statistical tests + ws = wb.add_worksheet("Statistical Tests") + tests_to_excel(ws, tests) + # Get a worksheet for the graphs + ws2 = wb.add_worksheet("Graphs") + graphs_to_excel(ws2, graphs) + writer.close() + wb.close() + return send_file(f.name, attachment_filename=file_obj["title"] + ".xlsx", as_attachment=True) diff --git a/site/surveyapp/surveys/utils.py b/site/surveyapp/surveys/utils.py new file mode 100644 index 0000000..518f8e3 --- /dev/null +++ b/site/surveyapp/surveys/utils.py @@ -0,0 +1,117 @@ +import os +import secrets +import pandas as pd + +import numpy as np +from flask import Flask, current_app + + +# Saves file after import. All files are saved as CSV for easier handling +def save_file(form_file): + # Split the extension from the fileName. I'm not using the filename so variable name is '_' according to PEP8 + _, f_ext = os.path.splitext(form_file.filename) + # If not CSV file I will convert it to csv before saving (for easier handling later) + if f_ext != ".csv": + df = pd.read_excel(form_file, index_col=None) + else: + df = pd.read_csv(form_file, index_col=None) + # Removes empty rows/columns + df = remove_nan(df) + # Trims white space from strings + df = trim_strings(df) + # Generate a random hex filename and create the path + file_name = generate_filepath() + file_path = os.path.join(current_app.root_path, "uploads", file_name) + # Save as CSV + df.to_csv(file_path, encoding='utf-8', index=False) + return file_name + +# Uses python secrets to generate a random hex token for the file name +def generate_filepath(): + # Generate a random hex for the filename + random_hex = secrets.token_hex(8) + file_name = random_hex + ".csv" + return file_name + + + +def trim_strings(df): + trimmed = lambda x: x.strip() if isinstance(x, str) else x + return df.applymap(trimmed) + + +# Given a name, locates file and returns the dataframe. +def read_file(name): + return pd.read_csv(os.path.join(current_app.root_path, "uploads", name)) + + +def delete_file(name): + file = os.path.join(current_app.root_path, "uploads", name) + os.remove(file) + + +# A function that removes all leading empty rows/columns +def remove_nan(df): + # Set a flag that is true if the first row is empty (subsequenlty requiring + # the header be reset after empty rows are removed) + if df.iloc[0].isnull().all(axis = 0): + reset_header = True + else: + reset_header = False + # Drops columns + data = df.dropna(how = 'all', axis = 1) + # Drops rows + data = data.dropna(how = 'all', axis = 0) + data = data.reset_index(drop = True) + # if the first row was empty, we reset the header to be the first row in the data + if reset_header: + # Get the first row to be set as the new header + new_header = data.iloc[0] + # Set the remaining data as the dataframe + data = data[1:] + # Set the new header on the dataframe + data.columns = new_header + return data + +# This function loops through each column and collects information on the type of data (numerical vs categorical) +# and the number of unique entries in that column. The type of graph that can be used will depend on the type of data. +# Will also be useful for suggesting to the user about grouping if there are lots of unique entries. +# e.g. if there are 100 different 'ages', can suggest grouping in 10 year batches. +def parse_data(df): + numerics = [np.int64, np.int32, np.int16, np.int8, np.float64, np.float32, np.float16, np.uint64, np.uint32, np.uint16, np.uint8] + column_info = [] + + for (column_title, column_data) in df.iteritems(): + uniques = df[column_title].nunique() + temp_dict = { + "title": column_title + } + temp_dict["num_unique"] = df[column_title].nunique() + if column_data.dtype == np.bool: + temp_dict["data_type"] = "true/false" + temp_dict["quantities"] = column_data.value_counts().to_dict() + elif column_data.dtype in numerics: + temp_dict["data_type"] = "numerical" + # Rounded to 4 significant figures so that it can fit on the page + temp_dict["standard_deviation"] = float('%.4g' % column_data.std()) + temp_dict["average"] = float('%.4g' % column_data.agg("mean")) + temp_dict["max"] = column_data.agg("max"); + temp_dict["min"] = column_data.agg("min"); + temp_dict["sum"] = column_data.agg("sum"); + else: + # Try to parse it as a date/time. If it fails, it must be an object (categorical data) + try: + column_data = pd.to_datetime(column_data, dayfirst=True) + if (column_data.dt.floor('d') == column_data).all(): + temp_dict["data_type"] = "date" + elif (column_data.dt.date == pd.Timestamp('now').date()).all(): + column_data = column_data.dt.time + temp_dict["data_type"] = "time" + else: + temp_dict["data_type"] = "date/time" + temp_dict["num_unique"] = df[column_title].nunique() + except ValueError: + temp_dict["data_type"] = "categorical" + temp_dict["quantities"] = column_data.value_counts().to_dict() + column_info.append(temp_dict) + return column_info diff --git a/site/surveyapp/templates/analysis/analysedata.html b/site/surveyapp/templates/analysis/analysedata.html new file mode 100644 index 0000000..edcd0f1 --- /dev/null +++ b/site/surveyapp/templates/analysis/analysedata.html @@ -0,0 +1,46 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+ {{ form.hidden_tag() }} +
+

What test would you like to apply?

+
+
+ {{ form.test(class="ml-4 custom-select statistical-test") }} +
+
+

+
+
+
+
+

Please select your

+
+
+ {{ form.independent_variable(class="ml-4 custom-select independent-variable") }} +
+
+

+
+
+
+
+

Please select your

+
+
+ {{ form.dependent_variable(class="ml-4 custom-select dependent-variable") }} +
+
+

+
+
+
+
+ {{ form.submit(class="btn btn-primary align-self-center my-3") }} +
+
+
+
+ +{% endblock content %} diff --git a/site/surveyapp/templates/analysis/chisquare.html b/site/surveyapp/templates/analysis/chisquare.html new file mode 100644 index 0000000..ccf1c33 --- /dev/null +++ b/site/surveyapp/templates/analysis/chisquare.html @@ -0,0 +1,46 @@ +{% extends "layout.html" %} {% block content %} +
+
+

Please Enter the expected percentage of each category

+
+ {{ form.hidden_tag() }} +

+ A Chi-square goodness of fit test will compare the values you obtained from + your survey with what you would expect from the sample population. It is used + to determine whether your sample data is consistent with a specific distribution. + The test will assume equal distribution if left as '0'.

+
+ {% for i in range(form.field|length) %} +
+
{{ keys[i] }}
+ {{ form.field[i].expected(class="form-control") }} + {% if form.field[i].errors %} + {% for error in form.field.errors %} + {% for e in error['expected'] %} + {{ e }} + {% endfor %} + {% endfor %} + {% endif %} +
+ {% endfor %} +
+
+
+

Total must equal to 0 or {{ total }}

+
+
+

Current total:

+
+
+
+ {{ form.submit(class="btn btn-primary align-self-center my-3") }} +
+
+
+
+ + +{% endblock content %} diff --git a/site/surveyapp/templates/analysis/quickstats.html b/site/surveyapp/templates/analysis/quickstats.html new file mode 100644 index 0000000..3e7d229 --- /dev/null +++ b/site/surveyapp/templates/analysis/quickstats.html @@ -0,0 +1,76 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+ Back +
+
+

Quick Stats: {{ survey_title }}

+
+
+
+

Number of rows: {{ rows }}

+

Number of columns: {{ cols }}

+
+
+ {% for column in column_info %} +
+
+
{{ column["title"] }}
+
+
+

Data type: {{ column["data_type"] }}

+ {% if column["data_type"] != "true/false" %} +

Unique values: {{ column["num_unique"] }}

+ {% endif %} + {% if column["quantities"] %} +
+
Quantities
+
    + {% for key in column["quantities"] %} +
  • +

    {{ key }}

    +

    {{ column["quantities"][key] }}

    +
  • + {% endfor %} +
+
+ {% endif %} + {% if column["data_type"] == "numerical" %} +
+
Aggregations
+
    +
  • +

    Average

    +

    {{ column["average"] }}

    +
  • +
  • +

    Standard deviation

    +

    {{ column["standard_deviation"] }}

    +
  • +
  • +

    Highest value

    +

    {{ column["max"] }}

    +
  • +
  • +

    Lowest value

    +

    {{ column["min"] }}

    +
  • +
  • +

    Sum of values

    +

    {{ column["sum"] }}

    +
  • +
+
+ {% endif %} +
+
+ {% endfor %} +
+
+ + + + +{% endblock content %} diff --git a/site/surveyapp/templates/analysis/result.html b/site/surveyapp/templates/analysis/result.html new file mode 100644 index 0000000..02be5c8 --- /dev/null +++ b/site/surveyapp/templates/analysis/result.html @@ -0,0 +1,75 @@ +{% extends "layout.html" %} {% block content %} +
+
+

Test results

+
+ {{ form.hidden_tag() }} + {% if form.title.errors %} + {{ form.title(class="py-2 form-control is-invalid") }} +
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.title(class="form-control") }} + {% endif %} + + + + + + + + + + + + + {% if result["test"] == "Chi-Square goodness of fit" %} + + {% elif result["test"] == "Chi-Square Test" %} + + {% else %} + + {% endif %} + + + + {% if result["p"] <= result["alpha"] %} + + {% else %} + + {% endif %} + +
+ Null Hypothesis + Statistical Test + Significance value + P-Value + Conclusion
There is no significant difference between the observed and the expected values.There is no association between {{ result["dv"] }} and {{ result["iv"] }}.The distribution of {{ result["dv"] }} is the same across groups of {{ result["iv"] }}{{ result["test"] }}{{ result["alpha"] }}{{ result["p"] }}Reject the null hypothesisAccept the null hypothesis
+
+ Cancel + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+ + + +{% endblock content %} diff --git a/site/surveyapp/templates/errors/403.html b/site/surveyapp/templates/errors/403.html new file mode 100644 index 0000000..854e72a --- /dev/null +++ b/site/surveyapp/templates/errors/403.html @@ -0,0 +1,11 @@ +{% extends 'layout.html' %} +{% block content %} +
+
+

You don't have permission to access that (403)

+

Check your account and try again

+
+
+ + +{% endblock content %} diff --git a/site/surveyapp/templates/errors/404.html b/site/surveyapp/templates/errors/404.html new file mode 100644 index 0000000..a83cdeb --- /dev/null +++ b/site/surveyapp/templates/errors/404.html @@ -0,0 +1,11 @@ +{% extends 'layout.html' %} +{% block content %} +
+
+

Oops, could not find that page (404)

+

Please try a different url

+
+
+ + +{% endblock content %} diff --git a/site/surveyapp/templates/errors/500.html b/site/surveyapp/templates/errors/500.html new file mode 100644 index 0000000..fc41060 --- /dev/null +++ b/site/surveyapp/templates/errors/500.html @@ -0,0 +1,9 @@ +{% extends 'layout.html' %} +{% block content %} +
+
+

Something went wrong (500)

+

We are having problems at our end. Please try again later.

+
+
+{% endblock content %} diff --git a/site/surveyapp/templates/graphs/barchart.html b/site/surveyapp/templates/graphs/barchart.html new file mode 100644 index 0000000..5bef6cc --- /dev/null +++ b/site/surveyapp/templates/graphs/barchart.html @@ -0,0 +1,31 @@ +{% extends "graphs/graph.html" %} {% block axis %} + +
+
+ +
+

X-axis

+
+ {{ form.x_axis.label() }} + {{ form.x_axis(class="custom-select x-axis-value axis-setting") }} +
+
+ +
+

Y-axis

+
+ {{ form.y_axis.label() }} + {{ form.y_axis(class="custom-select y-axis-value axis-setting") }} +
+
+ {{ form.y_axis_agg.label() }} + {{ form.y_axis_agg(class="custom-select y-axis-aggregation axis-setting") }} +
+
+
+
+ + + + +{% endblock axis %} diff --git a/site/surveyapp/templates/graphs/boxchart.html b/site/surveyapp/templates/graphs/boxchart.html new file mode 100644 index 0000000..a0b3e7c --- /dev/null +++ b/site/surveyapp/templates/graphs/boxchart.html @@ -0,0 +1,30 @@ +{% extends "graphs/graph.html" %} {% block axis %} + +
+
+ +
+

Numerical variable

+
+ {{ form.y_axis.label() }} + {{ form.y_axis(class="custom-select y-axis-value axis-setting") }} +
+ + Don't see your variable? +
+ +
+

Optional categorical variable

+
+ {{ form.x_axis.label() }} + {{ form.x_axis(class="custom-select x-axis-value axis-setting") }} +
+
+
+
+ + + + +{% endblock axis %} diff --git a/site/surveyapp/templates/graphs/choosegraph.html b/site/surveyapp/templates/graphs/choosegraph.html new file mode 100644 index 0000000..76fb123 --- /dev/null +++ b/site/surveyapp/templates/graphs/choosegraph.html @@ -0,0 +1,73 @@ +{% extends "layout.html" %} {% block content %} +
+

What type of graph would you like to make?

+ +
+{% endblock content %} diff --git a/site/surveyapp/templates/graphs/graph.html b/site/surveyapp/templates/graphs/graph.html new file mode 100644 index 0000000..f51ebe7 --- /dev/null +++ b/site/surveyapp/templates/graphs/graph.html @@ -0,0 +1,75 @@ +{% extends "layout.html" %} {% block content %} +
+
+ {{ form.hidden_tag() }} + +
+
+
+ {% if form.title.errors %} + {{ form.title(class="primary-colour form-control is-invalid w-75 title") }} +
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.title(class="primary-colour form-control w-75 title") }} + {% endif %} +
+
+ +
+
+
+
+
+ +
+
+ +
+

Choose your variables to get started

+
+ +
+

+
+
+
+ + {% block axis %} {% endblock %} + +
+
+ +
+
+ + + + + + +{% endblock content %} diff --git a/site/surveyapp/templates/graphs/histogram.html b/site/surveyapp/templates/graphs/histogram.html new file mode 100644 index 0000000..b2485c4 --- /dev/null +++ b/site/surveyapp/templates/graphs/histogram.html @@ -0,0 +1,37 @@ +{% extends "graphs/graph.html" %} {% block axis %} + +
+
+ +
+

X-axis

+
+ {{ form.x_axis.label() }} + {{ form.x_axis(class="custom-select x-axis-value axis-setting") }} +
+ + Don't see your variable? +
+

X-axis range

+
+ {{ form.x_axis_from.label() }} {{ form.x_axis_from(class="form-control extra-setting x-from w-25") }} + {{ form.x_axis_to.label() }} {{ form.x_axis_to(class="form-control extra-setting x-to w-25") }} +
+
+
+ {{ form.group_count.label() }} + {{ form.group_count(class="form-control extra-setting number-groups") }} +
+
+ This is not precise. If you specify the number of groups, the graph will approximate what is possible close to that number. +
+
+ +
+
+ + + + +{% endblock axis %} diff --git a/site/surveyapp/templates/graphs/map.html b/site/surveyapp/templates/graphs/map.html new file mode 100644 index 0000000..e49bcc0 --- /dev/null +++ b/site/surveyapp/templates/graphs/map.html @@ -0,0 +1,26 @@ +{% extends "graphs/graph.html" %} {% block axis %} + +
+
+ +
+

Select Variable

+ Variable must contain country names (e.g. "France") or 3 letter ISO code (e.g. "FRA") +
+ {{ form.variable(class="custom-select x-axis-value axis-setting") }} +
+

Select Scope

+
+ {{ form.scope(class="custom-select scope axis-setting") }} +
+
+ +
+
+ + + + + + +{% endblock axis %} diff --git a/site/surveyapp/templates/graphs/piechart.html b/site/surveyapp/templates/graphs/piechart.html new file mode 100644 index 0000000..67f2177 --- /dev/null +++ b/site/surveyapp/templates/graphs/piechart.html @@ -0,0 +1,32 @@ +{% extends "graphs/graph.html" %} {% block axis %} + +
+
+ +
+

Variable

+
+ {{ form.x_axis.label() }} + {{ form.x_axis(class="custom-select x-axis-value axis-setting") }} +
+
+ +
+

Against (optional)

+
+ {{ form.y_axis.label() }} + {{ form.y_axis(class="custom-select y-axis-value axis-setting") }} +
+
+ {{ form.y_axis_agg.label() }} + {{ form.y_axis_agg(class="custom-select y-axis-aggregation axis-setting") }} +
+
+ +
+
+ + + + +{% endblock axis %} diff --git a/site/surveyapp/templates/graphs/scatterchart.html b/site/surveyapp/templates/graphs/scatterchart.html new file mode 100644 index 0000000..18a6937 --- /dev/null +++ b/site/surveyapp/templates/graphs/scatterchart.html @@ -0,0 +1,47 @@ +{% extends "graphs/graph.html" %} {% block axis %} + +
+
+ +
+

X-axis

+
+ {{ form.x_axis.label() }} + {{ form.x_axis(class="custom-select x-axis-value axis-setting") }} +
+ + Don't see your variable? +
+
+ {{ form.x_axis_from.label() }} {{ form.x_axis_from(class="form-control axis-range x-from w-25") }} + {{ form.x_axis_to.label() }} {{ form.x_axis_to(class="form-control axis-range x-to w-25") }} +
+
+
+ +
+

Y-axis

+
+ {{ form.y_axis.label() }} + {{ form.y_axis(class="custom-select y-axis-value axis-setting") }} +
+
+
+ {{ form.y_axis_from.label() }} {{ form.y_axis_from(class="form-control axis-range y-from w-25") }} + {{ form.y_axis_to.label() }} {{ form.y_axis_to(class="form-control axis-range y-to w-25") }} +
+
+ +
+ +
+
+ + + + +{% endblock axis %} diff --git a/site/surveyapp/templates/layout.html b/site/surveyapp/templates/layout.html new file mode 100644 index 0000000..43adbc3 --- /dev/null +++ b/site/surveyapp/templates/layout.html @@ -0,0 +1,110 @@ + + + + + + {% if title %} + Datasaur - {{ title }} + {% else %} + Datasaur + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ JSGlue.include() }} + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} + + + + + {% block content %} {% endblock %} + + + diff --git a/site/surveyapp/templates/main/feedback.html b/site/surveyapp/templates/main/feedback.html new file mode 100644 index 0000000..214b24b --- /dev/null +++ b/site/surveyapp/templates/main/feedback.html @@ -0,0 +1,192 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+ {{ form.hidden_tag() }} +
+
+ {{ form.enough_graphs.label() }} +
+ {% if form.enough_graphs.errors %} + {{ form.enough_graphs(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.enough_graphs.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.enough_graphs(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.good_graphs.label() }} +
+ {% if form.good_graphs.errors %} + {{ form.good_graphs(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.good_graphs.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.good_graphs(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.enough_tests.label() }} +
+ {% if form.enough_tests.errors %} + {{ form.enough_tests(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.enough_tests.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.enough_tests(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.auto_tests.label() }} +
+ {% if form.auto_tests.errors %} + {{ form.auto_tests(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.auto_tests.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.auto_tests(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.navigation.label() }} +
+ {% if form.navigation.errors %} + {{ form.navigation(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.navigation.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.navigation(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.data_input.label() }} +
+ {% if form.data_input.errors %} + {{ form.data_input(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.data_input.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.data_input(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.export.label() }} +
+ {% if form.export.errors %} + {{ form.export(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.export.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.export(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.effort.label() }} +
+ {% if form.effort.errors %} + {{ form.effort(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.effort.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.effort(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.future_use.label() }} +
+ {% if form.future_use.errors %} + {{ form.future_use(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.future_use.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.future_use(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.user_interface.label() }} +
+ {% if form.user_interface.errors %} + {{ form.user_interface(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.user_interface.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.user_interface(class="list-unstyled d-flex justify-content-between mr-1") }} + {% endif %} +
+
+
+ {{ form.functionality.label() }} +
+ {% if form.functionality.errors %} + {{ form.functionality(class="list-unstyled d-flex justify-content-between mr-1") }} +
+ {% for error in form.functionality.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.functionality(class="list-unstyled d-flex justify-content-between") }} + {% endif %} +
+
+
+ {{ form.comments.label() }} +
+ {% if form.comments.errors %} + {{ form.comments(class="form-control", rows=3) }} +
+ {% for error in form.comments.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.comments(class="form-control bg-light", rows=3) }} + {% endif %} +
+
+ {{ form.submit(class="btn btn-primary align-self-center") }} +
+
+
+
+{% endblock content %} diff --git a/site/surveyapp/templates/main/index.html b/site/surveyapp/templates/main/index.html new file mode 100644 index 0000000..53bad5b --- /dev/null +++ b/site/surveyapp/templates/main/index.html @@ -0,0 +1,127 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+
+
+ Datasaur Logo +
+ +

Making survey analysis easy

+ {% if current_user.is_authenticated %} +
+ +
+ {% else %} + +
+ Already have an account? Login +
+ {% endif %} +
+
+
+
+
+
+
+
+

Colourful data visualisations

+

+ Creating graphical visualisations is easy and stress free, allowing you to quickly derive meaning + from your data. Select from a variety of different graph types and create stunning + data visualisations in just a few clicks. Save your graphs to your dashboard for later viewing + and editing or simply export your finished graph to your device. +

+
+
+ pie-chart image +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ Image of kruskal wallis test +
+
+

Quick analysis

+

+ Analysing data can be a daunting task. With our tools, it is easy to quickly discover any significant findings + in your data by carrying out statistical tests. We will automatically run a series of tests on your data + when you upload your file and alert you of any significant findings. You can also carry out specific tests of your + choosing and we will quickly generate p-values, suggest null hypotheses and advise whether to reject or accept + the null hypothesis. +

+
+
+
+
+
+

Easy data editing

+
+
+ Image of data in a table +
+
+
+

+ Edit your imported CSV and Excel files, or simply enter your data manually from scratch +

+
+
+
+
+
+
+
+
+
+

Datasaur is totally free!

+
    +
  • Unlimited surveys...
  • +
  • Unlimited statistical tests...
  • +
  • Unlimited graphs...
  • +
+
+
+ +
+
+
+
+
+
+ +
+ Site logo made at Logomakr +
+
+{% endblock content %} diff --git a/site/surveyapp/templates/surveys/dashboard.html b/site/surveyapp/templates/surveys/dashboard.html new file mode 100644 index 0000000..81c34ff --- /dev/null +++ b/site/surveyapp/templates/surveys/dashboard.html @@ -0,0 +1,119 @@ +{% extends "layout.html" %} {% block content %} +
+
+ Back +

{{ survey["title"] }}

+ + +
+
+ +
+
+
+

Graphs

+
+ {% if not graphs %} +
+ +
+

No graphs yet! Click here to add your first graph.

+
+ +
+
+
+
+ {% else %} +
+
+ {% for graph in graphs %} + + {% endfor %} +
+
+ +

Add new graph

+ +
+ {% endif %} +
+
+ +
+
+
+

Statistical tests

+
+ {% if not tests %} +
+ +
+

No tests yet! Click here to add your first statistical test.

+
+ +
+
+
+
+ {% else %} + {% for test in tests %} +
+ {{ test["title"] }} +
+
+ +
+
+
+ {% endfor %} + +

Add new tests

+ +
+ {% endif %} +
+
+
+
+ +{% endblock content %} diff --git a/site/surveyapp/templates/surveys/findings.html b/site/surveyapp/templates/surveys/findings.html new file mode 100644 index 0000000..16ee4b0 --- /dev/null +++ b/site/surveyapp/templates/surveys/findings.html @@ -0,0 +1,95 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+
+ Here are a list of significant findings we have found in your data + {% if count != 0 %} +
+ +
+ {% endif %} +
+

Note: please check the assumptions are correct with your data before accepting the result. + Just because a result does not appear here, it does not mean that it is not significant. If a particular test + you are looking for does not appear on this list, you can add it on your survey dashboard page.

+ {% if count == 0 %} +
+

No findings at the moment! Try uploading a file and we will check it for you.

+
+ {% else %} + {% for notification in notifications %} +
+
+
+ {{ form.hidden_tag() }} + {% if notification.result.variable_2 == "" %} + {% set f = form.title.process_data("" + notification.result.variable_1 + ": " + notification.result.test) %} + {% else %} + {% set f = form.title.process_data("" + notification.result.variable_1 + "/" + notification.result.variable_2 + ": " + notification.result.test) %} + {% endif %} + {{ form.title(class="form-control") }} + + + + + + + + + + + + + + + {% if notification.result.p_value == 0.0 %} + + + {% else %} + + {% endif %} + + +
+ Null Hypothesis + Statistical Test + Significance value + P-Value + Conclusion
{{ notification.result.null }}{{ notification.result.test }}0.05<0.001{{ notification.result.p_value }}Reject the null hypothesis
+

{{ notification.result.info }}

+
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+ +
+
+
+ {% endfor %} + {% endif %} +
+
+
+ +{% endblock content %} diff --git a/site/surveyapp/templates/surveys/home.html b/site/surveyapp/templates/surveys/home.html new file mode 100644 index 0000000..dcebeac --- /dev/null +++ b/site/surveyapp/templates/surveys/home.html @@ -0,0 +1,121 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+

+ Your surveys +

+ {% if not surveys %} +
+

No surveys yet! Click here to add your first survey.

+
+ +
+
+ {% else %} + {% for survey in surveys %} + + {% endfor %} +
+

Add new survey

+ +
+ {% endif %} +
+
+
+

Welcome, {{ current_user.first_name }}

+

Add new surveys or click on a survey to start analysing your data.

+
    +
  • Total surveys: + {% if surveys %} + {{ surveys | length }} + {% else %} + 0 + {% endif %}
  • + {% if notifications > 0 %} +
  • + Notifications: + {{ notifications }} +
  • + {% else %} +
  • Notifications: 0
  • + {% endif %} +
+
+ + +
+
+
+
+
+ + + + + + + +{% endblock content %} diff --git a/site/surveyapp/templates/surveys/import.html b/site/surveyapp/templates/surveys/import.html new file mode 100644 index 0000000..4be1cdc --- /dev/null +++ b/site/surveyapp/templates/surveys/import.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} {% block content %} +
+
+
+ {{ form.hidden_tag() }} +
+
Import a file
+
+
+ {{ form.title.label(class="primary-colour") }} + {% if form.title.errors %} + {{ form.title(class="form-control is-invalid") }} +
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.title(class="form-control", placeholder="Enter title") }} + {% endif %} +
+
+ {{ form.file.label(class="primary-colour") }} + {% if form.file.errors %} + {{ form.file(class="m-2 is-invalid") }} +
+ {% for error in form.file.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.file(class="m-2") }} + {% endif %} +
+
+ Go back + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+
+{% endblock content %} diff --git a/site/surveyapp/templates/surveys/input.html b/site/surveyapp/templates/surveys/input.html new file mode 100644 index 0000000..8aa2564 --- /dev/null +++ b/site/surveyapp/templates/surveys/input.html @@ -0,0 +1,116 @@ +{% extends "layout.html" %} {% block content %} +
+
+ +
+
+ {{ form.hidden_tag() }} + {% if survey_id %} +
+ Back +
+ {% endif %} +
+ {% if form.title.errors %} + {{ form.title(class="form-control is-invalid") }} +
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.title(class="form-control", placeholder="Enter title") }} + {% endif %} +
+
+ + Proceed to home + + {{ form.submit(class="btn btn-primary") }} +
+
+
+ + +
+ Click on cells to edit data + +
+
+
+
+
+ New rows will be generated as you enter +
+ +
+
+

Click here to add your first column

+
+ +
+
+
+
+
+ + + + + + + + + + + +{% endblock content %} diff --git a/site/surveyapp/templates/users/account.html b/site/surveyapp/templates/users/account.html new file mode 100644 index 0000000..c56dda7 --- /dev/null +++ b/site/surveyapp/templates/users/account.html @@ -0,0 +1,60 @@ +{% extends "layout.html" %} {% block content %} +
+
+ +
+ + {{ form.hidden_tag() }} +
+
Your account
+
+
+ {{ form.first_name.label(class="primary-colour") }} + {% if form.first_name.errors %} + {{ form.first_name(class="form-control is-invalid") }} +
+ {% for error in form.first_name.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.first_name(class="form-control", placeholder="Enter email") }} + {% endif %} +
+ +
+ {{ form.last_name.label(class="primary-colour") }} + {% if form.last_name.errors %} + {{ form.last_name(class="form-control is-invalid") }} +
+ {% for error in form.last_name.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.last_name(class="form-control", placeholder="Enter password") }} + {% endif %} +
+
+ {{ form.email.label(class="primary-colour") }} + {% if form.email.errors %} + {{ form.email(class="form-control is-invalid") }} +
+ {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.email(class="form-control", placeholder="Enter password") }} + {% endif %} +
+
+ Cancel + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+
+{% endblock content %} diff --git a/site/surveyapp/templates/users/login.html b/site/surveyapp/templates/users/login.html new file mode 100644 index 0000000..8a7eadc --- /dev/null +++ b/site/surveyapp/templates/users/login.html @@ -0,0 +1,66 @@ +{% extends "layout.html" %} {% block content %} +
+
+ +
+ + {{ form.hidden_tag() }} +
+
Login
+
+
+ {{ form.email.label(class="primary-colour") }} + {% if form.email.errors %} + {{ form.email(class="form-control is-invalid") }} +
+ {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.email(class="form-control", placeholder="Enter email") }} + {% endif %} +
+ +
+ {{ form.password.label(class="primary-colour") }} + {% if form.password.errors %} + {{ form.password(class="form-control is-invalid") }} +
+ {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password(class="form-control", placeholder="Enter password") }} + {% endif %} +
+
+
+ {{ form.remember(class="form-check-input") }} + {{ form.remember.label(class="form-check-label") }} +
+ + Forgot password? + +
+ +
+ Cancel + {{ form.submit(class="btn btn-primary") }} +
+ +
+ +
+ +
+
+
+ + Need to sign up? Register + +
+ +
+{% endblock content %} diff --git a/site/surveyapp/templates/users/register.html b/site/surveyapp/templates/users/register.html new file mode 100644 index 0000000..5a5b7a2 --- /dev/null +++ b/site/surveyapp/templates/users/register.html @@ -0,0 +1,95 @@ +{% extends "layout.html" %} {% block content %} +
+
+ +
+ + {{ form.hidden_tag() }} +
+
Register
+
+
+ {{ form.first_name.label(class="primary-colour") }} + {% if form.first_name.errors %} + {{ form.first_name(class="form-control is-invalid") }} +
+ {% for error in form.first_name.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.first_name(class="form-control", placeholder="Enter first name") }} + {% endif %} +
+
+ {{ form.last_name.label(class="primary-colour") }} + {% if form.last_name.errors %} + {{ form.last_name(class="form-control is-invalid") }} +
+ {% for error in form.last_name.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.last_name(class="form-control", placeholder="Enter last name") }} + {% endif %} +
+
+ {{ form.email.label(class="primary-colour") }} + {% if form.email.errors %} + {{ form.email(class="form-control is-invalid") }} +
+ {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.email(class="form-control", placeholder="Enter email") }} + We will never share your email with anyone else. + {% endif %} +
+ +
+ {{ form.password.label(class="primary-colour") }} + {% if form.password.errors %} + {{ form.password(class="form-control is-invalid") }} +
+ {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password(class="form-control", placeholder="Enter password") }} + {% endif %} +
+ +
+ {{ form.password2.label(class="primary-colour") }} + {% if form.password2.errors %} + {{ form.password2(class="form-control is-invalid") }} +
+ {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password2(class="form-control", placeholder="Re-enter password") }} + {% endif %} +
+ +
+ Cancel + {{ form.submit(class="btn btn-primary") }} +
+ +
+
+
+
+
+ + Already have an account? Login + +
+
+{% endblock content %} diff --git a/site/surveyapp/templates/users/request_reset.html b/site/surveyapp/templates/users/request_reset.html new file mode 100644 index 0000000..0c9342c --- /dev/null +++ b/site/surveyapp/templates/users/request_reset.html @@ -0,0 +1,33 @@ +{% extends "layout.html" %} {% block content %} +
+
+ +
+ + {{ form.hidden_tag() }} +
+
Reset Password
+
+
+ {{ form.email.label(class="primary-colour") }} + {% if form.email.errors %} + {{ form.email(class="form-control is-invalid") }} +
+ {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.email(class="form-control", placeholder="Enter email") }} + {% endif %} +
+
+ Cancel + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+
+{% endblock content %} diff --git a/site/surveyapp/templates/users/reset_password.html b/site/surveyapp/templates/users/reset_password.html new file mode 100644 index 0000000..4f3ff37 --- /dev/null +++ b/site/surveyapp/templates/users/reset_password.html @@ -0,0 +1,46 @@ +{% extends "layout.html" %} {% block content %} +
+
+ +
+ + {{ form.hidden_tag() }} +
+
Reset Password
+
+
+ {{ form.password.label(class="primary-colour") }} + {% if form.password.errors %} + {{ form.password(class="form-control is-invalid") }} +
+ {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password(class="form-control", placeholder="Enter password") }} + {% endif %} +
+
+ {{ form.password2.label(class="primary-colour") }} + {% if form.password2.errors %} + {{ form.password2(class="form-control is-invalid") }} +
+ {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password2(class="form-control", placeholder="Enter password") }} + {% endif %} +
+
+ Cancel + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+
+{% endblock content %} diff --git a/site/surveyapp/uploads/.gitkeep b/site/surveyapp/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/users/__init__.py b/site/surveyapp/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/surveyapp/users/forms.py b/site/surveyapp/users/forms.py new file mode 100644 index 0000000..cf1fd19 --- /dev/null +++ b/site/surveyapp/users/forms.py @@ -0,0 +1,60 @@ +from flask_wtf import FlaskForm +from flask_login import current_user +from wtforms import StringField, PasswordField, SubmitField, BooleanField +from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError +from surveyapp import mongo, login_manager + + +class RegistrationForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=30)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=30)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=6)]) + password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Register') + + # This method follows the custom validator pattern outlined in WTForms documentation + # When the form is validated, it checks if a user already exists with that email + def validate_email(self, email): + user_exists = mongo.db.users.find_one({"email" : email.data}) + if user_exists: + raise ValidationError("Account already exists with that email address.") + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember = BooleanField('Remember Me') + submit = SubmitField('Login') + + +class UpdateAccountForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=30)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=30)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + submit = SubmitField('Update') + + # Similar to method above in RegsitrationForm, checks if user exists with that Email + # before allowing user to change email + def validate_email(self, email): + if email.data != current_user.email: + user_exists = mongo.db.users.find_one({"email" : email.data}) + if user_exists: + raise ValidationError("Account already exists with that email address.") + + +class RequestPasswordForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + submit = SubmitField('Request password reset') + + # Opposite of methods above, checks if user doesn't exist with the given email + def validate_email(self, email): + user_exists = mongo.db.users.find_one({"email" : email.data}) + if not user_exists: + raise ValidationError("No account exists with that email.") + + +class PasswordResetForm(FlaskForm): + password = PasswordField('Password', validators=[DataRequired(), Length(min=6)]) + password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Update password') diff --git a/site/surveyapp/users/routes.py b/site/surveyapp/users/routes.py new file mode 100644 index 0000000..a1b2c6b --- /dev/null +++ b/site/surveyapp/users/routes.py @@ -0,0 +1,112 @@ +from surveyapp import mongo, bcrypt, mail +from flask import Flask, render_template, url_for, request, flash, redirect, Blueprint +# syntax for accessing files inside packages +from surveyapp.users.forms import (RegistrationForm, LoginForm, +UpdateAccountForm, RequestPasswordForm, PasswordResetForm) +from flask_login import current_user, login_user, logout_user, login_required +from surveyapp.models import User +from surveyapp.users.utils import send_email + + +users = Blueprint("users", __name__) + +@users.route("/register", methods=["GET", "POST"]) +def register(): + form = RegistrationForm() + # Checks validation when form is submitted with submit button + if form.validate_on_submit(): + # Decode with UTF-8 to make the hash a string rather than byte-code + password_hash = bcrypt.generate_password_hash(form.password.data).decode("utf-8") + # One user comment was to request the email validation to not be case sensitive. It is therefore saved in the database as lower case. + mongo.db.users.insert_one({\ + "firstName" : form.first_name.data,\ + "lastName" : form.last_name.data,\ + "email" : form.email.data.lower(),\ + "password" : password_hash}) + flash("Account created successfully! You can now login.", "success") + return redirect(url_for("users.login")) + return render_template("users/register.html", title = "Register", form = form) + + +@users.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + form = LoginForm() + if form.validate_on_submit(): + user = mongo.db.users.find_one({"email": form.email.data.lower()}) + if user and bcrypt.check_password_hash(user["password"], form.password.data): + user_obj = User(email=user["email"], first_name=user["firstName"], last_name=user["lastName"], _id=user["_id"]) + # logs user into a session + login_user(user_obj, remember=form.remember.data) + # retrieve the page the user was attempting to access previously from the URL + next_page = request.args.get("next") + # and redirect them to that page (if it exists) using a ternary conditional + flash("Login successful.", "success") + return redirect(next_page) if next_page else redirect(url_for("main.index")) + else: + flash("Invalid username or password", "danger") + return render_template("users/login.html", title = "Login", form = form) + + +@users.route('/logout') +def logout(): + logout_user() + flash("Logout successful.", "success") + return redirect(url_for('main.index')) + + +@users.route('/account', methods=["GET", "POST"]) +@login_required +def account(): + form = UpdateAccountForm() + if form.validate_on_submit(): + # Update the 'current_user' object + current_user.first_name = form.first_name.data + current_user.last_name = form.last_name.data + current_user.email = form.email.data + # Update the user in the database + mongo.db.users.update_one({"_id": current_user._id}, + {"$set": {"firstName": form.first_name.data,\ + "lastName": form.last_name.data,\ + "email": form.email.data.lower()}}) + flash("Account details updated.", "success") + return redirect(url_for('users.account')) + form.first_name.data = current_user.first_name + form.last_name.data = current_user.last_name + form.email.data = current_user.email + return render_template("users/account.html", title = "Account", form = form) + + +@users.route('/reset_password', methods=["GET", "POST"]) +def request_reset(): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + form = RequestPasswordForm() + if form.validate_on_submit(): + user = mongo.db.users.find_one({"email": form.email.data.lower()}) + user_obj = User(email=user["email"], first_name=user["firstName"], last_name=user["lastName"], _id=user["_id"]) + send_email(user_obj) + flash("An email has been sent with a password reset link", "success") + return redirect(url_for("users.login")) + return render_template("users/request_reset.html", title="Password Reset", form=form) + + +@users.route('/reset_password/', methods=["GET", "POST"]) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + user = User.verify_reset_token(token) + if not user: + flash("That token is invalid or has expired", "warning") + return redirect(url_for("request_reset")) + form = PasswordResetForm() + if form.validate_on_submit(): + # Decode with UTF-8 to make the hash a string rather than byte-code + password_hash = bcrypt.generate_password_hash(form.password.data).decode("utf-8") + # One user comment was to request the email validation to not be case sensitive. It is therefore saved in the database as lower case. + mongo.db.users.update_one({"_id": user["_id"]}, + {"$set": {"password": password_hash}}) + flash("Password updated! You can now login.", "success") + return redirect(url_for("users.login")) + return render_template("users/reset_password.html", title="Password Reset", form=form) diff --git a/site/surveyapp/users/utils.py b/site/surveyapp/users/utils.py new file mode 100644 index 0000000..48c9346 --- /dev/null +++ b/site/surveyapp/users/utils.py @@ -0,0 +1,15 @@ +from flask import url_for +from surveyapp import mail +from flask_mail import Message + + + +def send_email(user): + token = user.get_reset_token() + message = Message("Reset password request", sender="noreply@datasaur.com", recipients=[user.email]) + message.body = f"""You have requested a password reset for datasaur.dev\n + Please follow the link to reset your password.\n + {url_for("users.reset_password", token=token, _external=True)} + \nIf you did not request this email then please ignore it! + """ + mail.send(message)