├── .circleci ├── config.yml └── deployment-workflow.yml ├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── CODEOWNERS ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTORS.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── build_and_start_nginx.sh ├── cypress.config.js ├── default.template ├── docker_push.sh ├── jest.config.js ├── package-lock.json ├── package.json ├── run_e2e_tests.sh ├── spec ├── config-spec.js ├── end_to_end_tests │ ├── config.json │ ├── pageObjects │ │ ├── base_page.js │ │ ├── byor_page.js │ │ └── radar_page.js │ ├── resources │ │ ├── data.json │ │ ├── localfiles │ │ │ ├── radar.csv │ │ │ └── radar.json │ │ └── sheet.csv │ └── specs │ │ └── end_to_end.cy.js ├── exceptions │ ├── fileNotFoundError-spec.js │ ├── invalidConfigError-spec.js │ └── invalidContentError-spec.js ├── graphing │ ├── blips-spec.js │ ├── components │ │ └── quadrants-spec.js │ └── config-spec.js ├── helpers │ └── jsdom.js ├── models │ ├── blip-spec.js │ ├── quadrant-spec.js │ ├── radar-spec.js │ └── ring-spec.js ├── support │ └── jasmine.json └── util │ ├── contentValidator-spec.js │ ├── htmlUtil-spec.js │ ├── inputSanitizer-spec.js │ ├── mathUtils-spec.js │ ├── queryParamProcessor-spec.js │ ├── ringCalculator-spec.js │ ├── sheet-spec.js │ ├── urlUtils-spec.js │ └── util-spec.js ├── src ├── analytics.js ├── common.js ├── config.js ├── error.html ├── exceptions │ ├── fileNotFoundError.js │ ├── invalidConfigError.js │ ├── invalidContentError.js │ ├── malformedDataError.js │ ├── sheetNotFoundError.js │ └── unauthorizedError.js ├── graphing │ ├── blips.js │ ├── components │ │ ├── alternativeRadars.js │ │ ├── banner.js │ │ ├── buttons.js │ │ ├── quadrantSubnav.js │ │ ├── quadrantTables.js │ │ ├── quadrants.js │ │ └── search.js │ ├── config.js │ ├── pdfPage.js │ └── radar.js ├── images │ ├── arrow-icon.svg │ ├── arrow-white-icon.svg │ ├── banner-image-desktop.jpg │ ├── banner-image-mobile.jpg │ ├── existing.svg │ ├── favicon.ico │ ├── first-quadrant-btn-bg.svg │ ├── fourth-quadrant-btn-bg.svg │ ├── logo.png │ ├── moved.svg │ ├── new.svg │ ├── no-change.svg │ ├── pdf_banner.png │ ├── radar_legend.png │ ├── search-active-wave.svg │ ├── search-logo-2x.svg │ ├── second-quadrant-btn-bg.svg │ ├── tech-radar-landing-page-wide.png │ ├── third-quadrant-btn-bg.svg │ └── tw-logo.png ├── index.html ├── models │ ├── blip.js │ ├── quadrant.js │ ├── radar.js │ └── ring.js ├── site.js ├── stylesheets │ ├── _alternativeradars.scss │ ├── _buttons.scss │ ├── _colors.scss │ ├── _error.scss │ ├── _fonts.scss │ ├── _footer.scss │ ├── _form.scss │ ├── _header.scss │ ├── _herobanner.scss │ ├── _landingpage.scss │ ├── _layout.scss │ ├── _loader.scss │ ├── _mediaqueries.scss │ ├── _pdfPage.scss │ ├── _quadrantTables.scss │ ├── _quadrants.scss │ ├── _quadrantsubnav.scss │ ├── _screen.css │ ├── _search.scss │ ├── _tip.scss │ └── base.scss └── util │ ├── autoComplete.js │ ├── contentValidator.js │ ├── exceptionMessages.js │ ├── factory.js │ ├── googleAuth.js │ ├── htmlUtil.js │ ├── inputSanitizer.js │ ├── mathUtils.js │ ├── queryParamProcessor.js │ ├── ringCalculator.js │ ├── sheet.js │ ├── stringUtil.js │ └── urlUtils.js ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | setup: true 4 | 5 | orbs: 6 | continuation: circleci/continuation@0.2.0 7 | 8 | executors: 9 | base: 10 | docker: 11 | - image: cimg/node:18.12.1 12 | user: root 13 | 14 | commands: 15 | install-node-packages: 16 | description: Install node packages 17 | steps: 18 | - restore_cache: 19 | key: node-cache-v2-{{ checksum "package-lock.json" }} 20 | - run: 21 | name: Install node packages 22 | command: npm install 23 | - save_cache: 24 | paths: 25 | - ./node_modules 26 | key: node-cache-v2-{{ checksum "package-lock.json" }} 27 | 28 | jobs: 29 | tests: 30 | executor: base 31 | steps: 32 | - checkout 33 | - install-node-packages 34 | - run: 35 | name: Run linter and unit tests with coverage 36 | command: npm run quality 37 | check-for-pr: 38 | executor: base 39 | steps: 40 | - checkout 41 | - run: | 42 | if [[ $CIRCLE_PULL_REQUEST ]]; then 43 | circleci-agent step halt 44 | fi 45 | - continuation/continue: 46 | configuration_path: .circleci/deployment-workflow.yml 47 | docker-push: 48 | executor: base 49 | steps: 50 | - checkout 51 | - setup_remote_docker: 52 | version: 20.10.12 53 | docker_layer_caching: true 54 | - run: 55 | name: Build and push Docker image to DockerHub 56 | command: ./docker_push.sh 57 | 58 | workflows: 59 | main: 60 | jobs: 61 | - tests 62 | - check-for-pr: 63 | requires: 64 | - tests 65 | - approve-docker-push: 66 | type: approval 67 | filters: 68 | tags: 69 | only: /^v.*/ 70 | branches: 71 | ignore: /.*/ 72 | - docker-push: 73 | requires: 74 | - approve-docker-push 75 | filters: 76 | tags: 77 | only: /^v.*/ 78 | branches: 79 | ignore: /.*/ 80 | -------------------------------------------------------------------------------- /.circleci/deployment-workflow.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | aws-cli: circleci/aws-cli@4.1.1 5 | browser-tools: circleci/browser-tools@1.4.6 6 | 7 | executors: 8 | base: 9 | docker: 10 | - image: cimg/node:18.18.2-browsers 11 | user: root 12 | 13 | commands: 14 | install-node-packages: 15 | description: Install node packages 16 | steps: 17 | - restore_cache: 18 | key: node-cache-v2-{{ checksum "package-lock.json" }} 19 | - run: 20 | name: Install node packages 21 | command: npm install 22 | - save_cache: 23 | paths: 24 | - ./node_modules 25 | key: node-cache-v2-{{ checksum "package-lock.json" }} 26 | install-node-and-cypress-packages: 27 | description: Install node packages and Cypress dependencies 28 | steps: 29 | - restore_cache: 30 | key: node-cache-with-cypress-v1-{{ checksum "package-lock.json" }} 31 | - run: 32 | name: Install Cypress dependencies 33 | command: | 34 | apt-get update 35 | apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb 36 | - run: 37 | name: Install node packages 38 | command: npm install 39 | - save_cache: 40 | paths: 41 | - ./node_modules 42 | - ~/.cache/Cypress 43 | key: node-cache-with-cypress-v1-{{ checksum "package-lock.json" }} 44 | build-and-push: 45 | description: Build and push code to S3 bucket 46 | parameters: 47 | api_key: 48 | type: string 49 | default: '' 50 | client_id: 51 | type: string 52 | default: '' 53 | gtm_id: 54 | type: string 55 | default: '' 56 | adobe_launch_script_url: 57 | type: string 58 | default: '' 59 | bucket_name: 60 | type: string 61 | default: '' 62 | distribution_id: 63 | type: string 64 | default: '' 65 | env: 66 | type: string 67 | default: dev 68 | steps: 69 | - run: 70 | name: Build code 71 | command: API_KEY=<< parameters.api_key >> CLIENT_ID=<< parameters.client_id >> GTM_ID=<< parameters.gtm_id >> ADOBE_LAUNCH_SCRIPT_URL=<< parameters.adobe_launch_script_url >> npm run build:<< parameters.env >> 72 | - when: 73 | condition: 74 | and: 75 | - equal: [<< parameters.env >>, 'prod'] 76 | steps: 77 | - run: 78 | name: Set PROD specific env variables 79 | command: | 80 | echo 'export AWS_BYOR_OIDC_ROLE=$AWS_BYOR_OIDC_ROLE_PROD' >> $BASH_ENV 81 | - aws-cli/setup: 82 | role_arn: $AWS_BYOR_OIDC_ROLE 83 | session_duration: '900' 84 | - run: 85 | name: Sync build artifacts to S3 86 | command: aws s3 cp --recursive dist "s3://<< parameters.bucket_name >>/" 87 | - run: 88 | name: Create invalidation in CloudFront 89 | command: aws cloudfront create-invalidation --distribution-id << parameters.distribution_id >> --paths '/*' 90 | 91 | jobs: 92 | e2e-tests: 93 | executor: base 94 | steps: 95 | - checkout 96 | - install-node-and-cypress-packages 97 | - browser-tools/install-chrome 98 | - run: 99 | name: Run e2e test cases 100 | command: API_KEY=$LOCAL_API_KEY ./run_e2e_tests.sh $LOCAL_TEST_URL 101 | qa-deployment: 102 | executor: base 103 | steps: 104 | - checkout 105 | - install-node-packages 106 | - build-and-push: 107 | api_key: $QA_API_KEY 108 | client_id: $QA_CLIENT_ID 109 | gtm_id: $QA_GTM_ID 110 | adobe_launch_script_url: $QA_ADOBE_LAUNCH_SCRIPT_URL 111 | bucket_name: $QA_BUCKET_NAME 112 | distribution_id: $QA_DISTRIBUTION_ID 113 | env: dev 114 | prod-deployment: 115 | executor: base 116 | steps: 117 | - checkout 118 | - install-node-packages 119 | - build-and-push: 120 | api_key: $PROD_API_KEY 121 | client_id: $PROD_CLIENT_ID 122 | gtm_id: $PROD_GTM_ID 123 | adobe_launch_script_url: $PROD_ADOBE_LAUNCH_SCRIPT_URL 124 | bucket_name: $PROD_BUCKET_NAME 125 | distribution_id: $PROD_DISTRIBUTION_ID 126 | env: prod 127 | 128 | workflows: 129 | build-and-deploy: 130 | jobs: 131 | - e2e-tests: 132 | filters: 133 | branches: 134 | only: master 135 | - qa-deployment: 136 | requires: 137 | - e2e-tests 138 | - approve-prod-deployment: 139 | type: approval 140 | requires: 141 | - qa-deployment 142 | - prod-deployment: 143 | requires: 144 | - approve-prod-deployment 145 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/docker-existing-dockerfile 3 | { 4 | "name": "Existing Dockerfile", 5 | // Sets the run context to one level up instead of the .devcontainer folder. 6 | "context": "..", 7 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 8 | "dockerFile": "../Dockerfile", 9 | // Set *default* container specific settings.json values on container create. 10 | "settings": {}, 11 | // Add the IDs of extensions you want installed when the container is created. 12 | "extensions": [] 13 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 14 | // "forwardPorts": [], 15 | // Uncomment the next line to run commands after the container is created - for example installing curl. 16 | // "postCreateCommand": "apt-get update && apt-get install -y curl", 17 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 18 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 19 | // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. 20 | // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], 21 | // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root. 22 | // "remoteUser": "vscode" 23 | } 24 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | npm-debug.log* -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | dist 4 | *.swa 5 | *.swp 6 | *.swo 7 | 8 | coverage/ 9 | 10 | .idea/ 11 | .vscode/ 12 | .DS_Store 13 | 14 | spec/end_to_end_tests/reports/ 15 | cypress/ 16 | 17 | .nyc_output 18 | 19 | .private.notes 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true, 5 | "jest": true, 6 | "cypress/globals": true 7 | }, 8 | "extends": ["eslint:recommended", "prettier", "plugin:jest/recommended"], 9 | "parserOptions": { 10 | "ecmaVersion": "latest" 11 | }, 12 | "rules": { 13 | "cypress/no-assigning-return-values": "error", 14 | "cypress/no-unnecessary-waiting": "error", 15 | "cypress/assertion-before-screenshot": "warn", 16 | "cypress/no-force": "warn", 17 | "cypress/no-async-tests": "error", 18 | "cypress/no-pause": "error", 19 | "jest/no-disabled-tests": "warn", 20 | "jest/no-focused-tests": "error", 21 | "jest/no-identical-title": "off", 22 | "jest/prefer-to-have-length": "warn", 23 | "jest/valid-expect": "error", 24 | "jest/expect-expect": "off" 25 | }, 26 | "globals": { 27 | "jest": true 28 | }, 29 | "plugins": ["jest", "cypress"] 30 | } 31 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @thoughtworks/tw-digital @will-amaral 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | dist 4 | *.swa 5 | *.swp 6 | *.swo 7 | 8 | coverage/ 9 | 10 | .idea/ 11 | .DS_Store 12 | 13 | spec/end_to_end_tests/reports/ 14 | src/stylesheets/_featuretoggles.scss 15 | cypress/ 16 | 17 | .nyc_output 18 | 19 | .private.notes 20 | 21 | .talismanrc 22 | 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | LICENSE.md 2 | coverage/ 3 | dist/ 4 | node_modules/ 5 | .vscode/ 6 | .idea/ 7 | src/stylesheets/_screen.css 8 | spec/end_to_end_tests/reports 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "orta.vscode-jest", 6 | "editorconfig.editorconfig" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[json]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "prettier.enable": true, 9 | "prettier.prettierPath": "./node_modules/prettier", 10 | "editor.formatOnSave": true, 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | ## BYOR Contributors 2 | 3 | In alphabetic order: 4 | 5 | - [@andyw8](https://github.com/andyw8) 6 | 7 | - [@aschonfeld](https://github.com/aschonfeld) 8 | 9 | - [@br00k](https://github.com/br00k) 10 | 11 | - [@BRUNNEL6](https://github.com/BRUNNEL6) 12 | 13 | - [@camiloribeiro](https://github.com/camiloribeiro) 14 | 15 | - [@filipesabella](https://github.com/filipesabella) 16 | 17 | - [@hkurosawa](https://github.com/hkurosawa) 18 | 19 | - [@jaiganeshg](https://github.com/jaiganeshg) 20 | 21 | - [@kylec32](https://github.com/kylec32) 22 | 23 | - [@lauraionescu](https://github.com/lauraionescu) 24 | 25 | - [@setchy](https://github.com/setchy) 26 | 27 | - [@shahadarsh](https://github.com/shahadarsh) 28 | 29 | - [@thenano](https://github.com/thenano) 30 | 31 | - [@trecenti](https://github.com/trecenti) 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.23.0 2 | 3 | RUN apt-get update && apt-get upgrade -y 4 | 5 | RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - 6 | RUN apt-get install -y nodejs 7 | 8 | RUN \ 9 | apt-get install -y \ 10 | libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 \ 11 | libxss1 libasound2 libxtst6 xauth xvfb g++ make 12 | 13 | WORKDIR /src/build-your-own-radar 14 | COPY package.json ./ 15 | COPY package-lock.json ./ 16 | RUN npm ci 17 | 18 | COPY . ./ 19 | 20 | # Override parent node image's entrypoint script (/usr/local/bin/docker-entrypoint.sh), 21 | # which tries to run CMD as a node command 22 | ENTRYPOINT [] 23 | CMD ["./build_and_start_nginx.sh"] 24 | -------------------------------------------------------------------------------- /build_and_start_nginx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd /src/build-your-own-radar 5 | 6 | echo "Starting webpack build..." 7 | npm run build:prod 8 | 9 | echo "Copying built files to nginx directories..." 10 | mkdir -p /opt/build-your-own-radar 11 | cd /opt/build-your-own-radar 12 | cp -r /src/build-your-own-radar/dist/* ./ 13 | mkdir -p files 14 | cp /src/build-your-own-radar/spec/end_to_end_tests/resources/localfiles/* ./files/ 15 | cp /src/build-your-own-radar/default.template /etc/nginx/conf.d/default.conf 16 | 17 | echo "Starting nginx server..." 18 | exec nginx -g 'daemon off;' 19 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | chromeWebSecurity: false, 5 | screenshotsFolder: 'spec/end_to_end_tests/reports/screenshots', 6 | reporter: 'mochawesome', 7 | reporterOptions: { 8 | reportDir: 'spec/end_to_end_tests/reports', 9 | reportFilename: 'results', 10 | quiet: 'true', 11 | json: 'false', 12 | }, 13 | watchForFileChanges: false, 14 | defaultCommandTimeout: 30000, 15 | pageLoadTimeout: 60000, 16 | viewportHeight: 900, 17 | viewportWidth: 1440, 18 | e2e: { 19 | specPattern: 'spec/end_to_end_tests/specs/**/*.cy.{js,jsx,ts,tsx}', 20 | supportFile: false, 21 | }, 22 | video: false, 23 | }) 24 | -------------------------------------------------------------------------------- /default.template: -------------------------------------------------------------------------------- 1 | # server block for static file serving 2 | server { 3 | listen 80; 4 | location / { 5 | root /opt/build-your-own-radar; 6 | index index.html; 7 | } 8 | 9 | # nginx default error page for 50x errors 10 | error_page 500 502 503 504 /50x.html; 11 | location = /50x.html { 12 | root /usr/share/nginx/html; 13 | } 14 | 15 | location ~ /files/ { 16 | root /opt/build-your-own-radar; 17 | autoindex on; 18 | default_type text/plain; 19 | add_header 'Access-Control-Allow-Origin' '*'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker_push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker login -u $DOCKER_USER -p $DOCKER_PASS 4 | 5 | export REPO=wwwthoughtworks/build-your-own-radar 6 | export TAG=$CIRCLE_TAG 7 | 8 | docker build -f Dockerfile -t $REPO:latest . 9 | docker tag $REPO:latest $REPO:$TAG 10 | 11 | docker push $REPO:latest 12 | docker push $REPO:$TAG 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverageFrom: ['src/**/*.js'], 4 | testEnvironment: 'jsdom', 5 | transform: { 6 | '^.+\\.[jt]sx?$': 'babel-jest', 7 | '.+\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform', 8 | }, 9 | testMatch: ['**/spec/**/*-spec.js'], 10 | coverageThreshold: { 11 | global: { 12 | statements: 23.95, 13 | branches: 20.08, 14 | functions: 28.98, 15 | lines: 24.09, 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-your-own-radar", 3 | "version": "1.1.4", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build:dev": "webpack --mode development --config webpack.dev.js", 8 | "build:prod": "webpack --mode production --config webpack.prod.js", 9 | "dev": "webpack-dev-server --mode development --config webpack.dev.js", 10 | "test": "jest", 11 | "test:coverage": "jest --coverage", 12 | "test:e2e": "cypress open --env host=$TEST_URL", 13 | "test:e2e-headless": "cypress run --browser chrome --record false --env host=$TEST_URL", 14 | "lint-prettier:check": "eslint . && prettier --check .", 15 | "lint-prettier:fix": "eslint . --fix && prettier --write .", 16 | "quality": "npm run lint-prettier:check && npm run test:coverage" 17 | }, 18 | "author": "Thoughtworks", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/thoughtworks/build-your-own-radar" 22 | }, 23 | "keywords": [ 24 | "tech-radar" 25 | ], 26 | "license": "AGPL-3.0", 27 | "devDependencies": { 28 | "@babel/core": "^7.23.2", 29 | "@babel/preset-env": "^7.23.2", 30 | "babel-loader": "^9.1.3", 31 | "css-loader": "^6.8.1", 32 | "cssnano": "^6.0.1", 33 | "cypress": "^13.3.1", 34 | "dotenv": "^16.3.1", 35 | "eslint": "^8.51.0", 36 | "eslint-config-prettier": "^9.0.0", 37 | "eslint-plugin-cypress": "^2.15.1", 38 | "eslint-plugin-jest": "^27.4.2", 39 | "expose-loader": "^4.1.0", 40 | "html-webpack-plugin": "^5.5.3", 41 | "jest": "^29.7.0", 42 | "jest-css-modules-transform": "^4.4.2", 43 | "jest-environment-jsdom": "^29.7.0", 44 | "jsdom": "^22.1.0", 45 | "mini-css-extract-plugin": "^2.7.6", 46 | "mochawesome": "^7.1.3", 47 | "postcss-loader": "^7.3.3", 48 | "postcss-preset-env": "^9.2.0", 49 | "prettier": "^3.0.3", 50 | "sass": "^1.69.3", 51 | "sass-loader": "^13.3.2", 52 | "style-loader": "^3.3.3", 53 | "webpack": "^5.89.0", 54 | "webpack-cli": "^5.1.4", 55 | "webpack-dev-server": "^4.15.1", 56 | "yargs": "^17.7.2" 57 | }, 58 | "dependencies": { 59 | "chance": "^1.1.11", 60 | "d3": "^7.8.5", 61 | "d3-tip": "^0.9.1", 62 | "jquery": "^3.7.1", 63 | "jquery-ui": "^1.13.2", 64 | "lodash": "^4.17.21", 65 | "sanitize-html": "^2.11.0" 66 | }, 67 | "standard": { 68 | "globals": [ 69 | "Cypress", 70 | "cy", 71 | "XMLHttpRequest" 72 | ], 73 | "env": [ 74 | "jest" 75 | ], 76 | "ignore": [ 77 | "radar-spec.js", 78 | "ref-table-spec.js" 79 | ] 80 | }, 81 | "engines": { 82 | "node": ">=18", 83 | "npm": ">=9" 84 | }, 85 | "private": true 86 | } 87 | -------------------------------------------------------------------------------- /run_e2e_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TEST_URL=$1 4 | 5 | npm run dev & 6 | 7 | sleep 30 8 | TEST_URL=$TEST_URL npm run test:e2e-headless 9 | -------------------------------------------------------------------------------- /spec/config-spec.js: -------------------------------------------------------------------------------- 1 | const config = require('../src/config') 2 | 3 | describe('Config Test', () => { 4 | it('should return all env when no env defined', () => { 5 | const actual = config() 6 | expect(actual).toStrictEqual({ 7 | production: { featureToggles: { UIRefresh2022: true } }, 8 | development: { featureToggles: { UIRefresh2022: true } }, 9 | }) 10 | }) 11 | 12 | it('should return the given env', () => { 13 | const oldEnv = process.env 14 | process.env.ENVIRONMENT = 'development' 15 | const actual = config() 16 | expect(actual).toStrictEqual({ 17 | featureToggles: { UIRefresh2022: true }, 18 | }) 19 | 20 | process.env = oldEnv 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /spec/end_to_end_tests/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "CSV_FILE_URL": "https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/master/spec/end_to_end_tests/resources/sheet.csv", 3 | "JSON_FILE_URL": "https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/master/spec/end_to_end_tests/resources/data.json", 4 | "PUBLIC_GOOGLE_SHEET_URL": "https://docs.google.com/spreadsheets/d/1wZVb8V53O0Lzr4iMaz4qjJZKteA1xQhJNajGq0jE9sw", 5 | "PUBLIC_GOOGLE_SHEET_TITLE": "BYOR Test - Public Google Sheet", 6 | "PUBLIC_GOOGLE_SHEET_RADAR_SHEET_NAMES": ["Build your Technology Radar", "Build your Technology Radar - Sheet 2"], 7 | "QUADRANT_NAMES": ["All quadrants", "Techniques", "Platforms", "Tools", "Languages & Frameworks"], 8 | "RING_NAMES": ["Adopt", "Trial", "Assess", "Hold"], 9 | "PUBLIC_SHEET_QUADRANT_NAMES": ["All quadrants", "Techniques", "Platforms", "Tools", "Languages & Frameworks"], 10 | "QUADRANT_ORDERS": ["first", "second", "third", "fourth"], 11 | "QUADRANT_NAMES_LOWER": ["techniques", "platforms", "tools", "languages---frameworks"] 12 | } 13 | -------------------------------------------------------------------------------- /spec/end_to_end_tests/pageObjects/base_page.js: -------------------------------------------------------------------------------- 1 | /* eslint no-useless-constructor: "off" */ 2 | 3 | class BasePage { 4 | constructor() {} 5 | } 6 | 7 | module.exports = new BasePage() 8 | -------------------------------------------------------------------------------- /spec/end_to_end_tests/pageObjects/byor_page.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json') 2 | 3 | class ByorPage { 4 | constructor() { 5 | this.textBox = "[name='documentId']" 6 | this.submit = 'input[type=submit]' 7 | } 8 | 9 | provideCsvName() { 10 | cy.get(this.textBox).clear() 11 | cy.get(this.textBox).type(config.CSV_FILE_URL) 12 | } 13 | 14 | provideJsonName() { 15 | cy.get(this.textBox).clear() 16 | cy.get(this.textBox).type(config.JSON_FILE_URL) 17 | } 18 | 19 | providePublicSheetUrl() { 20 | cy.get(this.textBox).clear() 21 | cy.get(this.textBox).type(config.PUBLIC_GOOGLE_SHEET_URL) 22 | } 23 | 24 | clickSubmitButton() { 25 | cy.get(this.submit).click() 26 | } 27 | 28 | typeString(string) { 29 | cy.get('body').type(string) 30 | } 31 | 32 | typeStringInUrlInput(string) { 33 | cy.get(this.textBox).clear() 34 | cy.get(this.textBox).type(string) 35 | } 36 | 37 | validateUrlInputFocused() { 38 | cy.get(this.textBox).should('have.focus') 39 | } 40 | 41 | validateUrlInputValue(value) { 42 | cy.get(this.textBox).should('have.value', value) 43 | } 44 | } 45 | 46 | module.exports = new ByorPage() 47 | -------------------------------------------------------------------------------- /spec/end_to_end_tests/pageObjects/radar_page.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json') 2 | 3 | class RadarPage { 4 | constructor() { 5 | this.blip = '.quadrant-group-second .blip-link:nth-of-type(1)' 6 | this.allBlips = '.blip-link' 7 | this.bannerTitle = '.hero-banner__title-text' 8 | this.graphTitle = '.hero-banner__subtitle-text' 9 | this.quadrantList = '.quadrant-subnav__list-item' 10 | this.quadrantDropdown = '.quadrant-subnav__dropdown' 11 | this.quadrantSelector = '.quadrant-subnav__dropdown-selector' 12 | this.platformsSubnavItem = '.quadrant-subnav__list-item:nth-child(3)' 13 | this.searchBox = '.search-container__input' 14 | this.searchResultItems = '.ui-menu-item' 15 | this.alternateRadarsItems = '.alternative-radars__list-item' 16 | this.blipSelectedOld = '.quadrant-table.selected .blip-list-item.highlight' 17 | this.blipDescriptionOld = '.blip-item-description.expanded p' 18 | this.blipDescription = '.blip-list__item-container.expand .blip-list__item-container__description' 19 | this.autocomplete = '.search-radar' 20 | this.searchValue = 'Component' 21 | this.searchItem = '.ui-menu-item:first' 22 | this.quadrant = '#second-quadrant-mobile' 23 | this.firstQuadrant = '.quadrant-group-first' 24 | this.quadrantTableRings = '.quadrant-table.selected .quadrant-table__ring-name' 25 | this.quadrantTableBlips = '.quadrant-table.selected .blip-list__item' 26 | this.subnavDropdown = '.quadrant-subnav__dropdown' 27 | this.subnavList = '.quadrant-subnav__list' 28 | this.radarGraphSvg = 'svg#radar-plot' 29 | this.mobileQuadrants = '.all-quadrants-mobile' 30 | this.tooltip = '.d3-tip' 31 | this.quadrantTableBlip = function (blipId) { 32 | return `.quadrant-table.selected .blip-list__item-container[data-blip-id="${blipId}"]` 33 | } 34 | this.quadrantTableBlipDescription = function (blipId) { 35 | return `.quadrant-table.selected .blip-list__item-container[data-blip-id="${blipId}"] #blip-description-${blipId}` 36 | } 37 | this.radarGraphBlip = function (blipId) { 38 | return `a.blip-link[data-blip-id="${blipId}"]` 39 | } 40 | this.subnavQuadrant = function (quadrantName) { 41 | return `.quadrant-subnav__list-item#subnav-item-${quadrantName}` 42 | } 43 | this.quadrantGraph = function (quadrantOrder) { 44 | return `.quadrant-group-${quadrantOrder}` 45 | } 46 | this.quadrantGraphTablet = function (quadrantOrder) { 47 | return `#${quadrantOrder}-quadrant-mobile` 48 | } 49 | this.searchResultByIndex = function (index) { 50 | return `.ui-menu-item:nth-child(${index})` 51 | } 52 | this.alternateRadarsItemByIndex = function (index) { 53 | return `.alternative-radars__list-item:nth-child(${index})` 54 | } 55 | 56 | this.quadrantNameGroup = function (order) { 57 | return `.quadrant-group.quadrant-group-${order} .quadrant-name-group` 58 | } 59 | 60 | this.quadrantRingName = function (order, index) { 61 | return `.quadrant-group-${order} .line-text:nth-of-type(${index})` 62 | } 63 | } 64 | 65 | clickTheBlipInFullRadarView() { 66 | cy.get(this.blip).click() 67 | } 68 | 69 | clickTheBlip() { 70 | cy.get(this.blipSelectedOld).click() 71 | } 72 | 73 | clickQuadrantInFullRadarView(quadrantOrder) { 74 | cy.get(this.quadrantGraph(quadrantOrder)).click() 75 | } 76 | 77 | clickQuadrantInFullRadarViewTablet(quadrantOrder) { 78 | cy.get(this.quadrantGraphTablet(quadrantOrder)).click() 79 | } 80 | 81 | clickBlipItemInQuadrantTable(blipId) { 82 | cy.get(this.quadrantTableBlip(blipId)).click() 83 | } 84 | 85 | clickBlipInRadarGraph(blipId) { 86 | cy.get(this.radarGraphBlip(blipId)).click() 87 | } 88 | 89 | hoverBlipInRadarGraph(blipId) { 90 | cy.get(this.radarGraphBlip(blipId)).trigger('mouseover') 91 | } 92 | 93 | clickQuadrantInSubnav(quadrantName) { 94 | cy.get(this.subnavQuadrant(quadrantName)).click() 95 | } 96 | 97 | clickSubnavDropdownTablet() { 98 | cy.get(this.subnavDropdown).click() 99 | } 100 | 101 | clickSearchResult(index) { 102 | cy.get(this.searchResultByIndex(index)).click() 103 | } 104 | 105 | clickAlternateRadarItem(index) { 106 | cy.get(this.alternateRadarsItemByIndex(index)).click() 107 | } 108 | 109 | searchTheBlip() { 110 | cy.get(this.autocomplete).type(this.searchValue) 111 | cy.get(this.searchItem).click() 112 | } 113 | 114 | triggerSearch(query) { 115 | cy.get(this.searchBox).clear() 116 | cy.get(this.searchBox).type(query) 117 | } 118 | 119 | typeString(string) { 120 | cy.get('body').type(string) 121 | } 122 | 123 | typeStringInSearch(string) { 124 | cy.get(this.searchBox).clear() 125 | cy.get(this.searchBox).type(string) 126 | } 127 | 128 | validateBlipDescription(text) { 129 | cy.get(this.blipDescription).contains(text) 130 | } 131 | 132 | validateBlipText(blipId, text) { 133 | cy.get(`#${blipId}`).contains(text) 134 | } 135 | 136 | validateNoBlipToolTip(blipId) { 137 | cy.get(`#${blipId}`).trigger('mouseout') // cleanup of previous hover 138 | cy.get(`#${blipId}`).trigger('mouseover') 139 | cy.get(this.tooltip).should('have.attr', 'style').and('contains', 'opacity: 0') 140 | cy.get(this.tooltip).should('have.attr', 'style').and('contains', 'pointer-events: none') 141 | } 142 | 143 | validateBlipToolTip(blipId, text) { 144 | cy.get(`#${blipId}`).trigger('mouseover') 145 | cy.get(this.tooltip).contains(text) 146 | } 147 | 148 | validateBlipDescriptionOld(text) { 149 | cy.get(this.blipDescriptionOld).contains(text) 150 | } 151 | 152 | validateBlipSearch() { 153 | cy.get(this.blipSelectedOld).contains(this.searchValue) 154 | } 155 | 156 | validateBlipCountForPublicGoogleSheet() { 157 | cy.get(this.allBlips).should('have.length', 115) 158 | } 159 | 160 | validateGraphTitle(title) { 161 | cy.get(this.graphTitle).should('have.text', title) 162 | } 163 | 164 | validateQuadrantNames() { 165 | cy.get(this.quadrantList).should('have.length', 5) 166 | 167 | let i = 1 168 | for (const quadrant of config.QUADRANT_NAMES) { 169 | cy.get(`${this.quadrantList}:nth-child(${i})`).should('have.text', quadrant) 170 | i++ 171 | } 172 | } 173 | 174 | validateQuadrantNamesForPublicGoogleSheet() { 175 | cy.get(this.quadrantList).should('have.length', 5) 176 | 177 | let i = 1 178 | for (const quadrant of config.QUADRANT_NAMES) { 179 | cy.get(`${this.quadrantList}:nth-child(${i})`).should('have.text', quadrant) 180 | i++ 181 | } 182 | } 183 | 184 | validateSearchResults(query, results) { 185 | this.triggerSearch(query) 186 | cy.get(this.searchResultItems).should('have.length', results) 187 | } 188 | 189 | validateAlternateRadarsForPublicGoogleSheet() { 190 | cy.get(this.alternateRadarsItems).should('have.length', 2) 191 | 192 | let i = 1 193 | for (const name of config.PUBLIC_GOOGLE_SHEET_RADAR_SHEET_NAMES) { 194 | cy.get(`${this.alternateRadarsItems}:nth-child(${i})`).should('have.text', name) 195 | i++ 196 | } 197 | } 198 | 199 | validateQuadrantSubnavClick(name) { 200 | cy.get(this.quadrantDropdown).click() 201 | cy.get(this.platformsSubnavItem).click() 202 | cy.get(this.quadrantSelector).should('have.text', name) 203 | } 204 | 205 | validateRingsInQuadrantTable(count) { 206 | cy.get(this.quadrantTableRings).should('have.length', count) 207 | } 208 | 209 | validateBlipsInQuadrantTable(count) { 210 | cy.get(this.quadrantTableBlips).should('have.length', count) 211 | } 212 | 213 | validateBlipDescriptionVibisbleInQuadrantTable(blipId) { 214 | cy.get(this.quadrantTableBlip(blipId)).should('have.class', 'expand') 215 | cy.get(this.quadrantTableBlipDescription(blipId)).should('be.visible') 216 | } 217 | 218 | validBlipHighlightedInQuadrantTable(blipId) { 219 | cy.get(this.quadrantTableBlip(blipId)).parent('.blip-list__item').should('have.class', 'highlight') 220 | } 221 | 222 | validateBlipDescriptionHiddenInQuadrantTable(blipId) { 223 | cy.get(this.quadrantTableBlip(blipId)).should('not.have.class', 'expand') 224 | cy.get(this.quadrantTableBlipDescription(blipId)).should('be.hidden') 225 | } 226 | 227 | validateQuadrantGraphVisible(quadrantOrder) { 228 | cy.get(this.quadrantGraph(quadrantOrder)).should('be.visible') 229 | } 230 | 231 | validateQuadrantGraphHidden(quadrantOrder) { 232 | cy.get(this.quadrantGraph(quadrantOrder)).should('be.hidden') 233 | } 234 | 235 | validateActiveQuadrantInSubnav(quadrantName) { 236 | cy.get(this.subnavQuadrant(quadrantName)).should('have.class', 'active-item') 237 | } 238 | 239 | validateSubnavDropdownVisibleTablet() { 240 | cy.get(this.subnavList).should('be.visible') 241 | } 242 | 243 | validateSubnavDropdownHiddenTablet() { 244 | cy.get(this.subnavList).should('be.hidden') 245 | } 246 | 247 | validateActiveAlternateRadar(index) { 248 | cy.get(this.alternateRadarsItemByIndex(index)).should('have.class', 'active') 249 | } 250 | 251 | validateInactiveAlternateRadar(index) { 252 | cy.get(this.alternateRadarsItemByIndex(index)).should('not.have.class', 'active') 253 | } 254 | 255 | validateMobileQuadrantsVisible() { 256 | cy.get(this.mobileQuadrants).should('be.visible') 257 | } 258 | 259 | validateMobileQuadrantsHidden() { 260 | cy.get(this.mobileQuadrants).should('be.hidden') 261 | } 262 | 263 | validateGraphVisible() { 264 | cy.get(this.radarGraphSvg).should('be.visible') 265 | } 266 | 267 | validateGraphHidden() { 268 | cy.get(this.radarGraphSvg).should('be.hidden') 269 | } 270 | 271 | validateQuadrantOrder() { 272 | let i = 1 273 | for (const order of config.QUADRANT_ORDERS) { 274 | cy.get(this.quadrantNameGroup(order)).should('have.text', config.QUADRANT_NAMES[i]) 275 | i += 1 276 | } 277 | } 278 | 279 | validateRingOrder() { 280 | let i = 1 281 | for (const order of config.QUADRANT_ORDERS) { 282 | cy.get(this.quadrantRingName(order, i)).should('have.text', config.RING_NAMES[i - 1]) 283 | i += 1 284 | } 285 | } 286 | 287 | validateActiveQuadrant(quadrantName, quadrantOrder) { 288 | this.validateQuadrantGraphVisible(quadrantOrder) 289 | 290 | for (const order of config.QUADRANT_ORDERS.filter(function (order) { 291 | return order !== quadrantOrder 292 | })) { 293 | this.validateQuadrantGraphHidden(order) 294 | } 295 | 296 | this.validateActiveQuadrantInSubnav(quadrantName) 297 | } 298 | 299 | validateSearchVisible() { 300 | cy.get(this.searchBox).should('be.visible') 301 | } 302 | 303 | validateSearchFocused() { 304 | cy.get(this.searchBox).should('have.focus') 305 | } 306 | 307 | validateSearchValue(value) { 308 | cy.get(this.searchBox).should('have.value', value) 309 | } 310 | 311 | resetRadarView() { 312 | cy.scrollTo('top') 313 | cy.get(this.bannerTitle).click() 314 | } 315 | } 316 | 317 | module.exports = new RadarPage() 318 | -------------------------------------------------------------------------------- /spec/end_to_end_tests/resources/localfiles/radar.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Babel", 4 | "ring": "adopt", 5 | "quadrant": "tools", 6 | "isNew": "TRUE", 7 | "description": "test" 8 | }, 9 | { 10 | "name": "Apache Kafka", 11 | "ring": "trial", 12 | "quadrant": "languages & frameworks", 13 | "isNew": "FALSE", 14 | "description": "test" 15 | }, 16 | { 17 | "name": "Android-x86", 18 | "ring": "assess", 19 | "quadrant": "platforms", 20 | "isNew": "TRUE", 21 | "description": "test" 22 | }, 23 | { 24 | "name": "GrapCloud lift and shifthQL", 25 | "ring": "hold", 26 | "quadrant": "techniques", 27 | "isNew": "FALSE", 28 | "description": "test" 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /spec/exceptions/fileNotFoundError-spec.js: -------------------------------------------------------------------------------- 1 | const FileNotFoundError = require('../../src/exceptions/fileNotFoundError') 2 | describe('File Not Found Error', () => { 3 | it('should create a FileNotFoundException', () => { 4 | const error = new FileNotFoundError("Oops! We can't find the CSV file you've entered") 5 | expect(error).toBeInstanceOf(FileNotFoundError) 6 | expect(error).toBeInstanceOf(Error) 7 | expect(error.message).toStrictEqual("Oops! We can't find the CSV file you've entered") 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /spec/exceptions/invalidConfigError-spec.js: -------------------------------------------------------------------------------- 1 | const InvalidConfigError = require('../../src/exceptions/invalidConfigError') 2 | describe('Invalid Config Error', () => { 3 | it('should create a InvalidConfigError', () => { 4 | const error = new InvalidConfigError('Invalid Configuration') 5 | expect(error).toBeInstanceOf(InvalidConfigError) 6 | expect(error).toBeInstanceOf(Error) 7 | expect(error.message).toStrictEqual('Invalid Configuration') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /spec/exceptions/invalidContentError-spec.js: -------------------------------------------------------------------------------- 1 | const InvalidContentError = require('../../src/exceptions/invalidContentError') 2 | describe('Invalid Content Error', () => { 3 | it('should create a Invalid content Error', () => { 4 | const error = new InvalidContentError('Invalid Content') 5 | expect(error).toBeInstanceOf(InvalidContentError) 6 | expect(error).toBeInstanceOf(Error) 7 | expect(error.message).toStrictEqual('Invalid Content') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /spec/graphing/blips-spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | calculateRadarBlipCoordinates, 3 | getRingRadius, 4 | groupBlipsBaseCoords, 5 | transposeQuadrantCoords, 6 | getGroupBlipTooltipText, 7 | blipAssistiveText, 8 | createGroupBlip, 9 | thereIsCollision, 10 | sortBlipCoordinates, 11 | } = require('../../src/graphing/blips') 12 | const Chance = require('chance') 13 | const { graphConfig } = require('../../src/graphing/config') 14 | const Blip = require('../../src/models/blip') 15 | jest.mock('d3', () => { 16 | return { 17 | select: jest.fn(), 18 | } 19 | }) 20 | 21 | jest.mock('../../src/graphing/config', () => { 22 | return { 23 | graphConfig: { 24 | effectiveQuadrantHeight: 528, 25 | effectiveQuadrantWidth: 528, 26 | quadrantHeight: 512, 27 | quadrantWidth: 512, 28 | quadrantsGap: 32, 29 | minBlipWidth: 12, 30 | blipWidth: 22, 31 | groupBlipHeight: 24, 32 | newGroupBlipWidth: 84, 33 | existingGroupBlipWidth: 124, 34 | groupBlipAngles: [30, 35, 60, 80], 35 | }, 36 | } 37 | }) 38 | 39 | const chance = Chance() 40 | const chanceFloatingSpy = jest.spyOn(chance, 'floating') 41 | const chanceIntegerSpy = jest 42 | .spyOn(chance, 'integer') 43 | .mockImplementationOnce((options) => { 44 | return options.max 45 | }) 46 | .mockImplementation((options) => { 47 | return options.min 48 | }) 49 | 50 | function mockRingBlips(maxBlipCount) { 51 | let ringBlips = [] 52 | let blip 53 | for (let blipCounter = 1; blipCounter <= maxBlipCount; blipCounter++) { 54 | blip = new Blip(`blip${blipCounter}`, 'ring1', true, '', '') 55 | blip.setId(blipCounter) 56 | ringBlips.push(blip) 57 | } 58 | return ringBlips 59 | } 60 | 61 | describe('Blips', function () { 62 | it('should return coordinates which fall under the first quadrant and rings provided', function () { 63 | const startAngle = 0 64 | let minRadius = 160 65 | const maxRadius = 300 66 | const coordinates = calculateRadarBlipCoordinates(minRadius, maxRadius, startAngle, 'first', chance, { width: 22 }) 67 | 68 | const minRadiusAfterThreshold = minRadius + graphConfig.blipWidth / 2 69 | const maxRadiusAfterThreshold = maxRadius - graphConfig.blipWidth 70 | const xCoordMaxValue = 71 | graphConfig.effectiveQuadrantWidth + maxRadiusAfterThreshold * -1 * 0.9978403633398593 + graphConfig.blipWidth 72 | const yCoordMaxValue = graphConfig.effectiveQuadrantHeight + maxRadiusAfterThreshold * -1 * 0.06568568557743505 73 | const xCoordMinValue = graphConfig.effectiveQuadrantWidth + minRadiusAfterThreshold * -1 * 0.9942914830326867 74 | const yCoordMinValue = 75 | graphConfig.effectiveQuadrantHeight + minRadiusAfterThreshold * -1 * 0.9942914830326867 - graphConfig.blipWidth 76 | 77 | expect(chanceFloatingSpy).toHaveBeenCalledWith({ 78 | min: minRadiusAfterThreshold, 79 | max: maxRadiusAfterThreshold, 80 | fixed: 4, 81 | }) 82 | expect(chanceIntegerSpy).toHaveBeenCalled() 83 | expect(parseFloat(coordinates[0].toFixed(3))).toBeLessThanOrEqual(parseFloat(xCoordMinValue.toFixed(3))) 84 | expect(parseFloat(coordinates[1].toFixed(1))).toBeGreaterThanOrEqual(parseFloat(yCoordMinValue.toFixed(1))) 85 | expect(parseFloat(coordinates[0].toFixed(3))).toBeLessThanOrEqual(parseFloat(xCoordMaxValue.toFixed(3))) 86 | expect(parseFloat(coordinates[1].toFixed(3))).toBeLessThanOrEqual(parseFloat(yCoordMaxValue.toFixed(3))) 87 | }) 88 | 89 | it('should return coordinates for the second quadrant and consider the border offset provided', function () { 90 | const startAngle = -90 91 | let minRadius = 160 92 | const maxRadius = 300 93 | const blipWidth = 22 94 | const coordinates = calculateRadarBlipCoordinates(minRadius, maxRadius, startAngle, 'second', chance, { 95 | width: blipWidth, 96 | }) 97 | 98 | const minRadiusAfterThreshold = minRadius + blipWidth / 2 99 | const maxRadiusAfterThreshold = maxRadius - blipWidth 100 | const xCoordMaxValue = 101 | graphConfig.quadrantWidth + maxRadiusAfterThreshold * -1 * 0.0707372016677029 + graphConfig.quadrantsGap + 10 102 | const yCoordMaxValue = 103 | graphConfig.quadrantHeight + maxRadiusAfterThreshold * 0.9999 * 0.27563735581699916 + graphConfig.quadrantsGap 104 | const xCoordMinValue = 105 | graphConfig.quadrantWidth + minRadiusAfterThreshold * -1 * 0.9942914830326867 + graphConfig.quadrantsGap + 10 106 | const yCoordMinValue = 107 | graphConfig.quadrantHeight + minRadiusAfterThreshold * 0.9999 * 0.10670657355889696 + graphConfig.quadrantsGap 108 | 109 | expect(chanceFloatingSpy).toHaveBeenCalledWith({ 110 | min: minRadiusAfterThreshold, 111 | max: maxRadiusAfterThreshold, 112 | fixed: 4, 113 | }) 114 | expect(chanceIntegerSpy).toHaveBeenCalled() 115 | expect(parseFloat(coordinates[0].toFixed(3))).toBeLessThanOrEqual(parseFloat(xCoordMinValue.toFixed(3))) 116 | expect(coordinates[1]).toBeGreaterThan(yCoordMinValue) 117 | expect(coordinates[0]).toBeLessThan(xCoordMaxValue) 118 | expect(coordinates[1]).toBeLessThan(yCoordMaxValue) 119 | }) 120 | 121 | it('should return first quadrant group blip coordinates for ring1', function () { 122 | const baseCoords = groupBlipsBaseCoords(0) 123 | 124 | expect(baseCoords.new).toEqual([419.94200893545406, 442.552]) 125 | expect(baseCoords['existing']).toEqual([379.94200893545406, 471.552]) 126 | }) 127 | 128 | it('should transpose base coords for a new blip in ring1 to other three quadrants', function () { 129 | const newBlipBaseCoords = groupBlipsBaseCoords(0).new 130 | 131 | const coordsMap = transposeQuadrantCoords(newBlipBaseCoords, graphConfig.newGroupBlipWidth) 132 | expect(coordsMap.first).toEqual(newBlipBaseCoords) 133 | expect(coordsMap.second).toEqual([newBlipBaseCoords[0], 589.448]) 134 | expect(coordsMap.third).toEqual([552.057991064546, newBlipBaseCoords[1]]) 135 | expect(coordsMap.fourth).toEqual([552.057991064546, 589.448]) 136 | }) 137 | 138 | it('should return first quadrant group blip coordinates for ring2 with index 1', function () { 139 | const baseCoords = groupBlipsBaseCoords(1) 140 | expect(baseCoords.new).toEqual([287.0075702088335, 340.86317046071997]) 141 | expect(baseCoords['existing']).toEqual([247.0075702088335, 369.86317046071997]) 142 | }) 143 | 144 | it('should return first quadrant group blip coordinates for ring3 with index 2', function () { 145 | const baseCoords = groupBlipsBaseCoords(2) 146 | expect(baseCoords.new).toEqual([300.048, 153.99348500067663]) 147 | expect(baseCoords['existing']).toEqual([260.048, 182.99348500067663]) 148 | }) 149 | 150 | it('should return first quadrant group blip coordinates for ring4 with index 3', function () { 151 | const baseCoords = groupBlipsBaseCoords(3) 152 | expect(baseCoords.new).toEqual([408.91602532749283, 23.149928577467563]) 153 | expect(baseCoords['existing']).toEqual([368.91602532749283, 52.14992857746756]) 154 | }) 155 | 156 | it('should return group blip tool tip text as "Click to view all" count is more than 15', function () { 157 | let ringBlips = mockRingBlips(20) 158 | const actualToolTip = getGroupBlipTooltipText(ringBlips) 159 | const expectedToolTip = 'Click to view all' 160 | expect(actualToolTip).toEqual(expectedToolTip) 161 | }) 162 | 163 | it('should return group blip tool tip text as all blip names if count is <= 15', function () { 164 | let ringBlips = mockRingBlips(15) 165 | const actualToolTip = getGroupBlipTooltipText(ringBlips) 166 | const expectedToolTip = 167 | '1. blip1
2. blip2
3. blip3
4. blip4
5. blip5
6. blip6
7. blip7
8. blip8
9. blip9
10. blip10
11. blip11
12. blip12
13. blip13
14. blip14
15. blip15
' 168 | expect(actualToolTip).toEqual(expectedToolTip) 169 | }) 170 | 171 | it('should return ring radius based on the ring index', function () { 172 | expect(getRingRadius(0)).toBe(0) 173 | expect(getRingRadius(1)).toBe(161.792) 174 | expect(getRingRadius(2)).toBe(333.824) 175 | expect(getRingRadius(3)).toBe(425.984) 176 | expect(getRingRadius(4)).toBe(507.904) 177 | expect(getRingRadius(5)).toBe(0) 178 | }) 179 | 180 | it('should return group blip assistive text for group blip', function () { 181 | const blip = { 182 | isGroup: () => true, 183 | ring: () => { 184 | return { 185 | name: () => 'ring1', 186 | } 187 | }, 188 | blipText: () => '12 New Blips', 189 | name: 'blip1', 190 | isNew: () => true, 191 | status: () => null, 192 | } 193 | 194 | const actual = blipAssistiveText(blip) 195 | expect(actual).toEqual('`ring1 ring, group of 12 New Blips') 196 | }) 197 | 198 | it('should return correct assistive text for new blip', function () { 199 | const blip = { 200 | isGroup: () => false, 201 | ring: () => { 202 | return { 203 | name: () => 'Trial', 204 | } 205 | }, 206 | name: () => 'Some cool tech', 207 | status: () => 'New', 208 | } 209 | 210 | const actual = blipAssistiveText(blip) 211 | expect(actual).toEqual('Trial ring, Some cool tech, New.') 212 | }) 213 | 214 | it('should return correct assistive text for existing blip', function () { 215 | const blip = { 216 | isGroup: () => false, 217 | ring: () => { 218 | return { 219 | name: () => 'Trial', 220 | } 221 | }, 222 | name: () => 'Some cool tech', 223 | status: () => 'No change', 224 | } 225 | 226 | const actual = blipAssistiveText(blip) 227 | expect(actual).toEqual('Trial ring, Some cool tech, No change.') 228 | }) 229 | 230 | it('should return correct assistive text for moved in blip', function () { 231 | const blip = { 232 | isGroup: () => false, 233 | ring: () => { 234 | return { 235 | name: () => 'Trial', 236 | } 237 | }, 238 | name: () => 'Some cool tech', 239 | status: () => 'Moved in', 240 | } 241 | 242 | const actual = blipAssistiveText(blip) 243 | expect(actual).toEqual('Trial ring, Some cool tech, Moved in.') 244 | }) 245 | 246 | it('should return correct assistive text for moved out blip', function () { 247 | const blip = { 248 | isGroup: () => false, 249 | ring: () => { 250 | return { 251 | name: () => 'Trial', 252 | } 253 | }, 254 | name: () => 'Some cool tech', 255 | status: () => 'Moved out', 256 | } 257 | 258 | const actual = blipAssistiveText(blip) 259 | expect(actual).toEqual('Trial ring, Some cool tech, Moved out.') 260 | }) 261 | 262 | it('should return group blip with appropriate values', function () { 263 | const ringBlips = mockRingBlips(20) 264 | const groupBlip = createGroupBlip(ringBlips, 'New', { name: () => 'ring1' }, 'first') 265 | expect(groupBlip).toBeTruthy() 266 | expect(groupBlip.blipText()).toEqual('20 New blips') 267 | expect(groupBlip.id()).toEqual('first-ring1-group-new-blips') 268 | expect(groupBlip.isGroup()).toEqual(true) 269 | }) 270 | 271 | it('should return true when the given coords are colliding with existing coords', function () { 272 | const existingCoords = [{ coordinates: [10, 10], width: 22 }] 273 | 274 | expect(thereIsCollision([10, 10], existingCoords, 22)).toBe(true) 275 | expect(thereIsCollision([41, 41], existingCoords, 22)).toBe(true) 276 | expect(thereIsCollision([42, 42], existingCoords, 22)).toBe(false) 277 | }) 278 | 279 | it('should sort blips coordinates', function () { 280 | const existingCoords = [ 281 | { coordinates: [500, 400], width: 22 }, 282 | { coordinates: [200, 200], width: 22 }, 283 | { coordinates: [40, 40], width: 22 }, 284 | ] 285 | 286 | expect(sortBlipCoordinates(existingCoords, 'first')).toEqual([ 287 | { coordinates: [200, 200], width: 22 }, 288 | { coordinates: [40, 40], width: 22 }, 289 | { coordinates: [500, 400], width: 22 }, 290 | ]) 291 | expect(sortBlipCoordinates(existingCoords, 'third')).toEqual([ 292 | { coordinates: [200, 200], width: 22 }, 293 | { coordinates: [40, 40], width: 22 }, 294 | { coordinates: [500, 400], width: 22 }, 295 | ]) 296 | expect(sortBlipCoordinates(existingCoords, 'second')).toEqual([ 297 | { coordinates: [500, 400], width: 22 }, 298 | { coordinates: [200, 200], width: 22 }, 299 | { coordinates: [40, 40], width: 22 }, 300 | ]) 301 | expect(sortBlipCoordinates(existingCoords, 'fourth')).toEqual([ 302 | { coordinates: [500, 400], width: 22 }, 303 | { coordinates: [200, 200], width: 22 }, 304 | { coordinates: [40, 40], width: 22 }, 305 | ]) 306 | }) 307 | }) 308 | -------------------------------------------------------------------------------- /spec/graphing/components/quadrants-spec.js: -------------------------------------------------------------------------------- 1 | const { wrapQuadrantNameInMultiLine } = require('../../../src/graphing/components/quadrants') 2 | jest.mock('d3', () => { 3 | return { 4 | select: jest.fn(), 5 | } 6 | }) 7 | 8 | describe('Quadrants', function () { 9 | let element, mockedD3Element, quadrantGroup, tip 10 | beforeEach(() => { 11 | document.body.innerHTML = '
' + '
' 12 | 13 | window.Element.prototype.getBoundingClientRect = function () { 14 | return { 15 | x: 0, 16 | y: 0, 17 | bottom: 0, 18 | height: 0, 19 | left: 0, 20 | right: 0, 21 | top: 0, 22 | width: this.textContent.length * 10, 23 | } 24 | } 25 | element = document.querySelector('#my-elem') 26 | mockedD3Element = { node: () => element } 27 | quadrantGroup = { on: jest.fn() } 28 | tip = { show: jest.fn(), hide: jest.fn() } 29 | }) 30 | 31 | it('should render the text in one line if length is not above the max length', function () { 32 | element.innerHTML = 'Tools' 33 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip) 34 | let expectedTSpanTags = element.querySelectorAll('tspan') 35 | expect(expectedTSpanTags).toHaveLength(1) 36 | expect(expectedTSpanTags[0].textContent).toEqual('Tools') 37 | expect(expectedTSpanTags[0].getAttribute('dy')).toBeNull() 38 | }) 39 | 40 | it('should render the text in two lines if length is above the max length', function () { 41 | element.innerHTML = 'Languages & Frameworks' 42 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip) 43 | let expectedTSpanTags = element.querySelectorAll('tspan') 44 | expect(expectedTSpanTags).toHaveLength(2) 45 | expect(expectedTSpanTags[0].textContent).toEqual('Languages & ') 46 | expect(expectedTSpanTags[1].textContent).toEqual('Frameworks') 47 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20') 48 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20') 49 | }) 50 | 51 | it('should split the first word by hyphen and render the text in two lines if its longer than max length', function () { 52 | element.innerHTML = 'Pneumonoultramicroscopic' 53 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip) 54 | let expectedTSpanTags = element.querySelectorAll('tspan') 55 | expect(expectedTSpanTags).toHaveLength(2) 56 | expect(expectedTSpanTags[0].textContent).toEqual('Pneumonoultram-') 57 | expect(expectedTSpanTags[1].textContent).toEqual('icroscopic ') 58 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20') 59 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20') 60 | }) 61 | 62 | it('should split the first word by hyphen and render the text in two lines with ellipsis if its longer than max length after splitting also', function () { 63 | element.innerHTML = 'Pneumonoultramicro scopicsilicovolcanoconiosis' 64 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip) 65 | let expectedTSpanTags = element.querySelectorAll('tspan') 66 | expect(expectedTSpanTags).toHaveLength(2) 67 | expect(expectedTSpanTags[0].textContent).toEqual('Pneumonoultram-') 68 | expect(expectedTSpanTags[1].textContent).toEqual('icro scopics...') 69 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20') 70 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20') 71 | }) 72 | 73 | it('should render the text in two lines with ellipsis if its longer than max length', function () { 74 | element.innerHTML = 'Pneumonoultra microscopicsilicovolcanoconiosis' 75 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip) 76 | let expectedTSpanTags = element.querySelectorAll('tspan') 77 | expect(expectedTSpanTags).toHaveLength(2) 78 | expect(expectedTSpanTags[0].textContent).toEqual('Pneumonoultra ') 79 | expect(expectedTSpanTags[1].textContent).toEqual('microscopics...') 80 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20') 81 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /spec/graphing/config-spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | getScale, 3 | getGraphSize, 4 | getScaledQuadrantWidth, 5 | getScaledQuadrantHeightWithGap, 6 | isValidConfig, 7 | } = require('../../src/graphing/config') 8 | describe('Graphing Config', () => { 9 | it('should get the scale size for different window size', () => { 10 | window.innerWidth = 1440 11 | expect(getScale()).toStrictEqual(1.25) 12 | 13 | window.innerWidth = 1880 14 | expect(getScale()).toStrictEqual(1.5) 15 | }) 16 | 17 | it('should get the graph size', () => { 18 | expect(getGraphSize()).toStrictEqual(1056) 19 | }) 20 | 21 | it('should get the scaled quadrant width', () => { 22 | expect(getScaledQuadrantWidth(1.25)).toStrictEqual(640) 23 | }) 24 | 25 | it('should get the scaled quadrant height with gap', () => { 26 | expect(getScaledQuadrantHeightWithGap(1.25)).toStrictEqual(680) 27 | }) 28 | 29 | it('should validate the configs for quadrants and rings', () => { 30 | const oldEnv = process.env 31 | expect(isValidConfig()).toBeTruthy() 32 | 33 | process.env.QUADRANTS = '["radar"]' 34 | expect(isValidConfig()).toBeFalsy() 35 | 36 | process.env.QUADRANTS = '["radar", "r", "ra", "rad", "rada"]' 37 | expect(isValidConfig()).toBeFalsy() 38 | 39 | process.env.RINGS = '[]' 40 | expect(isValidConfig()).toBeFalsy() 41 | 42 | process.env.RINGS = '["radar", "r", "ra", "rad", "rada"]' 43 | expect(isValidConfig()).toBeFalsy() 44 | 45 | process.env = oldEnv 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /spec/helpers/jsdom.js: -------------------------------------------------------------------------------- 1 | const JSDOM = require('jsdom').JSDOM 2 | const dom = new JSDOM('') 3 | global.document = dom.window.document 4 | global.window = dom.window 5 | global.navigator = dom.window.navigator 6 | global.jQuery = global.$ = global.jquery = require('jquery') 7 | require('jquery-ui') 8 | -------------------------------------------------------------------------------- /spec/models/blip-spec.js: -------------------------------------------------------------------------------- 1 | const Blip = require('../../src/models/blip') 2 | const Ring = require('../../src/models/ring') 3 | const { graphConfig } = require('../../src/graphing/config') 4 | 5 | describe('Blip', function () { 6 | let blip 7 | 8 | beforeEach(function () { 9 | blip = new Blip('My Blip', new Ring('My Ring')) 10 | }) 11 | 12 | it('has a name', function () { 13 | expect(blip.name()).toEqual('My Blip') 14 | }) 15 | 16 | it('has a topic', function () { 17 | blip = new Blip('My Blip', new Ring('My Ring'), true, null, 'topic', 'description') 18 | 19 | expect(blip.topic()).toEqual('topic') 20 | }) 21 | 22 | it('has empty topic when not provided', function () { 23 | expect(blip.topic()).toEqual('') 24 | }) 25 | 26 | it('has a description', function () { 27 | blip = new Blip('My Blip', new Ring('My Ring'), true, null, 'topic', 'description') 28 | 29 | expect(blip.description()).toEqual('description') 30 | }) 31 | 32 | it('has empty description when not provided', function () { 33 | expect(blip.description()).toEqual('') 34 | }) 35 | 36 | it('has a ring', function () { 37 | expect(blip.ring().name()).toEqual('My Ring') 38 | }) 39 | 40 | it('has a default blip text', function () { 41 | expect(blip.blipText()).toEqual('') 42 | }) 43 | 44 | it('sets the blip text', function () { 45 | blip.setBlipText('blip text1') 46 | expect(blip.blipText()).toEqual('blip text1') 47 | }) 48 | 49 | it('has a default blip id', function () { 50 | expect(blip.id()).toEqual(-1) 51 | }) 52 | 53 | it('sets the blip id', function () { 54 | blip.setId(123) 55 | expect(blip.id()).toEqual(123) 56 | }) 57 | 58 | it('is new', function () { 59 | blip = new Blip('My Blip', new Ring('My Ring'), true, null, 'My Topic', 'My Description') 60 | 61 | expect(blip.isNew()).toBe(true) 62 | }) 63 | 64 | it('is not new', function () { 65 | blip = new Blip('My Blip', new Ring('My Ring'), false, null, 'My Topic', 'My Description') 66 | 67 | expect(blip.isNew()).toBe(false) 68 | }) 69 | 70 | it('status is new', function () { 71 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'new', 'My Topic', 'My Description') 72 | 73 | expect(blip.isNew()).toBe(true) 74 | }) 75 | 76 | it('status has moved in', function () { 77 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'Moved In', 'My Topic', 'My Description') 78 | 79 | expect(blip.hasMovedIn()).toBe(true) 80 | }) 81 | 82 | it('status has moved out', function () { 83 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'Moved Out', 'My Topic', 'My Description') 84 | 85 | expect(blip.hasMovedOut()).toBe(true) 86 | }) 87 | 88 | it('status has no change', function () { 89 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'No Change', 'My Topic', 'My Description') 90 | 91 | expect(blip.hasNoChange()).toBe(true) 92 | }) 93 | 94 | it('has false as default value for isGroup', function () { 95 | expect(blip.isGroup()).toEqual(false) 96 | }) 97 | 98 | it('sets the blip group', function () { 99 | blip.setIsGroup(true) 100 | expect(blip.isGroup()).toEqual(true) 101 | }) 102 | 103 | it('has blank group id by default', function () { 104 | expect(blip.groupIdInGraph()).toEqual('') 105 | }) 106 | 107 | it('sets the group id as passed value', function () { 108 | blip.setGroupIdInGraph('group-id-value') 109 | expect(blip.groupIdInGraph()).toEqual('group-id-value') 110 | }) 111 | 112 | it('get respective group blip width', function () { 113 | const existingBlip = new Blip('My Blip', new Ring('My Ring'), false) 114 | const newBlip = new Blip('My Blip', new Ring('My Ring'), true) 115 | 116 | expect(existingBlip.groupBlipWidth()).toEqual(graphConfig.existingGroupBlipWidth) 117 | expect(newBlip.groupBlipWidth()).toEqual(graphConfig.newGroupBlipWidth) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /spec/models/quadrant-spec.js: -------------------------------------------------------------------------------- 1 | const Quadrant = require('../../src/models/quadrant') 2 | const Blip = require('../../src/models/blip') 3 | 4 | describe('Quadrant', function () { 5 | it('has a name', function () { 6 | var quadrant = new Quadrant('My Quadrant') 7 | 8 | expect(quadrant.name()).toEqual('My Quadrant') 9 | }) 10 | 11 | it('has no blips by default', function () { 12 | var quadrant = new Quadrant('My Quadrant') 13 | 14 | expect(quadrant.blips()).toEqual([]) 15 | }) 16 | 17 | it('can add a single blip', function () { 18 | var quadrant = new Quadrant('My Quadrant') 19 | 20 | quadrant.add(new Blip()) 21 | 22 | expect(quadrant.blips()).toHaveLength(1) 23 | }) 24 | 25 | it('can add multiple blips', function () { 26 | var quadrant = new Quadrant('My Quadrant') 27 | 28 | quadrant.add([new Blip(), new Blip()]) 29 | 30 | expect(quadrant.blips()).toHaveLength(2) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /spec/models/radar-spec.js: -------------------------------------------------------------------------------- 1 | const Radar = require('../../src/models/radar') 2 | const Quadrant = require('../../src/models/quadrant') 3 | const Ring = require('../../src/models/ring') 4 | const Blip = require('../../src/models/blip') 5 | const MalformedDataError = require('../../src/exceptions/malformedDataError') 6 | const ExceptionMessages = require('../../src/util/exceptionMessages') 7 | const { graphConfig } = require('../../src/graphing/config') 8 | 9 | describe('Radar', function () { 10 | beforeEach(() => { 11 | process.env.ENVIRONMENT = 'development' 12 | }) 13 | 14 | it('has no quadrants by default', function () { 15 | var radar = new Radar() 16 | 17 | expect(radar.quadrants()[0].quadrant).not.toBeDefined() 18 | expect(radar.quadrants()[1].quadrant).not.toBeDefined() 19 | expect(radar.quadrants()[2].quadrant).not.toBeDefined() 20 | expect(radar.quadrants()[3].quadrant).not.toBeDefined() 21 | }) 22 | 23 | it('sets the first quadrant', function () { 24 | var quadrant, radar, blip 25 | 26 | blip = new Blip('A', new Ring('First')) 27 | quadrant = new Quadrant('First') 28 | quadrant.add([blip]) 29 | radar = new Radar() 30 | 31 | radar.addQuadrant(quadrant) 32 | 33 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant) 34 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1) 35 | }) 36 | 37 | it('sets the second quadrant', function () { 38 | var quadrant, radar, blip 39 | 40 | blip = new Blip('A', new Ring('First')) 41 | quadrant = new Quadrant('Second') 42 | quadrant.add([blip]) 43 | radar = new Radar() 44 | 45 | radar.addQuadrant(quadrant) 46 | 47 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant) 48 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1) 49 | }) 50 | 51 | it('sets the third quadrant', function () { 52 | var quadrant, radar, blip 53 | 54 | blip = new Blip('A', new Ring('First')) 55 | quadrant = new Quadrant('Third') 56 | quadrant.add([blip]) 57 | radar = new Radar() 58 | 59 | radar.addQuadrant(quadrant) 60 | 61 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant) 62 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1) 63 | }) 64 | 65 | it('sets the fourth quadrant', function () { 66 | var quadrant, radar, blip 67 | 68 | blip = new Blip('A', new Ring('First')) 69 | quadrant = new Quadrant('Fourth') 70 | quadrant.add([blip]) 71 | radar = new Radar() 72 | 73 | radar.addQuadrant(quadrant) 74 | 75 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant) 76 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1) 77 | }) 78 | 79 | it('sets the current sheet', function () { 80 | const radar = new Radar() 81 | let sheetName = 'The current sheet' 82 | radar.setCurrentSheet(sheetName) 83 | expect(radar.getCurrentSheet()).toEqual(sheetName) 84 | }) 85 | 86 | it('throws an error if too many quadrants are added', function () { 87 | var quadrant, radar, blip 88 | 89 | blip = new Blip('A', new Ring('First')) 90 | quadrant = new Quadrant('First') 91 | quadrant.add([blip]) 92 | radar = new Radar() 93 | 94 | radar.addQuadrant(quadrant) 95 | radar.addQuadrant(new Quadrant('Second')) 96 | radar.addQuadrant(new Quadrant('Third')) 97 | radar.addQuadrant(new Quadrant('Fourth')) 98 | 99 | expect(function () { 100 | radar.addQuadrant(new Quadrant('Fifth')) 101 | }).toThrow(new MalformedDataError(ExceptionMessages.TOO_MANY_QUADRANTS)) 102 | }) 103 | 104 | describe('blip numbers', function () { 105 | var firstQuadrant, secondQuadrant, radar, firstRing 106 | 107 | beforeEach(function () { 108 | firstRing = new Ring('Adopt', 0) 109 | firstQuadrant = new Quadrant('First') 110 | secondQuadrant = new Quadrant('Second') 111 | firstQuadrant.add([new Blip('A', firstRing), new Blip('B', firstRing)]) 112 | secondQuadrant.add([new Blip('C', firstRing), new Blip('D', firstRing)]) 113 | radar = new Radar() 114 | }) 115 | 116 | it('sets blip numbers starting on the first quadrant', function () { 117 | radar.addQuadrant(firstQuadrant) 118 | 119 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1) 120 | expect(radar.quadrants()[0].quadrant.blips()[1].blipText()).toEqual(2) 121 | }) 122 | 123 | it('continues the number from the previous quadrant set', function () { 124 | radar.addQuadrant(firstQuadrant) 125 | radar.addQuadrant(secondQuadrant) 126 | 127 | expect(radar.quadrants()[1].quadrant.blips()[0].blipText()).toEqual(3) 128 | expect(radar.quadrants()[1].quadrant.blips()[1].blipText()).toEqual(4) 129 | }) 130 | }) 131 | 132 | describe('alternatives', function () { 133 | it('returns a provided alternatives', function () { 134 | var radar = new Radar() 135 | 136 | var alternative1 = 'alternative1' 137 | var alternative2 = 'alternative2' 138 | 139 | radar.addAlternative(alternative1) 140 | radar.addAlternative(alternative2) 141 | 142 | expect(radar.getAlternatives()).toEqual([alternative1, alternative2]) 143 | }) 144 | }) 145 | 146 | describe('rings : new UI', function () { 147 | let quadrant, 148 | radar, 149 | firstRing, 150 | secondRing, 151 | otherQuadrant, 152 | thirdRing, 153 | fourthRing, 154 | invalidRing, 155 | rings = [] 156 | 157 | beforeEach(function () { 158 | process.env.ENVIRONMENT = 'development' 159 | firstRing = new Ring('hold', 0) 160 | secondRing = new Ring('ADOPT', 1) 161 | thirdRing = new Ring('TRiAl', 2) 162 | fourthRing = new Ring('assess', 3) 163 | invalidRing = new Ring('invalid', 3) 164 | quadrant = new Quadrant('Fourth') 165 | otherQuadrant = new Quadrant('Other') 166 | radar = new Radar() 167 | graphConfig.rings.forEach((ring, index) => rings.push(new Ring(ring, index))) 168 | radar.addRings(rings) 169 | }) 170 | 171 | it('returns an array of rings in configured order and ignore the order in which blips are provided', function () { 172 | quadrant.add([ 173 | new Blip('A', firstRing), 174 | new Blip('B', secondRing), 175 | new Blip('C', thirdRing), 176 | new Blip('D', fourthRing), 177 | ]) 178 | 179 | radar.addQuadrant(quadrant) 180 | radar.addQuadrant(otherQuadrant) 181 | radar.addQuadrant(otherQuadrant) 182 | radar.addQuadrant(otherQuadrant) 183 | 184 | expect(radar.rings()[0].name().toLowerCase()).toEqual(secondRing.name().toLowerCase()) 185 | expect(radar.rings()[1].name().toLowerCase()).toEqual(thirdRing.name().toLowerCase()) 186 | expect(radar.rings()[2].name().toLowerCase()).toEqual(fourthRing.name().toLowerCase()) 187 | expect(radar.rings()[3].name().toLowerCase()).toEqual(firstRing.name().toLowerCase()) 188 | }) 189 | 190 | it('should not return invalid rings other than the configured ones', function () { 191 | quadrant.add([new Blip('A', firstRing), new Blip('E', invalidRing)]) 192 | 193 | radar.addQuadrant(quadrant) 194 | 195 | expect(radar.rings()[3].name().toLowerCase()).toEqual(firstRing.name().toLowerCase()) 196 | expect( 197 | radar 198 | .rings() 199 | .map((ring) => ring.name().toLowerCase()) 200 | .includes(invalidRing.name().toLowerCase()), 201 | ).toBe(false) 202 | }) 203 | }) 204 | }) 205 | -------------------------------------------------------------------------------- /spec/models/ring-spec.js: -------------------------------------------------------------------------------- 1 | const Ring = require('../../src/models/ring') 2 | 3 | describe('Ring', function () { 4 | it('has a name', function () { 5 | var ring = Ring('My Ring') 6 | 7 | expect(ring.name()).toEqual('My Ring') 8 | }) 9 | 10 | it('has a order', function () { 11 | var ring = new Ring('My Ring', 0) 12 | 13 | expect(ring.order()).toEqual(0) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": ["**/*[sS]pec.js"], 4 | "helpers": ["helpers/**/*.js"], 5 | "stopSpecOnExpectationFailure": false, 6 | "random": false 7 | } 8 | -------------------------------------------------------------------------------- /spec/util/contentValidator-spec.js: -------------------------------------------------------------------------------- 1 | const ContentValidator = require('../../src/util/contentValidator') 2 | const MalformedDataError = require('../../src/exceptions/malformedDataError') 3 | const ExceptionMessages = require('../../src/util/exceptionMessages') 4 | 5 | describe('ContentValidator', function () { 6 | describe('verifyContent', function () { 7 | it('does not return anything if content is valid', function () { 8 | var columnNames = ['name', 'ring', 'quadrant', 'isNew', 'description'] 9 | var contentValidator = new ContentValidator(columnNames) 10 | 11 | expect(contentValidator.verifyContent()).not.toBeDefined() 12 | }) 13 | 14 | it('raises an error if content is empty', function () { 15 | var columnNames = [] 16 | var contentValidator = new ContentValidator(columnNames) 17 | 18 | expect(function () { 19 | contentValidator.verifyContent() 20 | }).toThrow(new MalformedDataError(ExceptionMessages.MISSING_CONTENT)) 21 | }) 22 | }) 23 | 24 | describe('verifyHeaders', function () { 25 | it('raises an error if one of the headers is empty', function () { 26 | var columnNames = ['ring', 'quadrant', 'isNew', 'description'] 27 | var contentValidator = new ContentValidator(columnNames) 28 | 29 | expect(function () { 30 | contentValidator.verifyHeaders() 31 | }).toThrow(new MalformedDataError(ExceptionMessages.MISSING_HEADERS)) 32 | }) 33 | 34 | it('does not return anything if the all required headers are present', function () { 35 | var columnNames = ['name', 'ring', 'quadrant', 'isNew', 'description'] 36 | var contentValidator = new ContentValidator(columnNames) 37 | 38 | expect(contentValidator.verifyHeaders()).not.toBeDefined() 39 | }) 40 | 41 | it('does not care about white spaces in the headers', function () { 42 | var columnNames = [' name', 'ring ', ' quadrant', 'isNew ', ' description '] 43 | var contentValidator = new ContentValidator(columnNames) 44 | 45 | expect(contentValidator.verifyHeaders()).not.toBeDefined() 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /spec/util/htmlUtil-spec.js: -------------------------------------------------------------------------------- 1 | const { getElementWidth, getElementHeight, decodeHTML } = require('../../src/util/htmlUtil') 2 | 3 | describe('HTML Utils', () => { 4 | let mockWidth, mockHeight, mockD3Element 5 | 6 | beforeEach(() => { 7 | mockWidth = 10 8 | mockHeight = 10 9 | 10 | mockD3Element = { 11 | node: function () { 12 | return { 13 | getBoundingClientRect: function () { 14 | return { 15 | width: mockWidth, 16 | height: mockHeight, 17 | } 18 | }, 19 | } 20 | }, 21 | } 22 | }) 23 | 24 | it('should return width of D3 element', () => { 25 | expect(getElementWidth(mockD3Element)).toEqual(mockWidth) 26 | }) 27 | 28 | it('should return height of D3 element', () => { 29 | expect(getElementHeight(mockD3Element)).toEqual(mockHeight) 30 | }) 31 | 32 | it('should decode encoded HTML entity', () => { 33 | expect(decodeHTML('<>&"'')).toEqual(`<>&"'`) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /spec/util/inputSanitizer-spec.js: -------------------------------------------------------------------------------- 1 | const InputSanitizer = require('../../src/util/inputSanitizer') 2 | 3 | describe('InputSanitizer', function () { 4 | var sanitizer, rawBlip, blip 5 | 6 | beforeAll(function () { 7 | sanitizer = new InputSanitizer() 8 | var description = "Hello there

heading

" 9 | rawBlip = { 10 | name: "Hello there

blip

", 11 | description: description, 12 | ring: 'Adopt', 13 | quadrant: 'techniques and tools', 14 | isNew: 'true
', 15 | } 16 | 17 | blip = sanitizer.sanitize(rawBlip) 18 | }) 19 | 20 | it('strips out script tags from blip descriptions', function () { 21 | expect(blip.description).toEqual('Hello there

heading

') 22 | }) 23 | 24 | it('strips out all tags from blip name', function () { 25 | expect(blip.name).toEqual('Hello there blip') 26 | }) 27 | 28 | it('strips out all tags from blip status', function () { 29 | expect(blip.isNew).toEqual('true') 30 | }) 31 | 32 | it('strips out all tags from blip ring', function () { 33 | expect(blip.ring).toEqual('Adopt') 34 | }) 35 | 36 | it('strips out all tags from blip quadrant', function () { 37 | expect(blip.quadrant).toEqual('techniques and tools') 38 | }) 39 | 40 | it('trims white spaces in keys and values', function () { 41 | rawBlip = { 42 | ' name': ' Some name ', 43 | ' ring ': ' Some ring name ', 44 | } 45 | blip = sanitizer.sanitize(rawBlip) 46 | 47 | expect(blip.name).toEqual('Some name') 48 | expect(blip.ring).toEqual('Some ring name') 49 | }) 50 | }) 51 | 52 | describe('Input Santizer for Protected sheet', function () { 53 | var sanitizer, rawBlip, blip, header 54 | beforeAll(function () { 55 | sanitizer = new InputSanitizer() 56 | header = ['name', 'quadrant', 'ring', 'isNew', 'description'] 57 | 58 | rawBlip = [ 59 | "Hello there

blip

", 60 | 'techniques & tools', 61 | "Adopt", 62 | 'true
', 63 | "Hello there

heading

", 64 | ] 65 | 66 | blip = sanitizer.sanitizeForProtectedSheet(rawBlip, header) 67 | }) 68 | 69 | it('strips out script tags from blip descriptions', function () { 70 | expect(blip.description).toEqual('Hello there

heading

') 71 | }) 72 | 73 | it('strips out all tags from blip name', function () { 74 | expect(blip.name).toEqual('Hello there blip') 75 | }) 76 | 77 | it('strips out all tags from blip status', function () { 78 | expect(blip.isNew).toEqual('true') 79 | }) 80 | 81 | it('strips out all tags from blip ring', function () { 82 | expect(blip.ring).toEqual('Adopt') 83 | }) 84 | 85 | it('strips out all tags from blip quadrant', function () { 86 | expect(blip.quadrant).toEqual('techniques & tools') 87 | }) 88 | 89 | it('trims white spaces in keys and values', function () { 90 | rawBlip = { 91 | ' name': ' Some name ', 92 | ' ring ': ' Some ring name ', 93 | } 94 | blip = sanitizer.sanitize(rawBlip) 95 | 96 | expect(blip.name).toEqual('Some name') 97 | expect(blip.ring).toEqual('Some ring name') 98 | }) 99 | 100 | it('should return blip with empty values if headers are empty', function () { 101 | const emptyHeader = [] 102 | const emptyBlip = sanitizer.sanitizeForProtectedSheet(rawBlip, emptyHeader) 103 | 104 | expect(emptyBlip).toStrictEqual({ 105 | name: '', 106 | description: '', 107 | ring: '', 108 | quadrant: '', 109 | isNew: '', 110 | status: '', 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /spec/util/mathUtils-spec.js: -------------------------------------------------------------------------------- 1 | const { toRadian } = require('../../src/util/mathUtils') 2 | 3 | describe('Math Utils', function () { 4 | it('should convert into radian', function () { 5 | expect(toRadian(180)).toEqual(Math.PI) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /spec/util/queryParamProcessor-spec.js: -------------------------------------------------------------------------------- 1 | const QueryParams = require('../../src/util/queryParamProcessor') 2 | 3 | describe('QueryParams', function () { 4 | it('retrieves one parameter', function () { 5 | var params = QueryParams('param1=value1') 6 | 7 | expect(params.param1).toEqual('value1') 8 | }) 9 | 10 | it('retrieves zero parameter', function () { 11 | var params = QueryParams('') 12 | 13 | expect(params).toEqual({}) 14 | }) 15 | 16 | it('retrieves two parameters', function () { 17 | var params = QueryParams('param1=value1¶m2=value2') 18 | 19 | expect(params.param1).toEqual('value1') 20 | expect(params.param2).toEqual('value2') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /spec/util/ringCalculator-spec.js: -------------------------------------------------------------------------------- 1 | const RingCalculator = require('../../src/util/ringCalculator') 2 | 3 | describe('ringCalculator', function () { 4 | var ringLength, radarSize, ringCalculator 5 | beforeAll(function () { 6 | ringLength = 4 7 | radarSize = 500 8 | ringCalculator = new RingCalculator(ringLength, radarSize) 9 | }) 10 | 11 | it('sums up the sequences', function () { 12 | expect(ringCalculator.sum(ringLength)).toEqual(16) 13 | }) 14 | 15 | it('calculates the correct radius', function () { 16 | expect(ringCalculator.getRadius(ringLength)).toEqual(radarSize) 17 | }) 18 | 19 | it('calculates the ring radius', function () { 20 | expect(ringCalculator.getRingRadius(1)).toEqual(158) 21 | }) 22 | 23 | it('calculates the ring radius for invalid ring as 0', function () { 24 | expect(ringCalculator.getRingRadius(10)).toEqual(0) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /spec/util/sheet-spec.js: -------------------------------------------------------------------------------- 1 | const Sheet = require('../../src/util/sheet') 2 | const config = require('../../src/config') 3 | 4 | jest.mock('../../src/config') 5 | describe('sheet', function () { 6 | const oldEnv = process.env 7 | beforeEach(() => { 8 | config.mockReturnValue({ featureToggles: { UIRefresh2022: true } }) 9 | process.env.API_KEY = 'API_KEY' 10 | }) 11 | 12 | afterEach(() => { 13 | jest.clearAllMocks() 14 | process.env = oldEnv 15 | }) 16 | 17 | it('knows to find the sheet id from published URL', function () { 18 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId/pubhtml') 19 | 20 | expect(sheet.id).toEqual('sheetId') 21 | }) 22 | 23 | it('knows to find the sheet id from publicly shared URL having query params', function () { 24 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId?abc=xyz') 25 | 26 | expect(sheet.id).toEqual('sheetId') 27 | }) 28 | 29 | it('knows to find the sheet id from publicly shared URL having extra path and query params', function () { 30 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId/edit?usp=sharing') 31 | 32 | expect(sheet.id).toEqual('sheetId') 33 | }) 34 | 35 | it('knows to find the sheet id from publicly shared URL having no extra path or query params', function () { 36 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId') 37 | 38 | expect(sheet.id).toEqual('sheetId') 39 | }) 40 | 41 | it('knows to find the sheet id from publicly shared URL with trailing slash', function () { 42 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId/') 43 | 44 | expect(sheet.id).toEqual('sheetId') 45 | }) 46 | 47 | it('can identify a plain sheet ID', function () { 48 | const sheet = new Sheet('sheetId') 49 | 50 | expect(sheet.id).toEqual('sheetId') 51 | }) 52 | 53 | it('calls back with nothing if the sheet exists', () => { 54 | const mockCallback = jest.fn() 55 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 200, response: 'response' } 56 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock) 57 | 58 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y') 59 | sheet.validate(mockCallback) 60 | xhrMock.onreadystatechange(new Event('')) 61 | 62 | expect(xhrMock.open).toHaveBeenCalledTimes(1) 63 | expect(xhrMock.open).toHaveBeenCalledWith( 64 | 'GET', 65 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y', 66 | true, 67 | ) 68 | expect(xhrMock.send).toHaveBeenCalledTimes(1) 69 | expect(xhrMock.send).toHaveBeenCalledWith(null) 70 | expect(mockCallback).toHaveBeenCalledTimes(1) 71 | expect(mockCallback).toHaveBeenCalledWith(null, 'API_KEY') 72 | }) 73 | 74 | it('calls back with error if sheet does not exist', function () { 75 | const mockCallback = jest.fn() 76 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 401, response: 'response' } 77 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock) 78 | 79 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y') 80 | sheet.validate(mockCallback) 81 | xhrMock.onreadystatechange(new Event('')) 82 | 83 | expect(xhrMock.open).toHaveBeenCalledTimes(1) 84 | expect(xhrMock.open).toHaveBeenCalledWith( 85 | 'GET', 86 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y', 87 | true, 88 | ) 89 | expect(xhrMock.send).toHaveBeenCalledTimes(1) 90 | expect(xhrMock.send).toHaveBeenCalledWith(null) 91 | expect(mockCallback).toHaveBeenCalledTimes(1) 92 | expect(mockCallback).toHaveBeenCalledWith({ message: 'UNAUTHORIZED' }, 'API_KEY') 93 | }) 94 | 95 | it('should give the sheet not found error with new message', () => { 96 | const errorMessage = 'Oops! We can’t find the Google Sheet you’ve entered, please check the URL of your sheet.' 97 | const mockCallback = jest.fn() 98 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 404, response: 'response' } 99 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock) 100 | 101 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y') 102 | sheet.validate(mockCallback) 103 | xhrMock.onreadystatechange(new Event('')) 104 | 105 | expect(xhrMock.open).toHaveBeenCalledTimes(1) 106 | expect(xhrMock.open).toHaveBeenCalledWith( 107 | 'GET', 108 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y', 109 | true, 110 | ) 111 | expect(xhrMock.send).toHaveBeenCalledTimes(1) 112 | expect(xhrMock.send).toHaveBeenCalledWith(null) 113 | expect(mockCallback).toHaveBeenCalledTimes(1) 114 | expect(mockCallback).toHaveBeenCalledWith({ message: errorMessage }, 'API_KEY') 115 | }) 116 | 117 | it('should give the sheet not found error with old message', () => { 118 | const errorMessage = 'Oops! We can’t find the Google Sheet you’ve entered. Can you check the URL?' 119 | const mockCallback = jest.fn() 120 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 404, response: 'response' } 121 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock) 122 | config.mockReturnValue({ featureToggles: { UIRefresh2022: false } }) 123 | 124 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y') 125 | sheet.validate(mockCallback) 126 | xhrMock.onreadystatechange(new Event('')) 127 | 128 | expect(xhrMock.open).toHaveBeenCalledTimes(1) 129 | expect(xhrMock.open).toHaveBeenCalledWith( 130 | 'GET', 131 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y', 132 | true, 133 | ) 134 | expect(xhrMock.send).toHaveBeenCalledTimes(1) 135 | expect(xhrMock.send).toHaveBeenCalledWith(null) 136 | expect(mockCallback).toHaveBeenCalledTimes(1) 137 | expect(mockCallback).toHaveBeenCalledWith({ message: errorMessage }, 'API_KEY') 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /spec/util/urlUtils-spec.js: -------------------------------------------------------------------------------- 1 | const { constructSheetUrl, getDocumentOrSheetId, getSheetName } = require('../../src/util/urlUtils') 2 | const queryParams = require('../../src/util/queryParamProcessor') 3 | 4 | jest.mock('../../src/util/queryParamProcessor') 5 | describe('Url Utils', () => { 6 | it('should construct the sheet url', () => { 7 | queryParams.mockReturnValue({ documentId: 'documentId' }) 8 | delete window.location 9 | window.location = Object.create(window) 10 | window.location.href = 'https://thoughtworks.com/radar?sheet=radar' 11 | window.location.search = '?' 12 | const sheetUrl = constructSheetUrl('radar') 13 | 14 | expect(sheetUrl).toStrictEqual('https://thoughtworks.com/radar?documentId=documentId&sheetName=radar') 15 | expect(queryParams).toHaveBeenCalledTimes(1) 16 | }) 17 | 18 | it('should construct the sheet url if sheetId is used', () => { 19 | queryParams.mockReturnValue({ sheetId: 'sheetId' }) 20 | delete window.location 21 | window.location = Object.create(window) 22 | window.location.href = 'https://thoughtworks.com/radar?sheet=radar' 23 | window.location.search = '?' 24 | const sheetUrl = constructSheetUrl('radar') 25 | 26 | expect(sheetUrl).toStrictEqual('https://thoughtworks.com/radar?sheetId=sheetId&sheetName=radar') 27 | expect(queryParams).toHaveBeenCalledTimes(1) 28 | }) 29 | 30 | it('should prioritize documentId before legacy sheetId', () => { 31 | queryParams.mockReturnValue({ documentId: 'documentId', sheetId: 'sheetId' }) 32 | delete window.location 33 | window.location = Object.create(window) 34 | window.location.href = 'https://thoughtworks.com/radar?documentId=documentId&sheetId=sheetId' 35 | window.location.search = '?' 36 | 37 | const id = getDocumentOrSheetId() 38 | 39 | expect(id).toEqual('documentId') 40 | }) 41 | 42 | it('supports documentId', () => { 43 | queryParams.mockReturnValue({ documentId: 'documentId' }) 44 | delete window.location 45 | window.location = Object.create(window) 46 | window.location.href = 'https://thoughtworks.com/radar?documentId=documentId' 47 | window.location.search = '?' 48 | 49 | const id = getDocumentOrSheetId() 50 | 51 | expect(id).toEqual('documentId') 52 | }) 53 | 54 | it('supports sheetId', () => { 55 | queryParams.mockReturnValue({ sheetId: 'sheetId' }) 56 | delete window.location 57 | window.location = Object.create(window) 58 | window.location.href = 'https://thoughtworks.com/radar?sheetId=sheetId' 59 | window.location.search = '?' 60 | 61 | const id = getDocumentOrSheetId() 62 | 63 | expect(id).toEqual('sheetId') 64 | }) 65 | 66 | it('supports sheetName', () => { 67 | queryParams.mockReturnValue({ sheetName: 'sheetName' }) 68 | delete window.location 69 | window.location = Object.create(window) 70 | window.location.href = 'https://thoughtworks.com/radar?sheetName=sheetName' 71 | window.location.search = '?' 72 | 73 | const sheetName = getSheetName() 74 | 75 | expect(sheetName).toEqual('sheetName') 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /spec/util/util-spec.js: -------------------------------------------------------------------------------- 1 | const { getRingIdString } = require('../../src/util/stringUtil') 2 | 3 | describe('Utils', () => { 4 | it('should return id string for ring name', () => { 5 | expect(getRingIdString('Tools')).toStrictEqual('tools') 6 | expect(getRingIdString('Platform Tools')).toStrictEqual('platform-tools') 7 | expect(getRingIdString('Languages & Frameworks')).toStrictEqual('languages---frameworks') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/analytics.js: -------------------------------------------------------------------------------- 1 | if (process.env.GTM_ID) { 2 | ;(function (w, d, s, l, i) { 3 | w[l] = w[l] || [] 4 | w[l].push({ 'gtm.start': new Date().getTime(), event: 'analytics.js' }) 5 | var f = d.getElementsByTagName(s)[0] 6 | var j = d.createElement(s) 7 | var dl = l !== 'dataLayer' ? '&l=' + l : '' 8 | j.async = true 9 | j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl 10 | f.parentNode.insertBefore(j, f) 11 | })(window, document, 'script', 'dataLayer', process.env.GTM_ID) 12 | } 13 | if (process.env.ADOBE_LAUNCH_SCRIPT_URL) { 14 | ;(function (w, d, s, i) { 15 | const l = d.createElement(s) 16 | l.async = true 17 | l.src = i 18 | l.type = 'text/javascript' 19 | d.head.append(l) 20 | })(window, document, 'script', process.env.ADOBE_LAUNCH_SCRIPT_URL) 21 | } 22 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | require('./stylesheets/base.scss') 2 | require('./images/tech-radar-landing-page-wide.png') 3 | require('./images/tw-logo.png') 4 | require('./images/favicon.ico') 5 | require('./images/search-logo-2x.svg') 6 | require('./images/banner-image-mobile.jpg') 7 | require('./images/banner-image-desktop.jpg') 8 | require('./images/new.svg') 9 | require('./images/moved.svg') 10 | require('./images/existing.svg') 11 | require('./images/no-change.svg') 12 | require('./images/arrow-icon.svg') 13 | require('./images/first-quadrant-btn-bg.svg') 14 | require('./images/second-quadrant-btn-bg.svg') 15 | require('./images/third-quadrant-btn-bg.svg') 16 | require('./images/fourth-quadrant-btn-bg.svg') 17 | require('./images/arrow-white-icon.svg') 18 | require('./images/search-active-wave.svg') 19 | require('./images/pdf_banner.png') 20 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const config = () => { 2 | const env = { 3 | production: { 4 | featureToggles: { 5 | UIRefresh2022: true, 6 | }, 7 | }, 8 | development: { 9 | featureToggles: { 10 | UIRefresh2022: true, 11 | }, 12 | }, 13 | } 14 | return process.env.ENVIRONMENT ? env[process.env.ENVIRONMENT] : env 15 | } 16 | module.exports = config 17 | -------------------------------------------------------------------------------- /src/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 7 |
8 |
9 |

Build your own radar

10 |
11 |
12 |
13 |

PAGE NOT FOUND

14 |
15 |

16 | Oops! It seems like the page you were trying to find isn't around anymore
(or at least isn't here). 17 |

18 |
19 |
20 |

You might want to look at

21 |
22 | 25 |
26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /src/exceptions/fileNotFoundError.js: -------------------------------------------------------------------------------- 1 | class FileNotFoundError extends Error { 2 | constructor(message) { 3 | super(message) 4 | this.message = message 5 | } 6 | } 7 | 8 | module.exports = FileNotFoundError 9 | -------------------------------------------------------------------------------- /src/exceptions/invalidConfigError.js: -------------------------------------------------------------------------------- 1 | class InvalidConfigError extends Error { 2 | constructor(message) { 3 | super(message) 4 | this.message = message 5 | } 6 | } 7 | 8 | module.exports = InvalidConfigError 9 | -------------------------------------------------------------------------------- /src/exceptions/invalidContentError.js: -------------------------------------------------------------------------------- 1 | class InvalidContentError extends Error { 2 | constructor(message) { 3 | super(message) 4 | this.message = message 5 | } 6 | } 7 | 8 | module.exports = InvalidContentError 9 | -------------------------------------------------------------------------------- /src/exceptions/malformedDataError.js: -------------------------------------------------------------------------------- 1 | const MalformedDataError = function (message) { 2 | this.message = message 3 | } 4 | 5 | Object.setPrototypeOf(MalformedDataError, Error) 6 | MalformedDataError.prototype = Object.create(Error.prototype) 7 | MalformedDataError.prototype.name = 'MalformedDataError' 8 | MalformedDataError.prototype.message = '' 9 | MalformedDataError.prototype.constructor = MalformedDataError 10 | 11 | module.exports = MalformedDataError 12 | -------------------------------------------------------------------------------- /src/exceptions/sheetNotFoundError.js: -------------------------------------------------------------------------------- 1 | const SheetNotFoundError = function (message) { 2 | this.message = message 3 | } 4 | 5 | Object.setPrototypeOf(SheetNotFoundError, Error) 6 | SheetNotFoundError.prototype = Object.create(Error.prototype) 7 | SheetNotFoundError.prototype.name = 'SheetNotFoundError' 8 | SheetNotFoundError.prototype.message = '' 9 | SheetNotFoundError.prototype.constructor = SheetNotFoundError 10 | 11 | module.exports = SheetNotFoundError 12 | -------------------------------------------------------------------------------- /src/exceptions/unauthorizedError.js: -------------------------------------------------------------------------------- 1 | const UnauthorizedError = function (message) { 2 | this.message = message 3 | } 4 | 5 | Object.setPrototypeOf(UnauthorizedError, Error) 6 | UnauthorizedError.prototype = Object.create(Error.prototype) 7 | UnauthorizedError.prototype.name = 'UnauthorizedError' 8 | UnauthorizedError.prototype.message = '' 9 | UnauthorizedError.prototype.constructor = UnauthorizedError 10 | 11 | module.exports = UnauthorizedError 12 | -------------------------------------------------------------------------------- /src/graphing/components/alternativeRadars.js: -------------------------------------------------------------------------------- 1 | const d3 = require('d3') 2 | const { constructSheetUrl } = require('../../util/urlUtils') 3 | 4 | function renderAlternativeRadars(radarFooter, alternatives, currentSheet) { 5 | const alternativesContainer = radarFooter.append('div').classed('alternative-radars', true) 6 | 7 | for (let i = 0; alternatives.length > 0; i++) { 8 | const list = alternatives.splice(0, 5) 9 | 10 | const alternativesList = alternativesContainer 11 | .append('ul') 12 | .classed(`alternative-radars__list`, true) 13 | .classed(`alternative-radars__list__row-${i}`, true) 14 | 15 | list.forEach(function (alternative) { 16 | const alternativeListItem = alternativesList.append('li').classed('alternative-radars__list-item', true) 17 | 18 | alternativeListItem 19 | .append('a') 20 | .classed('alternative-radars__list-item-link', true) 21 | .attr('href', constructSheetUrl(alternative)) 22 | .attr('role', 'tab') 23 | .text(alternative) 24 | 25 | if (currentSheet === alternative) { 26 | alternativeListItem.classed('active', true) 27 | 28 | d3.selectAll('.alternative-radars__list-item a').attr('aria-selected', null) 29 | alternativeListItem.select('a').attr('aria-selected', 'true') 30 | } 31 | }) 32 | } 33 | } 34 | 35 | module.exports = { 36 | renderAlternativeRadars, 37 | } 38 | -------------------------------------------------------------------------------- /src/graphing/components/banner.js: -------------------------------------------------------------------------------- 1 | const d3 = require('d3') 2 | 3 | const config = require('../../config') 4 | const { addPdfCoverTitle } = require('../pdfPage') 5 | const featureToggles = config().featureToggles 6 | 7 | function renderBanner(renderFullRadar) { 8 | if (featureToggles.UIRefresh2022) { 9 | const documentTitle = document.title[0].toUpperCase() + document.title.slice(1) 10 | 11 | document.title = documentTitle 12 | d3.select('.hero-banner__wrapper').append('p').classed('hero-banner__subtitle-text', true).text(document.title) 13 | d3.select('.hero-banner__title-text').on('click', renderFullRadar) 14 | 15 | addPdfCoverTitle(documentTitle) 16 | } else { 17 | const header = d3.select('body').insert('header', '#radar') 18 | header 19 | .append('div') 20 | .attr('class', 'radar-title') 21 | .append('div') 22 | .attr('class', 'radar-title__text') 23 | .append('h1') 24 | .text(document.title) 25 | .style('cursor', 'pointer') 26 | .on('click', renderFullRadar) 27 | 28 | header 29 | .select('.radar-title') 30 | .append('div') 31 | .attr('class', 'radar-title__logo') 32 | .html(' ') 33 | } 34 | } 35 | 36 | module.exports = { 37 | renderBanner, 38 | } 39 | -------------------------------------------------------------------------------- /src/graphing/components/buttons.js: -------------------------------------------------------------------------------- 1 | function renderButtons(radarFooter) { 2 | const buttonsRow = radarFooter.append('div').classed('buttons', true) 3 | 4 | buttonsRow 5 | .append('button') 6 | .classed('buttons__wave-btn', true) 7 | .text('Print this Radar') 8 | .on('click', window.print.bind(window)) 9 | 10 | buttonsRow 11 | .append('a') 12 | .classed('buttons__flamingo-btn', true) 13 | .attr('href', window.location.href.substring(0, window.location.href.indexOf(window.location.search))) 14 | .text('Generate new Radar') 15 | } 16 | 17 | module.exports = { 18 | renderButtons, 19 | } 20 | -------------------------------------------------------------------------------- /src/graphing/components/quadrantSubnav.js: -------------------------------------------------------------------------------- 1 | const d3 = require('d3') 2 | const { selectRadarQuadrant, removeScrollListener } = require('./quadrants') 3 | const { getRingIdString } = require('../../util/stringUtil') 4 | const { uiConfig } = require('../config') 5 | 6 | function addListItem(quadrantList, name, callback) { 7 | quadrantList 8 | .append('li') 9 | .attr('id', `subnav-item-${getRingIdString(name)}`) 10 | .classed('quadrant-subnav__list-item', true) 11 | .attr('title', name) 12 | .append('button') 13 | .classed('quadrant-subnav__list-item__button', true) 14 | .attr('role', 'tab') 15 | .text(name) 16 | .on('click', function (e) { 17 | d3.select('#radar').classed('no-blips', false) 18 | d3.select('#auto-complete').property('value', '') 19 | removeScrollListener() 20 | 21 | d3.select('.graph-header').node().scrollIntoView({ 22 | behavior: 'smooth', 23 | }) 24 | 25 | d3.select('span.quadrant-subnav__dropdown-selector').text(e.target.innerText) 26 | 27 | const subnavArrow = d3.select('.quadrant-subnav__dropdown-arrow') 28 | subnavArrow.classed('rotate', !d3.select('span.quadrant-subnav__dropdown-arrow').classed('rotate')) 29 | quadrantList.classed('show', !d3.select('ul.quadrant-subnav__list').classed('show')) 30 | 31 | const subnavDropdown = d3.select('.quadrant-subnav__dropdown') 32 | subnavDropdown.attr('aria-expanded', subnavDropdown.attr('aria-expanded') === 'false' ? 'true' : 'false') 33 | 34 | d3.selectAll('.blip-list__item-container.expand').classed('expand', false) 35 | 36 | if (callback) { 37 | callback() 38 | } 39 | }) 40 | } 41 | 42 | function renderQuadrantSubnav(radarHeader, quadrants, renderFullRadar) { 43 | const subnavContainer = radarHeader.append('nav').classed('quadrant-subnav', true) 44 | 45 | const subnavDropdown = subnavContainer 46 | .append('div') 47 | .classed('quadrant-subnav__dropdown', true) 48 | .attr('aria-expanded', 'false') 49 | subnavDropdown.append('span').classed('quadrant-subnav__dropdown-selector', true).text('All quadrants') 50 | const subnavArrow = subnavDropdown.append('span').classed('quadrant-subnav__dropdown-arrow', true) 51 | 52 | const quadrantList = subnavContainer.append('ul').classed('quadrant-subnav__list', true) 53 | addListItem(quadrantList, 'All quadrants', renderFullRadar) 54 | d3.select('li.quadrant-subnav__list-item').classed('active-item', true).select('button').attr('aria-selected', 'true') 55 | 56 | subnavDropdown.on('click', function () { 57 | subnavArrow.classed('rotate', !d3.select('span.quadrant-subnav__dropdown-arrow').classed('rotate')) 58 | quadrantList.classed('show', !d3.select('ul.quadrant-subnav__list').classed('show')) 59 | 60 | subnavDropdown.attr('aria-expanded', subnavDropdown.attr('aria-expanded') === 'false' ? 'true' : 'false') 61 | }) 62 | 63 | quadrants.forEach(function (quadrant) { 64 | addListItem(quadrantList, quadrant.quadrant.name(), () => 65 | selectRadarQuadrant(quadrant.order, quadrant.startAngle, quadrant.quadrant.name()), 66 | ) 67 | }) 68 | 69 | const subnavOffset = 70 | (window.innerWidth < 1024 ? uiConfig.tabletBannerHeight : uiConfig.bannerHeight) + uiConfig.headerHeight 71 | 72 | window.addEventListener('scroll', function () { 73 | if (subnavOffset <= window.scrollY) { 74 | d3.select('.quadrant-subnav').classed('sticky', true) 75 | d3.select('.search-container').classed('sticky-offset', true) 76 | } else { 77 | d3.select('.quadrant-subnav').classed('sticky', false) 78 | d3.select('.search-container').classed('sticky-offset', false) 79 | } 80 | }) 81 | } 82 | 83 | module.exports = { 84 | renderQuadrantSubnav, 85 | } 86 | -------------------------------------------------------------------------------- /src/graphing/components/quadrantTables.js: -------------------------------------------------------------------------------- 1 | const d3 = require('d3') 2 | const { graphConfig, getScale, uiConfig } = require('../config') 3 | const { stickQuadrantOnScroll } = require('./quadrants') 4 | const { removeAllSpaces } = require('../../util/stringUtil') 5 | 6 | function fadeOutAllBlips() { 7 | d3.selectAll('g > a.blip-link').attr('opacity', 0.3) 8 | } 9 | 10 | function fadeInSelectedBlip(selectedBlipOnGraph) { 11 | selectedBlipOnGraph.attr('opacity', 1.0) 12 | } 13 | 14 | function highlightBlipInTable(selectedBlip) { 15 | selectedBlip.classed('highlight', true) 16 | } 17 | 18 | function highlightBlipInGraph(blipIdToFocus) { 19 | fadeOutAllBlips() 20 | const selectedBlipOnGraph = d3.select(`g > a.blip-link[data-blip-id='${blipIdToFocus}'`) 21 | fadeInSelectedBlip(selectedBlipOnGraph) 22 | } 23 | 24 | function renderBlipDescription(blip, ring, quadrant, tip, groupBlipTooltipText) { 25 | let blipTableItem = d3.select(`.quadrant-table.${quadrant.order} ul[data-ring-order='${ring.order()}']`) 26 | if (!groupBlipTooltipText) { 27 | blipTableItem = blipTableItem.append('li').classed('blip-list__item', true) 28 | const blipItemDiv = blipTableItem 29 | .append('div') 30 | .classed('blip-list__item-container', true) 31 | .attr('data-blip-id', blip.id()) 32 | 33 | if (blip.groupIdInGraph()) { 34 | blipItemDiv.attr('data-group-id', blip.groupIdInGraph()) 35 | } 36 | 37 | const blipItemContainer = blipItemDiv 38 | .append('button') 39 | .classed('blip-list__item-container__name', true) 40 | .attr('aria-expanded', 'false') 41 | .attr('aria-controls', `blip-description-${blip.id()}`) 42 | .attr('aria-hidden', 'true') 43 | .attr('tabindex', -1) 44 | .on('click search-result-click', function (e) { 45 | e.stopPropagation() 46 | 47 | const expandFlag = d3.select(e.target.parentElement).classed('expand') 48 | 49 | d3.selectAll('.blip-list__item-container.expand').classed('expand', false) 50 | d3.select(e.target.parentElement).classed('expand', !expandFlag) 51 | 52 | d3.selectAll('.blip-list__item-container__name').attr('aria-expanded', 'false') 53 | d3.select('.blip-list__item-container.expand .blip-list__item-container__name').attr('aria-expanded', 'true') 54 | 55 | if (window.innerWidth >= uiConfig.tabletViewWidth) { 56 | stickQuadrantOnScroll() 57 | } 58 | }) 59 | 60 | blipItemContainer 61 | .append('span') 62 | .classed('blip-list__item-container__name-value', true) 63 | .text(`${blip.blipText()}. ${blip.name()}`) 64 | blipItemContainer.append('span').classed('blip-list__item-container__name-arrow', true) 65 | 66 | blipItemDiv 67 | .append('div') 68 | .classed('blip-list__item-container__description', true) 69 | .attr('id', `blip-description-${blip.id()}`) 70 | .html(blip.description()) 71 | } 72 | const blipGraphItem = d3.select(`g a#blip-link-${removeAllSpaces(blip.id())}`) 73 | const mouseOver = function (e) { 74 | const targetElement = e.target.classList.contains('blip-link') ? e.target : e.target.parentElement 75 | const isGroupIdInGraph = !targetElement.classList.contains('blip-link') ? true : false 76 | const blipWrapper = d3.select(targetElement) 77 | const blipIdToFocus = blip.groupIdInGraph() ? blipWrapper.attr('data-group-id') : blipWrapper.attr('data-blip-id') 78 | const selectedBlipOnGraph = d3.select(`g > a.blip-link[data-blip-id='${blipIdToFocus}'`) 79 | highlightBlipInGraph(blipIdToFocus) 80 | highlightBlipInTable(blipTableItem) 81 | 82 | const isQuadrantView = d3.select('svg#radar-plot').classed('quadrant-view') 83 | const displayToolTip = blip.isGroup() ? !isQuadrantView : !blip.groupIdInGraph() 84 | const toolTipText = blip.isGroup() ? groupBlipTooltipText : blip.name() 85 | 86 | if (displayToolTip && !isGroupIdInGraph) { 87 | tip.show(toolTipText, selectedBlipOnGraph.node()) 88 | 89 | const selectedBlipCoords = selectedBlipOnGraph.node().getBoundingClientRect() 90 | 91 | const tipElement = d3.select('div.d3-tip') 92 | const tipElementCoords = tipElement.node().getBoundingClientRect() 93 | 94 | tipElement 95 | .style( 96 | 'left', 97 | `${parseInt( 98 | selectedBlipCoords.left + window.scrollX - tipElementCoords.width / 2 + selectedBlipCoords.width / 2, 99 | )}px`, 100 | ) 101 | .style('top', `${parseInt(selectedBlipCoords.top + window.scrollY - tipElementCoords.height)}px`) 102 | } 103 | } 104 | 105 | const mouseOut = function () { 106 | d3.selectAll('g > a.blip-link').attr('opacity', 1.0) 107 | blipTableItem.classed('highlight', false) 108 | tip.hide().style('left', 0).style('top', 0) 109 | } 110 | 111 | const blipClick = function (e) { 112 | const isQuadrantView = d3.select('svg#radar-plot').classed('quadrant-view') 113 | const targetElement = e.target.classList.contains('blip-link') ? e.target : e.target.parentElement 114 | if (isQuadrantView) { 115 | e.stopPropagation() 116 | } 117 | 118 | const blipId = d3.select(targetElement).attr('data-blip-id') 119 | highlightBlipInGraph(blipId) 120 | 121 | d3.selectAll('.blip-list__item-container.expand').classed('expand', false) 122 | 123 | let selectedBlipContainer = d3.select(`.blip-list__item-container[data-blip-id="${blipId}"`) 124 | selectedBlipContainer.classed('expand', true) 125 | 126 | setTimeout( 127 | () => { 128 | if (window.innerWidth >= uiConfig.tabletViewWidth) { 129 | stickQuadrantOnScroll() 130 | } 131 | 132 | const isGroupBlip = isNaN(parseInt(blipId)) 133 | if (isGroupBlip) { 134 | selectedBlipContainer = d3.select(`.blip-list__item-container[data-group-id="${blipId}"`) 135 | } 136 | const elementToFocus = selectedBlipContainer.select('button.blip-list__item-container__name') 137 | elementToFocus.node()?.scrollIntoView({ 138 | behavior: 'smooth', 139 | }) 140 | }, 141 | isQuadrantView ? 0 : 1500, 142 | ) 143 | } 144 | 145 | !groupBlipTooltipText && 146 | blipTableItem.on('mouseover', mouseOver).on('mouseout', mouseOut).on('focusin', mouseOver).on('focusout', mouseOut) 147 | blipGraphItem 148 | .on('mouseover', mouseOver) 149 | .on('mouseout', mouseOut) 150 | .on('focusin', mouseOver) 151 | .on('focusout', mouseOut) 152 | .on('click', blipClick) 153 | } 154 | 155 | function renderQuadrantTables(quadrants, rings) { 156 | const radarContainer = d3.select('#radar') 157 | 158 | const quadrantTablesContainer = radarContainer.append('div').classed('quadrant-table__container', true) 159 | quadrants.forEach(function (quadrant) { 160 | const scale = getScale() 161 | let quadrantContainer 162 | if (window.innerWidth < uiConfig.tabletViewWidth && window.innerWidth >= uiConfig.mobileViewWidth) { 163 | quadrantContainer = quadrantTablesContainer 164 | .append('div') 165 | .classed('quadrant-table', true) 166 | .classed(quadrant.order, true) 167 | .style( 168 | 'margin', 169 | `${ 170 | graphConfig.quadrantHeight * scale + 171 | graphConfig.quadrantsGap * scale + 172 | graphConfig.quadrantsGap * 2 + 173 | uiConfig.legendsHeight 174 | }px auto 0px`, 175 | ) 176 | .style('left', '0') 177 | .style('right', 0) 178 | } else { 179 | quadrantContainer = quadrantTablesContainer 180 | .append('div') 181 | .classed('quadrant-table', true) 182 | .classed(quadrant.order, true) 183 | } 184 | 185 | const ringNames = Array.from( 186 | new Set( 187 | quadrant.quadrant 188 | .blips() 189 | .map((blip) => blip.ring()) 190 | .map((ring) => ring.name()), 191 | ), 192 | ) 193 | ringNames.forEach(function (ringName) { 194 | quadrantContainer 195 | .append('h2') 196 | .classed('quadrant-table__ring-name', true) 197 | .attr('data-ring-name', ringName) 198 | .text(ringName) 199 | quadrantContainer 200 | .append('ul') 201 | .classed('blip-list', true) 202 | .attr('data-ring-order', rings.filter((ring) => ring.name() === ringName)[0].order()) 203 | }) 204 | }) 205 | } 206 | 207 | module.exports = { 208 | renderQuadrantTables, 209 | renderBlipDescription, 210 | } 211 | -------------------------------------------------------------------------------- /src/graphing/components/search.js: -------------------------------------------------------------------------------- 1 | const d3 = require('d3') 2 | 3 | const AutoComplete = require('../../util/autoComplete') 4 | const { selectRadarQuadrant, removeScrollListener } = require('../components/quadrants') 5 | 6 | function renderSearch(radarHeader, quadrants) { 7 | const searchContainer = radarHeader.append('div').classed('search-container', true) 8 | 9 | searchContainer 10 | .append('input') 11 | .classed('search-container__input', true) 12 | .attr('placeholder', 'Search this radar') 13 | .attr('id', 'auto-complete') 14 | 15 | AutoComplete('#auto-complete', quadrants, function (e, ui) { 16 | const blipId = ui.item.blip.id() 17 | const quadrant = ui.item.quadrant 18 | 19 | selectRadarQuadrant(quadrant.order, quadrant.startAngle, quadrant.quadrant.name()) 20 | const blipElement = d3.select( 21 | `.blip-list__item-container[data-blip-id="${blipId}"] .blip-list__item-container__name`, 22 | ) 23 | 24 | removeScrollListener() 25 | blipElement.dispatch('search-result-click') 26 | 27 | setTimeout(() => { 28 | blipElement.node().scrollIntoView({ 29 | behavior: 'smooth', 30 | }) 31 | }, 1500) 32 | }) 33 | } 34 | 35 | module.exports = { 36 | renderSearch, 37 | } 38 | -------------------------------------------------------------------------------- /src/graphing/config.js: -------------------------------------------------------------------------------- 1 | const quadrantSize = 512 2 | const quadrantGap = 32 3 | 4 | const getQuadrants = () => { 5 | return JSON.parse(process.env.QUADRANTS || null) || ['Techniques', 'Platforms', 'Tools', 'Languages & Frameworks'] 6 | } 7 | 8 | const getRings = () => { 9 | return JSON.parse(process.env.RINGS || null) || ['Adopt', 'Trial', 'Assess', 'Hold'] 10 | } 11 | 12 | const isBetween = (number, startNumber, endNumber) => { 13 | return startNumber <= number && number <= endNumber 14 | } 15 | const isValidConfig = () => { 16 | return getQuadrants().length === 4 && isBetween(getRings().length, 1, 4) 17 | } 18 | 19 | const graphConfig = { 20 | effectiveQuadrantHeight: quadrantSize + quadrantGap / 2, 21 | effectiveQuadrantWidth: quadrantSize + quadrantGap / 2, 22 | quadrantHeight: quadrantSize, 23 | quadrantWidth: quadrantSize, 24 | quadrantsGap: quadrantGap, 25 | minBlipWidth: 12, 26 | blipWidth: 22, 27 | groupBlipHeight: 24, 28 | newGroupBlipWidth: 88, 29 | existingGroupBlipWidth: 124, 30 | rings: getRings(), 31 | quadrants: getQuadrants(), 32 | groupBlipAngles: [30, 35, 60, 80], 33 | maxBlipsInRings: [8, 22, 17, 18], 34 | } 35 | 36 | const uiConfig = { 37 | subnavHeight: 60, 38 | bannerHeight: 200, 39 | tabletBannerHeight: 300, 40 | headerHeight: 80, 41 | legendsHeight: 42, 42 | tabletViewWidth: 1280, 43 | mobileViewWidth: 768, 44 | } 45 | 46 | function getScale() { 47 | return window.innerWidth < 1800 ? 1.25 : 1.5 48 | } 49 | 50 | function getGraphSize() { 51 | return graphConfig.effectiveQuadrantHeight + graphConfig.effectiveQuadrantWidth 52 | } 53 | 54 | function getScaledQuadrantWidth(scale) { 55 | return graphConfig.quadrantWidth * scale 56 | } 57 | 58 | function getScaledQuadrantHeightWithGap(scale) { 59 | return (graphConfig.quadrantHeight + graphConfig.quadrantsGap) * scale 60 | } 61 | 62 | module.exports = { 63 | graphConfig, 64 | uiConfig, 65 | getScale, 66 | getGraphSize, 67 | getScaledQuadrantWidth, 68 | getScaledQuadrantHeightWithGap, 69 | isValidConfig, 70 | } 71 | -------------------------------------------------------------------------------- /src/graphing/pdfPage.js: -------------------------------------------------------------------------------- 1 | const d3 = require('d3') 2 | 3 | const addPdfCoverTitle = (title) => { 4 | d3.select('main #pdf-cover-page .pdf-title').text(title) 5 | } 6 | 7 | const addRadarLinkInPdfView = () => { 8 | d3.select('#generated-radar-link').attr('href', window.location.href) 9 | } 10 | 11 | const addQuadrantNameInPdfView = (order, quadrantName) => { 12 | d3.select(`.quadrant-table.${order}`) 13 | .insert('div', ':first-child') 14 | .attr('class', 'quadrant-table__name') 15 | .text(quadrantName) 16 | } 17 | 18 | module.exports = { addPdfCoverTitle, addQuadrantNameInPdfView, addRadarLinkInPdfView } 19 | -------------------------------------------------------------------------------- /src/images/arrow-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/images/arrow-white-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/images/banner-image-desktop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/banner-image-desktop.jpg -------------------------------------------------------------------------------- /src/images/banner-image-mobile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/banner-image-mobile.jpg -------------------------------------------------------------------------------- /src/images/existing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/favicon.ico -------------------------------------------------------------------------------- /src/images/first-quadrant-btn-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/images/fourth-quadrant-btn-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/logo.png -------------------------------------------------------------------------------- /src/images/moved.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/images/new.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/images/no-change.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/images/pdf_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/pdf_banner.png -------------------------------------------------------------------------------- /src/images/radar_legend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/radar_legend.png -------------------------------------------------------------------------------- /src/images/search-active-wave.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/images/search-logo-2x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | search@2x 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/images/second-quadrant-btn-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/images/tech-radar-landing-page-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/tech-radar-landing-page-wide.png -------------------------------------------------------------------------------- /src/images/third-quadrant-btn-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/images/tw-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/tw-logo.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 21 |
22 |
23 |

Build your own Radar

24 |
25 |
26 |
27 |
28 | Pdf cover image 29 |

30 |
31 | Powered by 32 | 33 | 34 | 35 |
36 |
37 |
38 |

39 | Once you've created your Radar you can use this service 40 | to generate an interactive version of your Technology Radar. Not sure how? 41 | Read this first. 42 |

43 | 44 | Building your radar... 45 |

Your Technology Radar will be available in just a few seconds

46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |

57 | Enter the URL of your 58 | Google Sheet, CSV or JSON file below… 59 |

60 |
61 | 68 | 69 | Need help? 70 |
71 |
72 |
73 |
74 |

75 | There are no blips on this quadrant, please check your Google sheet/CSV/JSON file once. 76 |

77 |
78 |
79 | 80 |
81 | 103 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/models/blip.js: -------------------------------------------------------------------------------- 1 | const { graphConfig } = require('../graphing/config') 2 | const IDEAL_BLIP_WIDTH = 22 3 | const Blip = function (name, ring, isNew, status, topic, description) { 4 | let self, blipText, isGroup, id, groupIdInGraph 5 | 6 | self = {} 7 | isGroup = false 8 | 9 | self.width = IDEAL_BLIP_WIDTH 10 | 11 | self.name = function () { 12 | return name 13 | } 14 | 15 | self.id = function () { 16 | return id || -1 17 | } 18 | 19 | self.groupBlipWidth = function () { 20 | return isNew ? graphConfig.newGroupBlipWidth : graphConfig.existingGroupBlipWidth 21 | } 22 | 23 | self.topic = function () { 24 | return topic || '' 25 | } 26 | 27 | self.description = function () { 28 | return description || '' 29 | } 30 | 31 | self.isNew = function () { 32 | if (status) { 33 | return status.toLowerCase() === 'new' 34 | } 35 | 36 | return isNew 37 | } 38 | 39 | self.hasMovedIn = function () { 40 | return status.toLowerCase() === 'moved in' 41 | } 42 | 43 | self.hasMovedOut = function () { 44 | return status.toLowerCase() === 'moved out' 45 | } 46 | 47 | self.hasNoChange = function () { 48 | return status.toLowerCase() === 'no change' 49 | } 50 | 51 | self.status = function () { 52 | return status.toLowerCase() || '' 53 | } 54 | 55 | self.isGroup = function () { 56 | return isGroup 57 | } 58 | 59 | self.groupIdInGraph = function () { 60 | return groupIdInGraph || '' 61 | } 62 | 63 | self.setGroupIdInGraph = function (groupId) { 64 | groupIdInGraph = groupId 65 | } 66 | 67 | self.ring = function () { 68 | return ring 69 | } 70 | 71 | self.blipText = function () { 72 | return blipText || '' 73 | } 74 | 75 | self.setBlipText = function (newBlipText) { 76 | blipText = newBlipText 77 | } 78 | 79 | self.setId = function (newId) { 80 | id = newId 81 | } 82 | 83 | self.setIsGroup = function (isAGroupBlip) { 84 | isGroup = isAGroupBlip 85 | } 86 | 87 | return self 88 | } 89 | 90 | module.exports = Blip 91 | -------------------------------------------------------------------------------- /src/models/quadrant.js: -------------------------------------------------------------------------------- 1 | const Quadrant = function (name) { 2 | var self, blips 3 | 4 | self = {} 5 | blips = [] 6 | 7 | self.name = function () { 8 | return name 9 | } 10 | 11 | self.add = function (newBlips) { 12 | if (Array.isArray(newBlips)) { 13 | blips = blips.concat(newBlips) 14 | } else { 15 | blips.push(newBlips) 16 | } 17 | } 18 | 19 | self.blips = function () { 20 | return blips.slice(0) 21 | } 22 | 23 | return self 24 | } 25 | 26 | module.exports = Quadrant 27 | -------------------------------------------------------------------------------- /src/models/radar.js: -------------------------------------------------------------------------------- 1 | const MalformedDataError = require('../exceptions/malformedDataError') 2 | const ExceptionMessages = require('../util/exceptionMessages') 3 | 4 | const _ = { 5 | map: require('lodash/map'), 6 | uniqBy: require('lodash/uniqBy'), 7 | sortBy: require('lodash/sortBy'), 8 | } 9 | 10 | const Radar = function () { 11 | const config = require('../config') 12 | const featureToggles = config().featureToggles 13 | 14 | let self, quadrants, blipNumber, addingQuadrant, alternatives, currentSheetName, rings 15 | 16 | blipNumber = 0 17 | addingQuadrant = 0 18 | quadrants = featureToggles.UIRefresh2022 19 | ? [ 20 | { order: 'first', startAngle: 0 }, 21 | { order: 'second', startAngle: -90 }, 22 | { order: 'third', startAngle: 90 }, 23 | { order: 'fourth', startAngle: -180 }, 24 | ] 25 | : [ 26 | { order: 'first', startAngle: 90 }, 27 | { order: 'second', startAngle: 0 }, 28 | { order: 'third', startAngle: -90 }, 29 | { order: 'fourth', startAngle: -180 }, 30 | ] 31 | alternatives = [] 32 | currentSheetName = '' 33 | self = {} 34 | rings = {} 35 | 36 | function setNumbers(blips) { 37 | blips.forEach(function (blip) { 38 | ++blipNumber 39 | blip.setBlipText(blipNumber) 40 | blip.setId(blipNumber) 41 | }) 42 | } 43 | 44 | self.addAlternative = function (sheetName) { 45 | alternatives.push(sheetName) 46 | } 47 | 48 | self.getAlternatives = function () { 49 | return alternatives 50 | } 51 | 52 | self.setCurrentSheet = function (sheetName) { 53 | currentSheetName = sheetName 54 | } 55 | 56 | self.getCurrentSheet = function () { 57 | return currentSheetName 58 | } 59 | 60 | self.addQuadrant = function (quadrant) { 61 | if (addingQuadrant >= 4) { 62 | throw new MalformedDataError(ExceptionMessages.TOO_MANY_QUADRANTS) 63 | } 64 | quadrants[addingQuadrant].quadrant = quadrant 65 | setNumbers(quadrant.blips()) 66 | addingQuadrant++ 67 | } 68 | 69 | self.addRings = function (allRings) { 70 | rings = allRings 71 | } 72 | 73 | function allQuadrants() { 74 | if (addingQuadrant < 4) { 75 | throw new MalformedDataError(ExceptionMessages.LESS_THAN_FOUR_QUADRANTS) 76 | } 77 | 78 | return _.map(quadrants, 'quadrant') 79 | } 80 | 81 | function allBlips() { 82 | return allQuadrants().reduce(function (blips, quadrant) { 83 | return blips.concat(quadrant.blips()) 84 | }, []) 85 | } 86 | 87 | self.rings = function () { 88 | if (featureToggles.UIRefresh2022) { 89 | return rings 90 | } 91 | 92 | return _.sortBy( 93 | _.map( 94 | _.uniqBy(allBlips(), function (blip) { 95 | return blip.ring().name() 96 | }), 97 | function (blip) { 98 | return blip.ring() 99 | }, 100 | ), 101 | function (ring) { 102 | return ring.order() 103 | }, 104 | ) 105 | } 106 | 107 | self.quadrants = function () { 108 | return quadrants 109 | } 110 | 111 | return self 112 | } 113 | 114 | module.exports = Radar 115 | -------------------------------------------------------------------------------- /src/models/ring.js: -------------------------------------------------------------------------------- 1 | const Ring = function (name, order) { 2 | var self = {} 3 | 4 | self.name = function () { 5 | return name 6 | } 7 | 8 | self.order = function () { 9 | return order 10 | } 11 | 12 | return self 13 | } 14 | 15 | module.exports = Ring 16 | -------------------------------------------------------------------------------- /src/site.js: -------------------------------------------------------------------------------- 1 | require('./common') 2 | require('./images/logo.png') 3 | require('./images/radar_legend.png') 4 | require('./analytics.js') 5 | 6 | const Factory = require('./util/factory') 7 | 8 | Factory().build() 9 | -------------------------------------------------------------------------------- /src/stylesheets/_alternativeradars.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | @if $UIRefresh2022 { 5 | .graph-footer { 6 | display: flex; 7 | flex-direction: column; 8 | width: 100%; 9 | 10 | @include media-query-medium { 11 | align-items: center; 12 | } 13 | } 14 | 15 | .alternative-radars { 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | 20 | margin: 32px auto 0; 21 | 22 | @include layout-margin(1, $screen-small); 23 | @include layout-margin(1, $screen-medium); 24 | @include layout-margin(1, $screen-large); 25 | @include layout-margin(1, $screen-xlarge); 26 | @include layout-margin(1, $screen-xxlarge); 27 | @include layout-margin(calc(10 / 12), $screen-xxxlarge); 28 | 29 | &__list { 30 | width: 100%; 31 | display: flex; 32 | flex-direction: column; 33 | list-style: none; 34 | padding: 0; 35 | justify-content: center; 36 | font-size: 1.125rem; 37 | font-family: $baseFontFamily; 38 | font-weight: 630; 39 | margin-block: 0; 40 | margin-bottom: 16px; 41 | margin-left: 0; 42 | 43 | @include media-query-medium { 44 | font-size: 1.25rem; 45 | flex-direction: row; 46 | } 47 | 48 | @include media-query-xxlarge { 49 | font-size: 1.5rem; 50 | } 51 | 52 | &__row-0 { 53 | @include media-query-medium { 54 | margin-top: 48px; 55 | } 56 | } 57 | 58 | &-item { 59 | flex-grow: 1; 60 | flex-basis: 0; 61 | text-align: center; 62 | padding: 0 4px; 63 | border-bottom: 1px solid $mist; 64 | 65 | &.active { 66 | border-bottom: 3px solid $flamingo-s40; 67 | color: $flamingo-s40; 68 | 69 | &:hover { 70 | color: $link-hover; 71 | } 72 | } 73 | 74 | &:only-child { 75 | flex-basis: unset; 76 | flex-grow: unset; 77 | padding: 0 32px 4px; 78 | } 79 | 80 | &-link { 81 | display: inline-block; 82 | width: 100%; 83 | margin-top: 32px; 84 | padding: 0 8px 4px; 85 | box-sizing: border-box; 86 | border: 1px solid transparent; 87 | border-width: 0px 1px; 88 | 89 | display: -webkit-box; 90 | line-height: 1.75rem; 91 | -webkit-box-orient: vertical; 92 | 93 | @include media-query-medium { 94 | -webkit-line-clamp: 1; 95 | overflow: hidden; 96 | text-overflow: ellipsis; 97 | margin-top: 0; 98 | } 99 | 100 | &:hover { 101 | border-color: transparent !important; 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/stylesheets/_buttons.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | @if $UIRefresh2022 { 5 | .buttons { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | justify-content: center; 10 | margin: 64px auto 56px; 11 | 12 | @include layout-margin(calc(3 / 4), $screen-small); 13 | @include layout-margin(calc(6 / 8), $screen-medium); 14 | @include layout-margin(calc(8 / 12), $screen-large); 15 | @include layout-margin(calc(6 / 12), $screen-xlarge); 16 | @include layout-margin(calc(6 / 12), $screen-xxlarge); 17 | @include layout-margin(calc(4 / 12), $screen-xxxlarge); 18 | 19 | @include media-query-medium { 20 | flex-direction: row; 21 | } 22 | 23 | button { 24 | border: none; 25 | font-size: 20px; 26 | color: $white; 27 | width: 220px; 28 | height: 48px; 29 | margin: 0 15px 32px; 30 | cursor: pointer; 31 | 32 | @include media-query-medium { 33 | margin: 0 15px; 34 | } 35 | 36 | a { 37 | border: none; 38 | color: $white; 39 | 40 | &:hover { 41 | color: $white; 42 | } 43 | } 44 | } 45 | 46 | &__wave-btn { 47 | background-color: $wave; 48 | } 49 | 50 | &__flamingo-btn { 51 | background-color: $flamingo-s40; 52 | color: $white; 53 | 54 | display: inline-flex; 55 | align-items: center; 56 | justify-content: center; 57 | 58 | width: 220px; 59 | height: 48px; 60 | 61 | border: none; 62 | font-size: 20px; 63 | 64 | &:hover { 65 | color: $white; 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/stylesheets/_colors.scss: -------------------------------------------------------------------------------- 1 | $green: #86b782; 2 | $blue: #1ebccd; 3 | $orange: #f38a3e; 4 | $violet: #b32059; 5 | $pink: #ee0b77; 6 | 7 | $grey-darkest: #bababa; 8 | $grey-dark: #cacaca; 9 | $grey: #dadada; 10 | $grey-light: #eee; 11 | $grey-lightest: #fafafa; 12 | $grey-even-darker: #d7d7d7; 13 | 14 | $white: #fff; 15 | $black: #000; 16 | $grey-text: rgb(51, 51, 51); 17 | 18 | $grey-alpha-03: rgba(255, 255, 255, 0.3); 19 | 20 | // Brand colors for use across theme. 21 | $black: #000000; //onyx 22 | $white: #ffffff; //white 23 | $wave: #163c4d; //wave 24 | $wave-light: #6b8591; //wave-light 25 | $flamingo: #e16a7c; //flamingo 26 | $mist: #edf1f3; //mist (ededed) 27 | $sapphire: #47a1ad; //$sapphire 28 | $jade: #6b9e78; //jade 29 | $turmeric: #cc850a; //turmeric 30 | $amethyst: #634f7d; //amethyst 31 | 32 | // Other colors 33 | $mist-s30: #71777d; 34 | $mist-s20: #d5d9db; 35 | $mist-s10: #e1e5e7; 36 | $mist-light: #f7fafc; 37 | $flamingo-s40: #bd4257; 38 | 39 | $button-normal: $wave; //button color 40 | $button-disabled: #909090; //disabled state for links and buttons 41 | 42 | $link-normal: $black; //links color 43 | $link-hover: #9b293c; //links hover state color 44 | $error-text: #d14234; 45 | 46 | $flamingo-dark: #9b293c; //flamingo-dark 47 | $sapphire-dark: #1f8290; //$sapphire-dark 48 | $jade-dark: #517b5c; //jade-dark 49 | $turmeric-dark: #a06908; //turmeric-dark 50 | -------------------------------------------------------------------------------- /src/stylesheets/_error.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | .error-container { 5 | text-align: center; 6 | padding-top: 48px; 7 | 8 | .error-container__message { 9 | @if not $UIRefresh2022 { 10 | width: 60%; 11 | display: inline-block; 12 | } 13 | @if $UIRefresh2022 { 14 | p:first-child { 15 | color: $error-text; 16 | } 17 | p { 18 | margin: 0; 19 | } 20 | } 21 | } 22 | 23 | .error-title { 24 | font-weight: 400; 25 | } 26 | 27 | .error-subtitle { 28 | margin-top: -10px; 29 | } 30 | 31 | .switch-account-button { 32 | margin: 0 0 35px 0; 33 | } 34 | 35 | .switch-account-button-newui { 36 | background-color: $button-normal; 37 | color: $white; 38 | font-size: 20px; 39 | font-family: $baseFontFamily; 40 | line-height: 24px; 41 | font-weight: bold; 42 | padding-left: 30px; 43 | padding-right: 30px; 44 | margin-top: 10px; 45 | margin-bottom: 18px; 46 | border: none; 47 | text-transform: none; 48 | cursor: pointer; 49 | &:focus { 50 | outline: auto; 51 | } 52 | } 53 | } 54 | 55 | .input-sheet { 56 | .page-not-found { 57 | font-size: 40px; 58 | font-weight: 900; 59 | margin-bottom: 20px; 60 | } 61 | 62 | .message p { 63 | font-size: 25px; 64 | } 65 | } 66 | 67 | .support { 68 | margin-top: 20px; 69 | 70 | p { 71 | font-weight: 500; 72 | font-size: 30px; 73 | margin-bottom: 1px; 74 | } 75 | } 76 | 77 | .support-link { 78 | font-size: 26px; 79 | padding: 20px; 80 | 81 | span { 82 | padding: 0 40px; 83 | display: table-cell; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/stylesheets/_fonts.scss: -------------------------------------------------------------------------------- 1 | $baseFont: 17px; 2 | $baseWeight: 100; 3 | $baseFontFamily: 'Inter', sans-serif; 4 | -------------------------------------------------------------------------------- /src/stylesheets/_footer.scss: -------------------------------------------------------------------------------- 1 | @import 'layout'; 2 | @import 'fonts'; 3 | 4 | #footer { 5 | text-align: center; 6 | clear: both; 7 | 8 | .footer-content { 9 | width: 50%; 10 | margin: 0 auto; 11 | 12 | p { 13 | padding-top: 60px; 14 | font-size: $baseFont; 15 | font-weight: $baseWeight; 16 | text-align: left; 17 | } 18 | } 19 | } 20 | 21 | @if $UIRefresh2022 { 22 | footer { 23 | margin-bottom: 40px; 24 | 25 | p { 26 | @include eight-column-layout-margin; 27 | font-family: $baseFontFamily; 28 | font-weight: normal; 29 | margin-top: 30px; 30 | font-size: 16px !important; 31 | 32 | @include media-query-large { 33 | font-size: 18px !important; 34 | text-align: center; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/stylesheets/_form.scss: -------------------------------------------------------------------------------- 1 | .input-sheet { 2 | margin: 60px auto; 3 | 4 | p { 5 | font-weight: 100; 6 | color: $black; 7 | font-size: 18px; 8 | } 9 | 10 | .input-sheet__logo { 11 | text-align: center; 12 | padding-bottom: 40px; 13 | a { 14 | border-bottom: none; 15 | 16 | img { 17 | width: 200px; 18 | } 19 | } 20 | } 21 | 22 | .radar-footer { 23 | padding-top: 200px; 24 | } 25 | 26 | .input-sheet__banner { 27 | background-image: url('/images/tech-radar-landing-page-wide.png'); 28 | background-repeat: no-repeat; 29 | background-color: $grey-light; 30 | background-size: cover; 31 | background-position: center; 32 | width: 100%; 33 | margin: 0 auto; 34 | text-align: center; 35 | min-height: 285px; 36 | display: table; 37 | 38 | div { 39 | display: table-cell; 40 | vertical-align: middle; 41 | } 42 | p, 43 | h1 { 44 | color: $white; 45 | } 46 | 47 | a { 48 | color: $white; 49 | border-bottom-color: $white; 50 | } 51 | } 52 | 53 | .input-sheet__form { 54 | width: 50%; 55 | margin: 0 auto; 56 | text-align: center; 57 | padding-top: 30px; 58 | 59 | button { 60 | font-family: inherit; 61 | border: none; 62 | background-color: transparent; 63 | margin: 0; 64 | padding: 0; 65 | } 66 | } 67 | 68 | input[type='text'] { 69 | border-bottom: 2px solid $grey-even-darker; 70 | display: block; 71 | font-size: 18px; 72 | margin-bottom: 30px; 73 | padding: 10px; 74 | transition: 75 | box-shadow 0.3s, 76 | border 0.3s; 77 | 78 | &:focus, 79 | &.focus { 80 | outline: none; 81 | border-bottom: 2px solid $grey-even-darker; 82 | box-shadow: none; 83 | } 84 | } 85 | 86 | form, 87 | input, 88 | a { 89 | font-family: inherit; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/stylesheets/_header.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | header { 5 | text-align: center; 6 | } 7 | 8 | .radar-title, 9 | .buttons-group, 10 | #alternative-buttons { 11 | display: flex; 12 | flex-direction: row; 13 | justify-content: flex-start; 14 | align-items: center; 15 | align-content: space-between; 16 | } 17 | 18 | .radar-title { 19 | background-color: $grey-light; 20 | padding: 30px 0; 21 | 22 | display: table; 23 | margin: auto; 24 | width: 100%; 25 | 26 | .radar-title__text { 27 | display: table-cell; 28 | width: 70%; 29 | 30 | text-align: left; 31 | padding-left: 10%; 32 | 33 | h1 { 34 | font-size: 55px; 35 | font-weight: 900; 36 | letter-spacing: -0.04em; 37 | line-height: 0.8em; 38 | margin: 0; 39 | text-transform: uppercase; 40 | } 41 | } 42 | 43 | .radar-title__logo { 44 | flex: 0 0 30%; 45 | margin-left: auto; 46 | 47 | width: 30%; 48 | display: table-cell; 49 | vertical-align: middle; 50 | 51 | a { 52 | border-bottom: none; 53 | } 54 | 55 | img { 56 | vertical-align: middle; 57 | width: 34%; 58 | } 59 | } 60 | } 61 | 62 | .quadrant-btn--group, 63 | .multiple-sheet-button-group { 64 | text-align: left; 65 | padding-left: 10%; 66 | } 67 | 68 | .print-radar-btn, 69 | .search-box { 70 | margin-left: auto; 71 | margin-right: 10%; 72 | } 73 | 74 | .buttons-group { 75 | padding: 15px 0 25px; 76 | } 77 | 78 | .home-link { 79 | color: $pink; 80 | margin-bottom: 10px; 81 | line-height: normal; 82 | cursor: pointer; 83 | display: inline-block; 84 | font-size: $baseFont; 85 | text-align: left; 86 | width: 80%; 87 | } 88 | 89 | .button { 90 | font-size: $baseFont; 91 | text-transform: capitalize; 92 | margin-right: 20px; 93 | border-radius: 2px; 94 | padding: 10px 20px; 95 | cursor: pointer; 96 | transition: all 0.2s ease-out; 97 | 98 | background-color: $grey-light; 99 | color: $black; 100 | 101 | &.no-capitalize { 102 | text-transform: none; 103 | } 104 | 105 | &:hover, 106 | &.selected { 107 | transform: translate(0, -2px); 108 | opacity: 0.85; 109 | 110 | &.first { 111 | color: white; 112 | background-color: $green; 113 | } 114 | 115 | &.second { 116 | color: white; 117 | background-color: $blue; 118 | } 119 | 120 | &.third { 121 | color: white; 122 | background-color: $orange; 123 | } 124 | 125 | &.fourth { 126 | color: white; 127 | background-color: $violet; 128 | } 129 | } 130 | 131 | &.full-view { 132 | &.first { 133 | background-color: $green; 134 | color: $white; 135 | } 136 | 137 | &.second { 138 | background-color: $blue; 139 | color: $white; 140 | } 141 | 142 | &.third { 143 | background-color: $orange; 144 | color: $white; 145 | } 146 | 147 | &.fourth { 148 | background-color: $violet; 149 | color: $white; 150 | } 151 | } 152 | } 153 | 154 | #alternative-buttons { 155 | margin-bottom: 50px; 156 | 157 | .highlight { 158 | border-bottom: none; 159 | font-weight: bold; 160 | } 161 | 162 | p { 163 | font-size: 16px; 164 | font-weight: 700; 165 | margin-top: 0; 166 | margin-bottom: 10px; 167 | } 168 | 169 | .multiple-sheet-button { 170 | margin-right: 10px; 171 | } 172 | 173 | .search-radar { 174 | border: 1px solid #aaa; 175 | background-color: inherit; 176 | background-image: url('/images/search-logo-2x.svg'); 177 | background-repeat: no-repeat; 178 | background-position: 10px; 179 | } 180 | 181 | input { 182 | padding-left: 35px; 183 | width: 275px; 184 | } 185 | } 186 | 187 | @if not $UIRefresh2022 { 188 | .ui-autocomplete { 189 | width: 275px !important; 190 | 191 | .ui-autocomplete-quadrant { 192 | font-size: 14px; 193 | font-weight: 600; 194 | padding: 5px; 195 | } 196 | 197 | .ui-menu-item { 198 | white-space: normal; 199 | font-size: 14px; 200 | font-weight: 400; 201 | 202 | .ui-menu-item-wrapper { 203 | padding: 0 10px; 204 | } 205 | } 206 | } 207 | } 208 | 209 | @if $UIRefresh2022 { 210 | header { 211 | height: $headerHeight; 212 | width: 100%; 213 | display: flex; 214 | align-items: center; 215 | 216 | div { 217 | display: flex; 218 | flex-direction: row; 219 | 220 | margin: auto; 221 | width: 90%; 222 | 223 | @include layout-margin(1, $screen-small); 224 | @include layout-margin(1, $screen-medium); 225 | @include layout-margin(1, $screen-large); 226 | @include layout-margin(1, $screen-xlarge); 227 | @include layout-margin(1, $screen-xxlarge); 228 | @include layout-margin(1, $screen-xxxlarge); 229 | 230 | span { 231 | margin-right: 5px; 232 | font-family: $baseFontFamily; 233 | font-weight: 630; 234 | } 235 | 236 | a { 237 | display: block; 238 | border-bottom: none; 239 | 240 | img { 241 | height: 20px; 242 | width: auto; 243 | margin-top: 1.5px; 244 | } 245 | } 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/stylesheets/_herobanner.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | @if $UIRefresh2022 { 5 | .hero-banner { 6 | height: $tabletBannerHeight; 7 | background-color: $amethyst; 8 | background-image: url('/images/banner-image-mobile.jpg'); 9 | background-size: contain; 10 | background-repeat: no-repeat; 11 | background-position: right; 12 | color: $white; 13 | display: flex; 14 | align-items: center; 15 | margin: 0 !important; 16 | 17 | @include media-query-large { 18 | background-image: url('/images/banner-image-mobile.jpg'); 19 | } 20 | 21 | @include media-query-xlarge { 22 | background-image: url('/images/banner-image-desktop.jpg'); 23 | height: $bannerHeight; 24 | background-size: cover; 25 | } 26 | 27 | &__wrapper { 28 | margin: auto; 29 | width: 90%; 30 | 31 | @include layout-margin(1, $screen-small); 32 | @include layout-margin(1, $screen-medium); 33 | @include layout-margin(1, $screen-large); 34 | @include layout-margin(1, $screen-xlarge); 35 | @include layout-margin(1, $screen-xxlarge); 36 | @include layout-margin(1, $screen-xxxlarge); 37 | } 38 | 39 | &__title-text { 40 | width: fit-content; 41 | text-align: left; 42 | text-transform: none; 43 | letter-spacing: 0; 44 | cursor: pointer; 45 | } 46 | 47 | &__subtitle-text { 48 | font-weight: 630; 49 | width: 50%; 50 | text-align: left; 51 | text-transform: none; 52 | letter-spacing: 0; 53 | overflow: hidden; 54 | text-overflow: ellipsis; 55 | display: -webkit-box; 56 | line-height: 1.75rem; 57 | -webkit-line-clamp: 4; 58 | -webkit-box-orient: vertical; 59 | 60 | @include media-query-medium { 61 | width: 37.5%; 62 | -webkit-line-clamp: 2; 63 | } 64 | 65 | @include media-query-large { 66 | width: 25%; 67 | } 68 | 69 | @include media-query-xlarge { 70 | width: 33%; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/stylesheets/_landingpage.scss: -------------------------------------------------------------------------------- 1 | @import 'layout'; 2 | @import 'colors'; 3 | @import 'fonts'; 4 | 5 | .helper-description { 6 | @include eight-column-layout-margin; 7 | padding-top: 32px; 8 | margin-top: 24px; 9 | 10 | p { 11 | font-weight: bold; 12 | margin: 0; 13 | } 14 | 15 | .loader-text { 16 | display: none; 17 | text-align: center; 18 | &__title { 19 | display: inline-block; 20 | font-size: 2.5rem; 21 | font-weight: bold; 22 | margin-bottom: 1rem; 23 | } 24 | } 25 | } 26 | 27 | .input-sheet-form { 28 | @include eight-column-layout-margin; 29 | display: flex; 30 | flex-direction: column; 31 | 32 | p { 33 | margin-top: 88px; 34 | text-align: center; 35 | } 36 | 37 | p.with-error { 38 | margin-top: 48px; 39 | } 40 | 41 | form { 42 | display: flex; 43 | flex-direction: column; 44 | justify-content: center; 45 | flex-wrap: nowrap; 46 | align-items: center; 47 | 48 | input#document-input { 49 | font-family: $baseFontFamily; 50 | background-color: $mist; 51 | border: 1px solid #d5d9db; 52 | letter-spacing: 0.06px; 53 | height: 48px; 54 | margin-bottom: 20px; 55 | color: $black; 56 | font-size: 18px; 57 | 58 | &::placeholder { 59 | color: #3c606f; 60 | } 61 | } 62 | 63 | input[type='submit'] { 64 | background-color: $button-normal; 65 | color: $white; 66 | font-size: 20px; 67 | font-family: $baseFontFamily; 68 | line-height: 24px; 69 | font-weight: bold; 70 | padding-left: 30px; 71 | padding-right: 30px; 72 | margin-bottom: 18px; 73 | border: none; 74 | cursor: pointer; 75 | &:focus { 76 | outline: auto; 77 | } 78 | } 79 | input:disabled { 80 | cursor: not-allowed; 81 | opacity: 0.9; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/stylesheets/_layout.scss: -------------------------------------------------------------------------------- 1 | @import 'mediaqueries'; 2 | 3 | @mixin layout-margin($factor, $screen-size) { 4 | @if $screen-size <= $screen-medium { 5 | @media screen and (min-width: $screen-size) { 6 | width: calc(0.9 * $factor * 100%); 7 | } 8 | } @else { 9 | @media screen and (min-width: $screen-size) { 10 | width: calc(0.8 * $factor * $screen-size); 11 | } 12 | } 13 | } 14 | 15 | @mixin eight-column-layout-margin { 16 | margin: auto; 17 | max-width: 80%; 18 | 19 | @include media-query-large { 20 | max-width: 54%; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/stylesheets/_loader.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | .loading-spinner { 3 | &-blocks { 4 | width: 100%; 5 | height: 100%; 6 | display: flex; 7 | align-items: center; 8 | flex-direction: column; 9 | margin: 50px 0; 10 | overflow: hidden; 11 | 12 | &.hide { 13 | display: none; 14 | } 15 | } 16 | } 17 | 18 | .loader-wrapper { 19 | max-width: 60px; 20 | height: 60px; 21 | display: flex; 22 | flex-wrap: wrap; 23 | justify-content: space-between; 24 | align-content: space-between; 25 | margin: 2rem auto; 26 | } 27 | .loader-wrapper div { 28 | box-sizing: content-box; 29 | } 30 | 31 | .loader-wrapper div { 32 | width: 30px; 33 | height: 30px; 34 | background: $mist; 35 | animation: loader 1s linear infinite; 36 | } 37 | 38 | @keyframes loader { 39 | 0% { 40 | background: $wave; 41 | } 42 | 50% { 43 | background: $mist; 44 | } 45 | 100% { 46 | background: $mist; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/stylesheets/_mediaqueries.scss: -------------------------------------------------------------------------------- 1 | $screen-small: 360px; 2 | $screen-medium: 768px; 3 | $screen-large: 1024px; 4 | $screen-xlarge: 1280px; 5 | $screen-xxlarge: 1440px; 6 | $screen-xxxlarge: 1800px; 7 | 8 | @mixin media-query-small { 9 | @media screen and (min-width: $screen-small) { 10 | // Mobile - 360px and above 11 | @content; 12 | } 13 | } 14 | 15 | @mixin media-query-medium { 16 | @media screen and (min-width: $screen-medium) { 17 | // iPad - 768px and above 18 | @content; 19 | } 20 | } 21 | 22 | @mixin media-query-large { 23 | @media screen and (min-width: $screen-large) { 24 | // Desktop - 1024px and above 25 | @content; 26 | } 27 | } 28 | 29 | @mixin media-query-xlarge { 30 | @media screen and (min-width: $screen-xlarge) { 31 | // Desktop - 1280px and above 32 | @content; 33 | } 34 | } 35 | 36 | @mixin media-query-xxlarge { 37 | @media screen and (min-width: $screen-xxlarge) { 38 | // Desktop - 1440px and above 39 | @content; 40 | } 41 | } 42 | 43 | @mixin media-query-xxxlarge { 44 | @media screen and (min-width: $screen-xxxlarge) { 45 | // Desktop - 1800px and above 46 | @content; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/stylesheets/_pdfPage.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | @media print { 4 | body { 5 | margin: 0; 6 | -webkit-print-color-adjust: exact !important; 7 | print-color-adjust: exact !important; 8 | } 9 | 10 | @page { 11 | margin: 20mm; 12 | } 13 | 14 | #pdf-cover-page { 15 | .pdf-banner-image { 16 | width: 100%; 17 | height: 50%; 18 | } 19 | 20 | .pdf-title { 21 | font-family: 'Bitter', serif; 22 | font-size: 4rem; 23 | margin: 24mm 12mm; 24 | font-weight: bold; 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | display: -webkit-box; 28 | display: -webkit-inline-box; 29 | line-height: 4.25rem; 30 | -webkit-line-clamp: 3; 31 | -webkit-box-orient: vertical; 32 | min-height: 200px; 33 | width: calc(100% - 24mm); 34 | } 35 | } 36 | .pdf-powered-by-text { 37 | float: right; 38 | margin-right: 12mm; 39 | display: flex; 40 | align-items: center; 41 | gap: 1.5rem; 42 | 43 | .pdf-tw-logo { 44 | height: 28px; 45 | } 46 | } 47 | 48 | footer.home-page { 49 | a { 50 | color: black; 51 | } 52 | 53 | p { 54 | margin: 0; 55 | font-style: italic; 56 | max-width: none; 57 | 58 | &.agree-terms { 59 | a:after { 60 | content: ' <' attr(href) '> '; 61 | font-weight: normal; 62 | color: black; 63 | } 64 | } 65 | } 66 | } 67 | 68 | .pdf-footer { 69 | page-break-before: always; 70 | 71 | .pdf-powered-by-text { 72 | flex-direction: column; 73 | align-items: flex-start; 74 | float: left; 75 | width: 100%; 76 | margin-bottom: 1.5rem; 77 | gap: 0; 78 | 79 | .pdf-tw-logo { 80 | margin: 2rem 0; 81 | } 82 | 83 | .pdf-footer-title { 84 | font-weight: bold; 85 | font-size: 1.5rem; 86 | } 87 | 88 | a { 89 | color: black; 90 | font-size: 1.25rem; 91 | 92 | &.radar-link { 93 | font-style: italic; 94 | margin-top: 0.5rem; 95 | } 96 | } 97 | } 98 | } 99 | 100 | #radar { 101 | display: block !important; 102 | height: max-content !important; 103 | 104 | &.no-blips .quadrant-table { 105 | display: block !important; 106 | } 107 | 108 | .no-blip-text { 109 | display: none !important; 110 | } 111 | 112 | a { 113 | color: black; 114 | } 115 | 116 | a:after { 117 | content: ' <' attr(href) '> '; 118 | font-weight: normal; 119 | color: black; 120 | } 121 | } 122 | 123 | .quadrant-table__container { 124 | width: 100%; 125 | display: block; 126 | 127 | .quadrant-table { 128 | float: none !important; 129 | page-break-before: always; 130 | max-height: initial !important; 131 | opacity: 1 !important; 132 | position: static !important; 133 | margin-bottom: 20px; 134 | 135 | &__name { 136 | font-size: 2rem; 137 | font-weight: bold; 138 | font-family: 'Bitter', serif; 139 | margin-bottom: 2.5rem; 140 | display: block !important; 141 | } 142 | 143 | &__ring-name { 144 | margin-bottom: 2rem; 145 | } 146 | 147 | &.first .quadrant-table__name { 148 | color: $sapphire; 149 | } 150 | 151 | &.second .quadrant-table__name { 152 | color: $turmeric; 153 | } 154 | 155 | &.third .quadrant-table__name { 156 | color: $jade; 157 | } 158 | 159 | &.fourth .quadrant-table__name { 160 | color: $flamingo; 161 | } 162 | 163 | .blip-list__item { 164 | pointer-events: none; 165 | } 166 | 167 | .blip-list__item-container { 168 | background-color: white !important; 169 | 170 | &__name { 171 | border-bottom: none; 172 | padding: 0 !important; 173 | 174 | &-value { 175 | font-weight: bold; 176 | font-size: 1.25rem; 177 | } 178 | 179 | &-arrow { 180 | display: none; 181 | } 182 | } 183 | 184 | &__description { 185 | display: block; 186 | } 187 | } 188 | 189 | ul.blip-list { 190 | list-style: none; 191 | margin: 0 0 3rem; 192 | padding: 0; 193 | 194 | .blip-list__item-container__description { 195 | padding-left: 0; 196 | line-height: 1.5 !important; 197 | } 198 | } 199 | 200 | & > ul.blip-list:last-child { 201 | margin-bottom: 0 !important; 202 | } 203 | } 204 | } 205 | 206 | .pdf-page-footer { 207 | display: none; 208 | position: fixed; 209 | bottom: 0; 210 | color: $mist-s30; 211 | justify-content: space-between; 212 | width: 98%; 213 | } 214 | 215 | .input-sheet__logo, 216 | .hero-banner, 217 | main > *:not(#pdf-cover-page, #radar), 218 | #radar > #radar-plot, 219 | .radar-legends, 220 | .ui-helper-hidden-accessible, 221 | #onetrust-consent-sdk { 222 | display: none !important; 223 | } 224 | } 225 | 226 | @media print and (orientation: landscape) { 227 | #pdf-cover-page { 228 | .pdf-banner-image { 229 | height: 310px; 230 | object-fit: cover; 231 | } 232 | 233 | .pdf-title { 234 | margin: 12mm 24mm 10mm; 235 | width: calc(100% - 48mm); 236 | } 237 | } 238 | } 239 | 240 | @media print and (orientation: portrait) { 241 | #pdf-cover-page { 242 | .pdf-banner-image { 243 | height: 50%; 244 | } 245 | } 246 | } 247 | 248 | @media screen { 249 | .pdf-footer, 250 | .pdf-page-footer, 251 | #pdf-cover-page { 252 | display: none !important; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/stylesheets/_quadrantTables.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | @if $UIRefresh2022 { 5 | .quadrant-table { 6 | @include layout-margin(calc(12 / 12), $screen-small); 7 | @include layout-margin(calc(12 / 12), $screen-medium); 8 | @include layout-margin(calc(12 / 12), $screen-large); 9 | @include layout-margin(calc(4 / 12), $screen-xlarge); 10 | @include layout-margin(calc(5 / 12), $screen-xxlarge); 11 | @include layout-margin(calc(5 / 12), $screen-xxxlarge); 12 | 13 | &__ring-name { 14 | text-transform: none; 15 | margin: 0; 16 | scroll-margin-top: $subnavHeight; 17 | font-family: $baseFontFamily; 18 | } 19 | 20 | @include media-query-xlarge { 21 | max-width: 40%; 22 | margin-top: 0 !important; 23 | } 24 | 25 | &__container { 26 | display: flex; 27 | justify-content: center; 28 | 29 | @include media-query-xlarge { 30 | display: block; 31 | justify-content: unset; 32 | } 33 | } 34 | } 35 | 36 | .blip-list { 37 | width: 100%; 38 | margin-bottom: 64px; 39 | 40 | &__item { 41 | width: 100%; 42 | 43 | &:hover, 44 | &.highlight { 45 | background-color: $mist; 46 | } 47 | 48 | &-container { 49 | &.expand { 50 | background-color: $mist; 51 | } 52 | 53 | &.expand &__name { 54 | &-arrow { 55 | rotate: -135deg; 56 | margin-top: 10px; 57 | } 58 | } 59 | 60 | &.expand &__description { 61 | display: block; 62 | } 63 | 64 | &__name { 65 | padding: 20px; 66 | width: 100%; 67 | border: none; 68 | background-color: transparent; 69 | border-bottom: 1px solid $mist-s20; 70 | text-align: unset; 71 | display: flex; 72 | justify-content: space-between; 73 | align-items: center; 74 | cursor: pointer; 75 | scroll-margin-top: $subnavHeight; 76 | 77 | & > * { 78 | pointer-events: none; 79 | } 80 | 81 | &-value { 82 | pointer-events: none; 83 | display: inline-block; 84 | width: 90%; 85 | font-size: 1rem; 86 | font-family: $baseFontFamily; 87 | 88 | @include media-query-large { 89 | font-size: 1.125rem; 90 | } 91 | } 92 | 93 | &-arrow { 94 | width: 8px; 95 | height: 8px; 96 | display: inline-flex; 97 | rotate: 45deg; 98 | border: 1px solid $flamingo; 99 | border-width: 0 2px 2px 0; 100 | -webkit-transition: all 0.2s ease; 101 | transition: all 0.2s ease; 102 | } 103 | } 104 | 105 | &__description { 106 | display: none; 107 | padding: 20px; 108 | font-size: 1rem; 109 | font-family: $baseFontFamily; 110 | line-height: 36px; 111 | 112 | @include media-query-large { 113 | font-size: 1.125rem; 114 | } 115 | 116 | & > * { 117 | font-family: inherit !important; 118 | font-size: inherit !important; 119 | line-height: inherit !important; 120 | margin: 0 !important; 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/stylesheets/_quadrants.scss: -------------------------------------------------------------------------------- 1 | #radar { 2 | width: 80%; 3 | margin: 0 auto; 4 | position: relative; 5 | 6 | @if $UIRefresh2022 { 7 | width: 100%; 8 | 9 | @include media-query-xlarge { 10 | width: 80%; 11 | min-width: calc($quadrantWidth * 2 + $quadrantsGap); 12 | } 13 | 14 | @include layout-margin(calc(12 / 12), $screen-xlarge); 15 | @include layout-margin(calc(12 / 12), $screen-xxlarge); 16 | @include layout-margin(calc(12 / 12), $screen-xxxlarge); 17 | 18 | .no-blip-text { 19 | display: none; 20 | text-align: center; 21 | font-size: 24px; 22 | font-weight: bold; 23 | } 24 | 25 | &.no-blips { 26 | height: auto !important; 27 | .no-blip-text { 28 | display: block; 29 | } 30 | 31 | .quadrant-group, 32 | .quadrant-table, 33 | .radar-legends { 34 | display: none !important; 35 | } 36 | } 37 | 38 | .mobile { 39 | display: block; 40 | } 41 | 42 | &:not(.mobile) { 43 | display: none; 44 | } 45 | 46 | @include media-query-xlarge { 47 | &:not(.mobile) { 48 | display: block; 49 | } 50 | } 51 | } 52 | 53 | svg#radar-plot { 54 | margin: 0 auto; 55 | transition: all 1s ease; 56 | position: absolute; 57 | left: 0; 58 | right: 0; 59 | 60 | @if $UIRefresh2022 { 61 | display: none; 62 | transition: none; 63 | 64 | margin: 0 auto; 65 | 66 | &.enable-transition { 67 | transition: all 1s ease; 68 | } 69 | 70 | @include media-query-medium { 71 | display: block; 72 | } 73 | } 74 | 75 | &.quadrant-view { 76 | .quadrant-group { 77 | pointer-events: none; 78 | } 79 | 80 | .quadrant-name-group { 81 | display: none; 82 | } 83 | 84 | @include media-query-xlarge { 85 | &.sticky { 86 | position: fixed; 87 | transition: none; 88 | top: $subnavHeight !important; 89 | } 90 | } 91 | } 92 | 93 | @if $UIRefresh2022 { 94 | pointer-events: none; 95 | z-index: 10; 96 | } 97 | 98 | .legend { 99 | visibility: hidden; 100 | transition: visibility 1s ease 1s; 101 | color: $black; 102 | } 103 | 104 | path { 105 | &.ring-arc-3 { 106 | stroke: none; 107 | fill: $grey-light; 108 | } 109 | 110 | &.ring-arc-2 { 111 | stroke: none; 112 | fill: $grey; 113 | } 114 | 115 | &.ring-arc-1 { 116 | stroke: none; 117 | fill: $grey-dark; 118 | } 119 | 120 | &.ring-arc-0 { 121 | stroke: none; 122 | fill: $grey-darkest; 123 | } 124 | } 125 | 126 | @if $UIRefresh2022 { 127 | path { 128 | &.ring-arc-3, 129 | &.ring-arc-2, 130 | &.ring-arc-1, 131 | &.ring-arc-0 { 132 | stroke: $mist-s30; 133 | stroke-width: 1; 134 | fill: white; 135 | } 136 | } 137 | 138 | .quadrant-group { 139 | transition: opacity 0.5s ease-out; 140 | pointer-events: all; 141 | } 142 | } 143 | 144 | .blip-link { 145 | text-decoration: none; 146 | cursor: pointer; 147 | pointer-events: initial; 148 | outline: none; 149 | } 150 | 151 | .quadrant-group { 152 | cursor: pointer; 153 | } 154 | 155 | circle, 156 | polygon, 157 | path { 158 | &.first { 159 | fill: $green; 160 | stroke: none; 161 | } 162 | 163 | &.second { 164 | fill: $blue; 165 | stroke: none; 166 | } 167 | 168 | &.third { 169 | fill: $orange; 170 | stroke: none; 171 | } 172 | 173 | &.fourth { 174 | fill: $violet; 175 | stroke: none; 176 | } 177 | } 178 | 179 | line { 180 | stroke: white; 181 | } 182 | 183 | text { 184 | &.blip-text { 185 | font-size: 9px; 186 | font-style: italic; 187 | fill: $white; 188 | } 189 | 190 | &.line-text { 191 | font-weight: bold; 192 | text-transform: uppercase; 193 | fill: $black; 194 | font-size: 7px; 195 | } 196 | } 197 | 198 | @if $UIRefresh2022 { 199 | circle, 200 | polygon, 201 | rect, 202 | path { 203 | &.first { 204 | fill: $sapphire-dark; 205 | stroke: none; 206 | } 207 | 208 | &.second { 209 | fill: $turmeric-dark; 210 | stroke: none; 211 | } 212 | 213 | &.third { 214 | fill: $jade-dark; 215 | stroke: none; 216 | } 217 | 218 | &.fourth { 219 | fill: $flamingo-dark; 220 | stroke: none; 221 | } 222 | } 223 | 224 | line { 225 | stroke: white; 226 | } 227 | 228 | text { 229 | &.blip-text { 230 | font-size: 9px; 231 | font-style: italic; 232 | fill: $black; 233 | } 234 | 235 | &.line-text { 236 | font-weight: bold; 237 | text-transform: none; 238 | fill: $black; 239 | font-size: 16px; 240 | } 241 | } 242 | } 243 | } 244 | 245 | div.quadrant-table { 246 | .quadrant-table__name { 247 | display: none; 248 | } 249 | 250 | max-height: 0; 251 | @if not $UIRefresh2022 { 252 | max-width: 0; 253 | } 254 | position: absolute; 255 | overflow: hidden; 256 | z-index: 11; 257 | transition: max-height 0.5s ease 1s; 258 | 259 | h3 { 260 | text-transform: uppercase; 261 | font-size: $baseFont; 262 | margin: 0; 263 | font-weight: bold; 264 | } 265 | 266 | @if $UIRefresh2022 { 267 | overflow: clip; 268 | &.first { 269 | float: left; 270 | } 271 | 272 | &.second { 273 | float: left; 274 | } 275 | 276 | &.third { 277 | float: right; 278 | } 279 | 280 | &.fourth { 281 | float: right; 282 | } 283 | } @else { 284 | &.first { 285 | &.selected { 286 | float: right; 287 | } 288 | } 289 | 290 | &.second { 291 | &.selected { 292 | float: left; 293 | } 294 | } 295 | 296 | &.third { 297 | &.selected { 298 | float: left; 299 | } 300 | } 301 | 302 | &.fourth { 303 | &.selected { 304 | float: right; 305 | } 306 | } 307 | } 308 | 309 | &.selected { 310 | position: relative; 311 | max-height: 10000px; 312 | 313 | @if not $UIRefresh2022 { 314 | max-width: 40%; 315 | } 316 | } 317 | 318 | @if $UIRefresh2022 { 319 | max-height: 0; 320 | opacity: 0; 321 | transition: opacity 0.3s ease-out; 322 | 323 | &.selected { 324 | opacity: 1; 325 | transition: opacity 1s ease; 326 | 327 | @include media-query-medium { 328 | transition: opacity 1s ease 1s; 329 | } 330 | } 331 | } 332 | 333 | ul { 334 | padding: 0; 335 | margin-left: 0; 336 | 337 | li { 338 | list-style-type: none; 339 | padding-left: 0; 340 | 341 | .blip-list-item { 342 | padding: 2px 5px; 343 | border-radius: 2px; 344 | cursor: pointer; 345 | font-size: $baseFont; 346 | font-weight: 400; 347 | 348 | &.highlight { 349 | color: white; 350 | background-color: rgba(0, 0, 0, 0.8); 351 | } 352 | } 353 | 354 | .blip-item-description { 355 | max-height: 0; 356 | overflow: hidden; 357 | width: 300px; 358 | 359 | p { 360 | margin: 0; 361 | border-top: 1px solid rgb(119, 119, 119); 362 | border-bottom: 1px solid rgb(119, 119, 119); 363 | padding: 20px; 364 | color: $grey-text; 365 | font-weight: 100; 366 | font-size: 14px; 367 | } 368 | 369 | transition: max-height 0.2s ease; 370 | 371 | &.expanded { 372 | transition: max-height 0.5s ease 0.2s; 373 | max-height: 1000px; 374 | } 375 | } 376 | } 377 | } 378 | } 379 | } 380 | 381 | @if ($UIRefresh2022) { 382 | .radar-legends { 383 | display: none; 384 | 385 | @include media-query-medium { 386 | &.right-view, 387 | &.left-view { 388 | justify-content: unset; 389 | width: unset; 390 | } 391 | 392 | &.sticky { 393 | position: fixed; 394 | } 395 | 396 | display: flex; 397 | align-items: center; 398 | justify-content: center; 399 | margin: $quadrantsGap auto; 400 | width: 100%; 401 | position: absolute; 402 | top: calc($quadrantWidth * 2 + $quadrantsGap); 403 | } 404 | 405 | img:nth-child(n + 2) { 406 | margin-left: 24px; 407 | } 408 | } 409 | 410 | .all-quadrants-mobile { 411 | --quadrant-gap: 12px; 412 | --quadrant-btn-width-mobile: 150px; 413 | --quadrant-btn-height-mobile: 70px; 414 | display: none; 415 | flex-direction: column; 416 | flex-wrap: wrap; 417 | justify-content: space-between; 418 | align-content: space-between; 419 | margin: auto; 420 | margin-bottom: 42px; 421 | 422 | &.show-all-quadrants-mobile { 423 | display: flex; 424 | } 425 | @include media-query-medium { 426 | --quadrant-btn-width-mobile: 345px; 427 | --quadrant-btn-height-mobile: 160px; 428 | } 429 | @include media-query-xlarge { 430 | display: none; 431 | &.show-all-quadrants-mobile { 432 | display: none; 433 | } 434 | } 435 | 436 | width: calc(var(--quadrant-btn-width-mobile) * 2 + var(--quadrant-gap)); 437 | height: calc(var(--quadrant-btn-height-mobile) * 2 + var(--quadrant-gap)); 438 | 439 | .all-quadrants-mobile--btn { 440 | display: flex; 441 | justify-content: center; 442 | align-items: center; 443 | text-align: center; 444 | margin: 3px; 445 | width: var(--quadrant-btn-width-mobile); 446 | height: var(--quadrant-btn-height-mobile); 447 | background-size: 100%; 448 | background-repeat: no-repeat; 449 | font-size: 16px; 450 | font-weight: bold; 451 | color: white; 452 | border: none; 453 | 454 | @include media-query-medium { 455 | font-size: 24px; 456 | } 457 | 458 | &::after { 459 | content: url('/images/arrow-white-icon.svg'); 460 | margin: 4px 4px 0; 461 | } 462 | 463 | .btn-text-wrapper { 464 | text-align: left; 465 | word-break: break-word; 466 | overflow: hidden; 467 | text-overflow: ellipsis; 468 | display: -webkit-box; 469 | -webkit-line-clamp: 3; 470 | -webkit-box-orient: vertical; 471 | 472 | @include media-query-medium { 473 | max-width: 60%; 474 | max-height: 60px; 475 | } 476 | } 477 | } 478 | 479 | #first-quadrant-mobile { 480 | background-color: $sapphire; 481 | } 482 | #second-quadrant-mobile { 483 | background-color: $turmeric; 484 | } 485 | #third-quadrant-mobile { 486 | background-color: $jade; 487 | } 488 | #fourth-quadrant-mobile { 489 | background-color: $flamingo; 490 | } 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /src/stylesheets/_quadrantsubnav.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | @if $UIRefresh2022 { 5 | .quadrant-subnav { 6 | font-size: 1.125rem; 7 | width: 100%; 8 | background-color: $mist; 9 | display: flex; 10 | flex-direction: column; 11 | height: fit-content; 12 | min-height: $subnavHeight; 13 | 14 | @include media-query-xlarge { 15 | flex-direction: row; 16 | justify-content: center; 17 | height: $subnavHeight; 18 | font-size: 1.25rem; 19 | } 20 | 21 | &.sticky { 22 | position: fixed; 23 | top: 0; 24 | width: 100%; 25 | z-index: 999; 26 | } 27 | 28 | &__dropdown { 29 | height: fit-content; 30 | min-height: $subnavHeight; 31 | font-weight: 630; 32 | display: inline-flex; 33 | border-bottom: 1px solid $mist-s20; 34 | align-items: center; 35 | flex-direction: row; 36 | justify-content: center; 37 | gap: 8px; 38 | cursor: pointer; 39 | 40 | &-arrow { 41 | width: 8px; 42 | height: 8px; 43 | display: inline-flex; 44 | rotate: 45deg; 45 | border: 1px solid $flamingo; 46 | border-width: 0 2px 2px 0; 47 | margin-top: 0; 48 | margin-bottom: 4px; 49 | -webkit-transition: all 0.2s ease; 50 | transition: all 0.2s ease; 51 | 52 | &.rotate { 53 | rotate: -135deg; 54 | margin-bottom: 0; 55 | margin-top: 4px; 56 | } 57 | } 58 | 59 | @include media-query-xlarge { 60 | display: none; 61 | } 62 | } 63 | 64 | &__list { 65 | display: none; 66 | width: 100%; 67 | 68 | &.show { 69 | display: flex; 70 | flex-direction: column; 71 | width: 100%; 72 | list-style-type: none; 73 | margin: 0; 74 | padding: 0; 75 | } 76 | 77 | &-item { 78 | width: 100%; 79 | min-height: $subnavHeight; 80 | display: inline-flex; 81 | align-items: center; 82 | border-bottom: 1px solid $mist-s20; 83 | justify-content: center; 84 | padding: 0; 85 | height: 100%; 86 | box-sizing: border-box; 87 | font-size: 16px; 88 | 89 | @include media-query-xlarge { 90 | max-width: 20% !important; 91 | &.active-item { 92 | padding-top: 4px; 93 | border-bottom: 4px solid transparent; 94 | pointer-events: none; 95 | font-weight: bold; 96 | transition: font-weight 0.3s ease-in-out; 97 | 98 | &:nth-child(1) { 99 | border-color: $mist-s30; 100 | } 101 | 102 | &:nth-child(2) { 103 | border-color: $sapphire; 104 | } 105 | 106 | &:nth-child(3) { 107 | border-color: $turmeric; 108 | } 109 | 110 | &:nth-child(4) { 111 | border-color: $jade; 112 | } 113 | 114 | &:nth-child(5) { 115 | border-color: $flamingo; 116 | } 117 | } 118 | } 119 | 120 | &__button { 121 | text-decoration: none; 122 | border: none; 123 | font: inherit; 124 | cursor: pointer; 125 | background-color: $mist; 126 | 127 | @include media-query-xlarge { 128 | padding: 15px 40px; 129 | margin: 0 1px; 130 | white-space: nowrap; 131 | overflow: hidden; 132 | text-overflow: ellipsis; 133 | } 134 | } 135 | } 136 | 137 | @include media-query-xlarge { 138 | height: 100%; 139 | display: flex; 140 | flex-direction: row; 141 | justify-content: center; 142 | align-items: center; 143 | margin: 0; 144 | padding: 0; 145 | 146 | &.show { 147 | flex-direction: row; 148 | } 149 | 150 | &-item { 151 | width: unset; 152 | min-height: unset; 153 | border: none; 154 | 155 | &:not(.active-item):hover { 156 | color: $flamingo-s40; 157 | text-decoration: underline; 158 | text-underline-offset: 6px; 159 | 160 | & > * { 161 | color: $flamingo-s40; 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/stylesheets/_search.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'layout'; 3 | 4 | @if $UIRefresh2022 { 5 | .graph-header { 6 | display: flex; 7 | flex-direction: column; 8 | height: auto; 9 | 10 | @include media-query-large { 11 | align-items: center; 12 | } 13 | } 14 | 15 | .search-container { 16 | height: auto; 17 | margin: 48px auto 40px; 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | 22 | &.sticky-offset { 23 | margin-top: 108px; 24 | } 25 | 26 | @include layout-margin(1, $screen-small); 27 | @include layout-margin(calc(10 / 12), $screen-large); 28 | @include layout-margin(calc(10 / 12), $screen-xlarge); 29 | @include layout-margin(calc(10 / 12), $screen-xxlarge); 30 | @include layout-margin(calc(8 / 12), $screen-xxxlarge); 31 | 32 | @include media-query-medium { 33 | height: 48px; 34 | margin: 64px auto 48px; 35 | 36 | &.sticky-offset { 37 | margin-top: 124px; 38 | } 39 | } 40 | 41 | @include media-query-large { 42 | margin: 72px auto 48px; 43 | 44 | &.sticky-offset { 45 | margin-top: 132px; 46 | } 47 | } 48 | 49 | @include media-query-xlarge { 50 | margin: 32px auto; 51 | 52 | &.sticky-offset { 53 | margin-top: 92px; 54 | } 55 | } 56 | 57 | @include media-query-xxlarge { 58 | margin: 40px auto; 59 | 60 | &.sticky-offset { 61 | margin-top: 100px; 62 | } 63 | } 64 | 65 | &__input { 66 | color: $wave; 67 | height: 48px; 68 | margin-bottom: 30px; 69 | background: #edf1f3 url(images/search-active-wave.svg) no-repeat 98% center; 70 | font-family: $baseFontFamily; 71 | scroll-margin-top: $subnavHeight; 72 | 73 | @include media-query-medium { 74 | height: 100%; 75 | margin-bottom: 0; 76 | flex-grow: 1; 77 | } 78 | } 79 | } 80 | 81 | ul.ui-autocomplete { 82 | max-height: 196px !important; 83 | z-index: 999; 84 | 85 | li div { 86 | height: 48px; 87 | display: flex; 88 | align-items: center; 89 | padding-left: 16px; 90 | border-bottom: 1px solid #edf1f3; 91 | font-family: $baseFontFamily; 92 | font-size: 16px; 93 | color: $black; 94 | 95 | &.ui-state-active { 96 | background-color: $mist !important; 97 | color: $black !important; 98 | 99 | &:active { 100 | background-color: $mist-light !important; 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | .ui-autocomplete { 108 | z-index: 30 !important; 109 | } 110 | -------------------------------------------------------------------------------- /src/stylesheets/_tip.scss: -------------------------------------------------------------------------------- 1 | .d3-tip { 2 | white-space: nowrap; 3 | line-height: 1; 4 | font-size: 12px; 5 | display: none; 6 | padding: 12px; 7 | background: rgba(0, 0, 0, 0.8); 8 | color: #fff; 9 | border-radius: 4px; 10 | border-color: #000; 11 | pointer-events: none; 12 | z-index: 20; 13 | } 14 | 15 | @media screen and (min-width: 800px) { 16 | .d3-tip { 17 | display: block; 18 | } 19 | } 20 | 21 | .d3-tip:after { 22 | box-sizing: border-box; 23 | display: none; 24 | font-size: 10px; 25 | width: 100%; 26 | line-height: 1; 27 | color: rgba(0, 0, 0, 0.8); 28 | position: absolute; 29 | pointer-events: none; 30 | } 31 | 32 | @media screen and (min-width: 800px) { 33 | .d3-tip:after { 34 | display: inline; 35 | } 36 | } 37 | 38 | .d3-tip.n { 39 | margin: -10px 0 0; 40 | } 41 | .d3-tip.n:after { 42 | content: '\25BC'; 43 | margin: -3px 0 0; 44 | top: 100%; 45 | left: 0; 46 | text-align: center; 47 | } 48 | 49 | .d3-tip.ne { 50 | margin: -10px 0 0 -45px; 51 | } 52 | .d3-tip.ne:after { 53 | content: '\25BC'; 54 | bottom: -8px; 55 | left: 18px; 56 | text-align: left; 57 | } 58 | 59 | .d3-tip.nw { 60 | margin: -10px 0 0 45px; 61 | } 62 | .d3-tip.nw:after { 63 | content: '\25BC'; 64 | bottom: -8px; 65 | right: 18px; 66 | text-align: right; 67 | } 68 | -------------------------------------------------------------------------------- /src/stylesheets/base.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'fonts'; 3 | @import 'tip'; 4 | @import 'form'; 5 | @import 'error'; 6 | @import 'header'; 7 | @import 'footer'; 8 | @import 'herobanner'; 9 | @import 'quadrantsubnav'; 10 | @import 'search'; 11 | @import 'alternativeradars'; 12 | @import 'buttons'; 13 | @import 'quadrants'; 14 | @import 'quadrantTables'; 15 | @import 'mediaqueries'; 16 | @import 'layout'; 17 | @import 'landingpage'; 18 | @import 'loader'; 19 | @import 'screen'; 20 | @import 'pdfPage'; 21 | 22 | :root { 23 | font-family: 'Inter', sans-serif; 24 | } 25 | 26 | @supports (font-variation-settings: normal) { 27 | :root { 28 | font-family: 'Inter var', sans-serif; 29 | } 30 | } 31 | 32 | @media screen { 33 | body { 34 | font: 18px 'Open Sans'; 35 | opacity: 0; 36 | @if $UIRefresh2022 { 37 | font-family: $baseFontFamily; 38 | opacity: 1; 39 | 40 | h1 { 41 | font-size: 1.5rem; 42 | font-family: 'Bitter', serif; 43 | text-transform: none; 44 | letter-spacing: normal; 45 | 46 | @include media-query-medium { 47 | font-size: 2rem; 48 | } 49 | 50 | @include media-query-large { 51 | font-size: 2.5rem; 52 | } 53 | 54 | @include media-query-xxlarge { 55 | font-size: 3rem; 56 | } 57 | 58 | @include media-query-xxxlarge { 59 | font-size: 3.5rem; 60 | } 61 | } 62 | 63 | h2 { 64 | font-size: 1.25rem; 65 | font-weight: 630; 66 | font-family: 'Inter', serif; 67 | text-transform: none; 68 | letter-spacing: normal; 69 | 70 | @include media-query-medium { 71 | font-size: 1.25rem; 72 | } 73 | 74 | @include media-query-large { 75 | font-size: 1.5rem; 76 | } 77 | 78 | @include media-query-xlarge { 79 | font-size: 1.5rem; 80 | } 81 | 82 | @include media-query-xxlarge { 83 | font-size: 2rem; 84 | } 85 | 86 | @include media-query-xxxlarge { 87 | font-size: 2rem; 88 | } 89 | } 90 | 91 | p { 92 | font-size: 1rem; 93 | font-family: $baseFontFamily; 94 | 95 | @include media-query-xxlarge { 96 | font-size: 1.125rem; 97 | } 98 | } 99 | 100 | a { 101 | color: $link-normal; 102 | border-color: $link-normal; 103 | 104 | &:hover { 105 | color: $link-hover; 106 | border-color: $link-hover; 107 | } 108 | } 109 | } 110 | 111 | -webkit-font-smoothing: antialiased; 112 | margin: 0; 113 | } 114 | 115 | @if $UIRefresh2022 { 116 | .d3-tip { 117 | font-size: 12px; 118 | display: block; 119 | padding: 12px; 120 | background: $wave; 121 | color: $white; 122 | pointer-events: none; 123 | z-index: 20; 124 | max-width: 170px; 125 | word-wrap: break-word; 126 | white-space: pre-line; 127 | line-height: 2; 128 | border-radius: unset; 129 | 130 | @include media-query-medium { 131 | max-width: 250px; 132 | } 133 | } 134 | 135 | .d3-tip:after { 136 | box-sizing: border-box; 137 | font-size: 10px; 138 | width: 100%; 139 | color: $wave; 140 | position: absolute; 141 | pointer-events: none; 142 | } 143 | 144 | .d3-tip.n { 145 | margin: -10px 0 0; 146 | } 147 | 148 | .d3-tip.n:after { 149 | content: '\25BC'; 150 | margin: -3px 0 0; 151 | top: 100%; 152 | left: 0; 153 | text-align: center; 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/util/autoComplete.js: -------------------------------------------------------------------------------- 1 | const $ = require('jquery') 2 | require('jquery-ui/ui/widgets/autocomplete') 3 | 4 | const config = require('../config') 5 | const featureToggles = config().featureToggles 6 | 7 | $.widget('custom.radarcomplete', $.ui.autocomplete, { 8 | _create: function () { 9 | this._super() 10 | this.widget().menu('option', 'items', '> :not(.ui-autocomplete-quadrant)') 11 | }, 12 | _renderMenu: function (ul, items) { 13 | let currentQuadrant = '' 14 | 15 | items.forEach((item) => { 16 | const quadrantName = item.quadrant.quadrant.name() 17 | if (quadrantName !== currentQuadrant) { 18 | ul.append(`
  • ${quadrantName}
  • `) 19 | currentQuadrant = quadrantName 20 | } 21 | const li = this._renderItemData(ul, item) 22 | if (quadrantName) { 23 | li.attr('aria-label', `${quadrantName}:${item.value}`) 24 | } 25 | }) 26 | }, 27 | }) 28 | 29 | const AutoComplete = (el, quadrants, cb) => { 30 | const blips = quadrants.reduce((acc, quadrant) => { 31 | return [...acc, ...quadrant.quadrant.blips().map((blip) => ({ blip, quadrant }))] 32 | }, []) 33 | 34 | if (featureToggles.UIRefresh2022) { 35 | $(el).autocomplete({ 36 | appendTo: '.search-container', 37 | source: (request, response) => { 38 | const matches = blips.filter(({ blip }) => { 39 | const searchable = `${blip.name()} ${blip.description()}`.toLowerCase() 40 | return request.term.split(' ').every((term) => searchable.includes(term.toLowerCase())) 41 | }) 42 | response(matches.map((item) => ({ ...item, value: item.blip.name() }))) 43 | }, 44 | select: cb.bind({}), 45 | }) 46 | } else { 47 | $(el).radarcomplete({ 48 | source: (request, response) => { 49 | const matches = blips.filter(({ blip }) => { 50 | const searchable = `${blip.name()} ${blip.description()}`.toLowerCase() 51 | return request.term.split(' ').every((term) => searchable.includes(term.toLowerCase())) 52 | }) 53 | response(matches.map((item) => ({ ...item, value: item.blip.name() }))) 54 | }, 55 | select: cb.bind({}), 56 | }) 57 | } 58 | } 59 | 60 | module.exports = AutoComplete 61 | -------------------------------------------------------------------------------- /src/util/contentValidator.js: -------------------------------------------------------------------------------- 1 | const _ = { 2 | map: require('lodash/map'), 3 | uniqBy: require('lodash/uniqBy'), 4 | capitalize: require('lodash/capitalize'), 5 | each: require('lodash/each'), 6 | } 7 | 8 | const MalformedDataError = require('../../src/exceptions/malformedDataError') 9 | const ExceptionMessages = require('./exceptionMessages') 10 | 11 | const ContentValidator = function (columnNames) { 12 | var self = {} 13 | columnNames = columnNames.map(function (columnName) { 14 | return columnName.trim() 15 | }) 16 | 17 | self.verifyContent = function () { 18 | if (columnNames.length === 0) { 19 | throw new MalformedDataError(ExceptionMessages.MISSING_CONTENT) 20 | } 21 | } 22 | 23 | self.verifyHeaders = function () { 24 | _.each(['name', 'ring', 'quadrant', 'description'], function (field) { 25 | if (columnNames.indexOf(field) === -1) { 26 | throw new MalformedDataError(ExceptionMessages.MISSING_HEADERS) 27 | } 28 | }) 29 | 30 | // At least one of isNew or status must be present 31 | if (columnNames.indexOf('isNew') === -1 && columnNames.indexOf('status') === -1) { 32 | throw new MalformedDataError(ExceptionMessages.MISSING_HEADERS) 33 | } 34 | } 35 | 36 | return self 37 | } 38 | 39 | module.exports = ContentValidator 40 | -------------------------------------------------------------------------------- /src/util/exceptionMessages.js: -------------------------------------------------------------------------------- 1 | const ExceptionMessages = { 2 | TOO_MANY_QUADRANTS: 'There are more than 4 quadrant names listed in your data. Check the quadrant column for errors.', 3 | TOO_MANY_RINGS: 'More than 4 rings.', 4 | MISSING_HEADERS: 5 | 'Document is missing one or more required headers or they are misspelled. ' + 6 | 'Check that your document contains headers for "name", "ring", "quadrant", "isNew", "description".', 7 | MISSING_CONTENT: 'Document is missing content.', 8 | LESS_THAN_FOUR_QUADRANTS: 9 | 'There are less than 4 quadrant names listed in your data. Check the quadrant column for errors.', 10 | SHEET_NOT_FOUND: 'Oops! We can’t find the Google Sheet you’ve entered. Can you check the URL?', 11 | SHEET_NOT_FOUND_NEW: 'Oops! We can’t find the Google Sheet you’ve entered, please check the URL of your sheet.', 12 | UNAUTHORIZED: 'UNAUTHORIZED', 13 | INVALID_CONFIG: 'Unexpected number of quadrants or rings. Please check in the configuration.', 14 | INVALID_JSON_CONTENT: 'Invalid content of JSON file. Please check the content of file.', 15 | INVALID_CSV_CONTENT: 'Invalid content of CSV file. Please check the content of file.', 16 | } 17 | 18 | module.exports = ExceptionMessages 19 | -------------------------------------------------------------------------------- /src/util/googleAuth.js: -------------------------------------------------------------------------------- 1 | /* global gapi */ 2 | const d3 = require('d3') 3 | 4 | // Client ID and API key from the Developer Console 5 | var CLIENT_ID = process.env.CLIENT_ID 6 | var API_KEY = process.env.API_KEY 7 | 8 | // Array of API discovery doc URLs for APIs used by the quickstart 9 | var DISCOVERY_DOCS = ['https://sheets.googleapis.com/$discovery/rest?version=v4'] 10 | 11 | // Authorization scopes required by the API multiple scopes can be 12 | // included, separated by spaces. 13 | var SCOPES = 'https://www.googleapis.com/auth/spreadsheets.readonly' 14 | 15 | const GoogleAuth = function () { 16 | const self = {} 17 | self.forceLogin = false 18 | self.isAuthorizedCallbacks = [] 19 | self.isLoggedIn = undefined 20 | self.userEmail = '' 21 | let tokenClient 22 | self.gapiInitiated = false 23 | self.gsiInitiated = false 24 | 25 | self.loadAuthAPI = function () { 26 | !self.gapiInitiated && 27 | self.content.append('script').attr('src', 'https://apis.google.com/js/api.js').on('load', self.handleClientLoad) 28 | } 29 | 30 | self.loadGSI = function () { 31 | !self.gsiInitiated && 32 | self.content 33 | .append('script') 34 | .attr('src', 'https://accounts.google.com/gsi/client') 35 | .on('load', function () { 36 | self.gsiLogin() 37 | }) 38 | } 39 | 40 | self.loadGoogle = function (forceLogin = false, callback) { 41 | self.loadedCallback = callback 42 | self.forceLogin = forceLogin 43 | self.content = d3.select('body') 44 | 45 | if (!self.forceLogin) { 46 | self.loadAuthAPI() 47 | } else { 48 | self.gsiLogin(forceLogin) 49 | } 50 | } 51 | 52 | function parseJwt(token) { 53 | var base64Url = token.split('.')[1] 54 | var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') 55 | var jsonPayload = decodeURIComponent( 56 | window 57 | .atob(base64) 58 | .split('') 59 | .map(function (c) { 60 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) 61 | }) 62 | .join(''), 63 | ) 64 | 65 | return JSON.parse(jsonPayload) 66 | } 67 | 68 | self.gsiCallback = async function (credentialResponse) { 69 | let jwToken 70 | if (credentialResponse) { 71 | jwToken = parseJwt(credentialResponse.credential) 72 | } 73 | 74 | tokenClient = await window.google.accounts.oauth2.initTokenClient({ 75 | client_id: CLIENT_ID, 76 | scope: SCOPES, 77 | callback: '', 78 | prompt: self.forceLogin ? 'select_account' : '', 79 | hint: self.forceLogin ? '' : jwToken?.email, 80 | }) 81 | 82 | self.gsiInitiated = true 83 | self.prompt() 84 | } 85 | 86 | self.gsiLogin = async function (forceLogin = false) { 87 | self.forceLogin = forceLogin 88 | window.google.accounts.id.initialize({ 89 | client_id: CLIENT_ID, 90 | callback: self.gsiCallback, 91 | auto_select: self.forceLogin ? false : true, 92 | cancel_on_tap_outside: false, 93 | }) 94 | if (!self.forceLogin) { 95 | window.google.accounts.id.prompt((notification) => { 96 | if (notification.isSkippedMoment()) { 97 | plotAuthenticationErrorMessage() 98 | } 99 | }) 100 | } else { 101 | await self.gsiCallback() 102 | } 103 | } 104 | function plotAuthenticationErrorMessage() { 105 | let homePageURL = window.location.protocol + '//' + window.location.hostname 106 | homePageURL += window.location.port === '' ? '' : ':' + window.location.port 107 | const message = `Oops! Authentication incomplete. Please try again.` 108 | const goBack = 'Go back' 109 | document.cookie = 'g_state=; path=/; max-age=0' 110 | d3.selectAll('.loader-text').remove() 111 | let content = d3.select('body').select('.error-container').append('div').attr('class', 'input-sheet') 112 | const errorContainer = content.append('div').attr('class', 'error-container__message') 113 | errorContainer.append('div').append('p').attr('class', 'error-title').html(message) 114 | errorContainer 115 | .append('div') 116 | .append('p') 117 | .attr('class', 'error-subtitle') 118 | .html(` ${goBack} and please login.`) 119 | } 120 | 121 | self.handleClientLoad = function () { 122 | gapi.load('client', self.initClient) 123 | } 124 | 125 | self.isAuthorized = function (callback) { 126 | self.isAuthorizedCallbacks.push(callback) 127 | if (self.isLoggedIn !== undefined) { 128 | callback(self.isLoggedIn) 129 | } 130 | } 131 | 132 | self.prompt = async function () { 133 | if (self.gsiInitiated && self.gapiInitiated) { 134 | const token = gapi.client.getToken() 135 | if (token && token.access_token && !self.forceLogin) { 136 | const options = { method: 'GET', headers: { authorization: `Bearer ${token.access_token}` } } 137 | const response = await fetch('https://www.googleapis.com/oauth2/v1/userinfo', options) 138 | const profile = await response.json() 139 | self.userEmail = profile.email 140 | self.loadedCallback() 141 | } else { 142 | tokenClient.callback = () => { 143 | self.forceLogin = false 144 | self.prompt() 145 | } 146 | tokenClient.requestAccessToken() 147 | } 148 | } 149 | } 150 | 151 | self.initClient = async function () { 152 | gapi.client 153 | .init({ 154 | apiKey: API_KEY, 155 | discoveryDocs: DISCOVERY_DOCS, 156 | scope: SCOPES, 157 | }) 158 | .then(() => { 159 | self.gapiInitiated = true 160 | self.loadedCallback() 161 | }) 162 | } 163 | 164 | self.getEmail = () => { 165 | return self.userEmail 166 | } 167 | 168 | return self 169 | } 170 | 171 | module.exports = new GoogleAuth() 172 | -------------------------------------------------------------------------------- /src/util/htmlUtil.js: -------------------------------------------------------------------------------- 1 | function getElementWidth(element) { 2 | return element.node().getBoundingClientRect().width 3 | } 4 | 5 | function decodeHTML(encodedText) { 6 | const parser = new DOMParser() 7 | return parser.parseFromString(encodedText, 'text/html').body.textContent 8 | } 9 | 10 | function getElementHeight(element) { 11 | return element.node().getBoundingClientRect().height 12 | } 13 | 14 | module.exports = { 15 | getElementWidth, 16 | getElementHeight, 17 | decodeHTML, 18 | } 19 | -------------------------------------------------------------------------------- /src/util/inputSanitizer.js: -------------------------------------------------------------------------------- 1 | const sanitizeHtml = require('sanitize-html') 2 | const _ = { 3 | forOwn: require('lodash/forOwn'), 4 | } 5 | 6 | const InputSanitizer = function () { 7 | var relaxedOptions = { 8 | allowedTags: ['b', 'i', 'em', 'strong', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'br', 'p', 'u'], 9 | allowedAttributes: { 10 | a: ['href', 'target', 'rel'], 11 | }, 12 | } 13 | 14 | var restrictedOptions = { 15 | allowedTags: [], 16 | allowedAttributes: {}, 17 | textFilter: function (text) { 18 | return text.replace(/&/, '&') 19 | }, 20 | } 21 | 22 | function trimWhiteSpaces(blip) { 23 | var processedBlip = {} 24 | _.forOwn(blip, function (value, key) { 25 | processedBlip[key.trim()] = value.trim() 26 | }) 27 | return processedBlip 28 | } 29 | 30 | var self = {} 31 | self.sanitize = function (rawBlip) { 32 | var blip = trimWhiteSpaces(rawBlip) 33 | blip.description = sanitizeHtml(blip.description, relaxedOptions) 34 | blip.name = sanitizeHtml(blip.name, restrictedOptions) 35 | blip.isNew = sanitizeHtml(blip.isNew, restrictedOptions) 36 | blip.status = sanitizeHtml(blip.status, restrictedOptions) 37 | blip.ring = sanitizeHtml(blip.ring, restrictedOptions) 38 | blip.quadrant = sanitizeHtml(blip.quadrant, restrictedOptions) 39 | 40 | return blip 41 | } 42 | 43 | self.sanitizeForProtectedSheet = function (rawBlip, header) { 44 | var blip = trimWhiteSpaces(rawBlip) 45 | 46 | const descriptionIndex = header.indexOf('description') 47 | const nameIndex = header.indexOf('name') 48 | const isNewIndex = header.indexOf('isNew') 49 | const statusIndex = header.indexOf('status') 50 | const quadrantIndex = header.indexOf('quadrant') 51 | const ringIndex = header.indexOf('ring') 52 | 53 | const description = descriptionIndex === -1 ? '' : blip[descriptionIndex] 54 | const name = nameIndex === -1 ? '' : blip[nameIndex] 55 | const isNew = isNewIndex === -1 ? '' : blip[isNewIndex] 56 | const status = statusIndex === -1 ? '' : blip[statusIndex] 57 | const ring = ringIndex === -1 ? '' : blip[ringIndex] 58 | const quadrant = quadrantIndex === -1 ? '' : blip[quadrantIndex] 59 | 60 | blip.description = sanitizeHtml(description, relaxedOptions) 61 | blip.name = sanitizeHtml(name, restrictedOptions) 62 | blip.isNew = sanitizeHtml(isNew, restrictedOptions) 63 | blip.status = sanitizeHtml(status, restrictedOptions) 64 | blip.ring = sanitizeHtml(ring, restrictedOptions) 65 | blip.quadrant = sanitizeHtml(quadrant, restrictedOptions) 66 | 67 | return blip 68 | } 69 | 70 | return self 71 | } 72 | 73 | module.exports = InputSanitizer 74 | -------------------------------------------------------------------------------- /src/util/mathUtils.js: -------------------------------------------------------------------------------- 1 | function toRadian(angleInDegrees) { 2 | return (Math.PI * angleInDegrees) / 180 3 | } 4 | 5 | module.exports = { 6 | toRadian, 7 | } 8 | -------------------------------------------------------------------------------- /src/util/queryParamProcessor.js: -------------------------------------------------------------------------------- 1 | const QueryParams = function (queryString) { 2 | var decode = function (s) { 3 | return decodeURIComponent(s.replace(/\+/g, ' ')) 4 | } 5 | 6 | var search = /([^&=]+)=?([^&]*)/g 7 | 8 | var queryParams = {} 9 | var match 10 | while ((match = search.exec(queryString))) { 11 | queryParams[decode(match[1])] = decode(match[2]) 12 | } 13 | 14 | return queryParams 15 | } 16 | 17 | module.exports = QueryParams 18 | -------------------------------------------------------------------------------- /src/util/ringCalculator.js: -------------------------------------------------------------------------------- 1 | const RingCalculator = function (numberOfRings, maxRadius) { 2 | var sequence = [0, 6, 5, 3, 2, 1, 1, 1] 3 | 4 | var self = {} 5 | 6 | self.sum = function (length) { 7 | return sequence.slice(0, length + 1).reduce(function (previous, current) { 8 | return previous + current 9 | }, 0) 10 | } 11 | 12 | self.getRadius = function (ring) { 13 | var total = self.sum(numberOfRings) 14 | var sum = self.sum(ring) 15 | 16 | return (maxRadius * sum) / total 17 | } 18 | 19 | self.getRingRadius = function (ringIndex) { 20 | const ratios = [0, 0.316, 0.652, 0.832, 1] 21 | const radius = ratios[ringIndex] * maxRadius 22 | return radius || 0 23 | } 24 | 25 | return self 26 | } 27 | 28 | module.exports = RingCalculator 29 | -------------------------------------------------------------------------------- /src/util/sheet.js: -------------------------------------------------------------------------------- 1 | /* global gapi */ 2 | const SheetNotFoundError = require('../../src/exceptions/sheetNotFoundError') 3 | const UnauthorizedError = require('../../src/exceptions/unauthorizedError') 4 | const ExceptionMessages = require('./exceptionMessages') 5 | const config = require('../config') 6 | 7 | const Sheet = function (sheetReference) { 8 | var self = {} 9 | const featureToggles = config().featureToggles 10 | 11 | ;(function () { 12 | var matches = sheetReference.match('https:\\/\\/docs.google.com\\/spreadsheets\\/d\\/(.*?)($|\\/$|\\/.*|\\?.*)') 13 | self.id = matches !== null ? matches[1] : sheetReference 14 | })() 15 | 16 | self.validate = function (callback) { 17 | var apiKeyEnabled = process.env.API_KEY || false 18 | var feedURL = 'https://docs.google.com/spreadsheets/d/' + self.id 19 | 20 | // TODO: Move this out (as HTTPClient) 21 | var xhr = new XMLHttpRequest() 22 | xhr.open('GET', feedURL, true) 23 | xhr.onreadystatechange = function () { 24 | if (xhr.readyState === 4) { 25 | if (xhr.status === 200) { 26 | return callback(null, apiKeyEnabled) 27 | } else if (xhr.status === 404) { 28 | return callback(self.createSheetNotFoundError(), apiKeyEnabled) 29 | } else { 30 | return callback(new UnauthorizedError(ExceptionMessages.UNAUTHORIZED), apiKeyEnabled) 31 | } 32 | } 33 | } 34 | xhr.send(null) 35 | } 36 | 37 | self.createSheetNotFoundError = function () { 38 | const exceptionMessage = featureToggles.UIRefresh2022 39 | ? ExceptionMessages.SHEET_NOT_FOUND_NEW 40 | : ExceptionMessages.SHEET_NOT_FOUND 41 | return new SheetNotFoundError(exceptionMessage) 42 | } 43 | 44 | self.getSheet = async function () { 45 | try { 46 | self.sheetResponse = await gapi.client.sheets.spreadsheets.get({ spreadsheetId: self.id }) 47 | } catch (error) { 48 | self.sheetResponse = error 49 | } 50 | } 51 | 52 | self.getData = function (range) { 53 | return gapi.client.sheets.spreadsheets.values.get({ 54 | spreadsheetId: self.id, 55 | range: range, 56 | }) 57 | } 58 | 59 | self.processSheetResponse = async function (sheetName, createBlips, handleError) { 60 | return self.sheetResponse.status !== 200 61 | ? handleError(self.sheetResponse) 62 | : processSheetData(sheetName, createBlips, handleError) 63 | } 64 | 65 | function processSheetData(sheetName, createBlips, handleError) { 66 | const sheetNames = self.sheetResponse.result.sheets.map((s) => s.properties.title) 67 | sheetName = !sheetName ? sheetNames[0] : sheetName 68 | self 69 | .getData(sheetName + '!A1:F') 70 | .then((r) => createBlips(self.sheetResponse.result.properties.title, r.result.values, sheetNames)) 71 | .catch(handleError) 72 | } 73 | 74 | return self 75 | } 76 | 77 | module.exports = Sheet 78 | -------------------------------------------------------------------------------- /src/util/stringUtil.js: -------------------------------------------------------------------------------- 1 | function getRingIdString(ringName) { 2 | return ringName.replaceAll(/[^a-zA-Z0-9]/g, '-').toLowerCase() 3 | } 4 | 5 | function replaceSpaceWithHyphens(anyString) { 6 | return anyString.trim().replace(/\s+/g, '-').toLowerCase() 7 | } 8 | 9 | function removeAllSpaces(blipId) { 10 | return blipId.toString().replace(/\s+/g, '') 11 | } 12 | 13 | module.exports = { 14 | getRingIdString, 15 | replaceSpaceWithHyphens, 16 | removeAllSpaces, 17 | } 18 | -------------------------------------------------------------------------------- /src/util/urlUtils.js: -------------------------------------------------------------------------------- 1 | const QueryParams = require('../util/queryParamProcessor') 2 | 3 | function constructSheetUrl(sheetName) { 4 | const noParamsUrl = window.location.href.substring(0, window.location.href.indexOf(window.location.search)) 5 | const queryParams = QueryParams(window.location.search.substring(1)) 6 | const sheetUrl = 7 | noParamsUrl + 8 | '?' + 9 | ((queryParams.documentId && `documentId=${encodeURIComponent(queryParams.documentId)}`) || 10 | (queryParams.sheetId && `sheetId=${encodeURIComponent(queryParams.sheetId)}`) || 11 | '') + 12 | '&sheetName=' + 13 | encodeURIComponent(sheetName) 14 | return sheetUrl 15 | } 16 | 17 | function getDocumentOrSheetId() { 18 | const queryParams = QueryParams(window.location.search.substring(1)) 19 | return queryParams.documentId ?? queryParams.sheetId 20 | } 21 | 22 | function getSheetName() { 23 | const queryParams = QueryParams(window.location.search.substring(1)) 24 | return queryParams.sheetName 25 | } 26 | 27 | module.exports = { 28 | constructSheetUrl, 29 | getDocumentOrSheetId, 30 | getSheetName, 31 | } 32 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const webpack = require('webpack') 4 | const path = require('path') 5 | const buildPath = path.resolve(__dirname, 'dist') 6 | const args = require('yargs').argv 7 | 8 | const HtmlWebpackPlugin = require('html-webpack-plugin') 9 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 10 | 11 | const env = args.envFile 12 | if (env) { 13 | // Load env file 14 | require('dotenv').config({ path: env }) 15 | } 16 | 17 | const common = ['./src/common.js'] 18 | 19 | const ASSET_PATH = process.env.ASSET_PATH || '/' 20 | 21 | const plugins = [ 22 | new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' }), 23 | new HtmlWebpackPlugin({ 24 | template: './src/index.html', 25 | chunks: ['main'], 26 | inject: 'body', 27 | }), 28 | new webpack.DefinePlugin({ 29 | 'process.env.CLIENT_ID': JSON.stringify(process.env.CLIENT_ID), 30 | 'process.env.API_KEY': JSON.stringify(process.env.API_KEY), 31 | 'process.env.ENABLE_GOOGLE_AUTH': JSON.stringify(process.env.ENABLE_GOOGLE_AUTH), 32 | 'process.env.GTM_ID': JSON.stringify(process.env.GTM_ID), 33 | 'process.env.RINGS': JSON.stringify(process.env.RINGS), 34 | 'process.env.QUADRANTS': JSON.stringify(process.env.QUADRANTS), 35 | 'process.env.ADOBE_LAUNCH_SCRIPT_URL': JSON.stringify(process.env.ADOBE_LAUNCH_SCRIPT_URL), 36 | }), 37 | ] 38 | 39 | module.exports = { 40 | context: __dirname, 41 | entry: { 42 | common: common, 43 | }, 44 | output: { 45 | path: buildPath, 46 | publicPath: ASSET_PATH, 47 | filename: '[name].[contenthash].js', 48 | assetModuleFilename: 'images/[name][ext]', 49 | }, 50 | resolve: { 51 | extensions: ['.js', '.ts'], 52 | fallback: { 53 | fs: false, 54 | }, 55 | }, 56 | 57 | module: { 58 | rules: [ 59 | { 60 | test: /\.js$/, 61 | exclude: /node_modules/, 62 | use: [ 63 | { 64 | loader: 'babel-loader', 65 | options: { 66 | presets: ['@babel/preset-env'], 67 | }, 68 | }, 69 | ], 70 | }, 71 | { 72 | test: /\.(eot|otf|ttf|woff|woff2)$/, 73 | type: 'asset/resource', 74 | }, 75 | { 76 | test: /\.(png|jpg|jpeg|gif|ico|svg)$/, 77 | exclude: /node_modules/, 78 | type: 'asset/resource', 79 | }, 80 | { 81 | test: require.resolve('jquery'), 82 | loader: 'expose-loader', 83 | options: { exposes: ['$', 'jQuery'] }, 84 | }, 85 | ], 86 | }, 87 | 88 | plugins: plugins, 89 | } 90 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const webpack = require('webpack') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const postcssPresetEnv = require('postcss-preset-env') 5 | const cssnano = require('cssnano') 6 | 7 | const common = require('./webpack.common.js') 8 | const config = require('./src/config') 9 | const { graphConfig, uiConfig } = require('./src/graphing/config') 10 | 11 | const featureToggles = config().development.featureToggles 12 | const main = ['./src/site.js'] 13 | const scssVariables = [] 14 | 15 | Object.entries(graphConfig).forEach(function ([key, value]) { 16 | scssVariables.push(`$${key}: ${value}px;`) 17 | }) 18 | 19 | Object.entries(uiConfig).forEach(function ([key, value]) { 20 | scssVariables.push(`$${key}: ${value}px;`) 21 | }) 22 | 23 | Object.entries(featureToggles).forEach(function ([key, value]) { 24 | scssVariables.push(`$${key}: ${value};`) 25 | }) 26 | 27 | module.exports = merge(common, { 28 | mode: 'development', 29 | entry: { main: main }, 30 | performance: { 31 | hints: false, 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.scss$/, 37 | exclude: /node_modules/, 38 | use: [ 39 | 'style-loader', 40 | MiniCssExtractPlugin.loader, 41 | { 42 | loader: 'css-loader', 43 | options: { importLoaders: 1, modules: 'global', url: false }, 44 | }, 45 | { 46 | loader: 'postcss-loader', 47 | options: { 48 | postcssOptions: { 49 | plugins: [ 50 | postcssPresetEnv({ browsers: 'last 2 versions' }), 51 | cssnano({ 52 | preset: ['default', { discardComments: { removeAll: true } }], 53 | }), 54 | ], 55 | }, 56 | }, 57 | }, 58 | { 59 | loader: 'sass-loader', 60 | options: { 61 | additionalData: scssVariables.join('\n'), 62 | }, 63 | }, 64 | ], 65 | }, 66 | ], 67 | }, 68 | plugins: [ 69 | new webpack.DefinePlugin({ 70 | 'process.env.ENVIRONMENT': JSON.stringify('development'), 71 | }), 72 | ], 73 | devtool: 'source-map', 74 | }) 75 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const webpack = require('webpack') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const postcssPresetEnv = require('postcss-preset-env') 5 | const cssnano = require('cssnano') 6 | 7 | const common = require('./webpack.common.js') 8 | const config = require('./src/config') 9 | const { graphConfig, uiConfig } = require('./src/graphing/config') 10 | 11 | const featureToggles = config().production.featureToggles 12 | const main = ['./src/site.js'] 13 | const scssVariables = [] 14 | 15 | Object.entries(graphConfig).forEach(function ([key, value]) { 16 | scssVariables.push(`$${key}: ${value}px;`) 17 | }) 18 | 19 | Object.entries(uiConfig).forEach(function ([key, value]) { 20 | scssVariables.push(`$${key}: ${value}px;`) 21 | }) 22 | 23 | Object.entries(featureToggles).forEach(function ([key, value]) { 24 | scssVariables.push(`$${key}: ${value};`) 25 | }) 26 | 27 | module.exports = merge(common, { 28 | mode: 'production', 29 | entry: { main }, 30 | performance: { 31 | hints: false, 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.scss$/, 37 | exclude: /node_modules/, 38 | use: [ 39 | 'style-loader', 40 | MiniCssExtractPlugin.loader, 41 | { 42 | loader: 'css-loader', 43 | options: { importLoaders: 1, modules: 'global', url: false }, 44 | }, 45 | { 46 | loader: 'postcss-loader', 47 | options: { 48 | postcssOptions: { 49 | plugins: [ 50 | postcssPresetEnv({ browsers: 'last 2 versions' }), 51 | cssnano({ 52 | preset: ['default', { discardComments: { removeAll: true } }], 53 | }), 54 | ], 55 | }, 56 | }, 57 | }, 58 | { 59 | loader: 'sass-loader', 60 | options: { 61 | additionalData: scssVariables.join('\n'), 62 | }, 63 | }, 64 | ], 65 | }, 66 | ], 67 | }, 68 | plugins: [ 69 | new webpack.NoEmitOnErrorsPlugin(), 70 | new webpack.DefinePlugin({ 71 | 'process.env.ENVIRONMENT': JSON.stringify('production'), 72 | }), 73 | ], 74 | }) 75 | --------------------------------------------------------------------------------