initial commit

This commit is contained in:
georgew 2026-01-25 15:56:01 +00:00
commit 38701a3017
119 changed files with 8770 additions and 0 deletions

3
.cache/v/cache/lastfailed vendored Normal file
View file

@ -0,0 +1,3 @@
{
"site/test_surveyapp.py": true
}

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
SECRET_KEY=yourSecretKey
MONGO_URI=yourMongoDbUri
EMAIL_USERNAME=email@example.com
EMAIL_PASSWORD=yourEmailPassword

18
.gitignore vendored Normal file
View file

@ -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/

31
Dockerfile Normal file
View file

@ -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"]

26
Pipfile Normal file
View file

@ -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"

654
Pipfile.lock generated Normal file
View file

@ -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": {}
}

5
README.md Normal file
View file

@ -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)

19
docker-compose.yaml Normal file
View file

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

232
documentation.md Normal file
View file

@ -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.
```
<script type="text/javascript" src="node_modules/d3/build/d3.js"></script>
```
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:
```
<script src="https://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript" src="https://unpkg.com/canvg@3.0.4/lib/umd.js"></script>
```
NOTE: DataMap requires version 3 of D3 and therefore you will need to include it separately (along with topojson):
```
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.5.3/d3.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/topojson/1.6.9/topojson.min.js"></script>
```
### 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
```

BIN
images/DataTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
images/GraphTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
images/UserTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

3
site/.cache/v/cache/lastfailed vendored Normal file
View file

@ -0,0 +1,3 @@
{
"test_surveyapp.py": true
}

2
site/.flaskenv Normal file
View file

@ -0,0 +1,2 @@
FLASK_APP=run.py
FLASK_ENV=development

8
site/run.py Normal file
View file

@ -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)

View file

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

View file

View file

@ -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")

View file

@ -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/<survey_id>", 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/<survey_id>/<variable>", 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/<survey_id>/<test_id>/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/<survey_id>", 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/<survey_id>", 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)

View file

@ -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/<survey_id>", 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/<survey_id>/<variable>", 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/<survey_id>/<test_id>/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/<survey_id>", 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/<survey_id>", 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)

View file

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

33
site/surveyapp/config.py Normal file
View file

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

View file

View file

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

View file

View file

@ -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.")

View file

@ -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/<survey_id>', 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/<survey_id>', 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/<survey_id>/<graph_id>/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))

View file

@ -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."
}
]

View file

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

View file

View file

@ -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')

View file

@ -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)

54
site/surveyapp/models.py Normal file
View file

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

View file

@ -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. */

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,37 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="591.000000pt" height="591.000000pt" viewBox="0 0 591.000000 591.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,591.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M783 5475 c-127 -55 -256 -195 -300 -326 -24 -71 -24 -179 0 -241 32
-83 128 -142 267 -164 79 -13 181 -44 218 -68 27 -17 45 -39 60 -74 19 -44 72
-249 85 -327 3 -16 12 -63 21 -102 9 -40 18 -87 21 -105 3 -18 12 -62 20 -98
8 -36 17 -76 19 -90 12 -67 71 -319 101 -432 81 -297 124 -394 255 -565 88
-114 270 -370 270 -378 0 -3 16 -30 36 -61 74 -115 115 -217 131 -321 26 -174
22 -352 -11 -510 -8 -37 -19 -104 -25 -148 -37 -283 -33 -445 13 -556 7 -15
-24 -16 -346 -13 -194 1 -550 3 -790 4 l-438 2 0 47 c-1 178 2 3292 3 3769 2
327 0 595 -4 598 -5 3 -232 8 -266 5 -9 -1 -12 -416 -13 -1718 0 -945 -2
-2001 -3 -2348 l-2 -630 110 -1 c61 -1 637 -4 1280 -8 644 -4 1393 -9 1665
-11 1059 -8 1630 -8 1637 -1 9 9 12 265 4 267 -5 1 -201 5 -476 9 -60 0 -76 3
-62 11 52 31 59 122 25 343 -27 174 -31 396 -8 441 26 53 78 69 220 72 109 2
173 -3 380 -32 70 -10 371 -71 520 -105 74 -17 144 -33 155 -35 11 -3 64 -14
118 -26 54 -11 108 -18 121 -15 29 7 46 50 35 91 -8 29 -15 32 -246 108 -565
187 -874 338 -1053 517 -58 57 -81 89 -100 135 -78 198 -145 301 -285 440
-254 252 -610 445 -996 540 -57 15 -113 28 -124 29 -77 12 -142 20 -155 19
-29 -2 -115 -15 -121 -19 -3 -1 -28 -6 -55 -9 -27 -4 -65 -10 -84 -15 -73 -18
-138 -24 -240 -25 -123 0 -140 7 -228 93 -95 92 -175 207 -241 346 -35 76 -45
105 -121 366 -105 356 -206 652 -286 834 -67 152 -146 248 -283 343 -25 18
-51 37 -57 42 -18 15 -145 78 -202 100 -61 24 -123 26 -169 6z m2332 -3858
c11 -3 40 -8 65 -12 25 -3 57 -8 71 -11 14 -2 56 -13 92 -24 84 -26 123 -70
203 -229 33 -64 61 -118 64 -121 3 -3 6 -9 7 -15 3 -16 96 -219 102 -225 3 -3
11 -14 17 -25 6 -11 28 -29 48 -39 20 -11 36 -23 36 -28 0 -8 -1439 -3 -1447
6 -3 2 2 15 11 28 17 26 32 112 41 233 3 44 8 91 10 105 2 14 7 49 11 79 9 85
42 178 75 216 30 34 73 52 149 61 18 3 36 6 38 9 5 5 381 -2 407 -8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

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

View file

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

View file

@ -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: <strong class="badge badge-dark">${d.value.q1}</strong>
Q3: <strong class="badge badge-dark">${d.value.q3}</strong>
Median: <strong class="badge badge-dark">${d.value.median}</strong>
Max: <strong class="badge badge-dark">${d.value.max}</strong>
Min: <strong class="badge badge-dark">${d.value.min}</strong>
`;
// 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) + "<br>" + 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)
});

View file

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

View file

@ -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( "<div id='graph' class='h-100 position-relative'/>");
$("#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 '<div class="hoverinfo"><strong>' + geography.properties.name + '</strong></div>';
}
return '<div class="hoverinfo"><strong>' + geography.properties.name + ':</strong> ' + data.value + '</div>';
}
},
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)
});

View file

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

View file

@ -0,0 +1,51 @@
function postgraph(width, height){
const svgNode = document.getElementsByTagName("svg")[0]
const doctype = '<?xml version="1.0" standalone="no"?>'
+ '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">';
// 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;
}

View file

@ -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 = '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [<!ENTITY nbsp "&#160;">]>';
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 = `<![CDATA[\n${css}\n]]>`;
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));
};
})();

View file

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

View file

@ -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();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

View file

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

811
site/surveyapp/static/package-lock.json generated Normal file
View file

@ -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=="
}
}
}

View file

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

View file

@ -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()

View file

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

View file

@ -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 = `<span data-toggle='tooltip' class="badge badge-dark ml-3 help"
title='Nominal data has no numeric value (i.e. cannot be measured) and has no natural order to it. An example could be race, gender or yes/no questions.'>
Nominal</span>`
const ordinal = `<span data-toggle='tooltip' class="badge badge-dark ml-3 help"
title='Ordinal data has no numeric value but it does have a natural order. An example could be positions in a race (first, second, third) or Likert type questions (answers ranging from strongly agree to strongly disagree).'>
Ordinal</span>`
const interval = `<span data-toggle='tooltip' class="badge badge-dark ml-3 help"
title='Interval data has numeric value, with equal value between each point, but 0 does not mean absolute 0. An example could be temperature in Celcius (0°C does not mean absolute 0 since you can have negative degree Celcius)'>
Interval</span>`
const ratio = `<span data-toggle='tooltip' class="badge badge-dark ml-3 help"
title='Ratio data is similar to interval data in that it has numeric value, but it also has absolute 0 (i.e. no negative values). An example could be weight or height.'>
Ratio</span>`
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()

View file

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

Binary file not shown.

View file

View file

@ -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")

View file

@ -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/<survey_id>', 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/<survey_id>', 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/<result_id>/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/<survey_id>/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/<survey_id>/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)

View file

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

View file

@ -0,0 +1,46 @@
{% extends "layout.html" %} {% block content %}
<main class="container">
<div class="p-0 rounded bg-white mt-3 shadow-sm border">
<form action="" method="post">
{{ form.hidden_tag() }}
<section>
<h4 class="text-light py-2 pl-4 primary-colour-bg rounded-top">What test would you like to apply?</h4>
<div class="row">
<div class="col-sm-4">
{{ form.test(class="ml-4 custom-select statistical-test") }}
</div>
<div class="col-sm-8">
<p class="px-4 test-info"></p>
</div>
</div>
</section>
<section class="primary-transition independent-variables hidden-down my-4">
<h4 class="primary-colour-bg text-light py-2 pl-4">Please select your <span class="first-variable-question"></span></h4>
<div class="row">
<div class="col-sm-4">
{{ form.independent_variable(class="ml-4 custom-select independent-variable") }}
</div>
<div class="col-sm-8">
<p class="px-4 iv-info"></p>
</div>
</div>
</section>
<section class="primary-transition dependent-variables hidden-down my-4">
<h4 class="primary-colour-bg text-light py-2 pl-4">Please select your <span class="second-variable-question"></span></h4>
<div class="row">
<div class="col-sm-4">
{{ form.dependent_variable(class="ml-4 custom-select dependent-variable") }}
</div>
<div class="col-sm-8">
<p class="px-4 dv-info"></p>
</div>
</div>
</section>
<section class="primary-transition analyse-continue hidden-down d-flex flex-column border-top">
{{ form.submit(class="btn btn-primary align-self-center my-3") }}
</section>
</form>
</div>
</main>
<script src="{{ url_for('static', filename='statscripts/statistics.js') }}" defer></script>
{% endblock content %}

View file

@ -0,0 +1,46 @@
{% extends "layout.html" %} {% block content %}
<main class="container">
<div class="p-0 rounded bg-white mt-3 shadow-sm border">
<h3 class="text-light py-2 pl-4 primary-colour-bg rounded-top">Please Enter the expected percentage of each category</h3>
<form action="" method="post">
{{ form.hidden_tag() }}
<p class="mt-4 px-4 primary-colour">
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'.</p>
<section class="row">
{% for i in range(form.field|length) %}
<div class="col-sm-3 mx-4 mt-2 d-flex flex-column mb-4">
<h5 class="primary-colour">{{ keys[i] }}</h5>
{{ form.field[i].expected(class="form-control") }}
{% if form.field[i].errors %}
{% for error in form.field.errors %}
{% for e in error['expected'] %}
<small>{{ e }}</small>
{% endfor %}
{% endfor %}
{% endif %}
</div>
{% endfor %}
</section>
<section class="row">
<div class="col-sm-4 ml-4">
<h4 class="primary-colour">Total must equal to 0 or {{ total }}</h4>
</div>
<div class="col-sm-4 ml-4">
<h4 class="primary-colour">Current total: <span class="chi-total"></span></h4>
</div>
</section>
<section class="analyse-continue hidden-down d-flex flex-column border-top">
{{ form.submit(class="btn btn-primary align-self-center my-3") }}
</section>
</form>
</div>
</main>
<script>
// Variable passed to javascript file from Jinja2
var totalChi = {{ total }};
</script>
<script src="{{ url_for('static', filename='statscripts/chi.js') }}" defer></script>
{% endblock content %}

View file

@ -0,0 +1,76 @@
{% extends "layout.html" %} {% block content %}
<main class="container">
<section class="row mt-4">
<div class="col-3">
<a class="btn btn-secondary" href="{{ url_for('surveys.dashboard', survey_id=survey_id) }}"
title="Return to the survey dashboard page">Back</a>
</div>
<div class="col-6">
<h3 class="mt-2 primary-colour text-center">Quick Stats: {{ survey_title }}</h3>
</div>
</section>
<section class="my-3 d-flex justify-content-center bg-white rounded shadow border p-2">
<h4 class="mx-4 my-0 font-weight-light">Number of rows: {{ rows }}</h4>
<h4 class="mx-4 my-0 font-weight-light">Number of columns: {{ cols }}</h4>
</section>
<section class="stats-grid">
{% for column in column_info %}
<article class="bg-light shadow rounded grid-element">
<div class="text-center">
<h5 class="primary-colour-bg p-2 text-light rounded-top">{{ column["title"] }}</h5>
</div>
<div class="p-3">
<p class="lead">Data type: {{ column["data_type"] }}</p>
{% if column["data_type"] != "true/false" %}
<p class="lead">Unique values: {{ column["num_unique"] }}</p>
{% endif %}
{% if column["quantities"] %}
<section class="bg-white border rounded shadow-sm">
<h5 class="text-light bg-secondary p-2 rounded-top mb-0">Quantities</h5>
<ul class="list-group text-dark">
{% for key in column["quantities"] %}
<li class="list-group-item d-flex justify-content-between">
<p class="m-0">{{ key }}</p>
<p class="m-0">{{ column["quantities"][key] }}</p>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if column["data_type"] == "numerical" %}
<section class="bg-white border rounded shadow-sm">
<h5 class="text-light bg-secondary p-2 rounded-top mb-0">Aggregations</h5>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between">
<p class="m-0">Average</p>
<p class="m-0">{{ column["average"] }}</p>
</li>
<li class="list-group-item d-flex justify-content-between">
<p class="m-0">Standard deviation</p>
<p class="m-0">{{ column["standard_deviation"] }}</p>
</li>
<li class="list-group-item d-flex justify-content-between">
<p class="m-0">Highest value</p>
<p class="m-0">{{ column["max"] }}</p>
</li>
<li class="list-group-item d-flex justify-content-between">
<p class="m-0">Lowest value</p>
<p class="m-0">{{ column["min"] }}</p>
</li>
<li class="list-group-item d-flex justify-content-between">
<p class="m-0">Sum of values</p>
<p class="m-0">{{ column["sum"] }}</p>
</li>
</ul>
</section>
{% endif %}
</div>
</article>
{% endfor %}
</section>
</main>
<!-- Script for masonry - allowing for easy display of variable sized grid elements -->
<script src="https://unpkg.com/masonry-layout@4/dist/masonry.pkgd.min.js" defer></script>
<script src="{{ url_for('static', filename='statscripts/quickstats.js') }}" defer></script>
{% endblock content %}

View file

@ -0,0 +1,75 @@
{% extends "layout.html" %} {% block content %}
<main class="container">
<section class="p-0 rounded bg-white mt-3 shadow-sm border">
<h3 class="text-light py-2 pl-4 primary-colour-bg rounded-top">Test results</h3>
<form class="mt-4 px-4" action="" method="post">
{{ form.hidden_tag() }}
{% if form.title.errors %}
{{ form.title(class="py-2 form-control is-invalid") }}
<div class="text-danger">
{% for error in form.title.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% else %}
{{ form.title(class="form-control") }}
{% endif %}
<table class="table table-bordered mt-3">
<thead class="thead-light">
<tr>
<th class="help" scope="col"data-toggle="tooltip"
title="This is the hypothesis that there are NO significant differences of characteristics between specifed populations.">
Null Hypothesis</th>
<th class="help" scope="col"data-toggle="tooltip"
title="This is the type of statistical test used to test the null hypothesis.">
Statistical Test</th>
<th class="help" scope="col" data-toggle="tooltip" data-placement="left"
title="Sometimes called 'Alpha', the significance value is the probability of rejecting the null hypothesis when it is true.
It can be though of as a 'cut-off' point; when the p-value is lower you can reject the null hypothesis. We have set this to a default
of 0.05 (indicating a 5% risk of no actual difference when concluding that a difference exists).">
Significance value</th>
<th class="help" scope="col" data-toggle="tooltip" data-placement="left"
title="If we assume that the null hypothesis is true, the p-value gives the probability of obtaining the results as extreme as the
observed results of the statistical test. This value is compared against the significance value, with a lower p-value meaning we
can reject the null hypothesis.">
P-Value</th>
<th class="help" scope="col" data-toggle='tooltip'
title='Whether we should reject or accept the null hypothesis.'>
Conclusion</th>
</tr>
</thead>
<tr class="primary-colour">
{% if result["test"] == "Chi-Square goodness of fit" %}
<td>There is no significant difference between the observed and the expected values.</td>
{% elif result["test"] == "Chi-Square Test" %}
<td>There is no association between <strong>{{ result["dv"] }}</strong> and <strong>{{ result["iv"] }}</strong>.</td>
{% else %}
<td>The distribution of <strong>{{ result["dv"] }}</strong> is the same across groups of <strong>{{ result["iv"] }}</strong></td>
{% endif %}
<td>{{ result["test"] }}</td>
<td>{{ result["alpha"] }}</td>
<td>{{ result["p"] }}</td>
{% if result["p"] <= result["alpha"] %}
<td>Reject the null hypothesis</td>
{% else %}
<td>Accept the null hypothesis</td>
{% endif %}
</tr>
</table>
<div class="d-flex justify-content-between mb-3">
<a class="btn btn-secondary" href="{{ url_for('surveys.dashboard', survey_id=survey_id) }}"
title="Cancel and return to the survey dashboard page">Cancel</a>
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</section>
</main>
<script type="text/javascript">
$(function () {
$("[data-toggle='tooltip']").tooltip();
});
</script>
{% endblock content %}

View file

@ -0,0 +1,11 @@
{% extends 'layout.html' %}
{% block content %}
<main class="container">
<div class="bg-white rounded shadow-sm border mt-4 p-3 primary-colour">
<h1>You don't have permission to access that (403)</h1>
<p>Check your account and try again</p>
</div>
</main>
{% endblock content %}

View file

@ -0,0 +1,11 @@
{% extends 'layout.html' %}
{% block content %}
<main class="container">
<div class="bg-white rounded shadow-sm border mt-4 p-3 primary-colour">
<h1>Oops, could not find that page (404)</h1>
<p>Please try a different url</p>
</div>
</main>
{% endblock content %}

View file

@ -0,0 +1,9 @@
{% extends 'layout.html' %}
{% block content %}
<main class="container">
<div class="bg-white rounded shadow-sm border mt-4 p-3 primary-colour">
<h1>Something went wrong (500)</h1>
<p>We are having problems at our end. Please try again later.</p>
</div>
</main>
{% endblock content %}

View file

@ -0,0 +1,31 @@
{% extends "graphs/graph.html" %} {% block axis %}
<!-- Axes settings -->
<section class="col-md-3 order-md-1">
<div class="h-100 bg-gradient text-light rounded p-3">
<!-- ------X AXIS------ -->
<section class="x-axis-details">
<h3>X-axis</h3>
<div class="axis-variable">
{{ form.x_axis.label() }}
{{ form.x_axis(class="custom-select x-axis-value axis-setting") }}
</div>
</section>
<!-- ------Y AXIS------ -->
<section class="y-axis-details primary-transition hidden-down">
<h3>Y-axis</h3>
<div class="axis-variable">
{{ form.y_axis.label() }}
{{ form.y_axis(class="custom-select y-axis-value axis-setting") }}
</div>
<div class="aggregate hidden-down">
{{ form.y_axis_agg.label() }}
{{ form.y_axis_agg(class="custom-select y-axis-aggregation axis-setting") }}
</div>
</section>
</div>
</section>
<!-- End of Axis settings -->
<!-- Scripts specifically for barchart -->
<script src="https://d3js.org/d3.v5.min.js" defer></script>
<script src="{{ url_for('static', filename='graphscripts/barchart.js') }}" defer></script>
{% endblock axis %}

View file

@ -0,0 +1,30 @@
{% extends "graphs/graph.html" %} {% block axis %}
<!-- Axes settings -->
<section class="col-md-3 order-md-1">
<div class="h-100 bg-gradient text-light rounded p-3">
<!-- ------Y AXIS------ -->
<section class="y-axis-details">
<h3>Numerical variable</h3>
<div class="axis-variable">
{{ form.y_axis.label() }}
{{ form.y_axis(class="custom-select y-axis-value axis-setting") }}
</div>
<small data-toggle='help' class="text-light help mb-3"
title="A box and whisker plot requires a numerical variable. If you don't see your variable it is because it was not recognised as numerical.">
<i class="far fa-question-circle mr-2"></i>Don't see your variable?</small>
</section>
<!-- ------X AXIS------ -->
<section class="x-axis-details hidden-down">
<h3>Optional categorical variable</h3>
<div class="axis-variable">
{{ form.x_axis.label() }}
{{ form.x_axis(class="custom-select x-axis-value axis-setting") }}
</div>
</section>
</div>
</section>
<!-- End of Axis settings -->
<!-- Scripts specifically for barchart -->
<script src="https://d3js.org/d3.v5.min.js" defer></script>
<script src="{{ url_for('static', filename='graphscripts/boxchart.js') }}" defer></script>
{% endblock axis %}

View file

@ -0,0 +1,73 @@
{% extends "layout.html" %} {% block content %}
<main class="container">
<h3 class="pb-2 mt-4 mb-2 border-bottom primary-colour">What type of graph would you like to make?</h3>
<div class="row">
<section class="col-sm-4 my-3">
<a class="text-decoration-none" href="{{ url_for('graphs.graph', survey_id=survey_id, chart_type='Bar chart') }}"
title="Go to the Bar chart creation page.">
<div class="card shadow-sm">
<img class="card-img-top" src="{{ url_for('static', filename='images/siteimages/barchart.png') }}" alt="Bar chart image">
<div class="card-body rounded-bottom primary-colour-bg text-light">
<h5 class="card-title text-light">Bar Chart</h5>
</div>
</div>
</a>
</section>
<section class="col-sm-4 my-3">
<a class="text-decoration-none" href="{{ url_for('graphs.graph', survey_id=survey_id, chart_type='Scatter chart') }}"
title="Go to the Scatter chart creation page.">
<div class="card shadow-sm">
<img class="card-img-top" src="{{ url_for('static', filename='images/siteimages/scatterchart.png') }}" alt="Scatter chart image">
<div class="card-body rounded-bottom primary-colour-bg text-light">
<h5 class="card-title text-light">Scatter Chart</h5>
</div>
</div>
</a>
</section>
<section class="col-sm-4 my-3">
<a class="text-decoration-none" href="{{ url_for('graphs.graph', survey_id=survey_id, chart_type='Pie chart') }}"
title="Go to the Pie chart creation page.">
<div class="card shadow-sm">
<img class="card-img-top" src="{{ url_for('static', filename='images/siteimages/piechart.png') }}" alt="Pie chart image">
<div class="card-body rounded-bottom primary-colour-bg text-light">
<h5 class="card-title text-light">Pie Chart</h5>
</div>
</div>
</a>
</section>
<section class="col-sm-4 my-3">
<a class="text-decoration-none" href="{{ url_for('graphs.graph', survey_id=survey_id, chart_type='Histogram') }}"
title="Go to the Histogram creation page.">
<div class="card shadow-sm">
<img class="card-img-top" src="{{ url_for('static', filename='images/siteimages/histogram.png') }}" alt="Histogram image">
<div class="card-body rounded-bottom primary-colour-bg text-light">
<h5 class="card-title text-light">Histogram</h5>
</div>
</div>
</a>
</section>
<section class="col-sm-4 my-3">
<a class="text-decoration-none" href="{{ url_for('graphs.graph', survey_id=survey_id, chart_type='Map') }}"
title="Go to the Map graph creation page.">
<div class="card shadow-sm">
<img class="card-img-top" src="{{ url_for('static', filename='images/siteimages/map.png') }}" alt="Map chart image">
<div class="card-body rounded-bottom primary-colour-bg text-light">
<h5 class="card-title text-light">Map Chart</h5>
</div>
</div>
</a>
</section>
<section class="col-sm-4 my-3">
<a class="text-decoration-none" href="{{ url_for('graphs.graph', survey_id=survey_id, chart_type='Box and whisker') }}"
title="Go to the box and whisker plot creation page.">
<div class="card shadow-sm">
<img class="card-img-top" src="{{ url_for('static', filename='images/siteimages/boxchart.png') }}" alt="Box and whisker image">
<div class="card-body rounded-bottom primary-colour-bg text-light">
<h5 class="card-title text-light">Box and Whisker Plot</h5>
</div>
</div>
</a>
</section>
</div>
</main>
{% endblock content %}

View file

@ -0,0 +1,75 @@
{% extends "layout.html" %} {% block content %}
<main class="graph-container shadow-sm">
<form class="min-vh-75" action="" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<!-- Form title section -->
<section class="container-fluid">
<div class="row pt-3">
<div class="d-flex col-sm-6 offset-md-3 justify-content-end">
{% if form.title.errors %}
{{ form.title(class="primary-colour form-control is-invalid w-75 title") }}
<div class="invalid">
{% for error in form.title.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.title(class="primary-colour form-control w-75 title") }}
{% endif %}
</div>
<div class="col-sm-3 d-flex justify-content-end">
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Save as
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<div class="dropdown-item export pointer">Export as PNG</div>
{{ form.submit(class="dropdown-item no-btn") }}
</div>
</div>
</div>
</div>
</section>
<div class="graph-sub-container">
<div class="row vh-75 p-3">
<!-- Graph container section -->
<section class="graph-height col-md-9 p-0 order-md-2">
<div id="graph" class="h-100 position-relative">
<!-- ------GRAPH OVERLAY------ -->
<div class="empty-graph visible h-100 w-100 mr-2">
<h1>Choose your variables to get started</h1>
</div>
<!-- ------TOOLTIP------ -->
<div class="graph-tooltip tooltip-hidden">
<p><span class="tooltip-value"></span></p>
</div>
</div>
</section>
<!-- Start of Axis settings. Each graph type has its own HTML -->
{% block axis %} {% endblock %}
<!-- End of Axis settings -->
</div>
</div>
<!-- END OF GRAPH GRID -->
</form>
</main>
<!-- Scripts that are used on all of the graph pages. Specific javscripts relating to each
graph type are included on their own page -->
<script type="text/javascript" defer>
// Conversions from python True/False to javascript true/false
var nan = "N/A";
var True = true;
var False = false;
var graphData = {{ data | safe }}
// Needed for testing
{% if 'csrf_token' in form %}
var csrf = "{{ form.csrf_token._value() }}"
{% endif %}
var redirectUrl = "{{ url_for('surveys.dashboard', survey_id=survey_id) }}"
var url = "{{ url_for('graphs.graph', survey_id=survey_id, graph_id=graph_id, chart_type=chart_type) }}"
</script>
<!-- Scripts for exporting graph as image and for saving to dashboard -->
<script src="{{ url_for('static', filename='graphscripts/postgraph.js') }}" defer></script>
<!-- Script taken from https://github.com/exupero/saveSvgAsPng -->
<script src="{{ url_for('static', filename='graphscripts/saveSvgAsPng.js') }}" defer></script>
{% endblock content %}

View file

@ -0,0 +1,37 @@
{% extends "graphs/graph.html" %} {% block axis %}
<!-- Axes settings -->
<section class="col-md-3 order-md-1">
<div class="h-100 bg-gradient text-light rounded p-3">
<!-- ------X AXIS------ -->
<section class="x-axis-details">
<h3>X-axis</h3>
<div class="axis-variable">
{{ form.x_axis.label() }}
{{ form.x_axis(class="custom-select x-axis-value axis-setting") }}
</div>
<small data-toggle='help' class="text-light help"
title="A histogram requires numerical type data. If you don't see your variable it is because it was not recognised as numerical.">
<i class="far fa-question-circle mr-2"></i>Don't see your variable?</small>
<div class="axis-variable extra-settings-group hidden-down">
<p>X-axis range</p>
<div class="d-flex justify-content-between">
{{ 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") }}
</div>
<div class="y-axis-group">
<div class="axis-variable">
{{ form.group_count.label() }}
{{ form.group_count(class="form-control extra-setting number-groups") }}
</div>
</div>
<small>This is not precise. If you specify the number of groups, the graph will approximate what is possible close to that number.</small>
</div>
</section>
<!-- End of x-axis -->
</div>
</section>
<!-- End of Axis settings -->
<!-- Scripts specific to histogram -->
<script src="https://d3js.org/d3.v5.min.js" defer></script>
<script src="{{ url_for('static', filename='graphscripts/histogram.js') }}" defer></script>
{% endblock axis %}

View file

@ -0,0 +1,26 @@
{% extends "graphs/graph.html" %} {% block axis %}
<!-- Axes settings -->
<section class="col-md-3 order-md-1">
<div class="h-100 bg-gradient text-light rounded p-3">
<!-- ------Country Select------ -->
<section class="x-axis-details">
<h3 class="border-bottom">Select Variable</h3>
<small>Variable must contain country names (e.g. "France") or 3 letter ISO code (e.g. "FRA")</small>
<div class="axis-variable">
{{ form.variable(class="custom-select x-axis-value axis-setting") }}
</div>
<h3 class="border-bottom mt-4">Select Scope</h3>
<div class="axis-variable">
{{ form.scope(class="custom-select scope axis-setting") }}
</div>
</section>
<!-- End of country select -->
</div>
</section>
<!-- End of Axes settings -->
<!-- Scripts specific to map graphs -->
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.5.3/d3.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/topojson/1.6.9/topojson.min.js" defer></script>
<script src="{{ url_for('static', filename='node_modules/datamaps/dist/datamaps.all.min.js')}}" defer></script>
<script src="{{ url_for('static', filename='graphscripts/map.js') }}" defer></script>
{% endblock axis %}

View file

@ -0,0 +1,32 @@
{% extends "graphs/graph.html" %} {% block axis %}
<!-- Axes settings -->
<section class="col-md-3 order-md-1">
<div class="h-100 bg-gradient text-light rounded p-3">
<!-- ------X AXIS on pie chart refers to the prime variable------ -->
<section class="x-axis-details">
<h3>Variable</h3>
<div class="axis-variable">
{{ form.x_axis.label() }}
{{ form.x_axis(class="custom-select x-axis-value axis-setting") }}
</div>
</section>
<!-- ------Y AXIS refers to what the main variable is being plotted against------ -->
<section class="y-axis-details primary-transition hidden-down">
<h3>Against (optional)</h3>
<div class="axis-variable">
{{ form.y_axis.label() }}
{{ form.y_axis(class="custom-select y-axis-value axis-setting") }}
</div>
<div class="aggregate hidden-down">
{{ form.y_axis_agg.label() }}
{{ form.y_axis_agg(class="custom-select y-axis-aggregation axis-setting") }}
</div>
</section>
<!-- End of y-axis settings -->
</div>
</section>
<!-- End of Axis settings -->
<script src="https://d3js.org/d3.v5.min.js" defer></script>
<script src="{{ url_for('static', filename='graphscripts/piechart.js') }}" defer></script>
{% endblock axis %}

Some files were not shown because too many files have changed in this diff Show more