diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..f50b949 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,12 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + trailingComma: "es5", + tabWidth: 4, + semi: false, + singleQuote: true, +}; + +export default config; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35f29e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +RUN apk update && apk add bash + +RUN npm install -g npm@^9.0.0 + +WORKDIR /srv/app +COPY package.json package-lock.json /srv/app/ +RUN cd /srv/app && npm install +COPY . /srv/app/ diff --git a/Makefile b/Makefile index d51d834..fc43cd3 100644 --- a/Makefile +++ b/Makefile @@ -5,34 +5,45 @@ help: ## List all make commands @echo ' ' build: ## Build the project with -d and --no-recreate flags - $(DOCKER_COMPOSE) up --build --no-recreate -d + docker compose up --build --no-recreate -d install: ## Exec container and make npm install commands - $(DOCKER_EXEC_TOOLS_APP) -c $(NODE_INSTALL) + docker compose exec mct_web npm install -bundle: ## Run build npm command script - $(DOCKER_EXEC_TOOLS_APP) -c $(BUNDLE_RUN) +bundle: + docker compose exec mct_web npm run bundle clean: ## Remove all dist/ files - $(DOCKER_EXEC_TOOLS_APP) -c $(CLEAN_RUN) + docker compose exec mct_web rm -r dist/* -interact: ## Interact to install new packages or run specific commands in container - $(DOCKER_EXEC_TOOLS_APP) +bash: ## Interact to install new packages or run specific commands in container + docker compose exec -it mct_web bash dev: # Internal command to run dev npm command script - $(DOCKER_EXEC_TOOLS_APP) -c $(SERVER_RUN) + docker compose exec -it mct_web npm run development up: ## Run up -d Docker command container will wait for interactions - $(DOCKER_COMPOSE) up -d + docker compose up -d start: up dev ## Up the docker env and run the npm run dev it to first: build install dev ## Build the env, up it and run the npm install and then run npm run dev it to -stop: $(ROOT_DIR)/compose.yml ## Stop and remove containers - $(DOCKER_COMPOSE) kill - $(DOCKER_COMPOSE) rm --force +stop: ./compose.yml ## Stop and remove containers + docker compose kill + docker compose rm --force restart: stop start dev ## Stop and restart container -clear: stop $(ROOT_DIR)/compose.yml ## Stop and remove container and orphans - $(DOCKER_COMPOSE) down -v --remove-orphans +types: ## Run type check and generator + docker compose exec mct_web npm run types + +prettier: ## Run prettier the opinionated code formatter in code + docker compose exec mct_web npm run prettier + +types-watch: ## Run type check and generator + docker compose exec mct_web npm run types-watch + +clear: stop ./compose.yml ## Stop and remove container and orphans + docker compose down -v --remove-orphans + +.PHONY: bash build clean help logs start stop types types-watch prettier diff --git a/compose.yml b/compose.yml index 9b79ef1..9661484 100644 --- a/compose.yml +++ b/compose.yml @@ -1,9 +1,7 @@ -version: "3.4" - services: - vite_docker: - image: node:alpine - container_name: "${DOCKER_NAME}" + mct_web: + build: . + image: mct:latest entrypoint: /bin/sh env_file: - .env diff --git a/env.example b/env.example index e1a49d1..45633f3 100644 --- a/env.example +++ b/env.example @@ -4,13 +4,3 @@ HOST_PORT=5173 CONTAINER_PORT=5173 SERVER_HOST_PORT=5000 SERVER_CONTAINER_PORT=5000 -DOCKER_NAME=mct_docker - -CURRENT_DIR=$(patsubst %/,%,$(dir $(realpath $(firstword $(MAKEFILE_LIST))))) -ROOT_DIR=$(CURRENT_DIR) -DOCKER_COMPOSE=docker compose -DOCKER_EXEC_TOOLS_APP=docker exec -it $(DOCKER_NAME) sh -NODE_INSTALL="npm i" -BUNDLE_RUN="npm run build" -CLEAN_RUN="rm -r dist/*" -SERVER_RUN="npm run development" diff --git a/images.d.ts b/images.d.ts new file mode 100644 index 0000000..9398a2b --- /dev/null +++ b/images.d.ts @@ -0,0 +1,13 @@ +declare module "*.svg" { + const content: string; + export default content; +} + +declare module '*.png' { + const value: string; + export default value; +} + +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.svg'; diff --git a/index.html b/index.html index 27c3ef0..04bde35 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,53 @@ + Map Chart Table + + + + +
- + + + + + + diff --git a/oldSrc/assets/css/base.css b/oldSrc/assets/css/base.css new file mode 100644 index 0000000..4d37a09 --- /dev/null +++ b/oldSrc/assets/css/base.css @@ -0,0 +1,20 @@ +:root { + --body-color: rgba(0, 0, 0, 0.87); + --text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white; + --gray-color: #ececec; + --gray-color-light: #fafafa; + --primary-color: #e96f5f; + --label-color: #827e7e; + --embed-color: #fafafa; + + --padding-container: 200px; +} + + +/* MediaQuery */ + +@media (max-width: 1368px) { + :root { + --padding-container: 20px; + } +} diff --git a/oldSrc/assets/css/components/collapsable.css b/oldSrc/assets/css/components/collapsable.css new file mode 100644 index 0000000..989b476 --- /dev/null +++ b/oldSrc/assets/css/components/collapsable.css @@ -0,0 +1,23 @@ +.n-collapse-item.n-collapse-item--left-arrow-placement { + outline: 1px solid #e0e0e0; + border: none; + border-radius: 3px; + padding: 8px; + box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 4px; +} + +.n-collapse .n-collapse-item .n-collapse-item__header .n-collapse-item__header-main { + font-weight: 500; +} + +.n-collapse .n-collapse-item:not(:first-child) { + border-top: none !important; +} + +.n-collapse .n-collapse-item .n-collapse-item__header { + padding: 0px; +} + +.n-collapse .n-collapse-item__header-extra .bi { + fill: var(--primary-color); +} diff --git a/oldSrc/assets/css/components/container.css b/oldSrc/assets/css/components/container.css new file mode 100644 index 0000000..07c5e1d --- /dev/null +++ b/oldSrc/assets/css/components/container.css @@ -0,0 +1,58 @@ +/* Components Styles */ + +.main { + display: flex; + flex-direction: column; +} + +.main-content { + position: relative; + max-width: 1368px; + margin: 0px auto; + padding: 0px 24px; +} + +.main-content--sub { + margin-bottom: 64px; +} + +.container-elements { + display: flex; + justify-content: end; +} + +.container-elements--table { + display: flex; + gap: 12px; + padding-bottom: 16px; +} + +.container-elements--theme { + padding: 15px 65px; +} + +.container-elements__selects { + display: flex; + gap: 12px; +} + +.container-input-card { + display: flex; + gap: 8px; + justify-content: end; +} + +.element-hidden { + display: none !important; +} + +@media (max-width: 800px) { + .container-elements--table { + display: flex; + flex-direction: column; + gap: 8px; + } + .container-input-card { + flex-direction: column; + } +} diff --git a/oldSrc/assets/css/components/filter-suggestion.css b/oldSrc/assets/css/components/filter-suggestion.css new file mode 100644 index 0000000..e1e8698 --- /dev/null +++ b/oldSrc/assets/css/components/filter-suggestion.css @@ -0,0 +1,65 @@ +.filter-suggestion { + height: 100%; + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + background-color: rgb(254, 254, 254); +} + +.filter-suggestion-center { + justify-content: center; +} + +.filter-suggestion-title { + text-align: center; + font-size: 24px; + padding-bottom: 48px; + font-weight: 400; +} + +.filters-container { + gap: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(600px, 1fr)) ; + grid-gap: 10px; +} + +.filter-container-suggestion { + overflow: hidden; +} + +.filter-title { + height: 18px; +} +.filter-text-suggestion { + text-align: initial; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-description { + font-size: 11px; + height: 14px; +} + +/* MediaQuery */ + +@media (max-width: 1300px) { + .filters-container { + display: flex; + flex-direction: column; + } +} + +@media (max-width: 768px) { + .filter-suggestion { + display: block; + } +} + diff --git a/oldSrc/assets/css/components/label.css b/oldSrc/assets/css/components/label.css new file mode 100644 index 0000000..25b8098 --- /dev/null +++ b/oldSrc/assets/css/components/label.css @@ -0,0 +1,10 @@ +/* Label */ +.n-form-item.n-form-item--top-labelled .n-form-item-label { + align-items: center; + padding: 0px; +} + +.n-form-item-label__text { + color: var(--label-color); + font-size: .65rem; +} diff --git a/oldSrc/assets/css/components/main-content.css b/oldSrc/assets/css/components/main-content.css new file mode 100644 index 0000000..1f8d322 --- /dev/null +++ b/oldSrc/assets/css/components/main-content.css @@ -0,0 +1,13 @@ +.main-content { + margin-top: 16px; +} + +.map-section { + min-height: 520px +} + +@media (max-width: 475px) { + .map-section { + min-height: 420px + } +} diff --git a/oldSrc/assets/css/components/main-footer.css b/oldSrc/assets/css/components/main-footer.css new file mode 100644 index 0000000..af228d7 --- /dev/null +++ b/oldSrc/assets/css/components/main-footer.css @@ -0,0 +1,75 @@ +.main-card-footer-container { + display: block; +} + +.main-card-footer { + display:flex; + justify-content: space-between; + align-items: center; + margin-top: 18px; + margin-bottom: 12px; +} + +.main-card-footer--mobile { + align-items: initial; + gap: 14px; +} + +.main-card-footer-mobile { + display: none; +} + +.main-card-footer__legend { + color:gray; + font-size:14px; + font-weight: 400; +} + +.main-card-footer__buttons { + display: flex; + gap: 8px; +} + +.main-card-footer__buttons--mobile { + justify-content: space-between; +} + +.main-card-footer-dates { + display: flex; + flex-direction: column; + gap: 4px +} + +/* MediaQuery */ + +@media (max-width: 1200px) { + .main-card-footer { + margin: 0px 0px 12px; + flex-direction: column; + gap: 8px; + } + .main-card-footer-container { + display: flex; + flex-direction: column; + } + .main-card-footer__buttons { + justify-items: start; + margin-bottom: 12px; + flex-wrap: wrap; + justify-content: center; + width: 100%; + gap: 12px; + } + .main-card-footer-mobile { + display: flex; + } + .main-card-footer-container-mobile { + display: block; + } + .main-card-footer-dates { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + gap: 4px 12px; + } +} diff --git a/oldSrc/assets/css/components/main-header.css b/oldSrc/assets/css/components/main-header.css new file mode 100644 index 0000000..678d57d --- /dev/null +++ b/oldSrc/assets/css/components/main-header.css @@ -0,0 +1,90 @@ +.main-header { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding-top: 6px; + padding-left: var(--padding-container); + padding-right: var(--padding-container); + background-color: var(--embed-color); + box-shadow: rgba(0, 0, 0, 0.35) 0px 2px 36px -28px inset; +} + +.main-header-container { + display:flex; + gap: 32px; + overflow: auto; + max-width: 100%; + align-items: center; + height: 70px; + overflow: hidden; +} + +.main-header__label { + color: #4a4a4a; + font-size: .9em; +} + +.main-header-form { + display: flex; + align-items: center; + gap: 24px; +} + +.main-header-form .n-tabs-tab__label { + height: 20.5167px; +} + +.custom-hr { + height: 48px; + border-left: 0px; + border-top: 0px; + opacity: 50%; + border-bottom: 1px solid; + border-right: 1px solid; +} + +.filter-mobile-button { + display: flex; + justify-content: center; +} + +.main-header__tab-label { + display: block; +} + +.main-header__tab-icon { + display: none; +} + +@media (max-width: 1368px) { + .main-header-container { + gap: 12px; + height: auto; + padding-bottom: 12px; + } + .main-header-form { + flex-direction: column; + gap: 6px; + } + .custom-hr { + height: 70px; + } + .main-header__tab-label { + display: none; + } + .main-header__tab-icon { + display: block; + } +} + +@media (max-width: 475px) { + .main-header-container { + flex-direction: column; + gap: 6px; + } + .custom-hr { + height: 0px; + width: 70px; + } +} diff --git a/oldSrc/assets/css/components/modal.css b/oldSrc/assets/css/components/modal.css new file mode 100644 index 0000000..f87d485 --- /dev/null +++ b/oldSrc/assets/css/components/modal.css @@ -0,0 +1,40 @@ +.custom-card-body.n-scrollbar { + padding: 0px; + font-size: 1rem; +} + +.custom-card.n-card .n-scrollbar-content { + padding: 12px 32px; +} + +.custom-card.n-card > .n-card__content { + padding: 0px; +} + +.custom-card .n-card-header { + border-bottom: 1px solid #eee; +} + +.custom-card-body { + max-height: calc(100vh - 170px); + overflow-y: hidden; +} + +.custom-card-body--tabs { + height: calc(100vh - 170px); +} + +.custom-card-body>.n-scrollbar-container>.n-scrollbar-content { + padding: 0px; +} +.custom-card-body .n-tabs-nav--line-type.n-tabs-nav--top.n-tabs-nav { + padding: 0px 25px; +} + +.custom-card-body .n-tabs-tab__label { + padding: 4px 0px 8px; +} + +.custom-card-body h3 { + font-size: 1.3rem; +} diff --git a/oldSrc/assets/css/components/select.css b/oldSrc/assets/css/components/select.css new file mode 100644 index 0000000..e1efa81 --- /dev/null +++ b/oldSrc/assets/css/components/select.css @@ -0,0 +1,142 @@ +/* + +.select .n-base-selection-label { + background: var(--gray-color); +} + +.select .n-base-selection-label:hover { + background-color: var(--primary-color); +} + +.select .n-base-selection--active .n-base-selection-label, +.select .n-base-selection--active.n-base-selection--focus +{ + background-color: var(--primary-color) !important; +} + +.select .n-base-selection-label, +.n-base-selection-placeholder__inner { + color: black; + font-weight: 600; +} + +.select .n-base-selection--active .n-base-selection-label .n-base-selection-placeholder__inner, +.select .n-base-selection--active.n-base-selection--focus .n-base-selection-placeholder__inner, +.select .n-base-selection--active .n-base-selection-label .n-base-icon.n-base-suffix__arrow, +.select .n-base-selection--active.n-base-selection--focus .n-base-icon.n-base-suffix__arrow, +.select .n-base-selection-label:hover .n-base-selection-placeholder__inner, +.select .n-base-selection-label:hover .n-base-icon.n-base-suffix__arrow, +.select .n-base-selection--active.n-base-selection--focus .n-base-selection-input { + color: white; +} + +.select .n-base-selection__border { + border: 0px solid; +} + +.select .n-base-icon.n-base-suffix__arrow { + color: var(--primary-color); +} + +.n-base-select-menu .n-base-select-option.n-base-select-option--pending::before { + background-color: var(--primary-color); +} + +.n-base-icon.n-base-select-option__check { + outline: 2px solid white; + outline-offset: 1px; +} + +.n-base-select-option.n-base-select-option--selected.n-base-select-option--show-checkmark, +.n-base-select-menu .n-base-select-option .n-base-select-option__check, +.n-base-selection .n-base-loading, +.n-base-select-menu .n-base-select-option.n-base-select-option--selected, +.n-base-select-menu .n-base-select-option +{ + color: white; +} + +.n-base-selection.n-base-selection--active.n-base-selection--focus { + box-shadow: none; +} + +.n-base-select-menu .n-base-select-option.n-base-select-option--selected.n-base-select-option--pending::before { + background-color: var(--primary-color); +} + +.v-vl-items { + background-color: var(--primary-color); +} + +.n-select-menu { + margin: 0px; + box-shadow: none; +} + +*/ + +.n-form-item-feedback-wrapper { + min-height: 0 !important; +} + +.start-datepicker .n-base-selection__border { + border-right-color: transparent; +} + +.end-datepicker .n-base-selection__border { + border-left-color: transparent; +} + +.start-datepicker .n-input.n-input--resizable.n-input--stateful { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.end-datepicker .n-input.n-input--resizable.n-input--stateful { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +/* Selects styles */ + +.sub-select-container { + background-color: var(--embed-color); + box-shadow: rgba(0, 0, 0, 0.35) 0px -2px 36px -28px inset; +} + +.mct-select { + width: 200px; + z-index: 0; +} + +.mct-select-dose { + width: 140px; + z-index: 0; +} + +.mct-selects { + display:flex; + justify-content: center; + gap: 14px; + align-items: flex-start; + max-width: 1368px; + margin: 0px auto; + padding: 0px 24px 24px; +} + +.mct-selects--modal { + flex-direction: column; +} + +.n-base-select-option__content { + z-index: 0 !important; +} + +@media (max-width: 1368px) { + .mct-selects { + background-color: white; + gap: 12px; + box-shadow: none; + align-items: center; + } +} diff --git a/oldSrc/assets/css/components/slider.css b/oldSrc/assets/css/components/slider.css new file mode 100644 index 0000000..b342f0c --- /dev/null +++ b/oldSrc/assets/css/components/slider.css @@ -0,0 +1,64 @@ +.n-slider-mark { + color: var(--label-color); + font-size: 12px; + font-weight: 500; +} + +.n-slider .n-slider-marks .n-slider-mark { + transform: translateX(-50%) translateY(-20%); +} + +.year-slider { + display: flex; + gap: 14px; + margin-top: 24px; + align-items: center; +} + +.n-slider-handle-indicator.n-slider-handle-indicator--top { + background-color: #32a1e6; + font-weight: 500; +} + +.mandatory-vaccine-years { + opacity: 100%; + cursor: auto !important; +} + +.mandatory-vaccine-years .n-slider-rail { + background-color: white; + height: 1px; +} +.mandatory-vaccine-years:hover .n-slider-rail { + background-color: white; +} + +.mandatory-vaccine-years.n-slider .n-slider-rail__fill { + background-color: #32a1e6; +} + +.mandatory-vaccine-years.n-slider:hover .n-slider-rail__fill { + background-color: #32a1e6; +} + +.mandatory-vaccine-years.n-slider.n-slider--disabled { + opacity: 100%; +} + +.mandatory-vaccine-years.n-slider.n-slider--active .n-slider-rail { + background-color: white; +} + +.mandatory-vaccine-years.n-slider.n-slider--active .n-slider-rail__fill { + background-color: #32a1e6; +} + +.span-date { + white-space: nowrap; + padding: 0px 6px; + font-size: 14px; +} + +.span-date--more-padding { + padding: 12px 6px; +} diff --git a/oldSrc/assets/css/components/tab.css b/oldSrc/assets/css/components/tab.css new file mode 100644 index 0000000..ba58d45 --- /dev/null +++ b/oldSrc/assets/css/components/tab.css @@ -0,0 +1,72 @@ +/* Tabs */ +.n-tabs.n-tabs--segment-type.n-tabs--medium-size.n-tabs--top { + width: fit-content; +} + +.n-tabs-nav .n-tabs-rail { + width: fit-content; + border-top-left-radius: 22px; + border-bottom-left-radius: 22px; + border-top-right-radius: 22px; + border-bottom-right-radius: 22px; + padding: 0px; +} + + +.n-tabs-nav .n-tabs-rail .n-tabs-tab:first-child { + border-top-left-radius: 22px !important; + border-bottom-left-radius: 22px !important; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name="table"], +.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name="immunizers"] { + border-top-right-radius: 22px !important; + border-bottom-right-radius: 22px !important; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab { + background-color: var(--gray-color); + color: black; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab--disabled { + color: #ccc; + background-color: var(--gray-color-light); +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab--disabled:hover { + color: #ccc !important; +} + + +.n-tabs-tab-wrapper .n-tabs-tab { + padding: 8px 24px; +} + +.n-tabs-nav { + display: flex; + justify-content: space-between; +} + +.n-tabs .n-tabs-rail .n-tabs-capsule { + display: none; +} + +.n-tabs .n-tabs-rail .n-tabs-tab-wrapper .n-tabs-tab.n-tabs-tab--active { + background-color: var(--primary-color); + transition: background-color 0.3s; + color: white; + font-weight: 400; +} + +.n-tabs-tab__label { + font-size: 0.9em; +} + +.n-tabs.n-tabs--line-type .n-tabs-tab { + padding: 2px 0px; +} + +.n-tabs-nav-scroll-content { + border-bottom-width: 3px; +} diff --git a/oldSrc/assets/css/components/table.css b/oldSrc/assets/css/components/table.css new file mode 100644 index 0000000..90f66a6 --- /dev/null +++ b/oldSrc/assets/css/components/table.css @@ -0,0 +1,93 @@ +.n-data-table-th.n-data-table-th--hover { + color: white; +} + +.n-data-table-th.n-data-table-th--sortable:hover { + color: white; +} + +.n-data-table .n-data-table-th.n-data-table-th--sortable:hover .n-data-table-sorter { + color: var(--gray-color); +} + +.n-data-table-tr .n-data-table-tr--striped, +.n-data-table-tr .n-data-table-tr--striped:hover, +.n-data-table .n-data-table-tr.n-data-table-tr--striped, +.n-data-table .n-data-table-tr:not(.n-data-table-tr--summary):hover, +.n-data-table-tr { + background: white; +} + +.n-data-table-tr .n-data-table-th:first-child, +.n-data-table-tr .n-data-table-td:first-child { + border-top-left-radius: .25rem; + border-bottom-left-radius: .25rem; +} + +.n-data-table-tr .n-data-table-th:last-child, +.n-data-table-tr .n-data-table-td:last-child { + border-top-right-radius: .25rem; + border-bottom-right-radius: .25rem; +} + +.n-data-table .n-data-table-tr.n-data-table-tr--striped .n-data-table-td.n-data-table-td--hover { + background: #fadfdb; +} + + +.n-data-table .n-data-table-tr:not(.n-data-table-tr--summary):hover > .n-data-table-td { + background: #f6f6f6; +} + +.n-data-table .n-data-table-th .n-data-table-sorter.n-data-table-sorter--desc, +.n-data-table .n-data-table-th .n-data-table-sorter.n-data-table-sorter--asc { + color: white; +} + +.n-data-table-tr { + font-weight: 500; +} + +/* MediaQuery */ + +@media (max-width: 800px) { + .table-custom .n-pagination { + justify-content: space-between; + min-width: 100%; + } +} + +/* Collapsable */ + +.collapse-table { + margin-top: 12px; +} +.collapse-table table { + border-spacing: 0 8px !important; +} + +.collapse-table .n-data-table-thead { + display: none; +} + +.collapse-table .n-data-table-tr td:first-child { + border-left: 1px solid #d1d1d1; + border-bottom: 1px solid #d1d1d1; + border-top: 1px solid #d1d1d1; +} + +.collapse-table .n-data-table-tr td:last-child { + border-right: 1px solid #d1d1d1; + border-bottom: 1px solid #d1d1d1; + border-top: 1px solid #d1d1d1; +} + +/* Custom pagination */ + +.n-input.n-input--resizable.n-input--stateful { + pointer-events: none; +} + +.n-pagination.n-pagination--simple .n-input.n-input--resizable.n-input--stateful { + --n-border: none !important; +} diff --git a/oldSrc/assets/css/map-chart-table-legend.css b/oldSrc/assets/css/map-chart-table-legend.css new file mode 100644 index 0000000..191762c --- /dev/null +++ b/oldSrc/assets/css/map-chart-table-legend.css @@ -0,0 +1,75 @@ +/* Map Legend */ + +.mct-legend { + position: absolute; + bottom: 15px; + right: 60px; + user-select: none; + pointer-events: none; + height: 52px; +} + +.mct-legend-svg { + width: 220px; +} + +.mct-legend__gradient { + position: relative; + box-shadow: 0 0 2px white, 0 0 2px white, 0 0 2px white, 0 0 2px white; +} + +@media (max-width: 800px) { + .mct-legend { + bottom: 0px; + right: 0px; + } + .mct-legend-svg { + width: 190px; + } +} + +/* Tooltip map */ + +.mct-tooltip { + display: none; + position: fixed; + opacity: 95%; + background-color: var(--primary-color); + border-radius: 5px; + padding: 12px 12px; + font-size: 14px; + z-index: 1000; + user-select: none; + pointer-events: none; + min-width: 100px; + max-width: 400px; +} + +.mct-tooltip__title { + font-weight: 600; + color: white; + margin-bottom: 4px; +} + +.mct-tooltip__title--sub { + font-size: x-small; + font-weight: 600; + color: white; + margin-top: 2px; + margin-bottom: 2px; +} + +.mct-tooltip__result { + font-weight: 600; + color: white; + font-size: 12px; + margin: -3px 0px; +} + +.mct-tooltip__result--sub { + font-size: 11px; +} + +.mct-tooltip__result--sub:last-child { + margin-bottom: 1px; +} diff --git a/oldSrc/assets/css/map-chart-table.css b/oldSrc/assets/css/map-chart-table.css new file mode 100644 index 0000000..b0b3d66 --- /dev/null +++ b/oldSrc/assets/css/map-chart-table.css @@ -0,0 +1,211 @@ +/* Map styles */ + +.map-container { + min-height: 440px !important; +} + +.mct-canva { + width: 100%; + height: 440px; + display: flex; + justify-content: center; +} + +.mct-canva__chart { + padding-top: 25px; + width: 100%; +} + +.mct__canva-section { + position: relative; +} + +/* Map Year */ + +.mct-canva-year { + border-radius: .25rem; + color: var(--body-color); + font-size: 1.5rem; + font-weight: 700; + margin: 0px auto; + max-width: fit-content; + padding: 6px 24px; + position: absolute; + right: 10px; + bottom: 5px; + opacity: 0; + transition: visibility 0s, opacity 0.5s ease-in-out; + user-select: none; +} + +/* Map Legend */ + +.mct-legend { + width: 195px; + position: absolute; + bottom: 20px; + right: 10px; + display: flex; + flex-direction: column; + user-select: none; + pointer-events: none; +} + +.mct-legend__gradient { + position: relative; + margin: auto; + box-shadow: 0 0 2px white, 0 0 2px white, 0 0 2px white, 0 0 2px white; +} + +.mct-legend__gradient-box { + position: absolute; + display: flex; + z-index: 40; +} + +.mct-legend__gradient-box-content { + width: 10px; + height: 10px; + margin: 0px 1px; +} + +.mct-legend__content-box { + display: flex; + justify-content: space-between; + font-size: .6rem; + text-shadow: var(--text-shadow); + color: #1E1E1E; + margin-left: 22px; + margin-right: 40px; +} + +.mct-legend-box-0 { + background-color: #9C3F33; +} + +.mct-legend-box-1 { + background-color: #CF5443; +} + +.mct-legend-box-2 { + background-color: #E75E4B; +} + +.mct-legend-box-3 { + background-color: #EA7262; +} + +.mct-legend-box-4 { + background-color: #ED8678; +} + +.mct-legend-box-5 { + background-color: #F3AEA5; +} + +.mct-legend-box-6 { + background-color: #F6C2BC; +} + +.mct-legend-box-7 { + background-color: #A0D1F2; +} + +.mct-legend-box-8 { + background-color: #32A1E6; +} + +.mct-legend-box-9 { + background-color: #0179DA; +} + +.mct-legend-box-10 { + background-color: #016FC4; +} + +.mct-legend-box-11 { + background-color: #005CA1; +} + +.mct-legend-box-text { + font-size: .65em; + white-space: nowrap; + background-color: white; + position: relative; + top: 18px; + left: 0; +} + +.mct-legend-box-text__line { + border-right: 1px solid; + border-color: red; + position: absolute; + top: -12px; + left: -10px; + padding: 10px; + z-index: 0; +} + +.mct-legend-box-text__line--end { + border-color: blue; +} + +.mct-legend-box-text__content { + border: 1px solid gray; + padding: 1px 4px; + color: black; + position: absolute; + border-radius: .23rem; + left: 0; + z-index: 40; + background-color: white; +} + +.mct-legend-box-text--end { + top: 18px; + left: auto; + right: 0; +} + +.mct-legend-box-text__line--end { + top: -12px; + left: auto; + right: 18px; +} + +.mct-legend-box-text__content--end { + left: auto; + top: 0; + right: 0; + z-index: 40; + background-color: white; +} + +.mct-legend__content { + display: flex; + gap: 38px; + justify-content: space-between; + white-space: nowrap; +} + + +.mct-legend-box-start { + background-color: #692A22; + width: 19px; +} + +.mct-legend-box-end { + background-color: #00457C; + width: 19px; +} + +/* MediaQuery */ + +@media (max-width: 800px) { + .map-container { + min-height: 337.6px !important; + } + .mct-canva { + height: 70vw; + } +} diff --git a/oldSrc/assets/css/style.css b/oldSrc/assets/css/style.css new file mode 100644 index 0000000..0c9f284 --- /dev/null +++ b/oldSrc/assets/css/style.css @@ -0,0 +1,109 @@ +@import url("./base.css"); +@import url("./map-chart-table.css"); +@import url("./map-chart-table-legend.css"); +@import url("./components/container.css"); +@import url("./components/label.css"); +@import url("./components/tab.css"); +@import url("./components/select.css"); +@import url("./components/slider.css"); +@import url("./components/table.css"); +@import url("./components/main-header.css"); +@import url("./components/main-content.css"); +@import url("./components/main-footer.css"); +@import url("./components/collapsable.css"); +@import url("./components/modal.css"); +@import url("./components/filter-suggestion.css"); + +/* + * Fix table resize rows error. + * + * After Resize to 20 and go back to 10, for example + * browser will show an height page error. + * + * */ + +.v-binder-follower-container { + position: initial; +} + +.mct-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.mct-scrollbar::-webkit-scrollbar-track { + border-radius: 100vh; + background: #f7f4ed; +} + +.mct-scrollbar::-webkit-scrollbar-thumb { + background: #e0cbcb; + border-radius: 100vh; + border: 3px solid #f6f7ed; +} + +.mct-scrollbar::-webkit-scrollbar-thumb:hover { + background: #c0a0b9; +} + +.pulse-button { + position: relative; + overflow: visible; +} + +.pulse-button::after { + content: ''; + position: absolute; + width: 100%; + height: 110%; + outline: 8px solid #18a058; + outline-offset: -6px; + border-radius: 34px; + animation: pulse 2s infinite; + z-index: 0; +} + +.pulse-button:hover::after { + outline-color: #36ad6a; +} + +.pulse-button:active::after { + outline-color: #0c7a43; +} + +.pulse-button:focus-visible { + border: 1px solid black; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.7; + } + 70% { + transform: scale(1.05); + opacity: 0; + } + 100% { + transform: scale(1.05); + opacity: 0; + } +} + +.vbr, +.vbr ::before, +.vbr ::after { + box-sizing: unset !important; +} + +/* Remove fade/scale selects animations */ +.fade-in-scale-up-transition-enter-active, +.fade-in-scale-up-transition-leave-active { + transition: none !important; +} + +.fade-in-scale-up-transition-enter-from, +.fade-in-scale-up-transition-leave-to { + opacity: 1 !important; + transform: none !important; +} diff --git a/oldSrc/assets/images/abandono.svg b/oldSrc/assets/images/abandono.svg new file mode 100644 index 0000000..fc83c0d --- /dev/null +++ b/oldSrc/assets/images/abandono.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/oldSrc/assets/images/cc.png b/oldSrc/assets/images/cc.png new file mode 100644 index 0000000..1bd4934 Binary files /dev/null and b/oldSrc/assets/images/cc.png differ diff --git a/oldSrc/assets/images/cobertura.svg b/oldSrc/assets/images/cobertura.svg new file mode 100644 index 0000000..1987ddc --- /dev/null +++ b/oldSrc/assets/images/cobertura.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/oldSrc/assets/images/hom_geo.svg b/oldSrc/assets/images/hom_geo.svg new file mode 100644 index 0000000..7f15095 --- /dev/null +++ b/oldSrc/assets/images/hom_geo.svg @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/oldSrc/assets/images/hom_vac.svg b/oldSrc/assets/images/hom_vac.svg new file mode 100644 index 0000000..9ceea6e --- /dev/null +++ b/oldSrc/assets/images/hom_vac.svg @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/oldSrc/assets/images/legend.png b/oldSrc/assets/images/legend.png new file mode 100644 index 0000000..c9fefd0 Binary files /dev/null and b/oldSrc/assets/images/legend.png differ diff --git a/oldSrc/assets/images/logo-vacinabr.svg b/oldSrc/assets/images/logo-vacinabr.svg new file mode 100644 index 0000000..9d20643 --- /dev/null +++ b/oldSrc/assets/images/logo-vacinabr.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/oldSrc/assets/images/meta.svg b/oldSrc/assets/images/meta.svg new file mode 100644 index 0000000..3cd8bad --- /dev/null +++ b/oldSrc/assets/images/meta.svg @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sim + Não + + + + diff --git a/oldSrc/assets/images/ri-alert-line.svg b/oldSrc/assets/images/ri-alert-line.svg new file mode 100644 index 0000000..36b56f3 --- /dev/null +++ b/oldSrc/assets/images/ri-alert-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/oldSrc/assets/images/sbim.png b/oldSrc/assets/images/sbim.png new file mode 100644 index 0000000..76855e6 Binary files /dev/null and b/oldSrc/assets/images/sbim.png differ diff --git a/oldSrc/canvas-download.js b/oldSrc/canvas-download.js new file mode 100644 index 0000000..bcb4b2c --- /dev/null +++ b/oldSrc/canvas-download.js @@ -0,0 +1,147 @@ +class CanvasDownload { + constructor(images, { title, subTitle, message, source, canvasWidth, canvasHeight, yTextSource } = {}) { + this.images = images; + this.title = title; + this.subTitle = subTitle; + this.source = source; + this.message = message; + + this.canvasWidth = canvasWidth ?? 1400; + this.canvasHeight = canvasHeight ?? 720; + this.yTextSource = yTextSource ?? 694; + } + + async setCanvas() { + const self = this; + const canvas = document.createElement("canvas"); + canvas.id = "canvas-generator"; + canvas.width = self.canvasWidth; + canvas.height = self.canvasHeight; + canvas.style.backgroundColor = "white"; + self.canvas = canvas; + self.ctx = canvas.getContext("2d"); + + // Set canvas color + self.ctx.fillStyle = "white"; + self.ctx.fillRect(0, 0, self.canvas.width, self.canvas.height); + + const promises = []; + self.images.forEach(img => { + promises.push(self.addImage(img.image, img.height, img.width, img.posX, img.posY)); + }); + + await Promise.all(promises); + + self.addText(self.title, self.subTitle); + } + + reduceProportion(height, width, factor) { + const nHeight = height * factor; + const nWidth = width * factor; + + return { nHeight, nWidth }; + } + + addImage(image, height, width, posX, posY) { + const self = this; + const canvas = this.canvas; + const ctx = this.ctx; + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = function() { + const x = posX ? posX : canvas.width / 2 - img.width / 2; + const y = posY ? posY : canvas.height / 2 - img.height / 2; + // Assuming 'ctx' is a 2D rendering context of the canvas + ctx.drawImage(img, x, y, img.width, img.height); + resolve(); + }; + img.onerror = (e) => reject(new Error('Image load failed')); + + img.src = image; + let factor = 1; + let result = self.reduceProportion(img.naturalHeight, img.naturalWidth, factor) + while (result.nWidth > self.canvasWidth) { + factor -= 0.01; + result = self.reduceProportion(img.naturalHeight, img.naturalWidth, factor) + } + img.height = height ?? result.nHeight; + img.width = width ?? result.nWidth; + }); + } + + drawTextWithLineBreaks(text, x, y, maxWidth = 1390, lineHeight = 30) { + const self = this; + let lineBreakedTimes = 0; + const words = text.split(' '); + let line = ''; + + for (const word of words) { + const testLine = line + word + ' '; + const { width } = self.ctx.measureText(testLine); + + if (width > maxWidth) { + self.ctx.fillText(line, x, y); + line = word + ' '; + y += lineHeight; + lineBreakedTimes++; + } else { + line = testLine; + } + } + + self.ctx.fillText(line, x, y); + + return lineBreakedTimes; + } + + addText() { + const self = this; + + if (!self.title) { + return; + } + + self.ctx.font = "bold 25px Arial"; + self.ctx.fillStyle = "#222"; + let xText = 10; + let yText = 30; + const lineBreakedTimes = self.drawTextWithLineBreaks(self.title, xText, yText); + + if (self.subTitle) { + self.ctx.font = "17px Arial"; + self.ctx.fillStyle = "#222"; + yText = lineBreakedTimes ? (lineBreakedTimes + 1) * 45 : 55; + xText = 12; + self.drawTextWithLineBreaks(self.subTitle, xText, yText); + } + + if (self.source) { + self.ctx.font = "12px Arial"; + self.ctx.fillStyle = "#222"; + yText = self.yTextSource; + xText = 230; + self.drawTextWithLineBreaks(self.source, xText, yText); + } + + if (self.message) { + self.ctx.font = "700 70px Arial"; + self.ctx.fillStyle = "rgba(100, 100, 100, 0.5)"; + yText = 560; + xText = -130; + self.ctx.rotate(-20 * Math.PI / 180) + self.drawTextWithLineBreaks(self.message, xText, yText); + } + } + + async download() { + const self = this; + await self.setCanvas(); + const link = document.createElement("a"); + link.href = self.canvas.toDataURL('image/png'); + link.download = "image"; + link.click(); + } +} + +export default CanvasDownload; + diff --git a/oldSrc/common.js b/oldSrc/common.js new file mode 100644 index 0000000..d1afde2 --- /dev/null +++ b/oldSrc/common.js @@ -0,0 +1,59 @@ +export const formatToApi = ({ + form, + tab, + tabBy +}) => { + const routerResult = {}; + if (form) { + for (let formField in form) { + switch (formField) { + case "local": + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField]; + } + break; + case "city": + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField]; + } + break; + case "periodEnd": + case "periodStart": + if (form[formField]) { + routerResult[formField] = form[formField]; + } + break; + case "cities": + case "doses": + case "granularities": + case "immunizers": + case "locals": + case "sicks": + case "types": + case "years": + // Do Nothing + break; + default: + if (form[formField]) { + routerResult[formField] = form[formField]; + } + break; + } + } + } + + if (tab) { + routerResult.tab = tab; + } else { + delete routerResult.tab + } + + if (tabBy) { + routerResult.tabBy = tabBy; + } else { + delete routerResult.tabBy + } + + return routerResult; +}; + diff --git a/src/components/chart.js b/oldSrc/components/chart.js similarity index 95% rename from src/components/chart.js rename to oldSrc/components/chart.js index a393ce1..3039c74 100644 --- a/src/components/chart.js +++ b/oldSrc/components/chart.js @@ -114,7 +114,10 @@ export const chart = { undefined; let labelAcronym = acronym ? acronym["sigla_vacinabr"] : (labelSplited[0].substr(0, 3) + "."); - if (label.includes(",")) { + if (store.state.content.form.granularity.toLowerCase() === 'municípios'){ + labelSplited = label.split(","); + lastLabel = " " + labelSplited[1] + ", " + labelSplited[2].substr(0, 6) + "."; + } else if (label.includes(",")) { labelSplited = label.split(","); lastLabel = labelSplited[1].split(" ")[0] + " " + labelSplited[1].split(" ")[2].substr(0, 3); } @@ -173,11 +176,15 @@ export const chart = { if (store.state.content.form.type !== "Doses aplicadas") { signal = "%"; for (const dataset of datasets) { - dataset.data = dataset.data.map(number => Number(number).toFixed(2)); + dataset.data = dataset.data.map((number, index) => { + return Number(number).toFixed(2) + }); } } else { for (const dataset of datasets) { - dataset.data = dataset.data.map(number => number ? Number(number.replace(/\./g, "")) : number); + dataset.data = dataset.data.map((number, index) => { + return number ? Number(number.replace(/\./g, "")) : number + }); } } @@ -303,6 +310,10 @@ export const chart = { stateTotal: true, }); + if (result && result.aborted) { + return; + } + if (!result || !result.data) { renderChart(); return {}; @@ -412,7 +423,12 @@ export const chart = { const dataChart = []; let i = 0; - for(let [key, value] of chartResultEntries) { + + for (let [key, value] of chartResultEntries) { + if (i > 99) { + store.commit('message/INFO', "Essa filtragem excedeu o máximo de 30 linhas, apenas 30 linhas serão exibidas") + break; + } const color = colors[i % colors.length]; dataChart.push({ label: key, diff --git a/oldSrc/components/config.js b/oldSrc/components/config.js new file mode 100644 index 0000000..ceb0099 --- /dev/null +++ b/oldSrc/components/config.js @@ -0,0 +1,59 @@ +import { NConfigProvider, ptBR } from "naive-ui"; + +export const config = { + components: { + NConfigProvider + }, + setup () { + const lightThemeOverrides = { + common: { + primaryColor: "#e96f5f", + primaryColorHover: "#e96f5f", + primaryColorPressed: "#e96f5f", + fontSizeMedium: ".95rem", + }, + Slider: { + indicatorColor: "#e96f5f" + }, + Pagination: { + itemBorderRadius: "50%" + }, + Button: { + fontSizeMedium: ".95rem", + }, + Tabs: { + tabFontSizeMedium: ".95rem", + }, + DataTable: { + fontSizeMedium: ".95rem", + thColorHover: "#e96f5f", + thColor: "#ececec", + tdColorStriped: "#ececec", + thFontWeight: "500", + thIconColor: "#e96f5f", + }, + Select: { + peers: { + InternalSelectMenu: { + clearTransition: null, + fadeTransition: null, + slideUpTransition: null, + } + } + } + }; + return { + // Config-provider setup + ptBR: ptBR, + lightThemeOverrides, + } + }, + template: ` + + + + `, +} diff --git a/src/components/filter-suggestion.js b/oldSrc/components/filter-suggestion.js similarity index 100% rename from src/components/filter-suggestion.js rename to oldSrc/components/filter-suggestion.js diff --git a/src/components/main-card.js b/oldSrc/components/main-card.js similarity index 56% rename from src/components/main-card.js rename to oldSrc/components/main-card.js index 50d9e04..86b2115 100644 --- a/src/components/main-card.js +++ b/oldSrc/components/main-card.js @@ -1,5 +1,5 @@ import { NCard, NSkeleton, useMessage, NModal, NButton, NSpin } from "naive-ui"; -import { ref, computed, onBeforeMount, watch } from "vue/dist/vue.esm-bundler"; +import { ref, computed, onMounted, watch } from "vue/dist/vue.esm-bundler"; import { chart as Chart } from "./chart"; import { map as Map } from "./map/map"; import { table as Table } from "./table"; @@ -83,121 +83,7 @@ export const mainCard = { mapTooltip.value = tooltip; }; - const URLquery = { ...route.query }; - const removeQueryFromRouter = (key) => { - delete URLquery[key]; - message.warning('URL contém valor inválido para filtragem') - router.replace({ query: URLquery }); - } - - const setStateFromUrl = () => { - const formState = store.state.content.form - const routeArgs = { ...route.query }; - const routerResult = {}; - const routerResultTabs = {}; - - if (!Object.keys(routeArgs).length) { - return; - } - - for (const [key, value] of Object.entries(routeArgs)) { - if (key === "sickImmunizer") { - if (value.includes(",")) { - const values = value.split(",") - const sicks = formState["sicks"].map(el => el.value) - const immunizers = formState["immunizers"].map(el => el.value) - if ( - values.every(val => sicks.includes(val)) || - values.every(val => immunizers.includes(val)) - ) { - routerResult[key] = values; - } else { - removeQueryFromRouter(key); - } - } else if ( - formState["sicks"].some(el => el.value === value) || - formState["immunizers"].some(el => el.value === value) - ) { - routerResult[key] = value; - } else { - removeQueryFromRouter(key); - } - } else if (key === "local") { - const values = value.split(",") - const locals = formState["locals"].map(el => el.value) - if (values.every(val => locals.includes(val))) { - routerResult[key] = values; - } else { - removeQueryFromRouter(key); - } - } else if (key === "granularity") { - formState["granularities"].some(el => el.value === value) ? - routerResult[key] = value : removeQueryFromRouter(key); - } else if (key === "dose") { - formState["doses"].some(el => el.value === value) ? - routerResult[key] = value : removeQueryFromRouter(key); - } else if (key === "type") { - formState["types"].some(el => el.value === value) ? - routerResult[key] = value : removeQueryFromRouter(key); - } else if (key === "tab") { - ["map", "chart", "table"].some(el => el === value) ? - routerResultTabs[key] = value : removeQueryFromRouter(key); - } else if (key === "tabBy") { - ["immunizers", "sicks"].some(el => el === value) ? - routerResultTabs[key] = value : removeQueryFromRouter(key); - } else if (["periodStart", "periodEnd"].includes(key)) { - const resultValue = Number(value) - formState["years"].some(el => el.value === resultValue) ? - routerResult[key] = resultValue : removeQueryFromRouter(key); - } else if (key === "period") { - routerResult[key] = Number(value); - } else if (value.includes(",")) { - routerResult[key] = value.split(","); - } else { - routerResult[key] = value ?? null; - } - } - - store.commit("content/UPDATE_FROM_URL", { - tab: routerResultTabs?.tab ? routerResultTabs.tab : "map", - tabBy: routerResultTabs?.tabBy ? routerResultTabs.tabBy : "sicks", - form: { ...routerResult }, - }); - }; - - const setUrlFromState = () => { - const routeArgs = { ...route.query }; - let stateResult = formatToApi({ - form: { ...store.state.content.form }, - tab: store.state.content.tab !== "map" ? store.state.content.tab : undefined, - tabBy: store.state.content.tabBy !== "sicks" ? store.state.content.tabBy : undefined, - }); - if (Array.isArray(stateResult.sickImmunizer) && stateResult.sickImmunizer.length) { - stateResult.sickImmunizer = [...stateResult?.sickImmunizer].join(","); - } - if (Array.isArray(stateResult.local) && stateResult.local.length) { - stateResult.local = [...stateResult?.local].join(","); - } - - if (!JSON.stringify(routeArgs) == JSON.stringify(stateResult)) { - return; - } - - return router.replace({ query: stateResult }); - } - - watch(() => { - const form = store.state.content.form; - return [form.sickImmunizer, form.type, form.dose, form.local, - form.period, form.periodStart, form.periodEnd, - form.granularity, store.state.content.tab, store.state.content.tabBy] - }, - async () => { - setUrlFromState(); - } - ) - - onBeforeMount(async () => { + onMounted(async () => { getWindowWidth(); await store.dispatch("content/updateFormSelect"); setStateFromUrl(); diff --git a/src/components/map/map-range.js b/oldSrc/components/map/map-range.js similarity index 100% rename from src/components/map/map-range.js rename to oldSrc/components/map/map-range.js diff --git a/src/components/map/map.js b/oldSrc/components/map/map.js similarity index 90% rename from src/components/map/map.js rename to oldSrc/components/map/map.js index f31a403..19bcdc4 100644 --- a/src/components/map/map.js +++ b/oldSrc/components/map/map.js @@ -15,8 +15,11 @@ export const map = { const map = ref(null); const yearMapElement = ref(null); const mapChart = ref(null); + const timeOutId = ref(null); + const store = useStore(); const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); + const datasetStates = ref(null); const datasetCities = ref(null); const granularity = computed(() => store.state.content.form.granularity); @@ -95,6 +98,11 @@ export const map = { loading.value = true; const results = await store.dispatch("content/requestData"); + + if (results && results.aborted) { + return; + } + try { let mapSetup = { element: mapElement, @@ -137,10 +145,16 @@ export const map = { if (local+granularity !== currentLocal.value) { map.value = await queryMap(local); } + if (map.value.aborted) { + return; + } currentLocal.value = local + granularity; } else if (local+granularity !== currentLocal.value) { const mapElement = document.querySelector('#map'); map.value = await queryMap("BR"); + if (map.value.aborted) { + return; + } renderMap({ element: mapElement, map: map.value }); currentLocal.value = "BR" + granularity; } @@ -179,16 +193,28 @@ export const map = { } ) + watch( + () => store.state.content.tab, + async (tab) => { + if (tab & tab !== 'map') { + clearTimeout(timeOutId.value) + } + } + ) + onMounted(async () => { // Avoiding wrong map loading - setTimeout(async () => { + // TODO: Centralize tables contents requests to better requests code + timeOutId.value = setTimeout(async () => { + const tab = store.state.content.tab + const tabIsMap = !tab || tab === 'map'; // If map not setted by watcher const mapElement = document.querySelector('#map'); - if(mapElement && !mapElement.innerHTML) { + if (mapElement && !mapElement.innerHTML && tabIsMap) { await updateMap(store.state.content.form.local); await setMap(); } - }, 500); + }, 2000); }); return { diff --git a/src/components/map/year-slider.js b/oldSrc/components/map/year-slider.js similarity index 94% rename from src/components/map/year-slider.js rename to oldSrc/components/map/year-slider.js index 2878541..eebbdd2 100644 --- a/src/components/map/year-slider.js +++ b/oldSrc/components/map/year-slider.js @@ -20,6 +20,7 @@ export const yearSlider = { const showTooltip = ref(false); const mapPlaying = computed(computedVar({ store, mutation: "content/UPDATE_YEAR_SLIDER_ANIMATION", field: "yearSlideAnimation" })); const stopPlayMap = ref(false); + const setSliderValue = (period) => { const form = store.state.content.form; showSlider.value = form.periodStart && form.periodEnd ? true : false; @@ -33,18 +34,19 @@ export const yearSlider = { const min = computed(() => setSliderValue(store.state.content.form.periodStart)); const valueMandatoryLabels = ref(null); + const valueMandatory = computed(() => { const tabBy = store.state.content.tabBy; if (tabBy !== "immunizers") { return } - const sickImmunizer = store.state.content.form.sickImmunizer; - const dose = store.state.content.form.dose ? store.state.content.form.dose : "1ª dose"; - const mandatoryVaccineYears = store.state.content.mandatoryVaccineYears; + const sickImmunizer = form.value.sickImmunizer; + const dose = form.value.dose ? form.value.dose : "1ª dose"; + const mandatoryVaccineYears = mandatoryVaccineYears.value; if (mandatoryVaccineYears) { - const result = mandatoryVaccineYears.find(el => el[0] === sickImmunizer && + const result = mandatoryVaccineYears.find(/** @type{string[]} **/ el => el[0] === sickImmunizer && (el[1] === dose || el[1] === "Dose única" && dose === "1ª dose") ); if (result) { diff --git a/src/components/modal.js b/oldSrc/components/modal.js similarity index 100% rename from src/components/modal.js rename to oldSrc/components/modal.js diff --git a/src/components/modalGeneric.js b/oldSrc/components/modalGeneric.js similarity index 100% rename from src/components/modalGeneric.js rename to oldSrc/components/modalGeneric.js diff --git a/src/components/modalWithTabs.js b/oldSrc/components/modalWithTabs.js similarity index 100% rename from src/components/modalWithTabs.js rename to oldSrc/components/modalWithTabs.js diff --git a/src/components/sub-buttons.js b/oldSrc/components/sub-buttons.js similarity index 90% rename from src/components/sub-buttons.js rename to oldSrc/components/sub-buttons.js index ae3391c..b715b8d 100644 --- a/src/components/sub-buttons.js +++ b/oldSrc/components/sub-buttons.js @@ -1,5 +1,5 @@ import { ref, computed } from "vue/dist/vue.esm-bundler"; -import { NButton, NIcon, NCard, NScrollbar, NTabs, NTabPane } from "naive-ui"; +import { NButton, NIcon, NCard, NScrollbar, NTabs, NTabPane, NSpin } from "naive-ui"; import { biBook, biListUl, biDownload, biShareFill, biFiletypeCsv, biGraphUp } from "../icons.js"; import { formatToTable, formatDatePtBr } from "../utils.js"; import { useStore } from "vuex"; @@ -37,6 +37,10 @@ export const subButtons = { const showModalVac = ref(false); const legend = ref(computed(() => store.state.content.legend)); const csvAllDataLink = ref(computed(() => store.state.content.csvAllDataLink)); + const csvRowsExceeded = ref(computed(() => store.state.content.csvRowsExceeded)); + const maxCsvExportRows = ref(computed(() => store.state.content.maxCsvExportRows)); + const loadingDownload = ref(false) + const formPopulated = computed(() => store.getters["content/selectsPopulated"]) const aboutVaccines = computed(() => { const text = store.state.content.aboutVaccines; @@ -62,6 +66,11 @@ export const subButtons = { }) const downloadSvg = () => { + if (!formPopulated.value) { + store.commit('message/ERROR', "Preencha os seletores para gerar mapa"); + return; + } + // GA Event if (window.gtag) { window.gtag('event', 'file_download', { @@ -91,6 +100,12 @@ export const subButtons = { 'file_name': 'mapa.png' }); } + + if (!formPopulated.value) { + store.commit('message/ERROR', "Preencha os seletores para gerar mapa"); + return; + } + const svgElement = document.querySelector("#canvas>svg"); const svgContent = new XMLSerializer().serializeToString(svgElement); @@ -135,6 +150,7 @@ export const subButtons = { } const downloadCsv = async () => { + loadingDownload.value = true; const periodStart = store.state.content.form.periodStart; const periodEnd = store.state.content.form.periodEnd; let years = []; @@ -145,10 +161,18 @@ export const subButtons = { } } - const currentResult = await store.dispatch("content/requestData", { detail: true }); + const currentResult = await store.dispatch("content/requestData", { detail: true, csv: true }); + + if (currentResult && currentResult.aborted) { + return; + } + if (currentResult && currentResult.error) { + loadingDownload.value = false; + } if (!currentResult) { store.commit('message/ERROR', "Preencha os seletores para gerar csv"); + loadingDownload.value = false; return; } // GA Event @@ -163,15 +187,17 @@ export const subButtons = { const tableData = formatToTable(currentResult.data, currentResult.localNames, currentResult.metadata); const header = tableData.header.map(x => Object.values(x)[0]) - header[header.findIndex(head => head === "Valor")] = currentResult.metadata.type + const type = store.state.content.form.type + header[header.findIndex(head => head === "Valor")] = type const rows = tableData.rows.map(x => Object.values(x)) - if (currentResult.metadata.type == "Doses aplicadas") { + if (type == "Doses aplicadas") { const index = header.findIndex(column => column === 'Doses (qtd)') header.splice(index, 1) rows.forEach(row => row.splice(index, 1)) } const csvwriter = new CsvWriterGen(header, rows); csvwriter.anchorElement('tabela'); + loadingDownload.value = false; } const openInNewTab = () => { @@ -282,7 +308,10 @@ export const subButtons = { showModalVac, clickShowVac, modalGlossary, - formatDatePtBr + formatDatePtBr, + csvRowsExceeded, + maxCsvExportRows, + loadingDownload }; }, template: ` @@ -343,6 +372,7 @@ export const subButtons = {
Faça o download de conteúdos
@@ -420,7 +450,14 @@ export const subButtons = {

Os dados que estão sendo utilizados nesta interface

- +   Baixar diff --git a/oldSrc/components/sub-select.js b/oldSrc/components/sub-select.js new file mode 100755 index 0000000..43f42cd --- /dev/null +++ b/oldSrc/components/sub-select.js @@ -0,0 +1,628 @@ +import { ref, watch, computed, toRaw, onBeforeMount, onMounted, h, reactive, nextTick } from "vue/dist/vue.esm-bundler"; +import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon, NSpin, NSpace } from "naive-ui"; +import { useStore } from 'vuex'; +import { computedVar } from "../utils"; +import { biEraser } from "../icons.js"; + +export const subSelect = { + components: { + NSelect, + NFormItem, + NDatePicker, + NButton, + NIcon, + NSpin, + NSpace + }, + props: { + modal: { + default: false, + type: Boolean, + }, + }, + setup (props) { + const allCitiesValues = []; + const store = useStore(); + const tab = computed(() => store.state.content.tab); + const tabBy = computed(() => store.state.content.tabBy); + const cityTemp = ref(null); + const sickTemp = ref(null); + const localTemp = ref(null); + const citiesTemp = ref([]); + + const disableLocalSelect = computed(() => store.getters[`content/disableLocalSelect`]); + const sick = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sickImmunizer" })); + const sicks = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sicks" })); + const immunizers = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "immunizers" })); + const type = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "type" })); + const types = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "types" })); + const local = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "local" })); + const locals = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "locals" })) + const dose = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "dose" })); + const doses = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "doses" })) + const period = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "period" })); + const granularity = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "granularity" })); + const granularities = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "granularities" })); + const periodStart = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "periodStart" })); + const periodEnd = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "periodEnd" })) + const years = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "years" })) + const city = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "city" })) + const cities = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "cities" })) + + const selectRefsMap = reactive({}); + const resizeObserver = ref(null); + const isLoadingCities = ref(false); + const firstLoadCities = ref(true); + + const activeSelectKey = ref(null); + const formRef = ref(null); + + const showingLocalsOptions = ref(null); + const showingSicksOptions = ref(null); + + const updateDatePosition = () => { + const endDate = periodEnd.value + const startDate = periodStart.value + const tsEndDate = endDate + const tsStartDate = startDate + if (!tsStartDate || !tsEndDate) { + return + } + if (tsStartDate > tsEndDate) { + periodEnd.value = startDate + periodStart.value = endDate + } + } + + const selectAllLocals = (field) => { + const allOptions = toRaw(locals.value); + const selectLength = Array.isArray(localTemp.value) ? localTemp.value.length : null + if (selectLength == allOptions.length) { + localTemp.value = []; + handleShowUpdate(true, field); + return; + } + + localTemp.value = allOptions.map(option => option.value); + handleShowUpdate(true, field); + } + + const selectAllCities = (field, uncheckAll = false) => { + if (isLoadingCities.value) { + return; + } + isLoadingCities.value = true; + + // We use setTimeout to run this code after Vue render process + setTimeout(() => { + const allOptions = toRaw(citiesTemp.value); + const selectLength = Array.isArray(cityTemp.value) ? cityTemp.value.length : null + + if ((selectLength == allOptions.length) || uncheckAll) { + city.value = []; + cityTemp.value = []; + handleShowUpdate(true, field); + isLoadingCities.value = false; + return; + } + + city.value = allCitiesValues; + cityTemp.value = allCitiesValues; + + handleShowUpdate(true, field); + isLoadingCities.value = false; + }, 0); + } + + const handleLocalsUpdateShow = (show, field) => { + showingLocalsOptions.value = show; + if (!showingLocalsOptions.value && localTemp.value) { + local.value = localTemp.value; + } + handleShowUpdate(show, field); + }; + + const handleLocalsUpdateValue = (value) => { + localTemp.value = value; + if (!showingLocalsOptions.value && localTemp.value){ + local.value = localTemp.value; + } + // Close hover box options remover + // const nPopover = document.querySelector(".n-popover"); + // if (nPopover) { + // nPopover.innerHTML = ""; + // } + }; + + const handleSicksUpdateShow = (show, field) => { + showingSicksOptions.value = show; + + if (!showingSicksOptions.value && sickTemp.value && tab.value !== "map") { + sick.value = sickTemp.value; + } + handleShowUpdate(show, field); + }; + + const handleSicksUpdateValue = (value) => { + sickTemp.value = value; + if (!showingSicksOptions.value && sickTemp.value) { + sick.value = value; + } + // Close hover box options remover + // const nPopover = document.querySelector(".n-popover"); + // if (nPopover) { + // nPopover.innerHTML = ""; + // } + }; + + const eraseForm = () => { + store.commit("content/CLEAR_STATE"); + } + + const clear = (key) => { + if (key === "sickImmunizer") { + sickTemp.value = null; + sick.value = null; + } else if (key === "dose") { + dose.value = null; + } else if (key === "type") { + type.value = null; + } + } + + const styleWidth = props.modal ? "width: 400px;" : "width: 200px;"; + + watch( + () => props.modal, + () => { + const loc = store.state.content.form.local; + if (loc) { + citiesTemp.value = cities.value.filter(city => loc.includes(city.uf)); + } + }, + { deep: true, immediate: true } + ) + + watch( + () => store.state.content.form.local, + (loc) => { + localTemp.value = loc; + + isLoadingCities.value = true; + + setTimeout(async () => { + if (!loc.length) { + citiesTemp.value = cities.value; + cityTemp.value = []; + city.value = []; + } else { + const rawCities = toRaw(cities.value); + const locSet = new Set(loc); + + citiesTemp.value = rawCities.filter(city => locSet.has(city.uf)); + + if (city.value?.length) { + const citiesTempSet = new Set(citiesTemp.value.map(item => item.value)); + + const rawCityValue = toRaw(city.value); + + city.value = rawCityValue.filter(itemA => citiesTempSet.has(itemA)); + cityTemp.value = city.value; + } + + disableStateCitiesSelector(cityTemp.value); + } + + await showCitiesSelectUpdate(); + isLoadingCities.value = false; + }, 0); + } + ); + + watch( + () => store.state.content.form.cities, + (cities) => { + if (firstLoadCities.value) { + citiesTemp.value = cities; + firstLoadCities.value = false; + for (let i = 0; i < cities.length; i++) { + allCitiesValues.push(cities[i].value); + } + } + } + ) + + watch( + () => tab.value, + async () => { + disableStateCitiesSelector(cityTemp.value); + await showCitiesSelectUpdate(); + } + ); + + watch( + () => store.state.content.form.sickImmunizer, + (sic) => { + sickTemp.value = sic + } + ); + + watch( + () => granularity.value, + async () => { + await showCitiesSelectUpdate(); + } + ); + + onBeforeMount(() => { + sickTemp.value = store.state.content.form.sickImmunizer; + localTemp.value = store.state.content.form.local; + }); + + const handleShowUpdate = (show, key) => { + if (show) { + activeSelectKey.value = key; + } else if (activeSelectKey.value === key) { + activeSelectKey.value = null; + } + }; + + const updateDropdownPosition = () => { + const key = activeSelectKey.value; + + const selectedRef = selectRefsMap[key]; + if (key && selectedRef) { + const activeSelect = selectedRef; + activeSelect.blur(); + nextTick(() => { + activeSelect.handleTriggerClick(); + }); + } + }; + + const disableStateCitiesSelector = (value) => { + if (tab.value === "table") { + city.value = value; + if (cities.value.some(item => item.disabled === true)) { + cities.value.forEach(item => { + item.disabled = false; + item.disabledText = "" + }); + } + return; + } + + if (!value) { + return; + } + + const valueLength = value.length; + + const maxSelection = 30; + + if (valueLength <= maxSelection) { + city.value = value; + if (cities.value.some(item => item.disabled === true)) { + cities.value.forEach(item => { + item.disabled = false; + item.disabledText = "" + }); + } + if (valueLength === maxSelection) { + cities.value.forEach(item => { + if (!value.includes(item.codigo6)) { + item.disabled = true; + item.disabledText = "Limite de seleções atingido" + } + }); + } + } + + if (valueLength > maxSelection) { + city.value = value.slice(0, maxSelection); + cityTemp.value = city.value; + store.commit("message/INFO", "Valores de seletor de municípios foram atualizado para limites de gráfico"); + } + + } + + const handleCitiesUpdateValue = (value) => { + disableStateCitiesSelector(value); + + return; + } + + onMounted(() => { + if (formRef.value) { + resizeObserver.value = new ResizeObserver(updateDropdownPosition); + resizeObserver.value.observe(formRef.value.closest('.main')); + } + }); + const wait = (timeInMs) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, timeInMs); + }); + } + + const showCitiesSelect = ref(false); + + const showCitiesSelectUpdate = async () => { + await wait(100); + const granValue = granularity.value; + if ( + (granValue && granValue.toLowerCase() === 'municípios') && + tab.value !== 'map' + ) { + showCitiesSelect.value = true; + return; + } + showCitiesSelect.value = false; + } + + const removeAccents = (str) => { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + }; + + const customFilter = (pattern, option) => { + const optionLabel = option.label || ''; + const normalizedPattern = removeAccents(pattern).toLowerCase(); + const normalizedLabel = removeAccents(optionLabel).toLowerCase(); + + return normalizedLabel.includes(normalizedPattern); + }; + + return { + biEraser, + cities, + citiesTemp, + city, + cityTemp, + clear, + customFilter, + disableAll: computed(() => store.state.content.yearSlideAnimation), + disableLocalSelect, + dose, + doses, + eraseForm, + formRef, + granularities, + granularity, + handleCitiesUpdateValue, + handleLocalsUpdateShow, + handleLocalsUpdateValue, + handleShowUpdate, + handleSicksUpdateShow, + handleSicksUpdateValue, + immunizers, + isLoadingCities, + local, + localTemp, + locals, + period, + periodEnd, + periodStart, + selectAllCities, + selectAllLocals, + selectRefsMap, + sick, + sickTemp, + sicks, + styleWidth, + tab, + tabBy, + type, + types, + updateDatePosition, + years, + showCitiesSelect, + modalContentGlossary: computed(() => { + const text = store.state.content.about; + let result = ""; + // TODO: Links inside text should be clickable + for (let [key, val] of Object.entries(text)){ + let validUrl = null; + let valFomated = val.replace(/\n/gi, "

"); + try { + validUrl = new URL(val); + } + catch (e) { + //Do nothing + } + if (validUrl) { + valFomated = `Acessar arquivo` + } + result += `

${key}

${valFomated}

`; + } + return result; + }), + renderOption: ({ node, option }) => { + if (!option.disabled) { + return node; + } + return h(NTooltip, { + style: "", + delay: 500 + }, { + trigger: () => node, + default: () => option.disabledText + }) + }, + } + }, + template: ` +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+ `, +} diff --git a/src/components/table.js b/oldSrc/components/table.js similarity index 64% rename from src/components/table.js rename to oldSrc/components/table.js index b8c4db9..094fe99 100644 --- a/src/components/table.js +++ b/oldSrc/components/table.js @@ -1,4 +1,4 @@ -import { ref, onMounted, computed, watch } from "vue/dist/vue.esm-bundler"; +import { ref, onMounted, onBeforeUnomount, computed, watch } from "vue/dist/vue.esm-bundler"; import { NButton, NDataTable, NSelect, NEmpty } from "naive-ui"; import { computedVar, formatToTable } from "../utils"; import { useStore } from 'vuex'; @@ -17,17 +17,43 @@ export const table = { }, setup() { const store = useStore(); + const rows = ref([]); const columns = ref([]); + const page = ref(1); + const pageCount = ref(0); + const pageTotalItems = ref(10); + const sorter = ref(null); + const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); + const pagination = computed(() => ({ + page: page.value, + pageCount: pageCount.value, + pageSize: 10, + pageSlot: 7, + pageTotalItems: pageTotalItems.value, + simple: true, + prev: () => "🠐 anterior", + next: () => "seguinte 🠒", + } + )); + const setTableData = async () => { - const currentResult = await store.dispatch("content/requestData", { detail: true }); + const currentResult = await store.dispatch("content/requestData", { detail: true, page: page.value, sorter: sorter.value }); + + if (currentResult && currentResult.aborted) { + return; + } + if (!currentResult || !currentResult.data ) { rows.value = []; return; } + pageCount.value = Number(currentResult.metadata.pages.total_pages); + pageTotalItems.value = currentResult.metadata.pages.total_records; + const tableData = formatToTable(currentResult.data, currentResult.localNames, currentResult.metadata); columns.value = tableData.header; const dosesQtd = columns.value.findIndex(column => column.title === 'Doses (qtd)'); @@ -40,19 +66,21 @@ export const table = { columnValue.minWidth = "160px"; columnValue.title = currentResult.metadata.type; rows.value = tableData.rows; - - const arraySortColumns = currentResult.metadata.type == "Meta atingida" ? [4, 5] : [3, 4, 5]; - arraySortColumns.forEach(col => columns.value[col].sorter = sortNumericValue(columns.value[col])); } - const sortNumericValue = (column) => (a, b) => - parseFloat(a[column.key].replace(/[%,.]/g, "")) - parseFloat(b[column.key].replace(/[%,.]/g, "")); - - onMounted(async () => { + const updateTableContent = async () => { loading.value = true; await setTableData(); loading.value = false; - }); + } + + onMounted(async () => { + updateTableContent() + }) + + onBeforeUnomount(() => { + tableStore.resetState() + }) watch( () => { @@ -62,17 +90,32 @@ export const table = { async () => { // Avoid render before change tab if (Array.isArray(store.state.content.form.sickImmunizer)) { - loading.value = true; - await setTableData(); - loading.value = false; + page.value = 1 + updateTableContent() } } ); + const handlePageChange = async (newPage) => { + page.value = newPage + updateTableContent() + } + + const handleSorterChange = async (newSorter) => { // { columnKey: string; order: string } + sorter.value = newSorter; + if (!newSorter.order) { + sorter.value = null; + } + updateTableContent(); + } + return { columns, loading, rows, + pagination, + handlePageChange, + handleSorterChange, formPopulated: computed(() => store.getters["content/selectsPopulated"]) }; }, @@ -85,8 +128,11 @@ export const table = { :columns="columns" :data="rows" :bordered="false" - :pagination="{ pageSlot:7 }" + :pagination="pagination" + :remote="true" :scrollbar-props="{ trigger: 'none', xScrollable: true }" + @update:page="handlePageChange" + @update:sorter="handleSorterChange" />
fetchOptions[key] === undefined && delete fetchOptions[key]); + + const response = await fetch(url, fetchOptions); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } + + return response.text(); + + } catch (error) { + console.log(error) + if (error.name === 'AbortError') { + return { aborted: true }; + } + return error; + } + } + + async request(endPoint, signal = undefined) { + const args = [endPoint, "/wp-json/api/v1/"]; + if (signal) { + args.push(signal); + } + const result = await this.requestData(...args); + return result; + } + + async requestSettingApiEndPoint(endPoint, apiEndpoint, signal) { + console.log(endPoint, apiEndpoint) + const args = [endPoint, apiEndpoint]; + if (args) { + args.push(signal); + } + const result = await this.requestData(...args); + return result; + } +} + diff --git a/oldSrc/icons.js b/oldSrc/icons.js new file mode 100644 index 0000000..8bea6e9 --- /dev/null +++ b/oldSrc/icons.js @@ -0,0 +1,36 @@ +const svgTag = (path) => { + return ` + ${path} + `; +}; + +export const biBook = svgTag(``); + +export const biListUl = svgTag(``); + +export const biDownload = svgTag(``); + +export const biShareFill = svgTag(``); + +export const biFiletypeCsv = svgTag(``); + +export const biGraphUp = svgTag(``); + +export const biInfoCircle = svgTag(``); + +export const biConeStriped = svgTag(``); + + +export const biEraser = svgTag(``); + +export const biCaretDown = svgTag(``); + +export const biMap = svgTag(``); + +export const biTable = svgTag(``) diff --git a/oldSrc/main.js b/oldSrc/main.js new file mode 100644 index 0000000..b638620 --- /dev/null +++ b/oldSrc/main.js @@ -0,0 +1,233 @@ +import "./assets/css/style.css"; +import { createApp, computed, onBeforeMount } from "vue/dist/vue.esm-bundler"; +import logo from "./assets/images/logo-vacinabr.svg"; +import store from "./store/"; +import { config as Config } from "./components/config"; +import { mainCard as MainCard } from "./components/main-card"; +import +{ + NTabs, + NTabPane, + NTab, + NMessageProvider, + NButton, + NIcon, + NScrollbar, + NTooltip, + NSkeleton, + NEmpty +} from "naive-ui"; +import { useStore } from "vuex"; +import { computedVar } from "./utils"; +import router from "./router"; +import { modalWithTabs as ModalWithTabs } from "./components/modalWithTabs.js"; +import { modalGeneric as ModalGeneric } from "./components/modalGeneric.js"; +import { biMap, biGraphUp, biTable } from "./icons.js"; + +export default class MCT { + constructor({ api = "", baseAddress = "" }) { + this.api = api; + this.baseAddress = baseAddress; + this.render(); + } + + render() { + const self = this; + const App = { + components: + { + NTabs, + NTabPane, + NTab, + Config, + MainCard, + NMessageProvider, + NButton, + NIcon, + ModalGeneric, + ModalWithTabs, + NScrollbar, + NTooltip, + NSkeleton, + NEmpty + }, + setup() { + const store = useStore(); + const tab = computed(computedVar({ store, mutation: "content/UPDATE_TAB", field: "tab" })); + const tabBy = computed(computedVar({ store, mutation: "content/UPDATE_TABBY", field: "tabBy" })); + const disableMap = computed(() => store.state.content.disableMap); + const disableChart = computed(() => store.state.content.disableChart); + const genericModalTitle = computed(computedVar({ + store, + mutation: "content/UPDATE_GENERIC_MODAL_TITLE", + field: "genericModalTitle" + }) + ); + const genericModal = computed(computedVar({ + store, + mutation: "content/UPDATE_GENERIC_MODAL", + field: "genericModal" + }) + ); + const genericModalShow = computed(computedVar({ + store, + mutation: "content/UPDATE_GENERIC_MODAL_SHOW", + field: "genericModalShow" + }) + ); + const genericModalLoading = computed(computedVar({ + store, + mutation: "content/UPDATE_GENERIC_MODAL_LOADING", + field: "genericModalLoading" + }) + ); + + // external callbacks + self.genericModal = async (title, slug) => { + genericModalLoading.value = true; + genericModal.value = null; + genericModalShow.value = !genericModalShow.value; + genericModalTitle.value = title; + try { + await store.dispatch( + "content/requestPage", + ["UPDATE_GENERIC_MODAL", slug] + ); + } catch { + // Do Nothing + } + genericModalLoading.value = false; + } + + // Define extra button in filters + self.genericModalWithFilterButton = async (title, slug) => { + await store.dispatch( + "content/updateExtraFilterButton", + [title, slug] + ); + } + + // Define apiUrl in store state + onBeforeMount(async () => { + store.commit("content/SET_API", self.api); + await Promise.all( + [ + [ + ["UPDATE_DOSE_BLOCKS", "dose-blocks"], + ["UPDATE_GRANULARITY_BLOCKS","granularity-blocks"], + ["UPDATE_LINK_CSV", "link-csv"], + ["UPDATE_MANDATORY_VACCINATIONS_YEARS", "mandatory-vaccinations-years"], + ["UPDATE_LAST_UPDATE_DATE", "lastupdatedate"], + ["UPDATE_AUTO_FILTERS", "auto-filters"], + ["UPDATE_ACRONYMS", "acronyms"] + ].map(request => store.dispatch("content/requestJson", request)), + [ + ["UPDATE_ABOUT_VACCINES", "?slug=sobre-vacinas-vacinabr"], + ].map(request => store.dispatch("content/requestPage", request)), + ] + ); + }); + + const modalContent = computed(() => { + const text = store.state.content.genericModal; + if (!text || !text.length) { + return + } + const div = document.createElement("div"); + div.innerHTML = text[0].content.rendered; + + if (div.querySelector("table")) { + const result = [...div.querySelectorAll("table>tbody>tr")].map( + tr => { return { + header: tr.querySelectorAll("td")[0].innerHTML, + content: tr.querySelectorAll("td")[1].innerHTML + } + } + ) + return result; + } + + return text[0].content.rendered; + }) + + return { + tab, + tabBy, + api: self.api, + genericModalShow, + genericModalLoading, + genericModalTitle, + logo, + disableMap, + disableChart, + modalContent, + disableAll: computed(() => store.state.content.yearSlideAnimation), + biMap, + biGraphUp, + biTable, + bodyStyle: { + maxWidth: '900px' + }, + }; + }, + template: ` + +
+ +
+
+
+ + + + + +
+
+
+ + + + + Mapa + + + + Gráfico + + + + Tabela + + +
+
+
+
+ +
+ + +
+
+
+ `, + }; + + const app = createApp(App); + app.use(store); + app.use(router(self.baseAddress)); + app.mount("#app"); + } +} diff --git a/oldSrc/map-chart.js b/oldSrc/map-chart.js new file mode 100644 index 0000000..ec6fbab --- /dev/null +++ b/oldSrc/map-chart.js @@ -0,0 +1,541 @@ +import Abandono from "./assets/images/abandono.svg" +import Cobertura from "./assets/images/cobertura.svg" +import HomGeo from "./assets/images/hom_geo.svg" +import HomVac from "./assets/images/hom_vac.svg" +import Meta from "./assets/images/meta.svg" + +export class MapChart { + + constructor({ + element, + map, + datasetCities, + cities, + datasetStates, + states, + statesSelected, + tooltipAction, + type, + formPopulated + }) { + this.element = typeof element === "string" ? + element.querySelector(element) : element; + this.map = map; + this.datasetCities = datasetCities; + this.cities = cities; + this.datasetStates = datasetStates; + this.states = states; + this.statesSelected = statesSelected; + this.tooltipAction = tooltipAction; + this.type = type; + this.formPopulated = formPopulated; + + this.start(); + } + + start() { + const self = this; + + if (!self.element) { + return; + } + + if (self.datasetCities) { + self.render(); + self.loadMapState(); + return; + } + + self.render(); + self.loadMapNation(); + } + + update({ map, datasetCities, cities, datasetStates, states, statesSelected, type, formPopulated }) { + const self = this; + if (!self.element) { + return; + } + + self.map = map ?? self.map; + self.cities = cities ?? self.cities; + self.states = states ?? self.states; + self.statesSelected = statesSelected ?? self.statesSelected; + + self.datasetCities = datasetCities; + self.datasetStates = datasetStates; + self.type = type; + self.formPopulated = formPopulated; + + self.start(); + } + + applyMap(map) { + const self = this; + + const svgContainer = self.element.querySelector("#canvas"); + svgContainer.innerHTML = map ?? ""; + for (const path of svgContainer.querySelectorAll('path')) { + path.style.stroke = "white"; + path.setAttribute("stroke-width", "1px"); + path.setAttribute("vector-effect", "non-scaling-stroke"); + } + + const svgElement = svgContainer.querySelector("svg"); + if (svgElement) { + svgElement.style.maxWidth = "100%"; + svgElement.style.height = "100%"; + svgElement.style.margin = "auto"; + } + } + + getData(contentData, elementId) { + if (!contentData) { + return []; + } + const index = contentData[0].indexOf("geom_id") != -1 ? contentData[0].indexOf("geom_id") : contentData[0].indexOf("id"); + const indexName = contentData[0].indexOf("name"); + const indexAcronym = contentData[0].indexOf("acronym"); + + return [ index, indexName, indexAcronym, contentData.find(el => el[index] === elementId) ]; + } + + setData( + { + datasetStates, + contentData, + type + } = {} + ) { + const self = this; + + // Querying map country states setting eventListener + for (const element of self.element.querySelectorAll('#canvas path, #canvas g')) { + let elementId = element.id; + + if (elementId.length > 6) { // granularity Municipios + elementId = element.id.substring(0, elementId.length - 1); + } + + let [ index, indexName, indexAcronym, currentElement ] = self.getData(contentData, elementId); + + let content; + if (currentElement) { + content = currentElement; + } + + if (!content || !content[indexName]) { + continue; + } + + let dataset = { data: { value: "---" }, color: "#D3D3D3" }; + let datasetValuesFound = []; + + if (content.id) { + // Get by id + datasetValuesFound = self.datasetValues.find(ds => (ds.name == content[indexName]) && (ds.id == content[indexName])); + } else { + // Get by name + datasetValuesFound = self.datasetValues.find(ds => (ds.name == content[indexName]) && (ds.name == content[indexName])); + } + + if (datasetValuesFound) { + dataset = datasetValuesFound; + } + + const result = dataset.data; + const resultColor = dataset.color; + const tooltip = self.element.querySelector(".mct-tooltip") + + element.addEventListener("mousemove", (event) => { + self.tooltipPosition(event, tooltip); + }); + element.addEventListener("mouseover", (event) => { + event.target.style.strokeWidth = "2px"; + let tooltipExtra = ""; + if (result && result.population) { + tooltipExtra = ` + População alvo +
${result.population.toLocaleString('pt-BR')}
+ `; + + if (type !== "Doses aplicadas" && result.doses) { + tooltipExtra += ` + Doses aplicadas +
${result.doses.toLocaleString('pt-BR')}
+ `; + } + } + let value = result.value; + if (type === "Meta atingida") { + if(result.value !== "---") { + value = parseInt(result.value) === 0 ? "Não" : "Sim"; + } + } + tooltip.innerHTML = ` +
+
${content[indexName]}
+
${value}
+ ${tooltipExtra} +
`; + tooltip.style.display = "block"; + tooltip.style.backgroundColor = result.value.includes("---") ? "grey" : "var(--primary-color)"; + self.tooltipPosition(event, tooltip); + self.runTooltipAction(true, content[indexName], content[index]); + }); + element.addEventListener("mouseleave", (event) => { + if (event.target.tagName === "g") { + [...event.target.querySelectorAll("path")].forEach(path => path.style.strokeWidth = "1px"); + } else { + element.style.strokeWidth = "1px"; + } + + element.style.fill = resultColor; + element.style.stroke = "white"; + tooltip.style.display = "none"; + self.runTooltipAction(false, content[indexName], content[index]); + }); + + element.style.fill = resultColor; + }; + + + // dataResult is udefined if nothing is comming from API and selects is setted + if (self.formPopulated && !datasetStates.length) { + self.element.querySelector(".empty-message span").innerHTML = "Não existem dados para os filtros selecionados"; + } else if (!self.formPopulated && !datasetStates.length) { + self.element.querySelector(".empty-message span").innerHTML = + "Selecione os filtros desejados para iniciar a visualização dos dados"; + } else { + self.element.querySelector(".empty-message").style.display = "none"; + } + } + + runTooltipAction(opened, name, id) { + if (!this.tooltipAction) { + return; + } + this.tooltipAction(opened, name, id); + } + + tooltipPosition(event, tooltip) { + tooltip.style.left = (event.clientX + 20)+ "px"; + tooltip.style.top = (event.clientY + 20) + "px"; + } + + findElement(arr, name) { + for (let i=0; i < arr.length; i++) { + const object = arr[i]; + const labelLowerCase = object.label.toLowerCase(); + + if(!name) { + continue; + } + + const nameAcronymLowerCase = name.acronym ? name.acronym.toLowerCase() : ""; + const nameNameLowerCase = name.name ? name.name.toLowerCase() : ""; + + const labelWithoutSpaces = labelLowerCase.replaceAll(" ", ""); + + if ( + labelLowerCase == nameAcronymLowerCase || + labelWithoutSpaces == nameNameLowerCase.replaceAll(" ", "") || + labelLowerCase == nameNameLowerCase || + labelWithoutSpaces == nameNameLowerCase.replaceAll(" ", "") + ) { + return object; + } + } + + return; + } + + getPercentage(maxVal, minVal, val) { + return ((val - minVal) / (maxVal - minVal)) * 100; + } + + getMaxAndMinValues(dataset) { + if (Object.values(dataset)[0].value.includes("%")) { + return; + } + + const values = Object.values(dataset).map((val) => val.value.replace(/[,.]/g, "")); + const maxVal = Math.max(...values); + const minVal = Math.min(...values); + return { maxVal, minVal }; + } + + getMaxColorVal() { + const self = this; + if (self.type === "Cobertura") { + return 120; + } + + return 100; + } + + + loadMapState() { + const self = this; + let result = []; + + if (self.datasetCities) { + const resultValues = self.getMaxAndMinValues(self.datasetCities); + result = + Object.entries( + self.datasetCities + ).map(([key, val]) => + { + let color = resultValues ? self.getPercentage( + resultValues.maxVal, + resultValues.minVal, + val.value.replace(/[,.]/g, "") + ) : parseFloat(val.value); + + let [ index, indexName, indexAcronym, currentElement ] = self.getData(self.cities, key); + + if(!currentElement) { + return; + } + + const name = currentElement[indexName]; + const label = currentElement[indexAcronym]; + + const contentData = { + label: label, + data: val, + name, + color: self.getColor(color, self.getMaxColorVal(), self.type), + } + const id = currentElement.id; + if (id) { + contentData["id"] = id + } + + return contentData + } + ); + } + + self.datasetValues = result; + self.applyMap(self.map); + + self.setData({ + datasetStates: result, + contentData: self.cities, + type: self.type + }) + } + + loadMapNation() { + const self = this; + let result = []; + + if (self.datasetStates) { + const resultValues = self.getMaxAndMinValues(self.datasetStates); + result = + Object.entries( + self.datasetStates + ).map(([key, val]) => + { + let color = resultValues ? self.getPercentage( + resultValues.maxVal, resultValues.minVal, val.value.replace(/[,.]/g, "") + ) : parseFloat(val.value); + + let [ index, indexName, indexAcronym, currentElement ] = self.getData(self.states, key); + + if(!currentElement) { + return; + } + + const name = currentElement[indexName]; + const label = currentElement[indexAcronym]; + + const contentData = { + label: label, + data: val, + name, + color: self.getColor(color, self.getMaxColorVal(), self.type), + } + const id = currentElement.id; + if (id) { + contentData["id"] = id + } + + return contentData + } + ).filter(content => { + if (content) { + return self.statesSelected.includes(content.label); + } + }); + } + + self.datasetValues = result; + + self.applyMap(self.map); + + self.setData({ + datasetStates: result, + contentData: self.states, + type: self.type + }) + } + + getColor(percentage, maxVal = 100, type, reverse = false) { + const cPalette0 = [ + "rgb(0, 69, 124)", + "rgb(0, 92, 161)", + "rgb(50, 161, 230)", + "rgb(246, 194, 188)", + "rgb(207, 84, 67)", + "rgb(105, 42, 34)" + ]; + + if (type === "Abandono") { + if (percentage <= -5) { + return cPalette0[0]; + } else if (percentage > -5 && percentage <= 0) { + return cPalette0[1]; + } else if (percentage > 0 && percentage <= 5) { + return cPalette0[2]; + } else if (percentage > 5 && percentage <= 10) { + return cPalette0[3]; + } else if (percentage > 10 && percentage <= 50) { + return cPalette0[4]; + } else { // percentage > 50 + return cPalette0[5]; + } + } else if (type === "Cobertura") { + if (percentage <= 50) { + return cPalette0[5]; + } else if (percentage > 50 && percentage <= 80) { + return cPalette0[4]; + } else if (percentage > 80 && percentage <= 95) { + return cPalette0[3]; + } else if (percentage > 95 && percentage <= 100) { + return cPalette0[2]; + } else if (percentage > 100 && percentage <= 120) { + return cPalette0[1]; + } else { // percentage > 120 + return cPalette0[0]; + } + } else if (type === "Homogeneidade geográfica") { + if (percentage <= 20) { + return cPalette0[5]; + } else if (percentage > 20 && percentage <= 50) { + return cPalette0[4]; + } else if (percentage > 50 && percentage <= 70) { + return cPalette0[3]; + } else if (percentage > 70 && percentage <= 95) { + return cPalette0[2]; + } else { // percentage > 95 + return cPalette0[0]; + } + } else if (type === "Homogeneidade entre vacinas") { + if (percentage <= 20) { + return cPalette0[0]; + } else if (percentage > 20 && percentage <= 40) { + return cPalette0[1]; + } else if (percentage > 40 && percentage <= 60) { + return cPalette0[2]; + } else if (percentage > 60 && percentage <= 80) { + return cPalette0[3]; + } else { // percentage > 80 + return cPalette0[4]; + } + } else if (type === "Meta atingida") { + if (percentage == 0) { + return cPalette0[4]; + } else { + return cPalette0[2]; + } + } + + const cPalette = [ + { r: 156, g: 63, b: 51 }, + { r: 207, g: 84, b: 67 }, + { r: 231, g: 94, b: 75 }, + { r: 234, g: 114, b: 98 }, + { r: 237, g: 134, b: 120 }, + { r: 243, g: 174, b: 165 }, + { r: 246, g: 194, b: 188 }, + { r: 160, g: 209, b: 242 }, + { r: 50, g: 161, b: 230 }, + { r: 1, g: 121, b: 218 }, + { r: 1, g: 111, b: 196 }, + { r: 0, g: 92, b: 161 } + ]; + + const colors = reverse ? cPalette.reverse() : cPalette; + + if (!percentage) { + percentage = 0 + } else if (percentage < 0) { + return reverse ? "rgb(0, 69, 124)" : "rgb(105, 42, 34)"; + } else if (percentage > maxVal) { + return reverse ? "rgb(0, 69, 124)" : "rgb(0, 69, 124)"; + } + + const index = Math.floor((percentage / maxVal) * (colors.length - 1)); + + const lowerColor = colors[index]; + const upperColor = index < (colors.length - 1) ? colors[index + 1] : colors[index]; + const factor = (percentage / maxVal) * (colors.length - 1) - index; + const interpolatedColor = { + r: Math.round(lowerColor.r + (upperColor.r - lowerColor.r) * factor), + g: Math.round(lowerColor.g + (upperColor.g - lowerColor.g) * factor), + b: Math.round(lowerColor.b + (upperColor.b - lowerColor.b) * factor) + }; + + return `rgb(${interpolatedColor.r}, ${interpolatedColor.g}, ${interpolatedColor.b})`; + } + + render () { + const self = this; + + let legend = ""; + let legendSvg = ""; + + if (self.type === "Abandono") { + legendSvg = Abandono; + } else if (self.type === "Cobertura") { + legendSvg = Cobertura; + } else if (self.type === "Homogeneidade geográfica") { + legendSvg = HomGeo; + } else if (self.type === "Homogeneidade entre vacinas") { + legendSvg = HomVac; + } else if (self.type === "Meta atingida") { + legendSvg = Meta; + } + + if (legendSvg) { + legend =`some file`; + } + + const emptyIcon = ` +
+ `; + + const map = ` +
+
+
+
+
+
+
+
+ ${legend} +
+
+
+ ${emptyIcon} +
+
+
+
+ `; + + self.element.innerHTML = map; + } +} diff --git a/oldSrc/router/index.js b/oldSrc/router/index.js new file mode 100644 index 0000000..12f4190 --- /dev/null +++ b/oldSrc/router/index.js @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { mainCard } from "../components/main-card"; + +const router = (baseAddress) => createRouter({ + history: createWebHistory(), + routes: [ + { + path: `${baseAddress}`, + name: "main", + component: mainCard, + }, + ], +}) + +export default router; diff --git a/src/store/index.js b/oldSrc/store/index.js similarity index 100% rename from src/store/index.js rename to oldSrc/store/index.js diff --git a/src/store/modules/content/actions.js b/oldSrc/store/modules/content/actions.js similarity index 71% rename from src/store/modules/content/actions.js rename to oldSrc/store/modules/content/actions.js index 6697f95..aba6382 100644 --- a/src/store/modules/content/actions.js +++ b/oldSrc/store/modules/content/actions.js @@ -1,12 +1,23 @@ import { DataFetcher } from "../../../data-fetcher"; +let currentController; +let currentControllerMap; + export default { async requestMap( { state }, { map } = {} ) { const api = new DataFetcher(state.apiUrl); - const result = await api.request(`map/${map}`); + + if (currentControllerMap) { + currentControllerMap.abort(); + } + + currentControllerMap = new AbortController(); + const signal = currentControllerMap.signal; + + const result = await api.request(`map/${map}`, signal); return result; }, async updateExtraFilterButton({ commit }, [ title, slug ]) { @@ -22,11 +33,13 @@ export default { return; } for (let [key, value] of Object.entries(options)) { - value.sort(); - payload[key] = value.map(x => { return { label: x, value: x } }); + if (key === 'cities') { + payload[key] = value.map(item => { return { ...item, label: `${item.uf} - ${item.nome}`, value: item.codigo6 } }); + } else { + value.sort(); + payload[key] = value.map(item => { return { label: item, value: item } }); + } } - // Select all in locals select - payload.locals.unshift({ label: "Todos", value: "Todos" }); commit("UPDATE_FORM_SELECTS", payload); }, async requestData( @@ -34,12 +47,22 @@ export default { { detail = false, stateNameAsCode = true, - stateTotal = false + stateTotal = false, + page = null, + sorter = null, + csv = false } = {} ) { + if (currentController) { + currentController.abort(); + } + const api = new DataFetcher(state.apiUrl); const form = state.form; + currentController = new AbortController(); + const signal = currentController.signal; + // Return if form field sickImmunizer is a multiple select and is empty if ( form.sickImmunizer && @@ -63,11 +86,13 @@ export default { // TODO: Add encodeURI to another fields const sI = Array.isArray(form.sickImmunizer) ? form.sickImmunizer.join("|") : form.sickImmunizer; const loc = Array.isArray(form.local) ? form.local.join("|") : form.local; - let request ="?tabBy=" + state.tabBy + "&type=" + form.type + "&granularity=" + form.granularity + + let request ="?tab=" + state.tab + "&tabBy=" + state.tabBy + "&type=" + form.type + "&granularity=" + form.granularity + "&sickImmunizer=" + encodeURIComponent(sI) + "&local=" + loc + "&dose=" + form.dose; request += form.periodStart ? "&periodStart=" + form.periodStart : ""; request += form.periodEnd ? "&periodEnd=" + form.periodEnd : ""; + request += page ? "&page=" + page : ""; + request += sorter ? "&sCol=" + sorter.columnKey + "&sOrder=" + sorter.order : ""; if (detail) { request += "&detail=true"; @@ -96,27 +121,34 @@ export default { } const [result, localNames] = await Promise.all([ - api.request(`data/${request}`), + api.request((csv ? `export-csv/` : `data/`) + request, signal), api.request(isStateData) ]); + if (result.aborted) { + return result; + } + if (result.error) { this.commit( "message/ERROR", "Não foi possível carregar os dados. Tente novamente mais tarde.", { root: true } ); - return { result: {}, localNames: {} } + return { result: {}, localNames: {}, error: result.error } } else if (!result || result.data && result.data.length <= 1) { commit("UPDATE_TITLES", null); + this.commit( "message/WARNING", "Não há dados disponíveis para os parâmetros selecionados.", { root: true } ); return { result: {}, localNames: {} } - } else { + } else if (result.metadata) { commit("UPDATE_TITLES", result.metadata.titles); + commit("UPDATE_CSV_ROWS_EXCEED", result.metadata.csv_rows_exceeded); + commit("UPDATE_CSV_MAX_EXPORT_ROWS", result.metadata.max_csv_export_rows); } if (form.type !== "Doses aplicadas") { @@ -149,7 +181,7 @@ export default { [ mutation, slug ] ) { const api = new DataFetcher(state.apiUrl); - const payload = await api.requestSettingApiEndPoint(slug, "/wp-json/wp/v2/pages"); + const payload = await api.requestSettingApiEndPoint(slug, "/wp-json/wp/v2/pages-"); commit(mutation, payload); return payload; }, @@ -158,7 +190,15 @@ export default { [ mutation, endpoint ] ) { const api = new DataFetcher(state.apiUrl); - const payload = await api.request(endpoint); - commit(mutation, payload); + try { + const payload = await api.request(endpoint); + commit(mutation, payload); + } catch(e){ + this.commit( + "message/ERROR", + `Não foi possível carregar os dados de '/${endpoint}'`, + { root: true } + ); + } }, } diff --git a/src/store/modules/content/getDefaultState.js b/oldSrc/store/modules/content/getDefaultState.js similarity index 89% rename from src/store/modules/content/getDefaultState.js rename to oldSrc/store/modules/content/getDefaultState.js index 0c548c7..e101e75 100644 --- a/src/store/modules/content/getDefaultState.js +++ b/oldSrc/store/modules/content/getDefaultState.js @@ -20,6 +20,8 @@ export const getDefaultState = () => { periodEnd: null, granularity: null, granularities: [], + city: null, + cities: [] }, yearSlideAnimation: false, autoFilters: null, @@ -37,6 +39,8 @@ export const getDefaultState = () => { granularityBlocks: null, disableMap: false, disableChart: false, - loading: false + loading: false, + maxCsvExportRows: 10000, + csvRowsExceeded: false } } diff --git a/src/store/modules/content/getters.js b/oldSrc/store/modules/content/getters.js similarity index 100% rename from src/store/modules/content/getters.js rename to oldSrc/store/modules/content/getters.js diff --git a/src/store/modules/content/index.js b/oldSrc/store/modules/content/index.js similarity index 100% rename from src/store/modules/content/index.js rename to oldSrc/store/modules/content/index.js diff --git a/src/store/modules/content/mutations.js b/oldSrc/store/modules/content/mutations.js similarity index 97% rename from src/store/modules/content/mutations.js rename to oldSrc/store/modules/content/mutations.js index 1afe351..e5f2e18 100644 --- a/src/store/modules/content/mutations.js +++ b/oldSrc/store/modules/content/mutations.js @@ -83,20 +83,15 @@ export default { state.form.granularity === "Municípios" && state.form.local.length > 1 ) { - if (["map", "chart"].includes(state.tab)) { + if (["map"].includes(state.tab)) { this.commit("content/UPDATE_TAB", { tab: "table" }); } state.disableMap = true; - state.disableChart = true; } else if ( state.form.granularity === "Municípios" || state.form.type === "Meta atingida" ) { - if (state.tab === "chart") { - this.commit("content/UPDATE_TAB", { tab: "table" }); - } state.disableMap = false; - state.disableChart = true; } else { state.disableMap = false; state.disableChart = false; @@ -123,6 +118,12 @@ export default { UPDATE_LAST_UPDATE_DATE(state, payload) { state.lastUpdateDate = payload; }, + UPDATE_CSV_ROWS_EXCEED(state, payload) { + state.csvRowsExceeded = payload; + }, + UPDATE_CSV_MAX_EXPORT_ROWS(state, payload) { + state.maxCsvExportRows = payload; + }, UPDATE_ACRONYMS(state, payload) { const result = []; const acronymsHeader = payload[0]; diff --git a/src/store/modules/message.module.js b/oldSrc/store/modules/message.module.js similarity index 100% rename from src/store/modules/message.module.js rename to oldSrc/store/modules/message.module.js diff --git a/oldSrc/utils.js b/oldSrc/utils.js new file mode 100644 index 0000000..0ce37ee --- /dev/null +++ b/oldSrc/utils.js @@ -0,0 +1,438 @@ +export const timestampToYear = (timestamp) => { + const date = new Date(timestamp); + const year = date.getFullYear(); + return year; +}; + +export const mapFields = (options) => { + const object = {}; + for (let i = 0; i < options.fields.length; i++) { + const field = [options.fields[i]]; + object[field] = { + get() { + return options.store.state[options.base][field]; + }, + set(value) { + options.store.commit(options.mutation, { [field]: value }); + } + }; + } + return object; +} + +export const computedVar = (options) => { + return { + get() { + if (options.base) { + return options.store.state.content[options.base][options.field]; + } + return options.store.state.content[options.field]; + }, + set(value) { + options.store.commit(options.mutation, { [options.field]: value }); + } + } +} + +export const formatDate = (timestamp) => { + const date = new Date(timestamp); + const year = date.getFullYear().toString().padStart(4, "0"); + return `${year}`; +}; + +export const convertDateToUtc = (dateString) => { + const utcdate = Date.UTC(dateString, 1, 1); + return utcdate; +}; + +export const formatToTable = (data, localNames, metadata) => { + let header = []; + for (const column of [...data[0], "código"]) { + // Setting width and behaveours of table column + let width = null; + let align = 0; + let minWidth = 200; + if (["ano", "valor", "população", "doses", "código"].includes(column)) { + align = "right"; + width = 120; + minWidth = null; + } + // Formating table title + let title = column.charAt(0).toUpperCase() + column.slice(1); + if (title === "Doenca") { + title = "Doença"; + } else if (title === "Doses") { + title = "Doses (qtd)"; + } + header.push( + { + title, + key: column, + sorter: ["código", "local"].includes(column) ? false : "default", + width, + titleAlign: "left", + align, + minWidth, + } + ) + } + + const index = localNames[0].indexOf("geom_id"); + const indexName = localNames[0].indexOf("name"); + const indexUF = localNames[0].indexOf("uf"); + const indexAcronym = localNames[0].indexOf("acronym"); + const rows = []; + + // Loop api return value + for (let i = 1; i < data.length; i++) { + const row = {}; + // Setting value as key: value in row object + for (let j = 0; j < data[i].length; j++) { + const key = header[j].key; + const value = data[i][j]; + if (key === "local") { + let localResult = + localNames.find(localName => localName[index] == value) || + localNames.find(localName => localName[indexAcronym] == value); + if (!localResult) { + continue + } + let name = localResult[indexName]; + let ufAcronymName = localResult[indexUF]; + if (ufAcronymName) { + name += " - " + ufAcronymName + } + row["código"] = value; + row[header[j].key] = name; + continue; + } else if (["população", "doses"].includes(key)) { + row[header[j].key] = value.toLocaleString("pt-BR"); + continue + } else if (metadata && metadata.type == "Meta atingida" && key == "valor") { + row[header[j].key] = parseInt(value) === 1 ? "Sim" : "Não"; + continue + } + row[header[j].key] = value; + } + // Pushing result row + rows.push(row) + } + + header.splice(1, 0, header.splice(6, 1)[0]); + + return { header, rows } +} + +export const convertArrayToObject = (inputArray) => { + const data = {}; + + // Loop through the input array starting from the second element + for (let i = 1; i < inputArray.length; i++) { + const [year, local, value, population, doses] = inputArray[i]; + if (!data[year]) { + data[year] = {}; + } + + data[year][local] = { value, population, doses }; + } + + return { header:inputArray[0], data }; +} + +export const createDebounce = () => { + let timer; + return (fn, wait = 300) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + if (typeof fn === "function") { + fn(); + } + }, wait); + }; +}; + +export const convertToLowerCaseExceptInParentheses = (input) => { + let result = ''; + let insideParentheses = false; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (char === '(') { + insideParentheses = true; + } else if (char === ')') { + insideParentheses = false; + } + if (insideParentheses) { + result += char; + } else { + result += char.toLowerCase(); + } + } + return result; +} + +export const sickImmunizerAsText = (form) => { + let sickImmunizer = null; + let multipleSickImmunizer = false; + if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length > 1) { + if(form.sickImmunizer.length > 2) { + sickImmunizer = convertToLowerCaseExceptInParentheses(form.sickImmunizer.slice(0, -1).join(', ')) + " e " + + convertToLowerCaseExceptInParentheses(form.sickImmunizer[form.sickImmunizer.length - 1]); + } else { + sickImmunizer = convertToLowerCaseExceptInParentheses(form.sickImmunizer.join(" e ")); + } + multipleSickImmunizer = true; + } else if (form.sickImmunizer && !Array.isArray(form.sickImmunizer)) { + sickImmunizer = convertToLowerCaseExceptInParentheses(form.sickImmunizer); + } else if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length) { + sickImmunizer = form.sickImmunizer.map(x => convertToLowerCaseExceptInParentheses(x.toLowerCase)); + } + + return [ sickImmunizer, multipleSickImmunizer ]; +} + +const resetOptions = (array, object = { disabled: false }) => { + for (let i=0; i < array.length; i++) { + array[i] = { + ...array[i], + ...object + }; + } +} + +export const disableOptionsByTypeOrDose = (state, formKey, formValue) => { + const disabledTextAbandono = "Essa informação não está disponível para 1ª dose"; + const disabledText1Dose = "Essa informação não está disponível para Abandono"; + if (formKey == "type" && formValue == "Abandono") { + const doses = state.form.doses; + const index = doses.indexOf(doses.find(el => el.label === "1ª dose")); + doses[index] = { ...doses[index], disabled: true, disabledText: disabledTextAbandono }; + if (state.form.dose == doses[index].label) { + state.form.dose = null; + } + } else if (formKey == "type" && formValue != "Abandono") { + const doses = state.form.doses; + const index = doses.indexOf(doses.find(el => el.label === "1ª dose")); + doses[index] = { ...doses[index], disabled: false, disabledText: disabledText1Dose } + } else if (formKey == "dose" && formValue == "1ª dose") { + const types = state.form.types; + const index = types.indexOf(types.find(el => el.label == "Abandono")); + types[index] = { ...types[index], disabled: true, disabledText: disabledTextAbandono }; + if (state.form.type == types[index].label) { + state.form.type = null; + } + } else if (formKey == "dose" && formValue != "1ª dose") { + const types = state.form.types; + const index = types.indexOf(types.find(el => el.label === "Abandono")); + types[index] = { ...types[index], disabled: false, disabledText: disabledText1Dose } + } else if (!formKey){ // CLEAR_STATE + const doses = state.form.doses; + const types = state.form.types; + doses[doses.indexOf(doses.find(el => el.label === "1ª dose"))].disabled = false + types[types.indexOf(types.find(el => el.label === "Abandono"))].disabled = false + } +} + +export const disableOptionsByTab = (state, payload) => { + if (payload && payload.tabBy == "immunizers") { + const types = state.form.types; + const index = types.indexOf(types.find(el => el.label == "Homogeneidade entre vacinas")); + types[index] = { ...types[index], disabled: false }; + } else { + const types = state.form.types; + const index = types.indexOf(types.find(el => el.label == "Homogeneidade entre vacinas")); + types[index] = { + ...types[index], + disabled: true, + disabledText: "Essa informação está disponível apenas no recorte por vacina" + }; + if (state.form.type == types[index].label) { + state.form.type = null; + } + } +} + +const blockHeaderName = (value) => { + const firstLetter = value[0]; + const lastLetter = value[value.length - 1]; + return (lastLetter === "o" ? "r" : "") + firstLetter; +} + +export const disableOptionsByDoseOrSick = (state, payload) => { + const sicksImmunizers = state.tabBy === "sicks" ? state.form['sicks'] : state.form['immunizers']; + const doses = state.form.doses; + + if(!payload) { // CLEAR_STATE + resetOptions(sicksImmunizers); + resetOptions(doses); + return; + } + + const blockedListHeader = [...state.doseBlocks[0]]; + const blockedListRows = [...state.doseBlocks]; + + // Removing header row from blockedListRows + blockedListRows.splice(0, 1); + + const selected = Object.entries(payload)[0]; + const selectedValue = selected[1]; + + const listIndexType = blockedListHeader.findIndex(el => el === "tipo"); + const type = state.tabBy === "immunizers" ? "vacina" : "doenca"; + const listIndexSickImmuno = blockedListHeader.findIndex(el => el === "doenca_imuno"); + if (selected[0] === "dose") { + if(!selectedValue) { // CLEAR_STATE + resetOptions(sicksImmunizers); + return; + } + const listIndex = blockedListHeader.findIndex(el => el === blockHeaderName(selectedValue)); + for (let i=0; i < sicksImmunizers.length; i++) { + const blockedListRow = blockedListRows.find(blr => + blr[listIndexSickImmuno] === sicksImmunizers[i].label && blr[listIndexType] && + blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type + ); + const disabled = blockedListRow && blockedListRow[listIndex] === false ? true : false; + sicksImmunizers[i] = { + ...sicksImmunizers[i], + disabled, + disabledText: "Não selecionável para essa dose." + }; + } + } else if (selected[0] === "sickImmunizer") { + if(!selectedValue) { // CLEAR_STATE + resetOptions(doses); + return; + } + let resultToBlock; + + if (Array.isArray(selectedValue)) { + resultToBlock = blockedListRows.filter(blr => + selectedValue.includes(blr[listIndexSickImmuno]) && blr[listIndexType] && + blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type + ); + } else { + resultToBlock = blockedListRows.find(blr => + { + return blr[listIndexSickImmuno] === selectedValue && blr[listIndexType] && + blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type + } + ); + } + + for (let i=0; i < doses.length; i++) { + let disabled; + if (resultToBlock && Array.isArray(selectedValue)) { + for(let result of resultToBlock) { + disabled = false; + if ( + result[ + blockedListHeader.findIndex(el => el === blockHeaderName(doses[i].label)) + ] === false + ) { + disabled = true; + break; + } + } + } else if (resultToBlock){ // Its not a multiple values select + disabled = resultToBlock[ + blockedListHeader.findIndex(el => el === blockHeaderName(doses[i].label)) + ] === true ? false : true; + } + + doses[i] = { + ...doses[i], + disabled, + disabledText: "Não selecionável para essa doença/vacina" + }; + } + } + + const doseFinded = doses.find(dose => dose.value === state.form.dose); + if (doseFinded && doseFinded.disabled) { + state.form.dose = null; + } +} + +export const formatDatePtBr = (date) => { + const inputDate = new Date(date + "T00:00:00"); + const options = { year: 'numeric', month: '2-digit', day: '2-digit' }; + const formatter = new Intl.DateTimeFormat('pt-BR', options); + return formatter.format(inputDate); +} + +export const disableOptionsByGranularityOrType = (state, payload) => { + const granularities = state.form.granularities; + const types = state.form.types; + + if (!payload) { // CLEAR_STATE + resetOptions(granularities); + resetOptions(types); + return; + } + + const selected = Object.entries(payload)[0]; + const selectedValue = selected[1]; + const blockedListHeader = [...state.granularityBlocks[0]]; + const blockedListRows = [...state.granularityBlocks]; + + // Removing header row from blockedListRows + blockedListRows.splice(0, 1); + const granularityColumnIndex = blockedListHeader.findIndex(el => el === "granularidade"); + const hv = "homogeneidade_entre_vacinas"; + const hg = "homogeneidade_geografica"; + const hvColumnIndex = blockedListHeader.findIndex(el => el === hv); + const hgColumnIndex = blockedListHeader.findIndex(el => el === hg); + + if (selected[0] === "granularity") { + const hvOpt = types.find(type => strToSnakeCaseNormalize(type.value) === hv); + const hgOpt = types.find(type => strToSnakeCaseNormalize(type.value) === hg); + if (!selectedValue) { + if (state.tabBy === "immunizers") { + hvOpt.disabled = false; + } + hgOpt.disabled = false; + return; + } + const elRow = blockedListRows.find(el => + el[granularityColumnIndex] === selectedValue.toLowerCase() + ); + + if (!elRow) { + return + } + + if (state.tabBy === "immunizers") { + hvOpt.disabled = !elRow[hvColumnIndex]; + hvOpt.disabledText = "Não selecionável para essa granularidade"; + } + hgOpt.disabled = !elRow[hgColumnIndex]; + hgOpt.disabledText = "Não selecionável para essa granularidade"; + + } else if (selected[0] === "type") { + if (!selectedValue) { + granularities.forEach(granularity => granularity.disabled = false) + return; + } + const listIndex = blockedListHeader.findIndex(el => el === strToSnakeCaseNormalize(selectedValue)); + if (listIndex < 1) { + granularities.forEach(granularity => granularity.disabled = false) + return + } + const resultToBlock = []; + blockedListRows.forEach(el => { + if (!el[listIndex]) { + resultToBlock.push(el[granularityColumnIndex]); + } + }); + granularities.forEach(granularity => { + if (resultToBlock.includes(granularity.value.toLowerCase())) { + granularity.disabled = true; + granularity.disabledText = "Não selecionável para essa tipo de dado"; + return; + } + granularity.disabled = false; + }) + } +} + +const strToSnakeCaseNormalize = (str) => + str.toLowerCase().replace(/\s+/g, '_').normalize('NFD').replace(/[\u0300-\u036f]/g, '') diff --git a/package-lock.json b/package-lock.json index c9a5f20..c5b77d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,35 +1,58 @@ { - "name": "dashboard", - "version": "1.5.1", + "name": "mct", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dashboard", - "version": "1.5.1", + "name": "mct", + "version": "1.6.1", "dependencies": { - "csvwritergen": "^0.0.4", - "vue-router": "^4.2.4" + "pinia": "^3.0.4" }, "devDependencies": { "chart.js": "^4.2.1", "chartjs-plugin-datalabels": "^2.2.0", "cors": "^2.8.5", + "csvwritergen": "^0.0.4", "dotenv": "^16.0.3", "express": "^4.18.2", - "naive-ui": "^2.34.4", + "naive-ui": "^2.43.1", "nodemon": "^2.0.22", "npm-run-all": "^4.1.5", + "prettier": "3.6.2", + "typescript": "^5.9.3", "vite": "^4.2.0", - "vue": "^3.2.47", - "vuex": "^4.1.0", - "vuex-map-fields": "^1.4.1" + "vue": "^3.5.24", + "vue-router": "^4.6.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.4.tgz", - "integrity": "sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -37,32 +60,35 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.15.tgz", - "integrity": "sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==", - "dev": true, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.14.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@css-render/plugin-bem": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.12.tgz", - "integrity": "sha512-Lq2jSOZn+wYQtsyaFj6QRz2EzAnd3iW5fZeHO1WSXQdVYwvwGX0ZiH3X2JQgtgYLT1yeGtrwrqJdNdMEUD2xTw==", + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", "dev": true, + "license": "MIT", "peerDependencies": { - "css-render": "~0.15.12" + "css-render": "~0.15.14" } }, "node_modules/@css-render/vue3-ssr": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.12.tgz", - "integrity": "sha512-AQLGhhaE0F+rwybRCkKUdzBdTEM/5PZBYy+fSYe1T9z9+yxMuV/k7ZRqa4M69X+EI1W8pa4kc9Iq2VjQkZx4rg==", + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", "dev": true, + "license": "MIT", "peerDependencies": { "vue": "^3.0.11" } @@ -71,7 +97,8 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@esbuild/android-arm": { "version": "0.17.15", @@ -425,11 +452,18 @@ "node": ">=12" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, "node_modules/@juggle/resize-observer": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/@kurkle/color": { "version": "0.3.2", @@ -438,137 +472,158 @@ "dev": true }, "node_modules/@types/katex": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.14.0.tgz", - "integrity": "sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==", - "dev": true + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.14.197", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz", - "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==", - "dev": true + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash-es": { - "version": "4.17.9", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.9.tgz", - "integrity": "sha512-ZTcmhiI3NNU7dEvWLZJkzG6ao49zOIjEgIE0RgV7wbPxU0f2xT3VSAHw2gmst8swH6V0YkLRGp4qPlX/6I90MQ==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/lodash": "*" } }, "node_modules/@vue/compiler-core": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.47.tgz", - "integrity": "sha512-p4D7FDnQb7+YJmO2iPEv0SQNeNzcbHdGByJDsT4lynf63AFkOTFN07HsiRSvjGo0QrxR/o3d0hUyNCUnBU2Tig==", - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.47", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", + "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.24", + "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map": "^0.6.1" + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.47.tgz", - "integrity": "sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", + "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-core": "3.5.24", + "@vue/shared": "3.5.24" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.47.tgz", - "integrity": "sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==", - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.47", - "@vue/compiler-dom": "3.2.47", - "@vue/compiler-ssr": "3.2.47", - "@vue/reactivity-transform": "3.2.47", - "@vue/shared": "3.2.47", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", + "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.24", + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24", "estree-walker": "^2.0.2", - "magic-string": "^0.25.7", - "postcss": "^8.1.10", - "source-map": "^0.6.1" + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.47.tgz", - "integrity": "sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", + "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-dom": "3.5.24", + "@vue/shared": "3.5.24" } }, "node_modules/@vue/devtools-api": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz", - "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==" + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "dev": true }, - "node_modules/@vue/reactivity": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.47.tgz", - "integrity": "sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==", + "node_modules/@vue/devtools-kit": { + "version": "7.7.8", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.8.tgz", + "integrity": "sha512-4Y8op+AoxOJhB9fpcEF6d5vcJXWKgHxC3B0ytUB8zz15KbP9g9WgVzral05xluxi2fOeAy6t140rdQ943GcLRQ==", + "license": "MIT", "dependencies": { - "@vue/shared": "3.2.47" + "@vue/devtools-shared": "^7.7.8", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" } }, - "node_modules/@vue/reactivity-transform": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.47.tgz", - "integrity": "sha512-m8lGXw8rdnPVVIdIFhf0LeQ/ixyHkH5plYuS83yop5n7ggVJU+z5v0zecwEnX7fa7HNLBhh2qngJJkxpwEEmYA==", + "node_modules/@vue/devtools-shared": { + "version": "7.7.8", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.8.tgz", + "integrity": "sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.47", - "@vue/shared": "3.2.47", - "estree-walker": "^2.0.2", - "magic-string": "^0.25.7" + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" } }, "node_modules/@vue/runtime-core": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.47.tgz", - "integrity": "sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", + "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/reactivity": "3.5.24", + "@vue/shared": "3.5.24" } }, "node_modules/@vue/runtime-dom": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.47.tgz", - "integrity": "sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", + "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "license": "MIT", "dependencies": { - "@vue/runtime-core": "3.2.47", - "@vue/shared": "3.2.47", - "csstype": "^2.6.8" + "@vue/reactivity": "3.5.24", + "@vue/runtime-core": "3.5.24", + "@vue/shared": "3.5.24", + "csstype": "^3.1.3" } }, - "node_modules/@vue/runtime-dom/node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" - }, "node_modules/@vue/server-renderer": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.47.tgz", - "integrity": "sha512-dN9gc1i8EvmP9RCzvneONXsKfBRgqFeFZLurmHOveL7oH6HiFXJw5OGu294n1nHc/HMgTy6LulU/tv5/A7f/LA==", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", + "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24" }, "peerDependencies": { - "vue": "3.2.47" + "vue": "3.5.24" } }, "node_modules/@vue/shared": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.47.tgz", - "integrity": "sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==" + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "license": "MIT" }, "node_modules/abbrev": { "version": "1.1.1", @@ -637,7 +692,8 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -666,6 +722,15 @@ "node": ">=8" } }, + "node_modules/birpc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", + "integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -853,6 +918,21 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -883,49 +963,54 @@ } }, "node_modules/css-render": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.12.tgz", - "integrity": "sha512-eWzS66patiGkTTik+ipO9qNGZ+uNuGyTmnz6/+EJIiFg8+3yZRpnMwgFo8YdXhQRsiePzehnusrxVvugNjXzbw==", + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "dev": true, + "license": "MIT", "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, - "node_modules/csstype": { + "node_modules/css-render/node_modules/csstype": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" }, "node_modules/csvwritergen": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/csvwritergen/-/csvwritergen-0.0.4.tgz", - "integrity": "sha512-uRVvkCqn5pPOD1lU7jPRTk/+0uajX4F0Mb/AZ9UJq/l6Iq9Q3YgRlhLALyNp2C537YEihFNxOgK4ns1hpph8pQ==" + "integrity": "sha512-uRVvkCqn5pPOD1lU7jPRTk/+0uajX4F0Mb/AZ9UJq/l6Iq9Q3YgRlhLALyNp2C537YEihFNxOgK4ns1hpph8pQ==", + "dev": true }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/date-fns-tz": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.8.tgz", - "integrity": "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", "dev": true, + "license": "MIT", "peerDependencies": { - "date-fns": ">=2.0.0" + "date-fns": "^3.0.0 || ^4.0.0" } }, "node_modules/debug": { @@ -996,6 +1081,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -1139,7 +1236,8 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", @@ -1154,7 +1252,8 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/express": { "version": "4.18.2", @@ -1459,14 +1558,21 @@ } }, "node_modules/highlight.js": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", - "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -1781,6 +1887,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1812,20 +1930,23 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", "dependencies": { - "sourcemap-codec": "^1.4.8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/media-typer": { @@ -1906,6 +2027,12 @@ "node": "*" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1913,44 +2040,47 @@ "dev": true }, "node_modules/naive-ui": { - "version": "2.34.4", - "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.34.4.tgz", - "integrity": "sha512-aPG8PDfhSzIzn/jSC9y3Jb3Pe2wHJ7F0cFV1EWlbImSrZECeUmoc+fIcOSWbizoztkKfaUAeKwYdMl09MKkj1g==", - "dev": true, - "dependencies": { - "@css-render/plugin-bem": "^0.15.10", - "@css-render/vue3-ssr": "^0.15.10", - "@types/katex": "^0.14.0", - "@types/lodash": "^4.14.181", - "@types/lodash-es": "^4.17.6", - "async-validator": "^4.0.7", - "css-render": "^0.15.10", - "date-fns": "^2.28.0", - "date-fns-tz": "^1.3.3", + "version": "2.43.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.43.1.tgz", + "integrity": "sha512-w52W0mOhdOGt4uucFSZmP0DI44PCsFyuxeLSs9aoUThfIuxms90MYjv46Qrr7xprjyJRw5RU6vYpCx4o9ind3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/katex": "^0.16.2", + "@types/lodash": "^4.14.198", + "@types/lodash-es": "^4.17.9", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "evtd": "^0.2.4", - "highlight.js": "^11.5.0", + "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", - "seemly": "^0.3.6", + "seemly": "^0.3.8", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", - "vueuc": "^0.4.51" + "vueuc": "^0.4.65" }, "peerDependencies": { "vue": "^3.0.0" } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2189,10 +2319,17 @@ "node": ">=4" } }, - "node_modules/picocolors": { + "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -2227,10 +2364,40 @@ "node": ">=4" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.8", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.8.tgz", + "integrity": "sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.8" + } + }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -2239,17 +2406,37 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2334,12 +2521,6 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true - }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", @@ -2374,6 +2555,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "3.20.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.20.2.tgz", @@ -2431,10 +2618,11 @@ "dev": true }, "node_modules/seemly": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.6.tgz", - "integrity": "sha512-lEV5VB8BUKTo/AfktXJcy+JeXns26ylbMkIUco8CYREsQijuz4mrXres2Q+vMLdwkuLxJdIPQ8IlCIxLYm71Yw==", - "dev": true + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "dev": true, + "license": "MIT" }, "node_modules/semver": { "version": "5.7.1", @@ -2561,28 +2749,15 @@ "semver": "bin/semver.js" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead" - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -2615,6 +2790,15 @@ "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", "dev": true }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2695,6 +2879,18 @@ "node": ">=4" } }, + "node_modules/superjson": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz", + "integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2756,7 +2952,8 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/type-is": { "version": "1.6.18", @@ -2785,6 +2982,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -2848,6 +3059,7 @@ "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", "dev": true, + "license": "MIT", "dependencies": { "evtd": "^0.2.2" }, @@ -2909,6 +3121,7 @@ "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", "dev": true, + "license": "MIT", "dependencies": { "evtd": "^0.2.2" }, @@ -2917,36 +3130,46 @@ } }, "node_modules/vue": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.47.tgz", - "integrity": "sha512-60188y/9Dc9WVrAZeUVSDxRQOZ+z+y5nO2ts9jWXSTkMvayiWxCWOWtBQoYjLeccfXkiiPZWAHcV+WTPhkqJHQ==", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", + "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", "dependencies": { - "@vue/compiler-dom": "3.2.47", - "@vue/compiler-sfc": "3.2.47", - "@vue/runtime-dom": "3.2.47", - "@vue/server-renderer": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-sfc": "3.5.24", + "@vue/runtime-dom": "3.5.24", + "@vue/server-renderer": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/vue-router": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.4.tgz", - "integrity": "sha512-9PISkmaCO02OzPVOMq2w82ilty6+xJmQrarYZDkjZBfl4RvYAlt4PKnEX21oW4KTtWfa9OuO/b3qk1Od3AEdCQ==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "dev": true, "dependencies": { - "@vue/devtools-api": "^6.5.0" + "@vue/devtools-api": "^6.6.4" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "vue": "^3.2.0" + "vue": "^3.5.0" } }, "node_modules/vueuc": { - "version": "0.4.51", - "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.51.tgz", - "integrity": "sha512-pLiMChM4f+W8czlIClGvGBYo656lc2Y0/mXFSCydcSmnCR1izlKPGMgiYBGjbY9FDkFG8a2HEVz7t0DNzBWbDw==", + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", "dev": true, + "license": "MIT", "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", @@ -2960,24 +3183,6 @@ "vue": "^3.0.11" } }, - "node_modules/vuex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", - "integrity": "sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==", - "dev": true, - "dependencies": { - "@vue/devtools-api": "^6.0.0-beta.11" - }, - "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/vuex-map-fields": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/vuex-map-fields/-/vuex-map-fields-1.4.1.tgz", - "integrity": "sha512-jvIcpvoIPqwvJCOfRkPU9Rj0EbjWuk7GlNC5LXU9mCXVGZph6bWGHZssnoUzpLMxJtXQEHoVyZkKf7YQV+/bnQ==", - "dev": true - }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index a5abb3d..8f2ad77 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,39 @@ { - "name": "dashboard", + "name": "mct", "private": true, - "version": "1.5.1", + "version": "1.7.0", "type": "module", - "main": "dist/dashboard.js", + "main": "dist/mct.js", "scripts": { "development": "run-p start dev", "dev": "vite --host", "start": "nodemon server.js", "build": "vite build", + "bundle": "run-p types build", "preview": "vite preview", "test:unit": "vitest --environment jsdom --root src/ run", - "test-watch:unit": "vitest --environment jsdom --root src/ watch" + "test-watch:unit": "vitest --environment jsdom --root src/ watch", + "types": "tsc", + "types-watch": "tsc --watch", + "prettier": "prettier src/ --write" }, "devDependencies": { "chart.js": "^4.2.1", "chartjs-plugin-datalabels": "^2.2.0", "cors": "^2.8.5", + "csvwritergen": "^0.0.4", "dotenv": "^16.0.3", "express": "^4.18.2", - "naive-ui": "^2.34.4", + "naive-ui": "^2.43.1", "nodemon": "^2.0.22", "npm-run-all": "^4.1.5", + "prettier": "3.6.2", + "typescript": "^5.9.3", "vite": "^4.2.0", - "vue": "^3.2.47", - "vuex": "^4.1.0", - "vuex-map-fields": "^1.4.1" + "vue": "^3.5.24", + "vue-router": "^4.6.3" }, "dependencies": { - "csvwritergen": "^0.0.4", - "vue-router": "^4.2.4" + "pinia": "^3.0.4" } } diff --git a/src/assets/css/base.css b/src/assets/css/base.css index 4d37a09..960282e 100644 --- a/src/assets/css/base.css +++ b/src/assets/css/base.css @@ -1,20 +1,19 @@ :root { - --body-color: rgba(0, 0, 0, 0.87); - --text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white; - --gray-color: #ececec; - --gray-color-light: #fafafa; - --primary-color: #e96f5f; - --label-color: #827e7e; - --embed-color: #fafafa; + --body-color: rgba(0, 0, 0, 0.87); + --text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white; + --gray-color: #ececec; + --gray-color-light: #fafafa; + --primary-color: #e96f5f; + --label-color: #827e7e; + --embed-color: #fafafa; - --padding-container: 200px; + --padding-container: 200px; } - -/* MediaQuery */ +/* Set padding for container elements on screens with a maximum width of 1368px */ @media (max-width: 1368px) { - :root { - --padding-container: 20px; - } + :root { + --padding-container: 20px; + } } diff --git a/src/assets/css/components/collapsable.css b/src/assets/css/components/collapsable.css index 989b476..8669163 100644 --- a/src/assets/css/components/collapsable.css +++ b/src/assets/css/components/collapsable.css @@ -1,23 +1,26 @@ .n-collapse-item.n-collapse-item--left-arrow-placement { - outline: 1px solid #e0e0e0; - border: none; - border-radius: 3px; - padding: 8px; - box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 4px; + outline: 1px solid #e0e0e0; + border: none; + border-radius: 3px; + padding: 8px; + box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 4px; } -.n-collapse .n-collapse-item .n-collapse-item__header .n-collapse-item__header-main { - font-weight: 500; +.n-collapse + .n-collapse-item + .n-collapse-item__header + .n-collapse-item__header-main { + font-weight: 500; } .n-collapse .n-collapse-item:not(:first-child) { - border-top: none !important; + border-top: none !important; } .n-collapse .n-collapse-item .n-collapse-item__header { - padding: 0px; + padding: 0px; } .n-collapse .n-collapse-item__header-extra .bi { - fill: var(--primary-color); + fill: var(--primary-color); } diff --git a/src/assets/css/components/container.css b/src/assets/css/components/container.css index 07c5e1d..63cf265 100644 --- a/src/assets/css/components/container.css +++ b/src/assets/css/components/container.css @@ -1,58 +1,58 @@ /* Components Styles */ .main { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .main-content { - position: relative; - max-width: 1368px; - margin: 0px auto; - padding: 0px 24px; + position: relative; + max-width: 1368px; + margin: 0px auto; + padding: 0px 24px; } .main-content--sub { - margin-bottom: 64px; + margin-bottom: 64px; } .container-elements { - display: flex; - justify-content: end; + display: flex; + justify-content: end; } .container-elements--table { - display: flex; - gap: 12px; - padding-bottom: 16px; + display: flex; + gap: 12px; + padding-bottom: 16px; } .container-elements--theme { - padding: 15px 65px; + padding: 15px 65px; } .container-elements__selects { - display: flex; - gap: 12px; + display: flex; + gap: 12px; } .container-input-card { - display: flex; - gap: 8px; - justify-content: end; + display: flex; + gap: 8px; + justify-content: end; } .element-hidden { - display: none !important; + display: none !important; } @media (max-width: 800px) { - .container-elements--table { - display: flex; - flex-direction: column; - gap: 8px; - } - .container-input-card { - flex-direction: column; - } + .container-elements--table { + display: flex; + flex-direction: column; + gap: 8px; + } + .container-input-card { + flex-direction: column; + } } diff --git a/src/assets/css/components/filter-suggestion.css b/src/assets/css/components/filter-suggestion.css index e1e8698..30a0ab7 100644 --- a/src/assets/css/components/filter-suggestion.css +++ b/src/assets/css/components/filter-suggestion.css @@ -1,65 +1,65 @@ .filter-suggestion { - height: 100%; - overflow-y: auto; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - flex-direction: column; - background-color: rgb(254, 254, 254); + height: 100%; + min-height: 520px; + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + background-color: rgb(254, 254, 254); } .filter-suggestion-center { - justify-content: center; + justify-content: center; } .filter-suggestion-title { - text-align: center; - font-size: 24px; - padding-bottom: 48px; - font-weight: 400; + text-align: center; + font-size: 24px; + padding-bottom: 48px; + font-weight: 400; } .filters-container { - gap: 8px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(600px, 1fr)) ; - grid-gap: 10px; + gap: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(600px, 1fr)); + grid-gap: 10px; } .filter-container-suggestion { - overflow: hidden; + overflow: hidden; } .filter-title { - height: 18px; + height: 18px; } .filter-text-suggestion { - text-align: initial; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + text-align: initial; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .filter-description { - font-size: 11px; - height: 14px; + font-size: 11px; + height: 14px; } /* MediaQuery */ @media (max-width: 1300px) { - .filters-container { - display: flex; - flex-direction: column; - } + .filters-container { + display: flex; + flex-direction: column; + } } @media (max-width: 768px) { - .filter-suggestion { - display: block; - } + .filter-suggestion { + display: block; + } } - diff --git a/src/assets/css/components/label.css b/src/assets/css/components/label.css index 25b8098..b9e8ae9 100644 --- a/src/assets/css/components/label.css +++ b/src/assets/css/components/label.css @@ -1,10 +1,10 @@ /* Label */ .n-form-item.n-form-item--top-labelled .n-form-item-label { - align-items: center; - padding: 0px; + align-items: center; + padding: 0px; } .n-form-item-label__text { - color: var(--label-color); - font-size: .65rem; + color: var(--label-color); + font-size: 0.65rem; } diff --git a/src/assets/css/components/main-content.css b/src/assets/css/components/main-content.css index 1f8d322..6c48f1f 100644 --- a/src/assets/css/components/main-content.css +++ b/src/assets/css/components/main-content.css @@ -1,13 +1,27 @@ .main-content { - margin-top: 16px; + margin-top: 16px; } .map-section { - min-height: 520px + min-height: 520px; } @media (max-width: 475px) { - .map-section { - min-height: 420px - } + .map-section { + min-height: 420px; + } +} + +.main-title { + margin: 0px; + padding: 0px; + font-weight: 700; + font-size: 1.5rem; +} + +.sub-title { + margin: 0px; + padding: 0px; + font-weight: 400; + font-size: 1.25rem; } diff --git a/src/assets/css/components/main-footer.css b/src/assets/css/components/main-footer.css index af228d7..185f978 100644 --- a/src/assets/css/components/main-footer.css +++ b/src/assets/css/components/main-footer.css @@ -1,75 +1,75 @@ .main-card-footer-container { - display: block; + display: block; } .main-card-footer { - display:flex; - justify-content: space-between; - align-items: center; - margin-top: 18px; - margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 18px; + margin-bottom: 12px; } .main-card-footer--mobile { - align-items: initial; - gap: 14px; + align-items: initial; + gap: 14px; } .main-card-footer-mobile { - display: none; + display: none; } .main-card-footer__legend { - color:gray; - font-size:14px; - font-weight: 400; + color: gray; + font-size: 14px; + font-weight: 400; } .main-card-footer__buttons { - display: flex; - gap: 8px; + display: flex; + gap: 8px; } .main-card-footer__buttons--mobile { - justify-content: space-between; + justify-content: space-between; } .main-card-footer-dates { - display: flex; - flex-direction: column; - gap: 4px + display: flex; + flex-direction: column; + gap: 4px; } /* MediaQuery */ @media (max-width: 1200px) { - .main-card-footer { - margin: 0px 0px 12px; - flex-direction: column; - gap: 8px; - } - .main-card-footer-container { - display: flex; - flex-direction: column; - } - .main-card-footer__buttons { - justify-items: start; - margin-bottom: 12px; - flex-wrap: wrap; - justify-content: center; - width: 100%; - gap: 12px; - } - .main-card-footer-mobile { - display: flex; - } - .main-card-footer-container-mobile { - display: block; - } - .main-card-footer-dates { - flex-direction: row; - flex-wrap: wrap; - justify-content: center; - gap: 4px 12px; - } + .main-card-footer { + margin: 0px 0px 12px; + flex-direction: column; + gap: 8px; + } + .main-card-footer-container { + display: flex; + flex-direction: column; + } + .main-card-footer__buttons { + justify-items: start; + margin-bottom: 12px; + flex-wrap: wrap; + justify-content: center; + width: 100%; + gap: 12px; + } + .main-card-footer-mobile { + display: flex; + } + .main-card-footer-container-mobile { + display: block; + } + .main-card-footer-dates { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + gap: 4px 12px; + } } diff --git a/src/assets/css/components/main-header.css b/src/assets/css/components/main-header.css index 678d57d..5541643 100644 --- a/src/assets/css/components/main-header.css +++ b/src/assets/css/components/main-header.css @@ -1,90 +1,90 @@ .main-header { - display: flex; - justify-content: center; - align-items: center; - gap: 12px; - padding-top: 6px; - padding-left: var(--padding-container); - padding-right: var(--padding-container); - background-color: var(--embed-color); - box-shadow: rgba(0, 0, 0, 0.35) 0px 2px 36px -28px inset; + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding-top: 6px; + padding-left: var(--padding-container); + padding-right: var(--padding-container); + background-color: var(--embed-color); + box-shadow: rgba(0, 0, 0, 0.35) 0px 2px 36px -28px inset; } .main-header-container { - display:flex; - gap: 32px; - overflow: auto; - max-width: 100%; - align-items: center; - height: 70px; - overflow: hidden; + display: flex; + gap: 32px; + overflow: auto; + max-width: 100%; + align-items: center; + height: 70px; + overflow: hidden; } .main-header__label { - color: #4a4a4a; - font-size: .9em; + color: #4a4a4a; + font-size: 0.9em; } .main-header-form { - display: flex; - align-items: center; - gap: 24px; + display: flex; + align-items: center; + gap: 24px; } .main-header-form .n-tabs-tab__label { - height: 20.5167px; + height: 20.5167px; } .custom-hr { - height: 48px; - border-left: 0px; - border-top: 0px; - opacity: 50%; - border-bottom: 1px solid; - border-right: 1px solid; + height: 48px; + border-left: 0px; + border-top: 0px; + opacity: 50%; + border-bottom: 1px solid; + border-right: 1px solid; } .filter-mobile-button { - display: flex; - justify-content: center; + display: flex; + justify-content: center; } .main-header__tab-label { - display: block; + display: block; } .main-header__tab-icon { - display: none; + display: none; } @media (max-width: 1368px) { - .main-header-container { - gap: 12px; - height: auto; - padding-bottom: 12px; - } - .main-header-form { - flex-direction: column; - gap: 6px; - } - .custom-hr { - height: 70px; - } - .main-header__tab-label { - display: none; - } - .main-header__tab-icon { - display: block; - } + .main-header-container { + gap: 12px; + height: auto; + padding-bottom: 12px; + } + .main-header-form { + flex-direction: column; + gap: 6px; + } + .custom-hr { + height: 70px; + } + .main-header__tab-label { + display: none; + } + .main-header__tab-icon { + display: block; + } } @media (max-width: 475px) { - .main-header-container { - flex-direction: column; - gap: 6px; - } - .custom-hr { - height: 0px; - width: 70px; - } + .main-header-container { + flex-direction: column; + gap: 6px; + } + .custom-hr { + height: 0px; + width: 70px; + } } diff --git a/src/assets/css/components/map.css b/src/assets/css/components/map.css new file mode 100644 index 0000000..502b791 --- /dev/null +++ b/src/assets/css/components/map.css @@ -0,0 +1,8 @@ +.map-element-container { + display: flex; + gap: 12px; +} + +.map-content { + width: 100%; +} diff --git a/src/assets/css/components/modal.css b/src/assets/css/components/modal.css index f87d485..819218f 100644 --- a/src/assets/css/components/modal.css +++ b/src/assets/css/components/modal.css @@ -1,40 +1,40 @@ .custom-card-body.n-scrollbar { - padding: 0px; - font-size: 1rem; + padding: 0px; + font-size: 1rem; } .custom-card.n-card .n-scrollbar-content { - padding: 12px 32px; + padding: 12px 32px; } .custom-card.n-card > .n-card__content { - padding: 0px; + padding: 0px; } .custom-card .n-card-header { - border-bottom: 1px solid #eee; + border-bottom: 1px solid #eee; } .custom-card-body { - max-height: calc(100vh - 170px); - overflow-y: hidden; + max-height: calc(100vh - 170px); + overflow-y: hidden; } .custom-card-body--tabs { - height: calc(100vh - 170px); + height: calc(100vh - 170px); } -.custom-card-body>.n-scrollbar-container>.n-scrollbar-content { - padding: 0px; +.custom-card-body > .n-scrollbar-container > .n-scrollbar-content { + padding: 0px; } .custom-card-body .n-tabs-nav--line-type.n-tabs-nav--top.n-tabs-nav { - padding: 0px 25px; + padding: 0px 25px; } .custom-card-body .n-tabs-tab__label { - padding: 4px 0px 8px; + padding: 4px 0px 8px; } .custom-card-body h3 { - font-size: 1.3rem; + font-size: 1.3rem; } diff --git a/src/assets/css/components/select.css b/src/assets/css/components/select.css index 8d50a7a..cc76183 100644 --- a/src/assets/css/components/select.css +++ b/src/assets/css/components/select.css @@ -75,63 +75,68 @@ */ +.n-form-item-feedback-wrapper { + min-height: 0 !important; +} + .start-datepicker .n-base-selection__border { - border-right-color: transparent; + border-right-color: transparent; } .end-datepicker .n-base-selection__border { - border-left-color: transparent; + border-left-color: transparent; } .start-datepicker .n-input.n-input--resizable.n-input--stateful { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .end-datepicker .n-input.n-input--resizable.n-input--stateful { - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } /* Selects styles */ .sub-select-container { - background-color: var(--embed-color); - box-shadow: rgba(0, 0, 0, 0.35) 0px -2px 36px -28px inset; + background-color: var(--embed-color); + box-shadow: rgba(0, 0, 0, 0.35) 0px -2px 36px -28px inset; } .mct-select { - width: 200px; - z-index: 0; + width: 200px; + z-index: 0; } .mct-select-dose { - width: 140px; - z-index: 0; + width: 140px; + z-index: 0; } .mct-selects { - display:flex; - justify-content: center; - gap: 14px; - align-items: center; - max-width: 1368px; - margin: 0px auto; - padding: 0px 24px; + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; + max-width: 1368px; + margin: 0px auto; + padding: 0px 24px 24px; } .mct-selects--modal { - flex-direction: column; + flex-direction: column; } .n-base-select-option__content { - z-index: 0 !important; + z-index: 0 !important; } @media (max-width: 1368px) { - .mct-selects { - background-color: white; - gap: 12px; - box-shadow: none; - } + .mct-selects { + background-color: white; + gap: 12px; + box-shadow: none; + align-items: center; + } } diff --git a/src/assets/css/components/slider.css b/src/assets/css/components/slider.css index b342f0c..8829b8a 100644 --- a/src/assets/css/components/slider.css +++ b/src/assets/css/components/slider.css @@ -1,64 +1,64 @@ .n-slider-mark { - color: var(--label-color); - font-size: 12px; - font-weight: 500; + color: var(--label-color); + font-size: 12px; + font-weight: 500; } .n-slider .n-slider-marks .n-slider-mark { - transform: translateX(-50%) translateY(-20%); + transform: translateX(-50%) translateY(-20%); } .year-slider { - display: flex; - gap: 14px; - margin-top: 24px; - align-items: center; + display: flex; + gap: 14px; + margin-top: 24px; + align-items: center; } .n-slider-handle-indicator.n-slider-handle-indicator--top { - background-color: #32a1e6; - font-weight: 500; + background-color: #32a1e6; + font-weight: 500; } .mandatory-vaccine-years { - opacity: 100%; - cursor: auto !important; + opacity: 100%; + cursor: auto !important; } .mandatory-vaccine-years .n-slider-rail { - background-color: white; - height: 1px; + background-color: white; + height: 1px; } .mandatory-vaccine-years:hover .n-slider-rail { - background-color: white; + background-color: white; } .mandatory-vaccine-years.n-slider .n-slider-rail__fill { - background-color: #32a1e6; + background-color: #32a1e6; } .mandatory-vaccine-years.n-slider:hover .n-slider-rail__fill { - background-color: #32a1e6; + background-color: #32a1e6; } .mandatory-vaccine-years.n-slider.n-slider--disabled { - opacity: 100%; + opacity: 100%; } .mandatory-vaccine-years.n-slider.n-slider--active .n-slider-rail { - background-color: white; + background-color: white; } .mandatory-vaccine-years.n-slider.n-slider--active .n-slider-rail__fill { - background-color: #32a1e6; + background-color: #32a1e6; } .span-date { - white-space: nowrap; - padding: 0px 6px; - font-size: 14px; + white-space: nowrap; + padding: 0px 6px; + font-size: 14px; } .span-date--more-padding { - padding: 12px 6px; + padding: 12px 6px; } diff --git a/src/assets/css/components/tab.css b/src/assets/css/components/tab.css index ba58d45..a5f2b35 100644 --- a/src/assets/css/components/tab.css +++ b/src/assets/css/components/tab.css @@ -1,72 +1,70 @@ /* Tabs */ .n-tabs.n-tabs--segment-type.n-tabs--medium-size.n-tabs--top { - width: fit-content; + width: fit-content; } .n-tabs-nav .n-tabs-rail { - width: fit-content; - border-top-left-radius: 22px; - border-bottom-left-radius: 22px; - border-top-right-radius: 22px; - border-bottom-right-radius: 22px; - padding: 0px; + width: fit-content; + border-top-left-radius: 22px; + border-bottom-left-radius: 22px; + border-top-right-radius: 22px; + border-bottom-right-radius: 22px; + padding: 0px; } - .n-tabs-nav .n-tabs-rail .n-tabs-tab:first-child { - border-top-left-radius: 22px !important; - border-bottom-left-radius: 22px !important; + border-top-left-radius: 22px !important; + border-bottom-left-radius: 22px !important; } -.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name="table"], -.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name="immunizers"] { - border-top-right-radius: 22px !important; - border-bottom-right-radius: 22px !important; +.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name='table'], +.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name='immunizers'] { + border-top-right-radius: 22px !important; + border-bottom-right-radius: 22px !important; } .n-tabs-nav .n-tabs-rail .n-tabs-tab { - background-color: var(--gray-color); - color: black; + background-color: var(--gray-color); + color: black; } .n-tabs-nav .n-tabs-rail .n-tabs-tab--disabled { - color: #ccc; - background-color: var(--gray-color-light); + color: #ccc; + background-color: var(--gray-color-light); } .n-tabs-nav .n-tabs-rail .n-tabs-tab--disabled:hover { - color: #ccc !important; + color: #ccc !important; } - .n-tabs-tab-wrapper .n-tabs-tab { - padding: 8px 24px; + padding: 8px 24px; } .n-tabs-nav { - display: flex; - justify-content: space-between; + display: flex; + justify-content: space-between; } .n-tabs .n-tabs-rail .n-tabs-capsule { - display: none; + display: none; } .n-tabs .n-tabs-rail .n-tabs-tab-wrapper .n-tabs-tab.n-tabs-tab--active { - background-color: var(--primary-color); - transition: background-color 0.3s; - color: white; - font-weight: 400; + background-color: var(--primary-color); + transition: background-color 0.3s; + color: white; + font-weight: 400; } .n-tabs-tab__label { - font-size: 0.9em; + font-size: 0.9em; } .n-tabs.n-tabs--line-type .n-tabs-tab { - padding: 2px 0px; + padding: 2px 0px; } .n-tabs-nav-scroll-content { - border-bottom-width: 3px; + border-bottom-width: 3px; } diff --git a/src/assets/css/components/table.css b/src/assets/css/components/table.css index 0f09748..97a489c 100644 --- a/src/assets/css/components/table.css +++ b/src/assets/css/components/table.css @@ -1,13 +1,15 @@ .n-data-table-th.n-data-table-th--hover { - color: white; + color: white; } .n-data-table-th.n-data-table-th--sortable:hover { - color: white; + color: white; } -.n-data-table .n-data-table-th.n-data-table-th--sortable:hover .n-data-table-sorter { - color: var(--gray-color); +.n-data-table + .n-data-table-th.n-data-table-th--sortable:hover + .n-data-table-sorter { + color: var(--gray-color); } .n-data-table-tr .n-data-table-tr--striped, @@ -15,74 +17,83 @@ .n-data-table .n-data-table-tr.n-data-table-tr--striped, .n-data-table .n-data-table-tr:not(.n-data-table-tr--summary):hover, .n-data-table-tr { - background: white; + background: white; } .n-data-table-tr .n-data-table-th:first-child, .n-data-table-tr .n-data-table-td:first-child { - border-top-left-radius: .25rem; - border-bottom-left-radius: .25rem; + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; } .n-data-table-tr .n-data-table-th:last-child, .n-data-table-tr .n-data-table-td:last-child { - border-top-right-radius: .25rem; - border-bottom-right-radius: .25rem; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; } -.n-data-table .n-data-table-tr.n-data-table-tr--striped .n-data-table-td.n-data-table-td--hover { - background: #fadfdb; +.n-data-table + .n-data-table-tr.n-data-table-tr--striped + .n-data-table-td.n-data-table-td--hover { + background: #fadfdb; } - -.n-data-table .n-data-table-tr:not(.n-data-table-tr--summary):hover > .n-data-table-td { - background: #f6f6f6; +.n-data-table + .n-data-table-tr:not(.n-data-table-tr--summary):hover + > .n-data-table-td { + background: #f6f6f6; } .n-data-table .n-data-table-th .n-data-table-sorter.n-data-table-sorter--desc, .n-data-table .n-data-table-th .n-data-table-sorter.n-data-table-sorter--asc { - color: white; + color: white; } .n-data-table-tr { - font-weight: 500; -} - -.table-custom .n-data-table__pagination { - justify-content: flex-start; + font-weight: 500; } - /* MediaQuery */ @media (max-width: 800px) { - .table-custom .n-pagination { - justify-content: space-between; - min-width: 100%; - } + .table-custom .n-pagination { + justify-content: space-between; + min-width: 100%; + } } /* Collapsable */ -.collapse-table { - margin-top: 12px; +.collapse-table { + margin-top: 12px; } .collapse-table table { - border-spacing: 0 8px !important; + border-spacing: 0 8px !important; } .collapse-table .n-data-table-thead { - display: none; + display: none; } .collapse-table .n-data-table-tr td:first-child { - border-left: 1px solid #d1d1d1; - border-bottom: 1px solid #d1d1d1; - border-top: 1px solid #d1d1d1; + border-left: 1px solid #d1d1d1; + border-bottom: 1px solid #d1d1d1; + border-top: 1px solid #d1d1d1; } .collapse-table .n-data-table-tr td:last-child { - border-right: 1px solid #d1d1d1; - border-bottom: 1px solid #d1d1d1; - border-top: 1px solid #d1d1d1; + border-right: 1px solid #d1d1d1; + border-bottom: 1px solid #d1d1d1; + border-top: 1px solid #d1d1d1; +} + +/* Custom pagination */ + +.n-input.n-input--resizable.n-input--stateful { + pointer-events: none; +} + +.n-pagination.n-pagination--simple + .n-input.n-input--resizable.n-input--stateful { + --n-border: none !important; } diff --git a/src/assets/css/map-chart-table-legend.css b/src/assets/css/map-chart-table-legend.css index 191762c..bb0fc52 100644 --- a/src/assets/css/map-chart-table-legend.css +++ b/src/assets/css/map-chart-table-legend.css @@ -1,75 +1,79 @@ /* Map Legend */ .mct-legend { - position: absolute; - bottom: 15px; - right: 60px; - user-select: none; - pointer-events: none; - height: 52px; + position: absolute; + bottom: 15px; + right: 60px; + user-select: none; + pointer-events: none; + height: 52px; } .mct-legend-svg { - width: 220px; + width: 220px; } .mct-legend__gradient { - position: relative; - box-shadow: 0 0 2px white, 0 0 2px white, 0 0 2px white, 0 0 2px white; + position: relative; + box-shadow: + 0 0 2px white, + 0 0 2px white, + 0 0 2px white, + 0 0 2px white; } @media (max-width: 800px) { - .mct-legend { - bottom: 0px; - right: 0px; - } - .mct-legend-svg { - width: 190px; - } + .mct-legend { + bottom: 0px; + right: 0px; + } + .mct-legend-svg { + width: 190px; + } } -/* Tooltip map */ +/* Tooltip map styles */ .mct-tooltip { - display: none; - position: fixed; - opacity: 95%; - background-color: var(--primary-color); - border-radius: 5px; - padding: 12px 12px; - font-size: 14px; - z-index: 1000; - user-select: none; - pointer-events: none; - min-width: 100px; - max-width: 400px; + display: none; + position: fixed; + opacity: 95%; + background-color: var(--primary-color); + border-radius: 5px; + padding: 12px; + font-size: 14px; + z-index: 1000; + user-select: none; + pointer-events: none; + min-width: 100px; + max-width: 400px; } .mct-tooltip__title { - font-weight: 600; - color: white; - margin-bottom: 4px; + font-weight: 600; + color: white; + margin-bottom: 4px; } .mct-tooltip__title--sub { - font-size: x-small; - font-weight: 600; - color: white; - margin-top: 2px; - margin-bottom: 2px; + font-size: x-small; + font-weight: 600; + color: white; + margin-top: 2px; + margin-bottom: 2px; } .mct-tooltip__result { - font-weight: 600; - color: white; - font-size: 12px; - margin: -3px 0px; + font-weight: 600; + color: white; + font-size: 12px; + margin: -3px 0px; } .mct-tooltip__result--sub { - font-size: 11px; + font-size: 11px; } .mct-tooltip__result--sub:last-child { - margin-bottom: 1px; + margin-bottom: 1px; } diff --git a/src/assets/css/map-chart-table.css b/src/assets/css/map-chart-table.css index b0b3d66..d54e997 100644 --- a/src/assets/css/map-chart-table.css +++ b/src/assets/css/map-chart-table.css @@ -1,211 +1,216 @@ /* Map styles */ .map-container { - min-height: 440px !important; + min-height: 440px !important; } .mct-canva { - width: 100%; - height: 440px; - display: flex; - justify-content: center; + width: 100%; + height: 440px; + display: flex; + justify-content: center; } .mct-canva__chart { - padding-top: 25px; - width: 100%; + padding-top: 25px; + width: 100%; } .mct__canva-section { - position: relative; + position: relative; } /* Map Year */ .mct-canva-year { - border-radius: .25rem; - color: var(--body-color); - font-size: 1.5rem; - font-weight: 700; - margin: 0px auto; - max-width: fit-content; - padding: 6px 24px; - position: absolute; - right: 10px; - bottom: 5px; - opacity: 0; - transition: visibility 0s, opacity 0.5s ease-in-out; - user-select: none; + border-radius: 0.25rem; + color: var(--body-color); + font-size: 1.5rem; + font-weight: 700; + margin: 0px auto; + max-width: fit-content; + padding: 6px 24px; + position: absolute; + right: 10px; + bottom: 5px; + opacity: 0; + transition: + visibility 0s, + opacity 0.5s ease-in-out; + user-select: none; } /* Map Legend */ .mct-legend { - width: 195px; - position: absolute; - bottom: 20px; - right: 10px; - display: flex; - flex-direction: column; - user-select: none; - pointer-events: none; + width: 195px; + position: absolute; + bottom: 20px; + right: 10px; + display: flex; + flex-direction: column; + user-select: none; + pointer-events: none; } .mct-legend__gradient { - position: relative; - margin: auto; - box-shadow: 0 0 2px white, 0 0 2px white, 0 0 2px white, 0 0 2px white; + position: relative; + margin: auto; + box-shadow: + 0 0 2px white, + 0 0 2px white, + 0 0 2px white, + 0 0 2px white; } .mct-legend__gradient-box { - position: absolute; - display: flex; - z-index: 40; + position: absolute; + display: flex; + z-index: 40; } .mct-legend__gradient-box-content { - width: 10px; - height: 10px; - margin: 0px 1px; + width: 10px; + height: 10px; + margin: 0px 1px; } .mct-legend__content-box { - display: flex; - justify-content: space-between; - font-size: .6rem; - text-shadow: var(--text-shadow); - color: #1E1E1E; - margin-left: 22px; - margin-right: 40px; + display: flex; + justify-content: space-between; + font-size: 0.6rem; + text-shadow: var(--text-shadow); + color: #1e1e1e; + margin-left: 22px; + margin-right: 40px; } .mct-legend-box-0 { - background-color: #9C3F33; + background-color: #9c3f33; } .mct-legend-box-1 { - background-color: #CF5443; + background-color: #cf5443; } .mct-legend-box-2 { - background-color: #E75E4B; + background-color: #e75e4b; } .mct-legend-box-3 { - background-color: #EA7262; + background-color: #ea7262; } .mct-legend-box-4 { - background-color: #ED8678; + background-color: #ed8678; } .mct-legend-box-5 { - background-color: #F3AEA5; + background-color: #f3aea5; } .mct-legend-box-6 { - background-color: #F6C2BC; + background-color: #f6c2bc; } .mct-legend-box-7 { - background-color: #A0D1F2; + background-color: #a0d1f2; } .mct-legend-box-8 { - background-color: #32A1E6; + background-color: #32a1e6; } .mct-legend-box-9 { - background-color: #0179DA; + background-color: #0179da; } .mct-legend-box-10 { - background-color: #016FC4; + background-color: #016fc4; } .mct-legend-box-11 { - background-color: #005CA1; + background-color: #005ca1; } .mct-legend-box-text { - font-size: .65em; - white-space: nowrap; - background-color: white; - position: relative; - top: 18px; - left: 0; + font-size: 0.65em; + white-space: nowrap; + background-color: white; + position: relative; + top: 18px; + left: 0; } .mct-legend-box-text__line { - border-right: 1px solid; - border-color: red; - position: absolute; - top: -12px; - left: -10px; - padding: 10px; - z-index: 0; + border-right: 1px solid; + border-color: red; + position: absolute; + top: -12px; + left: -10px; + padding: 10px; + z-index: 0; } .mct-legend-box-text__line--end { - border-color: blue; + border-color: blue; } .mct-legend-box-text__content { - border: 1px solid gray; - padding: 1px 4px; - color: black; - position: absolute; - border-radius: .23rem; - left: 0; - z-index: 40; - background-color: white; + border: 1px solid gray; + padding: 1px 4px; + color: black; + position: absolute; + border-radius: 0.23rem; + left: 0; + z-index: 40; + background-color: white; } .mct-legend-box-text--end { - top: 18px; - left: auto; - right: 0; + top: 18px; + left: auto; + right: 0; } .mct-legend-box-text__line--end { - top: -12px; - left: auto; - right: 18px; + top: -12px; + left: auto; + right: 18px; } .mct-legend-box-text__content--end { - left: auto; - top: 0; - right: 0; - z-index: 40; - background-color: white; + left: auto; + top: 0; + right: 0; + z-index: 40; + background-color: white; } .mct-legend__content { - display: flex; - gap: 38px; - justify-content: space-between; - white-space: nowrap; + display: flex; + gap: 38px; + justify-content: space-between; + white-space: nowrap; } - .mct-legend-box-start { - background-color: #692A22; - width: 19px; + background-color: #692a22; + width: 19px; } .mct-legend-box-end { - background-color: #00457C; - width: 19px; + background-color: #00457c; + width: 19px; } /* MediaQuery */ @media (max-width: 800px) { - .map-container { - min-height: 337.6px !important; - } - .mct-canva { - height: 70vw; - } + .map-container { + min-height: 337.6px !important; + } + .mct-canva { + height: 70vw; + } } diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 270364c..5412bf2 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -1,18 +1,19 @@ -@import url("./base.css"); -@import url("./map-chart-table.css"); -@import url("./map-chart-table-legend.css"); -@import url("./components/container.css"); -@import url("./components/label.css"); -@import url("./components/tab.css"); -@import url("./components/select.css"); -@import url("./components/slider.css"); -@import url("./components/table.css"); -@import url("./components/main-header.css"); -@import url("./components/main-content.css"); -@import url("./components/main-footer.css"); -@import url("./components/collapsable.css"); -@import url("./components/modal.css"); -@import url("./components/filter-suggestion.css"); +@import url('./base.css'); +@import url('./components/collapsable.css'); +@import url('./components/container.css'); +@import url('./components/filter-suggestion.css'); +@import url('./components/label.css'); +@import url('./components/main-content.css'); +@import url('./components/main-footer.css'); +@import url('./components/main-header.css'); +@import url('./components/map.css'); +@import url('./components/modal.css'); +@import url('./components/select.css'); +@import url('./components/slider.css'); +@import url('./components/tab.css'); +@import url('./components/table.css'); +@import url('./map-chart-table-legend.css'); +@import url('./map-chart-table.css'); /* * Fix table resize rows error. @@ -23,69 +24,87 @@ * */ .v-binder-follower-container { - position: initial; + position: initial; } .mct-scrollbar::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } .mct-scrollbar::-webkit-scrollbar-track { - border-radius: 100vh; - background: #f7f4ed; + border-radius: 100vh; + background: #f7f4ed; } .mct-scrollbar::-webkit-scrollbar-thumb { - background: #e0cbcb; - border-radius: 100vh; - border: 3px solid #f6f7ed; + background: #e0cbcb; + border-radius: 100vh; + border: 3px solid #f6f7ed; } .mct-scrollbar::-webkit-scrollbar-thumb:hover { - background: #c0a0b9; + background: #c0a0b9; } .pulse-button { - position: relative; - overflow: visible; + position: relative; + overflow: visible; } .pulse-button::after { - content: ''; - position: absolute; - width: 100%; - height: 110%; - outline: 8px solid #18a058; - outline-offset: -6px; - border-radius: 34px; - animation: pulse 2s infinite; - z-index: 0; + content: ''; + position: absolute; + width: 100%; + height: 110%; + outline: 8px solid #18a058; + outline-offset: -6px; + border-radius: 34px; + animation: pulse 2s infinite; + z-index: 0; } .pulse-button:hover::after { - outline-color: #36ad6a; + outline-color: #36ad6a; } .pulse-button:active::after { - outline-color: #0c7a43; + outline-color: #0c7a43; } .pulse-button:focus-visible { - border: 1px solid black; + border: 1px solid black; } @keyframes pulse { - 0% { - transform: scale(1); - opacity: 0.7; - } - 70% { - transform: scale(1.05); - opacity: 0; - } - 100% { - transform: scale(1.05); - opacity: 0; - } + 0% { + transform: scale(1); + opacity: 0.7; + } + 70% { + transform: scale(1.05); + opacity: 0; + } + 100% { + transform: scale(1.05); + opacity: 0; + } +} + +.vbr, +.vbr ::before, +.vbr ::after { + box-sizing: unset !important; +} + +/* Remove fade/scale selects animations */ +.fade-in-scale-up-transition-enter-active, +.fade-in-scale-up-transition-leave-active { + transition: none !important; +} + +.fade-in-scale-up-transition-enter-from, +.fade-in-scale-up-transition-leave-to { + opacity: 1 !important; + transform: none !important; } diff --git a/src/canvas-download.js b/src/canvas-download.js index bcb4b2c..eeb0552 100644 --- a/src/canvas-download.js +++ b/src/canvas-download.js @@ -1,147 +1,249 @@ +/** + * @typedef {Object} CanvasImageItem + * @property {string} image - A URL ou fonte da imagem (src). + * @property {number} [height] - A altura forçada da imagem. + * @property {number} [width] - A largura forçada da imagem. + * @property {number} [posX] - A posição X no canvas. + * @property {number} [posY] - A posição Y no canvas. + */ + +/** + * @typedef {Object} CanvasDownloadOptions + * @property {string | null} [title] - O título principal. + * @property {string | null} [subTitle] - O subtítulo. + * @property {string} [message] - Uma mensagem de marca d'água ou fundo. + * @property {string} [source] - O texto da fonte/créditos. + * @property {number} [canvasWidth] - Largura total do canvas (padrão: 1400). + * @property {number} [canvasHeight] - Altura total do canvas (padrão: 720). + * @property {number} [yTextSource] - Posição Y do texto da fonte (padrão: 694). + */ + +/** + * Classe responsável por gerar uma imagem composta (canvas) e permitir o download. + */ class CanvasDownload { - constructor(images, { title, subTitle, message, source, canvasWidth, canvasHeight, yTextSource } = {}) { - this.images = images; - this.title = title; - this.subTitle = subTitle; - this.source = source; - this.message = message; - - this.canvasWidth = canvasWidth ?? 1400; - this.canvasHeight = canvasHeight ?? 720; - this.yTextSource = yTextSource ?? 694; - } - - async setCanvas() { - const self = this; - const canvas = document.createElement("canvas"); - canvas.id = "canvas-generator"; - canvas.width = self.canvasWidth; - canvas.height = self.canvasHeight; - canvas.style.backgroundColor = "white"; - self.canvas = canvas; - self.ctx = canvas.getContext("2d"); - - // Set canvas color - self.ctx.fillStyle = "white"; - self.ctx.fillRect(0, 0, self.canvas.width, self.canvas.height); - - const promises = []; - self.images.forEach(img => { - promises.push(self.addImage(img.image, img.height, img.width, img.posX, img.posY)); - }); - - await Promise.all(promises); - - self.addText(self.title, self.subTitle); - } - - reduceProportion(height, width, factor) { - const nHeight = height * factor; - const nWidth = width * factor; - - return { nHeight, nWidth }; - } - - addImage(image, height, width, posX, posY) { - const self = this; - const canvas = this.canvas; - const ctx = this.ctx; - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = function() { - const x = posX ? posX : canvas.width / 2 - img.width / 2; - const y = posY ? posY : canvas.height / 2 - img.height / 2; - // Assuming 'ctx' is a 2D rendering context of the canvas - ctx.drawImage(img, x, y, img.width, img.height); - resolve(); - }; - img.onerror = (e) => reject(new Error('Image load failed')); - - img.src = image; - let factor = 1; - let result = self.reduceProportion(img.naturalHeight, img.naturalWidth, factor) - while (result.nWidth > self.canvasWidth) { - factor -= 0.01; - result = self.reduceProportion(img.naturalHeight, img.naturalWidth, factor) - } - img.height = height ?? result.nHeight; - img.width = width ?? result.nWidth; - }); - } - - drawTextWithLineBreaks(text, x, y, maxWidth = 1390, lineHeight = 30) { - const self = this; - let lineBreakedTimes = 0; - const words = text.split(' '); - let line = ''; - - for (const word of words) { - const testLine = line + word + ' '; - const { width } = self.ctx.measureText(testLine); - - if (width > maxWidth) { - self.ctx.fillText(line, x, y); - line = word + ' '; - y += lineHeight; - lineBreakedTimes++; - } else { - line = testLine; - } + /** + * Cria uma instância de CanvasDownload. + * @param {CanvasImageItem[]} images - Lista de objetos de imagem para desenhar. + * @param {CanvasDownloadOptions} [options] - Opções de configuração de textos e dimensões. + */ + constructor( + images, + { + title, + subTitle, + message, + source, + canvasWidth, + canvasHeight, + yTextSource, + } = {} + ) { + this.images = images + this.title = title + this.subTitle = subTitle + this.source = source + this.message = message + + this.canvasWidth = canvasWidth ?? 1400 + this.canvasHeight = canvasHeight ?? 720 + this.yTextSource = yTextSource ?? 694 + + /** @type {any} */ + this.canvas = null + /** @type {any} */ + this.ctx = null } - self.ctx.fillText(line, x, y); - - return lineBreakedTimes; - } + /** + * Inicializa o canvas, desenha o fundo, processa as imagens e adiciona os textos. + * @returns {Promise} + */ + async setCanvas() { + const self = this + const canvas = document.createElement('canvas') + canvas.id = 'canvas-generator' + canvas.width = self.canvasWidth + canvas.height = self.canvasHeight + canvas.style.backgroundColor = 'white' + self.canvas = canvas + self.ctx = canvas.getContext('2d') + + // Set canvas color + self.ctx.fillStyle = 'white' + self.ctx.fillRect(0, 0, self.canvas.width, self.canvas.height) + + /** @type{Promise[]} **/ + const promises = [] + self.images.forEach((img) => { + promises.push( + self.addImage( + img.image, + img.height, + img.width, + img.posX, + img.posY + ) + ) + }) + + await Promise.all(promises) + + self.addText() + } - addText() { - const self = this; + /** + * Calcula a redução proporcional de dimensões baseada em um fator. + * @param {number} height - Altura original. + * @param {number} width - Largura original. + * @param {number} factor - Fator de multiplicação (ex: 1, 0.9, etc). + * @returns {{ nHeight: number, nWidth: number }} Objeto com nova altura e largura. + */ + reduceProportion(height, width, factor) { + const nHeight = height * factor + const nWidth = width * factor + + return { nHeight, nWidth } + } - if (!self.title) { - return; + /** + * Carrega uma imagem e a desenha no contexto do canvas. + * Ajusta o tamanho proporcionalmente se a largura exceder o canvas. + * @param {string} image - URL da imagem. + * @param {number} [height] - Altura desejada. + * @param {number} [width] - Largura desejada. + * @param {number} [posX] - Posição X (centraliza se omitido). + * @param {number} [posY] - Posição Y (centraliza se omitido). + * @returns {Promise} + */ + addImage(image, height, width, posX, posY) { + const self = this + const canvas = this.canvas + const ctx = this.ctx + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = function () { + const x = posX ? posX : canvas.width / 2 - img.width / 2 + const y = posY ? posY : canvas.height / 2 - img.height / 2 + // Assuming 'ctx' is a 2D rendering context of the canvas + ctx.drawImage(img, x, y, img.width, img.height) + resolve() + } + img.onerror = (e) => reject(new Error('Image load failed')) + + img.src = image + let factor = 1 + let result = self.reduceProportion( + img.naturalHeight, + img.naturalWidth, + factor + ) + while (result.nWidth > self.canvasWidth) { + factor -= 0.01 + result = self.reduceProportion( + img.naturalHeight, + img.naturalWidth, + factor + ) + } + img.height = height ?? result.nHeight + img.width = width ?? result.nWidth + }) } - self.ctx.font = "bold 25px Arial"; - self.ctx.fillStyle = "#222"; - let xText = 10; - let yText = 30; - const lineBreakedTimes = self.drawTextWithLineBreaks(self.title, xText, yText); - - if (self.subTitle) { - self.ctx.font = "17px Arial"; - self.ctx.fillStyle = "#222"; - yText = lineBreakedTimes ? (lineBreakedTimes + 1) * 45 : 55; - xText = 12; - self.drawTextWithLineBreaks(self.subTitle, xText, yText); + /** + * Desenha texto no canvas com quebra de linha automática baseada na largura máxima. + * @param {string} text - O texto a ser escrito. + * @param {number} x - Posição inicial X. + * @param {number} y - Posição inicial Y. + * @param {number} [maxWidth=1390] - Largura máxima antes da quebra de linha. + * @param {number} [lineHeight=30] - Altura da linha. + * @returns {number} O número de vezes que a linha foi quebrada. + */ + drawTextWithLineBreaks(text, x, y, maxWidth = 1390, lineHeight = 30) { + const self = this + let lineBreakedTimes = 0 + const words = text.split(' ') + let line = '' + + for (const word of words) { + const testLine = line + word + ' ' + const { width } = self.ctx.measureText(testLine) + + if (width > maxWidth) { + self.ctx.fillText(line, x, y) + line = word + ' ' + y += lineHeight + lineBreakedTimes++ + } else { + line = testLine + } + } + + self.ctx.fillText(line, x, y) + + return lineBreakedTimes } - if (self.source) { - self.ctx.font = "12px Arial"; - self.ctx.fillStyle = "#222"; - yText = self.yTextSource; - xText = 230; - self.drawTextWithLineBreaks(self.source, xText, yText); + /** + * Adiciona os textos configurados (título, subtítulo, fonte, mensagem) ao canvas. + * @returns {void} + */ + addText() { + const self = this + + if (!self.title) { + return + } + + self.ctx.font = 'bold 25px Arial' + self.ctx.fillStyle = '#222' + let xText = 10 + let yText = 30 + const lineBreakedTimes = self.drawTextWithLineBreaks( + self.title, + xText, + yText + ) + + if (self.subTitle) { + self.ctx.font = '17px Arial' + self.ctx.fillStyle = '#222' + yText = lineBreakedTimes ? (lineBreakedTimes + 1) * 45 : 55 + xText = 12 + self.drawTextWithLineBreaks(self.subTitle, xText, yText) + } + + if (self.source) { + self.ctx.font = '12px Arial' + self.ctx.fillStyle = '#222' + yText = self.yTextSource + xText = 230 + self.drawTextWithLineBreaks(self.source, xText, yText) + } + + if (self.message) { + self.ctx.font = '700 70px Arial' + self.ctx.fillStyle = 'rgba(100, 100, 100, 0.5)' + yText = 560 + xText = -130 + self.ctx.rotate((-20 * Math.PI) / 180) + self.drawTextWithLineBreaks(self.message, xText, yText) + } } - if (self.message) { - self.ctx.font = "700 70px Arial"; - self.ctx.fillStyle = "rgba(100, 100, 100, 0.5)"; - yText = 560; - xText = -130; - self.ctx.rotate(-20 * Math.PI / 180) - self.drawTextWithLineBreaks(self.message, xText, yText); + /** + * Gera o canvas e dispara o download da imagem em formato PNG. + * @returns {Promise} + */ + async download() { + const self = this + await self.setCanvas() + const link = document.createElement('a') + link.href = self.canvas.toDataURL('image/png') + link.download = 'image' + link.click() } - } - - async download() { - const self = this; - await self.setCanvas(); - const link = document.createElement("a"); - link.href = self.canvas.toDataURL('image/png'); - link.download = "image"; - link.click(); - } } -export default CanvasDownload; - +export default CanvasDownload diff --git a/src/common.js b/src/common.js index 0c3eeb1..eb2dc4c 100644 --- a/src/common.js +++ b/src/common.js @@ -1,53 +1,70 @@ -export const formatToApi = ({ - form, - tab, - tabBy -}) => { - const routerResult = {}; - if (form) { - for (let formField in form) { - switch (formField) { - case "local": - if (form[formField] && form[formField].length) { - routerResult[formField] = form[formField]; - } - break; - case "periodEnd": - case "periodStart": - if (form[formField]) { - routerResult[formField] = form[formField]; - } - break; - case "doses": - case "granularities": - case "immunizers": - case "locals": - case "sicks": - case "types": - case "years": - // Do Nothing - break; - default: - if (form[formField]) { - routerResult[formField] = form[formField]; - } - break; - } +/** + * Formats form data and tab settings into a clean object for API or Router usage. + * This function filters the `form` object: + * - Copies 'local' and 'city' only if they have length (are not empty strings/arrays). + * - Copies 'periodEnd' and 'periodStart' if they exist (are truthy). + * - Explicitly ignores list fields (e.g., 'cities', 'doses', 'years', etc). + * - Copies any other keys if they are truthy. + * - Adds 'tab' and 'tabBy' if provided. + * + * @param {Object} params - The input parameters. + * @param {Record} [params.form] - The source form data object containing filters. + * @param {string|number} [params.tab] - The current active tab identifier. + * @param {string} [params.tabBy] - The grouping criteria for the tab. + * @returns {Record} A new object containing only the valid/filtered properties. + */ +export const formatToApi = ({ form, tab, tabBy }) => { + /** @type {Record} */ + const routerResult = {} + if (form) { + for (let formField in form) { + switch (formField) { + case 'local': + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField] + } + break + case 'city': + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField] + } + break + case 'periodEnd': + case 'periodStart': + if (form[formField]) { + routerResult[formField] = form[formField] + } + break + case 'cities': + case 'doses': + case 'granularities': + case 'immunizers': + case 'locals': + case 'sicks': + case 'types': + case 'years': + // Do Nothing + break + default: + if (form[formField]) { + routerResult[formField] = form[formField] + } + break + } + } } - } - if (tab) { - routerResult.tab = tab; - } else { - delete routerResult.tab - } - - if (tabBy) { - routerResult.tabBy = tabBy; - } else { - delete routerResult.tabBy - } + if (tab) { + routerResult.tab = tab + } else { + delete routerResult.tab + } - return routerResult; -}; + if (tabBy) { + routerResult.tabBy = tabBy + } else { + delete routerResult.tabBy + } + return routerResult +} diff --git a/src/components/config.js b/src/components/config.js index df9313b..24cd714 100644 --- a/src/components/config.js +++ b/src/components/config.js @@ -1,50 +1,59 @@ -import { NConfigProvider, ptBR } from "naive-ui"; +import { NConfigProvider, ptBR, NMessageProvider } from 'naive-ui' -export const config = { - components: { - NConfigProvider, - }, - setup () { - const lightThemeOverrides = { - common: { - primaryColor: "#e96f5f", - primaryColorHover: "#e96f5f", - primaryColorPressed: "#e96f5f", - fontSizeMedium: ".95rem", - }, - Slider: { - indicatorColor: "#e96f5f" - }, - Pagination: { - itemBorderRadius: "50%" - }, - Button: { - fontSizeMedium: ".95rem", - }, - Tabs: { - tabFontSizeMedium: ".95rem", - }, - DataTable: { - fontSizeMedium: ".95rem", - thColorHover: "#e96f5f", - thColor: "#ececec", - tdColorStriped: "#ececec", - thFontWeight: "500", - thIconColor: "#e96f5f", - }, - }; - return { - // Config-provider setup - ptBR: ptBR, - lightThemeOverrides, - } - }, - template: ` - - - + + + + `, } diff --git a/src/components/main/card-components/chart.js b/src/components/main/card-components/chart.js new file mode 100644 index 0000000..281186b --- /dev/null +++ b/src/components/main/card-components/chart.js @@ -0,0 +1,480 @@ +import { + defineComponent, + ref, + onMounted, + onUnmounted, + computed, + watch, +} from 'vue' +import { NSelect, NEmpty } from 'naive-ui' +import { useContentStore, useChartStore } from '@/stores/index' +import { storeToRefs } from 'pinia' + +/** + * @typedef {{dataset: { label: string, data: string }, parsed: { y: string }, dataIndex: number }} context + */ + +import ChartDataLabels from 'chartjs-plugin-datalabels' + +import { + Chart, + LineController, + LineElement, + PointElement, + LinearScale, + Tooltip, + CategoryScale, + Legend, + // @ts-ignore +} from 'chartjs' + +Chart.register( + CategoryScale, + LineController, + LineElement, + PointElement, + LinearScale, + Tooltip, + Legend, + ChartDataLabels +) + +export default defineComponent({ + components: { NSelect, NEmpty }, + setup() { + const contentStore = useContentStore() + const { tabBy, acronyms, form, loading } = storeToRefs(contentStore) + const chartStore = useChartStore() + const { years, dataChart } = storeToRefs(chartStore) + + const formPopulated = computed(() => contentStore.selectsPopulated) + + const chartDefined = ref(true) + + /** + * @param {HTMLElement} chart + * @param {string} id + * @returns {void | HTMLElement} + */ + const getOrCreateLegendList = (chart, id) => { + const legendContainer = document.getElementById(id) + + if (!legendContainer) { + return + } + + let listContainer = legendContainer.querySelector('ul') + + if (!listContainer) { + listContainer = document.createElement('ul') + listContainer.style.display = 'flex' + listContainer.style.gap = '4px 12px' + listContainer.style.flexDirection = 'row' + listContainer.style.flexWrap = 'wrap' + listContainer.style.margin = '0' + listContainer.style.padding = '0' + + legendContainer.appendChild(listContainer) + } + + return listContainer + } + + /** @type {(label: string) => string} */ + const splitTextToChart = (label) => { + let labelSplited = label.split(' ') + let lastLabel = labelSplited[labelSplited.length - 1] + const vaccineName = labelSplited + .slice(0, labelSplited.length - 1) + .join(' ') + const acronym = + tabBy.value === 'immunizers' + ? acronyms.value.find((acronym) => + vaccineName.includes(acronym['nome_vacinabr']) + ) + : undefined + let labelAcronym = acronym + ? acronym['sigla_vacinabr'] + : labelSplited[0].substr(0, 3) + '.' + + if (form.value.granularity.toLowerCase() === 'municípios') { + labelSplited = label.split(',') + lastLabel = + ' ' + + labelSplited[1] + + ', ' + + labelSplited[2].substr(0, 6) + + '.' + } else if (label.includes(',')) { + labelSplited = label.split(',') + lastLabel = + labelSplited[1].split(' ')[0] + + ' ' + + labelSplited[1].split(' ')[2].substr(0, 3) + } + return `${labelAcronym} ${lastLabel}` + } + + /** @type {(value: string, context: { dataIndex: number, dataset: { label: string, data: string } }, signal: string) => string | null} */ + const formatter = (value, context, signal) => { + const dataset = context.dataset.data + // Get last populated year data index from dataset + let count = 1 + while (dataset[dataset.length - count] === null) { + count++ + } + if (context.dataIndex === dataset.length - count) { + const label = splitTextToChart(context.dataset.label) + // @ts-ignore + return signal + ? `${label} ${value}${signal}` + : `${label} ${Number(value).toLocaleString('pt-BR')}` + } + + return null + } + + /** @type {{ id: string, afterUpdate: (chart: Chart, args: any, options: { containerID: string}) => void}} */ + const htmlLegendPlugin = { + id: 'htmlLegend', + afterUpdate(chart, args, options) { + if (!document.getElementById(options.containerID)) { + return + } + const ul = getOrCreateLegendList(chart, options.containerID) + + if (!ul) { + return + } + + // Remove old legend items + while (ul.firstChild) { + ul.firstChild.remove() + } + + // Reuse the built-in legendItems generator + const items = + /** @type {{ [key: string]: string }[]} */ + (chart.options.plugins.legend.labels.generateLabels(chart)) + + items.forEach((item) => { + const li = document.createElement('li') + li.style.alignItems = 'center' + li.style.display = 'flex' + li.style.cursor = 'pointer' + li.style.flexDirection = 'row' + li.style.opacity = item.hidden ? '30%' : '100%' + li.style.border = '1px solid #ddd' + li.style.padding = '2px 4px' + li.style.borderRadius = '3px' + li.title = + 'Clique para' + + (item.hidden ? ' exibir ' : ' ocultar ') + + 'dado no gráfico' + + li.onclick = () => { + chart.setDatasetVisibility( + item.datasetIndex, + !chart.isDatasetVisible(item.datasetIndex) + ) + chart.update() + } + + if (!item.hidden) { + li.onmouseenter = () => { + li.style.borderColor = '#e96f5f' + } + li.onmouseleave = () => { + li.style.borderColor = '#ddd' + } + } + + // Color box + const boxSpan = document.createElement('span') + boxSpan.style.background = item.hidden + ? 'gray' + : item.fillStyle + boxSpan.style.borderColor = item.strokeStyle + boxSpan.style.borderWidth = item.lineWidth + 'px' + boxSpan.style.display = 'inline-block' + boxSpan.style.borderRadius = '50%' + boxSpan.style.height = '14px' + boxSpan.style.marginRight = '4px' + boxSpan.style.width = '14px' + + // Text + const textContainer = document.createElement('p') + textContainer.style.color = item.fontColor + textContainer.style.margin = '0' + textContainer.style.padding = '0' + textContainer.style.textDecoration = item.hidden + ? 'line-through' + : '' + + const text = document.createTextNode( + splitTextToChart(item.text) + ) + textContainer.appendChild(text) + + li.appendChild(boxSpan) + li.appendChild(textContainer) + ul.appendChild(li) + }) + }, + } + + /** @type {(context: { dataset: { label: string }, parsed: { y: string } }, signal: string) => string } */ + const formatterTooltip = (context, signal) => { + let label = context.dataset.label || '' + if (label.includes(',')) { + let resultNewLabel = /** @type {string[]} */ (label.split(',')) + const resultNewLabelSplited = /** @type {string} */ ( + resultNewLabel.shift() + ) + // Extract first value remove region code + const sickName = resultNewLabelSplited.split(' ')[0] + resultNewLabel.pop() + label = sickName + ' ' + resultNewLabel.join(', ') + } + + label += ': ' + + if (context.parsed.y !== null) { + label += signal + ? context.parsed.y + signal + : Number(context.parsed.y).toLocaleString('pt-BR') + } + + return label + } + + /** @type {(value: string, signal: string|null) => string} */ + const chartTicks = (value, signal = null) => { + return signal + ? String(value) + signal + : Number(value).toLocaleString('pt-BR') + } + + let chart = /** @type {Chart | null} */ (null) + + /** + * @param {string[] | null} labels + * @param {{ label: string, + * data: (string | null)[], + * backgroundColor: string, + * borderColor: string, + * borderWidth: number, + * }[]|null} datasets + */ + const renderChart = (labels, datasets) => { + if (!labels || !datasets || !formPopulated.value) { + const legend = document.querySelector('#legend-container') + if (legend) { + legend.innerHTML = '' + } + chartDefined.value = false + return + } + + chartDefined.value = true + + let signal = '' + if (form.value.type !== 'Doses aplicadas') { + signal = '%' + for (const dataset of datasets) { + dataset.data = dataset.data.map((number) => { + return Number(number).toFixed(2) + }) + } + } else { + for (const dataset of datasets) { + dataset.data = dataset.data.map((number) => { + return number + ? String(number).replace(/\./g, '') + : number + }) + } + } + + if (chart) { + chart.data.labels = labels + chart.data.datasets = datasets + chart.options.scales.y.ticks.callback = + /** @type{(value: string) => any} */ (value) => + chartTicks(value, signal) + chart.options.plugins.datalabels.formatter = + /** @type{(value: string, context: context) => any} */ ( + value, + context + ) => formatter(value, context, signal) + chart.options.plugins.tooltip.callbacks.label = + /** @type{(context: context) => any} */ (context) => + formatterTooltip(context, signal) + chart.update() + return + } + const plugin = { + id: 'customCanvasBackgroundColor', + /** @type {(chart: Chart, args: string[], options: { color: string }) => void} */ + beforeDraw: (chart, args, options) => { + const { ctx } = chart + ctx.save() + ctx.globalCompositeOperation = 'destination-over' + ctx.fillStyle = options.color || 'white' + ctx.fillRect(0, 0, chart.width, chart.height) + ctx.restore() + }, + } + try { + const chartElement = /** @type {Chart} */ ( + document.querySelector('#chart') + ) + const ctx = chartElement.getContext('2d') + chart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + animateRotate: true, + animateScale: true, + }, + scales: { + x: { + border: { + display: false, + }, + grid: { + color: 'rgba(127,127,127, .2)', + }, + ticks: { + color: 'rgba(127,127,127, 1)', + padding: 20, + font: { + size: 14, + }, + }, + }, + y: { + suggestedMin: 0, + suggestedMax: 100, + border: { + display: false, + }, + grid: { + color: 'rgba(127,127,127, .2)', + }, + ticks: { + callback: + /** @type {(value: string) => string} */ ( + value + ) => chartTicks(value, signal), + color: 'rgba(127,127,127, 1)', + padding: 20, + font: { + size: 14, + }, + }, + }, + }, + plugins: { + htmlLegend: { + // ID of the container to put the legend in + containerID: 'legend-container', + }, + legend: { + display: false, + }, + datalabels: { + align: /** @type {(context: { dataset: { borderColor: string }}) => number} */ function ( + context + ) { + return 5 + }, + borderRadius: '50', + padding: '3', + backgroundColor: 'rgba(255,255,255, 0.95)', + color: /** @type {(context: { dataset: { borderColor: string }}) => string} */ function ( + context + ) { + return context.dataset.borderColor + }, + font: { + size: 10, + weight: 'bold', + }, + display: 'auto', + formatter: + /** @type {(value: string, context: context, signal: string) => string|null} */ ( + value, + context + ) => formatter(value, context, signal), + }, + tooltip: { + callbacks: { + label: /** @type {(context: context) => string|null} */ ( + context + ) => formatterTooltip(context, signal), + }, + }, + }, + layout: { + padding: { + right: 150, + }, + }, + }, + plugins: [htmlLegendPlugin, plugin], + }) + } catch (e) { + // Do nothing + } + } + + onMounted(async () => { + await chartStore.setChartData() + renderChart(years.value, dataChart.value) + }) + + onUnmounted(() => { + chartStore.resetState() + }) + + watch( + () => form.value, + async (formValue) => { + // Avoid render before tab changed to chart/tables + if (Array.isArray(formValue.sickImmunizer)) { + await chartStore.setChartData() + renderChart(years.value, dataChart.value) + } + }, + { deep: true } + ) + + return { + chartDefined, + formPopulated, + loading, + } + }, + template: ` +
+
+
+ + +
+
+
+ `, +}) diff --git a/src/components/main/card-components/filter-suggestion.js b/src/components/main/card-components/filter-suggestion.js new file mode 100644 index 0000000..fac4393 --- /dev/null +++ b/src/components/main/card-components/filter-suggestion.js @@ -0,0 +1,118 @@ +import { NButton } from 'naive-ui' +import { storeToRefs } from 'pinia' +import { useContentStore, useModalStore } from '@/stores' +import { defineComponent, computed } from 'vue' + +export default defineComponent({ + components: { + NButton, + }, + setup() { + const contentStore = useContentStore() + const { autoFilters, extraFilterButton } = storeToRefs(contentStore) + + const modalStore = useModalStore() + const { + genericModal, + genericModalShow, + genericModalTitle, + genericModalLoading, + } = storeToRefs(modalStore) + + /** + * @param {string[]} array + */ + const shuffle = (array) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[array[i], array[j]] = [array[j], array[i]] + } + return array + } + + /** + * @param {any} element + */ + const selectFilter = (element) => { + Object.entries(element).forEach( + (/** @type{string[]} element */ [key, value]) => { + if (key === 'tab') { + contentStore.setTabField(value) + } else if (key === 'tabBy') { + contentStore.setTabByField(value) + } else if (key === 'filters') { + Object.entries(value).forEach( + (/** @type{string[]} element */ [fKey, fValue]) => { + contentStore.setFormField(fKey, fValue) + } + ) + } + } + ) + } + + const elements = computed(() => { + const result = autoFilters.value + return result ? shuffle(result).slice(-4) : null + }) + + /** + * @param {string} title + * @param {string} slug + */ + const handleExtraButton = async (title, slug) => { + genericModalLoading.value = true + genericModal.value = null + genericModalShow.value = !genericModalShow.value + genericModalTitle.value = title + try { + await modalStore.requestContent(slug) + } catch { + // Do Nothing + } + genericModalLoading.value = false + } + + return { + elements, + selectFilter, + extraFilterButton, + handleExtraButton, + } + }, + template: ` +
+
+ {{ extraFilterButton.title }} +
+
+
+

+ Explore a plataforma usando os filtros acima, ou selecione um dos exemplos abaixo +

+
+
+ +
+
+ {{ element.title }} +
+
{{ element.description }}
+
+
+
+
+
+ `, +}) diff --git a/src/components/main/card-components/map/index.js b/src/components/main/card-components/map/index.js new file mode 100644 index 0000000..f110064 --- /dev/null +++ b/src/components/main/card-components/map/index.js @@ -0,0 +1,25 @@ +import { defineComponent } from 'vue' + +import Map from '@/components/main/card-components/map/map' +import YearSlider from '@/components/main/card-components/map/year-slider' +import MapRange from '@/components/main/card-components/map/map-range' + +export default defineComponent({ + components: { + Map, + MapRange, + YearSlider, + }, + setup() {}, + template: ` +
+
+ +
+ + +
+
+
+ `, +}) diff --git a/src/components/main/card-components/map/map-range.js b/src/components/main/card-components/map/map-range.js new file mode 100644 index 0000000..f6ccce6 --- /dev/null +++ b/src/components/main/card-components/map/map-range.js @@ -0,0 +1,397 @@ +import { defineComponent, ref, watch } from 'vue' + +import { NCard } from 'naive-ui' + +import { useContentStore, useMapStore } from '@/stores/index' +import { storeToRefs } from 'pinia' + +export default defineComponent({ + components: { + NCard, + }, + setup() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const mapStore = useMapStore() + const { mapData, mapTooltip } = storeToRefs(mapStore) + + /** + * @type {import('vue').Ref} + */ + const datasetValues = ref([]) + + /** + * @type {import('vue').Ref} + */ + const mapRangeSVG = ref(null) + + /** + * @type {import('vue').Ref} + */ + const maxVal = ref('---') + + /** + * @type {import('vue').Ref} + */ + const minVal = ref('--') + + /** + * Draws a line in an SVG element. + * @param {SVGSVGElement} svg - The SVG element to draw the line in. + */ + const drawLine = (svg) => { + svg.setAttribute('height', '0') + const offsetHeight = svg?.parentElement?.offsetHeight ?? 0 + svg.setAttribute('height', String(offsetHeight - 70)) + const line = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'line' + ) + line.setAttribute('x1', '20') + line.setAttribute('y1', '0') + line.setAttribute('x2', '20') + line.setAttribute('y2', '100%') + line.setAttribute('stroke', '#ccc') + line.setAttribute('stroke-width', '0.6') + svg.appendChild(line) + } + + /** + * Clears all circles from the map range. + */ + const clearCircles = () => { + const mapRange = document.querySelector('#map-range') + const circles = mapRange?.querySelectorAll('circle') + if (circles) { + circles.forEach((circle) => + circle?.parentElement?.removeChild(circle) + ) + } + } + + /** + * @param {SVGSVGElement} svg - The SVG element to query. + * @param {number} i - The index of the data item. + * @param {Array<{data: {value: any}}>} data - The array of data items. + * @param {boolean} [meta=false] - Whether to process meta data. + * @returns {SVGCircleElement | undefined} - The found circle element or undefined if not found. + */ + const samePercentCircle = (svg, i, data, meta = false) => + [...svg.querySelectorAll('circle')].find((el) => { + let result + let dataValue = data[i].data.value + if (meta) { + dataValue = parseInt(dataValue) === 0 ? 'Não' : 'Sim' + } + try { + const val = el.dataset.value + // @ts-ignore + result = JSON.parse(val).value + } catch (e) { + result = el.dataset.value + } + + return result == dataValue + }) + + // TODO: Make an assistant function to convert data and remove ts-ignores + /** + * @param {any} data - The data object containing map information. + */ + const handleMapChange = (data) => { + const svg = mapRangeSVG.value + if (!svg || !svg.parentNode) { + return + } + drawLine(svg) + clearCircles() + if (!data || !data.length) { + // Reset interface max/min values + maxVal.value = '---' + minVal.value = '---' + return + } + + const svgHeight = svg.getAttribute('height') + + /** @type { string | number } */ + let maxDataVal = String( + Math.max( + ...data.map((/** @type {{ data: string }} */ item) => + parseFloat(item.data) + ) + ) + ) + let defineMinVal = '0%' + const type = form.value.type + if (type === 'Doses aplicadas') { + maxDataVal = Number( + Math.max( + ...data.map( + (/** @type {{ data: { value: string } }} */ item) => + item.data.value.replace(/[.,]/g, '') + ) + ) + ).toLocaleString('pt-BR') + defineMinVal = '0' + } else if (type === 'Cobertura') { + maxDataVal = '120%' + } else if (type === 'Meta atingida') { + maxDataVal = 'Sim' + defineMinVal = 'Não' + } else { + maxDataVal = '100%' + } + + // Setting interface values + maxVal.value = maxDataVal + minVal.value = defineMinVal + + // If maxVal bigger than parent element add styles + + /** + * @type SVGSVGElement | null + */ + const maxValEl = svg.parentNode.querySelector('.max-val') + if (maxValEl) { + if (maxDataVal.toString().length > 4) { + maxValEl.style.border = '1px solid #f0f0f0' + maxValEl.style.boxShadow = + 'rgba(100, 100, 111, 0.2) 0px 7px 29px 0px' + } else { + maxValEl.style.border = '0px' + maxValEl.style.boxShadow = 'none' + } + } + + for (let i = 0; i < data.length; i++) { + let dataVal = data[i].data.value.replace(/[.,]/g, '') + if (data[i].data.value && data[i].data.value.includes('%')) { + dataVal = parseFloat(data[i].data.value) + } + + let y = 0 + let dataValue = JSON.stringify(data[i].data) + let samePercentCircleResult + if (type === 'Meta atingida') { + y = Number(svgHeight) - (dataVal / 1) * Number(svgHeight) + dataValue = + parseInt(data[i].data.value) === 0 ? 'Não' : 'Sim' + samePercentCircleResult = samePercentCircle( + svg, + i, + data, + true + ) + } else { + y = + Number(svgHeight) - + (dataVal / parseInt(maxDataVal.replace(/[.,]/g, ''))) * + Number(svgHeight) + samePercentCircleResult = samePercentCircle( + svg, + i, + data, + false + ) + } + + if ( + samePercentCircleResult && + samePercentCircleResult.dataset && + samePercentCircleResult.dataset.title + ) { + const newTitle = + samePercentCircleResult.dataset.title.replace( + /\se\s/, + ', ' + ) + + ' e ' + + data[i].name + samePercentCircleResult.setAttribute('data-title', newTitle) + continue + } + + // Block to max value as full or min height + if (y > Number(svgHeight)) { + y = Number(svgHeight) + } else if (y < 0) { + y = 0 + } + const circle = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'circle' + ) + circle.setAttribute('cx', '20') + circle.setAttribute('cy', String(y)) + circle.setAttribute('r', '6') + circle.setAttribute('fill', data[i].color) + if (data[i].label) { + circle.setAttribute('data-id', data[i].label) + } + circle.setAttribute('data-title', data[i].name) + circle.setAttribute('data-value', dataValue) + circle.setAttribute('opacity', '0.8') + circle.setAttribute('stroke', '#aaa') + circle.setAttribute('stroke-width', '0.4') + svg.appendChild(circle) + } + + svg.addEventListener( + 'mousemove', + (e) => { + const target = /** @type {SVGCircleElement} */ (e.target) + if (target && target.tagName === 'circle') { + let value + const dataValue = target.getAttribute('data-value') + try { + value = dataValue + ? JSON.parse(dataValue).value + : dataValue + } catch { + value = dataValue + } + const parentElement = /** @type {HTMLElement} */ ( + target.parentNode + ) + parentElement.appendChild(target) + showTooltip( + e, + String(target.getAttribute('data-title')), + value + ) + return + } + hideTooltip() + }, + false + ) + + svg.addEventListener('mouseleave', () => { + hideTooltip() + }) + } + + /** + * Exibe o tooltip na posição do mouse. + * @param {MouseEvent} evt - O evento de mouse (para capturar clientX e clientY). + * @param {string} text - O título ou texto principal. + * @param {string|number} value - O valor a ser exibido. + * @returns {void} + */ + const showTooltip = (evt, text, value) => { + const tooltip = + /** @type {HTMLElement} - Cast necessário para garantir acesso a .style */ ( + document.querySelector('.tooltip') + ) + + if (!tooltip) return + + tooltip.innerHTML = ` +
+
${text}
+
${value}
+
` + + tooltip.style.display = 'block' + tooltip.style.left = evt.clientX + 20 + 'px' + tooltip.style.top = evt.clientY - 30 + 'px' + } + + /** + * Esconde o tooltip alterando o display para none. + * @returns {void} + */ + const hideTooltip = () => { + const tooltip = /** @type {HTMLElement} */ ( + document.querySelector('.tooltip') + ) + + if (tooltip) { + tooltip.style.display = 'none' + } + } + + const getWindowWidth = () => { + // TODO: define a function to be called here + // handleMapChange(datasetValues.value); + } + + window.addEventListener('resize', getWindowWidth) + + // TODO: update code to work with new defined states vars instead of props + watch( + () => mapData.value, + () => { + if (mapData.value) { + datasetValues.value = mapData.value + handleMapChange(mapData.value) + } + } + ) + + watch( + () => mapTooltip.value, + () => { + const query = mapTooltip.value.label + ? `[data-id="${mapTooltip.value.label}"]` + : `[data-title="${mapTooltip.value.name}"]` + let allCircle = /** @type {any} **/ ([ + ...document.querySelectorAll('circle'), + ]) + let circle = document.querySelector(query) + ? document.querySelector(query) + : allCircle.find((/** @type {any} **/ item) => { + try { + const id = item.dataset.id.includes( + mapTooltip.value.label + ) + const name = item.dataset.title.includes( + mapTooltip.value.name + ) + return name || id + } catch { + // Do Nothing + } + }) + + if (!circle) { + return + } + + if (mapTooltip.value.opened) { + circle.setAttribute('r', '9') + circle.setAttribute('opacity', '1') + circle.setAttribute('stroke', '#7a7a7a') + return + } + + circle.setAttribute('r', '6') + circle.setAttribute('opacity', '0.8') + circle.setAttribute('stroke', '#aaa') + } + ) + + return { + mapRangeSVG, + maxVal, + minVal, + } + }, + template: ` + + {{ maxVal }} + + {{ minVal }} + +
+ `, +}) diff --git a/src/components/main/card-components/map/map.js b/src/components/main/card-components/map/map.js new file mode 100644 index 0000000..2295f6f --- /dev/null +++ b/src/components/main/card-components/map/map.js @@ -0,0 +1,57 @@ +import { defineComponent, onMounted, watch } from 'vue' + +import { NSpin } from 'naive-ui' + +import { useMapStore, useContentStore } from '@/stores/index' +import { storeToRefs } from 'pinia' + +export default defineComponent({ + components: { NSpin }, + setup() { + const mapStore = useMapStore() + const { mapElement } = storeToRefs(mapStore) + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const updateSetMap = async () => { + await mapStore.updateMap() + await mapStore.setMapData() + } + + onMounted(async () => { + await updateSetMap() + }) + watch( + () => { + return [ + form.value.sickImmunizer, + form.value.dose, + form.value.type, + form.value.local, + form.value.granularity, + form.value.periodStart, + form.value.periodEnd, + ] + }, + async () => { + await updateSetMap() + } + ) + + watch( + () => form.value.period, + async (period) => { + mapStore.updatePeriodManual(period) + } + ) + + return { mapElement } + }, + template: ` +
+
+
+
+
+ `, +}) diff --git a/src/components/main/card-components/map/year-slider.js b/src/components/main/card-components/map/year-slider.js new file mode 100644 index 0000000..90ff09e --- /dev/null +++ b/src/components/main/card-components/map/year-slider.js @@ -0,0 +1,245 @@ +import { NCard, NSlider, NSpace, NButton, NIconWrapper, NIcon } from 'naive-ui' +import { defineComponent, ref, computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useContentStore } from '@/stores/index' +import { biCaretDown } from '@/icons' + +export default defineComponent({ + components: { + NCard, + NSlider, + NSpace, + NButton, + NIconWrapper, + NIcon, + }, + setup() { + const contentStore = useContentStore() + const { form, tabBy, mandatoryVaccineYears, yearSlideAnimation } = + storeToRefs(contentStore) + + const showSlider = ref(false) + const showTooltip = ref(false) + const stopPlayMap = ref(false) + + /** + * @param {string | null} period + */ + const setSliderValue = (period) => { + showSlider.value = + form.value.periodStart && form.value.periodEnd ? true : false + if (period) { + return Number(period) + } + } + + const max = computed(() => setSliderValue(form.value.periodEnd)) + const min = computed(() => setSliderValue(form.value.periodStart)) + + /** + * @type {import('vue').Ref} + */ + const valueMandatoryLabels = ref(null) + + const valueMandatory = computed(() => { + if (tabBy.value !== 'immunizers') { + return + } + + const sickImmunizer = form.value.sickImmunizer + const dose = form.value.dose ? form.value.dose : '1ª dose' + + if (mandatoryVaccineYears.value) { + const result = mandatoryVaccineYears.value.find( + (/** @type{string[]} */ el) => + el[0] === sickImmunizer && + (el[1] === dose || + (el[1] === 'Dose única' && dose === '1ª dose')) + ) + if (result) { + valueMandatoryLabels.value = [result[2], result[3]] + if ( + max.value && + min.value && + ((max.value && max.value <= result[3]) || + (min.value && min.value >= result[2])) + ) { + return [result[2], result[3]] + } else if ( + max.value && + max.value <= result[3] && + max.value >= result[2] + ) { + return result[3] + } else if ( + min.value && + min.value >= result[2] && + min.value <= result[3] + ) { + return result[2] + } + } + } + + return + }) + + const years = computed(() => { + let y = min.value + const result = [] + + if (y && max.value) { + while (y <= max.value) { + result.push(y++) + } + } + + return result + }) + + const waitFor = (/** @type{number} */ delay) => + new Promise((resolve) => setTimeout(resolve, delay)) + + const playMap = async () => { + showTooltip.value = true + yearSlideAnimation.value = true + for (let year of years.value) { + if (stopPlayMap.value) { + stopPlayMap.value = false + return + } + form.value.period = year + await waitFor(1000) + } + showTooltip.value = false + yearSlideAnimation.value = false + stopPlayMap.value = false + } + + /** + * @param {string} key + * @param {string} value + * @returns void + */ + const updateDate = (key, value) => { + contentStore.setFormField(key, value) + } + return { + max, + min, + valueMandatory, + showSlider, + showTooltip, + formatTooltip: () => { + if (valueMandatoryLabels.value && valueMandatoryLabels.value) { + return `Presente no calendário vacinal entre ${valueMandatoryLabels.value[0]} e ${valueMandatoryLabels.value[1]}` + } + }, + playMap, + yearSlideAnimation, + stopMap: () => { + stopPlayMap.value = true + showTooltip.value = false + yearSlideAnimation.value = false + }, + biCaretDown, + updateDate, + form, + } + }, + template: ` +
+ + + + + + +
+
+ {{ min }} +
+ + + + + + +
+ {{ max }} +
+
+
+ `, +}) diff --git a/src/components/main/card-components/sub-buttons.js b/src/components/main/card-components/sub-buttons.js new file mode 100644 index 0000000..c7255d9 --- /dev/null +++ b/src/components/main/card-components/sub-buttons.js @@ -0,0 +1,557 @@ +import { NButton, NIcon, NCard, NScrollbar, NTabs, NTabPane } from 'naive-ui' +import { defineComponent, ref, computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useContentStore, useMessageStore } from '@/stores' +import CanvasDownload from '@/canvas-download' + +//@ts-ignore +import CsvWriterGen from 'csvwritergen' + +import { formatToTable, formatDatePtBr } from '@/utils' + +import { + biBook, + biListUl, + biDownload, + biShareFill, + biFiletypeCsv, + biGraphUp, +} from '@/icons' + +import sbim from '@/assets/images/sbim.png' +import cc from '@/assets/images/cc.png' +import riAlertLine from '@/assets/images/ri-alert-line.svg' + +import ModalGeneric from '@/components/main/modal/modal-generic' +import ModalGenericWithTabs from '@/components/main/modal/modal-genetic-with-tabs' + +import Abandono from '@/assets/images/abandono.svg' +import Cobertura from '@/assets/images/cobertura.svg' +import HomGeo from '@/assets/images/hom_geo.svg' +import HomVac from '@/assets/images/hom_vac.svg' +import Meta from '@/assets/images/meta.svg' +import logo from '@/assets/images/logo-vacinabr.svg' + +export default defineComponent({ + components: { + NButton, + NCard, + NIcon, + NScrollbar, + NTabPane, + NTabs, + ModalGeneric, + ModalGenericWithTabs, + }, + setup() { + const messageStore = useMessageStore() + const contentStore = useContentStore() + + const { + csvAllDataLink, + csvRowsExceeded, + maxCsvExportRows, + selectsEmpty, + selectsPopulated, + aboutVaccines, + lastUpdateDate, + form, + tab, + mainTitle, + subTitle, + legend, + } = storeToRefs(contentStore) + + /** @type import('vue').Ref */ + const svg = ref(null) + /** @type import('vue').Ref */ + const chartPNG = ref(null) + /** @type import('vue').Ref */ + const chart = ref(null) + + /** @type import('vue').Ref */ + const showModal = ref(false) + /** @type import('vue').Ref */ + const showModalVac = ref(false) + /** @type import('vue').Ref */ + const loadingDownload = ref(false) + + const clickShowModal = () => { + const map = document.querySelector('#canvas') + svg.value = map?.innerHTML + const canvas = /** @type{any} */ (document.getElementById('chart')) + chartPNG.value = + canvas && ![...canvas.classList].includes('element-hidden') + ? canvas?.toDataURL('image/png', 1) + : null + showModal.value = true + } + + const aboutVaccinesContent = computed(() => { + const text = aboutVaccines.value + if (!text || !text.length) { + return + } + const div = document.createElement('div') + div.innerHTML = text[0].content.rendered + const result = [...div.querySelectorAll('table>tbody>tr')].map( + (tr) => { + return { + header: tr.querySelectorAll('td')[0].innerHTML, + content: tr.querySelectorAll('td')[1].innerHTML, + } + } + ) + return result + }) + + const clickShowVac = () => { + showModalVac.value = !showModalVac.value + } + + const copyCurrentLink = () => { + navigator.clipboard.writeText(window.location.href) + messageStore.message('success', 'Link copiado para o seu clipboard') + } + + const sendMail = () => { + document.location.href = + 'mailto:vacinabr@iqc.org.br?subject=Erro no VacinaBR&body=Sua Mensagem' + } + + const downloadCsv = async () => { + loadingDownload.value = true + const periodStart = form.value.periodStart + const periodEnd = form.value.periodEnd + let years = [] + if (periodStart && periodEnd) { + let y = Number(periodStart) + while (y <= Number(periodEnd)) { + years.push(y++) + } + } + + const currentResult = /** @type{any} */ ( + await contentStore.requestData({ detail: true, csv: true }) + ) + + if (currentResult && currentResult.aborted) { + return + } + if (currentResult && currentResult.error) { + loadingDownload.value = false + } + + if (!currentResult) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar csv' + ) + loadingDownload.value = false + return + } + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'csv', + link_text: 'Dados utilizados na interface em CSV', + file_name: 'tabela.csv', + }) + } + + const tableData = formatToTable( + currentResult.result.data, + currentResult.localNames, + currentResult.metadata + ) + + const header = tableData.header.map((x) => Object.values(x)[0]) + const type = form.value.type + header[header.findIndex((head) => head === 'Valor')] = type + const rows = tableData.rows.map((x) => Object.values(x)) + if (type == 'Doses aplicadas') { + const index = header.findIndex( + (column) => column === 'Doses (qtd)' + ) + header.splice(index, 1) + rows.forEach((row) => row.splice(index, 1)) + } + const csvwriter = new CsvWriterGen(header, rows) + csvwriter.anchorElement('tabela') + loadingDownload.value = false + } + + const goToCCLink = () => { + window.open('https://creativecommons.org/licenses/by/4.0/') + } + + const downloadPng = async () => { + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'png', + link_text: 'Mapa PNG', + file_name: 'mapa.png', + }) + } + + if (!selectsPopulated.value) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar mapa' + ) + return + } + + const svgElement = /** @type{Element} */ ( + document.querySelector('#canvas>svg') + ) + const svgContent = new XMLSerializer().serializeToString(svgElement) + + // Convert SVG content to a data URL + const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' }) + const svgUrl = URL.createObjectURL(svgBlob) + + const images = [ + { image: svgUrl, height: 650, width: 650 }, + { image: logo, height: 53, width: 218, posX: 5, posY: 642 }, + ] + + const type = form.value.type + let legendSvg + + if (type === 'Abandono') { + legendSvg = Abandono + } else if (type === 'Cobertura') { + legendSvg = Cobertura + } else if (type === 'Homogeneidade geográfica') { + legendSvg = HomGeo + } else if (type === 'Homogeneidade entre vacinas') { + legendSvg = HomVac + } else if (type === 'Meta atingida') { + legendSvg = Meta + } + + if (legendSvg && tab.value === 'map') { + images.push({ + image: legendSvg, + width: 293, + height: 88, + posX: 1080, + posY: 622, + }) + } + const canvasDownload = new CanvasDownload(images, { + title: mainTitle.value, + subTitle: subTitle.value, + source: legend + '.', + }) + await canvasDownload.download() + } + + const downloadSvg = () => { + if (!selectsPopulated.value) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar mapa' + ) + return + } + + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'svg', + link_text: 'Mapa SVG', + file_name: 'mapa.svg', + }) + } + + const svgElement = /** @type{Element} */ ( + document.querySelector('#canvas') + ) + const svgData = svgElement.innerHTML + const svgBlob = new Blob([svgData], { + type: 'image/svg+xml;charset=utf-8', + }) + const svgUrl = URL.createObjectURL(svgBlob) + const downloadLink = document.createElement('a') + downloadLink.href = svgUrl + downloadLink.download = 'mapa.svg' + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) + } + + const downloadCsvAll = () => { + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'zip', + link_text: 'Dados completos em CSV', + file_name: 'vacinabr.zip', + link_url: '/wp-content/uploads/vacinabr/vacinabr.zip', + }) + } + + // @ts-ignore + window.open(csvAllDataLink.value, '_blank') + } + + const downloadChartAsImage = async () => { + const imageLink = document.createElement('a') + imageLink.download = 'chart.png' + if (!chartPNG.value) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar imagem' + ) + return + } + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'png', + link_text: 'Chart PNG', + file_name: 'image.png', + }) + } + + const canvasDownload = new CanvasDownload( + [ + { image: chartPNG.value }, + { image: logo, height: 53, width: 218, posX: 5, posY: 842 }, + ], + { + title: mainTitle.value, + subTitle: subTitle.value, + source: legend.value + '.', + canvasHeight: 900, + yTextSource: 894, + } + ) + await canvasDownload.download() + } + + return { + aboutVaccinesContent, + biBook, + biDownload, + biFiletypeCsv, + biGraphUp, + biListUl, + biShareFill, + cc, + clickShowModal, + clickShowVac, + copyCurrentLink, + csvRowsExceeded, + downloadCsv, + downloadPng, + formatDatePtBr, + goToCCLink, + lastUpdateDate, + legend, + loadingDownload, + maxCsvExportRows, + riAlertLine, + sbim, + selectsEmpty, + sendMail, + showModal, + showModalVac, + svg, + tab, + downloadSvg, + downloadCsvAll, + downloadChartAsImage, + chartPNG, + } + }, + template: ` +
+ + + +
Faça o download de conteúdos
+
+ + +
+
Dados
+
+ +
+
+
+ +
+
+

Dados utilizados na interface em CSV

+

Os dados que estão sendo utilizados nesta interface

+
+
+ + +   Baixar + +
+
+ +
+
+
+ +
+
+

Dados completos em CSV

+

Todos os dados por município da plataforma vacinaBR

+
+
+ + +   Baixar + +
+
+
+
+
Licença:
+
+ +
+
+
+
+ `, +}) diff --git a/src/components/main/card-components/sub-select.js b/src/components/main/card-components/sub-select.js new file mode 100644 index 0000000..9a0c6ef --- /dev/null +++ b/src/components/main/card-components/sub-select.js @@ -0,0 +1,763 @@ +import { + defineComponent, + ref, + toRaw, + computed, + watch, + onMounted, + onBeforeUnmount, + nextTick, + h, +} from 'vue' + +import { + NSelect, + NFormItem, + NDatePicker, + NButton, + NIcon, + NSpin, + NSpace, + NTooltip, +} from 'naive-ui' + +import { storeToRefs } from 'pinia' +import { useContentStore } from '@/stores' +import { biEraser } from '@/icons' + +import { useMessageStore } from '@/stores' + +export default defineComponent({ + components: { + NButton, + NDatePicker, + NFormItem, + NIcon, + NSelect, + NSpace, + NSpin, + NTooltip, + }, + props: { + isMobileScreen: { + default: false, + type: Boolean, + }, + }, + setup(props) { + const messageStore = useMessageStore() + /** @type {any[]} */ + const allCitiesValues = [] + + const contentStore = useContentStore() + const { form, tab, tabBy, disableLocalSelect, yearSlideAnimation } = + storeToRefs(contentStore) + + /** @type import('vue').Ref */ + const activeSelectKey = ref({}) + /** @type import('vue').Ref */ + const citiesTemp = ref([]) + /** @type import('vue').Ref */ + const cityTemp = ref(null) + /** @type import('vue').Ref */ + const firstLoadCities = ref(true) + /** @type import('vue').Ref */ + const formRef = ref(null) + /** @type import('vue').Ref */ + const isLoadingCities = ref(false) + /** @type import('vue').Ref */ + const localTemp = ref(null) + /** @type import('vue').Ref */ + const resizeObserver = ref(null) + /** @type import('vue').Ref */ + const selectRefsMap = ref({}) + /** @type import('vue').Ref */ + const sickTemp = ref(null) + /** @type import('vue').Ref */ + const showingLocalsOptions = ref(null) + /** @type import('vue').Ref */ + const showingSicksOptions = ref(null) + + /** @type import('vue').Ref */ + const showCitiesSelect = ref(false) + + // Computed + const disableAll = computed(() => yearSlideAnimation.value) + + const styleWidth = computed(() => + props.isMobileScreen ? 'width: 400px;' : 'width: 200px;' + ) + + /** + * @param {string} key + * @param {string} value + * @returns void + */ + const updateDate = (key, value) => { + contentStore.setFormField(key, value) + updateDatePosition() + } + + const updateDatePosition = () => { + const formValue = form.value + const endDate = formValue.periodEnd + const startDate = formValue.periodStart + const tsEndDate = endDate + const tsStartDate = startDate + + if (!tsStartDate || !tsEndDate) { + return + } else if (tsStartDate > tsEndDate) { + contentStore.setFormField('periodEnd', startDate) + contentStore.setFormField('periodStart', endDate) + return + } + } + /** + * Select all states function + * @param {string} field + */ + const selectAllLocals = (field) => { + const formValue = form.value + const allOptions = toRaw(formValue.locals) + const selectLength = Array.isArray(localTemp.value) + ? localTemp.value.length + : null + if (selectLength == allOptions.length) { + localTemp.value = [] + handleShowUpdate(true, field) + return + } + + localTemp.value = allOptions.map((option) => option.value) + // handleShowUpdate(true, field) + } + /** + * @param {Boolean} show + * @param {String} key + */ + const handleShowUpdate = (show, key) => { + if (show) { + activeSelectKey.value = key + } else if (activeSelectKey.value === key) { + activeSelectKey.value = null + } + } + /** + * Select all cities function + * @param {String} field + * @param {Boolean} uncheckAll + */ + const selectAllCities = (field, uncheckAll = false) => { + const formValue = form.value + if (isLoadingCities.value) { + return + } + isLoadingCities.value = true + + // We use setTimeout to run this code after Vue render process + setTimeout(() => { + const selectLength = Array.isArray(cityTemp.value) + ? cityTemp.value.length + : null + + if (Array.isArray(form.value.local) && form.value.local.length) { + + if ( + Array.isArray(cityTemp.value) && + Array.isArray(citiesTemp.value) && + cityTemp.value.length === citiesTemp.value.length + ) { + formValue.city = [] + cityTemp.value = [] + handleShowUpdate(true, field) + isLoadingCities.value = false + return + } + + let result = /** @type{string[]} */ ([]) + form.value.local.forEach((/** @type{string} **/ state) => { + form.value.cities.filter((/** @type{{ uf: string }} */ item) => item.uf === state) + const cities = /** @type{string[]} */ ( + form.value.cities.filter((/** @type{{ uf: string }} */ item) => item.uf === state).map(city => city.codigo6) + ) + result.push(...cities) + }) + formValue.city = result + cityTemp.value = result + } else { + const allOptions = toRaw(citiesTemp.value) + + if (selectLength === allOptions.length || uncheckAll) { + formValue.city = [] + cityTemp.value = [] + handleShowUpdate(true, field) + isLoadingCities.value = false + return + } + + formValue.city = allCitiesValues + cityTemp.value = allCitiesValues + } + + handleShowUpdate(true, field) + isLoadingCities.value = false + }, 0) + } + /** + * @param {Boolean} show + * @param {string} field + */ + const handleLocalsUpdateShow = (show, field) => { + showingLocalsOptions.value = show + if (!showingLocalsOptions.value && localTemp.value) { + contentStore.setFormField('local', localTemp.value) + } + handleShowUpdate(show, field) + } + /** + * @param {String} value + */ + const handleLocalsUpdateValue = (value) => { + localTemp.value = value + if (!showingLocalsOptions.value && localTemp.value) { + contentStore.setFormField('local', localTemp.value) + } + // Close hover box options remover - Mantido o comentário + } + /** + * @param {Boolean} show + * @param {String} field + */ + const handleSicksUpdateShow = (show, field) => { + showingSicksOptions.value = show + + if ( + !showingSicksOptions.value && + sickTemp.value && + tab.value !== 'map' + ) { + contentStore.setFormField('sickImmunizer', sickTemp.value) + } + handleShowUpdate(show, field) + } + /** + * @param {String} value + */ + const handleSicksUpdateValue = (value) => { + sickTemp.value = value + if (!showingSicksOptions.value && sickTemp.value) { + contentStore.setFormField('sickImmunizer', value) + } + // Close hover box options remover - Mantido o comentário + } + + const eraseForm = () => { + contentStore.clear() + } + + /** + * @param {String} key + */ + const clear = (key) => { + if (key === 'sickImmunizer') { + sickTemp.value = null + contentStore.setFormField('sickImmunizer', null) + } else if (key === 'dose') { + contentStore.setFormField('dose', null) + } else if (key === 'type') { + contentStore.setFormField('type', null) + } + } + + /** + * @param {Number} timeInMs + * @returns {Promise} + */ + const wait = (timeInMs) => { + return new Promise( + /** @param {function(): void} resolve */ + (resolve) => { + setTimeout(() => { + resolve() + }, timeInMs) + } + ) + } + + const showCitiesSelectUpdate = async () => { + await wait(100) + const granValue = form.value.granularity + if ( + granValue && + granValue.toLowerCase() === 'municípios' && + tab.value !== 'map' + ) { + showCitiesSelect.value = true + return + } + showCitiesSelect.value = false + } + + /** + * @param {String} value + */ + const disableStateCitiesSelector = (value) => { + const formValue = form.value + if (tab.value === 'table') { + formValue.city = value + if ( + formValue.cities && + formValue.cities.some((item) => item.disabled === true) + ) { + formValue.cities.forEach((item) => { + item.disabled = false + item.disabledText = '' + }) + } + return + } + + if (!value) { + return + } + + const valueLength = value.length + + const maxSelection = 30 + + if (valueLength <= maxSelection) { + formValue.city = value + if ( + formValue.cities && + formValue.cities.some((item) => item.disabled === true) + ) { + formValue.cities.forEach((item) => { + item.disabled = false + item.disabledText = '' + }) + } + if (valueLength === maxSelection) { + formValue.cities.forEach((item) => { + if (!value.includes(item.codigo6)) { + item.disabled = true + item.disabledText = 'Limite de seleções atingido' + } + }) + } + } + + if (valueLength > maxSelection) { + formValue.city = value.slice(0, maxSelection) + cityTemp.value = formValue.city + messageStore.message('info', 'Valores de seletor de municípios foram atualizado para limites de gráfico') + } + } + + const handleCitiesUpdateValue = (/** @type String */ value) => { + disableStateCitiesSelector(value) + } + + /** + * @param {String} str + */ + const removeAccents = (str) => { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + } + + /** + * @param {String} pattern + * @param {{ label: string, value: string}} option + */ + const customFilter = (pattern, option) => { + const optionLabel = option.label || '' + const normalizedPattern = removeAccents(pattern).toLowerCase() + const normalizedLabel = removeAccents(optionLabel).toLowerCase() + + return normalizedLabel.includes(normalizedPattern) + } + + /** + * @type {( + * payload: { + * node: import('vue').VNode, + * option: { disabledText: string, disabled: boolean } + * } + * ) => import('vue').VNode | string} + */ + const renderOption = ({ node, option }) => { + if (!option.disabled) { + return node + } + return h( + NTooltip, + { + style: '', + delay: 500, + }, + { + trigger: () => node, + default: () => option.disabledText, + } + ) + } + + // Watchers + watch( + () => props.isMobileScreen, + async () => { + const loc = form.value.local + if (loc) { + citiesTemp.value = form.value.cities.filter((city) => + loc.includes(city.uf) + ) + } + await showCitiesSelectUpdate() + }, + { + deep: true, + immediate: true, + } + ) + + watch( + () => form.value.local, + (loc) => { + localTemp.value = loc + + isLoadingCities.value = true + + setTimeout(async () => { + if (!loc || !loc.length) { + citiesTemp.value = form.value.cities + cityTemp.value = [] + form.value.city = [] + } else { + const rawCities = toRaw(form.value.cities) + const locSet = new Set(loc) + + citiesTemp.value = rawCities.filter((city) => + locSet.has(city.uf) + ) + + if (form.value.city?.length) { + const citiesTempSet = new Set( + citiesTemp.value.map( + (/** @type {{ value:string }} */ item) => + item.value + ) + ) + + const rawCityValue = toRaw(form.value.city) + + form.value.city = rawCityValue.filter( + (/** @type {{ value:string }} */ itemA) => + citiesTempSet.has(itemA) + ) + cityTemp.value = form.value.city + } + + disableStateCitiesSelector(cityTemp.value) + } + + await showCitiesSelectUpdate() + isLoadingCities.value = false + }, 0) + }, + { deep: true } + ) + + watch( + () => form.value.cities, + (newCities) => { + if (!newCities) { + return + } + if (firstLoadCities.value) { + citiesTemp.value = newCities + firstLoadCities.value = false + for (let i = 0; i < newCities.length; i++) { + allCitiesValues.push(newCities[i].value) + } + } + } + ) + + watch( + () => tab.value, + async () => { + disableStateCitiesSelector(cityTemp.value) + await showCitiesSelectUpdate() + } + ) + + watch( + () => form.value.sickImmunizer, + (sic) => { + sickTemp.value = sic + } + ) + + watch( + () => form.value.granularity, + async () => { + await showCitiesSelectUpdate() + } + ) + + onMounted(async () => { + if (formRef.value) { + const mainContainer = formRef.value.closest('.main') + if (mainContainer) { + resizeObserver.value = new ResizeObserver( + updateDropdownPosition + ) + resizeObserver.value.observe(mainContainer) + } + } + // Update values if user is resizing window to mobile size + if (form.value.sickImmunizer) { + sickTemp.value = form.value.sickImmunizer + } + if (form.value.local) { + localTemp.value = form.value.local + } + if (form.value.city) { + cityTemp.value = form.value.city + } + }) + + onBeforeUnmount(() => { + sickTemp.value = form.value.sickImmunizer + localTemp.value = form.value.local + + if (resizeObserver.value) { + resizeObserver.value.disconnect() + } + }) + + const updateDropdownPosition = () => { + const key = activeSelectKey.value + + const selectedRef = selectRefsMap.value[key] + if (key && selectedRef) { + const activeSelect = selectedRef + activeSelect.blur() + nextTick(() => { + activeSelect.handleTriggerClick() + }) + } + } + + return { + biEraser, + citiesTemp, + cityTemp, + clear, + customFilter, + disableAll, + disableLocalSelect, + eraseForm, + form, + formRef, + handleCitiesUpdateValue, + handleLocalsUpdateShow, + handleLocalsUpdateValue, + handleShowUpdate, + handleSicksUpdateShow, + handleSicksUpdateValue, + isLoadingCities, + localTemp, + renderOption, + selectAllCities, + selectAllLocals, + selectRefsMap, + showCitiesSelect, + sickTemp, + styleWidth, + tab, + tabBy, + updateDatePosition, + contentStore, + updateDate, + } + }, + template: ` +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+ `, +}) diff --git a/src/components/main/card-components/table.js b/src/components/main/card-components/table.js new file mode 100644 index 0000000..a560008 --- /dev/null +++ b/src/components/main/card-components/table.js @@ -0,0 +1,119 @@ +import { + defineComponent, + computed, + onBeforeMount, + onUnmounted, + watch, +} from 'vue' + +import { NButton, NDataTable, NEmpty, NSelect } from 'naive-ui' + +import { storeToRefs } from 'pinia' +import { useContentStore, useTableStore } from '@/stores' + +export default defineComponent({ + components: { + NButton, + NDataTable, + NEmpty, + NSelect, + }, + setup() { + const contentStore = useContentStore() + const { form, loading } = storeToRefs(contentStore) + + const formPopulated = computed(() => contentStore.selectsPopulated) + + const tableStore = useTableStore() + const { columns, page, pageCount, pageTotalItems, rows, sorter } = + storeToRefs(tableStore) + + const pagination = computed(() => ({ + page: page.value, + pageCount: pageCount.value, + pageSize: 10, + pageSlot: 7, + pageTotalItems: pageTotalItems.value, + simple: true, + prev: () => '🠐 anterior', + next: () => 'seguinte 🠒', + })) + + onBeforeMount(() => { + tableStore.setTableData() + }) + + onUnmounted(() => { + tableStore.resetState() + }) + + watch( + () => form.value, + async () => { + // Avoid render before change tab + if (Array.isArray(form.value.sickImmunizer)) { + page.value = 1 + await tableStore.setTableData() + } + }, + { deep: true } + ) + + /** + * @param {number} newPage + */ + const handlePageChange = async (newPage) => { + page.value = newPage + await tableStore.setTableData() + } + + /** + * @param {{ columnKey: string; order: string }} newSorter + */ + const handleSorterChange = async (newSorter) => { + sorter.value = newSorter + if (!newSorter.order) { + sorter.value = undefined + } + await tableStore.setTableData() + } + + return { + columns, + loading, + pagination, + rows, + handlePageChange, + handleSorterChange, + formPopulated, + } + }, + template: ` +
+ +
+ +
+
+
+ `, +}) diff --git a/src/components/main/card.js b/src/components/main/card.js new file mode 100644 index 0000000..c4c6790 --- /dev/null +++ b/src/components/main/card.js @@ -0,0 +1,127 @@ +import { defineComponent, onMounted, ref } from 'vue' +import { NCard, NButton, NIcon, NModal, NSkeleton, NSpin } from 'naive-ui' +import { useContentStore, useMapStore } from '@/stores' + +import { storeToRefs } from 'pinia' + +import Chart from '@/components/main/card-components/chart' +import FilterSuggestion from '@/components/main/card-components/filter-suggestion' +import Map from '@/components/main/card-components/map' +import SubSelect from '@/components/main/card-components/sub-select' +import Table from '@/components/main/card-components/table' +import SubButtons from '@/components/main/card-components/sub-buttons' + +export default defineComponent({ + components: { + Chart, + FilterSuggestion, + Map, + NButton, + NCard, + NIcon, + NModal, + NSkeleton, + NSpin, + SubButtons, + SubSelect, + Table, + }, + props: { api: { type: String, required: true } }, + setup() { + const isMobileScreen = ref(false) + const showModal = ref(false) + + const getWindowWidth = () => { + isMobileScreen.value = window.innerWidth <= 1368 + } + window.addEventListener('resize', getWindowWidth) + + const storeContent = useContentStore() + const { mainTitle, subTitle, loading, tab, selectsEmpty } = + storeToRefs(storeContent) + + const storeMap = useMapStore() + const { loadingMap } = storeToRefs(storeMap) + + onMounted(() => { + getWindowWidth() + }) + return { + isMobileScreen, + loading, + loadingMap, + mainTitle, + selectsEmpty, + showModal, + subTitle, + tab, + } + }, + template: ` +
+
+ +
+ +
+
+
+ + +
+

{{ mainTitle }}

+ +

{{ subTitle }}

+ +
+
+ + + +
+ +
+
+
+ +
+
+ `, +}) diff --git a/src/components/main/index.js b/src/components/main/index.js new file mode 100644 index 0000000..0e5eba2 --- /dev/null +++ b/src/components/main/index.js @@ -0,0 +1,158 @@ +import ModalCaller from '@/components/main/modal/modal-caller' +import MainCard from '@/components/main/card' +import { biMap, biGraphUp, biTable } from '@/icons' +import { computed, defineComponent, onBeforeMount, onMounted, watch } from 'vue' +import { storeToRefs } from 'pinia' +import { useContentStore } from '@/stores/content' +import { useMessageStore } from '@/stores/message' +import { + NButton, + NEmpty, + NIcon, + NScrollbar, + NSkeleton, + NTab, + NTabPane, + NTabs, + NTooltip, + useMessage, +} from 'naive-ui' + +export default defineComponent({ + components: { + NTabs, + NTabPane, + NTab, + NButton, + NIcon, + NScrollbar, + NTooltip, + NSkeleton, + NEmpty, + MainCard, + ModalCaller, + }, + props: { api: { type: String, required: true } }, + setup(props) { + const messageNaive = useMessage() + + const storeMessage = useMessageStore() + const { type, text, duration } = storeToRefs(storeMessage) + + const contentStore = useContentStore() + + const { + apiUrl, + disableChart, + disableMap, + tab, + tabBy, + yearSlideAnimation, + form, + } = storeToRefs(contentStore) + + const disableAll = computed(() => yearSlideAnimation.value) + + onBeforeMount(async () => { + apiUrl.value = props.api + contentStore.initial({ map: 'BR' }) + // Initialize filters, internal vars and modal contents + await Promise.all([ + contentStore.requestJson('dose-blocks'), + contentStore.requestJson('granularity-blocks'), + contentStore.requestJson('link-csv'), + contentStore.requestJson('mandatory-vaccinations-years'), + contentStore.requestJson('lastupdatedate'), + contentStore.requestJson('auto-filters'), + contentStore.requestJson('acronyms'), + contentStore.requestPage('slug=sobre-vacinas-vacinabr'), + ]) + }) + + onMounted(async () => { + await contentStore.updateFormSelect() + contentStore.setStateFromUrl() + }) + + // Update URL from state form and tabs changes + watch( + () => [ + form.value.dose, + form.value.granularity, + form.value.granularity, + form.value.local, + form.value.period, + form.value.periodEnd, + form.value.periodStart, + form.value.sickImmunizer, + form.value.type, + // TODO: define if cities will be in URL state + // form.city, + tab.value, + tabBy.value, + ], + () => { + contentStore.setUrlFromState() + } + ) + + // Show messages with useMessageStore state updates + storeMessage.$subscribe(() => { + if (text.value && type.value) { + messageNaive.create(text.value, { + type: type.value, + duration: duration.value ?? 3000, + }) + } + // Empty message state + storeMessage.clear() + }) + + return { + tabBy, + tab, + disableAll, + disableMap, + disableChart, + biMap, + biGraphUp, + biTable, + contentStore, + } + }, + template: ` +
+
+
+
+ + + + + +
+
+
+ + + + + Mapa + + + + Gráfico + + + + Tabela + + +
+
+
+ +
+ + `, +}) diff --git a/src/components/main/modal/index.js b/src/components/main/modal/index.js new file mode 100644 index 0000000..7378ed7 --- /dev/null +++ b/src/components/main/modal/index.js @@ -0,0 +1,51 @@ +import { NModal, NScrollbar } from 'naive-ui' +import { computed, defineComponent } from 'vue' + +export default defineComponent({ + components: { + NModal, + NScrollbar, + }, + props: { + show: { + type: Boolean, + }, + title: { + type: String, + }, + }, + setup(props, { emit }) { + const showModal = computed({ + get() { + return props.show + }, + set(value) { + emit('update:show', value) + }, + }) + return { + bodyStyle: { + maxWidth: '900px', + }, + showModal, + } + }, + template: ` + + +
+ +
+
+
+ `, +}) diff --git a/src/components/main/modal/modal-caller.js b/src/components/main/modal/modal-caller.js new file mode 100644 index 0000000..6cb5d65 --- /dev/null +++ b/src/components/main/modal/modal-caller.js @@ -0,0 +1,47 @@ +import ModalGeneric from '@/components/main/modal/modal-generic' +import ModalGenericWithTabs from '@/components/main/modal/modal-genetic-with-tabs' +import { computed, defineComponent } from 'vue' +import { storeToRefs } from 'pinia' +import { useModalStore } from '@/stores' + +export default defineComponent({ + components: { ModalGeneric, ModalGenericWithTabs }, + props: {}, + setup() { + const contentStore = useModalStore() + const { + genericModal, + genericModalLoading, + genericModalShow, + genericModalTitle, + } = storeToRefs(contentStore) + + const loading = computed(() => genericModalLoading.value) + const title = computed(() => genericModalTitle.value) + const modalContent = computed(() => { + const text = genericModal.value + if (!text || !text.length) { + return + } + const div = document.createElement('div') + div.innerHTML = text[0].content.rendered + + if (div.querySelector('table')) { + const trs = div.querySelectorAll('table>tbody>tr') + const result = Array.from(trs).map((tr) => ({ + header: tr.querySelectorAll('td')[0].innerHTML, + content: tr.querySelectorAll('td')[1].innerHTML, + })) + return result + } + + return text[0].content.rendered + }) + + return { loading, modalContent, genericModalShow, title } + }, + template: ` + + + `, +}) diff --git a/src/components/main/modal/modal-generic.js b/src/components/main/modal/modal-generic.js new file mode 100644 index 0000000..6893209 --- /dev/null +++ b/src/components/main/modal/modal-generic.js @@ -0,0 +1,76 @@ +import Modal from '@/components/main/modal' +import { NEmpty, NScrollbar, NSkeleton } from 'naive-ui' +import { useSlots, computed, defineComponent } from 'vue' + +export default defineComponent({ + components: { + Modal, + NScrollbar, + NSkeleton, + NEmpty, + }, + props: { + show: { type: Boolean }, + loading: { type: Boolean }, + title: { type: String }, + modalContent: { type: String }, + }, + setup(props, { emit }) { + const slots = useSlots() + + const showModal = computed({ + get() { + return props.show + }, + set(value) { + emit('update:show', value) + }, + }) + + const hasContent = computed(() => { + const defaultSlot = slots.default ? slots.default() : [] + return defaultSlot.length > 0 + }) + + return { showModal, hasContent } + }, + template: ` + + + + + + + `, +}) diff --git a/src/components/main/modal/modal-genetic-with-tabs.js b/src/components/main/modal/modal-genetic-with-tabs.js new file mode 100644 index 0000000..5ff6d86 --- /dev/null +++ b/src/components/main/modal/modal-genetic-with-tabs.js @@ -0,0 +1,63 @@ +import { computed, defineComponent } from 'vue' + +import { NModal, NScrollbar, NTabs, NTabPane, NSpin } from 'naive-ui' + +export default defineComponent({ + components: { + NModal, + NScrollbar, + NTabs, + NTabPane, + NSpin, + }, + props: { + modalContent: { type: Array }, + show: { type: Boolean }, + title: { type: String }, + }, + setup(props, { emit }) { + const showModal = computed({ + get() { + return props.show + }, + set(value) { + emit('update:show', value) + }, + }) + + return { + bodyStyle: { maxWidth: '900px' }, + showModal, + items: computed(() => props.modalContent), + } + }, + template: ` + +
+ + + +
+
+
+
+
+ +
+
+
+ `, +}) diff --git a/src/components/sub-select.js b/src/components/sub-select.js deleted file mode 100644 index f5f978c..0000000 --- a/src/components/sub-select.js +++ /dev/null @@ -1,326 +0,0 @@ -import { ref, watch, computed, toRaw, onBeforeMount, h } from "vue/dist/vue.esm-bundler"; -import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon } from "naive-ui"; -import { useStore } from 'vuex'; -import { computedVar } from "../utils"; -import { biEraser } from "../icons.js"; - -export const subSelect = { - components: { - NSelect, - NFormItem, - NDatePicker, - NButton, - NIcon - }, - props: { - modal: { - default: false, - type: Boolean, - }, - }, - setup (props) { - const store = useStore(); - const tab = computed(() => store.state.content.tab); - const tabBy = computed(() => store.state.content.tabBy); - const sickTemp = ref(null); - const localTemp = ref(null); - const disableLocalSelect = computed(() => store.getters[`content/disableLocalSelect`]); - const sick = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sickImmunizer" })); - const sicks = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sicks" })); - const immunizers = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "immunizers" })); - const type = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "type" })); - const types = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "types" })); - const local = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "local" })); - const locals = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "locals" })) - const dose = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "dose" })); - const doses = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "doses" })) - const period = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "period" })); - const granularity = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "granularity" })); - const granularities = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "granularities" })); - const periodStart = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "periodStart" })); - const periodEnd = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "periodEnd" })) - const years = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "years" })) - - const showingLocalsOptions = ref(null); - const showingSicksOptions = ref(null); - - const updateDatePosition = () => { - const endDate = periodEnd.value - const startDate = periodStart.value - const tsEndDate = endDate - const tsStartDate = startDate - if (!tsStartDate || !tsEndDate) { - return - } - if (tsStartDate > tsEndDate) { - periodEnd.value = startDate - periodStart.value = endDate - } - } - - const selectAllLocals = (options) => { - const allOptions = toRaw(options).filter((option) => option.value !== "Todos") - const selectLength = Array.isArray(localTemp.value) ? localTemp.value.length : null - if (selectLength == allOptions.length) { - localTemp.value = []; - return; - } - - localTemp.value = allOptions.map(x => x.value); - } - - const handleLocalsUpdateShow = (show) => { - showingLocalsOptions.value = show; - if (!showingLocalsOptions.value && localTemp.value) { - local.value = localTemp.value; - } - }; - - const handleLocalsUpdateValue = (value) => { - if (toRaw(value).includes("Todos")) { - selectAllLocals(locals.value); - return; - } - - localTemp.value = value; - if (!showingLocalsOptions.value && localTemp.value){ - local.value = localTemp.value; - } - const nPopover = document.querySelector(".n-popover"); - if (nPopover) { - nPopover.innerHTML = ""; - } - }; - - const handleSicksUpdateShow = (show) => { - showingSicksOptions.value = show; - - if (!showingSicksOptions.value && sickTemp.value && tab.value !== "map") { - sick.value = sickTemp.value; - } - }; - - const handleSicksUpdateValue = (value) => { - sickTemp.value = value; - if (!showingSicksOptions.value && sickTemp.value) { - sick.value = value; - } - const nPopover = document.querySelector(".n-popover"); - if (nPopover) { - nPopover.innerHTML = ""; - } - }; - - const eraseForm = () => { - store.commit("content/CLEAR_STATE"); - } - - const clear = (key) => { - if (key === "sickImmunizer") { - sickTemp.value = null; - sick.value = null; - } else if (key === "dose") { - dose.value = null; - } else if (key === "type") { - type.value = null; - } - } - - const styleWidth = props.modal ? "width: 400px;" : "width: 225px;"; - - watch( - () => store.state.content.form.local, - (loc) => { - localTemp.value = loc - } - ); - - watch( - () => store.state.content.form.sickImmunizer, - (sic) => { - sickTemp.value = sic - } - ); - - onBeforeMount(() => { - sickTemp.value = store.state.content.form.sickImmunizer; - localTemp.value = store.state.content.form.local; - }); - - return { - selectAllLocals, - handleLocalsUpdateShow, - handleLocalsUpdateValue, - handleSicksUpdateShow, - handleSicksUpdateValue, - type, - types, - local, - locals, - dose, - doses, - sick, - sicks, - periodStart, - periodEnd, - period, - years, - granularity, - granularities, - localTemp, - sickTemp, - updateDatePosition, - tab, - tabBy, - immunizers, - biEraser, - eraseForm, - clear, - disableAll: computed(() => store.state.content.yearSlideAnimation), - disableLocalSelect, - styleWidth, - modalContentGlossary: computed(() => { - const text = store.state.content.about; - let result = ""; - // TODO: Links inside text should be clickable - for (let [key, val] of Object.entries(text)){ - let validUrl = null; - let valFomated = val.replace(/\n/gi, "

"); - try { - validUrl = new URL(val); - } - catch (e) { - //Do nothing - } - if (validUrl) { - valFomated = `Acessar arquivo` - } - result += `

${key}

${valFomated}

`; - } - return result; - }), - renderOption: ({ node, option }) => { - if (!option.disabled) { - return node; - } - return h(NTooltip, { - style: "", - delay: 500 - }, { - trigger: () => node, - default: () => option.disabledText - }) - }, - } - }, - template: ` -
- - - - - - - - - - - - - - - - - - - - - - - -
- `, -} diff --git a/src/data-fetcher.js b/src/data-fetcher.js index 997f0a8..49c1e6b 100644 --- a/src/data-fetcher.js +++ b/src/data-fetcher.js @@ -1,28 +1,150 @@ +/** + * @typedef {{ + * signal?: AbortSignal, + * body?: object, + * method?: string, + * headers?: Object. + * }} FetchOptions + */ + +/** + * Utility class for fetching data from an API, formatted for WordPress-style endpoints. + * @export + */ export class DataFetcher { - constructor(api) { - this.api = api; - } - - async requestData(endPoint, apiPoint = "/wp-json/api/v1/") { - const self = this; - - try { - const response = await fetch(self.api + apiPoint + endPoint); - const data = await response.json(); - return data; - } catch (error) { - return { error } + /** + * Creates an instance of DataFetcher. + * @param {string} api - The base URL of the API (e.g., 'https://example.com'). + */ + constructor(api) { + /** @type {string} */ + this.api = api } - } - async request(endPoint) { - const result = await this.requestData(endPoint); - return result; - } + /** + * Fetches data using a GET request. + * Designed for simple requests where parameters are in the URL. + * + * @async + * @param {string} endPoint - The specific API endpoint to fetch (e.g., 'posts'). + * @param {string} [apiPoint="/wp-json/api/v1/"] - The API path prefix. + * @param {AbortSignal} [signal] - An optional AbortSignal to cancel the request. + * @returns {Promise<{ [key: string]: any, error?: Error, aborted?: boolean }>} + * an abort object, or an error object. + */ + async requestData(endPoint, apiPoint = '/wp-json/api/v1/', signal) { + try { + const url = this.api + apiPoint + endPoint + const options = signal ? { signal } : undefined + const response = await fetch(url, options) - async requestSettingApiEndPoint(endPoint, apiEndpoint) { - const result = await this.requestData(endPoint, apiEndpoint); - return result; - } -} + const data = await response.json() + return data + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return { aborted: true } + } + return { error } + } + return { error: new Error(String(error)) } + } + } + + // TODO: Maybe we will need to do more complex filters with args in request body + /** + * Fetches data with advanced options, allowing for custom methods, headers, and a request body. + * + * @async + * @param {string} endPoint - The specific API endpoint. + * @param {FetchOptions} [options={}] - Fetch options including signal, body, method, and headers. + * @param {string} [apiPoint="/wp-json/api/v1/"] - The API path prefix. + * @returns {Promise} + * This can be a JSON object, raw text, an abort object, or an Error. + */ + async requestDataInBody( + endPoint, + options = {}, + apiPoint = '/wp-json/api/v1/' + ) { + const { + signal, + body, + method = 'GET', + headers: customHeaders = {}, + } = options + + console.log({ api: this.api, apiPoint, endPoint }) + const url = this.api + apiPoint + endPoint + + /** @type {Object.} */ + const fetchOptions = { + method, + signal, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...customHeaders, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + } + + try { + // Remove keys with undefined values + Object.keys(fetchOptions).forEach( + (key) => + fetchOptions[key] === undefined && delete fetchOptions[key] + ) + + const response = await fetch(url, fetchOptions) + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + return await response.json() + } + + return response.text() + } catch (error) { + console.log(error) + if (error instanceof Error) { + if (error.name === 'AbortError') { + return { aborted: true } + } + return error + } + return new Error(String(error)) + } + } + + /** + * Simplified GET request using the default API path ("/wp-json/api/v1/"). + * + * @async + * @param {string} endPoint - The specific API endpoint. + * @param {AbortSignal} [signal] - An optional AbortSignal. + * @returns {Promise<{ [key: string]: any, error?: Error, aborted?: boolean }>} + * @see {@link DataFetcher#requestData} + */ + async request(endPoint, signal = undefined) { + const result = await this.requestData( + endPoint, + '/wp-json/api/v1/', + signal + ) + return result + } + + /** + * GET request that allows specifying a custom API path. + * + * @async + * @param {string} endPoint - The specific API endpoint. + * @param {string} apiEndpoint - The custom API path prefix (e.g., '/wp-json/v2/'). + * @param {AbortSignal} [signal] - An optional AbortSignal. + * @returns {Promise<{ [key: string]: any, error?: Error, aborted?: boolean }>} + * @see {@link DataFetcher#requestData} + */ + async requestSettingApiEndPoint(endPoint, apiEndpoint, signal) { + const result = await this.requestData(endPoint, apiEndpoint, signal) + return result + } +} diff --git a/src/icons.js b/src/icons.js index 8bea6e9..a9229a5 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,5 +1,11 @@ +/** + * Generates an SVG tag for use in HTML. + * + * @param {string} path - The SVG path data. + * @returns {string} The formatted SVG tag. + */ const svgTag = (path) => { - return ` { viewBox="0 0 16 16" > ${path} - `; -}; + ` +} -export const biBook = svgTag(``); +export const biBook = svgTag( + `` +) -export const biListUl = svgTag(``); +export const biListUl = svgTag( + `` +) -export const biDownload = svgTag(``); +export const biDownload = svgTag( + `` +) -export const biShareFill = svgTag(``); +export const biShareFill = svgTag( + `` +) -export const biFiletypeCsv = svgTag(``); +export const biFiletypeCsv = svgTag( + `` +) -export const biGraphUp = svgTag(``); +export const biGraphUp = svgTag( + `` +) -export const biInfoCircle = svgTag(``); +export const biInfoCircle = svgTag( + `` +) -export const biConeStriped = svgTag(``); +export const biConeStriped = svgTag( + `` +) +export const biEraser = svgTag( + `` +) -export const biEraser = svgTag(``); +export const biCaretDown = svgTag( + `` +) -export const biCaretDown = svgTag(``); +export const biMap = svgTag( + `` +) -export const biMap = svgTag(``); - -export const biTable = svgTag(``) +export const biTable = svgTag( + `` +) diff --git a/src/main.js b/src/main.js index a2ba8d2..d14f5c4 100644 --- a/src/main.js +++ b/src/main.js @@ -1,233 +1,122 @@ -import "./assets/css/style.css"; -import { createApp, computed, ref, onBeforeMount } from "vue/dist/vue.esm-bundler"; -import logo from "./assets/images/logo-vacinabr.svg"; -import store from "./store/"; -import { config as Config } from "./components/config"; -import { mainCard as MainCard } from "./components/main-card"; -import -{ - NTabs, - NTabPane, - NTab, - NMessageProvider, - NButton, - NIcon, - NScrollbar, - NTooltip, - NSkeleton, - NEmpty -} from "naive-ui"; -import { useStore } from "vuex"; -import { computedVar } from "./utils"; -import router from "./router"; -import { modalWithTabs as ModalWithTabs } from "./components/modalWithTabs.js"; -import { modalGeneric as ModalGeneric } from "./components/modalGeneric.js"; -import { biMap, biGraphUp, biTable } from "./icons.js"; - -export default class MCT { - constructor({ api = "", baseAddress = "" }) { - this.api = api; - this.baseAddress = baseAddress; - this.render(); - } - - render() { - const self = this; - const App = { - components: - { - NTabs, - NTabPane, - NTab, - Config, - MainCard, - NMessageProvider, - NButton, - NIcon, - ModalGeneric, - ModalWithTabs, - NScrollbar, - NTooltip, - NSkeleton, - NEmpty - }, - setup() { - const store = useStore(); - const tab = computed(computedVar({ store, mutation: "content/UPDATE_TAB", field: "tab" })); - const tabBy = computed(computedVar({ store, mutation: "content/UPDATE_TABBY", field: "tabBy" })); - const disableMap = computed(() => store.state.content.disableMap); - const disableChart = computed(() => store.state.content.disableChart); - const genericModalTitle = computed(computedVar({ - store, - mutation: "content/UPDATE_GENERIC_MODAL_TITLE", - field: "genericModalTitle" - }) - ); - const genericModal = computed(computedVar({ - store, - mutation: "content/UPDATE_GENERIC_MODAL", - field: "genericModal" - }) - ); - const genericModalShow = computed(computedVar({ - store, - mutation: "content/UPDATE_GENERIC_MODAL_SHOW", - field: "genericModalShow" - }) - ); - const genericModalLoading = computed(computedVar({ - store, - mutation: "content/UPDATE_GENERIC_MODAL_LOADING", - field: "genericModalLoading" - }) - ); - - // external callbacks - self.genericModal = async (title, slug) => { - genericModalLoading.value = true; - genericModal.value = null; - genericModalShow.value = !genericModalShow.value; - genericModalTitle.value = title; - try { - await store.dispatch( - "content/requestPage", - ["UPDATE_GENERIC_MODAL", slug] - ); - } catch { - // Do Nothing - } - genericModalLoading.value = false; - } +import '@/assets/css/style.css' +import Config from '@/components/config' +import Main from '@/components/main' +import router from '@/router' - // Define extra button in filters - self.genericModalWithFilterButton = async (title, slug) => { - await store.dispatch( - "content/updateExtraFilterButton", - [title, slug] - ); - } +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { storeToRefs } from 'pinia' +import { useModalStore, useContentStore } from '@/stores' + +/** + * @file Manages the rendering of a map, chart and table component. + * @module MapChartTable + */ + +/** + * Represents a component for rendering a map, chart, and table. + * @class MapChartTable + */ +class MapChartTable { + self = this + + /** + * A function that opens a generic modal with a title and slug, handling loading states and fetching content. + * @type {((title: string, slug: string) => Promise) | undefined} + */ + genericModal + + /** + * A function that handles opening a generic modal with title and slug, manages loading states, and fetches content. + * @type {((title: string, slug: string) => Promise) | undefined} + */ + genericModalWithFilterButton + + /** + * The API endpoint URL. + * @type {string} + */ + api + + /** + * The base address for resources or other URLs. + * @type {string} + */ + baseAddress + + /** + * Creates an instance of MapChartTable. + * @constructor + * @param {object} config - Configuration object for the component. + * @param {string} config.api - The API endpoint URL. + * @param {string} [config.baseAddress=''] - The base address, defaults to an empty string if not provided. + */ + + /** + * Constructs a new instance of the class. + * @param {Object} options - The configuration options for the instance. + * @param {string} options.api - The API address to be used by the instance. + * @param {string} [options.baseAddress=''] - The base address for requests, defaults to an empty string. + */ + constructor({ api = '', baseAddress = '' }) { + this.api = api + this.baseAddress = baseAddress + this.render() + } - // Define apiUrl in store state - onBeforeMount(async () => { - store.commit("content/SET_API", self.api); - await Promise.all( - [ - [ - ["UPDATE_DOSE_BLOCKS", "dose-blocks"], - ["UPDATE_GRANULARITY_BLOCKS","granularity-blocks"], - ["UPDATE_LINK_CSV", "link-csv"], - ["UPDATE_MANDATORY_VACCINATIONS_YEARS", "mandatory-vaccinations-years"], - ["UPDATE_LAST_UPDATE_DATE", "lastupdatedate"], - ["UPDATE_AUTO_FILTERS", "auto-filters"], - ["UPDATE_ACRONYMS", "acronyms"] - ].map(request => store.dispatch("content/requestJson", request)), - [ - ["UPDATE_ABOUT_VACCINES", "?slug=sobre-vacinas-vacinabr"], - ].map(request => store.dispatch("content/requestPage", request)), - ] - ); - }); - - const modalContent = computed(() => { - const text = store.state.content.genericModal; - if (!text || !text.length) { - return - } - const div = document.createElement("div"); - div.innerHTML = text[0].content.rendered; - - if (div.querySelector("table")) { - const result = [...div.querySelectorAll("table>tbody>tr")].map( - tr => { return { - header: tr.querySelectorAll("td")[0].innerHTML, - content: tr.querySelectorAll("td")[1].innerHTML + /** + * Renders the component, setting up Vue and Pinia applications to include Config and Main components. + * @returns {void} + */ + render() { + const self = this + const App = { + components: { Config, Main }, + setup() { + const modalStore = useModalStore() + const { + genericModal, + genericModalShow, + genericModalTitle, + genericModalLoading, + } = storeToRefs(modalStore) + + const contentStore = useContentStore() + const { extraFilterButton } = storeToRefs(contentStore) + + self.genericModal = async (title, slug) => { + genericModalLoading.value = true + genericModal.value = null + genericModalShow.value = !genericModalShow.value + genericModalTitle.value = title + try { + await modalStore.requestContent(slug) + } catch { + // Do Nothing + } + genericModalLoading.value = false } - } - ) - return result; - } - - return text[0].content.rendered; - }) - - return { - tab, - tabBy, - api: self.api, - genericModalShow, - genericModalLoading, - genericModalTitle, - logo, - disableMap, - disableChart, - modalContent, - disableAll: computed(() => store.state.content.yearSlideAnimation), - biMap, - biGraphUp, - biTable, - bodyStyle: { - maxWidth: '900px' - }, - }; - }, - template: ` - -
- -
-
-
- - - - - -
-
-
- - - - - Mapa - - - - Gráfico - - - - Tabela - - -
-
-
-
- -
- - -
-
-
- `, - }; - - const app = createApp(App); - app.use(store); - app.use(router(self.baseAddress)); - app.mount("#app"); - } + + self.genericModalWithFilterButton = async (title, slug) => { + extraFilterButton.value = { title, slug } + } + + return { api: self.api } + }, + template: ` + +
+ + `, + } + + const pinia = createPinia() + const app = createApp(App) + + app.use(pinia) + app.use(router(this.baseAddress)) + app.mount('#app') + } } + +export default MapChartTable diff --git a/src/map-chart.js b/src/map-chart.js index ec6fbab..5d00e3b 100644 --- a/src/map-chart.js +++ b/src/map-chart.js @@ -1,541 +1,759 @@ -import Abandono from "./assets/images/abandono.svg" -import Cobertura from "./assets/images/cobertura.svg" -import HomGeo from "./assets/images/hom_geo.svg" -import HomVac from "./assets/images/hom_vac.svg" -import Meta from "./assets/images/meta.svg" +import Abandono from '@/assets/images/abandono.svg' +import Cobertura from '@/assets/images/cobertura.svg' +import HomGeo from '@/assets/images/hom_geo.svg' +import HomVac from '@/assets/images/hom_vac.svg' +import Meta from '@/assets/images/meta.svg' +/** + * @typedef {Object} DatasetItem + * @property {string|number} value - Valor principal + * @property {string} [name] - Nome + * @property {string} [label] - Sigla/Label + * @property {string} [color] - Cor Hex ou RGB + * @property {number} [population] - População (usado no tooltip) + * @property {number} [doses] - Doses (usado no tooltip) + * @property {string|number} [id] - ID opcional + */ + +/** + * @typedef {Object} MapChartOptions + * @property {HTMLElement} element + * @property {string} [map] + * @property {Object.} [datasetCities] + * @property {Array>} [cities] + * @property {Object.} [datasetStates] + * @property {Array>} [states] + * @property {string[]} [statesSelected] + * @property {function(boolean, string, string|number): void} [tooltipAction] + * @property {string} [type] + * @property {boolean} [formPopulated] + * @property {boolean} [loading] + */ export class MapChart { - - constructor({ - element, - map, - datasetCities, - cities, - datasetStates, - states, - statesSelected, - tooltipAction, - type, - formPopulated - }) { - this.element = typeof element === "string" ? - element.querySelector(element) : element; - this.map = map; - this.datasetCities = datasetCities; - this.cities = cities; - this.datasetStates = datasetStates; - this.states = states; - this.statesSelected = statesSelected; - this.tooltipAction = tooltipAction; - this.type = type; - this.formPopulated = formPopulated; - - this.start(); - } - - start() { - const self = this; - - if (!self.element) { - return; + /** + * @param {MapChartOptions} options + */ + constructor({ + element, + map, + datasetCities, + cities, + datasetStates, + states, + statesSelected, + tooltipAction, + type, + formPopulated, + loading, + }) { + /** @type {HTMLElement} */ + this.element = + typeof element === 'string' + ? // @ts-ignore + element.querySelector(element) + : element + + /** @type {string | undefined} */ + this.map = map + /** @type {Object. | undefined} */ + this.datasetCities = datasetCities + /** @type {Array> | undefined} */ + this.cities = cities + /** @type {Object. | undefined} */ + this.datasetStates = datasetStates + /** @type {Array> | undefined} */ + this.states = states + /** @type {string[] | undefined} */ + this.statesSelected = statesSelected + /** @type {((opened: boolean, name: string, id: string|number) => void) | undefined} */ + this.tooltipAction = tooltipAction + /** @type {string | undefined} */ + this.type = type + /** @type {boolean | undefined} */ + this.formPopulated = formPopulated + + this.loading = loading + + /** @type {Array} */ + this.datasetValues = [] + + this.start() } - if (self.datasetCities) { - self.render(); - self.loadMapState(); - return; - } + start() { + const self = this - self.render(); - self.loadMapNation(); - } + if (!self.element) { + return + } - update({ map, datasetCities, cities, datasetStates, states, statesSelected, type, formPopulated }) { - const self = this; - if (!self.element) { - return; - } + if (self.datasetCities) { + self.render() + self.loadMapState() + return + } - self.map = map ?? self.map; - self.cities = cities ?? self.cities; - self.states = states ?? self.states; - self.statesSelected = statesSelected ?? self.statesSelected; + self.render() + self.loadMapNation() + } - self.datasetCities = datasetCities; - self.datasetStates = datasetStates; - self.type = type; - self.formPopulated = formPopulated; + /** + * @param {MapChartOptions} params + */ + update({ + element, + map, + datasetCities, + cities, + datasetStates, + states, + statesSelected, + type, + formPopulated, + loading, + }) { + const self = this + self.loading = loading + self.element = element + if (!self.element) { + return + } - self.start(); - } + self.map = map ?? self.map + self.cities = cities ?? self.cities + self.states = states ?? self.states + self.statesSelected = statesSelected ?? self.statesSelected - applyMap(map) { - const self = this; + self.datasetCities = datasetCities + self.datasetStates = datasetStates + self.type = type + self.formPopulated = formPopulated - const svgContainer = self.element.querySelector("#canvas"); - svgContainer.innerHTML = map ?? ""; - for (const path of svgContainer.querySelectorAll('path')) { - path.style.stroke = "white"; - path.setAttribute("stroke-width", "1px"); - path.setAttribute("vector-effect", "non-scaling-stroke"); + self.start() } - const svgElement = svgContainer.querySelector("svg"); - if (svgElement) { - svgElement.style.maxWidth = "100%"; - svgElement.style.height = "100%"; - svgElement.style.margin = "auto"; + /** + * @param {string} [map] + */ + applyMap(map) { + const self = this + + const svgContainer = /** @type {HTMLElement} */ ( + self.element.querySelector('#canvas') + ) + svgContainer.innerHTML = map ?? '' + for (const path of svgContainer.querySelectorAll('path')) { + path.style.stroke = 'white' + path.setAttribute('stroke-width', '1px') + path.setAttribute('vector-effect', 'non-scaling-stroke') + } + + const svgElement = /** @type {SVGElement} */ ( + svgContainer.querySelector('svg') + ) + if (svgElement) { + svgElement.style.maxWidth = '100%' + svgElement.style.height = '100%' + svgElement.style.margin = 'auto' + } } - } - getData(contentData, elementId) { - if (!contentData) { - return []; + /** + * @param {any} contentData + * @param {string|number} elementId + * @returns {[number, number, number, (string|number)[] | undefined]} + */ + getData(contentData, elementId) { + if (!contentData) { + // @ts-ignore + return [] + } + const index = + contentData[0].indexOf('geom_id') != -1 + ? contentData[0].indexOf('geom_id') + : contentData[0].indexOf('id') + const indexName = contentData[0].indexOf('name') + const indexAcronym = contentData[0].indexOf('acronym') + + return [ + index, + indexName, + indexAcronym, + contentData.find( + (/** @type {string[]} */ el) => el[index] === elementId + ), + ] } - const index = contentData[0].indexOf("geom_id") != -1 ? contentData[0].indexOf("geom_id") : contentData[0].indexOf("id"); - const indexName = contentData[0].indexOf("name"); - const indexAcronym = contentData[0].indexOf("acronym"); - - return [ index, indexName, indexAcronym, contentData.find(el => el[index] === elementId) ]; - } - - setData( - { - datasetStates, - contentData, - type - } = {} - ) { - const self = this; - - // Querying map country states setting eventListener - for (const element of self.element.querySelectorAll('#canvas path, #canvas g')) { - let elementId = element.id; - - if (elementId.length > 6) { // granularity Municipios - elementId = element.id.substring(0, elementId.length - 1); - } - - let [ index, indexName, indexAcronym, currentElement ] = self.getData(contentData, elementId); - - let content; - if (currentElement) { - content = currentElement; - } - - if (!content || !content[indexName]) { - continue; - } - - let dataset = { data: { value: "---" }, color: "#D3D3D3" }; - let datasetValuesFound = []; - - if (content.id) { - // Get by id - datasetValuesFound = self.datasetValues.find(ds => (ds.name == content[indexName]) && (ds.id == content[indexName])); - } else { - // Get by name - datasetValuesFound = self.datasetValues.find(ds => (ds.name == content[indexName]) && (ds.name == content[indexName])); - } - - if (datasetValuesFound) { - dataset = datasetValuesFound; - } - - const result = dataset.data; - const resultColor = dataset.color; - const tooltip = self.element.querySelector(".mct-tooltip") - - element.addEventListener("mousemove", (event) => { - self.tooltipPosition(event, tooltip); - }); - element.addEventListener("mouseover", (event) => { - event.target.style.strokeWidth = "2px"; - let tooltipExtra = ""; - if (result && result.population) { - tooltipExtra = ` + + /** + * @param {Object} params + * @param {Array} [params.datasetStates] + * @param {Array>} [params.contentData] + * @param {string} [params.type] + */ + setData({ datasetStates, contentData, type } = {}) { + const self = this + + // Querying map country states setting eventListener + for (const element of self.element.querySelectorAll( + '#canvas path, #canvas g' + )) { + let elementId = element.id + + if (elementId.length > 6) { + // granularity Municipios + elementId = element.id.substring(0, elementId.length - 1) + } + + // @ts-ignore + let [index, indexName, indexAcronym, currentElement] = self.getData( + contentData, + elementId + ) + + let content + if (currentElement) { + content = currentElement + } + + if (!content || !content[indexName]) { + continue + } + + /** @type {DatasetItem} */ + let dataset = { value: '---', color: '#D3D3D3' } + /** @type {any} */ + let datasetValuesFound = [] + + // @ts-ignore: Acesso dinâmico a propriedade 'id' em array genérico + if (content.id) { + // Get by id + // @ts-ignore + datasetValuesFound = self.datasetValues.find( + (ds) => + ds.name == content[indexName] && + ds.id == content[indexName] + ) + } else { + // Get by name + datasetValuesFound = self.datasetValues.find( + (ds) => + ds.name == content[indexName] && + ds.name == content[indexName] + ) + } + + if (datasetValuesFound) { + dataset = datasetValuesFound + } + + // @ts-ignore + const result = dataset.data || dataset + const resultColor = dataset.color + const tooltip = /** @type {HTMLElement} */ ( + self.element.querySelector('.mct-tooltip') + ) + + // @ts-ignore + const htmlElement = /** @type {HTMLElement} */ (element) + + htmlElement.addEventListener('mousemove', (event) => { + self.tooltipPosition(event, tooltip) + }) + htmlElement.addEventListener('mouseover', (event) => { + const target = /** @type {HTMLElement} */ (event.target) + target.style.strokeWidth = '2px' + let tooltipExtra = '' + if (result && result.population) { + tooltipExtra = ` População alvo
${result.population.toLocaleString('pt-BR')}
- `; + ` - if (type !== "Doses aplicadas" && result.doses) { - tooltipExtra += ` + if (type !== 'Doses aplicadas' && result.doses) { + tooltipExtra += ` Doses aplicadas
${result.doses.toLocaleString('pt-BR')}
- `; - } - } - let value = result.value; - if (type === "Meta atingida") { - if(result.value !== "---") { - value = parseInt(result.value) === 0 ? "Não" : "Sim"; - } - } - tooltip.innerHTML = ` + ` + } + } + let value = result.value + if (type === 'Meta atingida') { + if (result.value !== '---') { + // @ts-ignore: parseInt em string|number + value = parseInt(result.value) === 0 ? 'Não' : 'Sim' + } + } + tooltip.innerHTML = `
${content[indexName]}
${value}
${tooltipExtra} -
`; - tooltip.style.display = "block"; - tooltip.style.backgroundColor = result.value.includes("---") ? "grey" : "var(--primary-color)"; - self.tooltipPosition(event, tooltip); - self.runTooltipAction(true, content[indexName], content[index]); - }); - element.addEventListener("mouseleave", (event) => { - if (event.target.tagName === "g") { - [...event.target.querySelectorAll("path")].forEach(path => path.style.strokeWidth = "1px"); - } else { - element.style.strokeWidth = "1px"; + ` + tooltip.style.display = 'block' + // @ts-ignore: includes em number|string + tooltip.style.backgroundColor = result.value + .toString() + .includes('---') + ? 'grey' + : 'var(--primary-color)' + self.tooltipPosition(event, tooltip) + self.runTooltipAction(true, content[indexName], content[index]) + }) + htmlElement.addEventListener('mouseleave', (event) => { + const target = /** @type {HTMLElement} */ (event.target) + if (target.tagName === 'g') { + ;[...target.querySelectorAll('path')].forEach( + (path) => (path.style.strokeWidth = '1px') + ) + } else { + htmlElement.style.strokeWidth = '1px' + } + + htmlElement.style.fill = resultColor || '' + htmlElement.style.stroke = 'white' + tooltip.style.display = 'none' + self.runTooltipAction(false, content[indexName], content[index]) + }) + + htmlElement.style.fill = resultColor || '' } - element.style.fill = resultColor; - element.style.stroke = "white"; - tooltip.style.display = "none"; - self.runTooltipAction(false, content[indexName], content[index]); - }); - - element.style.fill = resultColor; - }; + // dataResult is undefined if nothing is comming from API and selects is setted + if ( + self.formPopulated && + (!datasetStates || !datasetStates.length) && + !this.loading + ) { + const span = self.element.querySelector('.empty-message span') + if (span) { + span.innerHTML = + 'Não existem dados para os filtros selecionados' + } + const emptyMessage = /** @type {HTMLElement} */ ( + self.element.querySelector('.empty-message') + ) + if (emptyMessage) { + emptyMessage.style.display = 'block' + } + } else if ( + !self.formPopulated && + (!datasetStates || !datasetStates.length) && + !this.loading + ) { + const span = self.element.querySelector('.empty-message span') + if (span) { + span.innerHTML = + 'Selecione os filtros desejados para iniciar a visualização dos dados' + } - // dataResult is udefined if nothing is comming from API and selects is setted - if (self.formPopulated && !datasetStates.length) { - self.element.querySelector(".empty-message span").innerHTML = "Não existem dados para os filtros selecionados"; - } else if (!self.formPopulated && !datasetStates.length) { - self.element.querySelector(".empty-message span").innerHTML = - "Selecione os filtros desejados para iniciar a visualização dos dados"; - } else { - self.element.querySelector(".empty-message").style.display = "none"; + const emptyMessage = /** @type {HTMLElement} */ ( + self.element.querySelector('.empty-message') + ) + if (emptyMessage) { + emptyMessage.style.display = 'block' + } + } else { + const emptyMessage = /** @type {HTMLElement} */ ( + self.element.querySelector('.empty-message') + ) + if (emptyMessage) { + emptyMessage.style.display = 'none' + } + } } - } - runTooltipAction(opened, name, id) { - if (!this.tooltipAction) { - return; + /** + * @param {boolean} opened + * @param {string} name + * @param {string|number} id + */ + runTooltipAction(opened, name, id) { + if (!this.tooltipAction) { + return + } + this.tooltipAction(opened, name, id) } - this.tooltipAction(opened, name, id); - } - - tooltipPosition(event, tooltip) { - tooltip.style.left = (event.clientX + 20)+ "px"; - tooltip.style.top = (event.clientY + 20) + "px"; - } - - findElement(arr, name) { - for (let i=0; i < arr.length; i++) { - const object = arr[i]; - const labelLowerCase = object.label.toLowerCase(); - - if(!name) { - continue; - } - - const nameAcronymLowerCase = name.acronym ? name.acronym.toLowerCase() : ""; - const nameNameLowerCase = name.name ? name.name.toLowerCase() : ""; - - const labelWithoutSpaces = labelLowerCase.replaceAll(" ", ""); - - if ( - labelLowerCase == nameAcronymLowerCase || - labelWithoutSpaces == nameNameLowerCase.replaceAll(" ", "") || - labelLowerCase == nameNameLowerCase || - labelWithoutSpaces == nameNameLowerCase.replaceAll(" ", "") - ) { - return object; - } + + /** + * @param {MouseEvent} event + * @param {HTMLElement} tooltip + */ + tooltipPosition(event, tooltip) { + tooltip.style.left = event.clientX + 20 + 'px' + tooltip.style.top = event.clientY + 20 + 'px' } - return; - } + /** + * @param {Array} arr + * @param {any} name + */ + findElement(arr, name) { + for (let i = 0; i < arr.length; i++) { + const object = arr[i] + const labelLowerCase = object.label.toLowerCase() + + if (!name) { + continue + } - getPercentage(maxVal, minVal, val) { - return ((val - minVal) / (maxVal - minVal)) * 100; - } + const nameAcronymLowerCase = name.acronym + ? name.acronym.toLowerCase() + : '' + const nameNameLowerCase = name.name ? name.name.toLowerCase() : '' - getMaxAndMinValues(dataset) { - if (Object.values(dataset)[0].value.includes("%")) { - return; + const labelWithoutSpaces = labelLowerCase.replaceAll(' ', '') + + if ( + labelLowerCase == nameAcronymLowerCase || + labelWithoutSpaces == nameNameLowerCase.replaceAll(' ', '') || + labelLowerCase == nameNameLowerCase || + labelWithoutSpaces == nameNameLowerCase.replaceAll(' ', '') + ) { + return object + } + } + + return } - const values = Object.values(dataset).map((val) => val.value.replace(/[,.]/g, "")); - const maxVal = Math.max(...values); - const minVal = Math.min(...values); - return { maxVal, minVal }; - } + /** + * @param {number} maxVal + * @param {number} minVal + * @param {number} val + */ + getPercentage(maxVal, minVal, val) { + return ((val - minVal) / (maxVal - minVal)) * 100 + } - getMaxColorVal() { - const self = this; - if (self.type === "Cobertura") { - return 120; + /** + * @param {Object.} dataset + */ + getMaxAndMinValues(dataset) { + // @ts-ignore: includes em number|string + if (Object.values(dataset)[0].value.toString().includes('%')) { + return + } + + const values = Object.values(dataset).map((val) => + val.value.toString().replace(/[,.]/g, '') + ) + // @ts-ignore: Spread em string[] + const maxVal = Math.max(...values) + // @ts-ignore: Spread em string[] + const minVal = Math.min(...values) + return { maxVal, minVal } } - return 100; - } + getMaxColorVal() { + const self = this + if (self.type === 'Cobertura') { + return 120 + } + return 100 + } - loadMapState() { - const self = this; - let result = []; + loadMapState() { + const self = this + /** @type {Array} */ + let result = [] + + if (self.datasetCities) { + const resultValues = self.getMaxAndMinValues(self.datasetCities) + result = Object.entries(self.datasetCities).map(([key, val]) => { + let color = resultValues + ? self.getPercentage( + resultValues.maxVal, + resultValues.minVal, + // @ts-ignore + val.value.toString().replace(/[,.]/g, '') + ) + : parseFloat(val.value.toString()) + + // @ts-ignore + let [index, indexName, indexAcronym, currentElement] = + self.getData(self.cities, key) + + if (!currentElement) { + return + } + + const name = currentElement[indexName] + const label = currentElement[indexAcronym] + + /** @type {DatasetItem} */ + const contentData = { + label: String(label), + // @ts-ignore + data: val, + name: String(name), + color: self.getColor( + color, + self.getMaxColorVal(), + self.type + ), + } + // @ts-ignore + const id = currentElement.id + if (id) { + contentData['id'] = id + } + + return contentData + }) + } - if (self.datasetCities) { - const resultValues = self.getMaxAndMinValues(self.datasetCities); - result = - Object.entries( - self.datasetCities - ).map(([key, val]) => - { - let color = resultValues ? self.getPercentage( - resultValues.maxVal, - resultValues.minVal, - val.value.replace(/[,.]/g, "") - ) : parseFloat(val.value); + self.datasetValues = result + self.applyMap(self.map) - let [ index, indexName, indexAcronym, currentElement ] = self.getData(self.cities, key); + self.setData({ + datasetStates: result, + contentData: self.cities, + type: self.type, + }) + } - if(!currentElement) { - return; - } + loadMapNation() { + const self = this + /** @type {Array} */ + let result = [] + + if (self.datasetStates) { + const resultValues = self.getMaxAndMinValues(self.datasetStates) + result = Object.entries(self.datasetStates) + .map(([key, val]) => { + let color = resultValues + ? self.getPercentage( + // @ts-ignore + resultValues.maxVal, + resultValues.minVal, + // @ts-ignore + val.value.toString().replace(/[,.]/g, '') + ) + : parseFloat(val.value.toString()) + + // @ts-ignore + let [index, indexName, indexAcronym, currentElement] = + self.getData(self.states, key) + + if (!currentElement) { + return + } + + const name = currentElement[indexName] + const label = currentElement[indexAcronym] + + /** @type {DatasetItem} */ + const contentData = { + label: String(label), + // @ts-ignore + data: val, + name: String(name), + color: self.getColor( + color, + self.getMaxColorVal(), + self.type + ), + } + // @ts-ignore + const id = currentElement.id + if (id) { + contentData['id'] = id + } + + return contentData + }) + .filter((content) => { + if (content && self.statesSelected) { + // @ts-ignore + return self.statesSelected.includes(content.label) + } + }) + } - const name = currentElement[indexName]; - const label = currentElement[indexAcronym]; + self.datasetValues = result - const contentData = { - label: label, - data: val, - name, - color: self.getColor(color, self.getMaxColorVal(), self.type), - } - const id = currentElement.id; - if (id) { - contentData["id"] = id - } + self.applyMap(self.map) - return contentData - } - ); + self.setData({ + datasetStates: result, + contentData: self.states, + type: self.type, + }) } - self.datasetValues = result; - self.applyMap(self.map); - - self.setData({ - datasetStates: result, - contentData: self.cities, - type: self.type - }) - } - - loadMapNation() { - const self = this; - let result = []; - - if (self.datasetStates) { - const resultValues = self.getMaxAndMinValues(self.datasetStates); - result = - Object.entries( - self.datasetStates - ).map(([key, val]) => - { - let color = resultValues ? self.getPercentage( - resultValues.maxVal, resultValues.minVal, val.value.replace(/[,.]/g, "") - ) : parseFloat(val.value); - - let [ index, indexName, indexAcronym, currentElement ] = self.getData(self.states, key); - - if(!currentElement) { - return; + /** + * @param {number} percentage + * @param {number} [maxVal] + * @param {string} [type] + * @param {boolean} [reverse] + */ + getColor(percentage, maxVal = 100, type, reverse = false) { + const cPalette0 = [ + 'rgb(0, 69, 124)', + 'rgb(0, 92, 161)', + 'rgb(50, 161, 230)', + 'rgb(246, 194, 188)', + 'rgb(207, 84, 67)', + 'rgb(105, 42, 34)', + ] + + if (type === 'Abandono') { + if (percentage <= -5) { + return cPalette0[0] + } else if (percentage > -5 && percentage <= 0) { + return cPalette0[1] + } else if (percentage > 0 && percentage <= 5) { + return cPalette0[2] + } else if (percentage > 5 && percentage <= 10) { + return cPalette0[3] + } else if (percentage > 10 && percentage <= 50) { + return cPalette0[4] + } else { + // percentage > 50 + return cPalette0[5] } - - const name = currentElement[indexName]; - const label = currentElement[indexAcronym]; - - const contentData = { - label: label, - data: val, - name, - color: self.getColor(color, self.getMaxColorVal(), self.type), + } else if (type === 'Cobertura') { + if (percentage <= 50) { + return cPalette0[5] + } else if (percentage > 50 && percentage <= 80) { + return cPalette0[4] + } else if (percentage > 80 && percentage <= 95) { + return cPalette0[3] + } else if (percentage > 95 && percentage <= 100) { + return cPalette0[2] + } else if (percentage > 100 && percentage <= 120) { + return cPalette0[1] + } else { + // percentage > 120 + return cPalette0[0] + } + } else if (type === 'Homogeneidade geográfica') { + if (percentage <= 20) { + return cPalette0[5] + } else if (percentage > 20 && percentage <= 50) { + return cPalette0[4] + } else if (percentage > 50 && percentage <= 70) { + return cPalette0[3] + } else if (percentage > 70 && percentage <= 95) { + return cPalette0[2] + } else { + // percentage > 95 + return cPalette0[0] + } + } else if (type === 'Homogeneidade entre vacinas') { + if (percentage <= 20) { + return cPalette0[0] + } else if (percentage > 20 && percentage <= 40) { + return cPalette0[1] + } else if (percentage > 40 && percentage <= 60) { + return cPalette0[2] + } else if (percentage > 60 && percentage <= 80) { + return cPalette0[3] + } else { + // percentage > 80 + return cPalette0[4] } - const id = currentElement.id; - if (id) { - contentData["id"] = id + } else if (type === 'Meta atingida') { + if (percentage == 0) { + return cPalette0[4] + } else { + return cPalette0[2] } + } - return contentData - } - ).filter(content => { - if (content) { - return self.statesSelected.includes(content.label); - } - }); - } + const cPalette = [ + { r: 156, g: 63, b: 51 }, + { r: 207, g: 84, b: 67 }, + { r: 231, g: 94, b: 75 }, + { r: 234, g: 114, b: 98 }, + { r: 237, g: 134, b: 120 }, + { r: 243, g: 174, b: 165 }, + { r: 246, g: 194, b: 188 }, + { r: 160, g: 209, b: 242 }, + { r: 50, g: 161, b: 230 }, + { r: 1, g: 121, b: 218 }, + { r: 1, g: 111, b: 196 }, + { r: 0, g: 92, b: 161 }, + ] + + const colors = reverse ? cPalette.reverse() : cPalette + + if (!percentage) { + percentage = 0 + } else if (percentage < 0) { + return reverse ? 'rgb(0, 69, 124)' : 'rgb(105, 42, 34)' + } else if (percentage > maxVal) { + return reverse ? 'rgb(0, 69, 124)' : 'rgb(0, 69, 124)' + } - self.datasetValues = result; - - self.applyMap(self.map); - - self.setData({ - datasetStates: result, - contentData: self.states, - type: self.type - }) - } - - getColor(percentage, maxVal = 100, type, reverse = false) { - const cPalette0 = [ - "rgb(0, 69, 124)", - "rgb(0, 92, 161)", - "rgb(50, 161, 230)", - "rgb(246, 194, 188)", - "rgb(207, 84, 67)", - "rgb(105, 42, 34)" - ]; - - if (type === "Abandono") { - if (percentage <= -5) { - return cPalette0[0]; - } else if (percentage > -5 && percentage <= 0) { - return cPalette0[1]; - } else if (percentage > 0 && percentage <= 5) { - return cPalette0[2]; - } else if (percentage > 5 && percentage <= 10) { - return cPalette0[3]; - } else if (percentage > 10 && percentage <= 50) { - return cPalette0[4]; - } else { // percentage > 50 - return cPalette0[5]; - } - } else if (type === "Cobertura") { - if (percentage <= 50) { - return cPalette0[5]; - } else if (percentage > 50 && percentage <= 80) { - return cPalette0[4]; - } else if (percentage > 80 && percentage <= 95) { - return cPalette0[3]; - } else if (percentage > 95 && percentage <= 100) { - return cPalette0[2]; - } else if (percentage > 100 && percentage <= 120) { - return cPalette0[1]; - } else { // percentage > 120 - return cPalette0[0]; - } - } else if (type === "Homogeneidade geográfica") { - if (percentage <= 20) { - return cPalette0[5]; - } else if (percentage > 20 && percentage <= 50) { - return cPalette0[4]; - } else if (percentage > 50 && percentage <= 70) { - return cPalette0[3]; - } else if (percentage > 70 && percentage <= 95) { - return cPalette0[2]; - } else { // percentage > 95 - return cPalette0[0]; - } - } else if (type === "Homogeneidade entre vacinas") { - if (percentage <= 20) { - return cPalette0[0]; - } else if (percentage > 20 && percentage <= 40) { - return cPalette0[1]; - } else if (percentage > 40 && percentage <= 60) { - return cPalette0[2]; - } else if (percentage > 60 && percentage <= 80) { - return cPalette0[3]; - } else { // percentage > 80 - return cPalette0[4]; - } - } else if (type === "Meta atingida") { - if (percentage == 0) { - return cPalette0[4]; - } else { - return cPalette0[2]; - } - } + const index = Math.floor((percentage / maxVal) * (colors.length - 1)) + + const lowerColor = colors[index] + const upperColor = + index < colors.length - 1 ? colors[index + 1] : colors[index] + const factor = (percentage / maxVal) * (colors.length - 1) - index + const interpolatedColor = { + r: Math.round( + lowerColor.r + (upperColor.r - lowerColor.r) * factor + ), + g: Math.round( + lowerColor.g + (upperColor.g - lowerColor.g) * factor + ), + b: Math.round( + lowerColor.b + (upperColor.b - lowerColor.b) * factor + ), + } - const cPalette = [ - { r: 156, g: 63, b: 51 }, - { r: 207, g: 84, b: 67 }, - { r: 231, g: 94, b: 75 }, - { r: 234, g: 114, b: 98 }, - { r: 237, g: 134, b: 120 }, - { r: 243, g: 174, b: 165 }, - { r: 246, g: 194, b: 188 }, - { r: 160, g: 209, b: 242 }, - { r: 50, g: 161, b: 230 }, - { r: 1, g: 121, b: 218 }, - { r: 1, g: 111, b: 196 }, - { r: 0, g: 92, b: 161 } - ]; - - const colors = reverse ? cPalette.reverse() : cPalette; - - if (!percentage) { - percentage = 0 - } else if (percentage < 0) { - return reverse ? "rgb(0, 69, 124)" : "rgb(105, 42, 34)"; - } else if (percentage > maxVal) { - return reverse ? "rgb(0, 69, 124)" : "rgb(0, 69, 124)"; + return `rgb(${interpolatedColor.r}, ${interpolatedColor.g}, ${interpolatedColor.b})` } - const index = Math.floor((percentage / maxVal) * (colors.length - 1)); - - const lowerColor = colors[index]; - const upperColor = index < (colors.length - 1) ? colors[index + 1] : colors[index]; - const factor = (percentage / maxVal) * (colors.length - 1) - index; - const interpolatedColor = { - r: Math.round(lowerColor.r + (upperColor.r - lowerColor.r) * factor), - g: Math.round(lowerColor.g + (upperColor.g - lowerColor.g) * factor), - b: Math.round(lowerColor.b + (upperColor.b - lowerColor.b) * factor) - }; - - return `rgb(${interpolatedColor.r}, ${interpolatedColor.g}, ${interpolatedColor.b})`; - } - - render () { - const self = this; - - let legend = ""; - let legendSvg = ""; - - if (self.type === "Abandono") { - legendSvg = Abandono; - } else if (self.type === "Cobertura") { - legendSvg = Cobertura; - } else if (self.type === "Homogeneidade geográfica") { - legendSvg = HomGeo; - } else if (self.type === "Homogeneidade entre vacinas") { - legendSvg = HomVac; - } else if (self.type === "Meta atingida") { - legendSvg = Meta; - } + render() { + const self = this + + let legend = '' + let legendSvg = '' + + if (self.type === 'Abandono') { + legendSvg = Abandono + } else if (self.type === 'Cobertura') { + legendSvg = Cobertura + } else if (self.type === 'Homogeneidade geográfica') { + legendSvg = HomGeo + } else if (self.type === 'Homogeneidade entre vacinas') { + legendSvg = HomVac + } else if (self.type === 'Meta atingida') { + legendSvg = Meta + } - if (legendSvg) { - legend =`some file`; - } + if (legendSvg) { + legend = `map file` + } - const emptyIcon = ` + const emptyIcon = `
- `; + ` - const map = ` + const map = `
-
-
-
-
-
+
${legend}
-
+
- `; + ` - self.element.innerHTML = map; - } + if (self.element) { + self.element.innerHTML = map + } + } } diff --git a/src/router/index.js b/src/router/index.js index 12f4190..3db1b1f 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,15 +1,30 @@ -import { createRouter, createWebHistory } from "vue-router"; -import { mainCard } from "../components/main-card"; - -const router = (baseAddress) => createRouter({ - history: createWebHistory(), - routes: [ - { - path: `${baseAddress}`, - name: "main", - component: mainCard, - }, - ], -}) - -export default router; +import { createRouter, createWebHistory } from 'vue-router' +import Main from '@/components/main' + +/** @type {import('vue-router').Router|null} */ +let routerInstance = null + +/** + * Create new incence fo Vue Router with dynamic setup + * + * @param {string} baseAddress - base path to routes (ex: '/'). + * @returns {import('vue-router').Router} An instance of VueRouter. + */ +export default (baseAddress) => { + const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: baseAddress, + name: 'main', + component: Main, + }, + ], + }) + + routerInstance = router + + return router +} + +export const getRouter = () => routerInstance diff --git a/src/stores/chart.js b/src/stores/chart.js new file mode 100644 index 0000000..1340a18 --- /dev/null +++ b/src/stores/chart.js @@ -0,0 +1,200 @@ +import { defineStore } from 'pinia' +import { useContentStore } from '@/stores/content' + +/** + * @typedef {Object} ApiResponseChart + * @property {string[]} localNames - Array of local names. + * @property {{ data: any }} result - Result object containing data. + * @property {boolean} aborted - Indicates if the request was aborted. + * @property {string[]} data - Array of data entries. + */ + +/** + * Retuns default state + * @returns {{ + * dataChart: { label: string, data: (string | null)[], backgroundColor: string, borderColor: string, borderWidth: number, }[] | null + * loading: boolean + * locals: string[] | null + * years: string[] | null + * }} + */ +const getDefaultState = () => { + return { + dataChart: null, + loading: false, + locals: null, + years: null, + } +} + +export const useChartStore = defineStore('chart', { + state: () => getDefaultState(), + actions: { + async setChartData() { + this.loading = true + const contentStore = useContentStore() + const response = /** @type{ApiResponseChart} */ ( + await contentStore.requestData({ + detail: true, + stateNameAsCode: false, + stateTotal: true, + }) + ) + + if (!response || response.aborted || !response?.result?.data) { + this.resetState() + return {} + } + + const dataArray = response.result.data + const data = + /** @type{Record>>} */ ({}) + const years = [] + const locals = [] + + // Loop through the dataArray starting from the second element to not get header + let localNames = /** @type string[] */ ([]) + let counter = 0 + for (let i = 1; i < dataArray.length; i++) { + let [year, local, value, population, doses, sickImmunizer] = + dataArray[i] + if (!isNaN(local)) { + local = response.localNames.find( + (/** @type{string} */ name) => name[0] == local + ) + } + if (!localNames.includes(local + sickImmunizer)) { + counter++ + localNames.push(local + sickImmunizer) + } + + if (!data[sickImmunizer]) { + data[sickImmunizer] = {} + } + if (!data[sickImmunizer][year]) { + data[sickImmunizer][year] = {} + } + if (value.at(-1) === '%') { + data[sickImmunizer][year][local] = value.substring( + 0, + value.length - 1 + ) + } else { + data[sickImmunizer][year][local] = value + } + years.push(year) + locals.push(local) + } + + // Extract unique years and locals + this.years = Array.from(new Set(years)).sort() + // TODO: If not necessary as state remove from state + this.locals = Array.from(new Set(locals)) + + // Formating data to chartResult + const chartResult = + /** @type{Record} */ ({}) + for (let local of this.locals) { + for (let [key, val] of Object.entries(data)) { + const legend = `${key} ${local}` + for (let year of this.years) { + if (!chartResult[legend]) { + chartResult[legend] = [] + } + if (val[year] && val[year][local] !== null) { + chartResult[legend].push(val[year][local]) + } else { + chartResult[legend].push(null) + } + } + } + } + + /** + * Generates a random integer between min and max (inclusive). + * + * @param {number} min - The minimum value of the range. + * @param {number} max - The maximum value of the range. + * @returns {number} A random integer within the specified range. + */ + function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min + } + + const getRandomColor = () => { + // Generate random RGB values, ensuring they are not all 255 (to avoid white) + let r, g, b + do { + r = getRandomInt(0, 255) + g = getRandomInt(0, 255) + b = getRandomInt(0, 255) + } while (r === 255 && g === 255 && b === 255) + + // Return the color in RGB format + return `rgb(${r}, ${g}, ${b})` + } + + const generateUniqueColors = + /** @type{(numColors: number) => string[]} */ (numColors) => { + const colors = new Set() + + while (colors.size < numColors) { + const color = getRandomColor() + colors.add(color) + } + + return Array.from(colors) + } + + const chartResultEntries = Object.entries(chartResult) + + const colorsBase = [ + '#e96f5f', // Base color + '#5f9fe9', // Blue + '#558e5a', // Darker Green + '#e9c35f', // Yellow + '#915fe9', // Purple + '#3ca0a0', // Cyan + '#ff007f', // Shocking Pink + '#666666', // Gray + '#e9a35f', // Orange + ] + + const colors = + chartResultEntries.length > 9 + ? [ + ...colorsBase, + ...generateUniqueColors( + chartResultEntries.length - 9 + ), + ] + : colorsBase + + const dataChart = [] + let i = 0 + + for (let [key, value] of chartResultEntries) { + if (i > 30) { + // TODO: Update to call storeMessage + // store.commit('message/INFO', "Essa filtragem excedeu o máximo de 30 linhas, apenas 30 linhas serão exibidas") + break + } + const color = colors[i % colors.length] + dataChart.push({ + label: key, + data: value, + backgroundColor: color, + borderColor: color, + borderWidth: 2, + }) + i++ + } + + this.dataChart = dataChart + this.loading = false + }, + resetState() { + this.$reset() + }, + }, +}) diff --git a/src/stores/content.js b/src/stores/content.js new file mode 100644 index 0000000..fbd1754 --- /dev/null +++ b/src/stores/content.js @@ -0,0 +1,916 @@ +import { defineStore } from 'pinia' +import { DataFetcher } from '@/data-fetcher' +import { useMessageStore } from '@/stores' +import { formatToApi } from '@/common' +import { getRouter } from '@/router' +import { + disableOptionsByTypeOrDose, + disableOptionsByGranularityOrType, + disableOptionsByTab, + disableOptionsByDoseOrSick, +} from '@/utils' + +/** @type {AbortController} */ +let currentController + +/** @type {AbortController} */ +let currentControllerMap + +/** + * @typedef {object} ContentStateForm + * @property {any | null} sickImmunizer - The selected item or its ID. + * @property {any[]} sicks - A list of options. + * @property {any[]} immunizers - A list of options. + * @property {any | null} type - The selected type. + * @property {any[]} types - A list of options. + * @property {string[] | string} local - Selected locations (multi-select). + * @property {any[]} locals - A list of options. + * @property {any | null} dose - The selected dose. + * @property {any[]} doses - A list of options. + * @property {any | null} period - The selected period. + * @property {any | null} years - Selected years. + * @property {string | null} periodStart - Start date of the period. + * @property {string | null} periodEnd - End date of the period. + * @property {any | null} granularity - Selected granularity. + * @property {any[]} granularities - A list of options. + * @property {any | null} city - The selected city. + * @property {any[]} cities - A list of options. + */ + +/** + * @typedef {object} ContentState + * @property {string} apiUrl - URL base da API + * @property {string} tab - Aba ativa (ex: 'map', 'chart') + * @property {string} tabBy - Agrupamento da aba (ex: 'sicks', 'immunizers') + * @property {string} legend - Texto da legenda + * @property {ContentStateForm} form - Objeto com todos os filtros do formulário + * @property {boolean} yearSlideAnimation - Controla a animação do slider de ano + * @property {any | null} autoFilters - Configurações de filtros automáticos + * @property {any | null} aboutVaccines - Informações "sobre vacinas" + * @property {any | null} mandatoryVaccineYears - Anos de vacina obrigatória + * @property {{ [key: string]: string } | null} lastUpdateDate - Data da última atualização + * @property {{ [key: string]: { [key: string]: string } } | null} titles - Textos de título para seções + * @property {string | null} csvAllDataLink - Link para download de CSV completo + * @property {any | null} doseBlocks - Configuração de blocos de dose + * @property {any | null} granularityBlocks - Configuração de blocos de granularidade + * @property {boolean} disableMap - Desabilita o mapa + * @property {boolean} disableChart - Desabilita os gráficos + * @property {boolean} loading - Estado de loading principal + * @property {number} maxCsvExportRows - Limite de linhas para exportação CSV + * @property {boolean} csvRowsExceeded - Indica se o limite de CSV foi excedido + * @property {any[]} acronyms - Lista de acrônimos + * @property {{ title: string, slug: string } | null} extraFilterButton - Botão extra de interação com modal + */ + +/** + * Return initial default store state + * @returns {ContentState} The object state initial + */ +const getDefaultState = () => { + return { + apiUrl: '', + tab: '', + tabBy: '', + legend: 'Fonte: Programa Nacional de Imunização (PNI), disponibilizadas no TabNet-DATASUS', + form: { + sickImmunizer: null, + sicks: [], + immunizers: [], + type: null, + types: [], + local: [], + locals: [], + dose: null, + doses: [], + period: null, + years: null, + periodStart: null, + periodEnd: null, + granularity: null, + granularities: [], + city: null, + cities: [], + }, + yearSlideAnimation: false, + autoFilters: null, + aboutVaccines: null, + mandatoryVaccineYears: null, + lastUpdateDate: null, + titles: null, + csvAllDataLink: null, + doseBlocks: null, + granularityBlocks: null, + disableMap: false, + disableChart: false, + loading: true, + maxCsvExportRows: 10000, + csvRowsExceeded: false, + acronyms: [], + extraFilterButton: null, + } +} + +export const useContentStore = defineStore('content', { + state: () => getDefaultState(), + actions: { + /** + * Initializes the map fetcher. + * @param { { map: string | string[] } } map - The map identifier (slug) to fetch. + */ + async requestMap({ map }) { + /** @type {DataFetcher} */ + const api = new DataFetcher(this.apiUrl) + + if (currentControllerMap) { + currentControllerMap.abort() + } + + currentControllerMap = new AbortController() + + const signal = currentControllerMap.signal + const result = await api.request(`map/${map}`, signal) + return result + }, + /** + * Request data, process formats and maps states to code if necessary + * @async + * @param {Object} [options={}] - Options for the request. + * @param {boolean} [options.detail=false] - Whether to fetch detailed data. + * @param {boolean} [options.stateNameAsCode=true] - Whether to convert state names to their codes (UF). + * @param {boolean} [options.stateTotal=false] - Whether to include state totals. + * @param {number|null} [options.page=null] - Page number for pagination. + * @param {Object} [options.sorter] - Sorting object. + * @param {string} options.sorter.columnKey - Column to be sorted. + * @param {string} options.sorter.order - Direction of sorting ('ascend' or 'descend'). + * @param {boolean} [options.csv=false] - Whether the request is for CSV export. + * @returns {Promise<{ result: Object, localNames?: any, error?: any, aborted?: boolean } | void>} + * Returns an object containing data and metadata, or void if the form is invalid. + */ + async requestData({ + detail = false, + stateNameAsCode = true, + stateTotal = false, + page = null, + sorter = undefined, + csv = false, + } = {}) { + this.loading = true + if (currentController) { + currentController.abort() + } + + const api = new DataFetcher(this.apiUrl) + const form = this.form + + currentController = new AbortController() + const signal = currentController.signal + + // If the form field 'sickImmunizer' is an array and empty, return without making a request + if ( + form.sickImmunizer && + Array.isArray(form.sickImmunizer) && + !form.sickImmunizer.length + ) { + this.loading = false + return + } + // Ensure all required form fields are populated + if ( + !form.type || + !form.granularity || + !form.sickImmunizer || + !form.dose || + (!form.periodStart && !form.periodEnd) || + (!form.local.length && form.granularity !== 'Nacional') + ) { + this.loading = false + return + } + + // TODO: Add encodeURI to another fields + const sI = Array.isArray(form.sickImmunizer) + ? form.sickImmunizer.join('|') + : form.sickImmunizer + const loc = Array.isArray(form.local) + ? form.local.join('|') + : form.local + let request = + '?tab=' + + this.tab + + '&tabBy=' + + this.tabBy + + '&type=' + + form.type + + '&granularity=' + + form.granularity + + '&sickImmunizer=' + + encodeURIComponent(sI) + + '&local=' + + loc + + '&dose=' + + form.dose + + request += form.periodStart + ? '&periodStart=' + form.periodStart + : '' + request += form.periodEnd ? '&periodEnd=' + form.periodEnd : '' + request += page ? '&page=' + page : '' + request += sorter + ? '&sCol=' + sorter.columnKey + '&sOrder=' + sorter.order + : '' + + if (detail) { + request += '&detail=true' + } + if (stateTotal) { + request += '&stateTotal=true' + } + + const granularity = form.granularity + + const states = form.local + + let isStateData + if (granularity === 'Região de saúde' && states.length > 1) { + isStateData = 'regNames' + } else if (granularity === 'Macrorregião de saúde') { + isStateData = 'macregNames' + } else if (granularity === 'Região de saúde') { + isStateData = 'regNames' + } else if (granularity === 'Estados') { + isStateData = 'statesNames' + } else if (granularity === 'Nacional') { + isStateData = 'countryName' + } else { + isStateData = 'citiesNames' + } + + const [result, localNames] = await Promise.all([ + api.request((csv ? `export-csv/` : `data/`) + request, signal), + api.request(isStateData), + ]) + + if (result.aborted) { + this.loading = false + return { result, localNames: [] } + } + + const messageStore = useMessageStore() + if (result.error) { + messageStore.message( + 'error', + 'Não foi possível carregar os dados. Tente novamente mais tarde.' + ) + this.loading = false + return { result: {}, localNames: [], error: result.error } + } else if (!result || (result.data && result.data.length <= 1)) { + this.titles = null + messageStore.message( + 'warning', + 'Não há dados disponíveis para os parâmetros selecionados.' + ) + this.loading = false + return { result: {}, localNames: [] } + } else if (result.metadata) { + this.titles = result.metadata.titles + this.csvRowsExceeded = result.metadata.csv_rows_exceeded + this.maxCsvExportRows = result.metadata.max_csv_export_rows + } + + if (form.type !== 'Doses aplicadas') { + /** + * Processes the data rows (ignoring the header): + * Converts the value of the third column (index 2) to a string percentage with two decimal places. + * Ex: 0.5 -> "0.50%" + */ + result.data.slice(1).forEach( + /** * @param {string[]} val - Array representando a linha da tabela */ + (val) => (val[2] = Number(val[2]).toFixed(2) + '%') + ) + } else if (form.type === 'Doses aplicadas') { + result.data.forEach( + /** + * @param {string[]} val - Array representing lines of table + * @param {number} index - Current iteration number + */ + (val, index) => { + let number = Number(val[2]) + val[2] = + index > 0 ? number.toLocaleString('pt-BR') : val[2] + } + ) + } + + // Update data to display state names as code + if (result && isStateData === 'statesNames' && stateNameAsCode) { + const newResult = [] + const data = result.data + for (let i = 1; i < data.length; i++) { + const currentData = data[i] + const code = localNames.find( + /** * @param {number[]} val - Array representando a linha da tabela */ + (val) => val[1] === currentData[1] + )[0] + currentData[1] = code + newResult.push(currentData) + } + // Add header + newResult.unshift(data[0]) + result.data = newResult + } + + this.loading = false + return { result, localNames } + }, + /** + * Initializes the data fetcher. + * @param {string} endpoint - The path (slug) to fetch. + */ + async requestPage(endpoint) { + const api = new DataFetcher(this.apiUrl) + const result = await api.requestSettingApiEndPoint( + endpoint, + '/wp-json/wp/v2/pages?' + ) + if (endpoint === 'slug=sobre-vacinas-vacinabr') { + this.aboutVaccines = result + } + }, + /** + * Initializes the data fetcher. + * @param {string} endpoint - The path (slug) to fetch. + */ + async requestJson(endpoint) { + const messageStore = useMessageStore() + const api = new DataFetcher(this.apiUrl) + try { + const result = await api.request(endpoint) + + if (endpoint === 'dose-blocks') { + this.doseBlocks = result + } else if (endpoint === 'granularity-blocks') { + this.granularityBlocks = result + } else if (endpoint === 'link-csv') { + this.csvAllDataLink = result.url + } else if (endpoint === 'mandatory-vaccinations-years') { + this.mandatoryVaccineYears = result + } else if (endpoint === 'lastupdatedate') { + this.lastUpdateDate = result + } else if (endpoint === 'auto-filters') { + this.autoFilters = result + } else if (endpoint === 'acronyms') { + /** @type {Object[]} Final array of formatted objects */ + const finalResult = [] + + /** @type {string[]} The first row contains the headers */ + const acronymsHeader = result[0] + + result.forEach( + /** + * Iterates through matrix rows (skipping header). + * @param {any[]} row - Array representing the table row + * @param {number} i - Current row index + */ + (row, i) => { + // Skip header row (index 0) + if (i < 1) { + return + } + + /** @type {Object.} Object being built */ + const resultRow = {} + + row.forEach( + /** + * Maps column value to the corresponding header key. + * @param {any} col - Cell value + * @param {number} j - Column index + */ + (col, j) => { + resultRow[acronymsHeader[j]] = col + } + ) + + finalResult.push(resultRow) + } + ) + this.acronyms = finalResult + } + } catch (e) { + messageStore.message( + 'error', + `Não foi possível carregar os dados de '/${endpoint}'` + ) + } + }, + /** + * Initializes the data fetcher. + * @param { { map: string } } object - The map identifier (slug) to fetch. + */ + async initial({ map }) { + this.requestMap({ map }) + }, + /** + * Remove query from router. + * @param { string } key - A query to be removed from router. + */ + removeQueryFromRouter(key) { + const router = getRouter() + const messageStore = useMessageStore() + + const searchString = window.location.search + const params = new URLSearchParams(searchString) + const routeArgs = Object.fromEntries(params) + + const URLquery = routeArgs + delete URLquery[key] + + messageStore.message( + 'warning', + 'URL contém valor inválido para filtragem' + ) + + router?.replace({ query: URLquery }) + }, + /** + * Define app form state from URL. + */ + setStateFromUrl() { + const searchString = window.location.search + const params = new URLSearchParams(searchString) + const routeArgs = Object.fromEntries(params) + + const formState = this.form + + /** @type {ContentStateForm} */ + const routerResult = {} + /** @type {{ [key: string]: string[] | string | number }} */ + const routerResultTabs = {} + + if (!Object.keys(routeArgs).length) { + this.setTabField('map') + this.setTabByField('sicks') + return + } + + const routeArgsAsEntries = Object.entries(routeArgs) + + const isTabDefinedInRouterArgs = !routeArgsAsEntries.find( + (item) => item[0] === 'tab' + ) + + const includeTabs = ['chart', 'table'] + const isTabToShowSickAsArray = + !isTabDefinedInRouterArgs || includeTabs.includes(this.tab) + + for (const [key, val] of routeArgsAsEntries) { + if (!val) { + continue + } + const value = String(val) + if (key === 'sickImmunizer') { + if (isTabToShowSickAsArray) { + const values = value.split(',') + const sicks = formState['sicks'].map((el) => el.value) + const immunizers = formState['immunizers'].map( + (el) => el.value + ) + if ( + values.every((val) => sicks.includes(val)) || + values.every((val) => immunizers.includes(val)) + ) { + routerResult[key] = values + } else { + this.removeQueryFromRouter(key) + } + } else if ( + formState['sicks'].some((el) => el.value === value) || + formState['immunizers'].some((el) => el.value === value) + ) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'city') { + // TODO: define if cities will be in URL state + // const values = value.split(",") + // const cities = formState["cities"].map(el => el.value) + // if (values.every(val => cities.includes(val))) { + // routerResult[key] = values; + // } else { + // removeQueryFromRouter(key); + // } + } else if (key === 'local') { + const values = value.split(',') + const locals = formState['locals'].map((el) => el.value) + if (values.every((val) => locals.includes(val))) { + routerResult.local = values + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'granularity') { + if ( + formState['granularities'].some( + (el) => el.value === value + ) + ) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'dose') { + if (formState['doses'].some((el) => el.value === value)) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'type') { + if (formState['types'].some((el) => el.value === value)) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'tab') { + if (['map', 'chart', 'table'].some((el) => el === value)) { + routerResultTabs[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'tabBy') { + if (['immunizers', 'sicks'].some((el) => el === value)) { + routerResultTabs[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'periodStart' || key === 'periodEnd') { + const resultValue = Number(value) + if ( + formState['years'].some( + /** @param {{ value: number }} el - Objeto com campo value representando ano */ + (el) => el.value === resultValue + ) + ) { + routerResult[key] = String(resultValue) + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'period') { + routerResult[key] = Number(value) + } else if (value.includes(',')) { + //@ts-ignore + routerResult[key] = value.split(',') + } else { + //@ts-ignore + routerResult[key] = value ?? null + } + } + + this.setTabField( + routerResultTabs?.tab ? String(routerResultTabs.tab) : 'map' + ) + this.setTabByField( + routerResultTabs?.tabBy + ? String(routerResultTabs.tabBy) + : 'sicks' + ) + + for (let [key, value] of Object.entries(routerResult)) { + this.setFormField(key, value) + } + }, + setUrlFromState() { + const router = getRouter() + const searchString = window.location.search + const params = new URLSearchParams(searchString) + const routeArgs = Object.fromEntries(params) + + let stateResult = formatToApi({ + form: { ...this.form }, + tab: this.tab !== 'map' ? this.tab : undefined, + tabBy: this.tabBy !== 'sicks' ? this.tabBy : undefined, + }) + + if ( + Array.isArray(stateResult.sickImmunizer) && + stateResult.sickImmunizer.length + ) { + stateResult.sickImmunizer = [ + ...stateResult?.sickImmunizer, + ].join(',') + } + if (Array.isArray(stateResult.local) && stateResult.local.length) { + stateResult.local = [...stateResult?.local].join(',') + } + + // TODO: define if cities will be in URL state + + // if (Array.isArray(stateResult.city) && stateResult.city.length) { + // stateResult.city = [...stateResult?.city].join(","); + // } + + delete stateResult.city + + if (JSON.stringify(routeArgs) === JSON.stringify(stateResult)) { + return + } + + router?.replace({ query: stateResult }) + }, + async updateFormSelect() { + const api = new DataFetcher(this.apiUrl) + /** @type {any} payload */ + const payload = {} + const options = await api.request('options') + if (!options) { + return + } + // TODO: make an error handling in case of api offline or instead of value we have an error + for (let [key, value] of Object.entries(options)) { + if (key === 'cities') { + payload[key] = value.map( + ( + /** @type {{ uf: String, nome: String, codigo6: string }} */ item + ) => { + return { + ...item, + label: `${item.uf} - ${item.nome}`, + value: item.codigo6, + } + } + ) + } else { + value.sort() + payload[key] = value.map((/** @type {String} */ item) => { + return { label: item, value: item } + }) + } + } + for (let [key, value] of Object.entries(payload)) { + //@ts-ignore + this.setFormField(key, value) + } + }, + clear() { + this.disableMap = false + this.disableChart = false + const defaultState = getDefaultState() + Object.keys(defaultState.form).forEach((key) => { + // Reset only default select fields, in this case options are not comming from API + if (!key.endsWith('s')) { + //@ts-ignore + this.setFormField(key, defaultState.form[key]) + } + }) + this.tab = 'map' + this.tabBy = 'sicks' + disableOptionsByTypeOrDose(this) + disableOptionsByGranularityOrType(this) + disableOptionsByDoseOrSick(this) + disableOptionsByTab(this) + }, + /** + * Atualiza um campo do formulário e executa as regras de negócio associadas + * @param {string} key - O nome do campo (ex: 'type', 'dose') + * @param {any} value - O novo valor + */ + setFormField(key, value) { + if (key === 'tab') { + this.setTabField(value) + return + } else if (key === 'tabBy') { + this.setTabByField(value) + return + } + if (key === 'periodStart') { + if (!value && this.form.periodEnd) { + this.form.period = this.form.periodEnd + } else { + this.form.period = value + } + } else if (key === 'periodEnd' && !this.form.periodStart) { + // If update and not periodStart, set period as periodEnd value + this.form.period = value + } else if (key === 'sickImmunizer' || key === 'dose') { + disableOptionsByDoseOrSick(this, { [key]: value }) + disableOptionsByTypeOrDose(this, key, value) + // After sickImmunizer update dose select update with type and granularity + const type = this.form.type + if (type) { + disableOptionsByTypeOrDose(this, 'type', type) + disableOptionsByGranularityOrType(this, { type: type }) + } + } else if (key === 'granularity') { + disableOptionsByGranularityOrType(this, { [key]: value }) + } else if (key === 'type') { + disableOptionsByTypeOrDose(this, key, value) + disableOptionsByGranularityOrType(this, { [key]: value }) + } + + // @ts-ignore + this.form[key] = value + + if ( + this.form.sickImmunizer && + this.form.type && + this.form.local.length && + this.form.periodStart && + this.form.periodEnd && + this.form.granularity && + // Avoid unecessary updates and enable use empty dose field + !Object.keys({ key, value }).includes('period') && + !Object.keys({ key, value }).includes('dose') && + !this.form.dose + ) { + const activeDoses = this.form.doses.filter( + (dose) => !dose.disabled + ) + if (activeDoses.length) { + const newDose = activeDoses[activeDoses.length - 1].value + disableOptionsByDoseOrSick(this, { dose: newDose }) + disableOptionsByTypeOrDose(this, 'dose', newDose) + this.form.dose = newDose + } + } + this.checkGramWithState() + }, + checkGramWithState() { + if ( + this.form.granularity === 'Municípios' && + this.form.local.length > 1 + ) { + if (this.tab === 'map') { + this.setTabField('table') + } + this.disableMap = true + } else if ( + this.form.granularity === 'Municípios' || + this.form.type === 'Meta atingida' + ) { + this.disableMap = false + } else { + this.disableMap = false + this.disableChart = false + } + }, + /** + * @param {string} value - New tab value selected + */ + setTabField(value) { + const messageStore = useMessageStore() + this.tab = value + + if (['table', 'chart'].includes(value)) { + if (!this.form.sickImmunizer) { + this.form.sickImmunizer = [] + } else if (!Array.isArray(this.form.sickImmunizer)) { + this.form.sickImmunizer = [this.form.sickImmunizer] + messageStore.message( + 'info', + 'Seletores atualizados para tipo de exibição selecionada' + ) + } + } else if ( + this.form.sickImmunizer && + Array.isArray(this.form.sickImmunizer) && + this.form.sickImmunizer.length > 0 + ) { + this.form.sickImmunizer = this.form.sickImmunizer[0] + disableOptionsByDoseOrSick(this, { + ['sickImmunizer']: this.form.sickImmunizer, + }) + messageStore.message( + 'info', + 'Seletores atualizados para tipo de exibição selecionada' + ) + } else { + this.form.sickImmunizer = null + } + + this.checkGramWithState() + }, + /** + * @param {string} value - New value tab selected + */ + setTabByField(value) { + disableOptionsByGranularityOrType(this) + disableOptionsByTab(this, { tabBy: value }) + this.tabBy = value + + this.form.sickImmunizer = Array.isArray(this.form.sickImmunizer) + ? [] + : null + this.form.dose = null + this.form.granularity = null + this.form.type = null + + disableOptionsByTypeOrDose(this) + disableOptionsByDoseOrSick(this) + }, + }, + getters: { + disableLocalSelect: (state) => { + const granularity = state.form.granularity + + if (granularity === 'Nacional') { + state.form.local = [] + return true + } + return false + }, + // TODO: Fix title not being setted after chart update data + mainTitle: (state) => { + let title = null + const { sickImmunizer, dose, granularity, local, period, type } = + state.form + if ( + sickImmunizer && + Array.isArray(sickImmunizer) && + !sickImmunizer.length + ) { + return + } + if ( + !dose || + !granularity || + !period || + !sickImmunizer || + !type || + (!local.length && granularity !== 'Nacional') + ) { + return + } + if (state.titles) { + if (state.tab === 'map' && state.titles.map) { + title = state.titles.map?.title + ' em ' + period + } else { + title = state.titles.table.title + } + } + return title + }, + subTitle: (state) => { + let subtitle = null + const { sickImmunizer, dose, granularity, local, period, type } = + state.form + if ( + sickImmunizer && + Array.isArray(sickImmunizer) && + !sickImmunizer.length + ) { + return + } + if ( + !granularity || + !period || + !sickImmunizer || + !type || + (!local.length && granularity !== 'Nacional') || + !dose + ) { + return + } + + if (state.titles) { + subtitle = + state.tab === 'map' + ? state.titles.map?.subtitle + : state.titles.table.subtitle + } + return subtitle + }, + selectsPopulated: (state) => { + const { sickImmunizer, dose, granularity, local, period, type } = + state.form + + const isSickImuAnArray = + sickImmunizer && Array.isArray(sickImmunizer) + const isSickImuFilledArray = + isSickImuAnArray && sickImmunizer.length + const isSickImuFilledField = !isSickImuAnArray && sickImmunizer + return ( + (isSickImuFilledArray || isSickImuFilledField) && + dose && + granularity && + (local.length || + (!local.length && granularity === 'Nacional')) && + period && + type + ) + }, + selectsEmpty: (state) => { + const form = state.form + if ( + // If sickImmunizer selected in map or if sickImmunizer array is empty in chart and tables + (form.sickImmunizer && !Array.isArray(form.sickImmunizer)) || + (form.sickImmunizer && form.sickImmunizer.length) || + form.type || + form.local.length || + form.period || + form.granularity + ) { + return false + } + return !state.loading + }, + }, +}) diff --git a/src/stores/index.js b/src/stores/index.js new file mode 100644 index 0000000..e1c9a28 --- /dev/null +++ b/src/stores/index.js @@ -0,0 +1,6 @@ +export * from '@/stores/chart' +export * from '@/stores/content' +export * from '@/stores/map' +export * from '@/stores/message' +export * from '@/stores/modal' +export * from '@/stores/table' diff --git a/src/stores/map.js b/src/stores/map.js new file mode 100644 index 0000000..70469e4 --- /dev/null +++ b/src/stores/map.js @@ -0,0 +1,268 @@ +import { defineStore } from 'pinia' +import { useContentStore } from '@/stores/content' +import { storeToRefs } from 'pinia' + +import { convertArrayToObject } from '@/utils' + +import { MapChart } from '@/map-chart' + +/** + * @typedef {Object} ApiResponseMap + * @property {string[]} localNames - Array of local names. + * @property {{ data: any }} result - Result object containing data. + * @property {boolean} aborted - Indicates if the request was aborted. + * @property {any[]} data - Array of data entries. + */ + +/** + * Retuns default state + * @returns {{ + * mapElement: null | HTMLElement + * map: any + * datasetCities: any + * datasetStates: any + * loadingMap: boolean + * mapChart: any + * currentLocal: any + * mapData: MapChart | null + * mapTooltip: any + * }} + */ +const getDefaultState = () => { + return { + mapElement: null, + map: null, + datasetCities: null, + datasetStates: null, + loadingMap: false, + mapChart: null, + currentLocal: null, + mapData: null, + mapTooltip: null, + } +} + +export const useMapStore = defineStore('map', { + state: () => getDefaultState(), + actions: { + updatePeriod() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + const startYear = form.value.periodStart + const endYear = form.value.periodEnd + // If updated select start or end year update period + if (startYear && endYear) { + const cities = this.datasetCities + const states = this.datasetStates + if (cities) { + form.value.period = Number(Object.keys(cities)[0]) + } else if (states) { + form.value.period = Number(Object.keys(states)[0]) + } + } + }, + /** + * @param {string} period + */ + updatePeriodManual(period) { + if (this.datasetStates) { + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + datasetStates: this.datasetStates[period], + }) + } else if (this.datasetCities) { + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + datasetCities: this.datasetCities[period], + }) + } + }, + /** + * Renders or updates a map chart based on the provided arguments. + * + * @param {any} args - The arguments for rendering the map chart. + */ + renderMap(args) { + const contentStore = useContentStore() + const { form, selectsPopulated } = storeToRefs(contentStore) + const type = form.value.type + + if (!this.mapChart) { + this.mapChart = new MapChart({ + ...args, + type, + formPopulated: selectsPopulated.value, + /** + * @param {boolean} opened + * @param {string} name + * @param {string|number} id + */ + tooltipAction: (opened, name, id) => { + this.mapTooltip = { opened, name, id, type } + }, + }) + } else { + /** @type {MapChart} */ this.mapChart.update({ + ...args, + type, + formPopulated: selectsPopulated.value, + }) + } + + if (this.mapChart) { + this.mapData = /** @type {MapChart} */ ( + this.mapChart.datasetValues + ) + } + }, + async setMapData() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const granularity = form.value.granularity + let local = form.value.local + if (granularity === 'Nacional') { + local = ['BR'] + } + if (!local) { + return + } + const period = form.value.period + + this.datasetCities = null + this.datasetStates = null + + this.loadingMap = true + const results = /** @type{ApiResponseMap} */ ( + await contentStore.requestData() + ) + this.loadingMap = false + + if (results && results.aborted) { + this.loadingMap = false + return + } + + try { + let mapSetup = + /** @type{{ element: any, map: any, datasetCities: any, datasetStates: any, cities: null | string[], states: null | string[], statesSelected: any, loading: boolean }} */ ({ + element: this.mapElement, + map: this.map, + datasetStates: null, + datasetCities: null, + cities: null, + loading: this.loadingMap, + }) + + if (local.length === 1) { + this.datasetCities = convertArrayToObject( + results.result.data + ).data + this.datasetStates = null + this.updatePeriod() + mapSetup = { + ...mapSetup, + datasetCities: this.datasetCities + ? this.datasetCities[period] + : null, + cities: results.localNames, + loading: this.loadingMap, + } + } else { + this.datasetCities = null + this.datasetStates = convertArrayToObject( + results.result.data + ).data + this.updatePeriod() + mapSetup = { + ...mapSetup, + datasetStates: this.datasetStates + ? this.datasetStates[period] + : null, + states: results.localNames, + statesSelected: local, + loading: this.loadingMap, + } + } + this.renderMap(mapSetup) + } catch (e) { + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + }) + } + }, + async updateMap() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const granularity = form.value.granularity + const local = form.value.local + + this.loadingMap = true + if (local.length === 1) { + if (local + granularity !== this.currentLocal) { + this.map = await this.queryMap(local) + } + if (this.map.aborted) { + this.loadingMap = false + return + } + this.currentLocal = local + granularity + } else if (local + granularity !== this.currentLocal) { + this.map = await this.queryMap('BR') + if (this.map.aborted) { + this.loadingMap = false + return + } + + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + }) + this.currentLocal = 'BR' + granularity + } + }, + /** + * @param {string[] | string} local - local to query map. + */ + async queryMap(local) { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + let maplocal + + if ( + form.value.granularity === 'Macrorregião de saúde' && + local.length > 1 + ) { + maplocal = 'macreg/BR' + } else if (form.value.granularity === 'Macrorregião de saúde') { + maplocal = `macreg/${local}` + } else if ( + form.value.granularity === 'Região de saúde' && + local.length > 1 + ) { + maplocal = `reg/BR` + } else if (form.value.granularity === 'Região de saúde') { + maplocal = `reg/${local}` + } else if (form.value.granularity === 'Estados') { + maplocal = 'BR-UF' + } else if (form.value.granularity === 'Nacional') { + maplocal = 'BR' + } else { + maplocal = local + } + + const file = await contentStore.requestMap({ map: maplocal }) + + return file + }, + }, +}) diff --git a/src/stores/message.js b/src/stores/message.js new file mode 100644 index 0000000..8e23ae0 --- /dev/null +++ b/src/stores/message.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' + +/** + * Returns default state for message store + * @returns {{ + * type: 'success' | 'info' | 'warning' | 'error' | null, + * text: string | null, + * duration: number | null + * }} + */ +const getDefaultState = () => { + return { + type: null, + text: null, + duration: null, + } +} + +export const useMessageStore = defineStore('message', { + state: () => getDefaultState(), + actions: { + /** + * @param {'success'|'info'|'warning'|'error'} type - The type of message. + * @param {string} text - The text to display in the alert. + * @param {number} [duration=3000] - The duration for which the message should be displayed, in milliseconds. + */ + message(type, text, duration = 3000) { + this.type = type + this.text = text + this.duration = duration + }, + clear() { + const { type, text, duration } = getDefaultState() + this.type = type + this.text = text + this.duration = duration + }, + }, +}) diff --git a/src/stores/modal.js b/src/stores/modal.js new file mode 100644 index 0000000..dfe3a23 --- /dev/null +++ b/src/stores/modal.js @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia' +import { DataFetcher } from '@/data-fetcher' +import { useContentStore } from '@/stores' + +/** + * @typedef {object} ModalState + * @property {boolean} genericModalLoading - Estado de loading do modal genérico + * @property {boolean} genericModalShow - Controla a exibição do modal genérico + * @property {string | null} extraFilterButton - Configuração de botão de filtro extra + * @property {{[key: string]: any; error?: Error | undefined; aborted?: boolean | undefined;} | null} genericModal - Conteúdo do modal genérico + * @property {string | null} genericModalTitle - Título do modal genérico + * @returns {ModalState} The object state initial + */ +const getDefaultState = () => { + return { + genericModal: null, + genericModalTitle: null, + genericModalLoading: false, + extraFilterButton: null, + genericModalShow: false, + } +} + +export const useModalStore = defineStore('modal', { + state: () => getDefaultState(), + actions: { + /** + * Request page data + * @param {string} slug - Url path + * @return Promise + */ + async requestContent(slug) { + const contentStore = useContentStore() + const api = new DataFetcher(contentStore.apiUrl) + const result = await api.requestSettingApiEndPoint( + slug, + '/wp-json/wp/v2/pages' + ) + this.genericModal = result + }, + }, +}) diff --git a/src/stores/table.js b/src/stores/table.js new file mode 100644 index 0000000..2789ccb --- /dev/null +++ b/src/stores/table.js @@ -0,0 +1,94 @@ +import { defineStore } from 'pinia' +import { useContentStore } from '@/stores' +import { formatToTable } from '@/utils' + +/** + * @typedef {object} ApiResponseTable + * @property {string[]} localNames + * @property {{ data: any, metadata: { pages: { total_pages: string, total_records: string }, type: string } }} result + * @property {any} error + * @property {boolean} aborted + * @property {string[]} data + */ + +/** + * Retuns default state + * @returns {{ + * columns: { title: string, minWidth: string | number, key: string }[] + * loading: boolean + * page: number + * pageCount: number + * pageTotalItems: number + * rows: string[] + * sorter: { columnKey: string, order: string } | undefined + * }} + */ +const getDefaultState = () => { + return { + columns: [], + loading: false, + page: 1, + pageCount: 0, + pageTotalItems: 10, + rows: [], + sorter: undefined, + } +} + +export const useTableStore = defineStore('table', { + state: () => getDefaultState(), + actions: { + async setTableData() { + this.loading = true + const contentStore = useContentStore() + const response = /** @type{ApiResponseTable} */ ( + await contentStore.requestData({ + detail: true, + page: this.page, + sorter: this.sorter, + }) + ) + + if (response?.aborted || !response || !response.result.data) { + this.rows = [] + this.loading = false + return + } + + this.pageCount = Number(response.result.metadata.pages.total_pages) + this.pageTotalItems = Number( + response.result.metadata.pages.total_records + ) + + const tableData = formatToTable( + response.result.data, + response.localNames, + response.result.metadata + ) + this.columns = + /** @type {{ title: string, minWidth: string | number, key: string }[]} */ ( + tableData.header + ) + const dosesQtd = this.columns.findIndex( + (column) => column.title === 'Doses (qtd)' + ) + if (response.result.metadata.type == 'Doses aplicadas') { + this.columns.splice(dosesQtd, 1) + } else { + this.columns[dosesQtd].minWidth = '130px' + } + const columnValue = this.columns.find( + (column) => column.key === 'valor' + ) + if (columnValue) { + columnValue.minWidth = '160px' + columnValue.title = response.result.metadata.type + } + this.rows = /** @type {string[]} */ (tableData.rows) + this.loading = false + }, + resetState() { + this.$reset() + }, + }, +}) diff --git a/src/utils.js b/src/utils.js index 3f8e539..7599427 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,437 +1,696 @@ +/** + * @typedef {Object} SelectOption + * @property {string} label + * @property {string|number} value + * @property {boolean} [disabled] + * @property {string} [disabledText] + * @property {string} [toLowerCase] + */ + +/** + * @typedef {Object} VueState + * @property {Object} form + * @property {SelectOption[]} form.doses + * @property {SelectOption[]} form.types + * @property {SelectOption[]} form.granularities + * @property {string|null} form.dose + * @property {string|null} form.type + * @property {string|string[]|Object[]} [form.sickImmunizer] + * @property {SelectOption[]} [form.sicks] + * @property {SelectOption[]} [form.immunizers] + * @property {string} tabBy + * @property {Array>} doseBlocks + * @property {Array>} granularityBlocks + */ + +/** + * @param {string|number|Date} timestamp + * @returns {number} + */ export const timestampToYear = (timestamp) => { - const date = new Date(timestamp); - const year = date.getFullYear(); - return year; -}; - -export const mapFields = (options) => { - const object = {}; - for (let i = 0; i < options.fields.length; i++) { - const field = [options.fields[i]]; - object[field] = { - get() { - return options.store.state[options.base][field]; - }, - set(value) { - options.store.commit(options.mutation, { [field]: value }); - } - }; - } - return object; + const date = new Date(timestamp) + const year = date.getFullYear() + return year } -export const computedVar = (options) => { - return { - get() { - if (options.base) { - return options.store.state.content[options.base][options.field]; - } - return options.store.state.content[options.field]; - }, - set(value) { - options.store.commit(options.mutation, { [options.field]: value }); +/** + * @param {Object} options + * @param {string[]} options.fields + * @param {Record} options.store + * @param {string} [options.base] + * @returns {Object} + */ +export const mapFields = (options) => { + /** @type {Record} */ + const object = {} + for (let i = 0; i < options.fields.length; i++) { + const field = options.fields[i] + object[field] = { + get() { + // Pinia: Acesso direto (sem .state) + if (options.base) { + return options.store[options.base][field] + } + return options.store[field] + }, + /** @param {any} value */ + set(value) { + // Pinia: Atribuição direta (sem .commit) + if (options.base) { + options.store[options.base][field] = value + } else { + options.store[field] = value + } + }, + } } - } + return object } +/** + * Extracts and formats the year from a given timestamp. + * + * @param {string|number|Date} timestamp - The timestamp to be formatted. + * @returns {string} The year extracted from the timestamp, formatted as a four-digit string. + */ export const formatDate = (timestamp) => { - const date = new Date(timestamp); - const year = date.getFullYear().toString().padStart(4, "0"); - return `${year}`; -}; + const date = new Date(timestamp) + const year = date.getFullYear().toString().padStart(4, '0') + return year +} +/** + * @param {string} dateString - The date string to be converted to UTC. + * @returns {number} - The number representing the UTC timestamp. + */ export const convertDateToUtc = (dateString) => { - const utcdate = Date.UTC(dateString, 1, 1); - return utcdate; -}; + // @ts-ignore + const utcDate = Date.UTC(dateString, 1, 1) + return utcDate +} +/** + * Converts an array of data into a table format. + * + * @param {string[]} data - The array of data to convert. + * @param {string[]} localNames - Array of local names corresponding to the data. + * @param {Object} [metadata] - Metadata object containing additional information. + * @param {string} [metadata.type] - Type of metadata. + * @returns {{header: Array, rows: Array}} An object containing the header and rows for the table. + */ export const formatToTable = (data, localNames, metadata) => { - let header = []; - for (const column of [...data[0], "código"]) { - // Setting width and behaveours of table column - let width = null; - let align = 0; - let minWidth = 200; - if (["ano", "valor", "população", "doses", "código"].includes(column)) { - align = "right"; - width = 120; - minWidth = null; - } - // Formating table title - let title = column.charAt(0).toUpperCase() + column.slice(1); - if (title === "Doenca") { - title = "Doença"; - } - if (title === "Doses") { - title = "Doses (qtd)"; - } - header.push( - { - title, - key: column, - sorter: 'default', - width, - titleAlign: "left", - align, - minWidth, - } - ) - } - - const index = localNames[0].indexOf("geom_id"); - const indexName = localNames[0].indexOf("name"); - const indexUF = localNames[0].indexOf("uf"); - const indexAcronym = localNames[0].indexOf("acronym"); - const rows = []; - - // Loop api return value - for (let i = 1; i < data.length; i++) { - const row = {}; - // Setting value as key: value in row object - for (let j = 0; j < data[i].length; j++) { - const key = header[j].key; - const value = data[i][j]; - if (key === "local") { - let localResult = - localNames.find(localName => localName[index] == value) || - localNames.find(localName => localName[indexAcronym] == value); - if (!localResult) { - continue + /** @type {Array} */ + let header = [] + for (const column of [...data[0], 'código']) { + // Setting width and behaveours of table column + let width = null + /** @type {number|string} */ + let align = 0 + /** @type {number|null} */ + let minWidth = 200 + if (['ano', 'valor', 'população', 'doses', 'código'].includes(column)) { + align = 'right' + width = 120 + minWidth = null } - let name = localResult[indexName]; - let ufAcronymName = localResult[indexUF]; - if (ufAcronymName) { - name += " - " + ufAcronymName + // Formating table title + let title = column.charAt(0).toUpperCase() + column.slice(1) + if (title === 'Doenca') { + title = 'Doença' + } else if (title === 'Doses') { + title = 'Doses (qtd)' } - row["código"] = value; - row[header[j].key] = name; - continue; - } else if (["população", "doses"].includes(key)) { - row[header[j].key] = value.toLocaleString("pt-BR"); - continue - } else if (metadata.type == "Meta atingida" && key == "valor") { - row[header[j].key] = parseInt(value) === 1 ? "Sim" : "Não"; - continue - } - row[header[j].key] = value; + header.push({ + title, + key: column, + sorter: ['código', 'local'].includes(column) ? false : 'default', + width, + titleAlign: 'left', + align, + minWidth, + }) } - // Pushing result row - rows.push(row) - } - header.splice(1, 0, header.splice(6, 1)[0]); + const index = localNames[0].indexOf('geom_id') + const indexName = localNames[0].indexOf('name') + const indexUF = localNames[0].indexOf('uf') + const indexAcronym = localNames[0].indexOf('acronym') + /** @type {Array>} */ + const rows = [] + + // Loop api return value + for (let i = 1; i < data.length; i++) { + /** @type {Record} */ + const row = {} + // Setting value as key: value in row object + for (let j = 0; j < data[i].length; j++) { + const key = header[j].key + const value = data[i][j] + if (key === 'local') { + let localResult = + localNames.find((localName) => localName[index] == value) || + localNames.find( + (localName) => localName[indexAcronym] == value + ) + if (!localResult) { + continue + } + let name = localResult[indexName] + let ufAcronymName = localResult[indexUF] + if (ufAcronymName) { + name += ' - ' + ufAcronymName + } + row['código'] = value + row[header[j].key] = name + continue + } else if (['população', 'doses'].includes(key)) { + // @ts-ignore + row[header[j].key] = value.toLocaleString('pt-BR') + continue + } else if ( + metadata && + metadata.type == 'Meta atingida' && + key == 'valor' + ) { + row[header[j].key] = parseInt(value) === 1 ? 'Sim' : 'Não' + continue + } + row[header[j].key] = value + } + // Pushing result row + rows.push(row) + } - return { header, rows } + header.splice(1, 0, header.splice(6, 1)[0]) + + return { header, rows } } +/** + * Converts an array of data into an object, where each year is a key and its value is another object containing local data. + * + * @param {Array>} inputArray - The input array to convert. The first element should be the header row. + * @returns {{header: Array, data: Object}} An object containing the header and data extracted from the input array. + */ export const convertArrayToObject = (inputArray) => { - const data = {}; + /** @type {Record} */ + const data = {} + + // Loop through the input array starting from the second element + for (let i = 1; i < inputArray.length; i++) { + const [year, local, value, population, doses] = inputArray[i] + if (!data[year]) { + data[year] = {} + } - // Loop through the input array starting from the second element - for (let i = 1; i < inputArray.length; i++) { - const [year, local, value, population, doses] = inputArray[i]; - if (!data[year]) { - data[year] = {}; + data[year][local] = { value, population, doses } } - data[year][local] = { value, population, doses }; - } - - return { header:inputArray[0], data }; + return { header: inputArray[0], data } } +/** + * Creates a debounced function. + * + * @returns {function(Function, number=): void} - A debounced version of the input function. + */ export const createDebounce = () => { - let timer; - return (fn, wait = 300) => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - if (typeof fn === "function") { - fn(); - } - }, wait); - }; -}; + /** @type {number | undefined} */ + let timer + return (fn, wait = 300) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + if (typeof fn === 'function') { + fn() + } + }, wait) + } +} +/** + * @param {string} input + * @returns {string} + */ export const convertToLowerCaseExceptInParentheses = (input) => { - let result = ''; - let insideParentheses = false; - for (let i = 0; i < input.length; i++) { - const char = input[i]; - if (char === '(') { - insideParentheses = true; - } else if (char === ')') { - insideParentheses = false; - } - if (insideParentheses) { - result += char; - } else { - result += char.toLowerCase(); + let result = '' + let insideParentheses = false + for (let i = 0; i < input.length; i++) { + const char = input[i] + if (char === '(') { + insideParentheses = true + } else if (char === ')') { + insideParentheses = false + } + if (insideParentheses) { + result += char + } else { + result += char.toLowerCase() + } } - } - return result; + return result } +/** + * @param {VueState['form']} form + * @returns {[string|string[]|null, boolean]} + */ export const sickImmunizerAsText = (form) => { - let sickImmunizer = null; - let multipleSickImmunizer = false; - if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length > 1) { - if(form.sickImmunizer.length > 2) { - sickImmunizer = convertToLowerCaseExceptInParentheses(form.sickImmunizer.slice(0, -1).join(', ')) + " e " + - convertToLowerCaseExceptInParentheses(form.sickImmunizer[form.sickImmunizer.length - 1]); - } else { - sickImmunizer = convertToLowerCaseExceptInParentheses(form.sickImmunizer.join(" e ")); + /** @type {string|string[]|null} */ + let sickImmunizer = null + let multipleSickImmunizer = false + if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length > 1) { + if (form.sickImmunizer.length > 2) { + sickImmunizer = + convertToLowerCaseExceptInParentheses( + /** @type {string[]} */ (form.sickImmunizer) + .slice(0, -1) + .join(', ') + ) + + ' e ' + + convertToLowerCaseExceptInParentheses( + /** @type {string} */ ( + form.sickImmunizer[form.sickImmunizer.length - 1] + ) + ) + } else { + sickImmunizer = convertToLowerCaseExceptInParentheses( + /** @type {string[]} */ (form.sickImmunizer).join(' e ') + ) + } + multipleSickImmunizer = true + } else if (form.sickImmunizer && !Array.isArray(form.sickImmunizer)) { + sickImmunizer = convertToLowerCaseExceptInParentheses( + /** @type {string} */ (form.sickImmunizer) + ) + } else if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length) { + // @ts-ignore + sickImmunizer = form.sickImmunizer.map((x) => + // @ts-ignore + convertToLowerCaseExceptInParentheses(x.toLowerCase) + ) } - multipleSickImmunizer = true; - } else if (form.sickImmunizer && !Array.isArray(form.sickImmunizer)) { - sickImmunizer = convertToLowerCaseExceptInParentheses(form.sickImmunizer); - } else if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length) { - sickImmunizer = form.sickImmunizer.map(x => convertToLowerCaseExceptInParentheses(x.toLowerCase)); - } - - return [ sickImmunizer, multipleSickImmunizer ]; + + return [sickImmunizer, multipleSickImmunizer] } +/** + * @param {Array} array + * @param {Object} [object={ disabled: false }] + */ const resetOptions = (array, object = { disabled: false }) => { - for (let i=0; i < array.length; i++) { - array[i] = { - ...array[i], - ...object - }; - } + for (let i = 0; i < array.length; i++) { + array[i] = { + ...array[i], + ...object, + } + } } +/* + * TODO: Make dose selector disable field in selector type of + * data instead of change it's data + */ + +/* + * TODO: Enhance situation where select type of data update Dose + * field as 3ª dose + */ + +/** + * @param {VueState} state + * @param {string} [formKey] + * @param {any} [formValue] + */ export const disableOptionsByTypeOrDose = (state, formKey, formValue) => { - const disabledTextAbandono = "Essa informação não está disponível para 1ª dose"; - const disabledText1Dose = "Essa informação não está disponível para Abandono"; - if (formKey == "type" && formValue == "Abandono") { - const doses = state.form.doses; - const index = doses.indexOf(doses.find(el => el.label === "1ª dose")); - doses[index] = { ...doses[index], disabled: true, disabledText: disabledTextAbandono }; - if (state.form.dose == doses[index].label) { - state.form.dose = null; - } - } else if (formKey == "type" && formValue != "Abandono") { - const doses = state.form.doses; - const index = doses.indexOf(doses.find(el => el.label === "1ª dose")); - doses[index] = { ...doses[index], disabled: false, disabledText: disabledText1Dose } - } else if (formKey == "dose" && formValue == "1ª dose") { - const types = state.form.types; - const index = types.indexOf(types.find(el => el.label == "Abandono")); - types[index] = { ...types[index], disabled: true, disabledText: disabledTextAbandono }; - if (state.form.type == types[index].label) { - state.form.type = null; + const disabledTextAbandono = + 'Essa informação não está disponível para 1ª dose' + const disabledText1Dose = + 'Essa informação não está disponível para Abandono' + if (formKey == 'type' && formValue == 'Abandono') { + const doses = state.form.doses + /** @type {number} */ + // @ts-ignore + const index = doses.indexOf(doses.find((el) => el.label === '1ª dose')) + doses[index] = { + ...doses[index], + disabled: true, + disabledText: disabledTextAbandono, + } + if (state.form.dose == doses[index].label) { + state.form.dose = null + } + } else if (formKey == 'type' && formValue != 'Abandono') { + const doses = state.form.doses + /** @type {number} */ + // @ts-ignore + const index = doses.indexOf(doses.find((el) => el.label === '1ª dose')) + doses[index] = { + ...doses[index], + disabled: false, + disabledText: disabledText1Dose, + } + } else if (formKey == 'dose' && formValue == '1ª dose') { + const types = state.form.types + /** @type {number} */ + // @ts-ignore + const index = types.indexOf(types.find((el) => el.label == 'Abandono')) + types[index] = { + ...types[index], + disabled: true, + disabledText: disabledTextAbandono, + } + if (state.form.type == types[index].label) { + state.form.type = null + } + } else if (formKey == 'dose' && formValue != '1ª dose') { + const types = state.form.types + /** @type {number} */ + // @ts-ignore + const index = types.indexOf(types.find((el) => el.label === 'Abandono')) + types[index] = { + ...types[index], + disabled: false, + disabledText: disabledText1Dose, + } + } else if (!formKey) { + // CLEAR_STATE + const doses = state.form.doses + const types = state.form.types + // @ts-ignore + doses[ + // @ts-ignore + doses.indexOf(doses.find((el) => el.label === '1ª dose')) + ].disabled = false + types[ + // @ts-ignore + types.indexOf(types.find((el) => el.label === 'Abandono')) + ].disabled = false } - } else if (formKey == "dose" && formValue != "1ª dose") { - const types = state.form.types; - const index = types.indexOf(types.find(el => el.label === "Abandono")); - types[index] = { ...types[index], disabled: false, disabledText: disabledText1Dose } - } else if (!formKey){ // CLEAR_STATE - const doses = state.form.doses; - const types = state.form.types; - doses[doses.indexOf(doses.find(el => el.label === "1ª dose"))].disabled = false - types[types.indexOf(types.find(el => el.label === "Abandono"))].disabled = false - } } +/** + * @param {VueState} state + * @param {Object} [payload] + * @param {string} [payload.tabBy] + */ export const disableOptionsByTab = (state, payload) => { - if (payload && payload.tabBy == "immunizers") { - const types = state.form.types; - const index = types.indexOf(types.find(el => el.label == "Homogeneidade entre vacinas")); - types[index] = { ...types[index], disabled: false }; - } else { - const types = state.form.types; - const index = types.indexOf(types.find(el => el.label == "Homogeneidade entre vacinas")); - types[index] = { - ...types[index], - disabled: true, - disabledText: "Essa informação está disponível apenas no recorte por vacina" - }; - if (state.form.type == types[index].label) { - state.form.type = null; + if (payload && payload.tabBy == 'immunizers') { + const types = state.form.types + const index = types.indexOf( + // @ts-ignore + types.find((el) => el.label == 'Homogeneidade entre vacinas') + ) + types[index] = { ...types[index], disabled: false } + } else { + const types = state.form.types + const index = types.indexOf( + // @ts-ignore + types.find((el) => el.label == 'Homogeneidade entre vacinas') + ) + types[index] = { + ...types[index], + disabled: true, + disabledText: + 'Essa informação está disponível apenas no recorte por vacina', + } + if (state.form.type == types[index].label) { + state.form.type = null + } } - } } +/** + * @param {string} value + * @returns {string} + */ const blockHeaderName = (value) => { - const firstLetter = value[0]; - const lastLetter = value[value.length - 1]; - return (lastLetter === "o" ? "r" : "") + firstLetter; + const firstLetter = value[0] + const lastLetter = value[value.length - 1] + return (lastLetter === 'o' ? 'r' : '') + firstLetter } +/** + * @param {VueState} state + * @param {Object} [payload] + */ export const disableOptionsByDoseOrSick = (state, payload) => { - const sicksImmunizers = state.tabBy === "sicks" ? state.form['sicks'] : state.form['immunizers']; - const doses = state.form.doses; - - if(!payload) { // CLEAR_STATE - resetOptions(sicksImmunizers); - resetOptions(doses); - return; - } - - const blockedListHeader = [...state.doseBlocks[0]]; - const blockedListRows = [...state.doseBlocks]; - - // Removing header row from blockedListRows - blockedListRows.splice(0, 1); - - const selected = Object.entries(payload)[0]; - const selectedValue = selected[1]; - - const listIndexType = blockedListHeader.findIndex(el => el === "tipo"); - const type = state.tabBy === "immunizers" ? "vacina" : "doenca"; - const listIndexSickImmuno = blockedListHeader.findIndex(el => el === "doenca_imuno"); - if (selected[0] === "dose") { - if(!selectedValue) { // CLEAR_STATE - resetOptions(sicksImmunizers); - return; - } - const listIndex = blockedListHeader.findIndex(el => el === blockHeaderName(selectedValue)); - for (let i=0; i < sicksImmunizers.length; i++) { - const blockedListRow = blockedListRows.find(blr => - blr[listIndexSickImmuno] === sicksImmunizers[i].label && - blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type - ); - const disabled = blockedListRow && blockedListRow[listIndex] === false ? true : false; - sicksImmunizers[i] = { - ...sicksImmunizers[i], - disabled, - disabledText: "Não selecionável para essa dose." - }; - } - } else if (selected[0] === "sickImmunizer") { - if(!selectedValue) { // CLEAR_STATE - resetOptions(doses); - return; + const sicksImmunizers = + state.tabBy === 'sicks' ? state.form['sicks'] : state.form['immunizers'] + // @ts-ignore + const doses = state.form.doses + + if (!payload) { + // CLEAR_STATE + // @ts-ignore + resetOptions(sicksImmunizers) + resetOptions(doses) + return } - let resultToBlock; - if (Array.isArray(selectedValue)) { - resultToBlock = blockedListRows.filter(blr => - selectedValue.includes(blr[listIndexSickImmuno]) && - blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type - ); - } else { - resultToBlock = blockedListRows.find(blr => - blr[listIndexSickImmuno] === selectedValue && - blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type - ); - } + const blockedListHeader = [...state.doseBlocks[0]] + const blockedListRows = [...state.doseBlocks] + + // Removing header row from blockedListRows + blockedListRows.splice(0, 1) + + const selected = Object.entries(payload)[0] + const selectedValue = selected[1] - for (let i=0; i < doses.length; i++) { - let disabled; - if (resultToBlock && Array.isArray(selectedValue)) { - for(let result of resultToBlock) { - disabled = false; - if ( - result[ - blockedListHeader.findIndex(el => el === blockHeaderName(doses[i].label)) - ] === false - ) { - disabled = true; - break; - } + const listIndexType = blockedListHeader.findIndex((el) => el === 'tipo') + const type = state.tabBy === 'immunizers' ? 'vacina' : 'doenca' + const listIndexSickImmuno = blockedListHeader.findIndex( + (el) => el === 'doenca_imuno' + ) + if (selected[0] === 'dose') { + if (!selectedValue) { + // CLEAR_STATE + // @ts-ignore + resetOptions(sicksImmunizers) + return + } + const listIndex = blockedListHeader.findIndex( + (el) => el === blockHeaderName(selectedValue) + ) + + // @ts-ignore + for (let i = 0; i < sicksImmunizers.length; i++) { + const blockedListRow = blockedListRows.find( + (blr) => + // @ts-ignore + blr[listIndexSickImmuno] === sicksImmunizers[i].label && + blr[listIndexType] && + blr[listIndexType] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') === type + ) + const disabled = + blockedListRow && blockedListRow[listIndex] === false + ? true + : false + + // @ts-ignore + sicksImmunizers[i] = { + // @ts-ignore + ...sicksImmunizers[i], + disabled, + disabledText: 'Não selecionável para essa dose.', + } + } + } else if (selected[0] === 'sickImmunizer') { + if (!selectedValue) { + // CLEAR_STATE + resetOptions(doses) + return + } + let resultToBlock + + if (Array.isArray(selectedValue)) { + resultToBlock = blockedListRows.filter( + (blr) => + selectedValue.includes(blr[listIndexSickImmuno]) && + blr[listIndexType] && + blr[listIndexType] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') === type + ) + } else { + resultToBlock = blockedListRows.find((blr) => { + return ( + blr[listIndexSickImmuno] === selectedValue && + blr[listIndexType] && + blr[listIndexType] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') === type + ) + }) + } + + for (let i = 0; i < doses.length; i++) { + let disabled + if (resultToBlock && Array.isArray(selectedValue)) { + for (let result of resultToBlock) { + disabled = false + if ( + result[ + blockedListHeader.findIndex( + (el) => el === blockHeaderName(doses[i].label) + ) + ] === false + ) { + disabled = true + break + } + } + } else if (resultToBlock) { + // Its not a multiple values select + disabled = + resultToBlock[ + blockedListHeader.findIndex( + (el) => el === blockHeaderName(doses[i].label) + ) + ] === true + ? false + : true + } + + doses[i] = { + ...doses[i], + disabled, + disabledText: 'Não selecionável para essa doença/vacina', + } } - } else if (resultToBlock){ // Its not a multiple values select - disabled = resultToBlock[ - blockedListHeader.findIndex(el => el === blockHeaderName(doses[i].label)) - ] === true ? false : true; - } - - doses[i] = { - ...doses[i], - disabled, - disabledText: "Não selecionável para essa doença/vacina" - }; } - } - const doseFinded = doses.find(dose => dose.value === state.form.dose); - if (doseFinded && doseFinded.disabled) { - state.form.dose = null; - } + const doseFinded = doses.find((dose) => dose.value === state.form.dose) + if (doseFinded && doseFinded.disabled) { + state.form.dose = null + } } +/** + * @param {string} date + * @returns {string} + */ export const formatDatePtBr = (date) => { - const inputDate = new Date(date + "T00:00:00"); - const options = { year: 'numeric', month: '2-digit', day: '2-digit' }; - const formatter = new Intl.DateTimeFormat('pt-BR', options); - return formatter.format(inputDate); + const inputDate = new Date(date + 'T00:00:00') + // @ts-ignore + const options = { year: 'numeric', month: '2-digit', day: '2-digit' } + // @ts-ignore + const formatter = new Intl.DateTimeFormat('pt-BR', options) + return formatter.format(inputDate) } +/** + * @param {VueState} state + * @param {Object} [payload] + */ export const disableOptionsByGranularityOrType = (state, payload) => { - const granularities = state.form.granularities; - const types = state.form.types; - - if (!payload) { // CLEAR_STATE - resetOptions(granularities); - resetOptions(types); - return; - } - - const selected = Object.entries(payload)[0]; - const selectedValue = selected[1]; - const blockedListHeader = [...state.granularityBlocks[0]]; - const blockedListRows = [...state.granularityBlocks]; - - // Removing header row from blockedListRows - blockedListRows.splice(0, 1); - const granularityColumnIndex = blockedListHeader.findIndex(el => el === "granularidade"); - const hv = "homogeneidade_entre_vacinas"; - const hg = "homogeneidade_geografica"; - const hvColumnIndex = blockedListHeader.findIndex(el => el === hv); - const hgColumnIndex = blockedListHeader.findIndex(el => el === hg); - - if (selected[0] === "granularity") { - const hvOpt = types.find(type => strToSnakeCaseNormalize(type.value) === hv); - const hgOpt = types.find(type => strToSnakeCaseNormalize(type.value) === hg); - if (!selectedValue) { - if (state.tabBy === "immunizers") { - hvOpt.disabled = false; - } - hgOpt.disabled = false; - return; + const granularities = state.form.granularities + const types = state.form.types + + if (!payload) { + // CLEAR_STATE + resetOptions(granularities) + resetOptions(types) + return } - const elRow = blockedListRows.find(el => - el[granularityColumnIndex] === selectedValue.toLowerCase() - ); - if (!elRow) { - return - } + const selected = Object.entries(payload)[0] + const selectedValue = selected[1] + const blockedListHeader = [...state.granularityBlocks[0]] + const blockedListRows = [...state.granularityBlocks] - if (state.tabBy === "immunizers") { - hvOpt.disabled = !elRow[hvColumnIndex]; - hvOpt.disabledText = "Não selecionável para essa granularidade"; - } - hgOpt.disabled = !elRow[hgColumnIndex]; - hgOpt.disabledText = "Não selecionável para essa granularidade"; + // Removing header row from blockedListRows + blockedListRows.splice(0, 1) + const granularityColumnIndex = blockedListHeader.findIndex( + (el) => el === 'granularidade' + ) + const hv = 'homogeneidade_entre_vacinas' + const hg = 'homogeneidade_geografica' + const hvColumnIndex = blockedListHeader.findIndex((el) => el === hv) + const hgColumnIndex = blockedListHeader.findIndex((el) => el === hg) + + if (selected[0] === 'granularity') { + /** @type {SelectOption} */ + // @ts-ignore + const hvOpt = types.find( + // @ts-ignore + (type) => strToSnakeCaseNormalize(type.value) === hv + ) + /** @type {SelectOption} */ + // @ts-ignore + const hgOpt = types.find( + // @ts-ignore + (type) => strToSnakeCaseNormalize(type.value) === hg + ) + if (!selectedValue) { + if (state.tabBy === 'immunizers') { + hvOpt.disabled = false + } + hgOpt.disabled = false + return + } + const elRow = blockedListRows.find( + (el) => el[granularityColumnIndex] === selectedValue.toLowerCase() + ) - } else if (selected[0] === "type") { - if (!selectedValue) { - granularities.forEach(granularity => granularity.disabled = false) - return; - } - const listIndex = blockedListHeader.findIndex(el => el === strToSnakeCaseNormalize(selectedValue)); - if (listIndex < 1) { - granularities.forEach(granularity => granularity.disabled = false) - return + if (!elRow) { + return + } + + if (state.tabBy === 'immunizers') { + hvOpt.disabled = !elRow[hvColumnIndex] + hvOpt.disabledText = 'Não selecionável para essa granularidade' + } + hgOpt.disabled = !elRow[hgColumnIndex] + hgOpt.disabledText = 'Não selecionável para essa granularidade' + } else if (selected[0] === 'type') { + if (!selectedValue) { + granularities.forEach( + (granularity) => (granularity.disabled = false) + ) + return + } + const listIndex = blockedListHeader.findIndex( + (el) => el === strToSnakeCaseNormalize(selectedValue) + ) + if (listIndex < 1) { + granularities.forEach( + (granularity) => (granularity.disabled = false) + ) + return + } + /** @type {any[]} */ + const resultToBlock = [] + blockedListRows.forEach((el) => { + if (!el[listIndex]) { + resultToBlock.push(el[granularityColumnIndex]) + } + }) + granularities.forEach((granularity) => { + if ( + resultToBlock.includes(String(granularity.value).toLowerCase()) + ) { + granularity.disabled = true + granularity.disabledText = + 'Não selecionável para essa tipo de dado' + return + } + granularity.disabled = false + }) } - const resultToBlock = []; - blockedListRows.forEach(el => { - if (!el[listIndex]) { - resultToBlock.push(el[granularityColumnIndex]); - } - }); - granularities.forEach(granularity => { - if (resultToBlock.includes(granularity.value.toLowerCase())) { - granularity.disabled = true; - granularity.disabledText = "Não selecionável para essa tipo de dado"; - return; - } - granularity.disabled = false; - }) - } } +/** + * @param {string} str + * @returns {string} + */ const strToSnakeCaseNormalize = (str) => - str.toLowerCase().replace(/\s+/g, '_').normalize('NFD').replace(/[\u0300-\u036f]/g, '') + str + .toLowerCase() + .replace(/\s+/g, '_') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7d69239 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + }, + "downlevelIteration": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"] + }, + "exclude": ["node_modules", "dist"], + "include": [ + "src/**/*", + "images.d.ts" + ] +} diff --git a/vite.config.js b/vite.config.js index 601786b..d95ae2c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,31 +1,38 @@ import { defineConfig } from "vite"; import path from "path"; import { version } from "./package.json"; +import { fileURLToPath, URL } from "node:url"; -export default defineConfig({ - resolve: { - alias: { - 'chartjs': 'chart.js', - } - }, - build: { - minify: true, - lib: { - entry: path.resolve(__dirname, "src/main.js"), - name: "MCT", - fileName: () => `mct-${version}.js`, - - formats: ['umd'] +export default defineConfig(({ command, mode }) => { + const isBuild = command === 'build'; + return { + resolve: { + alias: { + 'chartjs': 'chart.js', + 'vue': 'vue/dist/vue.esm-bundler.js', + "@": fileURLToPath(new URL("./src", import.meta.url)), + } }, - rollupOptions: { - output: { - assetFileNames: `mct-${version}.[ext]`, + build: { + minify: true, + lib: { + entry: path.resolve(__dirname, "src/main.js"), + name: "MCT", + fileName: () => `mct-${version}.js`, + + formats: ['umd'] + }, + rollupOptions: { + output: { + assetFileNames: `mct-${version}.[ext]`, + }, }, }, - }, - define: { - 'process.env.NODE_ENV': '"production"', - '__VUE_OPTIONS_API__': true, - '__VUE_PROD_DEVTOOLS__': true, - }, + define: { + 'process.env.NODE_ENV': isBuild ? '"production"' : '"development"', + '__VUE_OPTIONS_API__': !isBuild, + '__VUE_PROD_DEVTOOLS__': !isBuild, + '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': !isBuild + } + } });