initial commit
3
.cache/v/cache/lastfailed
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"site/test_surveyapp.py": true
|
||||||
|
}
|
||||||
4
.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
SECRET_KEY=yourSecretKey
|
||||||
|
MONGO_URI=yourMongoDbUri
|
||||||
|
EMAIL_USERNAME=email@example.com
|
||||||
|
EMAIL_PASSWORD=yourEmailPassword
|
||||||
18
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 19 KiB |
BIN
images/GraphTable.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
images/StatisticalTestTable.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
images/UserTable.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
3
site/.cache/v/cache/lastfailed
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"test_surveyapp.py": true
|
||||||
|
}
|
||||||
2
site/.flaskenv
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
FLASK_APP=run.py
|
||||||
|
FLASK_ENV=development
|
||||||
8
site/run.py
Normal 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)
|
||||||
57
site/surveyapp/__init__.py
Normal 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
|
||||||
0
site/surveyapp/analysis/__init__.py
Normal file
25
site/surveyapp/analysis/forms.py
Normal 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")
|
||||||
249
site/surveyapp/analysis/routes.py
Normal 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)
|
||||||
221
site/surveyapp/analysis/routes2.py
Normal 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)
|
||||||
230
site/surveyapp/analysis/utils.py
Normal 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
|
|
@ -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
|
||||||
0
site/surveyapp/errors/__init__.py
Normal file
24
site/surveyapp/errors/handlers.py
Normal 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
|
||||||
0
site/surveyapp/graphs/__init__.py
Normal file
64
site/surveyapp/graphs/forms.py
Normal 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.")
|
||||||
271
site/surveyapp/graphs/routes.py
Normal 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))
|
||||||
53
site/surveyapp/graphs/tests.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
40
site/surveyapp/graphs/utils.py
Normal 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
|
||||||
0
site/surveyapp/main/__init__.py
Normal file
41
site/surveyapp/main/forms.py
Normal 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')
|
||||||
37
site/surveyapp/main/routes.py
Normal 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
|
|
@ -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})
|
||||||
18
site/surveyapp/static/d3.css
Normal 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. */
|
||||||
BIN
site/surveyapp/static/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
site/surveyapp/static/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
site/surveyapp/static/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
9
site/surveyapp/static/favicon/browserconfig.xml
Normal 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>
|
||||||
BIN
site/surveyapp/static/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 906 B |
BIN
site/surveyapp/static/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
site/surveyapp/static/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
site/surveyapp/static/favicon/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
37
site/surveyapp/static/favicon/safari-pinned-tab.svg
Normal 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 |
19
site/surveyapp/static/favicon/site.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
271
site/surveyapp/static/graphscripts/barchart.js
Normal 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)
|
||||||
|
});
|
||||||
360
site/surveyapp/static/graphscripts/boxchart.js
Normal 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)
|
||||||
|
});
|
||||||
267
site/surveyapp/static/graphscripts/histogram.js
Normal 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)
|
||||||
|
});
|
||||||
248
site/surveyapp/static/graphscripts/map.js
Normal 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)
|
||||||
|
});
|
||||||
323
site/surveyapp/static/graphscripts/piechart.js
Normal 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)
|
||||||
|
});
|
||||||
51
site/surveyapp/static/graphscripts/postgraph.js
Normal 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;
|
||||||
|
}
|
||||||
408
site/surveyapp/static/graphscripts/saveSvgAsPng.js
Normal 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 " ">]>';
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
})();
|
||||||
324
site/surveyapp/static/graphscripts/scatterchart.js
Normal 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)
|
||||||
|
});
|
||||||
26
site/surveyapp/static/home.js
Normal 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();
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 72 KiB |
0
site/surveyapp/static/images/graphimages/.gitkeep
Normal file
BIN
site/surveyapp/static/images/graphimages/224a92293ef30246.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
site/surveyapp/static/images/graphimages/7b330761be6bd797.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/surveyapp/static/images/graphimages/ce215585871c14cd.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
site/surveyapp/static/images/graphimages/e43c10e56a08a902.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
site/surveyapp/static/images/graphimages/fec14876222e8931.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
site/surveyapp/static/images/siteimages/barchart.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
site/surveyapp/static/images/siteimages/boxchart.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
site/surveyapp/static/images/siteimages/favicon.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
site/surveyapp/static/images/siteimages/histogram.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
site/surveyapp/static/images/siteimages/landing2.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
site/surveyapp/static/images/siteimages/landing3.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
site/surveyapp/static/images/siteimages/landing4.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
site/surveyapp/static/images/siteimages/landing5.png
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
site/surveyapp/static/images/siteimages/logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
site/surveyapp/static/images/siteimages/logo2.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
site/surveyapp/static/images/siteimages/map.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
site/surveyapp/static/images/siteimages/piechart.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
site/surveyapp/static/images/siteimages/scatterchart.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
227
site/surveyapp/static/input.js
Normal 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
|
|
@ -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=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
site/surveyapp/static/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
site/surveyapp/static/statscripts/chi.js
Normal 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()
|
||||||
11
site/surveyapp/static/statscripts/quickstats.js
Normal 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
|
||||||
|
});
|
||||||
204
site/surveyapp/static/statscripts/statistics.js
Normal 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()
|
||||||
271
site/surveyapp/static/styles.css
Normal 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;
|
||||||
|
}
|
||||||
BIN
site/surveyapp/static/video/site_video.mov
Normal file
0
site/surveyapp/surveys/__init__.py
Normal file
15
site/surveyapp/surveys/forms.py
Normal 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")
|
||||||
248
site/surveyapp/surveys/routes.py
Normal 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)
|
||||||
117
site/surveyapp/surveys/utils.py
Normal 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
|
||||||
46
site/surveyapp/templates/analysis/analysedata.html
Normal 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 %}
|
||||||
46
site/surveyapp/templates/analysis/chisquare.html
Normal 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 %}
|
||||||
76
site/surveyapp/templates/analysis/quickstats.html
Normal 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 %}
|
||||||
75
site/surveyapp/templates/analysis/result.html
Normal 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 %}
|
||||||
11
site/surveyapp/templates/errors/403.html
Normal 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 %}
|
||||||
11
site/surveyapp/templates/errors/404.html
Normal 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 %}
|
||||||
9
site/surveyapp/templates/errors/500.html
Normal 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 %}
|
||||||
31
site/surveyapp/templates/graphs/barchart.html
Normal 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 %}
|
||||||
30
site/surveyapp/templates/graphs/boxchart.html
Normal 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 %}
|
||||||
73
site/surveyapp/templates/graphs/choosegraph.html
Normal 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 %}
|
||||||
75
site/surveyapp/templates/graphs/graph.html
Normal 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 %}
|
||||||
37
site/surveyapp/templates/graphs/histogram.html
Normal 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 %}
|
||||||
26
site/surveyapp/templates/graphs/map.html
Normal 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 %}
|
||||||
32
site/surveyapp/templates/graphs/piechart.html
Normal 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 %}
|
||||||