diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98df1251..20efe34a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -164,12 +164,22 @@ repos: rev: v6.0.0 hooks: - id: check-yaml + exclude: (\.pnpm-store|node_modules)/ - id: check-json + exclude: (\.pnpm-store|node_modules)/ - id: check-toml + exclude: (\.pnpm-store|node_modules)/ - id: check-ast + exclude: (\.pnpm-store|node_modules|\.venv)/ - id: check-symlinks + exclude: (\.pnpm-store|node_modules)/ - id: end-of-file-fixer + exclude: (\.pnpm-store|node_modules)/ - id: trailing-whitespace + exclude: (\.pnpm-store|node_modules)/ - id: check-illegal-windows-names + exclude: (\.pnpm-store|node_modules)/ - id: check-executables-have-shebangs + exclude: (\.pnpm-store|node_modules)/ - id: check-shebang-scripts-are-executable + exclude: (\.pnpm-store|node_modules)/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example new file mode 100644 index 00000000..8d310b5a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example @@ -0,0 +1,37 @@ +# ©AngelaMos | 2026 +# .env.example.dev + +APP_NAME=monitor +PUBLIC_URL=http://localhost:8432 + +NGINX_HOST_PORT=8432 +BACKEND_HOST_PORT=5432 +FRONTEND_HOST_PORT=3432 +POSTGRES_HOST_PORT=4432 +REDIS_HOST_PORT=6432 + +POSTGRES_USER=monitor +POSTGRES_PASSWORD=changeme +POSTGRES_DB=monitor + +REDIS_PASSWORD= + +# AES-256 key for at-rest encryption of webhook URLs / bot tokens. +# Generate with: openssl rand -base64 32 +# Without this set, the entire notifications + alerts pipeline is disabled. +NOTIFICATION_ENCRYPTION_KEY= + +NVD_API_KEY= +CF_RADAR_TOKEN= +GREYNOISE_API_KEY= +ABUSEIPDB_API_KEY= + +# Number of trusted reverse-proxy hops in front of the backend. +# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx). +TRUSTED_PROXY_HOPS=1 + +# Email for the bootstrap admin. The matching account is auto-promoted to +# role=admin on registration / login. Leave blank to disable auto-promotion. +ADMIN_EMAIL= + +VITE_API_URL=/api diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.dev b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.dev new file mode 100644 index 00000000..94143c70 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.dev @@ -0,0 +1,39 @@ +# ©AngelaMos | 2026 +# .env.example + +APP_NAME=monitor +PUBLIC_URL=http://localhost:8432 + +NGINX_HOST_PORT=8432 +BACKEND_HOST_PORT=5432 +FRONTEND_HOST_PORT=3432 +POSTGRES_HOST_PORT=4432 +REDIS_HOST_PORT=6432 + +POSTGRES_USER=monitor +POSTGRES_PASSWORD=changeme +POSTGRES_DB=monitor + +REDIS_PASSWORD= + +# AES-256 key for at-rest encryption of webhook URLs / bot tokens. +# Generate with: openssl rand -base64 32 +# Without this set, the entire notifications + alerts pipeline is disabled. +NOTIFICATION_ENCRYPTION_KEY= + +NVD_API_KEY= +CF_RADAR_TOKEN= +GREYNOISE_API_KEY= +ABUSEIPDB_API_KEY= + +CLOUDFLARE_TUNNEL_TOKEN= + +# Number of trusted reverse-proxy hops in front of the backend. +# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx). +TRUSTED_PROXY_HOPS=1 + +# Email for the bootstrap admin. The matching account is auto-promoted to +# role=admin on registration / login. Leave blank to disable auto-promotion. +ADMIN_EMAIL= + +VITE_API_URL=/api diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.prod b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.prod new file mode 100644 index 00000000..53950351 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.prod @@ -0,0 +1,39 @@ +# ©AngelaMos | 2026 +# .env.example.prod + +APP_NAME=situation-monitor-prod +PUBLIC_URL=https://xxx.xxx + +NGINX_HOST_PORT=7729 +BACKEND_HOST_PORT=9292 +FRONTEND_HOST_PORT=49967 +POSTGRES_HOST_PORT=47844 +REDIS_HOST_PORT=55675 + +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= + +REDIS_PASSWORD= + +# AES-256 key for at-rest encryption of webhook URLs / bot tokens. +# Generate with: openssl rand -base64 32 +# Without this set, the entire notifications + alerts pipeline is disabled. +NOTIFICATION_ENCRYPTION_KEY= + +NVD_API_KEY= +CF_RADAR_TOKEN=c +GREYNOISE_API_KEY= +ABUSEIPDB_API_KEY= + +CLOUDFLARE_TUNNEL_TOKEN= + +VITE_API_URL=/api + +# Number of trusted reverse-proxy hops in front of the backend. +# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx). +TRUSTED_PROXY_HOPS=2 + +# Email for the bootstrap admin. The matching account is auto-promoted to +# role=admin on registration / login. Leave blank to disable auto-promotion. +ADMIN_EMAIL= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore new file mode 100644 index 00000000..2301f2b3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore @@ -0,0 +1,21 @@ +# ©AngelaMos | 2026 +# .gitignore + +.env +.env.local +.env.*.local +.env.prod + +backend/tmp/ +backend/bin/ +backend/coverage.out +backend/coverage.html +backend/keys/ + + +frontend/dist/ +frontend/node_modules/ +frontend/.vite/ +frontend/.pnpm-store/ + +docs/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile b/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile new file mode 100644 index 00000000..6a21c33b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile @@ -0,0 +1,253 @@ +# ©AngelaMos | 2026 +# Justfile + +set export +set shell := ["bash", "-uc"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` +binary := "monitor" + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Linting and Formatting +# ============================================================================= + +[group('lint')] +lint *ARGS: + cd backend && golangci-lint run --timeout=5m {{ARGS}} + +[group('lint')] +lint-fix: + cd backend && golangci-lint run --timeout=5m --fix + +[group('lint')] +format: + cd backend && golangci-lint fmt + +[group('lint')] +tidy: + cd backend && go mod tidy + +[group('lint')] +vet: + cd backend && go vet ./... + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + cd backend && go test -race ./... {{ARGS}} + +[group('test')] +test-v *ARGS: + cd backend && go test -race -v ./... {{ARGS}} + +[group('test')] +cover: + cd backend && go test -race -cover ./... + +# ============================================================================= +# CI / Quality +# ============================================================================= + +[group('ci')] +ci: lint test + @echo "All checks passed." + +[group('ci')] +check: lint vet + +# ============================================================================= +# Development (host go) +# ============================================================================= + +[group('dev')] +run *ARGS: + cd backend && go run ./cmd/api {{ARGS}} + +[group('dev')] +dev-serve: + cd backend && go run ./cmd/api -config config.yaml + +# ============================================================================= +# Build (Production) +# ============================================================================= + +[group('prod')] +build: + cd backend && go build -ldflags="-s -w" -o ../bin/{{binary}} ./cmd/api + @echo "Built: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))" + +[group('prod')] +build-static: + cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o ../bin/{{binary}} ./cmd/api + @echo "Built static: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))" + +[group('prod')] +build-debug: + cd backend && go build -o ../bin/{{binary}} ./cmd/api + +[group('prod')] +install: + cd backend && go install ./cmd/api + +# ============================================================================= +# Database +# ============================================================================= + +[group('db')] +migrate *ARGS: + docker compose --env-file .env.prod -f compose.yml exec backend sh -c 'goose -dir /migrations postgres "$DATABASE_URL" up' {{ARGS}} + +[group('db')] +migrate-dev *ARGS: + docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" up' {{ARGS}} + +[group('db')] +migrate-down: + docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" down' + +[group('db')] +migrate-status: + docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" status' + +[group('db')] +psql: + docker compose -f dev.compose.yml exec postgres psql -U $${POSTGRES_USER:-monitor} -d $${POSTGRES_DB:-monitor} + +[group('db')] +redis-cli: + docker compose -f dev.compose.yml exec redis redis-cli + +# ============================================================================= +# Docker (production) +# ============================================================================= + +[group('docker')] +up *ARGS: + docker compose --env-file .env.prod -f compose.yml up {{ARGS}} + +[group('docker')] +start *ARGS: + docker compose --env-file .env.prod -f compose.yml up -d --build {{ARGS}} + +[group('docker')] +down *ARGS: + docker compose --env-file .env.prod -f compose.yml down {{ARGS}} + +[group('docker')] +logs *SERVICE: + docker compose --env-file .env.prod -f compose.yml logs -f {{SERVICE}} + +[group('docker')] +ps: + docker compose --env-file .env.prod -f compose.yml ps + +[group('docker')] +build-prod: + docker compose --env-file .env.prod -f compose.yml build + +# ============================================================================= +# Docker (development) +# ============================================================================= + +[group('docker')] +dev-up *ARGS: + docker compose -f dev.compose.yml up {{ARGS}} + +[group('docker')] +dev-start *ARGS: + docker compose -f dev.compose.yml up -d --build {{ARGS}} + +[group('docker')] +dev-down *ARGS: + docker compose -f dev.compose.yml down {{ARGS}} + +[group('docker')] +dev-logs *SERVICE: + docker compose -f dev.compose.yml logs -f {{SERVICE}} + +[group('docker')] +dev-ps: + docker compose -f dev.compose.yml ps + +[group('docker')] +dev-shell service='backend': + docker compose -f dev.compose.yml exec -it {{service}} /bin/sh + +[group('docker')] +dev-restart: + docker compose -f dev.compose.yml down + docker compose -f dev.compose.yml up -d --build + docker compose -f dev.compose.yml logs -f + +[group('docker')] +dev-clean: + docker compose -f dev.compose.yml down -v + +# ============================================================================= +# Cloudflare Tunnel +# ============================================================================= + +[group('tunnel')] +tunnel-up *ARGS: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up {{ARGS}} + +[group('tunnel')] +tunnel-start *ARGS: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d --build {{ARGS}} + +[group('tunnel')] +tunnel-down *ARGS: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down {{ARGS}} + +[group('tunnel')] +tunnel-logs: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f cloudflared + +[group('tunnel')] +prod-restart: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f + +[group('tunnel')] +prod-redeploy: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d --build + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f + +[group('tunnel')] +prod-logs *SERVICE: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f {{SERVICE}} + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "Go: $(cd backend && go version | cut -d' ' -f3)" + @echo "OS: {{os()}} ({{arch()}})" + @echo "Module: $(head -1 backend/go.mod | cut -d' ' -f2)" + +[group('util')] +clean: + -rm -rf bin/ + -rm -rf backend/tmp/ + @echo "Cleaned build artifacts." + +[group('util')] +env-init: + @if [ ! -f .env ]; then cp .env.example .env && echo "created .env from .env.example — fill in secrets"; else echo ".env already exists"; fi diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/LICENSE b/PROJECTS/advanced/monitor-the-situation-dashboard/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md new file mode 100644 index 00000000..f1585d53 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -0,0 +1,105 @@ + + +```json + ██████╗██╗ █████╗ +██╔════╝██║██╔══██╗ +██║ ██║███████║ +██║ ██║██╔══██║ +╚██████╗██║██║ ██║ + ╚═════╝╚═╝╚═╝ ╚═╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2328-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/monitor-the-situation-dashboard) +[![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev) +[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![Live Demo](https://img.shields.io/badge/Live-iminthewalls.com-green?style=flat&logo=googlechrome)](https://iminthewalls.com/) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com) + +> Operator-grade real-time situational awareness dashboard. Eleven live feeds across cyber, world, and finance — fused into a single 3D-globe SOC view with WebSocket delivery and configurable alerting. + +*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +> The phrase "monitoring the situation" is a Twitter/X meme from June 2025. This is the version that actually monitors the situation. + +## What It Does + +- Aggregates 11 high-signal data feeds (DShield, Cloudflare Radar, NVD/EPSS, CISA KEV, ransomware.live, Coinbase WS, USGS, NOAA SWPC, Wikipedia ITN, GDELT, ISS) with per-source cadences from sub-second to daily +- WebSocket fan-out from a single Go binary — collectors run as errgroup goroutines, events flow through an in-process bus to all connected clients +- 3D MapLibre globe centerpiece with country-level outage shading, BGP hijack regions, mass-scan source ASN dots, ransomware victim markers, earthquake epicenters, and live ISS orbital track +- CVE velocity timeline with EPSS-weighted prioritization and CISA KEV diff alerts +- Configurable alerts (toast / banner / chime / Telegram / Discord) with AES-256 encryption of webhook secrets at rest +- BRIN-indexed Postgres time-series storage tuned for append-mostly event streams +- JWT auth with auto-rotating Ed25519 keys, public read-only mode, multi-device session management + +## Quick Start + +```bash +just dev-up +``` + +Visit `http://localhost:8432` or the live demo at [iminthewalls.com](https://iminthewalls.com/) + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Stack + +**Backend:** Go 1.25, chi v5, `coder/websocket`, pgx + pgxpool, goose migrations, errgroup-driven collectors, Argon2id, JWT (Ed25519) + +**Frontend:** React 19, TypeScript, Vite, TanStack Query v5, Zustand, MapLibre GL, D3, SCSS Modules + +**Data:** PostgreSQL 16 (BRIN time-series indexes), Redis 7 + +**Infrastructure:** Docker Compose, nginx reverse proxy, Cloudflare Tunnel (prod), multi-stage builds, air for live reload + +## Data Sources + +| Panel | Source | Cadence | Auth | +|-------|--------|---------|------| +| Mass-scan firehose | DShield (SANS ISC) | 1h | none | +| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` | +| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional) | +| CISA KEV (in-the-wild) | CISA KEV catalog | 1h | none | +| Ransomware victims | ransomware.live | 15m | none | +| BTC + ETH live ticks | Coinbase Advanced Trade WS | persistent | none | +| Earthquakes (M2.5+) | USGS GeoJSON | 1m | none | +| Space weather (Kp / Bz / X-flux) | NOAA SWPC | 1m / 3h | none | +| World events | Wikipedia ITN + GDELT v2 | 5m / 15m | none | +| ISS position + passes | wheretheiss.at + CelesTrak | 10s / 24h | none | +| IP enrichment (BGP) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional) | + +## Production (Cloudflare Tunnel) + +```bash +cp .env.example .env +just prod-redeploy +just migrate +``` + +## Tests + +```bash +cd backend && go test -race ./... +``` + +## Learn + +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | Threat intel feeds, BGP hijacks, EPSS, KEV, situational awareness theory | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | Single-binary collector pipeline, in-process event bus, WebSocket fan-out | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough across collectors, snapshot, ws, alerts | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas (additional feeds, custom alerts, deployment) | + +## License + +AGPL 3.0 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml new file mode 100644 index 00000000..5f03fe95 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml @@ -0,0 +1,29 @@ +# AngelaMos | 2026 +# .air.toml - Hot reload configuration + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "./tmp/main" +include_ext = ["go", "yaml", "yml"] +exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"] +exclude_regex = ["_test\\.go"] +delay = 1000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "cyan" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example new file mode 100644 index 00000000..f542823a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example @@ -0,0 +1,33 @@ +# AngelaMos | 2026 +# .env.example + +# Environment: development, staging, production +ENVIRONMENT=development + +# Server +HOST=0.0.0.0 +PORT=8080 + +# Database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/app?sslmode=disable + +# Redis +REDIS_URL=redis://localhost:6379/0 + +# JWT +JWT_PRIVATE_KEY_PATH=keys/private.pem +JWT_PUBLIC_KEY_PATH=keys/public.pem +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=1m + +# OpenTelemetry +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=go-backend + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore new file mode 100644 index 00000000..3688c87b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore @@ -0,0 +1,44 @@ +# AngelaMos | 2026 +# .gitignore + +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test +*.test +coverage.out +coverage.html + +# Build +tmp/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Keys (sensitive) +keys/*.pem +keys/*.key + +# Vendor (if using) +vendor/ + +# Debug +__debug_bin* diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml new file mode 100644 index 00000000..b73984e2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml @@ -0,0 +1,160 @@ +# AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + settings: + errcheck: + check-type-assertions: true + check-blank: false + exclude-functions: + - (io.Closer).Close + - (*github.com/coder/websocket.Conn).Close + - (*nhooyr.io/websocket.Conn).Close + - encoding/json.Marshal + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + + staticcheck: + checks: + - all + + gosec: + excludes: + - G104 # unhandled errors — covered by errcheck where actionable + - G101 # hardcoded credentials — false on env-var-name maps + HTTP header constants + - G117 # exported field name matches secret pattern — false on every Password/APIKey/Token DTO field + - G115 # int conversions — values are config-bounded, no realistic overflow + - G304 # file inclusion via variable — false on test fixtures + safe internal paths + - G704 # SSRF via taint — webhook senders POST to user-supplied URLs by design + - G706 # log injection — using slog structured fields, not concat + + testifylint: + disable: + - go-require # fires inside httptest handler closures; known false-positive pattern + + sloglint: + no-mixed-args: true + kv-only: true + context: all + + exclusions: + paths: + - vendor + - testdata + rules: + # test files: don't enforce production-grade hygiene on fixtures + mocks + - path: _test\.go + linters: + - funlen + - dupl + - goconst + - gocritic + - errcheck + - exhaustive + + # bodyclose: coder/websocket Dial returns a *http.Response whose body + # is empty by protocol — closing it isn't part of the websocket idiom + - path: internal/ws/ + linters: + - bodyclose + + # bodyclose: ratelimit.New[*http.Response](...) is a generic type param, + # not an actual response body — linter pattern-matches the type symbol + - path: internal/httpx/client\.go + linters: + - bodyclose + + # main.go bootstrap is inherently long + branchy + - path: cmd/api/main\.go + linters: + - gocognit + - funlen + + # auth.Login vs auth.Register: structurally similar, semantically + # different (different DTOs, errors, status codes). Deduping with a + # generic helper would obscure the code more than the duplication does. + - path: internal/auth/handler\.go + linters: + - dupl + + # coinbase websocket state machine: handleConn is inherently branchy + - path: internal/collectors/coinbase/collector\.go + linters: + - gocognit + +formatters: + enable: + - gci # Groups imports + - gofumpt # Whitespace + - golines # Vertical wrap + settings: + golines: + max-len: 80 + reformat-tags: true + goimports: + local-prefixes: + - github.com/carterperez-dev/monitor-the-situation/backend + gci: + sections: + - standard + - default + - prefix(github.com/carterperez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go new file mode 100644 index 00000000..15745f93 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -0,0 +1,686 @@ +// ©AngelaMos | 2026 +// main.go + +package main + +import ( + "context" + "errors" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/sync/errgroup" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/admin" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/alerts" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/state" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/greynoise" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/intel" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/notifications" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/server" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/user" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +const ( + drainDelay = 5 * time.Second +) + +type abuseipdbEnricher struct { + client *abuseipdb.Client +} + +func (a abuseipdbEnricher) Lookup( + ctx context.Context, + ip string, +) (cfradar.Enrichment, error) { + v, err := a.client.Lookup(ctx, ip) + if err != nil { + return cfradar.Enrichment{}, err + } + return cfradar.Enrichment{ + Country: v.CountryCode, + AbuseConfidence: v.AbuseConfidenceScore, + ISP: v.ISP, + }, nil +} + +type compositeDshieldEnricher struct { + abuse *abuseipdb.Client + gn *greynoise.Cached +} + +func (c compositeDshieldEnricher) Lookup( + ctx context.Context, + ip string, +) (dshield.DShieldEnrichment, error) { + out := dshield.DShieldEnrichment{} + if c.abuse != nil { + if v, err := c.abuse.Lookup(ctx, ip); err == nil { + out.Country = v.CountryCode + } + } + if c.gn != nil { + if v, err := c.gn.Lookup(ctx, ip); err == nil { + out.Classification = v.Classification + out.Actor = v.Name + } + } + return out, nil +} + +func main() { + configPath := flag.String("config", "config.yaml", "path to config file") + healthcheck := flag.Bool( + "healthcheck", + false, + "probe local /healthz and exit 0 if healthy, 1 otherwise", + ) + flag.Parse() + + if *healthcheck { + os.Exit(runHealthcheck()) + } + + if err := run(*configPath); err != nil { + slog.Error("application error", "error", err) + os.Exit(1) + } +} + +func runHealthcheck() int { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/healthz") + if err != nil { + return 1 + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return 1 + } + return 0 +} + +//nolint:funlen // bootstrap code is inherently verbose +func run(configPath string) error { + ctx, stop := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + ) + defer stop() + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + logger := setupLogger(cfg.Log) + slog.SetDefault(logger) + + logger.Info("starting application", + "name", cfg.App.Name, + "version", cfg.App.Version, + "environment", cfg.App.Environment, + ) + + var telemetry *core.Telemetry + if cfg.Otel.Enabled { + tel, telErr := core.NewTelemetry(ctx, cfg.Otel, cfg.App) + if telErr != nil { + logger.Warn("failed to initialize telemetry", "error", telErr) + } else { + telemetry = tel + logger.Info("OpenTelemetry tracer initialized", + "endpoint", cfg.Otel.Endpoint, + ) + } + } + + db, err := core.NewDatabase(ctx, cfg.Database) + if err != nil { + return err + } + logger.Info("database connected", + "max_open_conns", cfg.Database.MaxOpenConns, + "max_idle_conns", cfg.Database.MaxIdleConns, + ) + + redis, err := core.NewRedis(ctx, cfg.Redis) + if err != nil { + return err + } + logger.Info("redis connected", + "pool_size", cfg.Redis.PoolSize, + ) + + if keyErr := ensureJWTKeys(cfg.JWT, logger); keyErr != nil { + return keyErr + } + + jwtManager, err := auth.NewJWTManager(cfg.JWT) + if err != nil { + return err + } + logger.Info("JWT manager initialized", + "algorithm", "ES256", + "key_id", jwtManager.GetKeyID(), + ) + + userRepo := user.NewRepository(db.DB) + userSvc := user.NewService(userRepo) + userHandler := user.NewHandler(userSvc) + + authRepo := auth.NewRepository(db.DB) + authSvc := auth.NewServiceWithConfig(auth.ServiceConfig{ + Repo: authRepo, + JWT: jwtManager, + UserProvider: userSvc, + Redis: redis.Client, + AdminEmail: cfg.App.AdminEmail, + }) + authVerifier := auth.NewVerifier(jwtManager, authSvc, userSvc) + authHandler := auth.NewHandlerWithConfig(auth.HandlerConfig{ + Service: authSvc, + TrustedProxyHops: cfg.Server.TrustedProxyHops, + SecureCookies: cfg.IsProduction(), + }) + + var notifHandler *notifications.Handler + var notifBridge *notifications.Bridge + if cfg.Notifications.EncryptionKey != "" { + enc, encErr := notifications.NewEncryptor( + cfg.Notifications.EncryptionKey, + ) + if encErr != nil { + logger.Warn( + "notifications disabled: invalid encryption key", + "error", encErr, + ) + } else { + notifRepo := notifications.NewRepository(db.DB) + notifSender := notifications.NewSender() + notifSvc := notifications.NewService( + notifRepo, + enc, + notifSender, + cfg.App.PublicURL, + logger.With("service", "notifications"), + ) + notifHandler = notifications.NewHandler(notifSvc) + notifBridge = notifications.NewBridge( + notifRepo, + enc, + notifSender, + logger.With("service", "alerts"), + ) + } + } else { + logger.Warn( + "notifications disabled: NOTIFICATION_ENCRYPTION_KEY not set", + ) + } + + alertsRepo := alerts.NewRepository(db.DB) + authSvc.SetRuleSeeder(func(ctx context.Context, userID string) error { + return alertsRepo.SeedDefaults(ctx, userID) + }) + alertsHandler := alerts.NewHandler(alertsRepo) + + healthHandler := health.NewHandler(db, redis) + + adminHandler := admin.NewHandler(admin.HandlerConfig{ + DBStats: db.Stats, + RedisStats: redis.PoolStats, + DBPing: db.Ping, + RedisPing: redis.Ping, + }) + + snapStore := snapshot.NewStore(redis.Client) + snapHandler := snapshot.NewHandler(snapStore) + + cveRepo := cve.NewRepo(db.DB) + kevRepo := kev.NewRepo(db.DB) + cfradarRepo := cfradar.NewRepo(db.DB) + ransomwareRepo := ransomware.NewRepo(db.DB) + usgsRepo := usgs.NewRepo(db.DB) + intelHandler := intel.NewHandler(intel.HandlerConfig{ + CVE: cveRepo, + KEV: kevRepo, + CFRadar: cfradarRepo, + Ransomware: ransomwareRepo, + USGS: usgsRepo, + }) + + hub := ws.NewHub(ws.HubConfig{ + Logger: logger, + MaxSubscribers: cfg.Server.WSMaxSubscribers, + }) + wsHandler := ws.NewHandlerWithConfig(ws.HandlerConfig{ + Hub: hub, + MaxConnsPerIP: cfg.Server.WSMaxConnsPerIP, + TrustedProxyHops: cfg.Server.TrustedProxyHops, + }) + + eventBus := bus.New(bus.Config{ + BufferSize: 512, + Persister: snapshot.StorePersister{Store: snapStore}, + Broadcaster: ws.HubBroadcaster{Hub: hub}, + Logger: logger, + }) + + beat := heartbeat.New(heartbeat.Config{ + Interval: 5 * time.Second, + Emitter: eventBus, + }) + + collectorState := state.NewRepo(db.DB) + + collectorGroup, collectorCtx := errgroup.WithContext(ctx) + collectorGroup.Go(func() error { return eventBus.Run(collectorCtx) }) + collectorGroup.Go(func() error { return beat.Run(collectorCtx) }) + + if notifBridge != nil { + alertsEngine, err := alerts.NewEngine(alerts.EngineConfig{ + Repo: alertsRepo, + Notifier: notifBridge, + Loader: notifBridge, + Cooldowns: alerts.NewRedisCooldown(redis.Client), + Logger: logger.With("component", "alerts.engine"), + }) + if err != nil { + return err + } + dispatcher := alerts.NewDispatcher( + eventBus.Subscribe(), + alertsEngine, + logger.With("component", "alerts.dispatcher"), + ) + collectorGroup.Go( + func() error { return alertsEngine.RefreshLoop(collectorCtx) }, + ) + collectorGroup.Go(func() error { return dispatcher.Run(collectorCtx) }) + } else { + logger.Info( + "alerts engine disabled (notifications module not configured)", + ) + } + + if cfg.Collectors.DShield.Enabled { + var dsEnricher dshield.Enricher + var abuseClient *abuseipdb.Client + var gnCached *greynoise.Cached + if cfg.Collectors.AbuseIPDB.Enabled && + cfg.Collectors.AbuseIPDB.APIKey != "" { + abuseClient = abuseipdb.NewClient( + abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey}, + ) + } + if cfg.Collectors.GreyNoise.Enabled && + cfg.Collectors.GreyNoise.APIKey != "" { + gnCached = greynoise.NewCached( + greynoise.NewClient( + greynoise.ClientConfig{ + APIKey: cfg.Collectors.GreyNoise.APIKey, + }, + ), + redis.Client, + ) + } + if abuseClient != nil || gnCached != nil { + dsEnricher = compositeDshieldEnricher{ + abuse: abuseClient, + gn: gnCached, + } + } + coll := dshield.NewCollector(dshield.CollectorConfig{ + Interval: cfg.Collectors.DShield.Interval, + Fetcher: dshield.NewClient(dshield.ClientConfig{}), + Persister: dshield.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Enricher: dsEnricher, + Logger: logger.With("collector", "dshield"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.CFRadar.Enabled { + var enricher cfradar.Enricher + if cfg.Collectors.AbuseIPDB.Enabled && + cfg.Collectors.AbuseIPDB.APIKey != "" { + enricher = abuseipdbEnricher{ + client: abuseipdb.NewClient( + abuseipdb.ClientConfig{ + APIKey: cfg.Collectors.AbuseIPDB.APIKey, + }, + ), + } + } + coll := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: cfg.Collectors.CFRadar.Interval, + MinConfidence: cfg.Collectors.CFRadar.MinConfidence, + Fetcher: cfradar.NewClient( + cfradar.ClientConfig{ + BearerToken: cfg.Collectors.CFRadar.BearerToken, + }, + ), + Repo: cfradar.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Enricher: enricher, + Logger: logger.With("collector", "cfradar"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.CVE.Enabled { + coll := cve.NewCollector(cve.CollectorConfig{ + Interval: cfg.Collectors.CVE.Interval, + Window: cfg.Collectors.CVE.Window, + NVD: cve.NewNVDClient( + cve.NVDClientConfig{APIKey: cfg.Collectors.CVE.NVDAPIKey}, + ), + EPSS: cve.NewEPSSClient(cve.EPSSClientConfig{}), + Repo: cve.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "cve"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.KEV.Enabled { + coll := kev.NewCollector(kev.CollectorConfig{ + Interval: cfg.Collectors.KEV.Interval, + Fetcher: kev.NewClient(kev.ClientConfig{}), + Repo: kev.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "kev"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.Ransomware.Enabled { + coll := ransomware.NewCollector(ransomware.CollectorConfig{ + Interval: cfg.Collectors.Ransomware.Interval, + Fetcher: ransomware.NewClient(ransomware.ClientConfig{}), + Repo: ransomware.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "ransomware"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.Coinbase.Enabled { + coll := coinbase.NewCollector(coinbase.CollectorConfig{ + URL: cfg.Collectors.Coinbase.URL, + ProductIDs: cfg.Collectors.Coinbase.ProductIDs, + Repo: coinbase.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Throttle: cfg.Collectors.Coinbase.Throttle, + Logger: logger.With("collector", "coinbase"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.USGS.Enabled { + coll := usgs.NewCollector(usgs.CollectorConfig{ + Interval: cfg.Collectors.USGS.Interval, + Fetcher: usgs.NewClient(usgs.ClientConfig{}), + Repo: usgs.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "usgs"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.SWPC.Enabled { + ring := redisring.New( + redis.Client, + redisring.Config{Retention: 24 * time.Hour}, + ) + coll := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: cfg.Collectors.SWPC.FastInterval, + SlowInterval: cfg.Collectors.SWPC.SlowInterval, + Fetcher: swpc.NewClient(swpc.ClientConfig{}), + Ring: ring, + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "swpc"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.Wikipedia.Enabled { + coll := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: cfg.Collectors.Wikipedia.Interval, + Fetcher: wikipedia.NewClient(wikipedia.ClientConfig{}), + Repo: wikipedia.NewRepo(db.DB, redis.Client), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "wikipedia"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.GDELT.Enabled { + coll := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: cfg.Collectors.GDELT.Interval, + Fetcher: gdelt.NewClient(gdelt.ClientConfig{}), + Repo: gdelt.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "gdelt"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.ISS.Enabled { + coll := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: cfg.Collectors.ISS.PositionInterval, + TLEInterval: cfg.Collectors.ISS.TLEInterval, + Fetcher: iss.NewClient(iss.ClientConfig{}), + TLEStore: iss.NewTLEStore(redis.Client), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "iss"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + logger.Info("collectors started", + "heartbeat", true, + "dshield", cfg.Collectors.DShield.Enabled, + "cfradar", cfg.Collectors.CFRadar.Enabled, + "cve", cfg.Collectors.CVE.Enabled, + "kev", cfg.Collectors.KEV.Enabled, + "ransomware", cfg.Collectors.Ransomware.Enabled, + "coinbase", cfg.Collectors.Coinbase.Enabled, + "usgs", cfg.Collectors.USGS.Enabled, + "swpc", cfg.Collectors.SWPC.Enabled, + "wikipedia", cfg.Collectors.Wikipedia.Enabled, + "gdelt", cfg.Collectors.GDELT.Enabled, + "iss", cfg.Collectors.ISS.Enabled, + ) + + srv := server.New(server.Config{ + ServerConfig: cfg.Server, + HealthHandler: healthHandler, + Logger: logger, + }) + + router := srv.Router() + + router.Use(middleware.RequestID) + router.Use(middleware.Logger(logger)) + router.Use( + middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{ + Limit: middleware.PerWindow( + cfg.RateLimit.Requests, + cfg.RateLimit.Burst, + cfg.RateLimit.Window, + ), + KeyFunc: middleware.KeyByClientIP(cfg.Server.TrustedProxyHops), + FailOpen: true, + }).Handler, + ) + router.Use(middleware.SecurityHeaders(cfg.App.Environment == "production")) + router.Use(middleware.CORS(cfg.CORS)) + + healthHandler.RegisterRoutes(router) + + router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler()) + + authenticator := middleware.Authenticator(authVerifier) + adminOnly := middleware.RequireAdmin + + router.Route("/v1", func(r chi.Router) { + r.Get("/healthz", healthHandler.Liveness) + r.Get("/readyz", healthHandler.Readiness) + + r.Get("/snapshot", snapHandler.ServeHTTP) + r.Get("/ws", wsHandler.ServeHTTP) + + intelHandler.RegisterRoutes(r) + + authHandler.RegisterRoutes(r, authenticator) + + userHandler.RegisterRoutes(r, authenticator) + userHandler.RegisterAdminRoutes(r, authenticator, adminOnly) + adminHandler.RegisterRoutes(r, authenticator, adminOnly) + + if notifHandler != nil { + notifHandler.RegisterRoutes(r, authenticator) + } + if alertsHandler != nil { + alertsHandler.RegisterRoutes(r, authenticator) + } + }) + + errChan := make(chan error, 1) + go func() { + errChan <- srv.Start() + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + logger.Info("shutdown signal received") + } + + shutdownCtx, cancel := context.WithTimeout( + context.Background(), + cfg.Server.ShutdownTimeout+drainDelay+5*time.Second, + ) + defer cancel() + + if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil { + logger.Error("server shutdown error", "error", err) + } + + if telemetry != nil { + if err := telemetry.Shutdown(shutdownCtx); err != nil { + logger.Error("telemetry shutdown error", "error", err) + } + } + + if err := collectorGroup.Wait(); err != nil && + !errors.Is(err, context.Canceled) && + !errors.Is(err, context.DeadlineExceeded) { + logger.Error("collector group exit", "error", err) + } + + if err := redis.Close(); err != nil { + logger.Error("redis close error", "error", err) + } + + if err := db.Close(); err != nil { + logger.Error("database close error", "error", err) + } + + logger.Info("application stopped") + return nil +} + +func ensureJWTKeys(cfg config.JWTConfig, logger *slog.Logger) error { + if _, err := os.Stat(cfg.PrivateKeyPath); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if dir := filepath.Dir(cfg.PrivateKeyPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + if dir := filepath.Dir(cfg.PublicKeyPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + + logger.Warn("JWT keys missing, generating", + "private", cfg.PrivateKeyPath, + "public", cfg.PublicKeyPath, + ) + return auth.GenerateKeyPair(cfg.PrivateKeyPath, cfg.PublicKeyPath) +} + +func setupLogger(cfg config.LogConfig) *slog.Logger { + var handler slog.Handler + + level := slog.LevelInfo + switch cfg.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + opts := &slog.HandlerOptions{Level: level} + + if cfg.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + + return slog.New(handler) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml new file mode 100644 index 00000000..01aa28f0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml @@ -0,0 +1,53 @@ +# AngelaMos | 2026 +# config.yaml - Default configuration + +app: + name: "Go Backend Template" + version: "1.0.0" + +server: + host: "0.0.0.0" + port: 8080 + read_timeout: 30s + write_timeout: 30s + idle_timeout: 120s + shutdown_timeout: 15s + +database: + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + conn_max_idle_time: 30m + +redis: + pool_size: 10 + min_idle_conns: 5 + +jwt: + access_token_expire: 15m + refresh_token_expire: 168h + issuer: "go-backend" + +rate_limit: + requests: 100 + window: 1m + burst: 20 + +cors: + allowed_origins: + - "http://localhost:3000" + - "http://localhost:3420" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-Request-ID" + allow_credentials: true + max_age: 300 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod new file mode 100644 index 00000000..011b079b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -0,0 +1,124 @@ +module github.com/carterperez-dev/monitor-the-situation/backend + +go 1.25.0 + +require ( + github.com/PuerkitoBio/goquery v1.12.0 + github.com/cenkalti/backoff/v4 v4.3.0 + github.com/coder/websocket v1.8.14 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-playground/validator/v10 v10.23.0 + github.com/go-redis/redis_rate/v10 v10.0.1 + github.com/google/cel-go v0.28.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/jmoiron/sqlx v1.4.0 + github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.1.2 + github.com/lestrrat-go/jwx/v3 v3.0.12 + github.com/lib/pq v1.12.3 + github.com/redis/go-redis/v9 v9.7.3 + github.com/shopspring/decimal v1.4.0 + github.com/sony/gobreaker/v2 v2.4.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 + go.opentelemetry.io/otel v1.41.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 + go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.41.0 + golang.org/x/crypto v0.49.0 + golang.org/x/sync v0.20.0 + golang.org/x/time v0.14.0 + google.golang.org/grpc v1.68.1 +) + +require ( + cel.dev/expr v0.25.1 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/valyala/fastjson v1.6.4 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum new file mode 100644 index 00000000..a9850706 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -0,0 +1,356 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b h1:JlltDRgni6FuoFwluvoZCrE6cmpojccO4WsqeYlFJLE= +github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b/go.mod h1:msW2QeN9IsnRyvuK8OBAzBwn6DHwXpiAiqBk8dbLfrU= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= +github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= +github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824 h1:MbMqwlWoESqhGm4Sslfdyeq7Ww8R9ppeKS5DcO3xDI0= +github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2 h1:38zSYUaJJkzreBjLz7tx4AUTVjnFI7EQBnlRoWt4QFA= +github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= +github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129 h1:RBgb9aPUbZ9nu66ecQNIBNsA7j3mB5h8PNDIfhPjaJg= +gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go new file mode 100644 index 00000000..2bc5bf94 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go @@ -0,0 +1,208 @@ +// AngelaMos | 2026 +// handler.go + +package admin + +import ( + "context" + "database/sql" + "net/http" + "runtime" + + "github.com/go-chi/chi/v5" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type AuthService interface { + InvalidateAllSessions(ctx context.Context) error +} + +type Handler struct { + dbStats func() sql.DBStats + redisStats func() *redis.PoolStats + redisPing func(ctx context.Context) error + dbPing func(ctx context.Context) error + authSvc AuthService +} + +type HandlerConfig struct { + DBStats func() sql.DBStats + RedisStats func() *redis.PoolStats + RedisPing func(ctx context.Context) error + DBPing func(ctx context.Context) error + AuthSvc AuthService +} + +func NewHandler(cfg HandlerConfig) *Handler { + return &Handler{ + dbStats: cfg.DBStats, + redisStats: cfg.RedisStats, + redisPing: cfg.RedisPing, + dbPing: cfg.DBPing, + authSvc: cfg.AuthSvc, + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/stats", h.GetSystemStats) + r.Get("/stats/db", h.GetDatabaseStats) + r.Get("/stats/redis", h.GetRedisStats) + r.Get("/stats/runtime", h.GetRuntimeStats) + }) +} + +func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbHealthy := true + if h.dbPing != nil { + if err := h.dbPing(ctx); err != nil { + dbHealthy = false + } + } + + redisHealthy := true + if h.redisPing != nil { + if err := h.redisPing(ctx); err != nil { + redisHealthy = false + } + } + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := SystemStatsResponse{ + Database: DatabaseStatus{ + Healthy: dbHealthy, + Stats: h.getDBStats(), + }, + Redis: RedisStatus{ + Healthy: redisHealthy, + Stats: h.getRedisStats(), + }, + Runtime: RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + }, + } + + core.OK(w, response) +} + +func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getDBStats()) +} + +func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getRedisStats()) +} + +func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + } + + core.OK(w, response) +} + +func (h *Handler) getDBStats() *DBPoolStats { + if h.dbStats == nil { + return nil + } + + stats := h.dbStats() + return &DBPoolStats{ + MaxOpenConnections: stats.MaxOpenConnections, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration.String(), + MaxIdleClosed: stats.MaxIdleClosed, + MaxIdleTimeClosed: stats.MaxIdleTimeClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +func (h *Handler) getRedisStats() *RedisPoolStats { + if h.redisStats == nil { + return nil + } + + stats := h.redisStats() + return &RedisPoolStats{ + Hits: stats.Hits, + Misses: stats.Misses, + Timeouts: stats.Timeouts, + TotalConns: stats.TotalConns, + IdleConns: stats.IdleConns, + StaleConns: stats.StaleConns, + } +} + +type SystemStatsResponse struct { + Database DatabaseStatus `json:"database"` + Redis RedisStatus `json:"redis"` + Runtime RuntimeStats `json:"runtime"` +} + +type DatabaseStatus struct { + Healthy bool `json:"healthy"` + Stats *DBPoolStats `json:"stats,omitempty"` +} + +type RedisStatus struct { + Healthy bool `json:"healthy"` + Stats *RedisPoolStats `json:"stats,omitempty"` +} + +type DBPoolStats struct { + MaxOpenConnections int `json:"max_open_connections"` + OpenConnections int `json:"open_connections"` + InUse int `json:"in_use"` + Idle int `json:"idle"` + WaitCount int64 `json:"wait_count"` + WaitDuration string `json:"wait_duration"` + MaxIdleClosed int64 `json:"max_idle_closed"` + MaxIdleTimeClosed int64 `json:"max_idle_time_closed"` + MaxLifetimeClosed int64 `json:"max_lifetime_closed"` +} + +type RedisPoolStats struct { + Hits uint32 `json:"hits"` + Misses uint32 `json:"misses"` + Timeouts uint32 `json:"timeouts"` + TotalConns uint32 `json:"total_conns"` + IdleConns uint32 `json:"idle_conns"` + StaleConns uint32 `json:"stale_conns"` +} + +type RuntimeStats struct { + GoVersion string `json:"go_version"` + NumGoroutine int `json:"num_goroutine"` + NumCPU int `json:"num_cpu"` + MemAlloc uint64 `json:"mem_alloc_bytes"` + MemSys uint64 `json:"mem_sys_bytes"` + NumGC uint32 `json:"num_gc"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/dispatcher.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/dispatcher.go new file mode 100644 index 00000000..8903bcb5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/dispatcher.go @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// dispatcher.go + +package alerts + +import ( + "context" + "log/slog" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +// Dispatcher reads from a bus subscriber channel and pushes each event +// into the engine. Separate from the engine itself so the bus->engine +// wiring is testable in isolation. +type Dispatcher struct { + in <-chan events.Event + engine *Engine + logger *slog.Logger +} + +func NewDispatcher( + in <-chan events.Event, + engine *Engine, + logger *slog.Logger, +) *Dispatcher { + if logger == nil { + logger = slog.Default() + } + return &Dispatcher{in: in, engine: engine, logger: logger} +} + +func (d *Dispatcher) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case ev, ok := <-d.in: + if !ok { + return nil + } + d.engine.Evaluate(ctx, ev) + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/engine.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/engine.go new file mode 100644 index 00000000..3b0c84bb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/engine.go @@ -0,0 +1,377 @@ +// ©AngelaMos | 2026 +// engine.go + +package alerts + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +// Channel is the abstract destination an alert is sent to. Slack/Discord +// webhooks and Telegram bots all reduce to "send this text via this +// transport". The engine doesn't care about the specific kind — that's +// the Notifier's job. +type Channel struct { + ID string + Type string + Label string + // Decoded config — for slack/discord this is the webhook URL, + // for telegram it's a (bot_token, chat_id) pair. The Notifier + // implementation handles the type-specific details. + WebhookURL string + BotToken string + ChatID int64 +} + +// Notifier is the transport for sending an alert. The notifications +// package implements one — this seam is here so tests can mock it. +type Notifier interface { + SendAlert(ctx context.Context, ch Channel, message string) error +} + +// ChannelLoader returns the configured destinations for a user. The +// notifications package decrypts channel configs on demand and produces +// these. Engine invokes it once per matching rule. +type ChannelLoader interface { + LoadChannels(ctx context.Context, userID string) ([]Channel, error) +} + +type CooldownStore interface { + TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error) +} + +type EngineConfig struct { + Repo Repository + Notifier Notifier + Loader ChannelLoader + Cooldowns CooldownStore + Logger *slog.Logger + ReloadTick time.Duration +} + +type Engine struct { + repo Repository + notifier Notifier + loader ChannelLoader + cooldowns CooldownStore + logger *slog.Logger + reloadTick time.Duration + celEnv *cel.Env + rulesByTopic atomic.Pointer[map[string][]compiledRule] +} + +type compiledRule struct { + rule Rule + program cel.Program +} + +func NewEngine(cfg EngineConfig) (*Engine, error) { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + if cfg.ReloadTick <= 0 { + cfg.ReloadTick = 30 * time.Second + } + env, err := cel.NewEnv( + cel.Variable("event", cel.DynType), + ) + if err != nil { + return nil, fmt.Errorf("cel env: %w", err) + } + e := &Engine{ + repo: cfg.Repo, + notifier: cfg.Notifier, + loader: cfg.Loader, + cooldowns: cfg.Cooldowns, + logger: logger, + reloadTick: cfg.ReloadTick, + celEnv: env, + } + empty := map[string][]compiledRule{} + e.rulesByTopic.Store(&empty) + return e, nil +} + +// RefreshLoop reloads rules from the database periodically. Cheap — the +// table is small (one user × ~6 default rules). Hot path stays in-memory. +func (e *Engine) RefreshLoop(ctx context.Context) error { + if err := e.reload(ctx); err != nil { + e.logger.Warn("alerts: initial reload failed", "err", err) + } + t := time.NewTicker(e.reloadTick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + if err := e.reload(ctx); err != nil { + e.logger.Warn("alerts: reload failed", "err", err) + } + } + } +} + +func (e *Engine) reload(ctx context.Context) error { + all, err := e.repo.ListAll(ctx) + if err != nil { + return err + } + indexed := make(map[string][]compiledRule, len(all)) + for _, r := range all { + prog, err := e.compile(r.Predicate) + if err != nil { + e.logger.Warn("alerts: skipping rule with bad predicate", + "rule_id", r.ID, "topic", r.Topic, "err", err) + continue + } + indexed[r.Topic] = append( + indexed[r.Topic], + compiledRule{rule: r, program: prog}, + ) + } + e.rulesByTopic.Store(&indexed) + return nil +} + +func (e *Engine) compile(predicate string) (cel.Program, error) { + if predicate == "" { + // Empty predicate = always fire. Use a constant-true program so + // the eval path is uniform. + ast, iss := e.celEnv.Compile("true") + if iss != nil && iss.Err() != nil { + return nil, iss.Err() + } + return e.celEnv.Program(ast) + } + ast, iss := e.celEnv.Compile(predicate) + if iss != nil && iss.Err() != nil { + return nil, iss.Err() + } + return e.celEnv.Program(ast) +} + +// Evaluate is the per-event hot path. Looks up rules indexed by topic, +// evaluates each predicate against the event payload, then for matches +// loads the user's channels and dispatches via the notifier. Cooldowns +// are per (rule, channel) so a critical Telegram alert and a less- +// critical Discord alert for the same rule have independent windows. +func (e *Engine) Evaluate(ctx context.Context, ev events.Event) { + idx := e.rulesByTopic.Load() + if idx == nil { + return + } + rules, ok := (*idx)[string(ev.Topic)] + if !ok || len(rules) == 0 { + return + } + + payload, err := normalizePayload(ev.Payload) + if err != nil { + e.logger.Warn( + "alerts: payload normalize failed", + "topic", + ev.Topic, + "err", + err, + ) + return + } + + for _, cr := range rules { + if !cr.rule.Enabled { + continue + } + match, err := evalPredicate(cr.program, payload) + if err != nil { + e.logger.Warn("alerts: predicate eval failed", + "rule_id", cr.rule.ID, "topic", ev.Topic, "err", err) + continue + } + if !match { + continue + } + e.fire(ctx, cr.rule, ev, payload) + } +} + +func (e *Engine) fire( + ctx context.Context, + rule Rule, + ev events.Event, + payload map[string]any, +) { + channels, err := e.loader.LoadChannels(ctx, rule.UserID) + if err != nil { + e.logger.Warn("alerts: load channels failed", + "rule_id", rule.ID, "user_id", rule.UserID, "err", err) + return + } + if len(channels) == 0 { + return + } + cooldown := time.Duration(rule.CooldownSec) * time.Second + message := formatMessage(rule, ev, payload) + + delivered := []string{} + deliveryErrs := map[string]string{} + + var wg sync.WaitGroup + var mu sync.Mutex + + for _, ch := range channels { + ch := ch + key := fmt.Sprintf("alert_cooldown:%s:%s:%s", rule.ID, ch.Type, ch.ID) + ok, err := e.cooldowns.TryAcquire(ctx, key, cooldown) + if err != nil { + e.logger.Warn("alerts: cooldown lookup failed", + "rule_id", rule.ID, "channel", ch.ID, "err", err) + continue + } + if !ok { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + err := e.notifier.SendAlert(ctx, ch, message) + mu.Lock() + defer mu.Unlock() + if err != nil { + deliveryErrs[ch.ID] = err.Error() + e.logger.Warn("alerts: deliver failed", + "rule_id", rule.ID, "channel_type", ch.Type, "err", err) + return + } + delivered = append(delivered, ch.ID) + }() + } + wg.Wait() + + body, _ := json.Marshal(payload) + errBody, _ := json.Marshal(deliveryErrs) + if err := e.repo.RecordFire(ctx, HistoryRow{ + RuleID: rule.ID, + UserID: rule.UserID, + FiredAt: time.Now().UTC(), + Payload: body, + DeliveredTo: delivered, + DeliveryErrors: errBody, + }); err != nil { + e.logger.Warn( + "alerts: record fire failed", + "rule_id", + rule.ID, + "err", + err, + ) + } +} + +func evalPredicate(program cel.Program, payload map[string]any) (bool, error) { + out, _, err := program.Eval(map[string]any{"event": payload}) + if err != nil { + return false, err + } + v, err := celBool(out) + if err != nil { + return false, err + } + return v, nil +} + +func celBool(v ref.Val) (bool, error) { + if v == nil { + return false, nil + } + switch v.Type() { + case types.BoolType: + b, ok := v.Value().(bool) + if !ok { + return false, fmt.Errorf( + "cel bool: unexpected concrete type %T", + v.Value(), + ) + } + return b, nil + default: + return false, fmt.Errorf("cel result is not bool: %v", v.Type()) + } +} + +// normalizePayload turns whatever the bus carries (map[string]any, +// json.RawMessage, struct, ...) into a map suitable for CEL. CEL needs +// a map for field access syntax like `event.kp` to work. +func normalizePayload(p any) (map[string]any, error) { + if p == nil { + return map[string]any{}, nil + } + if m, ok := p.(map[string]any); ok { + return m, nil + } + if raw, ok := p.(json.RawMessage); ok { + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil + } + body, err := json.Marshal(p) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +func formatMessage(rule Rule, ev events.Event, payload map[string]any) string { + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + body = []byte(fmt.Sprintf("%v", payload)) + } + return fmt.Sprintf("[%s] %s\n\nTopic: %s\nFired: %s\n\n%s", + rule.Name, ev.Source, ev.Topic, ev.Timestamp.UTC().Format(time.RFC3339), + body, + ) +} + +// RedisCooldown is a default CooldownStore using SET NX EX. +type RedisCooldown struct { + rdb *redis.Client +} + +func NewRedisCooldown(rdb *redis.Client) *RedisCooldown { + return &RedisCooldown{rdb: rdb} +} + +func (c *RedisCooldown) TryAcquire( + ctx context.Context, + key string, + ttl time.Duration, +) (bool, error) { + if ttl <= 0 { + return true, nil + } + ok, err := c.rdb.SetNX(ctx, key, "1", ttl).Result() + if err != nil { + return false, err + } + return ok, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/entity.go new file mode 100644 index 00000000..bcf0aa71 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/entity.go @@ -0,0 +1,107 @@ +// ©AngelaMos | 2026 +// entity.go + +package alerts + +import ( + "encoding/json" + "time" +) + +// Rule mirrors the alert_rules table. Predicate is a CEL expression +// evaluated against {event: }. Empty predicate means +// "always fire" — used for default rules like "every KEV add". +type Rule struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Name string `db:"name"` + Topic string `db:"topic"` + Predicate string `db:"predicate"` + CooldownSec int `db:"cooldown_sec"` + Enabled bool `db:"enabled"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +// HistoryRow mirrors alert_history. Used both for delivery audit and so +// the UI can show "last fired". +type HistoryRow struct { + ID int64 `db:"id"` + RuleID string `db:"rule_id"` + UserID string `db:"user_id"` + FiredAt time.Time `db:"fired_at"` + Payload json.RawMessage `db:"payload"` + DeliveredTo []string `db:"delivered_to"` + DeliveryErrors json.RawMessage `db:"delivery_errors"` +} + +// DefaultRuleSeed is what registers as the bootstrap rule set for a new +// user. The predicates come straight from +// docs/specs/2026-05-01-monitor-the-situation-design.md. +type DefaultRuleSeed struct { + Name string + Topic string + Predicate string + CooldownSec int +} + +// Defaults returns the seed rules. KEV is the highest-priority alarm +// (per spec); CVE filters to severe items only; earthquake / space +// weather use the spec thresholds; ransomware fires on every entry. +// BTC / ETH ride on the `change_pct_1h` field the coinbase collector +// computes from a 1-hour sliding window — `has(...)` guards against +// firing during the warmup period when the window isn't full yet. +// Cooldowns are sized so a sustained crash doesn't spam — 30 min for +// the price drops; you can shorten in the settings UI per-rule. +func Defaults() []DefaultRuleSeed { + return []DefaultRuleSeed{ + { + Name: "KEV: every newly-added entry", + Topic: "kev_added", + Predicate: "", + CooldownSec: 0, + }, + { + Name: "CVE: critical (CVSS≥9 or EPSS≥0.95)", + Topic: "cve_new", + Predicate: "event.CVSS >= 9.0 || (has(event.EPSSPercentile) && event.EPSSPercentile != null && event.EPSSPercentile >= 0.95)", + CooldownSec: 300, + }, + { + Name: "Earthquake: M6.5+", + Topic: "earthquake", + Predicate: "event.properties.mag >= 6.5", + CooldownSec: 60, + }, + { + Name: "Space weather: Kp≥7 or X-class flare", + Topic: "space_weather", + Predicate: "(has(event.kp) && event.kp >= 7.0) || (has(event.xray_class) && event.xray_class.startsWith('X'))", + CooldownSec: 3600, + }, + { + Name: "Ransomware: every new victim", + Topic: "ransomware_victim", + Predicate: "", + CooldownSec: 60, + }, + { + Name: "BGP hijack: confidence≥9", + Topic: "bgp_hijack", + Predicate: "event.confidenceScore >= 9", + CooldownSec: 300, + }, + { + Name: "BTC: 1h drop ≥5%", + Topic: "coinbase_price", + Predicate: "event.symbol == 'BTC-USD' && has(event.change_pct_1h) && event.change_pct_1h <= -5.0", + CooldownSec: 1800, + }, + { + Name: "ETH: 1h drop ≥5%", + Topic: "coinbase_price", + Predicate: "event.symbol == 'ETH-USD' && has(event.change_pct_1h) && event.change_pct_1h <= -5.0", + CooldownSec: 1800, + }, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/handler.go new file mode 100644 index 00000000..325f0c9a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/handler.go @@ -0,0 +1,196 @@ +// ©AngelaMos | 2026 +// handler.go + +package alerts + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +type Handler struct { + repo Repository + validator *validator.Validate +} + +func NewHandler(repo Repository) *Handler { + return &Handler{ + repo: repo, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/me/alerts", func(r chi.Router) { + r.Use(authenticator) + + r.Get("/", h.list) + r.Post("/", h.create) + r.Patch("/{id}", h.update) + r.Delete("/{id}", h.delete) + + r.Get("/history", h.history) + }) +} + +type ruleDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` + Predicate string `json:"predicate"` + CooldownSec int `json:"cooldown_sec"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRuleDTO(r Rule) ruleDTO { + return ruleDTO{ + ID: r.ID, + Name: r.Name, + Topic: r.Topic, + Predicate: r.Predicate, + CooldownSec: r.CooldownSec, + Enabled: r.Enabled, + CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + rules, err := h.repo.ListByUser(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]ruleDTO, 0, len(rules)) + for _, ru := range rules { + out = append(out, toRuleDTO(ru)) + } + core.OK(w, out) +} + +type createRuleRequest struct { + Name string `json:"name" validate:"required,min=1,max=200"` + Topic string `json:"topic" validate:"required,min=1,max=64"` + Predicate string `json:"predicate" validate:"omitempty,max=2048"` + CooldownSec int `json:"cooldown_sec" validate:"gte=0,lte=86400"` + Enabled bool `json:"enabled"` +} + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + var req createRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + rule := &Rule{ + UserID: userID, + Name: req.Name, + Topic: req.Topic, + Predicate: req.Predicate, + CooldownSec: req.CooldownSec, + Enabled: req.Enabled, + } + if err := h.repo.Create(r.Context(), rule); err != nil { + core.InternalServerError(w, err) + return + } + core.Created(w, toRuleDTO(*rule)) +} + +type updateRuleRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=200"` + Predicate *string `json:"predicate,omitempty" validate:"omitempty,max=2048"` + CooldownSec *int `json:"cooldown_sec,omitempty" validate:"omitempty,gte=0,lte=86400"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (h *Handler) update(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + + var req updateRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + rule, err := h.repo.Get(r.Context(), id, userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "rule") + return + } + core.InternalServerError(w, err) + return + } + if req.Name != nil { + rule.Name = *req.Name + } + if req.Predicate != nil { + rule.Predicate = *req.Predicate + } + if req.CooldownSec != nil { + rule.CooldownSec = *req.CooldownSec + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if err := h.repo.Update(r.Context(), rule); err != nil { + core.InternalServerError(w, err) + return + } + core.OK(w, toRuleDTO(*rule)) +} + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.repo.Delete(r.Context(), id, userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "rule") + return + } + core.InternalServerError(w, err) + return + } + core.NoContent(w) +} + +func (h *Handler) history(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + limit := 50 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + limit = n + } + } + rows, err := h.repo.RecentHistory(r.Context(), userID, limit) + if err != nil { + core.InternalServerError(w, err) + return + } + core.OK(w, rows) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/repository.go new file mode 100644 index 00000000..9c54569a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/repository.go @@ -0,0 +1,239 @@ +// ©AngelaMos | 2026 +// repository.go + +package alerts + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Repository interface { + ListAll(ctx context.Context) ([]Rule, error) + ListByUser(ctx context.Context, userID string) ([]Rule, error) + Get(ctx context.Context, id, userID string) (*Rule, error) + Create(ctx context.Context, r *Rule) error + Update(ctx context.Context, r *Rule) error + Delete(ctx context.Context, id, userID string) error + SeedDefaults(ctx context.Context, userID string) error + RecordFire(ctx context.Context, h HistoryRow) error + RecentHistory( + ctx context.Context, + userID string, + limit int, + ) ([]HistoryRow, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) ListAll(ctx context.Context) ([]Rule, error) { + var rules []Rule + err := r.db.SelectContext(ctx, &rules, ` + SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at + FROM alert_rules + WHERE enabled = true`) + if err != nil { + return nil, fmt.Errorf("alerts list all: %w", err) + } + return rules, nil +} + +func (r *repository) ListByUser( + ctx context.Context, + userID string, +) ([]Rule, error) { + var rules []Rule + err := r.db.SelectContext(ctx, &rules, ` + SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at + FROM alert_rules + WHERE user_id = $1 + ORDER BY topic, name`, userID) + if err != nil { + return nil, fmt.Errorf("alerts list user: %w", err) + } + return rules, nil +} + +func (r *repository) Get( + ctx context.Context, + id, userID string, +) (*Rule, error) { + var rule Rule + err := r.db.GetContext(ctx, &rule, ` + SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at + FROM alert_rules + WHERE id = $1 AND user_id = $2`, id, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("alerts get: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("alerts get: %w", err) + } + return &rule, nil +} + +func (r *repository) Create(ctx context.Context, rule *Rule) error { + if rule.ID == "" { + rule.ID = uuid.New().String() + } + err := r.db.GetContext( + ctx, + &rule.CreatedAt, + ` + INSERT INTO alert_rules (id, user_id, name, topic, predicate, cooldown_sec, enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING created_at`, + rule.ID, + rule.UserID, + rule.Name, + rule.Topic, + rule.Predicate, + rule.CooldownSec, + rule.Enabled, + ) + if err != nil { + return fmt.Errorf("alerts create: %w", err) + } + rule.UpdatedAt = rule.CreatedAt + return nil +} + +func (r *repository) Update(ctx context.Context, rule *Rule) error { + res, err := r.db.ExecContext( + ctx, + ` + UPDATE alert_rules + SET name = $3, + predicate = $4, + cooldown_sec = $5, + enabled = $6, + updated_at = NOW() + WHERE id = $1 AND user_id = $2`, + rule.ID, + rule.UserID, + rule.Name, + rule.Predicate, + rule.CooldownSec, + rule.Enabled, + ) + if err != nil { + return fmt.Errorf("alerts update: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("alerts update: %w", err) + } + if rows == 0 { + return fmt.Errorf("alerts update: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) Delete(ctx context.Context, id, userID string) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM alert_rules WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return fmt.Errorf("alerts delete: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("alerts delete: %w", err) + } + if rows == 0 { + return fmt.Errorf("alerts delete: %w", core.ErrNotFound) + } + return nil +} + +// SeedDefaults inserts the default rule set for a user, skipping rules +// they already have. Dedup is by (user_id, name) — not (user_id, topic) — +// so multiple defaults can target the same topic. BTC and ETH both fire +// on `coinbase_price`, and the original (user_id, topic) check would +// have inserted only the first one. Idempotent: safe to call on every +// login, which is how existing accounts pick up newly-added defaults. +func (r *repository) SeedDefaults(ctx context.Context, userID string) error { + for _, def := range Defaults() { + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO alert_rules (id, user_id, name, topic, predicate, cooldown_sec, enabled) + SELECT $1, $2, $3, $4, $5, $6, true + WHERE NOT EXISTS ( + SELECT 1 FROM alert_rules WHERE user_id = $2 AND name = $3 + )`, + uuid.New(). + String(), + userID, + def.Name, + def.Topic, + def.Predicate, + def.CooldownSec, + ) + if err != nil { + return fmt.Errorf("seed default %s: %w", def.Name, err) + } + } + return nil +} + +func (r *repository) RecordFire(ctx context.Context, h HistoryRow) error { + if h.FiredAt.IsZero() { + h.FiredAt = time.Now().UTC() + } + if len(h.Payload) == 0 { + h.Payload = json.RawMessage("{}") + } + delivered := pq.StringArray(h.DeliveredTo) + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO alert_history (rule_id, user_id, fired_at, payload, delivered_to, delivery_errors) + VALUES ($1, $2, $3, $4, $5, $6)`, + h.RuleID, + h.UserID, + h.FiredAt, + []byte(h.Payload), + delivered, + []byte(h.DeliveryErrors), + ) + if err != nil { + return fmt.Errorf("alerts record fire: %w", err) + } + return nil +} + +func (r *repository) RecentHistory( + ctx context.Context, + userID string, + limit int, +) ([]HistoryRow, error) { + if limit <= 0 { + limit = 50 + } + var rows []HistoryRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, rule_id, user_id, fired_at, payload, delivered_to, delivery_errors + FROM alert_history + WHERE user_id = $1 + ORDER BY fired_at DESC + LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("alerts recent history: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go new file mode 100644 index 00000000..4d3c36b2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go @@ -0,0 +1,62 @@ +// AngelaMos | 2026 +// dto.go + +package auth + +import ( + "time" +) + +type LoginRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` +} + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token,omitempty"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Tier string `json:"tier"` + CreatedAt time.Time `json:"created_at"` +} + +type AuthResponse struct { + User UserResponse `json:"user"` + Tokens TokenResponse `json:"tokens"` +} + +type SessionInfo struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + IPAddress string `json:"ip_address"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type SessionsResponse struct { + Sessions []SessionInfo `json:"sessions"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go new file mode 100644 index 00000000..8ce8a4e5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go @@ -0,0 +1,47 @@ +// AngelaMos | 2026 +// entity.go + +package auth + +import ( + "time" +) + +type RefreshToken struct { + ID string `db:"id"` + UserID string `db:"user_id"` + TokenHash string `db:"token_hash"` + FamilyID string `db:"family_id"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + IsUsed bool `db:"is_used"` + UsedAt *time.Time `db:"used_at"` + RevokedAt *time.Time `db:"revoked_at"` + ReplacedByID *string `db:"replaced_by_id"` + UserAgent string `db:"user_agent"` + IPAddress string `db:"ip_address"` +} + +func (t *RefreshToken) IsExpired() bool { + return time.Now().After(t.ExpiresAt) +} + +func (t *RefreshToken) IsRevoked() bool { + return t.RevokedAt != nil +} + +func (t *RefreshToken) IsValid() bool { + return !t.IsExpired() && !t.IsRevoked() && !t.IsUsed +} + +func (t *RefreshToken) MarkAsUsed(replacedByID string) { + now := time.Now() + t.IsUsed = true + t.UsedAt = &now + t.ReplacedByID = &replacedByID +} + +func (t *RefreshToken) Revoke() { + now := time.Now() + t.RevokedAt = &now +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go new file mode 100644 index 00000000..e5c39bec --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go @@ -0,0 +1,401 @@ +// AngelaMos | 2026 +// handler.go + +package auth + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +const ( + refreshCookieName = "mts_rt" + refreshCookiePath = "/api/v1/auth" + refreshCookieMaxAge = 7 * 24 * time.Hour +) + +type Handler struct { + service *Service + validator *validator.Validate + trustedProxyHops int + secureCookies bool +} + +type HandlerConfig struct { + Service *Service + TrustedProxyHops int + SecureCookies bool +} + +func NewHandler(service *Service, trustedProxyHops int) *Handler { + return NewHandlerWithConfig(HandlerConfig{ + Service: service, + TrustedProxyHops: trustedProxyHops, + }) +} + +func NewHandlerWithConfig(cfg HandlerConfig) *Handler { + return &Handler{ + service: cfg.Service, + validator: validator.New(validator.WithRequiredStructEnabled()), + trustedProxyHops: cfg.TrustedProxyHops, + secureCookies: cfg.SecureCookies, + } +} + +func (h *Handler) setRefreshCookie( + w http.ResponseWriter, + token string, + expiresAt time.Time, +) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, + Value: token, + Path: refreshCookiePath, + HttpOnly: true, + Secure: h.secureCookies, + SameSite: http.SameSiteStrictMode, + Expires: expiresAt, + }) +} + +func (h *Handler) clearRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, + Value: "", + Path: refreshCookiePath, + HttpOnly: true, + Secure: h.secureCookies, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +func (h *Handler) refreshTokenFromRequest(r *http.Request, body string) string { + if body != "" { + return body + } + if c, err := r.Cookie(refreshCookieName); err == nil { + return c.Value + } + return "" +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/auth", func(r chi.Router) { + r.Post("/login", h.Login) + r.Post("/register", h.Register) + r.Post("/refresh", h.Refresh) + + r.Group(func(r chi.Router) { + r.Use(authenticator) + r.Get("/me", h.GetMe) + r.Post("/logout", h.Logout) + r.Post("/logout-all", h.LogoutAll) + r.Get("/sessions", h.GetSessions) + r.Delete("/sessions/{sessionID}", h.RevokeSession) + r.Post("/change-password", h.ChangePassword) + }) + }) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := h.extractIPAddress(r) + + resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress) + if err != nil { + if errors.Is(err, ErrInvalidCredentials) { + core.JSONError( + w, + core.UnauthorizedError("invalid email or password"), + ) + return + } + core.InternalServerError(w, err) + return + } + + h.setRefreshCookie( + w, + resp.Tokens.RefreshToken, + time.Now().Add(refreshCookieMaxAge), + ) + core.OK(w, resp) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := h.extractIPAddress(r) + + resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress) + if err != nil { + if errors.Is(err, ErrEmailExists) { + core.JSONError(w, core.DuplicateError("email")) + return + } + core.InternalServerError(w, err) + return + } + + h.setRefreshCookie( + w, + resp.Tokens.RefreshToken, + time.Now().Add(refreshCookieMaxAge), + ) + core.Created(w, resp) +} + +func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Body may be empty when frontend uses cookie-only flow. + req = RefreshRequest{} + } + + refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken) + if refreshToken == "" { + core.BadRequest(w, "refresh_token required (cookie or body)") + return + } + + userAgent := r.UserAgent() + ipAddress := h.extractIPAddress(r) + + resp, err := h.service.Refresh( + r.Context(), + refreshToken, + userAgent, + ipAddress, + ) + if err != nil { + if errors.Is(err, ErrTokenReuse) { + core.JSONError(w, core.NewAppError( + core.ErrTokenRevoked, + "security alert: token reuse detected, all sessions revoked", + http.StatusUnauthorized, + "TOKEN_REUSE_DETECTED", + )) + return + } + if errors.Is(err, core.ErrTokenExpired) { + core.JSONError(w, core.TokenExpiredError()) + return + } + if errors.Is(err, core.ErrTokenRevoked) { + core.JSONError(w, core.TokenRevokedError()) + return + } + if errors.Is(err, core.ErrTokenInvalid) { + core.JSONError(w, core.TokenInvalidError()) + return + } + core.InternalServerError(w, err) + return + } + + h.setRefreshCookie( + w, + resp.Tokens.RefreshToken, + time.Now().Add(refreshCookieMaxAge), + ) + core.OK(w, resp) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + req = RefreshRequest{} + } + refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken) + + var jti string + var expiresAt time.Time + if claims := middleware.GetClaims(r.Context()); claims != nil { + jti = claims.JTI + expiresAt = claims.ExpiresAt + } + + if err := h.service.Logout( + r.Context(), + refreshToken, + userID, + jti, + expiresAt, + ); err != nil { + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "cannot revoke another user's token") + return + } + core.InternalServerError(w, err) + return + } + + h.clearRefreshCookie(w) + core.NoContent(w) +} + +func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + if err := h.service.LogoutAll(r.Context(), userID); err != nil { + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetSessions(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + sessions, err := h.service.GetActiveSessions(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, SessionsResponse{Sessions: sessions}) +} + +func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + sessionID := chi.URLParam(r, "sessionID") + if sessionID == "" { + core.BadRequest(w, "session ID required") + return + } + + if err := h.service.RevokeSession( + r.Context(), + userID, + sessionID, + ); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "session") + return + } + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "cannot revoke another user's session") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + if err := h.service.ChangePassword( + r.Context(), + userID, + req.CurrentPassword, + req.NewPassword, + ); err != nil { + if errors.Is(err, ErrInvalidCredentials) { + core.JSONError( + w, + core.UnauthorizedError("current password is incorrect"), + ) + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + user, err := h.service.GetCurrentUser(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, user) +} + +func (h *Handler) extractIPAddress(r *http.Request) string { + return middleware.ClientIP(r, h.trustedProxyHops) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go new file mode 100644 index 00000000..9ab73cc7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go @@ -0,0 +1,327 @@ +// AngelaMos | 2026 +// jwt.go + +package auth + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + _ "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +const kidLength = 8 + +type JWTManager struct { + privateKey jwk.Key + publicKey jwk.Key + publicJWKS jwk.Set + config config.JWTConfig +} + +func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) { + privateKeyPEM, err := os.ReadFile(cfg.PrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("read private key: %w", err) + } + + privateKey, err := jwk.ParseKey(privateKeyPEM, jwk.WithPEM(true)) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + if setErr := privateKey.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { + return nil, fmt.Errorf("set algorithm: %w", setErr) + } + + publicKey, err := privateKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("derive public key: %w", err) + } + + keyID, err := deriveKeyID(publicKey) + if err != nil { + return nil, fmt.Errorf("derive key id: %w", err) + } + if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil { + return nil, fmt.Errorf("set key id: %w", setErr) + } + if setErr := publicKey.Set(jwk.KeyIDKey, keyID); setErr != nil { + return nil, fmt.Errorf("set public key id: %w", setErr) + } + + if setErr := publicKey.Set(jwk.KeyUsageKey, "sig"); setErr != nil { + return nil, fmt.Errorf("set key usage: %w", setErr) + } + + publicJWKS := jwk.NewSet() + if addErr := publicJWKS.AddKey(publicKey); addErr != nil { + return nil, fmt.Errorf("add key to set: %w", addErr) + } + + return &JWTManager{ + privateKey: privateKey, + publicKey: publicKey, + publicJWKS: publicJWKS, + config: cfg, + }, nil +} + +func GenerateKeyPair(privateKeyPath, publicKeyPath string) error { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + jwkPrivate, err := jwk.Import(privateKey) + if err != nil { + return fmt.Errorf("import private key: %w", err) + } + + if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { + return fmt.Errorf("set algorithm: %w", setErr) + } + + privatePEM, err := jwk.Pem(jwkPrivate) + if err != nil { + return fmt.Errorf("encode private key: %w", err) + } + + if writeErr := os.WriteFile( + privateKeyPath, + privatePEM, + 0o600, + ); writeErr != nil { + return fmt.Errorf("write private key: %w", writeErr) + } + + jwkPublic, err := jwkPrivate.PublicKey() + if err != nil { + return fmt.Errorf("derive public key: %w", err) + } + + publicPEM, err := jwk.Pem(jwkPublic) + if err != nil { + return fmt.Errorf("encode public key: %w", err) + } + + //nolint:gosec // G306: public key is intentionally world-readable + if writeErr := os.WriteFile( + publicKeyPath, + publicPEM, + 0o644, + ); writeErr != nil { + return fmt.Errorf("write public key: %w", writeErr) + } + + return nil +} + +type AccessTokenClaims struct { + UserID string `json:"sub"` + Role string `json:"role"` + Tier string `json:"tier"` + TokenVersion int `json:"token_version"` +} + +func (m *JWTManager) CreateAccessToken( + claims AccessTokenClaims, +) (string, error) { + now := time.Now() + + token, err := jwt.NewBuilder(). + JwtID(uuid.New().String()). + Issuer(m.config.Issuer). + Audience([]string{m.config.Audience}). + Subject(claims.UserID). + IssuedAt(now). + Expiration(now.Add(m.config.AccessTokenExpire)). + NotBefore(now). + Claim("role", claims.Role). + Claim("tier", claims.Tier). + Claim("token_version", claims.TokenVersion). + Claim("type", "access"). + Build() + if err != nil { + return "", fmt.Errorf("build token: %w", err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), m.privateKey)) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + + return string(signed), nil +} + +func (m *JWTManager) VerifyAccessToken( + ctx context.Context, + tokenString string, +) (*middleware.AccessTokenClaims, error) { + token, err := jwt.Parse( + []byte(tokenString), + jwt.WithKey(jwa.ES256(), m.publicKey), + jwt.WithValidate(true), + jwt.WithIssuer(m.config.Issuer), + jwt.WithAudience(m.config.Audience), + ) + if err != nil { + if isTokenExpiredError(err) { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenExpired) + } + return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid) + } + + var tokenType string + if err := token.Get("type", &tokenType); err != nil || + tokenType != "access" { + return nil, fmt.Errorf( + "verify token: invalid token type: %w", + core.ErrTokenInvalid, + ) + } + + subject, ok := token.Subject() + if !ok || subject == "" { + return nil, fmt.Errorf( + "verify token: missing subject: %w", + core.ErrTokenInvalid, + ) + } + + var roleStr string + if err := token.Get("role", &roleStr); err != nil { + return nil, fmt.Errorf( + "verify token: missing role claim: %w", + core.ErrTokenInvalid, + ) + } + + var tierStr string + if err := token.Get("tier", &tierStr); err != nil { + return nil, fmt.Errorf( + "verify token: missing tier claim: %w", + core.ErrTokenInvalid, + ) + } + + var versionFloat float64 + if err := token.Get("token_version", &versionFloat); err != nil { + return nil, fmt.Errorf( + "verify token: missing token_version claim: %w", + core.ErrTokenInvalid, + ) + } + + jti, _ := token.JwtID() + expiresAt, _ := token.Expiration() + + return &middleware.AccessTokenClaims{ + UserID: subject, + Role: roleStr, + Tier: tierStr, + TokenVersion: int(versionFloat), + JTI: jti, + ExpiresAt: expiresAt, + }, nil +} + +func isTokenExpiredError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, jwt.TokenExpiredError()) { + return true + } + errStr := err.Error() + return strings.Contains(errStr, "exp") && + strings.Contains(errStr, "not satisfied") +} + +func deriveKeyID(publicKey jwk.Key) (string, error) { + thumb, err := publicKey.Thumbprint(crypto.SHA256) + if err != nil { + return "", fmt.Errorf("thumbprint: %w", err) + } + return hex.EncodeToString(thumb)[:kidLength], nil +} + +func (m *JWTManager) GetJWKSHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + + if err := json.NewEncoder(w).Encode(m.publicJWKS); err != nil { + http.Error( + w, + "Internal Server Error", + http.StatusInternalServerError, + ) + return + } + } +} + +func (m *JWTManager) GetPublicKey() jwk.Key { + return m.publicKey +} + +func (m *JWTManager) GetKeyID() string { + var kid string + //nolint:errcheck // key ID always set during NewJWTManager init + _ = m.privateKey.Get(jwk.KeyIDKey, &kid) + return kid +} + +type RefreshTokenData struct { + Token string + Hash string + ExpiresAt time.Time + FamilyID string +} + +func (m *JWTManager) CreateRefreshToken( + userID, familyID string, +) (*RefreshTokenData, error) { + token, err := core.GenerateRefreshToken() + if err != nil { + return nil, fmt.Errorf("generate refresh token: %w", err) + } + + hash := core.HashToken(token) + expiresAt := time.Now().Add(m.config.RefreshTokenExpire) + + if familyID == "" { + familyID = uuid.New().String() + } + + return &RefreshTokenData{ + Token: token, + Hash: hash, + ExpiresAt: expiresAt, + FamilyID: familyID, + }, nil +} + +func (m *JWTManager) VerifyRefreshTokenHash(token, storedHash string) bool { + return core.CompareTokenHash(token, storedHash) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go new file mode 100644 index 00000000..f4f86605 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go @@ -0,0 +1,236 @@ +// AngelaMos | 2026 +// repository.go + +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Repository interface { + Create(ctx context.Context, token *RefreshToken) error + FindByHash(ctx context.Context, tokenHash string) (*RefreshToken, error) + FindByID(ctx context.Context, id string) (*RefreshToken, error) + MarkAsUsed(ctx context.Context, id, replacedByID string) error + RevokeByID(ctx context.Context, id string) error + RevokeByFamilyID(ctx context.Context, familyID string) error + RevokeAllForUser(ctx context.Context, userID string) error + GetActiveSessionsForUser( + ctx context.Context, + userID string, + ) ([]RefreshToken, error) + DeleteExpired(ctx context.Context) (int64, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) Create(ctx context.Context, token *RefreshToken) error { + query := ` + INSERT INTO refresh_tokens ( + id, user_id, token_hash, family_id, expires_at, + user_agent, ip_address + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + RETURNING created_at` + + err := r.db.GetContext(ctx, &token.CreatedAt, query, + token.ID, + token.UserID, + token.TokenHash, + token.FamilyID, + token.ExpiresAt, + token.UserAgent, + token.IPAddress, + ) + if err != nil { + return fmt.Errorf("create refresh token: %w", err) + } + + return nil +} + +func (r *repository) FindByHash( + ctx context.Context, + tokenHash string, +) (*RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE token_hash = $1` + + var token RefreshToken + err := r.db.GetContext(ctx, &token, query, tokenHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("find refresh token: %w", err) + } + + return &token, nil +} + +func (r *repository) FindByID( + ctx context.Context, + id string, +) (*RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE id = $1` + + var token RefreshToken + err := r.db.GetContext(ctx, &token, query, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("find refresh token: %w", err) + } + + return &token, nil +} + +func (r *repository) MarkAsUsed( + ctx context.Context, + id, replacedByID string, +) error { + query := ` + UPDATE refresh_tokens + SET is_used = true, used_at = NOW(), replaced_by_id = $2 + WHERE id = $1 AND is_used = false` + + result, err := r.db.ExecContext(ctx, query, id, replacedByID) + if err != nil { + return fmt.Errorf("mark refresh token as used: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("mark refresh token as used: %w", err) + } + + if rows == 0 { + return fmt.Errorf("mark refresh token as used: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) RevokeByID(ctx context.Context, id string) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE id = $1 AND revoked_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + if rows == 0 { + return fmt.Errorf("revoke refresh token: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) RevokeByFamilyID( + ctx context.Context, + familyID string, +) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE family_id = $1 AND revoked_at IS NULL` + + _, err := r.db.ExecContext(ctx, query, familyID) + if err != nil { + return fmt.Errorf("revoke token family: %w", err) + } + + return nil +} + +func (r *repository) RevokeAllForUser( + ctx context.Context, + userID string, +) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL` + + _, err := r.db.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("revoke all user tokens: %w", err) + } + + return nil +} + +func (r *repository) GetActiveSessionsForUser( + ctx context.Context, + userID string, +) ([]RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE user_id = $1 + AND revoked_at IS NULL + AND is_used = false + AND expires_at > NOW() + ORDER BY created_at DESC` + + var tokens []RefreshToken + err := r.db.SelectContext(ctx, &tokens, query, userID) + if err != nil { + return nil, fmt.Errorf("get active sessions: %w", err) + } + + return tokens, nil +} + +func (r *repository) DeleteExpired(ctx context.Context) (int64, error) { + query := ` + DELETE FROM refresh_tokens + WHERE expires_at < $1` + + cutoff := time.Now().Add(-24 * time.Hour) + + result, err := r.db.ExecContext(ctx, query, cutoff) + if err != nil { + return 0, fmt.Errorf("delete expired tokens: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired tokens: %w", err) + } + + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go new file mode 100644 index 00000000..c9a1418d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go @@ -0,0 +1,525 @@ +// AngelaMos | 2026 +// service.go + +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrTokenReuse = errors.New("token reuse detected") + ErrEmailExists = errors.New("email already exists") +) + +type UserInfo struct { + ID string + Email string + Name string + PasswordHash string + Role string + Tier string + TokenVersion int +} + +type UserProvider interface { + GetByEmail(ctx context.Context, email string) (*UserInfo, error) + GetByID(ctx context.Context, id string) (*UserInfo, error) + Create( + ctx context.Context, + email, passwordHash, name string, + ) (*UserInfo, error) + IncrementTokenVersion(ctx context.Context, userID string) error + UpdatePassword(ctx context.Context, userID, passwordHash string) error + SetRole(ctx context.Context, userID, role string) error +} + +// RuleSeeder is called after a fresh user registers, to populate +// default alert rules. Wired by main.go using alerts.Repo.SeedDefaults. +// Optional — auth works without it; user just won't get alerts until +// they create rules manually. +type RuleSeeder func(ctx context.Context, userID string) error + +type Service struct { + repo Repository + jwt *JWTManager + userProvider UserProvider + redis *redis.Client + blacklistTTL time.Duration + adminEmail string + ruleSeeder RuleSeeder +} + +// SetRuleSeeder registers a callback fired after Register succeeds. +// Called from main.go after both auth.Service and alerts.Repo are +// constructed; resolves the cyclic dependency (auth doesn't import +// alerts, alerts doesn't import auth). +func (s *Service) SetRuleSeeder(fn RuleSeeder) { + s.ruleSeeder = fn +} + +type ServiceConfig struct { + Repo Repository + JWT *JWTManager + UserProvider UserProvider + Redis *redis.Client + AdminEmail string +} + +func NewService( + repo Repository, + jwt *JWTManager, + userProvider UserProvider, + redisClient *redis.Client, +) *Service { + return NewServiceWithConfig(ServiceConfig{ + Repo: repo, + JWT: jwt, + UserProvider: userProvider, + Redis: redisClient, + }) +} + +func NewServiceWithConfig(cfg ServiceConfig) *Service { + return &Service{ + repo: cfg.Repo, + jwt: cfg.JWT, + userProvider: cfg.UserProvider, + redis: cfg.Redis, + blacklistTTL: 15 * time.Minute, + adminEmail: strings.ToLower(strings.TrimSpace(cfg.AdminEmail)), + } +} + +// promoteIfAdminEmail flips role to admin when the user's email matches +// the configured ADMIN_EMAIL. Idempotent — does nothing if already admin +// or if no admin email is configured. Mutates `user` in place so the +// returned auth response reflects the new role. +func (s *Service) promoteIfAdminEmail(ctx context.Context, user *UserInfo) { + if s.adminEmail == "" || user == nil { + return + } + if !strings.EqualFold(user.Email, s.adminEmail) { + return + } + if user.Role == "admin" { + return + } + if err := s.userProvider.SetRole(ctx, user.ID, "admin"); err != nil { + // Best-effort. Logging is the caller's job. + return + } + user.Role = "admin" +} + +func (s *Service) Login( + ctx context.Context, + req LoginRequest, + userAgent, ipAddress string, +) (*AuthResponse, error) { + user, err := s.userProvider.GetByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + //nolint:errcheck // timing attack prevention - always verify to prevent enumeration + _, _, _ = core.VerifyPasswordTimingSafe(req.Password, nil) + return nil, ErrInvalidCredentials + } + return nil, fmt.Errorf("get user: %w", err) + } + + valid, newHash, err := core.VerifyPasswordTimingSafe( + req.Password, + &user.PasswordHash, + ) + if err != nil { + return nil, fmt.Errorf("verify password: %w", err) + } + + if !valid { + return nil, ErrInvalidCredentials + } + + if newHash != "" { + //nolint:errcheck // best-effort rehash upgrade + _ = s.userProvider.UpdatePassword(ctx, user.ID, newHash) + } + + s.promoteIfAdminEmail(ctx, user) + + if s.ruleSeeder != nil { + // Idempotent. Cheap (one INSERT-WHERE-NOT-EXISTS per default + // rule) and gives existing accounts new defaults the moment we + // ship them, without needing a one-off migration. + _ = s.ruleSeeder(ctx, user.ID) + } + + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) +} + +func (s *Service) Register( + ctx context.Context, + req RegisterRequest, + userAgent, ipAddress string, +) (*AuthResponse, error) { + passwordHash, err := core.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user, err := s.userProvider.Create(ctx, req.Email, passwordHash, req.Name) + if err != nil { + if errors.Is(err, core.ErrDuplicateKey) { + return nil, ErrEmailExists + } + return nil, fmt.Errorf("create user: %w", err) + } + + s.promoteIfAdminEmail(ctx, user) + + if s.ruleSeeder != nil { + // Best-effort. Don't block registration if seeding fails — the + // user can configure rules manually. + _ = s.ruleSeeder(ctx, user.ID) + } + + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) +} + +func (s *Service) Refresh( + ctx context.Context, + refreshToken, userAgent, ipAddress string, +) (*AuthResponse, error) { + tokenHash := core.HashToken(refreshToken) + + storedToken, err := s.repo.FindByHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, fmt.Errorf("refresh: %w", core.ErrTokenInvalid) + } + return nil, fmt.Errorf("find token: %w", err) + } + + if storedToken.IsUsed { + //nolint:errcheck // security revocation continues regardless + _ = s.repo.RevokeByFamilyID(ctx, storedToken.FamilyID) + return nil, ErrTokenReuse + } + + if !storedToken.IsValid() { + if storedToken.IsRevoked() { + return nil, fmt.Errorf("refresh: %w", core.ErrTokenRevoked) + } + return nil, fmt.Errorf("refresh: %w", core.ErrTokenExpired) + } + + user, err := s.userProvider.GetByID(ctx, storedToken.UserID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return s.createAuthResponse( + ctx, + user, + userAgent, + ipAddress, + storedToken.FamilyID, + &storedToken.ID, + ) +} + +func (s *Service) Logout( + ctx context.Context, + refreshToken, userID, accessJTI string, + accessExpiresAt time.Time, +) error { + tokenHash := core.HashToken(refreshToken) + + storedToken, err := s.repo.FindByHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + // Refresh token already gone — still blacklist the access JTI + // so the current session can't make authenticated calls. + if accessJTI != "" { + if revErr := s.RevokeAccessToken( + ctx, + accessJTI, + accessExpiresAt, + ); revErr != nil { + return fmt.Errorf("blacklist access token: %w", revErr) + } + } + return nil + } + return fmt.Errorf("find token: %w", err) + } + + if storedToken.UserID != userID { + return fmt.Errorf("logout: %w", core.ErrForbidden) + } + + if err := s.repo.RevokeByID(ctx, storedToken.ID); err != nil && + !errors.Is(err, core.ErrNotFound) { + return fmt.Errorf("revoke token: %w", err) + } + + if accessJTI != "" { + if err := s.RevokeAccessToken( + ctx, + accessJTI, + accessExpiresAt, + ); err != nil { + return fmt.Errorf("blacklist access token: %w", err) + } + } + + return nil +} + +func (s *Service) LogoutAll(ctx context.Context, userID string) error { + if err := s.repo.RevokeAllForUser(ctx, userID); err != nil { + return fmt.Errorf("revoke all tokens: %w", err) + } + + if err := s.userProvider.IncrementTokenVersion(ctx, userID); err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + return nil +} + +func (s *Service) RevokeAccessToken( + ctx context.Context, + jti string, + expiresAt time.Time, +) error { + key := "blacklist:" + jti + ttl := time.Until(expiresAt) + + if ttl <= 0 { + return nil + } + + if err := s.redis.Set(ctx, key, "1", ttl).Err(); err != nil { + return fmt.Errorf("blacklist token: %w", err) + } + + return nil +} + +func (s *Service) IsAccessTokenBlacklisted( + ctx context.Context, + jti string, +) (bool, error) { + key := "blacklist:" + jti + + exists, err := s.redis.Exists(ctx, key).Result() + if err != nil { + return false, fmt.Errorf("check blacklist: %w", err) + } + + return exists > 0, nil +} + +func (s *Service) GetActiveSessions( + ctx context.Context, + userID string, +) ([]SessionInfo, error) { + tokens, err := s.repo.GetActiveSessionsForUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get sessions: %w", err) + } + + sessions := make([]SessionInfo, 0, len(tokens)) + for _, t := range tokens { + sessions = append(sessions, SessionInfo{ + ID: t.ID, + UserAgent: t.UserAgent, + IPAddress: t.IPAddress, + CreatedAt: t.CreatedAt, + ExpiresAt: t.ExpiresAt, + }) + } + + return sessions, nil +} + +func (s *Service) RevokeSession( + ctx context.Context, + userID, sessionID string, +) error { + token, err := s.repo.FindByID(ctx, sessionID) + if err != nil { + return fmt.Errorf("find session: %w", err) + } + + if token.UserID != userID { + return fmt.Errorf("revoke session: %w", core.ErrForbidden) + } + + if err := s.repo.RevokeByID(ctx, sessionID); err != nil { + return fmt.Errorf("revoke session: %w", err) + } + + return nil +} + +func (s *Service) ChangePassword( + ctx context.Context, + userID, currentPassword, newPassword string, +) error { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + valid, _, err := core.VerifyPasswordWithRehash( + currentPassword, + user.PasswordHash, + ) + if err != nil { + return fmt.Errorf("verify password: %w", err) + } + + if !valid { + return ErrInvalidCredentials + } + + newHash, err := core.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := s.userProvider.UpdatePassword(ctx, userID, newHash); err != nil { + return fmt.Errorf("update password: %w", err) + } + + if err := s.LogoutAll(ctx, userID); err != nil { + return fmt.Errorf("logout all: %w", err) + } + + return nil +} + +func (s *Service) ValidateTokenVersion( + ctx context.Context, + userID string, + tokenVersion int, +) error { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + if tokenVersion < user.TokenVersion { + return fmt.Errorf("validate token version: %w", core.ErrTokenRevoked) + } + + return nil +} + +func (s *Service) GetCurrentUser( + ctx context.Context, + userID string, +) (*UserResponse, error) { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return &UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Tier: user.Tier, + }, nil +} + +func (s *Service) createAuthResponse( + ctx context.Context, + user *UserInfo, + userAgent, ipAddress, familyID string, + oldTokenID *string, +) (*AuthResponse, error) { + accessToken, err := s.jwt.CreateAccessToken(AccessTokenClaims{ + UserID: user.ID, + Role: user.Role, + Tier: user.Tier, + TokenVersion: user.TokenVersion, + }) + if err != nil { + return nil, fmt.Errorf("create access token: %w", err) + } + + refreshData, err := s.jwt.CreateRefreshToken(user.ID, familyID) + if err != nil { + return nil, fmt.Errorf("create refresh token: %w", err) + } + + newTokenID := uuid.New().String() + + // Claim the old token BEFORE inserting the new one. Two concurrent + // refresh attempts with the same token race on this UPDATE; only one + // flips is_used=false→true. The loser sees ErrNotFound and returns + // ErrTokenReuse without ever creating a new refresh row, which keeps + // the rotation chain single-use. + if oldTokenID != nil { + if err := s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID); err != nil { + if errors.Is(err, core.ErrNotFound) { + if revErr := s.repo.RevokeByFamilyID( + ctx, + familyID, + ); revErr != nil { + return nil, fmt.Errorf( + "rotate refresh token: revoke family on race-loss: %w", + revErr, + ) + } + return nil, ErrTokenReuse + } + return nil, fmt.Errorf("rotate refresh token: %w", err) + } + } + + refreshTokenEntity := &RefreshToken{ + ID: newTokenID, + UserID: user.ID, + TokenHash: refreshData.Hash, + FamilyID: refreshData.FamilyID, + ExpiresAt: refreshData.ExpiresAt, + UserAgent: userAgent, + IPAddress: ipAddress, + } + + if err := s.repo.Create(ctx, refreshTokenEntity); err != nil { + return nil, fmt.Errorf("store refresh token: %w", err) + } + + return &AuthResponse{ + User: UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Tier: user.Tier, + CreatedAt: time.Now(), + }, + Tokens: TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshData.Token, + TokenType: "Bearer", + ExpiresIn: int(15 * time.Minute / time.Second), + ExpiresAt: time.Now().Add(15 * time.Minute), + }, + }, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/verifier.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/verifier.go new file mode 100644 index 00000000..8afffff0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/verifier.go @@ -0,0 +1,81 @@ +// ©AngelaMos | 2026 +// verifier.go + +package auth + +import ( + "context" + "errors" + "fmt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +// Verifier composes JWT validation, token-version check, and JTI blacklist +// check into a single TokenVerifier the middleware can use. This is what +// wires the auth-service's revocation infrastructure (LogoutAll's +// IncrementTokenVersion, Logout's RevokeAccessToken) into the request +// path — without it, those calls were dead code. +type Verifier struct { + jwt *JWTManager + service *Service + userProvider UserProvider +} + +func NewVerifier( + jwt *JWTManager, + service *Service, + userProvider UserProvider, +) *Verifier { + return &Verifier{ + jwt: jwt, + service: service, + userProvider: userProvider, + } +} + +// VerifyAccessToken parses the JWT, then enforces: +// - The user's current token_version is <= the version embedded in this +// token. A LogoutAll bump invalidates every previously-issued access +// token by raising the floor. +// - The token's JTI is not in the Redis blacklist (Logout adds it there +// to revoke a single session immediately, not just the refresh token). +func (v *Verifier) VerifyAccessToken( + ctx context.Context, + tokenString string, +) (*middleware.AccessTokenClaims, error) { + claims, err := v.jwt.VerifyAccessToken(ctx, tokenString) + if err != nil { + return nil, err + } + + if claims.JTI != "" { + blacklisted, blErr := v.service.IsAccessTokenBlacklisted( + ctx, + claims.JTI, + ) + if blErr != nil { + return nil, fmt.Errorf("verify token: blacklist lookup: %w", blErr) + } + if blacklisted { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenRevoked) + } + } + + user, err := v.userProvider.GetByID(ctx, claims.UserID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid) + } + return nil, fmt.Errorf("verify token: load user: %w", err) + } + if claims.TokenVersion < user.TokenVersion { + return nil, fmt.Errorf( + "verify token: version superseded: %w", + core.ErrTokenRevoked, + ) + } + + return claims, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go new file mode 100644 index 00000000..e53733d4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// bus.go + +package bus + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "sync/atomic" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + defaultBufferSize = 512 + defaultSubscriberSize = 256 +) + +type Config struct { + BufferSize int + Persister Persister + Broadcaster Broadcaster + Logger *slog.Logger +} + +type Bus struct { + ch chan events.Event + persister Persister + broadcaster Broadcaster + logger *slog.Logger + dropped atomic.Uint64 + + subsMu sync.RWMutex + subscribers []chan events.Event + subDropped atomic.Uint64 +} + +func New(cfg Config) *Bus { + if cfg.BufferSize <= 0 { + cfg.BufferSize = defaultBufferSize + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Bus{ + ch: make(chan events.Event, cfg.BufferSize), + persister: cfg.Persister, + broadcaster: cfg.Broadcaster, + logger: logger, + } +} + +func (b *Bus) Emit(ev events.Event) { + select { + case b.ch <- ev: + default: + b.dropped.Add(1) + b.logger.Warn("event bus full, dropped", + "topic", ev.Topic, "source", ev.Source) + } +} + +func (b *Bus) DroppedCount() uint64 { + return b.dropped.Load() +} + +// SubscriberDroppedCount counts events dropped to channel subscribers +// (separate from the main bus drop counter). +func (b *Bus) SubscriberDroppedCount() uint64 { + return b.subDropped.Load() +} + +// Subscribe returns a buffered channel that receives every event the bus +// sees. Slow subscribers drop events rather than block the persist+ +// broadcast hot path; SubscriberDroppedCount reports total drops. Wire +// subscribers at startup before Run() begins. +func (b *Bus) Subscribe() <-chan events.Event { + b.subsMu.Lock() + defer b.subsMu.Unlock() + ch := make(chan events.Event, defaultSubscriberSize) + b.subscribers = append(b.subscribers, ch) + return ch +} + +func (b *Bus) Run(ctx context.Context) error { + defer b.closeSubscribers() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case ev := <-b.ch: + if b.persister != nil { + if err := b.persister.Save(ctx, ev); err != nil { + b.logger.Error("persist event failed", + "err", err, "topic", ev.Topic) + } + } + if b.broadcaster != nil { + payload, err := json.Marshal(ev.Payload) + if err != nil { + b.logger.Error("marshal payload failed", + "err", err, "topic", ev.Topic) + continue + } + b.broadcaster.Broadcast(string(ev.Topic), payload) + } + b.fanout(ev) + } + } +} + +func (b *Bus) fanout(ev events.Event) { + b.subsMu.RLock() + subs := b.subscribers + b.subsMu.RUnlock() + for _, ch := range subs { + select { + case ch <- ev: + default: + b.subDropped.Add(1) + } + } +} + +func (b *Bus) closeSubscribers() { + b.subsMu.Lock() + defer b.subsMu.Unlock() + for _, ch := range b.subscribers { + close(ch) + } + b.subscribers = nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go new file mode 100644 index 00000000..3895255b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go @@ -0,0 +1,99 @@ +// ©AngelaMos | 2026 +// bus_test.go + +package bus_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type capturePersister struct { + mu sync.Mutex + saved []events.Event +} + +func (c *capturePersister) Save(_ context.Context, ev events.Event) error { + c.mu.Lock() + defer c.mu.Unlock() + c.saved = append(c.saved, ev) + return nil +} + +func (c *capturePersister) Count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.saved) +} + +type captureBroadcaster struct { + mu sync.Mutex + sent []bus.Broadcast +} + +func (b *captureBroadcaster) Broadcast(topic string, payload []byte) { + b.mu.Lock() + defer b.mu.Unlock() + b.sent = append(b.sent, bus.Broadcast{Topic: topic, Payload: payload}) +} + +func (b *captureBroadcaster) Count() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.sent) +} + +func TestBus_EmitFlowsThroughDispatcher(t *testing.T) { + persist := &capturePersister{} + cast := &captureBroadcaster{} + + b := bus.New(bus.Config{ + BufferSize: 16, + Persister: persist, + Broadcaster: cast, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + + now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + b.Emit(events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: now, + Source: "heartbeat", + Payload: map[string]string{"ts": now.Format(time.RFC3339Nano)}, + }) + + require.Eventually(t, func() bool { + return persist.Count() == 1 && cast.Count() == 1 + }, 2*time.Second, 10*time.Millisecond) + + cancel() + require.ErrorIs(t, <-done, context.Canceled) +} + +func TestBus_DropOnFullBuffer(t *testing.T) { + persist := &capturePersister{} + cast := &captureBroadcaster{} + + b := bus.New(bus.Config{ + BufferSize: 1, + Persister: persist, + Broadcaster: cast, + }) + + for i := 0; i < 50; i++ { + b.Emit(events.Event{Topic: events.TopicHeartbeat, Source: "stress"}) + } + require.Positive(t, b.DroppedCount()) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go new file mode 100644 index 00000000..1472faa5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// persister.go + +package bus + +import ( + "context" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type Persister interface { + Save(ctx context.Context, ev events.Event) error +} + +type Broadcaster interface { + Broadcast(topic string, payload []byte) +} + +type Broadcast struct { + Topic string + Payload []byte +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go new file mode 100644 index 00000000..f3ad491c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// client.go + +package cfradar + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultCFBaseURL = "https://api.cloudflare.com" + defaultCFRate = 250 * time.Millisecond + defaultCFBurst = 5 + defaultCFBudget = 5 + defaultCFBreaker = 60 * time.Second + pathRadarOutages = "/client/v4/radar/annotations/outages" + pathRadarHijacks = "/client/v4/radar/bgp/hijacks/events" + defaultDateRange = "1d" +) + +type ClientConfig struct { + BaseURL string + BearerToken string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultCFBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "cfradar", + BaseURL: cfg.BaseURL, + BearerToken: cfg.BearerToken, + Rate: rate.Every(defaultCFRate), + Burst: defaultCFBurst, + ConsecutiveFailureBudget: defaultCFBudget, + BreakerTimeout: defaultCFBreaker, + }), + } +} + +type OutageEnvelope struct { + Success bool `json:"success"` + Result OutageResultBody `json:"result"` + Errors []ErrorEntry `json:"errors"` +} + +type OutageResultBody struct { + Annotations []OutageAnnotation `json:"annotations"` +} + +type OutageAnnotation struct { + ID string `json:"id"` + StartDate time.Time `json:"startDate"` + EndDate *time.Time `json:"endDate"` + Locations []string `json:"locations"` + ASNs []int32 `json:"asns"` + Reason string `json:"reason"` + OutageType string `json:"outageType"` +} + +type HijackEnvelope struct { + Success bool `json:"success"` + Result HijackBody `json:"result"` + Errors []ErrorEntry `json:"errors"` +} + +type HijackBody struct { + Events []HijackEvent `json:"events"` +} + +type HijackEvent struct { + ID int64 `json:"id"` + DetectedAt time.Time `json:"detectedAt"` + StartedAt time.Time `json:"startedAt"` + DurationSec int32 `json:"duration"` + Confidence int16 `json:"confidenceScore"` + HijackerASN int32 `json:"hijackerAsn"` + VictimASNs []int32 `json:"victimAsns"` + Prefixes []string `json:"prefixes"` +} + +type ErrorEntry struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (c *Client) FetchOutages(ctx context.Context) (OutageResultBody, error) { + q := url.Values{"dateRange": []string{defaultDateRange}} + var env OutageEnvelope + if err := c.hx.GetJSON(ctx, pathRadarOutages, q, &env); err != nil { + return OutageResultBody{}, fmt.Errorf("fetch outages: %w", err) + } + if !env.Success { + return OutageResultBody{}, fmt.Errorf( + "radar outages: success=false errors=%v", + env.Errors, + ) + } + return env.Result, nil +} + +func (c *Client) FetchHijacks( + ctx context.Context, + minConfidence int, +) (HijackBody, error) { + q := url.Values{ + "dateRange": []string{defaultDateRange}, + "minConfidence": []string{strconv.Itoa(minConfidence)}, + } + var env HijackEnvelope + if err := c.hx.GetJSON(ctx, pathRadarHijacks, q, &env); err != nil { + return HijackBody{}, fmt.Errorf("fetch hijacks: %w", err) + } + if !env.Success { + return HijackBody{}, fmt.Errorf( + "radar hijacks: success=false errors=%v", + env.Errors, + ) + } + return env.Result, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go new file mode 100644 index 00000000..8061d297 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go @@ -0,0 +1,107 @@ +// ©AngelaMos | 2026 +// client_test.go + +package cfradar_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" +) + +func newRadarServer(t *testing.T, authHits *atomic.Int32) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc( + "/client/v4/radar/annotations/outages", + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer test-token" { + authHits.Add(1) + } + body, err := os.ReadFile("testdata/outages.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + mux.HandleFunc( + "/client/v4/radar/bgp/hijacks/events", + func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/hijacks.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + return httptest.NewServer(mux) +} + +func TestClient_FetchOutagesSendsBearerAndDecodes(t *testing.T) { + var hits atomic.Int32 + srv := newRadarServer(t, &hits) + defer srv.Close() + + c := cfradar.NewClient(cfradar.ClientConfig{ + BaseURL: srv.URL, + BearerToken: "test-token", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + body, err := c.FetchOutages(ctx) + require.NoError(t, err) + require.Len(t, body.Annotations, 2) + require.Equal(t, "outage-2026-04-30-DE-DTAG", body.Annotations[0].ID) + require.Equal(t, []string{"DE"}, body.Annotations[0].Locations) + require.Equal(t, []int32{3320}, body.Annotations[0].ASNs) + require.EqualValues(t, 1, hits.Load()) +} + +func TestClient_FetchHijacksDecodes(t *testing.T) { + var hits atomic.Int32 + srv := newRadarServer(t, &hits) + defer srv.Close() + + c := cfradar.NewClient(cfradar.ClientConfig{ + BaseURL: srv.URL, + BearerToken: "test-token", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + body, err := c.FetchHijacks(ctx, 7) + require.NoError(t, err) + require.Len(t, body.Events, 2) + require.EqualValues(t, 7700001, body.Events[0].ID) + require.EqualValues(t, 9, body.Events[0].Confidence) + require.Equal(t, []string{"203.0.113.0/24"}, body.Events[0].Prefixes) +} + +func TestClient_FetchOutagesFailsOnSuccessFalse(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write( + []byte( + `{"success":false,"errors":[{"message":"unauthorized"}]}`, + ), + ) + }), + ) + defer srv.Close() + + c := cfradar.NewClient( + cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"}, + ) + _, err := c.FetchOutages(context.Background()) + require.Error(t, err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go new file mode 100644 index 00000000..3ffbf642 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go @@ -0,0 +1,251 @@ +// ©AngelaMos | 2026 +// collector.go + +package cfradar + +import ( + "context" + "encoding/json" + "log/slog" + "net/netip" + "time" + + "github.com/lib/pq" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "cfradar" + defaultCollectorInterval = 5 * time.Minute + defaultCollectorConfidence = 7 +) + +type Fetcher interface { + FetchOutages(ctx context.Context) (OutageResultBody, error) + FetchHijacks(ctx context.Context, minConfidence int) (HijackBody, error) +} + +type Repository interface { + UpsertOutage(ctx context.Context, o OutageRow) error + UpsertHijack(ctx context.Context, h HijackRow) error + KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error) + KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type Enricher interface { + Lookup(ctx context.Context, ip string) (Enrichment, error) +} + +type Enrichment struct { + Country string `json:"country,omitempty"` + AbuseConfidence int `json:"abuse_confidence,omitempty"` + ISP string `json:"isp,omitempty"` + CheckedIP string `json:"checked_ip,omitempty"` +} + +type EnrichedHijack struct { + HijackEvent + Enrichment *Enrichment `json:"enrichment,omitempty"` +} + +type CollectorConfig struct { + Interval time.Duration + MinConfidence int + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Enricher Enricher + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultCollectorInterval + } + if cfg.MinConfidence <= 0 { + cfg.MinConfidence = defaultCollectorConfidence + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + emitted := int64(0) + hadError := false + + if n, err := c.tickOutages(ctx); err != nil { + c.logger.Warn("cfradar outages tick failed", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + emitted += n + } + + if n, err := c.tickHijacks(ctx); err != nil { + c.logger.Warn("cfradar hijacks tick failed", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + emitted += n + } + + if !hadError { + c.cfg.State.RecordSuccess(ctx, Name, emitted) + } +} + +func (c *Collector) tickOutages(ctx context.Context) (int64, error) { + body, err := c.cfg.Fetcher.FetchOutages(ctx) + if err != nil { + return 0, err + } + + ids := make([]string, 0, len(body.Annotations)) + for _, a := range body.Annotations { + ids = append(ids, a.ID) + } + + known, err := c.cfg.Repo.KnownOutageIDs(ctx, ids) + if err != nil { + return 0, err + } + + now := time.Now().UTC() + emitted := int64(0) + for _, a := range body.Annotations { + if known[a.ID] { + continue + } + rawBytes, _ := json.Marshal(a) + raw := json.RawMessage(rawBytes) + row := OutageRow{ + ID: a.ID, + StartedAt: a.StartDate, + EndedAt: a.EndDate, + Locations: pq.StringArray(a.Locations), + ASNs: pq.Int32Array(a.ASNs), + Cause: a.Reason, + OutageType: a.OutageType, + Payload: raw, + } + if uerr := c.cfg.Repo.UpsertOutage(ctx, row); uerr != nil { + c.logger.Warn("upsert outage", "id", a.ID, "err", uerr) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicInternetOutage, + Timestamp: now, + Source: Name, + Payload: raw, + }) + emitted++ + } + return emitted, nil +} + +func (c *Collector) tickHijacks(ctx context.Context) (int64, error) { + body, err := c.cfg.Fetcher.FetchHijacks(ctx, c.cfg.MinConfidence) + if err != nil { + return 0, err + } + + ids := make([]int64, 0, len(body.Events)) + for _, e := range body.Events { + ids = append(ids, e.ID) + } + + known, err := c.cfg.Repo.KnownHijackIDs(ctx, ids) + if err != nil { + return 0, err + } + + now := time.Now().UTC() + emitted := int64(0) + for _, e := range body.Events { + if known[e.ID] { + continue + } + enriched := EnrichedHijack{HijackEvent: e} + if c.cfg.Enricher != nil && len(e.Prefixes) > 0 { + if ip, ok := representativeIP(e.Prefixes[0]); ok { + if v, lerr := c.cfg.Enricher.Lookup(ctx, ip); lerr == nil { + enriched.Enrichment = &Enrichment{ + Country: v.Country, + AbuseConfidence: v.AbuseConfidence, + ISP: v.ISP, + CheckedIP: ip, + } + } + } + } + rawBytes, _ := json.Marshal(enriched) + raw := json.RawMessage(rawBytes) + row := HijackRow{ + ID: e.ID, + DetectedAt: e.DetectedAt, + StartedAt: e.StartedAt, + DurationSec: e.DurationSec, + Confidence: e.Confidence, + HijackerASN: e.HijackerASN, + VictimASNs: pq.Int32Array(e.VictimASNs), + Prefixes: e.Prefixes, + Payload: raw, + } + if uerr := c.cfg.Repo.UpsertHijack(ctx, row); uerr != nil { + c.logger.Warn("upsert hijack", "id", e.ID, "err", uerr) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicBGPHijack, + Timestamp: now, + Source: Name, + Payload: raw, + }) + emitted++ + } + return emitted, nil +} + +func representativeIP(cidr string) (string, bool) { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return "", false + } + return p.Addr().String(), true +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go new file mode 100644 index 00000000..f4e76b3d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go @@ -0,0 +1,336 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package cfradar_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + outages cfradar.OutageResultBody + hijacks cfradar.HijackBody +} + +func (f *fakeFetcher) FetchOutages( + context.Context, +) (cfradar.OutageResultBody, error) { + return f.outages, nil +} + +func (f *fakeFetcher) FetchHijacks( + context.Context, + int, +) (cfradar.HijackBody, error) { + return f.hijacks, nil +} + +type fakeRepo struct { + mu sync.Mutex + outageUpserts int + hijackUpserts int + knownOutages map[string]bool + knownHijacks map[int64]bool +} + +func (r *fakeRepo) UpsertOutage(_ context.Context, _ cfradar.OutageRow) error { + r.mu.Lock() + defer r.mu.Unlock() + r.outageUpserts++ + return nil +} + +func (r *fakeRepo) UpsertHijack(_ context.Context, _ cfradar.HijackRow) error { + r.mu.Lock() + defer r.mu.Unlock() + r.hijackUpserts++ + return nil +} + +func (r *fakeRepo) KnownOutageIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { + out := make(map[string]bool) + for _, id := range ids { + if r.knownOutages[id] { + out[id] = true + } + } + return out, nil +} + +func (r *fakeRepo) KnownHijackIDs( + _ context.Context, + ids []int64, +) (map[int64]bool, error) { + out := make(map[int64]bool) + for _, id := range ids { + if r.knownHijacks[id] { + out[id] = true + } + } + return out, nil +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type noopState struct{} + +func (noopState) RecordSuccess(context.Context, string, int64) {} +func (noopState) RecordError(context.Context, string, string) {} + +type fakeEnricher struct { + calls int + verdict cfradar.Enrichment + lastIP string + returnFn func(ip string) (cfradar.Enrichment, error) +} + +func (e *fakeEnricher) Lookup( + _ context.Context, + ip string, +) (cfradar.Enrichment, error) { + e.calls++ + e.lastIP = ip + if e.returnFn != nil { + return e.returnFn(ip) + } + return e.verdict, nil +} + +func TestCollector_OnlyEmitsNetNew(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + outages: cfradar.OutageResultBody{ + Annotations: []cfradar.OutageAnnotation{ + {ID: "out-known", StartDate: now}, + {ID: "out-new", StartDate: now}, + }, + }, + hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ + {ID: 100, DetectedAt: now, StartedAt: now, Confidence: 9}, + {ID: 200, DetectedAt: now, StartedAt: now, Confidence: 8}, + }}, + } + repo := &fakeRepo{ + knownOutages: map[string]bool{"out-known": true}, + knownHijacks: map[int64]bool{200: true}, + } + emt := &fakeEmitter{} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 30 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: noopState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + evs := emt.Events() + + var outageEvents, hijackEvents int + for _, ev := range evs { + switch ev.Topic { + case events.TopicInternetOutage: + outageEvents++ + body, _ := json.Marshal(ev.Payload) + require.Contains(t, string(body), "out-new") + require.NotContains(t, string(body), "out-known") + case events.TopicBGPHijack: + hijackEvents++ + body, _ := json.Marshal(ev.Payload) + require.Contains(t, string(body), "100") + } + } + require.GreaterOrEqual(t, outageEvents, 1) + require.GreaterOrEqual(t, hijackEvents, 1) +} + +func TestCollector_EnrichesHijackPayloadWhenEnricherProvided(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ + { + ID: 501, + DetectedAt: now, + StartedAt: now, + Confidence: 9, + HijackerASN: 4242, + Prefixes: []string{"203.0.113.0/24"}, + }, + }}, + } + repo := &fakeRepo{knownHijacks: map[int64]bool{}} + emt := &fakeEmitter{} + enr := &fakeEnricher{verdict: cfradar.Enrichment{ + Country: "RU", + AbuseConfidence: 95, + ISP: "ExampleNet", + }} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 30 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: noopState{}, + Enricher: enr, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, enr.calls, 1) + require.Equal(t, "203.0.113.0", enr.lastIP) + + var found bool + for _, ev := range emt.Events() { + if ev.Topic != events.TopicBGPHijack { + continue + } + body, _ := json.Marshal(ev.Payload) + require.Contains(t, string(body), `"country":"RU"`) + require.Contains(t, string(body), `"abuse_confidence":95`) + require.Contains(t, string(body), `"checked_ip":"203.0.113.0"`) + found = true + } + require.True(t, found, "expected at least one hijack event with enrichment") +} + +func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ + { + ID: 777, + DetectedAt: now, + StartedAt: now, + Confidence: 9, + Prefixes: []string{"198.51.100.0/24"}, + }, + }}, + } + repo := &fakeRepo{knownHijacks: map[int64]bool{}} + emt := &fakeEmitter{} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 30 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: noopState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + for _, ev := range emt.Events() { + if ev.Topic != events.TopicBGPHijack { + continue + } + body, _ := json.Marshal(ev.Payload) + require.NotContains(t, string(body), `"enrichment"`) + } +} + +func TestCollector_RepeatedTickIsIdempotent(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + outages: cfradar.OutageResultBody{ + Annotations: []cfradar.OutageAnnotation{ + {ID: "out-x", StartDate: now}, + }, + }, + } + emit := &fakeEmitter{} + + known := map[string]bool{} + repo := &fakeRepo{knownOutages: known} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 20 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emit, + State: noopState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 25*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + known["out-x"] = true + + emit2 := &fakeEmitter{} + c2 := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 20 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emit2, + State: noopState{}, + }) + ctx2, cancel2 := context.WithTimeout( + context.Background(), + 25*time.Millisecond, + ) + defer cancel2() + _ = c2.Run(ctx2) + + for _, ev := range emit2.Events() { + require.NotEqual( + t, + events.TopicInternetOutage, + ev.Topic, + "should not re-emit known outage", + ) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go new file mode 100644 index 00000000..b2ab73fb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go @@ -0,0 +1,196 @@ +// ©AngelaMos | 2026 +// repo.go + +package cfradar + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type OutageRow struct { + ID string `db:"id"` + StartedAt time.Time `db:"started_at"` + EndedAt *time.Time `db:"ended_at"` + Locations pq.StringArray `db:"locations"` + ASNs pq.Int32Array `db:"asns"` + Cause string `db:"cause"` + OutageType string `db:"outage_type"` + Payload json.RawMessage `db:"payload"` +} + +type HijackRow struct { + ID int64 `db:"id"` + DetectedAt time.Time `db:"detected_at"` + StartedAt time.Time `db:"started_at"` + DurationSec int32 `db:"duration_sec"` + Confidence int16 `db:"confidence"` + HijackerASN int32 `db:"hijacker_asn"` + VictimASNs pq.Int32Array `db:"victim_asns"` + Prefixes []string `db:"-"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error { + if o.Locations == nil { + o.Locations = pq.StringArray{} + } + if o.ASNs == nil { + o.ASNs = pq.Int32Array{} + } + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO outage_events (id, started_at, ended_at, locations, asns, cause, outage_type, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + ended_at = EXCLUDED.ended_at, + locations = EXCLUDED.locations, + asns = EXCLUDED.asns, + cause = EXCLUDED.cause, + outage_type = EXCLUDED.outage_type, + payload = EXCLUDED.payload`, + o.ID, + o.StartedAt, + o.EndedAt, + o.Locations, + o.ASNs, + o.Cause, + o.OutageType, + []byte(o.Payload), + ) + if err != nil { + return fmt.Errorf("upsert outage %s: %w", o.ID, err) + } + return nil +} + +func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error { + if h.VictimASNs == nil { + h.VictimASNs = pq.Int32Array{} + } + if h.Prefixes == nil { + h.Prefixes = []string{} + } + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO bgp_hijack_events + (id, detected_at, started_at, duration_sec, confidence, hijacker_asn, victim_asns, prefixes, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::cidr[], $9) + ON CONFLICT (id) DO UPDATE SET + detected_at = EXCLUDED.detected_at, + duration_sec = EXCLUDED.duration_sec, + confidence = EXCLUDED.confidence, + payload = EXCLUDED.payload`, + h.ID, + h.DetectedAt, + h.StartedAt, + h.DurationSec, + h.Confidence, + h.HijackerASN, + h.VictimASNs, + pq.Array(h.Prefixes), + []byte(h.Payload), + ) + if err != nil { + return fmt.Errorf("upsert hijack %d: %w", h.ID, err) + } + return nil +} + +func (r *Repo) KnownOutageIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { + if len(ids) == 0 { + return map[string]bool{}, nil + } + var found []string + if err := r.db.SelectContext( + ctx, + &found, + `SELECT id FROM outage_events WHERE id = ANY($1::text[])`, + pq.Array(ids), + ); err != nil { + return nil, fmt.Errorf("known outage ids: %w", err) + } + out := make(map[string]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) KnownHijackIDs( + ctx context.Context, + ids []int64, +) (map[int64]bool, error) { + if len(ids) == 0 { + return map[int64]bool{}, nil + } + var found []int64 + if err := r.db.SelectContext( + ctx, + &found, + `SELECT id FROM bgp_hijack_events WHERE id = ANY($1::bigint[])`, + pq.Array(ids), + ); err != nil { + return nil, fmt.Errorf("known hijack ids: %w", err) + } + out := make(map[int64]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) RecentOutages( + ctx context.Context, + limit int, +) ([]OutageRow, error) { + if limit <= 0 { + limit = 50 + } + var rows []OutageRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, started_at, ended_at, locations, asns, cause, outage_type, payload + FROM outage_events + ORDER BY started_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent outages: %w", err) + } + return rows, nil +} + +func (r *Repo) RecentHijacks( + ctx context.Context, + limit int, +) ([]HijackRow, error) { + if limit <= 0 { + limit = 50 + } + var rows []HijackRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, detected_at, started_at, duration_sec, confidence, hijacker_asn, + victim_asns, payload + FROM bgp_hijack_events + ORDER BY detected_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent hijacks: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go new file mode 100644 index 00000000..52559c7b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go @@ -0,0 +1,136 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package cfradar_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE outage_events ( + id text PRIMARY KEY, + started_at timestamptz NOT NULL, + ended_at timestamptz, + locations text[] NOT NULL DEFAULT '{}', + asns integer[] NOT NULL DEFAULT '{}', + cause text NOT NULL DEFAULT '', + outage_type text NOT NULL DEFAULT '', + payload jsonb NOT NULL + ); + CREATE TABLE bgp_hijack_events ( + id bigint PRIMARY KEY, + detected_at timestamptz NOT NULL, + started_at timestamptz NOT NULL, + duration_sec integer NOT NULL DEFAULT 0, + confidence smallint NOT NULL DEFAULT 0, + hijacker_asn integer NOT NULL DEFAULT 0, + victim_asns integer[] NOT NULL DEFAULT '{}', + prefixes cidr[] NOT NULL DEFAULT '{}', + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_UpsertOutageIsIdempotent(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + row := cfradar.OutageRow{ + ID: "out-1", + StartedAt: time.Now().UTC().Add(-time.Hour), + Locations: pq.StringArray{"DE", "AT"}, + ASNs: pq.Int32Array{15169}, + Cause: "fiber-cut", + OutageType: "regional", + Payload: json.RawMessage(`{"id":"out-1"}`), + } + require.NoError(t, repo.UpsertOutage(ctx, row)) + require.NoError(t, repo.UpsertOutage(ctx, row)) +} + +func TestRepo_UpsertHijackAndDiff(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + row := cfradar.HijackRow{ + ID: 7700001, + DetectedAt: time.Now().UTC(), + StartedAt: time.Now().UTC().Add(-2 * time.Minute), + Confidence: 9, + HijackerASN: 64500, + VictimASNs: pq.Int32Array{15169, 13335}, + Prefixes: []string{"203.0.113.0/24"}, + Payload: json.RawMessage(`{"id":7700001}`), + } + require.NoError(t, repo.UpsertHijack(ctx, row)) + + known, err := repo.KnownHijackIDs(ctx, []int64{7700001, 7700002}) + require.NoError(t, err) + require.Equal(t, map[int64]bool{7700001: true}, known) +} + +func TestRepo_KnownOutageIDs(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + require.NoError(t, repo.UpsertOutage(ctx, cfradar.OutageRow{ + ID: "out-known", StartedAt: time.Now().UTC(), + Payload: json.RawMessage(`{}`), + })) + + known, err := repo.KnownOutageIDs(ctx, []string{"out-known", "out-new"}) + require.NoError(t, err) + require.Equal(t, map[string]bool{"out-known": true}, known) +} + +func TestRepo_KnownIDsHandlesEmptySlice(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + out, err := repo.KnownOutageIDs(ctx, nil) + require.NoError(t, err) + require.Empty(t, out) + + hij, err := repo.KnownHijackIDs(ctx, nil) + require.NoError(t, err) + require.Empty(t, hij) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json new file mode 100644 index 00000000..7835afe0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json @@ -0,0 +1,29 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "events": [ + { + "id": 7700001, + "detectedAt": "2026-05-01T17:23:14Z", + "startedAt": "2026-05-01T17:21:50Z", + "duration": 600, + "confidenceScore": 9, + "hijackerAsn": 64500, + "victimAsns": [15169, 13335], + "prefixes": ["203.0.113.0/24"] + }, + { + "id": 7700002, + "detectedAt": "2026-05-01T18:02:09Z", + "startedAt": "2026-05-01T18:00:00Z", + "duration": 180, + "confidenceScore": 7, + "hijackerAsn": 64512, + "victimAsns": [3356], + "prefixes": ["198.51.100.0/24", "198.51.101.0/24"] + } + ] + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json new file mode 100644 index 00000000..8b03d2ad --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json @@ -0,0 +1,27 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "annotations": [ + { + "id": "outage-2026-04-30-DE-DTAG", + "startDate": "2026-04-30T14:00:00Z", + "endDate": "2026-04-30T15:30:00Z", + "locations": ["DE"], + "asns": [3320], + "reason": "fiber cut, regional infrastructure", + "outageType": "REGIONAL" + }, + { + "id": "outage-2026-05-01-IR-MCI", + "startDate": "2026-05-01T09:15:00Z", + "endDate": null, + "locations": ["IR"], + "asns": [197207], + "reason": "government-directed shutdown", + "outageType": "NATION_WIDE" + } + ] + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go new file mode 100644 index 00000000..5e2a4958 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// aggregator.go + +package coinbase + +import "time" + +type Aggregator struct { + open map[string]MinuteBar +} + +func NewAggregator() *Aggregator { + return &Aggregator{open: make(map[string]MinuteBar)} +} + +// Push folds a tick into the open minute bar for its symbol. Volume24h +// from the latest tick within the minute wins — the column on +// MinuteBar is documented as "24h volume sampled at close of minute", +// not as per-minute volume. Per-trade size isn't on the ticker channel. +func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) { + minute := t.TS.UTC().Truncate(time.Minute) + cur, exists := a.open[t.Symbol] + + if !exists { + cur = MinuteBar{ + Symbol: t.Symbol, + Minute: minute, + Open: t.Price, + High: t.Price, + Low: t.Price, + Close: t.Price, + Volume24hAtClose: t.Volume24h, + } + a.open[t.Symbol] = cur + return nil, cur + } + + if minute.After(cur.Minute) { + closed := cur + cur = MinuteBar{ + Symbol: t.Symbol, + Minute: minute, + Open: t.Price, + High: t.Price, + Low: t.Price, + Close: t.Price, + Volume24hAtClose: t.Volume24h, + } + a.open[t.Symbol] = cur + return &closed, cur + } + + if t.Price.GreaterThan(cur.High) { + cur.High = t.Price + } + if t.Price.LessThan(cur.Low) { + cur.Low = t.Price + } + cur.Close = t.Price + cur.Volume24hAtClose = t.Volume24h + a.open[t.Symbol] = cur + return nil, cur +} + +func (a *Aggregator) Flush() []MinuteBar { + out := make([]MinuteBar, 0, len(a.open)) + for _, bar := range a.open { + out = append(out, bar) + } + a.open = make(map[string]MinuteBar) + return out +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go new file mode 100644 index 00000000..a5e1b1fd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go @@ -0,0 +1,155 @@ +// ©AngelaMos | 2026 +// aggregator_test.go + +package coinbase_test + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func TestAggregator_FirstTickOpensBar(t *testing.T) { + a := coinbase.NewAggregator() + ts := time.Date(2026, 5, 1, 12, 0, 30, 0, time.UTC) + closed, cur := a.Push(coinbase.Tick{ + Symbol: "BTC-USD", TS: ts, + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }) + require.Nil(t, closed) + require.Equal(t, ts.Truncate(time.Minute), cur.Minute) + require.True(t, cur.Open.Equal(decimal.RequireFromString("42000"))) + require.True(t, cur.Close.Equal(decimal.RequireFromString("42000"))) +} + +func TestAggregator_FoldUpdatesHighLowClose(t *testing.T) { + a := coinbase.NewAggregator() + min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(20 * time.Second), + Price: decimal.RequireFromString("42050"), + Volume24h: decimal.RequireFromString("110"), + }, + ) + closed, cur := a.Push(coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(30 * time.Second), + Price: decimal.RequireFromString( + "41950", + ), + Volume24h: decimal.RequireFromString("120"), + }) + require.Nil(t, closed) + require.True(t, cur.Open.Equal(decimal.RequireFromString("42000"))) + require.True(t, cur.High.Equal(decimal.RequireFromString("42050"))) + require.True(t, cur.Low.Equal(decimal.RequireFromString("41950"))) + require.True(t, cur.Close.Equal(decimal.RequireFromString("41950"))) + require.True( + t, + cur.Volume24hAtClose.Equal(decimal.RequireFromString("120")), + ) +} + +func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) { + a := coinbase.NewAggregator() + min1 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + min2 := min1.Add(time.Minute) + + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min1.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min1.Add(50 * time.Second), + Price: decimal.RequireFromString("42100"), + Volume24h: decimal.RequireFromString("105"), + }, + ) + + closed, cur := a.Push(coinbase.Tick{ + Symbol: "BTC-USD", + TS: min2.Add(5 * time.Second), + Price: decimal.RequireFromString( + "42150", + ), + Volume24h: decimal.RequireFromString("110"), + }) + + require.NotNil(t, closed) + require.Equal(t, min1, closed.Minute) + require.True(t, closed.Close.Equal(decimal.RequireFromString("42100"))) + + require.Equal(t, min2, cur.Minute) + require.True(t, cur.Open.Equal(decimal.RequireFromString("42150"))) +} + +func TestAggregator_BarsArePerProduct(t *testing.T) { + a := coinbase.NewAggregator() + min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + + _, btc := a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + _, eth := a.Push( + coinbase.Tick{ + Symbol: "ETH-USD", + TS: min.Add(11 * time.Second), + Price: decimal.RequireFromString("2300"), + Volume24h: decimal.RequireFromString("80"), + }, + ) + + require.Equal(t, "BTC-USD", btc.Symbol) + require.Equal(t, "ETH-USD", eth.Symbol) + require.True(t, btc.Open.Equal(decimal.RequireFromString("42000"))) + require.True(t, eth.Open.Equal(decimal.RequireFromString("2300"))) +} + +func TestAggregator_FlushReturnsAllOpenBars(t *testing.T) { + a := coinbase.NewAggregator() + min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + a.Push( + coinbase.Tick{ + Symbol: "ETH-USD", + TS: min.Add(15 * time.Second), + Price: decimal.RequireFromString("2300"), + Volume24h: decimal.RequireFromString("80"), + }, + ) + bars := a.Flush() + require.Len(t, bars, 2) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go new file mode 100644 index 00000000..83094e1a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go @@ -0,0 +1,223 @@ +// ©AngelaMos | 2026 +// client.go + +package coinbase + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/shopspring/decimal" +) + +const ( + defaultProductBTC = "BTC-USD" + defaultProductETH = "ETH-USD" + channelTicker = "ticker" + channelHeartbeats = "heartbeats" + channelSubscriptions = "subscriptions" + subscribeWriteTimeout = 5 * time.Second + defaultReadFrameSize = 1 << 20 +) + +type FrameType int + +const ( + FrameTypeUnknown FrameType = iota + FrameTypeSubscriptions + FrameTypeTicker + FrameTypeSnapshot + FrameTypeHeartbeats +) + +type TickerEntry struct { + ProductID string `json:"product_id"` + Price decimal.Decimal `json:"price"` + Volume24h decimal.Decimal `json:"volume_24_h"` + Time time.Time `json:"time"` +} + +type Frame struct { + Kind FrameType + SequenceNum int64 + Timestamp time.Time + Tickers []TickerEntry + HeartbeatTime time.Time +} + +type DialerConfig struct { + URL string + ProductIDs []string + WriteTimeout time.Duration + ReadFrameMax int64 +} + +type Dialer interface { + Dial(ctx context.Context) (*Conn, error) +} + +type WSDialer struct { + cfg DialerConfig +} + +func NewWSDialer(cfg DialerConfig) *WSDialer { + if len(cfg.ProductIDs) == 0 { + cfg.ProductIDs = []string{defaultProductBTC, defaultProductETH} + } + if cfg.WriteTimeout <= 0 { + cfg.WriteTimeout = subscribeWriteTimeout + } + if cfg.ReadFrameMax <= 0 { + cfg.ReadFrameMax = defaultReadFrameSize + } + return &WSDialer{cfg: cfg} +} + +func (d *WSDialer) Dial(ctx context.Context) (*Conn, error) { + c, _, err := websocket.Dial(ctx, d.cfg.URL, nil) + if err != nil { + return nil, fmt.Errorf("dial coinbase ws: %w", err) + } + c.SetReadLimit(d.cfg.ReadFrameMax) + + conn := &Conn{c: c} + subCtx, cancel := context.WithTimeout(ctx, d.cfg.WriteTimeout) + defer cancel() + if err := conn.subscribe( + subCtx, + channelTicker, + d.cfg.ProductIDs, + ); err != nil { + _ = c.Close(websocket.StatusInternalError, "subscribe ticker") + return nil, err + } + if err := conn.subscribe(subCtx, channelHeartbeats, nil); err != nil { + _ = c.Close(websocket.StatusInternalError, "subscribe heartbeats") + return nil, err + } + return conn, nil +} + +type Conn struct { + c *websocket.Conn +} + +func (c *Conn) Close() error { + if c == nil || c.c == nil { + return nil + } + return c.c.Close(websocket.StatusNormalClosure, "") +} + +type subscribeMsg struct { + Type string `json:"type"` + Channel string `json:"channel"` + ProductIDs []string `json:"product_ids,omitempty"` +} + +func (c *Conn) subscribe( + ctx context.Context, + channel string, + productIDs []string, +) error { + msg := subscribeMsg{ + Type: "subscribe", + Channel: channel, + ProductIDs: productIDs, + } + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal subscribe %s: %w", channel, err) + } + if err := c.c.Write(ctx, websocket.MessageText, body); err != nil { + return fmt.Errorf("write subscribe %s: %w", channel, err) + } + return nil +} + +type rawEnvelope struct { + Channel string `json:"channel"` + SequenceNum int64 `json:"sequence_num"` + Timestamp time.Time `json:"timestamp"` + Events []rawEnvelopeEv `json:"events"` +} + +type rawEnvelopeEv struct { + Type string `json:"type"` + Tickers []TickerEntry `json:"tickers,omitempty"` + CurrentTime string `json:"current_time,omitempty"` +} + +func parseCoinbaseTime(s string) time.Time { + if s == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t + } + if idx := strings.Index(s, " m=+"); idx > 0 { + s = s[:idx] + } + if t, err := time.Parse( + "2006-01-02 15:04:05.999999999 -0700 MST", + s, + ); err == nil { + return t + } + return time.Time{} +} + +var ErrFrameMalformed = errors.New("coinbase: malformed frame") + +func (c *Conn) ReadFrame(ctx context.Context) (Frame, error) { + _, msg, err := c.c.Read(ctx) + if err != nil { + return Frame{}, fmt.Errorf("read coinbase frame: %w", err) + } + return decodeFrame(msg) +} + +func decodeFrame(msg []byte) (Frame, error) { + var env rawEnvelope + if err := json.Unmarshal(msg, &env); err != nil { + return Frame{}, fmt.Errorf("%w: %w", ErrFrameMalformed, err) + } + + frame := Frame{ + SequenceNum: env.SequenceNum, + Timestamp: env.Timestamp, + } + + switch env.Channel { + case channelSubscriptions: + frame.Kind = FrameTypeSubscriptions + return frame, nil + + case channelHeartbeats: + frame.Kind = FrameTypeHeartbeats + if len(env.Events) > 0 { + frame.HeartbeatTime = parseCoinbaseTime(env.Events[0].CurrentTime) + } + return frame, nil + + case channelTicker: + for _, ev := range env.Events { + frame.Tickers = append(frame.Tickers, ev.Tickers...) + if ev.Type == "snapshot" { + frame.Kind = FrameTypeSnapshot + } + } + if frame.Kind != FrameTypeSnapshot { + frame.Kind = FrameTypeTicker + } + return frame, nil + } + + frame.Kind = FrameTypeUnknown + return frame, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go new file mode 100644 index 00000000..872e6961 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go @@ -0,0 +1,192 @@ +// ©AngelaMos | 2026 +// client_test.go + +package coinbase_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +type fakeServer struct { + t *testing.T + frames [][]byte + received []string + mu sync.Mutex + srv *httptest.Server +} + +func newFakeServer(t *testing.T, frames ...[]byte) *fakeServer { + t.Helper() + fs := &fakeServer{t: t, frames: frames} + fs.srv = httptest.NewServer(http.HandlerFunc(fs.handle)) + t.Cleanup(fs.srv.Close) + return fs +} + +func (fs *fakeServer) URL() string { + return "ws" + strings.TrimPrefix(fs.srv.URL, "http") +} + +func (fs *fakeServer) Received() []string { + fs.mu.Lock() + defer fs.mu.Unlock() + out := make([]string, len(fs.received)) + copy(out, fs.received) + return out +} + +func (fs *fakeServer) handle(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + for i := 0; i < 2; i++ { + _, msg, err := conn.Read(ctx) + if err != nil { + return + } + fs.mu.Lock() + fs.received = append(fs.received, string(msg)) + fs.mu.Unlock() + } + + for _, frame := range fs.frames { + if err := conn.Write(ctx, websocket.MessageText, frame); err != nil { + return + } + } + + <-ctx.Done() +} + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile("testdata/" + name) + require.NoError(t, err) + return b +} + +func TestClient_DialSubscribesTickerAndHeartbeats(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "ticker.json"), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD", "ETH-USD"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + frame, err := conn.ReadFrame(ctx) + require.NoError(t, err) + require.Equal(t, coinbase.FrameTypeSubscriptions, frame.Kind) + + frame, err = conn.ReadFrame(ctx) + require.NoError(t, err) + require.Equal(t, coinbase.FrameTypeTicker, frame.Kind) + require.Len(t, frame.Tickers, 1) + require.Equal(t, "BTC-USD", frame.Tickers[0].ProductID) + require.Equal(t, "42163.45", frame.Tickers[0].Price.String()) + + require.Eventually(t, func() bool { + recv := fs.Received() + if len(recv) < 2 { + return false + } + var subTicker, subHB struct { + Type string `json:"type"` + ProductIDs []string `json:"product_ids"` + Channel string `json:"channel"` + } + if err := json.Unmarshal([]byte(recv[0]), &subTicker); err != nil { + return false + } + if err := json.Unmarshal([]byte(recv[1]), &subHB); err != nil { + return false + } + return subTicker.Type == "subscribe" && + subTicker.Channel == "ticker" && + len(subTicker.ProductIDs) == 2 && + subHB.Type == "subscribe" && + subHB.Channel == "heartbeats" + }, time.Second, 20*time.Millisecond) +} + +func TestClient_DecodeSnapshotFrameYieldsAllTickers(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "snapshot.json"), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD", "ETH-USD"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + for { + frame, err := conn.ReadFrame(ctx) + require.NoError(t, err) + if frame.Kind == coinbase.FrameTypeSnapshot { + require.Len(t, frame.Tickers, 2) + return + } + } +} + +func TestClient_DecodeHeartbeatExposesCurrentTime(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "heartbeats.json"), + ) + + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + for { + frame, err := conn.ReadFrame(ctx) + require.NoError(t, err) + if frame.Kind == coinbase.FrameTypeHeartbeats { + require.False(t, frame.HeartbeatTime.IsZero()) + return + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go new file mode 100644 index 00000000..3ee3d274 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go @@ -0,0 +1,287 @@ +// ©AngelaMos | 2026 +// collector.go + +package coinbase + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "sync" + "time" + + "github.com/shopspring/decimal" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "coinbase" + defaultURL = "wss://advanced-trade-ws.coinbase.com" + defaultThrottle = 250 * time.Millisecond +) + +type Repository interface { + InsertTick(ctx context.Context, t Tick) error + UpsertMinute(ctx context.Context, b MinuteBar) error + LatestTick(ctx context.Context, symbol string) (Tick, error) + History1h(ctx context.Context, symbol string) ([]MinuteBar, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + URL string + ProductIDs []string + Repo Repository + Emitter Emitter + State StateRecorder + Dialer Dialer + Throttle time.Duration + Reconnect ReconnectConfig + Logger *slog.Logger +} + +type priceObs struct { + ts time.Time + price decimal.Decimal +} + +const ( + priceHistoryWindow = time.Hour + // minHistoryAge guards against false alarms after a backend restart: + // if we only have ~5 minutes of price data and the price moved 5% + // in that window, that's not "1h drop ≥5%" — it's a 5-minute spike + // that happens to round to the threshold. Wait until we have at + // least 30 min of observations before reporting change_pct_1h. + minHistoryAge = 30 * time.Minute +) + +type Collector struct { + cfg CollectorConfig + dialer Dialer + logger *slog.Logger + mu sync.Mutex + lastEmit map[string]time.Time + priceHistory map[string][]priceObs +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.URL == "" { + cfg.URL = defaultURL + } + if len(cfg.ProductIDs) == 0 { + cfg.ProductIDs = []string{defaultProductBTC, defaultProductETH} + } + if cfg.Throttle <= 0 { + cfg.Throttle = defaultThrottle + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + + d := cfg.Dialer + if d == nil { + d = NewWSDialer(DialerConfig{URL: cfg.URL, ProductIDs: cfg.ProductIDs}) + } + + return &Collector{ + cfg: cfg, + dialer: d, + logger: cfg.Logger, + lastEmit: make(map[string]time.Time), + priceHistory: make(map[string][]priceObs), + } +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + err := Reconnect(ctx, c.dialer, c.cfg.Reconnect, c.handleConn) + if err != nil && !errors.Is(err, context.Canceled) && + !errors.Is(err, context.DeadlineExceeded) { + c.cfg.State.RecordError(ctx, Name, err.Error()) + } + return err +} + +func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { + seq := NewSequencer() + agg := NewAggregator() + count := int64(0) + + loopErr := ReadLoop( + ctx, + conn, + seq, + func(hctx context.Context, f Frame) error { + switch f.Kind { + case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats: + return nil + case FrameTypeTicker, FrameTypeSnapshot: + ts := f.Timestamp.UTC() + if ts.IsZero() { + ts = time.Now().UTC() + } + for _, tk := range f.Tickers { + tick := Tick{ + Symbol: tk.ProductID, + TS: ts, + Price: tk.Price, + Volume24h: tk.Volume24h, + } + if err := c.cfg.Repo.InsertTick(hctx, tick); err != nil { + c.logger.Warn( + "insert tick", + "symbol", + tick.Symbol, + "err", + err, + ) + continue + } + if closed, _ := agg.Push(tick); closed != nil { + if err := c.cfg.Repo.UpsertMinute( + hctx, + *closed, + ); err != nil { + c.logger.Warn( + "upsert minute", + "symbol", + closed.Symbol, + "minute", + closed.Minute, + "err", + err, + ) + } + } + if c.shouldEmit(tick.Symbol) { + c.emitTick(tick) + count++ + } + } + } + return nil + }, + ) + + c.logger.Info("coinbase loop exit", + "err", loopErrString(loopErr), + "emit_count", count, + "agg_open", aggLen(agg), + ) + if loopErr == nil || errors.Is(loopErr, ErrSequenceGap) { + c.cfg.State.RecordSuccess(ctx, Name, count) + } + return loopErr +} + +func loopErrString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func aggLen(a *Aggregator) int { + if a == nil { + return 0 + } + return len(a.open) +} + +func (c *Collector) shouldEmit(symbol string) bool { + c.mu.Lock() + defer c.mu.Unlock() + last, ok := c.lastEmit[symbol] + now := time.Now() + if !ok || now.Sub(last) >= c.cfg.Throttle { + c.lastEmit[symbol] = now + return true + } + return false +} + +type tickPayload struct { + Symbol string `json:"symbol"` + TS time.Time `json:"ts"` + Price decimal.Decimal `json:"price"` + Volume24h decimal.Decimal `json:"volume_24h"` + ChangePct1h *float64 `json:"change_pct_1h,omitempty"` +} + +func (c *Collector) emitTick(t Tick) { + c.recordPrice(t.Symbol, t.TS, t.Price) + payload := tickPayload{ + Symbol: t.Symbol, + TS: t.TS, + Price: t.Price, + Volume24h: t.Volume24h, + } + if pct, ok := c.changePct1h(t.Symbol, t.TS, t.Price); ok { + payload.ChangePct1h = &pct + } + body, _ := json.Marshal(payload) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicCoinbasePrice, + Timestamp: t.TS, + Source: Name, + Payload: json.RawMessage(body), + }) +} + +// recordPrice keeps a sliding 1-hour window of (timestamp, price) +// observations per symbol so we can compute trailing percentage change +// without hitting the database on every tick. +func (c *Collector) recordPrice( + symbol string, + ts time.Time, + price decimal.Decimal, +) { + c.mu.Lock() + defer c.mu.Unlock() + cutoff := ts.Add(-priceHistoryWindow) + hist := c.priceHistory[symbol] + drop := 0 + for drop < len(hist) && hist[drop].ts.Before(cutoff) { + drop++ + } + hist = append(hist[drop:], priceObs{ts: ts, price: price}) + c.priceHistory[symbol] = hist +} + +// changePct1h returns the percent change from the oldest observation in +// the sliding window to `current`. Returns false if we haven't yet seen +// minHistoryAge of data — predicates against early-life ticks would +// otherwise fire on noisy short windows. Used by the alerts engine via +// the emitted `change_pct_1h` field. +func (c *Collector) changePct1h( + symbol string, + currentTs time.Time, + current decimal.Decimal, +) (float64, bool) { + c.mu.Lock() + defer c.mu.Unlock() + hist := c.priceHistory[symbol] + if len(hist) == 0 { + return 0, false + } + oldest := hist[0] + if currentTs.Sub(oldest.ts) < minHistoryAge { + return 0, false + } + if oldest.price.IsZero() { + return 0, false + } + pct, _ := current.Sub(oldest.price).Div(oldest.price).Float64() + return pct * 100, true +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go new file mode 100644 index 00000000..a6f71b5c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go @@ -0,0 +1,195 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package coinbase_test + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeRepo struct { + mu sync.Mutex + ticks []coinbase.Tick + minutes []coinbase.MinuteBar +} + +func (r *fakeRepo) InsertTick(_ context.Context, t coinbase.Tick) error { + r.mu.Lock() + defer r.mu.Unlock() + r.ticks = append(r.ticks, t) + return nil +} + +func (r *fakeRepo) UpsertMinute(_ context.Context, b coinbase.MinuteBar) error { + r.mu.Lock() + defer r.mu.Unlock() + r.minutes = append(r.minutes, b) + return nil +} + +func (r *fakeRepo) LatestTick( + _ context.Context, + _ string, +) (coinbase.Tick, error) { + return coinbase.Tick{}, nil +} + +func (r *fakeRepo) History1h( + _ context.Context, + _ string, +) ([]coinbase.MinuteBar, error) { + return nil, nil +} + +func (r *fakeRepo) Tickers() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.ticks) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type fakeState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ +} + +func (s *fakeState) RecordError(_ context.Context, _, _ string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ +} + +func tickerFrame(seq int64, productID, price string, ts time.Time) []byte { + body, _ := json.Marshal(map[string]any{ + "channel": "ticker", + "sequence_num": seq, + "timestamp": ts.Format(time.RFC3339Nano), + "events": []any{ + map[string]any{ + "type": "update", + "tickers": []any{ + map[string]any{ + "type": "ticker", + "product_id": productID, + "price": price, + "volume_24_h": "1.0", + "time": ts.Format(time.RFC3339Nano), + }, + }, + }, + }, + }) + return body +} + +func TestCollector_RunPersistsAndEmits(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + frames := [][]byte{loadFixture(t, "subscriptions.json")} + for i := 0; i < 6; i++ { + ts := now.Add(time.Duration(i) * time.Second) + frames = append( + frames, + tickerFrame(int64(i+1), "BTC-USD", fmt.Sprintf("4200%d.00", i), ts), + ) + } + fs := newFakeServer(t, frames...) + + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &fakeState{} + + c := coinbase.NewCollector(coinbase.CollectorConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD"}, + Repo: repo, + Emitter: emt, + State: st, + Throttle: 10 * time.Millisecond, + Reconnect: coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 800*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Tickers(), 3, "should persist most ticks") + require.GreaterOrEqual( + t, + emt.Count(), + 1, + "should emit at least one event after throttle", + ) +} + +func TestCollector_GapTriggersReconnect(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + frames := [][]byte{ + loadFixture(t, "subscriptions.json"), + tickerFrame(100, "BTC-USD", "42000.00", now), + tickerFrame(500, "BTC-USD", "42100.00", now), + } + fs := newFakeServer(t, frames...) + + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &fakeState{} + + c := coinbase.NewCollector(coinbase.CollectorConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD"}, + Repo: repo, + Emitter: emt, + State: st, + Throttle: 10 * time.Millisecond, + Reconnect: coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 600*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go new file mode 100644 index 00000000..36596355 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go @@ -0,0 +1,52 @@ +// ©AngelaMos | 2026 +// readloop.go + +package coinbase + +import ( + "context" + "errors" + "log/slog" +) + +var ErrSequenceGap = errors.New("coinbase: sequence gap detected") + +type FrameHandler func(ctx context.Context, f Frame) error + +func ReadLoop( + ctx context.Context, + conn *Conn, + seq *Sequencer, + handler FrameHandler, +) error { + for { + if err := ctx.Err(); err != nil { + return err + } + frame, err := conn.ReadFrame(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + + switch frame.Kind { + case FrameTypeUnknown, FrameTypeSubscriptions: + case FrameTypeSnapshot: + seq.Reset() + _ = seq.Observe(frame.SequenceNum) + case FrameTypeTicker, FrameTypeHeartbeats: + if seq.Observe(frame.SequenceNum) { + slog.Default().Warn("coinbase seq gap (non-fatal)", + "channel_kind", frame.Kind, + "seq", frame.SequenceNum, + ) + } + } + + if err := handler(ctx, frame); err != nil { + return err + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go new file mode 100644 index 00000000..914f1399 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go @@ -0,0 +1,165 @@ +// ©AngelaMos | 2026 +// readloop_test.go + +package coinbase_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func TestReadLoop_DeliversTickerFrames(t *testing.T) { + fs := newFakeServer( + t, + loadFixture(t, "subscriptions.json"), + []byte( + `{"channel":"ticker","sequence_num":1000,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`, + ), + []byte( + `{"channel":"ticker","sequence_num":1001,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`, + ), + ) + + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + seq := coinbase.NewSequencer() + mu := sync.Mutex{} + tickerFrames := 0 + + loopCtx, loopCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer loopCancel() + + err = coinbase.ReadLoop( + loopCtx, + conn, + seq, + func(_ context.Context, f coinbase.Frame) error { + mu.Lock() + defer mu.Unlock() + if f.Kind == coinbase.FrameTypeTicker { + tickerFrames++ + } + return nil + }, + ) + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + err == nil, + ) + mu.Lock() + defer mu.Unlock() + require.GreaterOrEqual(t, tickerFrames, 2) +} + +func TestReadLoop_GapIsLoggedButLoopContinues(t *testing.T) { + fs := newFakeServer( + t, + loadFixture(t, "subscriptions.json"), + []byte( + `{"channel":"ticker","sequence_num":100,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`, + ), + []byte( + `{"channel":"ticker","sequence_num":250,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`, + ), + ) + + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) + ctx, cancel := context.WithTimeout( + context.Background(), + 600*time.Millisecond, + ) + defer cancel() + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + seq := coinbase.NewSequencer() + delivered := 0 + loopErr := coinbase.ReadLoop( + ctx, + conn, + seq, + func(_ context.Context, f coinbase.Frame) error { + if f.Kind == coinbase.FrameTypeTicker { + delivered++ + } + return nil + }, + ) + require.True( + t, + errors.Is(loopErr, context.DeadlineExceeded) || errors.Is(loopErr, context.Canceled) || + loopErr == nil, + ) + require.Equal( + t, + 2, + delivered, + "both ticker frames must be delivered despite the gap", + ) +} + +func TestReadLoop_SnapshotResetsSequencer(t *testing.T) { + fs := newFakeServer( + t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "snapshot.json"), + []byte( + `{"channel":"ticker","sequence_num":2,"timestamp":"2026-05-01T22:30:02Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42164.00","volume_24_h":"1.0","time":"2026-05-01T22:30:02Z"}]}]}`, + ), + ) + + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + seq := coinbase.NewSequencer() + mu := sync.Mutex{} + kinds := []coinbase.FrameType{} + + loopCtx, loopCancel := context.WithTimeout(ctx, 800*time.Millisecond) + defer loopCancel() + err = coinbase.ReadLoop( + loopCtx, + conn, + seq, + func(_ context.Context, f coinbase.Frame) error { + mu.Lock() + defer mu.Unlock() + kinds = append(kinds, f.Kind) + return nil + }, + ) + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + err == nil, + ) + + mu.Lock() + defer mu.Unlock() + require.Contains(t, kinds, coinbase.FrameTypeSnapshot) + require.Contains(t, kinds, coinbase.FrameTypeTicker) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go new file mode 100644 index 00000000..4890e69d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go @@ -0,0 +1,75 @@ +// ©AngelaMos | 2026 +// reconnect.go + +package coinbase + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" +) + +const ( + defaultReconnectInitial = time.Second + defaultReconnectMax = 60 * time.Second +) + +type ReconnectConfig struct { + InitialInterval time.Duration + MaxInterval time.Duration +} + +type ConnHandler func(ctx context.Context, conn *Conn) error + +func PermanentReconnectError(err error) error { + return backoff.Permanent(err) +} + +func Reconnect( + ctx context.Context, + d Dialer, + cfg ReconnectConfig, + handler ConnHandler, +) error { + if cfg.InitialInterval <= 0 { + cfg.InitialInterval = defaultReconnectInitial + } + if cfg.MaxInterval <= 0 { + cfg.MaxInterval = defaultReconnectMax + } + + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = cfg.InitialInterval + bo.MaxInterval = cfg.MaxInterval + bo.MaxElapsedTime = 0 + + op := func() error { + if ctx.Err() != nil { + return backoff.Permanent(ctx.Err()) + } + conn, err := d.Dial(ctx) + if err != nil { + if ctx.Err() != nil { + return backoff.Permanent(ctx.Err()) + } + return err + } + hErr := handler(ctx, conn) + _ = conn.Close() + if hErr == nil { + return nil + } + var perm *backoff.PermanentError + if errors.As(hErr, &perm) { + return hErr + } + if ctx.Err() != nil { + return backoff.Permanent(ctx.Err()) + } + return hErr + } + + return backoff.Retry(op, backoff.WithContext(bo, ctx)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go new file mode 100644 index 00000000..d86fff14 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go @@ -0,0 +1,108 @@ +// ©AngelaMos | 2026 +// reconnect_test.go + +package coinbase_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +type stubDialer struct { + failsBeforeOK int32 + called atomic.Int32 +} + +func (d *stubDialer) Dial(_ context.Context) (*coinbase.Conn, error) { + n := d.called.Add(1) + if n <= d.failsBeforeOK { + return nil, errors.New("transient dial failure") + } + return &coinbase.Conn{}, nil +} + +func TestReconnect_StopsOnContextCancel(t *testing.T) { + d := &stubDialer{failsBeforeOK: 0} + + ctx, cancel := context.WithTimeout( + context.Background(), + 80*time.Millisecond, + ) + defer cancel() + + calls := atomic.Int32{} + err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }, func(_ context.Context, _ *coinbase.Conn) error { + calls.Add(1) + return errors.New("upstream closed") + }) + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled), + ) + require.Greater( + t, + calls.Load(), + int32(1), + "callback should fire multiple times before ctx expires", + ) +} + +func TestReconnect_TransientDialFailureBacksOffThenSucceeds(t *testing.T) { + d := &stubDialer{failsBeforeOK: 3} + ctx, cancel := context.WithTimeout( + context.Background(), + 500*time.Millisecond, + ) + defer cancel() + + cbInvocations := atomic.Int32{} + err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ + InitialInterval: 10 * time.Millisecond, + MaxInterval: 50 * time.Millisecond, + }, func(_ context.Context, _ *coinbase.Conn) error { + cbInvocations.Add(1) + return errors.New("force loop") + }) + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled), + ) + require.GreaterOrEqual( + t, + d.called.Load(), + int32(4), + "must dial at least 4 times (3 failures + 1 success)", + ) + require.GreaterOrEqual(t, cbInvocations.Load(), int32(1)) +} + +func TestReconnect_PermanentErrorFromCallbackPropagates(t *testing.T) { + d := &stubDialer{failsBeforeOK: 0} + sentinel := errors.New("hard stop") + + ctx, cancel := context.WithTimeout( + context.Background(), + 200*time.Millisecond, + ) + defer cancel() + + err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 10 * time.Millisecond, + }, func(_ context.Context, _ *coinbase.Conn) error { + return coinbase.PermanentReconnectError(sentinel) + }) + require.ErrorIs(t, err, sentinel) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go new file mode 100644 index 00000000..c664473d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go @@ -0,0 +1,108 @@ +// ©AngelaMos | 2026 +// repo.go + +package coinbase + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" +) + +const ( + history1hLimit = 60 +) + +type Tick struct { + Symbol string `db:"symbol"` + TS time.Time `db:"ts"` + Price decimal.Decimal `db:"price"` + Volume24h decimal.Decimal `db:"volume_24h"` +} + +// MinuteBar holds a per-minute OHLC for a symbol plus the rolling 24h +// volume sampled at the minute's close. The Coinbase ticker channel only +// publishes 24h volume, not per-trade size, so true per-minute volume +// would require the market_trades channel — out of scope here. +type MinuteBar struct { + Symbol string `db:"symbol"` + Minute time.Time `db:"minute"` + Open decimal.Decimal `db:"open"` + High decimal.Decimal `db:"high"` + Low decimal.Decimal `db:"low"` + Close decimal.Decimal `db:"close"` + Volume24hAtClose decimal.Decimal `db:"volume_24h_at_close"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) InsertTick(ctx context.Context, t Tick) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO btc_eth_ticks (symbol, ts, price, volume_24h) + VALUES ($1, $2, $3, $4) + ON CONFLICT (symbol, ts) DO NOTHING`, + t.Symbol, t.TS, t.Price, t.Volume24h, + ) + if err != nil { + return fmt.Errorf("insert tick %s @ %s: %w", t.Symbol, t.TS, err) + } + return nil +} + +func (r *Repo) UpsertMinute(ctx context.Context, b MinuteBar) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO btc_eth_minute (symbol, minute, open, high, low, close, volume_24h_at_close) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (symbol, minute) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume_24h_at_close = EXCLUDED.volume_24h_at_close`, + b.Symbol, b.Minute, b.Open, b.High, b.Low, b.Close, b.Volume24hAtClose, + ) + if err != nil { + return fmt.Errorf("upsert minute %s @ %s: %w", b.Symbol, b.Minute, err) + } + return nil +} + +func (r *Repo) LatestTick(ctx context.Context, symbol string) (Tick, error) { + var t Tick + err := r.db.GetContext(ctx, &t, ` + SELECT symbol, ts, price, volume_24h + FROM btc_eth_ticks + WHERE symbol = $1 + ORDER BY ts DESC LIMIT 1`, symbol) + if err != nil { + return Tick{}, err + } + return t, nil +} + +func (r *Repo) History1h( + ctx context.Context, + symbol string, +) ([]MinuteBar, error) { + var rows []MinuteBar + err := r.db.SelectContext(ctx, &rows, ` + SELECT symbol, minute, open, high, low, close, volume_24h_at_close + FROM btc_eth_minute + WHERE symbol = $1 + ORDER BY minute DESC LIMIT $2`, symbol, history1hLimit, + ) + if err != nil { + return nil, fmt.Errorf("history1h %s: %w", symbol, err) + } + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go new file mode 100644 index 00000000..c8294bc1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go @@ -0,0 +1,151 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package coinbase_test + +import ( + "context" + "database/sql" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE btc_eth_ticks ( + symbol text NOT NULL, + ts timestamptz NOT NULL, + price numeric(18,8) NOT NULL, + volume_24h numeric(20,8), + PRIMARY KEY (symbol, ts) + ); + CREATE TABLE btc_eth_minute ( + symbol text NOT NULL, + minute timestamptz NOT NULL, + open numeric(18,8) NOT NULL, + high numeric(18,8) NOT NULL, + low numeric(18,8) NOT NULL, + close numeric(18,8) NOT NULL, + volume_24h_at_close numeric(20,8), + PRIMARY KEY (symbol, minute) + );`) + require.NoError(t, err) + return db +} + +func TestRepo_InsertTickIdempotent(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + ts := time.Now().UTC().Truncate(time.Microsecond) + tk := coinbase.Tick{ + Symbol: "BTC-USD", + TS: ts, + Price: decimal.RequireFromString("42163.45"), + Volume24h: decimal.RequireFromString("19834.91230000"), + } + require.NoError(t, repo.InsertTick(ctx, tk)) + require.NoError(t, repo.InsertTick(ctx, tk)) + + got, err := repo.LatestTick(ctx, "BTC-USD") + require.NoError(t, err) + require.True(t, got.TS.Equal(ts)) + require.True(t, got.Price.Equal(tk.Price)) + require.True(t, got.Volume24h.Equal(tk.Volume24h)) +} + +func TestRepo_UpsertMinuteUpdatesOHLC(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + minute := time.Now().UTC().Truncate(time.Minute) + bar := coinbase.MinuteBar{ + Symbol: "ETH-USD", + Minute: minute, + Open: decimal.RequireFromString("2310.00"), + High: decimal.RequireFromString("2315.50"), + Low: decimal.RequireFromString("2308.10"), + Close: decimal.RequireFromString("2312.75"), + Volume24hAtClose: decimal.RequireFromString("88.12300000"), + } + require.NoError(t, repo.UpsertMinute(ctx, bar)) + + bar.Close = decimal.RequireFromString("2316.00") + bar.High = decimal.RequireFromString("2317.00") + require.NoError(t, repo.UpsertMinute(ctx, bar)) + + hist, err := repo.History1h(ctx, "ETH-USD") + require.NoError(t, err) + require.Len(t, hist, 1) + require.True(t, hist[0].Close.Equal(decimal.RequireFromString("2316.00"))) + require.True(t, hist[0].High.Equal(decimal.RequireFromString("2317.00"))) +} + +func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + now := time.Now().UTC().Truncate(time.Minute) + for i := 0; i < 75; i++ { + bar := coinbase.MinuteBar{ + Symbol: "BTC-USD", + Minute: now.Add(-time.Duration(i) * time.Minute), + Open: decimal.NewFromInt(int64(40000 + i)), + High: decimal.NewFromInt(int64(40050 + i)), + Low: decimal.NewFromInt(int64(39950 + i)), + Close: decimal.NewFromInt(int64(40010 + i)), + Volume24hAtClose: decimal.NewFromInt(int64(i)), + } + require.NoError(t, repo.UpsertMinute(ctx, bar)) + } + + hist, err := repo.History1h(ctx, "BTC-USD") + require.NoError(t, err) + require.Len(t, hist, 60) + require.True( + t, + hist[0].Minute.Before(hist[len(hist)-1].Minute), + "history must be oldest → newest", + ) +} + +func TestRepo_LatestTickMissingReturnsErrNoRows(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + _, err := repo.LatestTick(ctx, "DOES-NOT-EXIST") + require.ErrorIs(t, err, sql.ErrNoRows) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go new file mode 100644 index 00000000..bcd33570 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go @@ -0,0 +1,36 @@ +// ©AngelaMos | 2026 +// sequencer.go + +package coinbase + +import "sync" + +type Sequencer struct { + mu sync.Mutex + last int64 + set bool +} + +func NewSequencer() *Sequencer { + return &Sequencer{} +} + +func (s *Sequencer) Observe(seq int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + if !s.set { + s.last = seq + s.set = true + return false + } + expected := s.last + 1 + s.last = seq + return seq != expected +} + +func (s *Sequencer) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.set = false + s.last = 0 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go new file mode 100644 index 00000000..92562081 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go @@ -0,0 +1,50 @@ +// ©AngelaMos | 2026 +// sequencer_test.go + +package coinbase_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func TestSequencer_FirstObservationIsBaseline(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe(100), "first sequence number must not be a gap") +} + +func TestSequencer_ConsecutiveSequencesAreInOrder(t *testing.T) { + s := coinbase.NewSequencer() + for i := int64(50); i < 60; i++ { + require.False(t, s.Observe(i), "i=%d", i) + } +} + +func TestSequencer_GapTriggersReportSignal(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe(50)) + require.False(t, s.Observe(51)) + require.True(t, s.Observe(60), "skipping 52-59 must report gap") +} + +func TestSequencer_ResetClearsBaseline(t *testing.T) { + s := coinbase.NewSequencer() + s.Observe(100) + s.Reset() + require.False(t, s.Observe(9999)) +} + +func TestSequencer_DuplicateSequenceTreatedAsGap(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe(100)) + require.True(t, s.Observe(100), "replaying the same seq is a gap") +} + +func TestSequencer_BackwardSequenceTreatedAsGap(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe(100)) + require.True(t, s.Observe(90)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json new file mode 100644 index 00000000..e99d33e7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json @@ -0,0 +1,9 @@ +{ + "channel": "heartbeats", + "client_id": "", + "timestamp": "2026-05-01T22:30:00.000Z", + "sequence_num": 12346, + "events": [ + {"current_time": "2026-05-01T22:30:00.000Z", "heartbeat_counter": 42} + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json new file mode 100644 index 00000000..73019b59 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json @@ -0,0 +1,15 @@ +{ + "channel": "ticker", + "client_id": "", + "timestamp": "2026-05-01T22:30:01.000Z", + "sequence_num": 1, + "events": [ + { + "type": "snapshot", + "tickers": [ + {"type": "ticker", "product_id": "BTC-USD", "price": "42163.45", "volume_24_h": "19834.9123", "time": "2026-05-01T22:30:01.000Z"}, + {"type": "ticker", "product_id": "ETH-USD", "price": "2312.75", "volume_24_h": "88123.4567", "time": "2026-05-01T22:30:01.000Z"} + ] + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json new file mode 100644 index 00000000..d7383fc4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json @@ -0,0 +1,9 @@ +{ + "channel": "subscriptions", + "client_id": "", + "timestamp": "2026-05-01T22:29:59.000Z", + "sequence_num": 0, + "events": [ + {"subscriptions": {"ticker": ["BTC-USD","ETH-USD"], "heartbeats": [""]}} + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json new file mode 100644 index 00000000..1c02de25 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json @@ -0,0 +1,20 @@ +{ + "channel": "ticker", + "client_id": "", + "timestamp": "2026-05-01T22:30:00.000Z", + "sequence_num": 12345, + "events": [ + { + "type": "update", + "tickers": [ + { + "type": "ticker", + "product_id": "BTC-USD", + "price": "42163.45", + "volume_24_h": "19834.9123", + "time": "2026-05-01T22:30:00.000Z" + } + ] + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go new file mode 100644 index 00000000..dd725143 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go @@ -0,0 +1,158 @@ +// ©AngelaMos | 2026 +// collector.go + +package cve + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "cve" + defaultCVEInterval = 2 * time.Hour + defaultCVEWindow = 2 * time.Hour +) + +type NVDFetcher interface { + Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error) +} + +type EPSSFetcher interface { + LookupBatch( + ctx context.Context, + cveIDs []string, + ) (map[string]EPSSScore, error) +} + +type Repository interface { + Upsert(ctx context.Context, row Row) error + UpdateEPSS( + ctx context.Context, + cveID string, + score, percentile float64, + ) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Window time.Duration + NVD NVDFetcher + EPSS EPSSFetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultCVEInterval + } + if cfg.Window <= 0 { + cfg.Window = defaultCVEWindow + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + end := time.Now().UTC() + start := end.Add(-c.cfg.Window) + + resp, err := c.cfg.NVD.Fetch(ctx, start, end) + if err != nil { + c.logger.Warn("nvd fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(resp.Vulnerabilities)) + rows := make([]Row, 0, len(resp.Vulnerabilities)) + for _, v := range resp.Vulnerabilities { + score, severity := v.PrimarySeverity() + raw, _ := json.Marshal(v) + row := Row{ + CveID: v.CVE.ID, + Published: v.CVE.Published.Time, + LastModified: v.CVE.LastModified.Time, + Severity: severity, + CVSS: score, + Payload: raw, + } + rows = append(rows, row) + ids = append(ids, v.CVE.ID) + } + + scores, err := c.cfg.EPSS.LookupBatch(ctx, ids) + if err != nil { + c.logger.Warn("epss lookup", "err", err) + } + + emitted := int64(0) + for _, row := range rows { + if err := c.cfg.Repo.Upsert(ctx, row); err != nil { + c.logger.Warn("upsert cve", "id", row.CveID, "err", err) + continue + } + if s, ok := scores[row.CveID]; ok { + if err := c.cfg.Repo.UpdateEPSS( + ctx, + row.CveID, + s.Score, + s.Percentile, + ); err != nil { + c.logger.Warn("update epss", "id", row.CveID, "err", err) + } + row.EPSSScore = &s.Score + row.EPSSPercentile = &s.Percentile + } + body, _ := json.Marshal(row) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicCVENew, + Timestamp: end, + Source: Name, + Payload: json.RawMessage(body), + }) + emitted++ + } + c.cfg.State.RecordSuccess(ctx, Name, emitted) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go new file mode 100644 index 00000000..78e88042 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go @@ -0,0 +1,196 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package cve_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type stubNVD struct { + resp cve.NVDResponse + err error +} + +func (s *stubNVD) Fetch( + context.Context, + time.Time, + time.Time, +) (cve.NVDResponse, error) { + return s.resp, s.err +} + +type stubEPSS struct { + scores map[string]cve.EPSSScore + err error +} + +func (s *stubEPSS) LookupBatch( + context.Context, + []string, +) (map[string]cve.EPSSScore, error) { + return s.scores, s.err +} + +type stubCVERepo struct { + mu sync.Mutex + upserts map[string]cve.Row + patches map[string][2]float64 +} + +func newStubCVERepo() *stubCVERepo { + return &stubCVERepo{ + upserts: map[string]cve.Row{}, + patches: map[string][2]float64{}, + } +} + +func (r *stubCVERepo) Upsert(_ context.Context, row cve.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.upserts[row.CveID] = row + return nil +} + +func (r *stubCVERepo) UpdateEPSS( + _ context.Context, + id string, + score, pct float64, +) error { + r.mu.Lock() + defer r.mu.Unlock() + r.patches[id] = [2]float64{score, pct} + return nil +} + +func (r *stubCVERepo) Upserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.upserts) +} + +func (r *stubCVERepo) Patches() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.patches) +} + +type stubCVEEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *stubCVEEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *stubCVEEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type stubCVEState struct{} + +func (stubCVEState) RecordSuccess(context.Context, string, int64) {} +func (stubCVEState) RecordError(context.Context, string, string) {} + +func TestCVECollector_FetchesEnrichesAndEmits(t *testing.T) { + now := time.Now().UTC() + nvd := &stubNVD{resp: cve.NVDResponse{ + Vulnerabilities: []cve.NVDVulnRoot{ + {CVE: cve.NVDCVE{ + ID: "CVE-2026-0001", + Published: cve.NVDTime{Time: now.Add(-time.Hour)}, + LastModified: cve.NVDTime{Time: now}, + Metrics: cve.NVDMetrics{CVSSv31: []cve.NVDMetricEntry{ + { + CVSSData: cve.NVDCVSSData{ + BaseScore: 9.8, + BaseSeverity: "CRITICAL", + }, + }, + }}, + }}, + {CVE: cve.NVDCVE{ + ID: "CVE-2026-0002", + Published: cve.NVDTime{Time: now.Add(-time.Hour)}, + LastModified: cve.NVDTime{Time: now}, + }}, + }, + }} + epss := &stubEPSS{scores: map[string]cve.EPSSScore{ + "CVE-2026-0001": {CveID: "CVE-2026-0001", Score: 0.9, Percentile: 0.99}, + "CVE-2026-0002": {CveID: "CVE-2026-0002", Score: 0.1, Percentile: 0.5}, + }} + repo := newStubCVERepo() + emt := &stubCVEEmitter{} + + c := cve.NewCollector(cve.CollectorConfig{ + Interval: 30 * time.Millisecond, + NVD: nvd, + EPSS: epss, + Repo: repo, + Emitter: emt, + State: stubCVEState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Upserts(), 2) + require.GreaterOrEqual(t, repo.Patches(), 2) + + evs := emt.Events() + require.GreaterOrEqual(t, len(evs), 2) + for _, ev := range evs { + require.Equal(t, events.TopicCVENew, ev.Topic) + require.Equal(t, cve.Name, ev.Source) + raw, ok := ev.Payload.(json.RawMessage) + require.True(t, ok) + require.Contains(t, string(raw), "CVE-2026-") + } +} + +func TestCVECollector_NVDFetchErrorRecordsState(t *testing.T) { + nvd := &stubNVD{err: context.DeadlineExceeded} + repo := newStubCVERepo() + emt := &stubCVEEmitter{} + + c := cve.NewCollector(cve.CollectorConfig{ + Interval: 20 * time.Millisecond, + NVD: nvd, + EPSS: &stubEPSS{}, + Repo: repo, + Emitter: emt, + State: stubCVEState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 30*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Zero(t, repo.Upserts()) + require.Zero(t, repo.Patches()) + require.Empty(t, emt.Events()) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go new file mode 100644 index 00000000..132e5aec --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go @@ -0,0 +1,89 @@ +// ©AngelaMos | 2026 +// epss_client.go + +package cve + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + epssBatchSize = 100 + defaultEPSSBaseURL = "https://api.first.org" + pathEPSS = "/data/v1/epss" + defaultEPSSRate = 500 * time.Millisecond + defaultEPSSBurst = 5 + defaultEPSSBudget = 5 + defaultEPSSBreakerWin = 60 * time.Second +) + +type EPSSClientConfig struct { + BaseURL string +} + +type EPSSClient struct { + hx *httpx.Client +} + +func NewEPSSClient(cfg EPSSClientConfig) *EPSSClient { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultEPSSBaseURL + } + return &EPSSClient{ + hx: httpx.New(httpx.Config{ + Name: "epss", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultEPSSRate), + Burst: defaultEPSSBurst, + ConsecutiveFailureBudget: defaultEPSSBudget, + BreakerTimeout: defaultEPSSBreakerWin, + }), + } +} + +type EPSSScore struct { + CveID string `json:"cve"` + Score float64 `json:"-"` + Percentile float64 `json:"-"` + ScoreRaw string `json:"epss"` + PctileRaw string `json:"percentile"` + Date string `json:"date"` +} + +type EPSSResponse struct { + Data []EPSSScore `json:"data"` +} + +func (c *EPSSClient) LookupBatch( + ctx context.Context, + cveIDs []string, +) (map[string]EPSSScore, error) { + out := map[string]EPSSScore{} + for i := 0; i < len(cveIDs); i += epssBatchSize { + end := i + epssBatchSize + if end > len(cveIDs) { + end = len(cveIDs) + } + chunk := cveIDs[i:end] + q := url.Values{"cve": []string{strings.Join(chunk, ",")}} + var resp EPSSResponse + if err := c.hx.GetJSON(ctx, pathEPSS, q, &resp); err != nil { + return nil, fmt.Errorf("epss batch [%d:%d]: %w", i, end, err) + } + for _, s := range resp.Data { + s.Score, _ = strconv.ParseFloat(s.ScoreRaw, 64) + s.Percentile, _ = strconv.ParseFloat(s.PctileRaw, 64) + out[s.CveID] = s + } + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go new file mode 100644 index 00000000..a87b60b4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// epss_client_test.go + +package cve_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids := r.URL.Query().Get("cve") + require.Contains(t, ids, "CVE-2024-3094") + body, err := os.ReadFile("testdata/epss_batch.json") + require.NoError(t, err) + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) + scores, err := c.LookupBatch( + context.Background(), + []string{"CVE-2024-3094", "CVE-2024-21413"}, + ) + require.NoError(t, err) + require.NotEmpty(t, scores) + + got := scores["CVE-2024-3094"] + require.InDelta(t, 0.84588, got.Score, 0.0001) + require.InDelta(t, 0.99335, got.Percentile, 0.0001) +} + +func TestEPSSClient_ChunksOver100PerRequest(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"data":[]}`)) + }), + ) + defer srv.Close() + + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) + + ids := make([]string, 250) + for i := range ids { + ids[i] = "CVE-2024-X" + } + _, err := c.LookupBatch(context.Background(), ids) + require.NoError(t, err) + require.EqualValues(t, 3, hits.Load()) +} + +func TestEPSSClient_EmptyInputReturnsEmptyMap(t *testing.T) { + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: "http://invalid"}) + out, err := c.LookupBatch(context.Background(), nil) + require.NoError(t, err) + require.Empty(t, out) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go new file mode 100644 index 00000000..bb95451a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go @@ -0,0 +1,165 @@ +// ©AngelaMos | 2026 +// nvd_client.go + +package cve + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultNVDBaseURL = "https://services.nvd.nist.gov" + pathNVDCVE2 = "/rest/json/cves/2.0" + defaultNVDRate = 600 * time.Millisecond + defaultNVDBurst = 5 + defaultNVDBudget = 5 + defaultNVDBreakerWin = 120 * time.Second + nvdTimeFormat = "2006-01-02T15:04:05.000" + nvdAPIKeyHeader = "apiKey" + nvdResultsPerPage = 2000 + nvdMaxPages = 50 +) + +type NVDClientConfig struct { + BaseURL string + APIKey string +} + +type NVDClient struct { + hx *httpx.Client +} + +func NewNVDClient(cfg NVDClientConfig) *NVDClient { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultNVDBaseURL + } + return &NVDClient{ + hx: httpx.New(httpx.Config{ + Name: "nvd", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: nvdAPIKeyHeader, + Rate: rate.Every(defaultNVDRate), + Burst: defaultNVDBurst, + ConsecutiveFailureBudget: defaultNVDBudget, + BreakerTimeout: defaultNVDBreakerWin, + }), + } +} + +type NVDResponse struct { + ResultsPerPage int `json:"resultsPerPage"` + StartIndex int `json:"startIndex"` + TotalResults int `json:"totalResults"` + Vulnerabilities []NVDVulnRoot `json:"vulnerabilities"` +} + +type NVDVulnRoot struct { + CVE NVDCVE `json:"cve"` +} + +type NVDCVE struct { + ID string `json:"id"` + Published NVDTime `json:"published"` + LastModified NVDTime `json:"lastModified"` + Metrics NVDMetrics `json:"metrics"` +} + +type NVDTime struct { + time.Time +} + +func (t *NVDTime) UnmarshalJSON(b []byte) error { + s := string(b) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + s = s[1 : len(s)-1] + } + if s == "" || s == "null" { + return nil + } + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.999", + "2006-01-02T15:04:05", + } { + if parsed, err := time.Parse(layout, s); err == nil { + t.Time = parsed.UTC() + return nil + } + } + return fmt.Errorf("nvd time: unrecognized format %q", s) +} + +type NVDMetrics struct { + CVSSv31 []NVDMetricEntry `json:"cvssMetricV31"` + CVSSv30 []NVDMetricEntry `json:"cvssMetricV30"` +} + +type NVDMetricEntry struct { + CVSSData NVDCVSSData `json:"cvssData"` +} + +type NVDCVSSData struct { + BaseScore float64 `json:"baseScore"` + BaseSeverity string `json:"baseSeverity"` +} + +// Fetch returns every NVD CVE updated in [start, end], paging through the +// API as needed. NVD caps a single response at 2000 vulns; without +// pagination, big-disclosure days (Patch Tuesday) silently drop everything +// past the first page. This is what the audit's Finding 4 was about. +func (c *NVDClient) Fetch( + ctx context.Context, + start, end time.Time, +) (NVDResponse, error) { + combined := NVDResponse{} + startIdx := 0 + + for page := 0; page < nvdMaxPages; page++ { + q := url.Values{ + "lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)}, + "lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)}, + "resultsPerPage": []string{strconv.Itoa(nvdResultsPerPage)}, + "startIndex": []string{strconv.Itoa(startIdx)}, + } + var pageResp NVDResponse + if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &pageResp); err != nil { + return NVDResponse{}, fmt.Errorf("nvd fetch page %d: %w", page, err) + } + combined.Vulnerabilities = append( + combined.Vulnerabilities, + pageResp.Vulnerabilities...) + combined.TotalResults = pageResp.TotalResults + + fetched := len(pageResp.Vulnerabilities) + if fetched == 0 { + break + } + startIdx += fetched + if startIdx >= pageResp.TotalResults { + break + } + } + combined.ResultsPerPage = len(combined.Vulnerabilities) + combined.StartIndex = 0 + return combined, nil +} + +func (v NVDVulnRoot) PrimarySeverity() (float64, string) { + if len(v.CVE.Metrics.CVSSv31) > 0 { + return v.CVE.Metrics.CVSSv31[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv31[0].CVSSData.BaseSeverity + } + if len(v.CVE.Metrics.CVSSv30) > 0 { + return v.CVE.Metrics.CVSSv30[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv30[0].CVSSData.BaseSeverity + } + return 0, "" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go new file mode 100644 index 00000000..ff3c3310 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go @@ -0,0 +1,150 @@ +// ©AngelaMos | 2026 +// nvd_client_test.go + +package cve_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func TestNVDClient_FetchSendsAPIKeyAndDecodes(t *testing.T) { + var sawKey, sawStart, sawEnd string + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawKey = r.Header.Get("apiKey") + sawStart = r.URL.Query().Get("lastModStartDate") + sawEnd = r.URL.Query().Get("lastModEndDate") + body, err := os.ReadFile("testdata/nvd_2h_window.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := cve.NewNVDClient( + cve.NVDClientConfig{BaseURL: srv.URL, APIKey: "test-key"}, + ) + + end := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + start := end.Add(-2 * time.Hour) + + resp, err := c.Fetch(context.Background(), start, end) + require.NoError(t, err) + require.NotEmpty(t, resp.Vulnerabilities) + require.Equal(t, "test-key", sawKey) + require.NotEmpty(t, sawStart) + require.NotEmpty(t, sawEnd) +} + +func TestNVDClient_PrimarySeverityFromV31(t *testing.T) { + v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ + ID: "CVE-2026-X", + Metrics: cve.NVDMetrics{ + CVSSv31: []cve.NVDMetricEntry{ + { + CVSSData: cve.NVDCVSSData{ + BaseScore: 9.8, + BaseSeverity: "CRITICAL", + }, + }, + }, + }, + }} + score, sev := v.PrimarySeverity() + require.InDelta(t, 9.8, score, 0.0001) + require.Equal(t, "CRITICAL", sev) +} + +func TestNVDClient_PrimarySeverityFallsBackToV30(t *testing.T) { + v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ + ID: "CVE-2018-X", + Metrics: cve.NVDMetrics{ + CVSSv30: []cve.NVDMetricEntry{ + { + CVSSData: cve.NVDCVSSData{ + BaseScore: 7.5, + BaseSeverity: "HIGH", + }, + }, + }, + }, + }} + score, sev := v.PrimarySeverity() + require.InDelta(t, 7.5, score, 0.0001) + require.Equal(t, "HIGH", sev) +} + +func TestNVDClient_PrimarySeverityZeroWhenMissing(t *testing.T) { + v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ID: "CVE-X"}} + score, sev := v.PrimarySeverity() + require.Zero(t, score) + require.Empty(t, sev) +} + +// TestNVDClient_FetchPaginatesAcrossMultiplePages: NVD caps a single +// response at 2000 vulns. With a window like Patch Tuesday that exceeds +// that, we have to walk startIndex until startIndex >= totalResults. +// Pre-fix the collector silently dropped everything past page 1. +func TestNVDClient_FetchPaginatesAcrossMultiplePages(t *testing.T) { + const total = 4500 + var hits atomic.Int32 + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + startIdx, _ := strconv.Atoi(r.URL.Query().Get("startIndex")) + perPage, _ := strconv.Atoi(r.URL.Query().Get("resultsPerPage")) + if perPage <= 0 { + perPage = 2000 + } + end := startIdx + perPage + if end > total { + end = total + } + vulns := make([]cve.NVDVulnRoot, 0, end-startIdx) + for i := startIdx; i < end; i++ { + vulns = append(vulns, cve.NVDVulnRoot{ + CVE: cve.NVDCVE{ID: fmt.Sprintf("CVE-2026-%05d", i)}, + }) + } + resp := cve.NVDResponse{ + ResultsPerPage: perPage, + StartIndex: startIdx, + TotalResults: total, + Vulnerabilities: vulns, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }), + ) + defer srv.Close() + + c := cve.NewNVDClient(cve.NVDClientConfig{BaseURL: srv.URL}) + end := time.Now().UTC() + start := end.Add(-2 * time.Hour) + + resp, err := c.Fetch(context.Background(), start, end) + require.NoError(t, err) + require.Len(t, resp.Vulnerabilities, total, "every page should be combined") + require.Equal(t, total, resp.TotalResults) + require.GreaterOrEqual( + t, + hits.Load(), + int32(3), + "4500/2000 needs >=3 page calls", + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go new file mode 100644 index 00000000..4f7a9083 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go @@ -0,0 +1,101 @@ +// ©AngelaMos | 2026 +// repo.go + +package cve + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +type Row struct { + CveID string `db:"cve_id"` + Published time.Time `db:"published"` + LastModified time.Time `db:"last_modified"` + Severity string `db:"severity"` + CVSS float64 `db:"cvss_score"` + EPSSScore *float64 `db:"epss_score"` + EPSSPercentile *float64 `db:"epss_percentile"` + InKEV bool `db:"in_kev"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Upsert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO cve_events (cve_id, published, last_modified, severity, cvss_score, payload) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (cve_id) DO UPDATE SET + last_modified = EXCLUDED.last_modified, + severity = EXCLUDED.severity, + cvss_score = EXCLUDED.cvss_score, + payload = EXCLUDED.payload`, + row.CveID, + row.Published, + row.LastModified, + row.Severity, + row.CVSS, + []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("upsert cve %s: %w", row.CveID, err) + } + return nil +} + +func (r *Repo) UpdateEPSS( + ctx context.Context, + cveID string, + score, percentile float64, +) error { + _, err := r.db.ExecContext( + ctx, + `UPDATE cve_events SET epss_score = $2, epss_percentile = $3 WHERE cve_id = $1`, + cveID, + score, + percentile, + ) + if err != nil { + return fmt.Errorf("update epss %s: %w", cveID, err) + } + return nil +} + +func (r *Repo) Get(ctx context.Context, cveID string) (Row, error) { + var row Row + err := r.db.GetContext(ctx, &row, ` + SELECT cve_id, published, last_modified, severity, cvss_score, + epss_score, epss_percentile, in_kev, payload + FROM cve_events WHERE cve_id = $1`, cveID) + if err != nil { + return Row{}, fmt.Errorf("get cve %s: %w", cveID, err) + } + return row, nil +} + +func (r *Repo) RecentByLastModified( + ctx context.Context, + limit int, +) ([]Row, error) { + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT cve_id, published, last_modified, severity, cvss_score, + epss_score, epss_percentile, in_kev, payload + FROM cve_events + ORDER BY last_modified DESC LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent cves: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go new file mode 100644 index 00000000..28c171aa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package cve_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE cve_events ( + cve_id text PRIMARY KEY, + published timestamptz, + last_modified timestamptz NOT NULL, + severity text NOT NULL DEFAULT '', + cvss_score numeric(3,1) NOT NULL DEFAULT 0, + epss_score numeric(5,4), + epss_percentile numeric(5,4), + in_kev boolean NOT NULL DEFAULT false, + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_UpsertThenEPSSUpdate(t *testing.T) { + db := setupDB(t) + repo := cve.NewRepo(db) + ctx := context.Background() + + row := cve.Row{ + CveID: "CVE-2026-0001", + Published: time.Now().UTC().Add(-time.Hour), + LastModified: time.Now().UTC(), + Severity: "CRITICAL", + CVSS: 9.8, + Payload: json.RawMessage(`{"id":"CVE-2026-0001"}`), + } + require.NoError(t, repo.Upsert(ctx, row)) + + require.NoError(t, repo.UpdateEPSS(ctx, "CVE-2026-0001", 0.9512, 0.9988)) + + got, err := repo.Get(ctx, "CVE-2026-0001") + require.NoError(t, err) + require.NotNil(t, got.EPSSScore) + require.InDelta(t, 0.9512, *got.EPSSScore, 0.0001) + require.NotNil(t, got.EPSSPercentile) + require.InDelta(t, 0.9988, *got.EPSSPercentile, 0.0001) + require.Equal(t, "CRITICAL", got.Severity) + require.InDelta(t, 9.8, got.CVSS, 0.0001) +} + +func TestRepo_UpsertOnConflictUpdatesLastModified(t *testing.T) { + db := setupDB(t) + repo := cve.NewRepo(db) + ctx := context.Background() + + first := cve.Row{ + CveID: "CVE-2026-0002", + Published: time.Now().UTC().Add(-2 * time.Hour), + LastModified: time.Now().UTC().Add(-time.Hour), + Severity: "MEDIUM", + CVSS: 5.0, + Payload: json.RawMessage(`{"v":1}`), + } + require.NoError(t, repo.Upsert(ctx, first)) + + updated := first + updated.LastModified = time.Now().UTC() + updated.Severity = "HIGH" + updated.CVSS = 7.5 + updated.Payload = json.RawMessage(`{"v":2}`) + require.NoError(t, repo.Upsert(ctx, updated)) + + got, err := repo.Get(ctx, "CVE-2026-0002") + require.NoError(t, err) + require.Equal(t, "HIGH", got.Severity) + require.InDelta(t, 7.5, got.CVSS, 0.0001) +} + +func TestRepo_RecentByLastModifiedOrdered(t *testing.T) { + db := setupDB(t) + repo := cve.NewRepo(db) + ctx := context.Background() + + now := time.Now().UTC() + for i, name := range []string{"CVE-A", "CVE-B", "CVE-C"} { + require.NoError(t, repo.Upsert(ctx, cve.Row{ + CveID: name, + Published: now.Add(-time.Duration(10-i) * time.Hour), + LastModified: now.Add(-time.Duration(10-i) * time.Hour), + Severity: "LOW", + Payload: json.RawMessage(`{}`), + })) + } + + rows, err := repo.RecentByLastModified(ctx, 3) + require.NoError(t, err) + require.Len(t, rows, 3) + require.Equal(t, "CVE-C", rows[0].CveID) + require.Equal(t, "CVE-B", rows[1].CveID) + require.Equal(t, "CVE-A", rows[2].CveID) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json new file mode 100644 index 00000000..ce667a5f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json @@ -0,0 +1 @@ +{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,"data":[{"cve":"CVE-2024-3094","epss":"0.845880000","percentile":"0.993350000","date":"2026-05-01"},{"cve":"CVE-2024-21413","epss":"0.929920000","percentile":"0.997830000","date":"2026-05-01"}]} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json new file mode 100644 index 00000000..965be798 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json @@ -0,0 +1 @@ +{"resultsPerPage":9,"startIndex":0,"totalResults":9,"format":"NVD_CVE","version":"2.0","timestamp":"2026-05-02T02:39:09.642","vulnerabilities":[{"cve":{"id":"CVE-2025-54236","sourceIdentifier":"psirt@adobe.com","published":"2025-09-09T14:15:46.563","lastModified":"2026-04-22T19:00:02.080","vulnStatus":"Analyzed","cveTags":[],"descriptions":[{"lang":"en","value":"Adobe Commerce versions 2.4.9-alpha2, 2.4.8-p2, 2.4.7-p7, 2.4.6-p12, 2.4.5-p14, 2.4.4-p15 and earlier are affected by an Improper Input Validation vulnerability. A successful attacker can abuse this to achieve session takeover, increasing the confidentiality, and integrity impact to high. Exploitation of this issue does not require user interaction."}],"metrics":{"cvssMetricV31":[{"source":"psirt@adobe.com","type":"Secondary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N","baseScore":9.1,"baseSeverity":"CRITICAL","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"NONE","userInteraction":"NONE","scope":"UNCHANGED","confidentialityImpact":"HIGH","integrityImpact":"HIGH","availabilityImpact":"NONE"},"exploitabilityScore":3.9,"impactScore":5.2}]},"cisaExploitAdd":"2025-10-24","cisaActionDue":"2025-11-14","cisaRequiredAction":"Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.","cisaVulnerabilityName":"Adobe Commerce and?Magento Improper Input Validation Vulnerability","weaknesses":[{"source":"psirt@adobe.com","type":"Secondary","description":[{"lang":"en","value":"CWE-20"}]}],"configurations":[{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:-:*:*:*:*:*:*","matchCriteriaId":"D258D9EF-94FB-41F0-A7A5-7F66FA7A0055"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p1:*:*:*:*:*:*","matchCriteriaId":"4E5CF6F0-2388-4D3F-8FE1-43B8AF148564"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p10:*:*:*:*:*:*","matchCriteriaId":"1C3D7164-1C5F-40BC-9EEC-B0E00CD45808"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p11:*:*:*:*:*:*","matchCriteriaId":"68AAE162-5957-42AF-BE20-40F341837FAC"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p12:*:*:*:*:*:*","matchCriteriaId":"D9D01159-3309-4F6B-93B0-2D89DDD33DEE"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p13:*:*:*:*:*:*","matchCriteriaId":"91736E79-D8E7-4AF2-8E01-A7B4EB8AD6F4"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p14:*:*:*:*:*:*","matchCriteriaId":"8412C043-64E7-4DFF-A303-13A6FE113BFB"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p15:*:*:*:*:*:*","matchCriteriaId":"BBDA2BCF-E784-4CF3-B30D-6FF5BEE2055F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p2:*:*:*:*:*:*","matchCriteriaId":"D6D6F1A7-ABB5-4EDC-9EA8-98B74518847A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p3:*:*:*:*:*:*","matchCriteriaId":"CFEBDDF2-6443-4482-83B2-3CD272CF599F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p4:*:*:*:*:*:*","matchCriteriaId":"6661093F-8D22-450F-BC6C-A8894A52E6A9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p5:*:*:*:*:*:*","matchCriteriaId":"2515DA6D-2E74-4A05-BD29-FEEF3322BCB6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p6:*:*:*:*:*:*","matchCriteriaId":"69A1F1F7-E53C-40F3-B3D9-DC011FC353BF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p7:*:*:*:*:*:*","matchCriteriaId":"6A56E96C-6CE5-442C-AA88-F0059B02B5E7"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p8:*:*:*:*:*:*","matchCriteriaId":"8867F510-201C-4199-8554-53DE156CE669"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p9:*:*:*:*:*:*","matchCriteriaId":"23988132-DD4E-4968-B6B8-954122F76081"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:-:*:*:*:*:*:*","matchCriteriaId":"9B07F7B2-E915-4EFF-8FFC-91143CEF082E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p1:*:*:*:*:*:*","matchCriteriaId":"7F5E9DB6-1386-4274-8270-2FE0F0CAF7FD"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p10:*:*:*:*:*:*","matchCriteriaId":"5764CC97-C866-415D-A3A1-5B5B9E1C06A6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p11:*:*:*:*:*:*","matchCriteriaId":"E82D10D8-2894-4E5B-B47B-F00964DD5CDE"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p12:*:*:*:*:*:*","matchCriteriaId":"B044F2D9-E888-4852-8A40-DCE688860ED3"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p13:*:*:*:*:*:*","matchCriteriaId":"6423C754-36F9-4680-9211-60940ED63E79"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p14:*:*:*:*:*:*","matchCriteriaId":"3472064A-8C79-436B-965A-96834AE8D346"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p2:*:*:*:*:*:*","matchCriteriaId":"8605E4E6-0F7D-42C8-B35B-2349A0BEFC69"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p3:*:*:*:*:*:*","matchCriteriaId":"B6318F97-E59A-4425-8DC7-045C78A644F8"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p4:*:*:*:*:*:*","matchCriteriaId":"324A573E-DBC8-42A0-8CB8-EDD8FBAB7115"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p5:*:*:*:*:*:*","matchCriteriaId":"54151A00-CFB8-4E6A-8E74-497CB67BF7E2"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p6:*:*:*:*:*:*","matchCriteriaId":"6DF0E74D-9293-4209-97D1-A3BA13C3DDE9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p7:*:*:*:*:*:*","matchCriteriaId":"8922D646-1A97-47ED-91C6-5A426781C98A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p8:*:*:*:*:*:*","matchCriteriaId":"952787C6-9BF1-49FB-9824-1236678E1902"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p9:*:*:*:*:*:*","matchCriteriaId":"898A8679-3C46-4718-9EDF-583ADDFCF2EC"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:-:*:*:*:*:*:*","matchCriteriaId":"7C7AFBB1-F9C9-4BDE-BCEF-94C9F0AC6798"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p1:*:*:*:*:*:*","matchCriteriaId":"D6086841-C175-46A1-8414-71C6163A0E7A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p10:*:*:*:*:*:*","matchCriteriaId":"E57889CC-3E90-46AF-9CD6-3328DD501AD1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p11:*:*:*:*:*:*","matchCriteriaId":"47A86566-DE38-4032-947D-B6181F0BC120"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p12:*:*:*:*:*:*","matchCriteriaId":"B7D1D684-CE7E-4D6D-95B5-1F86A8DB6C66"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p2:*:*:*:*:*:*","matchCriteriaId":"D2E0DDD1-0F4A-4F96-B25D-40A39A1A535A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p3:*:*:*:*:*:*","matchCriteriaId":"A576B1B5-73A2-431E-998F-7E5458B51D6A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p4:*:*:*:*:*:*","matchCriteriaId":"0E05F4AC-2A28-47E3-96DE-0E31AF73CD43"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p5:*:*:*:*:*:*","matchCriteriaId":"3A9A62EE-1649-4815-8EC9-7AEF7949EB2F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p6:*:*:*:*:*:*","matchCriteriaId":"E58690F9-FA9C-42A0-B4CD-91FD1197A53E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p7:*:*:*:*:*:*","matchCriteriaId":"77D01D8B-1FBC-43CA-90F9-C89D9B4D18F0"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p8:*:*:*:*:*:*","matchCriteriaId":"8B83729E-80AF-47CE-A70C-32BF83024A40"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p9:*:*:*:*:*:*","matchCriteriaId":"73D22D42-646D-4955-A6F9-9B7BA63DC0A9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:-:*:*:*:*:*:*","matchCriteriaId":"B5D04853-0C2F-47DD-A939-3A8F6E22CB7D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:b1:*:*:*:*:*:*","matchCriteriaId":"6EBB0608-034B-4F07-A59B-9E6A989BA260"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:b2:*:*:*:*:*:*","matchCriteriaId":"B3BF9B08-84E3-4974-9DEB-F4285995D796"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:beta3:*:*:*:*:*:*","matchCriteriaId":"7771BEDB-05E2-430E-B2A2-E2F7574B7114"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p1:*:*:*:*:*:*","matchCriteriaId":"2E05341A-C70C-4B3D-AF30-9520D6B97D30"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p2:*:*:*:*:*:*","matchCriteriaId":"4D98B52E-3B59-4327-AC7E-DDBB0ADA08F6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p3:*:*:*:*:*:*","matchCriteriaId":"95026AA9-A28B-4D94-BD77-7628429EBA30"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p4:*:*:*:*:*:*","matchCriteriaId":"83FD1220-7D46-42B2-8110-30A934144572"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p5:*:*:*:*:*:*","matchCriteriaId":"3F1439CE-8A3B-414A-B974-559209FF480C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p6:*:*:*:*:*:*","matchCriteriaId":"13726DEE-FFCB-447B-9FFF-136F132F2C4C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p7:*:*:*:*:*:*","matchCriteriaId":"1A9443CE-AE1F-4D66-9C88-5E2E3FD28EE6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:-:*:*:*:*:*:*","matchCriteriaId":"1EE12F4B-5607-4790-A29B-EE23383BCC1A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:beta1:*:*:*:*:*:*","matchCriteriaId":"6D05A958-9749-486A-A149-C21647CDCADF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:beta2:*:*:*:*:*:*","matchCriteriaId":"C9E12B43-AD3E-48A2-9042-5586186CA3BE"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:p1:*:*:*:*:*:*","matchCriteriaId":"C267AF14-7BA8-4D1F-BCD9-BE3ED0DA3D25"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:p2:*:*:*:*:*:*","matchCriteriaId":"B4947C63-CFD9-437B-A09E-A197DCE40095"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.9:alpha1:*:*:*:*:*:*","matchCriteriaId":"37F32F70-7B2A-4BAB-B3F0-AFF5C04CCDED"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.9:alpha2:*:*:*:*:*:*","matchCriteriaId":"8B6B7609-6A8E-4154-BC05-2A9099909684"}]}]},{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:-:*:*:*:*:*:*","matchCriteriaId":"C4667AA3-4CC9-41C0-8E0C-19B0FCE1CF79"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p1:*:*:*:*:*:*","matchCriteriaId":"E396FB4F-B20A-4BF9-8FBD-014A0F197F08"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p10:*:*:*:*:*:*","matchCriteriaId":"2ADE32D1-2845-4030-BE1F-ECE28189D0F9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p11:*:*:*:*:*:*","matchCriteriaId":"F2E771C9-86C4-455C-98D4-6F4FE7A9A822"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p12:*:*:*:*:*:*","matchCriteriaId":"491AB715-F62A-46DB-A56E-055CF7CB7BEF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p13:*:*:*:*:*:*","matchCriteriaId":"6FE364A8-4780-426F-9E8A-284A31FE2623"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p14:*:*:*:*:*:*","matchCriteriaId":"F9258027-8A6A-4C6A-BC6F-349B6E03D828"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p15:*:*:*:*:*:*","matchCriteriaId":"934C52C7-8751-481E-BAA7-F631C4E31F32"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p2:*:*:*:*:*:*","matchCriteriaId":"5677B7E2-FA07-4536-96A9-2C64BEFD3751"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p3:*:*:*:*:*:*","matchCriteriaId":"2DCD1522-6E27-474F-9FC6-413409D6AD55"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p4:*:*:*:*:*:*","matchCriteriaId":"B7968FCA-CCFD-4222-8FB8-E6E21107944F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p5:*:*:*:*:*:*","matchCriteriaId":"8C175A1F-7814-4C51-A7B7-AD5140F0688F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p6:*:*:*:*:*:*","matchCriteriaId":"E66CBFB3-40C3-474A-A3A3-12135F610814"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p7:*:*:*:*:*:*","matchCriteriaId":"F51DFA17-1875-41A9-B141-D89BB6238B3F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p8:*:*:*:*:*:*","matchCriteriaId":"5A4D10EF-9137-4DF5-A5DD-97907E8B4C02"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p9:*:*:*:*:*:*","matchCriteriaId":"5CD0DC76-7181-4954-A59E-AB7BB47D0576"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:-:*:*:*:*:*:*","matchCriteriaId":"1C90C433-6655-4038-9AB3-0304C1AFF360"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p1:*:*:*:*:*:*","matchCriteriaId":"374E7EDD-512A-4633-A136-01A656935334"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p10:*:*:*:*:*:*","matchCriteriaId":"89BAB227-03E6-4776-ADE4-9D9CB666EFD9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p11:*:*:*:*:*:*","matchCriteriaId":"0E5ACABA-D6D6-4F29-A9DD-5A04A44ABE64"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p12:*:*:*:*:*:*","matchCriteriaId":"FA80AFCE-2663-46C0-AEC0-C16C8E675E6A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p13:*:*:*:*:*:*","matchCriteriaId":"EB9955CA-7E7B-40D3-A85D-58BB0D9AC897"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p14:*:*:*:*:*:*","matchCriteriaId":"5D0A17AC-D433-47C2-A1AC-88291DCCECCD"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p2:*:*:*:*:*:*","matchCriteriaId":"0E9D364A-C858-4160-8B8B-33ECF94796D9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p3:*:*:*:*:*:*","matchCriteriaId":"61559E50-581E-40FF-9FD4-10192ECFCD04"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p4:*:*:*:*:*:*","matchCriteriaId":"DE3BFB41-5633-4167-B1EA-9E958BCE9DC2"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p5:*:*:*:*:*:*","matchCriteriaId":"F2C525D2-837D-486A-8B38-5634AE2ECE2B"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p6:*:*:*:*:*:*","matchCriteriaId":"6F220229-F2DF-4C9D-90A6-8B09F8BE3391"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p7:*:*:*:*:*:*","matchCriteriaId":"63AB9506-3F8E-4C2E-A859-2380431C15A6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p8:*:*:*:*:*:*","matchCriteriaId":"51B76658-EA6B-4AC9-9D9C-374C5308D069"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p9:*:*:*:*:*:*","matchCriteriaId":"6E94B136-7A2C-47F0-BCE4-6BB8E776A305"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:-:*:*:*:*:*:*","matchCriteriaId":"15C638A8-EFE0-47DB-B1F9-34093AF0FC17"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p1:*:*:*:*:*:*","matchCriteriaId":"CB863404-A9D7-4692-AB43-08945E669928"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p2:*:*:*:*:*:*","matchCriteriaId":"D8CFA8F4-D57D-4D0F-88D5-00A72E3AD8DA"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p3:*:*:*:*:*:*","matchCriteriaId":"A21F608C-C356-47B8-8FBB-DB28BABFC4C6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p4:*:*:*:*:*:*","matchCriteriaId":"E14195F1-5016-46BE-A614-6FB4E312FC93"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p5:*:*:*:*:*:*","matchCriteriaId":"9C360EA8-B18F-4327-90EF-7EED2892BE4F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p6:*:*:*:*:*:*","matchCriteriaId":"500E3A54-D7C7-4887-9EA6-7DF85389A831"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p7:*:*:*:*:*:*","matchCriteriaId":"ED6FFC1D-E921-4FF7-9928-015630613FE1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.2:-:*:*:*:*:*:*","matchCriteriaId":"D855D141-7876-4F5A-91BE-6350DD379879"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.2:p1:*:*:*:*:*:*","matchCriteriaId":"79CBDF59-EB84-44D3-81CF-5CBF943B411E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.2:p2:*:*:*:*:*:*","matchCriteriaId":"2117B163-D88E-4EB4-AEA7-F27FB732BD48"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.3:alpha1:*:*:*:*:*:*","matchCriteriaId":"508EE0EF-D54A-4834-84AB-FFC62040FDAB"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.3:alpha2:*:*:*:*:*:*","matchCriteriaId":"4D7C6592-33B0-4586-8178-E8F4EB837B7F"}]}]},{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:-:*:*:open_source:*:*:*","matchCriteriaId":"7A41C717-4B9F-4972-ABA3-2294EEC20F3E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p1:*:*:open_source:*:*:*","matchCriteriaId":"3FA80BBC-2DF2-46E1-84CE-8A899415114E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p10:*:*:open_source:*:*:*","matchCriteriaId":"783E4AF1-52F3-446B-B003-8079EDA78CBF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p11:*:*:open_source:*:*:*","matchCriteriaId":"08B7898F-E25A-4D16-A007-6D4543E80C58"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p12:*:*:open_source:*:*:*","matchCriteriaId":"313CB0C1-2E8C-46AC-B72B-AFA9E0A6E064"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p13:*:*:open_source:*:*:*","matchCriteriaId":"E99C1F27-68C9-481F-B01D-8B58B0AFB437"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p14:*:*:open_source:*:*:*","matchCriteriaId":"D4A3F4C7-8784-43BD-A11B-E66872DD8812"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p2:*:*:open_source:*:*:*","matchCriteriaId":"510B1840-AE77-4BDD-9C09-26C64CC8FC81"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p3:*:*:open_source:*:*:*","matchCriteriaId":"FA1EDF58-8384-48C4-A584-54D24F6F7973"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p4:*:*:open_source:*:*:*","matchCriteriaId":"9D2D9715-3A6B-4BE0-B1C5-8D19A683A083"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p5:*:*:open_source:*:*:*","matchCriteriaId":"1C99B578-5DD6-476D-BB75-4DCAD7F79535"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p6:*:*:open_source:*:*:*","matchCriteriaId":"7C1B2897-79A5-4A5B-9137-7A4B6B85AA84"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p7:*:*:open_source:*:*:*","matchCriteriaId":"B9E8299D-FA97-483A-8E1B-BA7B869E467D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p8:*:*:open_source:*:*:*","matchCriteriaId":"9A1B92EC-E83A-43B3-8F14-5C1A52B579B1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p9:*:*:open_source:*:*:*","matchCriteriaId":"E5F2B6F1-AE8F-4AEE-9AB3-080976AE48B7"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:-:*:*:open_source:*:*:*","matchCriteriaId":"789BD987-9DAD-4EAE-93DE-0E267D54F124"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p1:*:*:open_source:*:*:*","matchCriteriaId":"A3F113C0-00C5-4BC2-B42B-8AE3756252F2"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p10:*:*:open_source:*:*:*","matchCriteriaId":"AE842CC8-7795-4238-B727-0BA2FFFBF62C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p11:*:*:open_source:*:*:*","matchCriteriaId":"AE724531-422D-4ABB-98F5-2C0B1BBEF031"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p12:*:*:open_source:*:*:*","matchCriteriaId":"BB499397-0E40-45B0-A7E9-BEFCC909DD07"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p2:*:*:open_source:*:*:*","matchCriteriaId":"02592D65-2D2C-460A-A970-8A18F9B156ED"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p3:*:*:open_source:*:*:*","matchCriteriaId":"457B89CF-C75E-4ED6-8603-9C52BA462A9E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p4:*:*:open_source:*:*:*","matchCriteriaId":"A572A2DC-2DAB-4ABE-8FC2-5AF2340C826F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p5:*:*:open_source:*:*:*","matchCriteriaId":"2A2DD9C6-BAF5-4DF5-9C14-3478923B2019"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p6:*:*:open_source:*:*:*","matchCriteriaId":"BA9CFC70-24CF-4DFA-AEF9-9B5A9DAF837D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p7:*:*:open_source:*:*:*","matchCriteriaId":"2AA0B806-ABB8-4C18-9F9C-8291BE208F52"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p8:*:*:open_source:*:*:*","matchCriteriaId":"AA9D4DAB-7567-48D7-BE60-2A10B35CFF27"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p9:*:*:open_source:*:*:*","matchCriteriaId":"A91E797D-63F6-4DE8-869C-AF0133DC6C03"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:-:*:*:open_source:*:*:*","matchCriteriaId":"0E06FE04-8844-4409-92D9-4972B47C921B"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:b1:*:*:open_source:*:*:*","matchCriteriaId":"99C620F3-40ED-4D7F-B6A1-205E948FD6F5"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:b2:*:*:open_source:*:*:*","matchCriteriaId":"FBCFE5FB-FAB7-4BF0-90AE-79F9590FD872"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:beta3:*:*:open_source:*:*:*","matchCriteriaId":"7EB4B9C5-513C-4039-8087-5E8880894318"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p1:*:*:open_source:*:*:*","matchCriteriaId":"9C77154A-DBFE-48C3-A274-03075A0DB040"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p2:*:*:open_source:*:*:*","matchCriteriaId":"F5AAC414-623C-444F-9BD5-EE0ACE2B2246"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p3:*:*:open_source:*:*:*","matchCriteriaId":"8292888D-B0B0-4DF3-8719-EA4CDCAB39D1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p4:*:*:open_source:*:*:*","matchCriteriaId":"9830E074-FDCF-41E9-98C7-10C20424EF4C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p5:*:*:open_source:*:*:*","matchCriteriaId":"9D0C8648-B39E-47C7-AA5C-3AFED22F8D40"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p6:*:*:open_source:*:*:*","matchCriteriaId":"082F8B60-ECC5-4C55-BBFE-A0C8A3E95590"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p7:*:*:open_source:*:*:*","matchCriteriaId":"A7B83AD4-3134-414A-80E3-106C3C0F975A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:-:*:*:open_source:*:*:*","matchCriteriaId":"00E8284F-10CD-449C-AEF1-688B8287292F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:beta1:*:*:open_source:*:*:*","matchCriteriaId":"59C10C74-FDB1-46EC-8F41-F3AC24AEFB7D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:beta2:*:*:open_source:*:*:*","matchCriteriaId":"2957B390-52C5-48D7-A6D7-709BC76B9C69"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:p1:*:*:open_source:*:*:*","matchCriteriaId":"524F64B6-F7F7-4926-884F-E9448636007C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:p2:*:*:open_source:*:*:*","matchCriteriaId":"9F56F919-69B6-4A77-B8CE-F13409542F14"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.9:alpha1:*:*:open_source:*:*:*","matchCriteriaId":"E34849F7-54EE-4E4C-9184-3DE9C30E12AA"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.9:alpha2:*:*:open_source:*:*:*","matchCriteriaId":"4E21DFF0-9F15-44D2-B78A-097BF3ACD752"}]}]}],"references":[{"url":"https://helpx.adobe.com/security/products/magento/apsb25-88.html","source":"psirt@adobe.com","tags":["Vendor Advisory"]},{"url":"https://experienceleague.adobe.com/en/docs/experience-cloud-kcs/kbarticles/ka-27397","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","tags":["Vendor Advisory"]},{"url":"https://nullsecurityx.codes/cve-2025-54236-sessionreaper-unauthenticated-rce-in-magento","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","tags":["Broken Link","Exploit","Third Party Advisory"]},{"url":"https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2025-54236","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","tags":["US Government Resource"]}]}},{"cve":{"id":"CVE-2026-39804","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:16.853","lastModified":"2026-05-02T02:16:00.013","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via memory exhaustion when WebSocket permessage-deflate compression is enabled.\n\n'Elixir.Bandit.WebSocket.PerMessageDeflate':inflate/2 in lib/bandit/websocket/permessage_deflate.ex calls :zlib.inflate/2 with no output-size cap, then materializes the entire decompressed payload as a single binary via IO.iodata_to_binary/1. The websocket_options.max_frame_size option only bounds the on-the-wire (compressed) frame size, not the decompressed output. A high-ratio compressed frame (e.g. uniform data at ~1024:1 ratio) can stay well under any wire-size limit while forcing GiB-scale heap allocations in the connection process before any application code runs.\n\nAn unauthenticated attacker who can open a WebSocket connection can send a single such frame to exhaust the BEAM node's memory and trigger an OOM kill.\n\nThis vulnerability requires both Bandit's server-level websocket_options.compress and the per-upgrade compress: true option passed to WebSockAdapter.upgrade/4 to be enabled. Stock Phoenix and LiveView applications are not affected as they default to compress: false.\n\nThis issue affects bandit: from 0.5.9 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":8.2,"baseSeverity":"HIGH","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"PRESENT","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"NONE","vulnAvailabilityImpact":"HIGH","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-770"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-39804.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/8156921a51e684a951221da7bc30a70a022f722e","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-frh3-6pv6-rc8j","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-39804","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-frh3-6pv6-rc8j","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-39805","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.037","lastModified":"2026-05-02T02:16:00.180","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Inconsistent Interpretation of HTTP Requests vulnerability in mtrudel bandit allows HTTP request smuggling via duplicate Content-Length headers.\n\n'Elixir.Bandit.Headers':get_content_length/1 in lib/bandit/headers.ex uses List.keyfind/3, which returns only the first matching header. When a request contains two Content-Length headers with different values, Bandit silently accepts it, uses the first value to read the body, and dispatches the remaining bytes as a second pipelined request on the same keep-alive connection. RFC 9112 §6.3 requires recipients to treat this as an unrecoverable framing error.\n\nWhen Bandit sits behind a proxy that picks the last Content-Length value and forwards the request rather than rejecting it, an unauthenticated attacker can smuggle requests past edge WAF rules, path-based ACLs, rate limiting, and audit logging.\n\nThis issue affects bandit: before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":6.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"PRESENT","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"LOW","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"NONE","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-444"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-39805.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/f2ca636eb6df385219957e8934e9fc6efa1630d1","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-c67r-gc9j-2qf7","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-39805","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-c67r-gc9j-2qf7","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-39807","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.180","lastModified":"2026-05-02T02:16:00.320","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Reliance on Untrusted Inputs in a Security Decision vulnerability in mtrudel bandit allows unauthenticated transport-state spoofing on plaintext HTTP connections.\n\n'Elixir.Bandit.Pipeline':determine_scheme/2 in lib/bandit/pipeline.ex returns the client-supplied URI scheme verbatim, ignoring the transport's secure? flag. HTTP/1.1 absolute-form request targets (e.g. GET https://victim/path HTTP/1.1) and the HTTP/2 :scheme pseudo-header are both attacker-controlled strings that flow through this function. Over a plaintext TCP connection, a client can declare https and Bandit will set conn.scheme = :https even though no TLS was negotiated.\n\nDownstream Plug consumers that branch on conn.scheme are silently misled: Plug.SSL's already-secure branch skips its HTTP→HTTPS redirect, cookies emitted with secure: true are sent over plaintext, audit logs record requests as having arrived over HTTPS, and CSRF/SameSite gating may make incorrect decisions.\n\nThis issue affects bandit: from 1.0.0 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":6.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"PRESENT","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"LOW","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"NONE","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-807"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-39807.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/45feea20dea8af7ffd7245271107b695c040e667","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-375f-4r2h-f99j","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-39807","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-375f-4r2h-f99j","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-42786","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.347","lastModified":"2026-05-02T02:16:00.467","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via memory exhaustion.\n\nThe fragment reassembly path in 'Elixir.Bandit.WebSocket.Connection':handle_frame/3 in lib/bandit/websocket/connection.ex appends every incoming Continuation{fin: false} frame's payload to a per-connection iolist with no cumulative size cap. The existing max_frame_size option only bounds individual frames; a peer that streams an unbounded number of continuation frames without ever setting fin=1 grows BEAM heap linearly until the OS or a supervisor kills the process.\n\nBecause the accumulation happens before WebSock.handle_in/2 is called, the application has no opportunity to interpose a size check. Phoenix Channels and LiveView both run over WebSock on Bandit, so a stock Phoenix application exposes this surface as soon as it accepts socket connections.\n\nThis issue affects bandit: from 0.5.0 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":8.7,"baseSeverity":"HIGH","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"NONE","vulnAvailabilityImpact":"HIGH","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-770"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-42786.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/21612c7c7b1ce43eccd36d3af3a2299d23513667","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-pf94-94m9-536p","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-42786","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-pf94-94m9-536p","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-42788","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.500","lastModified":"2026-05-02T02:16:00.610","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated memory exhaustion via oversized HTTP/2 frames.\n\n'Elixir.Bandit.HTTP2.Frame':deserialize/2 in lib/bandit/http2/frame.ex checks the SETTINGS_MAX_FRAME_SIZE limit only after pattern-matching payload::binary-size(length), which requires the entire frame body to be present in memory before either the accept or reject clause can fire. A peer that announces a frame length up to the 24-bit maximum (~16 MiB) causes the server to buffer that entire body before the size guard is evaluated, regardless of the max_frame_size negotiated during the HTTP/2 handshake (default 16 KiB per RFC 9113).\n\nAn unauthenticated attacker holding many concurrent connections can force the server to buffer far more memory than the negotiated frame size limit should permit, leading to memory pressure and potential denial of service.\n\nThis issue affects bandit: from 0.3.6 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":6.9,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"NONE","vulnAvailabilityImpact":"LOW","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-770"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-42788.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/1e8e55966da9129016b73d32f0e1df4630e3b463","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-q6v9-r226-v65f","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-42788","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-q6v9-r226-v65f","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-7596","sourceIdentifier":"cna@vuldb.com","published":"2026-05-01T21:16:18.300","lastModified":"2026-05-02T02:16:00.947","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"A vulnerability has been found in nextlevelbuilder ui-ux-pro-max-skill up to 2.5.0. Affected by this issue is the function data.get of the file .claude/skills/design-system/scripts/generate-slide.py of the component Slide Generator. Such manipulation leads to cross site scripting. The attack may be performed from remote. The exploit has been disclosed to the public and may be used. The project was informed of the problem early through a pull request but has not reacted yet."}],"metrics":{"cvssMetricV40":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":2.1,"baseSeverity":"LOW","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"NONE","userInteraction":"PASSIVE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"NONE","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"PROOF_OF_CONCEPT","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}],"cvssMetricV31":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N","baseScore":4.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"NONE","userInteraction":"REQUIRED","scope":"UNCHANGED","confidentialityImpact":"NONE","integrityImpact":"LOW","availabilityImpact":"NONE"},"exploitabilityScore":2.8,"impactScore":1.4}],"cvssMetricV2":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"2.0","vectorString":"AV:N/AC:L/Au:N/C:N/I:P/A:N","baseScore":5.0,"accessVector":"NETWORK","accessComplexity":"LOW","authentication":"NONE","confidentialityImpact":"NONE","integrityImpact":"PARTIAL","availabilityImpact":"NONE"},"baseSeverity":"MEDIUM","exploitabilityScore":10.0,"impactScore":2.9,"acInsufInfo":false,"obtainAllPrivilege":false,"obtainUserPrivilege":false,"obtainOtherPrivilege":false,"userInteractionRequired":false}]},"weaknesses":[{"source":"cna@vuldb.com","type":"Secondary","description":[{"lang":"en","value":"CWE-79"},{"lang":"en","value":"CWE-94"}]}],"references":[{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/","source":"cna@vuldb.com"},{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/issues/247","source":"cna@vuldb.com"},{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/pull/274","source":"cna@vuldb.com"},{"url":"https://vuldb.com/submit/805510","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360549","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360549/cti","source":"cna@vuldb.com"},{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/issues/247","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-7600","sourceIdentifier":"cna@vuldb.com","published":"2026-05-02T01:16:00.903","lastModified":"2026-05-02T01:16:00.903","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"A flaw has been found in ArtMin96 yii2-mcp-server 1.0.2. This impacts the function yii_command_help/yii_execute_command of the file src/index.ts of the component MCP Interface. Executing a manipulation can lead to os command injection. The attack can be executed remotely. The exploit has been published and may be used. The project was informed of the problem early through an issue report but has not responded yet."}],"metrics":{"cvssMetricV40":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":2.1,"baseSeverity":"LOW","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"LOW","userInteraction":"NONE","vulnConfidentialityImpact":"LOW","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"LOW","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"PROOF_OF_CONCEPT","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}],"cvssMetricV31":[{"source":"cna@vuldb.com","type":"Primary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L","baseScore":6.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"LOW","userInteraction":"NONE","scope":"UNCHANGED","confidentialityImpact":"LOW","integrityImpact":"LOW","availabilityImpact":"LOW"},"exploitabilityScore":2.8,"impactScore":3.4}],"cvssMetricV2":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"2.0","vectorString":"AV:N/AC:L/Au:S/C:P/I:P/A:P","baseScore":6.5,"accessVector":"NETWORK","accessComplexity":"LOW","authentication":"SINGLE","confidentialityImpact":"PARTIAL","integrityImpact":"PARTIAL","availabilityImpact":"PARTIAL"},"baseSeverity":"MEDIUM","exploitabilityScore":8.0,"impactScore":6.4,"acInsufInfo":false,"obtainAllPrivilege":false,"obtainUserPrivilege":false,"obtainOtherPrivilege":false,"userInteractionRequired":false}]},"weaknesses":[{"source":"cna@vuldb.com","type":"Primary","description":[{"lang":"en","value":"CWE-77"},{"lang":"en","value":"CWE-78"}]}],"references":[{"url":"https://github.com/ArtMin96/yii2-mcp-server/","source":"cna@vuldb.com"},{"url":"https://github.com/ArtMin96/yii2-mcp-server/issues/3","source":"cna@vuldb.com"},{"url":"https://github.com/BruceJqs/public_exp/issues/29","source":"cna@vuldb.com"},{"url":"https://vuldb.com/submit/805613","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360557","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360557/cti","source":"cna@vuldb.com"}]}},{"cve":{"id":"CVE-2026-43824","sourceIdentifier":"cve@mitre.org","published":"2026-05-02T02:16:00.747","lastModified":"2026-05-02T02:16:00.747","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"In Argo CD 3.2.0 before 3.2.11 and 3.3.0 before 3.3.9, ServerSideDiff allows reading cleartext Kubernetes Secret data."}],"metrics":{"cvssMetricV31":[{"source":"cve@mitre.org","type":"Secondary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","baseScore":7.7,"baseSeverity":"HIGH","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"LOW","userInteraction":"NONE","scope":"CHANGED","confidentialityImpact":"HIGH","integrityImpact":"NONE","availabilityImpact":"NONE"},"exploitabilityScore":3.1,"impactScore":4.0}]},"weaknesses":[{"source":"cve@mitre.org","type":"Primary","description":[{"lang":"en","value":"CWE-212"}]}],"references":[{"url":"https://github.com/argoproj/argo-cd/security/advisories/GHSA-3v3m-wc6v-x4x3","source":"cve@mitre.org"}]}}]} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go new file mode 100644 index 00000000..d2c79d22 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go @@ -0,0 +1,90 @@ +// ©AngelaMos | 2026 +// client.go + +package dshield + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + KindTopPorts = "topports" + KindTopIPs = "topips" + KindDailySummary = "dailysummary" + + defaultTopN = 10 + defaultDShieldRate = 2 * time.Second + defaultDShieldBudget = 5 + defaultBreakerWindow = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string + TopN int +} + +type Client struct { + hx *httpx.Client + topN int +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://isc.sans.edu" + } + if cfg.TopN <= 0 { + cfg.TopN = defaultTopN + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "dshield", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultDShieldRate), + Burst: 1, + ConsecutiveFailureBudget: defaultDShieldBudget, + BreakerTimeout: defaultBreakerWindow, + }), + topN: cfg.TopN, + } +} + +type SnapshotPayload struct { + Kind string + Payload json.RawMessage +} + +func (c *Client) FetchAll(ctx context.Context) ([]SnapshotPayload, error) { + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + + endpoints := []struct { + Kind string + Path string + }{ + { + KindTopPorts, + "/api/topports/records/" + strconv.Itoa( + c.topN, + ) + "/" + yesterday + "?json", + }, + {KindTopIPs, "/api/topips/?json"}, + {KindDailySummary, "/api/dailysummary/" + yesterday + "/?json"}, + } + + out := make([]SnapshotPayload, 0, len(endpoints)) + for _, ep := range endpoints { + var raw json.RawMessage + if err := c.hx.GetJSON(ctx, ep.Path, nil, &raw); err != nil { + return nil, fmt.Errorf("dshield %s: %w", ep.Kind, err) + } + out = append(out, SnapshotPayload{Kind: ep.Kind, Payload: raw}) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go new file mode 100644 index 00000000..326f764c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go @@ -0,0 +1,113 @@ +// ©AngelaMos | 2026 +// client_test.go + +package dshield_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" +) + +func newFixtureServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc( + "/api/topports/", + func(w http.ResponseWriter, r *http.Request) { + require.Contains(t, r.URL.Path, "/api/topports/records/") + body, err := os.ReadFile(filepath.Join("testdata", "topports.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + mux.HandleFunc( + "/api/topips/", + func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile(filepath.Join("testdata", "topips.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + mux.HandleFunc( + "/api/dailysummary/", + func(w http.ResponseWriter, r *http.Request) { + require.True(t, strings.HasPrefix(r.URL.Path, "/api/dailysummary/")) + body, err := os.ReadFile( + filepath.Join("testdata", "dailysummary.json"), + ) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + return httptest.NewServer(mux) +} + +func TestClient_FetchAllReturnsThreeKinds(t *testing.T) { + srv := newFixtureServer(t) + defer srv.Close() + + c := dshield.NewClient(dshield.ClientConfig{BaseURL: srv.URL, TopN: 10}) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + snaps, err := c.FetchAll(ctx) + require.NoError(t, err) + require.Len(t, snaps, 3) + + kinds := map[string]bool{} + for _, s := range snaps { + kinds[s.Kind] = true + require.NotEmpty(t, s.Payload, "kind %s payload empty", s.Kind) + } + require.True(t, kinds[dshield.KindTopPorts]) + require.True(t, kinds[dshield.KindTopIPs]) + require.True(t, kinds[dshield.KindDailySummary]) +} + +func TestClient_FetchAllUsesYesterdayDate(t *testing.T) { + var topPortsPath, dailyPath string + mux := http.NewServeMux() + mux.HandleFunc( + "/api/topports/", + func(w http.ResponseWriter, r *http.Request) { + topPortsPath = r.URL.Path + _, _ = w.Write([]byte(`{}`)) + }, + ) + mux.HandleFunc( + "/api/topips/", + func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + }, + ) + mux.HandleFunc( + "/api/dailysummary/", + func(w http.ResponseWriter, r *http.Request) { + dailyPath = r.URL.Path + _, _ = w.Write([]byte(`[]`)) + }, + ) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := dshield.NewClient(dshield.ClientConfig{BaseURL: srv.URL, TopN: 5}) + _, _ = c.FetchAll(context.Background()) + + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + require.Contains(t, topPortsPath, "/records/5/"+yesterday) + require.Contains(t, dailyPath, "/dailysummary/"+yesterday) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go new file mode 100644 index 00000000..8de10ec1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go @@ -0,0 +1,175 @@ +// ©AngelaMos | 2026 +// collector.go + +package dshield + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "dshield" + defaultInterval = time.Hour +) + +type Fetcher interface { + FetchAll(ctx context.Context) ([]SnapshotPayload, error) +} + +type DShieldEnrichment struct { + Country string + Classification string + Actor string +} + +type Enricher interface { + Lookup(ctx context.Context, ip string) (DShieldEnrichment, error) +} + +type Persister interface { + PutSnapshot( + ctx context.Context, + ts time.Time, + kind string, + payload json.RawMessage, + ) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Persister Persister + Emitter Emitter + State StateRecorder + Enricher Enricher + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + snaps, err := c.cfg.Fetcher.FetchAll(ctx) + if err != nil { + c.logger.Warn("dshield fetch failed", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + now := time.Now().UTC() + tsRaw, err := json.Marshal(now.Format(time.RFC3339Nano)) + if err != nil { + c.logger.Error("dshield marshal ts", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + merged := map[string]json.RawMessage{"ts": tsRaw} + for _, s := range snaps { + payload := s.Payload + if s.Kind == KindTopIPs { + payload = c.enrichSources(ctx, payload) + } + if perr := c.cfg.Persister.PutSnapshot( + ctx, + now, + s.Kind, + payload, + ); perr != nil { + c.logger.Warn("dshield persist failed", "kind", s.Kind, "err", perr) + } + merged[s.Kind] = payload + } + + body, err := json.Marshal(merged) + if err != nil { + c.logger.Error("dshield marshal merged", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicScanFirehose, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(body), + }) + c.cfg.State.RecordSuccess(ctx, Name, 1) +} + +func (c *Collector) enrichSources( + ctx context.Context, + raw json.RawMessage, +) json.RawMessage { + if c.cfg.Enricher == nil { + return raw + } + var srcs []RawSource + if err := json.Unmarshal(raw, &srcs); err != nil { + return raw + } + enriched := make([]EnrichedSource, 0, len(srcs)) + for _, s := range srcs { + e := EnrichedSource{ + Rank: s.Rank, + Source: s.Source, + Reports: s.Reports, + Targets: s.Targets, + } + if info, err := c.cfg.Enricher.Lookup(ctx, s.Source); err == nil { + e.Country = info.Country + e.Classification = info.Classification + e.Actor = info.Actor + } + enriched = append(enriched, e) + } + out, err := json.Marshal(enriched) + if err != nil { + return raw + } + return out +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go new file mode 100644 index 00000000..ae292e28 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go @@ -0,0 +1,194 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package dshield_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + mu sync.Mutex + calls int + err error +} + +func (f *fakeFetcher) FetchAll( + _ context.Context, +) ([]dshield.SnapshotPayload, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.err != nil { + return nil, f.err + } + return []dshield.SnapshotPayload{ + {Kind: dshield.KindTopPorts, Payload: json.RawMessage(`[{"port":22}]`)}, + { + Kind: dshield.KindTopIPs, + Payload: json.RawMessage(`[{"ip":"1.2.3.4"}]`), + }, + { + Kind: dshield.KindDailySummary, + Payload: json.RawMessage(`[{"records":42}]`), + }, + }, nil +} + +func (f *fakeFetcher) Calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +type persistedRow struct { + Kind string + Body json.RawMessage +} + +type fakePersister struct { + mu sync.Mutex + rows []persistedRow +} + +func (p *fakePersister) PutSnapshot( + _ context.Context, + _ time.Time, + kind string, + body json.RawMessage, +) error { + p.mu.Lock() + defer p.mu.Unlock() + p.rows = append(p.rows, persistedRow{Kind: kind, Body: body}) + return nil +} + +func (p *fakePersister) Rows() []persistedRow { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]persistedRow, len(p.rows)) + copy(out, p.rows) + return out +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type recordingState struct { + mu sync.Mutex + successes int + successN int64 + failures int + lastErr string +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + s.successN += n +} + +func (s *recordingState) RecordError(_ context.Context, _, msg string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + s.lastErr = msg +} + +func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) { + ftch := &fakeFetcher{} + prst := &fakePersister{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := dshield.NewCollector(dshield.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Persister: prst, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 70*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, ftch.Calls(), 2) + require.GreaterOrEqual(t, len(prst.Rows()), 6) + + evs := emt.Events() + require.GreaterOrEqual(t, len(evs), 2) + for _, ev := range evs { + require.Equal(t, events.TopicScanFirehose, ev.Topic) + require.Equal(t, dshield.Name, ev.Source) + body, ok := ev.Payload.(json.RawMessage) + require.True(t, ok, "payload must be json.RawMessage") + + var merged map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &merged)) + require.Contains(t, merged, dshield.KindTopPorts) + require.Contains(t, merged, dshield.KindTopIPs) + require.Contains(t, merged, dshield.KindDailySummary) + require.Contains(t, merged, "ts") + } + + require.Positive(t, st.successes) + require.Equal(t, 0, st.failures) +} + +func TestCollector_FetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + prst := &fakePersister{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := dshield.NewCollector(dshield.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Persister: prst, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Empty(t, prst.Rows()) + require.Empty(t, emt.Events()) + require.Positive(t, st.failures) + require.Contains(t, st.lastErr, "upstream 503") +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go new file mode 100644 index 00000000..a4f711af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go @@ -0,0 +1,59 @@ +// ©AngelaMos | 2026 +// repo.go + +package dshield + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +type Snapshot struct { + TS time.Time `db:"ts"` + Kind string `db:"kind"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) PutSnapshot( + ctx context.Context, + ts time.Time, + kind string, + payload json.RawMessage, +) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO dshield_snapshots (ts, kind, payload) + VALUES ($1, $2, $3) + ON CONFLICT (ts, kind) DO UPDATE SET payload = EXCLUDED.payload`, + ts, kind, []byte(payload), + ) + if err != nil { + return fmt.Errorf("put dshield snapshot %s: %w", kind, err) + } + return nil +} + +func (r *Repo) LatestByKind( + ctx context.Context, + kind string, + limit int, +) ([]Snapshot, error) { + var rows []Snapshot + err := r.db.SelectContext(ctx, &rows, ` + SELECT ts, kind, payload FROM dshield_snapshots + WHERE kind = $1 + ORDER BY ts DESC LIMIT $2`, kind, limit) + if err != nil { + return nil, fmt.Errorf("latest dshield by kind: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go new file mode 100644 index 00000000..4e87d9d3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go @@ -0,0 +1,117 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package dshield_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE dshield_snapshots ( + ts timestamptz NOT NULL, + kind text NOT NULL, + payload jsonb NOT NULL, + PRIMARY KEY (ts, kind) + )`) + require.NoError(t, err) + return db +} + +func TestRepo_PutSnapshotAndReadLatest(t *testing.T) { + db := setupDB(t) + repo := dshield.NewRepo(db) + ctx := context.Background() + + ts := time.Now().UTC().Truncate(time.Second) + payload := json.RawMessage(`[{"port":"22","records":1234}]`) + + require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", payload)) + + rows, err := repo.LatestByKind(ctx, "topports", 1) + require.NoError(t, err) + require.Len(t, rows, 1) + require.True(t, ts.Equal(rows[0].TS), "expected %s == %s", ts, rows[0].TS) + require.Equal(t, "topports", rows[0].Kind) + require.JSONEq(t, string(payload), string(rows[0].Payload)) +} + +func TestRepo_PutSnapshotIsIdempotentOnConflict(t *testing.T) { + db := setupDB(t) + repo := dshield.NewRepo(db) + ctx := context.Background() + + ts := time.Now().UTC().Truncate(time.Second) + first := json.RawMessage(`[{"port":"22"}]`) + second := json.RawMessage(`[{"port":"22","records":99}]`) + + require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", first)) + require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", second)) + + rows, err := repo.LatestByKind(ctx, "topports", 1) + require.NoError(t, err) + require.Len(t, rows, 1) + require.JSONEq(t, string(second), string(rows[0].Payload)) +} + +func TestRepo_LatestByKindIsOrderedDesc(t *testing.T) { + db := setupDB(t) + repo := dshield.NewRepo(db) + ctx := context.Background() + + t1 := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second) + t2 := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second) + t3 := time.Now().UTC().Truncate(time.Second) + + require.NoError( + t, + repo.PutSnapshot(ctx, t1, "sources", json.RawMessage(`[{"i":1}]`)), + ) + require.NoError( + t, + repo.PutSnapshot(ctx, t3, "sources", json.RawMessage(`[{"i":3}]`)), + ) + require.NoError( + t, + repo.PutSnapshot(ctx, t2, "sources", json.RawMessage(`[{"i":2}]`)), + ) + + rows, err := repo.LatestByKind(ctx, "sources", 3) + require.NoError(t, err) + require.Len(t, rows, 3) + require.True(t, t3.Equal(rows[0].TS)) + require.True(t, t2.Equal(rows[1].TS)) + require.True(t, t1.Equal(rows[2].TS)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json new file mode 100644 index 00000000..59f2fafc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json @@ -0,0 +1 @@ +[{"date":"2026-05-01","records":17196921,"sources":146555,"targets":871}] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json new file mode 100644 index 00000000..32610be1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json @@ -0,0 +1 @@ +[{"rank":1,"source":"13.94.254.200","reports":319739,"targets":1},{"rank":2,"source":"194.224.249.214","reports":309508,"targets":1},{"rank":3,"source":"52.157.207.201","reports":194780,"targets":1},{"rank":4,"source":"89.248.163.109","reports":96279,"targets":150},{"rank":5,"source":"143.244.186.35","reports":92007,"targets":8},{"rank":6,"source":"18.116.198.191","reports":59907,"targets":1},{"rank":7,"source":"152.89.198.163","reports":41378,"targets":478},{"rank":8,"source":"152.89.198.103","reports":41346,"targets":479},{"rank":9,"source":"152.89.198.187","reports":41332,"targets":477},{"rank":10,"source":"185.122.204.71","reports":41221,"targets":479}] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json new file mode 100644 index 00000000..e63e7484 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json @@ -0,0 +1 @@ +{"0":{"rank":1,"targetport":23,"records":5756817,"targets":316,"sources":52950},"1":{"rank":2,"targetport":80,"records":1151422,"targets":316,"sources":9421},"2":{"rank":3,"targetport":5555,"records":1106744,"targets":298,"sources":3668},"3":{"rank":4,"targetport":8080,"records":978049,"targets":309,"sources":7062},"4":{"rank":5,"targetport":22,"records":514078,"targets":326,"sources":9113},"5":{"rank":6,"targetport":2222,"records":371960,"targets":318,"sources":7388},"6":{"rank":7,"targetport":51223,"records":313702,"targets":15,"sources":1293},"7":{"rank":8,"targetport":8000,"records":241520,"targets":323,"sources":6078},"8":{"rank":9,"targetport":443,"records":231949,"targets":432,"sources":8308},"9":{"rank":10,"targetport":68,"records":215704,"targets":185,"sources":209},"date":"2026-05-01","limit":10} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/types.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/types.go new file mode 100644 index 00000000..5334b7ab --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/types.go @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// types.go + +package dshield + +type RawSource struct { + Rank int `json:"rank"` + Source string `json:"source"` + Reports int `json:"reports"` + Targets int `json:"targets"` +} + +type EnrichedSource struct { + Rank int `json:"rank"` + Source string `json:"source"` + Reports int `json:"reports"` + Targets int `json:"targets"` + Country string `json:"country,omitempty"` + Classification string `json:"classification,omitempty"` + Actor string `json:"actor,omitempty"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go new file mode 100644 index 00000000..cbaab34b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// baseline.go + +package gdelt + +import "math" + +const ( + minStdDev = 1.0 +) + +type Bucket struct { + Score int64 + Count int +} + +type ThemeState struct { + buckets []Bucket + capN int +} + +func NewThemeState(capN int) *ThemeState { + if capN <= 0 { + capN = 96 + } + return &ThemeState{capN: capN} +} + +func (s *ThemeState) Push(b Bucket) { + s.buckets = append(s.buckets, b) + if len(s.buckets) > s.capN { + s.buckets = s.buckets[len(s.buckets)-s.capN:] + } +} + +func (s *ThemeState) Len() int { + return len(s.buckets) +} + +func (s *ThemeState) Stats() (mean, stddev float64) { + if len(s.buckets) == 0 { + return 0, 0 + } + sum := 0.0 + for _, b := range s.buckets { + sum += float64(b.Count) + } + mean = sum / float64(len(s.buckets)) + + variance := 0.0 + for _, b := range s.buckets { + d := float64(b.Count) - mean + variance += d * d + } + variance /= float64(len(s.buckets)) + stddev = math.Sqrt(variance) + return mean, stddev +} + +func (s *ThemeState) ZScore(count int) float64 { + mean, stddev := s.Stats() + if stddev < minStdDev { + return 0 + } + return (float64(count) - mean) / stddev +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go new file mode 100644 index 00000000..c8c33760 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go @@ -0,0 +1,76 @@ +// ©AngelaMos | 2026 +// baseline_test.go + +package gdelt_test + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" +) + +func TestBaseline_EmptyReturnsZeroZ(t *testing.T) { + b := gdelt.NewThemeState(96) + require.InDelta(t, 0.0, b.ZScore(100), 0.0001) +} + +func TestBaseline_StableThenSpikeReturnsHighZ(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push( + gdelt.Bucket{ + Score: now.Add(-time.Duration(i) * 15 * time.Minute). + UnixMilli(), + Count: 100 + (i % 10), + }, + ) + } + z := b.ZScore(500) + require.Greater(t, z, 3.0) +} + +func TestBaseline_StableThenNormalReturnsLowZ(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push( + gdelt.Bucket{ + Score: now.Add(-time.Duration(i) * 15 * time.Minute). + UnixMilli(), + Count: 100 + (i % 10), + }, + ) + } + z := b.ZScore(108) + require.Less(t, z, 3.0) +} + +func TestBaseline_CapacityCapsBuckets(t *testing.T) { + b := gdelt.NewThemeState(10) + for i := 0; i < 200; i++ { + b.Push(gdelt.Bucket{Score: int64(i), Count: i}) + } + require.Equal(t, 10, b.Len()) +} + +func TestBaseline_TinyStdDevDoesNotDivByZero(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push( + gdelt.Bucket{ + Score: now.Add(-time.Duration(i) * 15 * time.Minute). + UnixMilli(), + Count: 100, + }, + ) + } + z := b.ZScore(101) + require.False(t, math.IsInf(z, 0)) + require.False(t, math.IsNaN(z)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go new file mode 100644 index 00000000..496bd12f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go @@ -0,0 +1,119 @@ +// ©AngelaMos | 2026 +// client.go + +package gdelt + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultGDELTBaseURL = "https://api.gdeltproject.org" + pathDoc = "/api/v2/doc/doc" + defaultGDELTRate = 500 * time.Millisecond + defaultGDELTBurst = 2 + defaultGDELTBudget = 5 + defaultGDELTBreaker = 60 * time.Second + + gdeltDateFormat = "20060102T150405Z" +) + +var DefaultThemes = []string{ + "NATURAL_DISASTER", + "ARMEDCONFLICT", + "DISEASE_OUTBREAK", + "ECON_BANKRUPTCY", + "TERROR", +} + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultGDELTBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "gdelt", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultGDELTRate), + Burst: defaultGDELTBurst, + ConsecutiveFailureBudget: defaultGDELTBudget, + BreakerTimeout: defaultGDELTBreaker, + }), + } +} + +type ThemeBucket struct { + Theme string + Time time.Time + Count int +} + +type rawTimeline struct { + Timeline []struct { + Data []struct { + Date string `json:"date"` + Value float64 `json:"value"` + } `json:"data"` + } `json:"timeline"` +} + +func (c *Client) FetchTheme( + ctx context.Context, + theme string, +) ([]ThemeBucket, error) { + q := url.Values{} + q.Set("query", "theme:"+theme) + q.Set("mode", "timelinevol") + q.Set("TIMELINESMOOTH", "5") + q.Set("format", "json") + q.Set("timespan", "1d") + + resp, err := c.hx.Get(ctx, pathDoc, q) + if err != nil { + return nil, fmt.Errorf("fetch gdelt theme %s: %w", theme, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read gdelt theme %s body: %w", theme, err) + } + + var raw rawTimeline + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("decode gdelt theme %s: %w", theme, err) + } + if len(raw.Timeline) == 0 { + return nil, nil + } + out := make([]ThemeBucket, 0, len(raw.Timeline[0].Data)) + for _, d := range raw.Timeline[0].Data { + ts, err := time.Parse(gdeltDateFormat, d.Date) + if err != nil { + continue + } + out = append(out, ThemeBucket{ + Theme: theme, + Time: ts.UTC(), + Count: int(d.Value), + }) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go new file mode 100644 index 00000000..6bcca56a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go @@ -0,0 +1,50 @@ +// ©AngelaMos | 2026 +// client_test.go + +package gdelt_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" +) + +func TestClient_FetchThemeDecodesBuckets(t *testing.T) { + body, err := os.ReadFile("testdata/timelinevol.json") + require.NoError(t, err) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/doc/doc", r.URL.Path) + require.Equal( + t, + "theme:NATURAL_DISASTER", + r.URL.Query().Get("query"), + ) + require.Equal(t, "timelinevol", r.URL.Query().Get("mode")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := gdelt.NewClient(gdelt.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + buckets, err := c.FetchTheme(ctx, "NATURAL_DISASTER") + require.NoError(t, err) + require.GreaterOrEqual(t, len(buckets), 1) + for _, b := range buckets { + require.False(t, b.Time.IsZero()) + require.Positive(t, b.Count) + require.Equal(t, "NATURAL_DISASTER", b.Theme) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go new file mode 100644 index 00000000..d23c1baa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go @@ -0,0 +1,176 @@ +// ©AngelaMos | 2026 +// collector.go + +package gdelt + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "gdelt" + defaultGDELTInterval = 15 * time.Minute + defaultBaselineCap = 96 + zScoreSpikeThreshold = 3.0 + idHashBytes = 16 +) + +type Fetcher interface { + FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error) +} + +type Repository interface { + Insert(ctx context.Context, row SpikeRow) (inserted bool, err error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Themes []string + BaselineCap int + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger + baselines map[string]*ThemeState +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultGDELTInterval + } + if cfg.BaselineCap <= 0 { + cfg.BaselineCap = defaultBaselineCap + } + if len(cfg.Themes) == 0 { + cfg.Themes = DefaultThemes + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + c := &Collector{ + cfg: cfg, + logger: cfg.Logger, + baselines: make(map[string]*ThemeState, len(cfg.Themes)), + } + for _, t := range cfg.Themes { + c.baselines[t] = NewThemeState(cfg.BaselineCap) + } + return c +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + c.tick(ctx) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + hadError := false + emitted := int64(0) + + for _, theme := range c.cfg.Themes { + buckets, err := c.cfg.Fetcher.FetchTheme(ctx, theme) + if err != nil { + c.logger.Warn("gdelt fetch", "theme", theme, "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + continue + } + baseline := c.baselines[theme] + for _, b := range buckets { + z := baseline.ZScore(b.Count) + baseline.Push(Bucket{Score: b.Time.UnixMilli(), Count: b.Count}) + + if z <= zScoreSpikeThreshold { + continue + } + id := spikeID(theme, b.Time) + + payload, perr := json.Marshal(map[string]any{ + "theme": theme, + "time": b.Time, + "count": b.Count, + "zscore": z, + }) + if perr != nil { + c.logger.Warn( + "gdelt marshal payload", + "theme", + theme, + "err", + perr, + ) + continue + } + row := SpikeRow{ + ID: id, + Theme: theme, + OccurredAt: b.Time, + Headline: fmt.Sprintf( + "Theme spike: %s (z=%.2f, count=%d)", + theme, + z, + b.Count, + ), + Payload: payload, + } + inserted, ierr := c.cfg.Repo.Insert(ctx, row) + if ierr != nil { + c.logger.Warn("gdelt insert", "id", id, "err", ierr) + continue + } + if !inserted { + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicGDELTSpike, + Timestamp: b.Time, + Source: Name, + Payload: json.RawMessage(payload), + }) + emitted++ + } + } + + if !hadError { + c.cfg.State.RecordSuccess(ctx, Name, emitted) + } +} + +func spikeID(theme string, t time.Time) string { + h := sha256.Sum256([]byte(theme + "|" + t.UTC().Format(time.RFC3339))) + return hex.EncodeToString(h[:idHashBytes]) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go new file mode 100644 index 00000000..79116887 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go @@ -0,0 +1,208 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package gdelt_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + mu sync.Mutex + calls int + buckets map[string][]gdelt.ThemeBucket + err error +} + +func (f *fakeFetcher) FetchTheme( + _ context.Context, + theme string, +) ([]gdelt.ThemeBucket, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.buckets[theme], nil +} + +type fakeRepo struct { + mu sync.Mutex + inserts []gdelt.SpikeRow + seen map[string]struct{} +} + +func (r *fakeRepo) Insert(_ context.Context, row gdelt.SpikeRow) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.seen == nil { + r.seen = map[string]struct{}{} + } + if _, ok := r.seen[row.ID]; ok { + return false, nil + } + r.seen[row.ID] = struct{}{} + r.inserts = append(r.inserts, row) + return true, nil +} + +func (r *fakeRepo) Inserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.inserts) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ +} + +func TestCollector_NoBaselineNoSpike(t *testing.T) { + now := time.Now().UTC() + buckets := []gdelt.ThemeBucket{ + {Theme: "X", Time: now, Count: 5000}, + } + ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: 20 * time.Millisecond, + Themes: []string{"X"}, + BaselineCap: 8, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal( + t, + 0, + repo.Inserts(), + "first observation has no baseline → cannot detect spike", + ) + require.Equal(t, 0, emt.Count()) + require.Positive(t, st.successes) +} + +func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) { + base := time.Now().UTC().Truncate(15 * time.Minute) + stable := make([]gdelt.ThemeBucket, 0, 10) + for i := 0; i < 10; i++ { + stable = append(stable, gdelt.ThemeBucket{ + Theme: "X", + Time: base.Add(time.Duration(i) * 15 * time.Minute), + Count: 100 + i, + }) + } + spikeBucket := gdelt.ThemeBucket{ + Theme: "X", + Time: base.Add(15 * 15 * time.Minute), + Count: 5000, + } + buckets := append(stable, spikeBucket) + + ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: 20 * time.Millisecond, + Themes: []string{"X"}, + BaselineCap: 8, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 80*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Inserts(), 1, "spike must be inserted") + require.GreaterOrEqual(t, emt.Count(), 1) + for _, ev := range emt.events { + require.Equal(t, events.TopicGDELTSpike, ev.Topic) + } +} + +func TestCollector_FetchErrorsRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: 20 * time.Millisecond, + Themes: []string{"X"}, + BaselineCap: 8, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts()) + require.Positive(t, st.failures) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go new file mode 100644 index 00000000..b7320184 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go @@ -0,0 +1,58 @@ +// ©AngelaMos | 2026 +// repo.go + +package gdelt + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +const ( + sourceGDELTSpike = "gdelt_spike" +) + +type SpikeRow struct { + ID string + Theme string + OccurredAt time.Time + Headline string + Payload json.RawMessage +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Insert(ctx context.Context, row SpikeRow) (bool, error) { + var id string + err := r.db.GetContext( + ctx, + &id, + ` + INSERT INTO world_events (id, source, occurred_at, headline, payload) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING + RETURNING id`, + row.ID, + sourceGDELTSpike, + row.OccurredAt, + row.Headline, + []byte(row.Payload), + ) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("insert gdelt spike %s: %w", row.ID, err) + } + return true, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevol.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevol.json new file mode 100644 index 00000000..6aef353b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevol.json @@ -0,0 +1,16 @@ +{ + "timeline": [ + { + "data": [ + {"date": "20260501T120000Z", "value": 100}, + {"date": "20260501T121500Z", "value": 105}, + {"date": "20260501T123000Z", "value": 102}, + {"date": "20260501T124500Z", "value": 108}, + {"date": "20260501T130000Z", "value": 112}, + {"date": "20260501T131500Z", "value": 98}, + {"date": "20260501T133000Z", "value": 103}, + {"date": "20260501T134500Z", "value": 107} + ] + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go new file mode 100644 index 00000000..4c9c0333 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go @@ -0,0 +1,63 @@ +// ©AngelaMos | 2026 +// collector.go + +package heartbeat + +import ( + "context" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const defaultInterval = 5 * time.Second + +type Emitter interface { + Emit(ev events.Event) +} + +type Config struct { + Interval time.Duration + Emitter Emitter +} + +type Collector struct { + cfg Config +} + +func New(cfg Config) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultInterval + } + return &Collector{cfg: cfg} +} + +func (c *Collector) Name() string { return "heartbeat" } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.emitNow() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.emitNow() + } + } +} + +func (c *Collector) emitNow() { + now := time.Now().UTC() + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: now, + Source: c.Name(), + Payload: map[string]string{ + "ts": now.Format(time.RFC3339Nano), + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go new file mode 100644 index 00000000..1ecf6eba --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go @@ -0,0 +1,83 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package heartbeat_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type collectingEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (c *collectingEmitter) Emit(ev events.Event) { + c.mu.Lock() + defer c.mu.Unlock() + c.events = append(c.events, ev) +} + +func (c *collectingEmitter) Count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.events) +} + +func (c *collectingEmitter) Last() events.Event { + c.mu.Lock() + defer c.mu.Unlock() + return c.events[len(c.events)-1] +} + +func TestCollector_EmitsAtInterval(t *testing.T) { + emitter := &collectingEmitter{} + c := heartbeat.New(heartbeat.Config{ + Interval: 50 * time.Millisecond, + Emitter: emitter, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 200*time.Millisecond, + ) + defer cancel() + + err := c.Run(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + + require.GreaterOrEqual(t, emitter.Count(), 3) + last := emitter.Last() + require.Equal(t, events.TopicHeartbeat, last.Topic) + require.Equal(t, "heartbeat", last.Source) +} + +func TestCollector_EmitsImmediatelyOnStart(t *testing.T) { + emitter := &collectingEmitter{} + c := heartbeat.New(heartbeat.Config{ + Interval: time.Hour, + Emitter: emitter, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + + _ = c.Run(ctx) + require.Equal( + t, + 1, + emitter.Count(), + "should emit one tick immediately on start", + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go new file mode 100644 index 00000000..24a7c998 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go @@ -0,0 +1,118 @@ +// ©AngelaMos | 2026 +// client.go + +package iss + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultISSPositionURL = "https://api.wheretheiss.at" + defaultISSTLEURL = "https://celestrak.org" + pathPosition = "/v1/satellites/25544" + pathTLE = "/NORAD/elements/gp.php" + defaultISSRate = 200 * time.Millisecond + defaultISSBurst = 5 + defaultISSBudget = 5 + defaultISSBreaker = 60 * time.Second +) + +type Position struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude float64 `json:"altitude"` + Velocity float64 `json:"velocity"` + Timestamp int64 `json:"timestamp"` + FetchedAt time.Time `json:"fetched_at"` +} + +type TLE struct { + Line1 string `json:"line1"` + Line2 string `json:"line2"` + FetchedAt time.Time `json:"fetched_at"` +} + +type ClientConfig struct { + PositionBaseURL string + TLEBaseURL string +} + +type Client struct { + pos *httpx.Client + tle *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.PositionBaseURL == "" { + cfg.PositionBaseURL = defaultISSPositionURL + } + if cfg.TLEBaseURL == "" { + cfg.TLEBaseURL = defaultISSTLEURL + } + return &Client{ + pos: httpx.New(httpx.Config{ + Name: "iss-position", + BaseURL: cfg.PositionBaseURL, + Rate: rate.Every(defaultISSRate), + Burst: defaultISSBurst, + ConsecutiveFailureBudget: defaultISSBudget, + BreakerTimeout: defaultISSBreaker, + }), + tle: httpx.New(httpx.Config{ + Name: "iss-tle", + BaseURL: cfg.TLEBaseURL, + Rate: rate.Every(time.Second), + Burst: 1, + ConsecutiveFailureBudget: defaultISSBudget, + BreakerTimeout: defaultISSBreaker, + }), + } +} + +func (c *Client) FetchPosition(ctx context.Context) (Position, error) { + var p Position + if err := c.pos.GetJSON(ctx, pathPosition, nil, &p); err != nil { + return Position{}, fmt.Errorf("fetch iss position: %w", err) + } + p.FetchedAt = time.Now().UTC() + return p, nil +} + +func (c *Client) FetchTLE(ctx context.Context) (TLE, error) { + q := map[string][]string{ + "CATNR": {"25544"}, + "FORMAT": {"TLE"}, + } + resp, err := c.tle.Get(ctx, pathTLE, q) + if err != nil { + return TLE{}, fmt.Errorf("fetch iss tle: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return TLE{}, fmt.Errorf("read iss tle body: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + if len(lines) < 3 { + return TLE{}, fmt.Errorf( + "iss tle: expected 3 lines, got %d", + len(lines), + ) + } + return TLE{ + Line1: strings.TrimRight(lines[1], " \r"), + Line2: strings.TrimRight(lines[2], " \r"), + FetchedAt: time.Now().UTC(), + }, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go new file mode 100644 index 00000000..3c52abed --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go @@ -0,0 +1,119 @@ +// ©AngelaMos | 2026 +// collector.go + +package iss + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "iss" + defaultPositionInterval = 10 * time.Second + defaultTLEInterval = 24 * time.Hour +) + +type Fetcher interface { + FetchPosition(ctx context.Context) (Position, error) + FetchTLE(ctx context.Context) (TLE, error) +} + +type TLEStorer interface { + Save(ctx context.Context, tle TLE) error + Load(ctx context.Context) (TLE, bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + PositionInterval time.Duration + TLEInterval time.Duration + Fetcher Fetcher + TLEStore TLEStorer + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.PositionInterval <= 0 { + cfg.PositionInterval = defaultPositionInterval + } + if cfg.TLEInterval <= 0 { + cfg.TLEInterval = defaultTLEInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + pos := time.NewTicker(c.cfg.PositionInterval) + defer pos.Stop() + tle := time.NewTicker(c.cfg.TLEInterval) + defer tle.Stop() + + c.tickPosition(ctx) + c.tickTLE(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-pos.C: + c.tickPosition(ctx) + case <-tle.C: + c.tickTLE(ctx) + } + } +} + +func (c *Collector) tickPosition(ctx context.Context) { + p, err := c.cfg.Fetcher.FetchPosition(ctx) + if err != nil { + c.logger.Warn("iss position fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + body, _ := json.Marshal(p) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicISSPosition, + Timestamp: p.FetchedAt, + Source: Name, + Payload: json.RawMessage(body), + }) + c.cfg.State.RecordSuccess(ctx, Name, 1) +} + +func (c *Collector) tickTLE(ctx context.Context) { + tle, err := c.cfg.Fetcher.FetchTLE(ctx) + if err != nil { + c.logger.Warn("iss tle fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + if err := c.cfg.TLEStore.Save(ctx, tle); err != nil { + c.logger.Warn("iss tle save", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go new file mode 100644 index 00000000..c65a81f6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go @@ -0,0 +1,212 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package iss_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + mu sync.Mutex + pos iss.Position + tle iss.TLE + posErr error + tleErr error + posN int + tleN int +} + +func (f *fakeFetcher) FetchPosition(_ context.Context) (iss.Position, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.posN++ + return f.pos, f.posErr +} + +func (f *fakeFetcher) FetchTLE(_ context.Context) (iss.TLE, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tleN++ + return f.tle, f.tleErr +} + +type fakeStore struct { + mu sync.Mutex + saves int + saved iss.TLE +} + +func (s *fakeStore) Save(_ context.Context, tle iss.TLE) error { + s.mu.Lock() + defer s.mu.Unlock() + s.saves++ + s.saved = tle + return nil +} + +func (s *fakeStore) Load(_ context.Context) (iss.TLE, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.saves == 0 { + return iss.TLE{}, false, nil + } + return s.saved, true, nil +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ +} + +func TestCollector_PositionLoopEmitsAndRecords(t *testing.T) { + ftch := &fakeFetcher{ + pos: iss.Position{ + Latitude: 10, + Longitude: 20, + Altitude: 420, + Velocity: 27500, + Timestamp: 1234, + FetchedAt: time.Now().UTC(), + }, + tle: iss.TLE{Line1: "1 25544U ...", Line2: "2 25544 ..."}, + } + store := &fakeStore{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: 20 * time.Millisecond, + TLEInterval: time.Hour, + Fetcher: ftch, + TLEStore: store, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 70*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual( + t, + ftch.posN, + 2, + "position should fire multiple times", + ) + require.GreaterOrEqual(t, emt.Count(), 2) + for _, ev := range emt.events { + require.Equal(t, events.TopicISSPosition, ev.Topic) + } + require.Positive(t, st.successes) +} + +func TestCollector_TLELoopSavesToStore(t *testing.T) { + ftch := &fakeFetcher{ + pos: iss.Position{ + Latitude: 10, + Longitude: 20, + Altitude: 420, + FetchedAt: time.Now().UTC(), + }, + tle: iss.TLE{Line1: "1 25544U test", Line2: "2 25544 test"}, + } + store := &fakeStore{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: 50 * time.Millisecond, + TLEInterval: 30 * time.Millisecond, + Fetcher: ftch, + TLEStore: store, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 100*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual( + t, + store.saves, + 2, + "TLE should refresh multiple times in 100ms with 30ms interval", + ) + require.Equal(t, "1 25544U test", store.saved.Line1) +} + +func TestCollector_PositionFetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{ + posErr: errors.New("upstream 503"), + tle: iss.TLE{Line1: "1 ...", Line2: "2 ..."}, + } + store := &fakeStore{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: 20 * time.Millisecond, + TLEInterval: time.Hour, + Fetcher: ftch, + TLEStore: store, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, emt.Count()) + require.Positive(t, st.failures) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go new file mode 100644 index 00000000..55c53976 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go @@ -0,0 +1,89 @@ +// ©AngelaMos | 2026 +// propagator.go + +package iss + +import ( + "fmt" + "math" + "strings" + "time" + + satellite "github.com/joshuaferrara/go-satellite" +) + +type Sat struct { + inner satellite.Satellite +} + +func LoadTLE(line1, line2 string) (Sat, error) { + if !strings.HasPrefix(line1, "1 ") || !strings.HasPrefix(line2, "2 ") { + return Sat{}, fmt.Errorf( + "invalid TLE: lines must start with '1 ' and '2 '", + ) + } + s := satellite.TLEToSat(line1, line2, satellite.GravityWGS84) + if s.Error != 0 || s.ErrorStr != "" { + return Sat{}, fmt.Errorf( + "parse TLE: error %d / %s", + s.Error, + s.ErrorStr, + ) + } + return Sat{inner: s}, nil +} + +func Propagate(s Sat, t time.Time) (lat, lon, altKm float64) { + t = t.UTC() + pos, _ := satellite.Propagate( + s.inner, + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) + gmst := satellite.GSTimeFromDate( + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) + altitude, _, ll := satellite.ECIToLLA(pos, gmst) + deg := satellite.LatLongDeg(ll) + return deg.Latitude, deg.Longitude, altitude +} + +func LookAngles( + s Sat, + t time.Time, + observerLatDeg, observerLonDeg, observerAltKm float64, +) (azimuthDeg, elevationDeg, rangeKm float64) { + t = t.UTC() + pos, _ := satellite.Propagate( + s.inner, + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) + jday := satellite.JDay( + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) + obs := satellite.LatLong{ + Latitude: observerLatDeg * math.Pi / 180, + Longitude: observerLonDeg * math.Pi / 180, + } + la := satellite.ECIToLookAngles(pos, obs, observerAltKm, jday) + return la.Az * 180 / math.Pi, la.El * 180 / math.Pi, la.Rg +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go new file mode 100644 index 00000000..ed4e981e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go @@ -0,0 +1,71 @@ +// ©AngelaMos | 2026 +// propagator_test.go + +package iss_test + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss" +) + +func loadTLE(t *testing.T) (string, string) { + t.Helper() + body, err := os.ReadFile("testdata/tle.txt") + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + require.GreaterOrEqual( + t, + len(lines), + 3, + "TLE file must have name + 2 element lines", + ) + return strings.TrimRight( + lines[1], + " \r", + ), strings.TrimRight( + lines[2], + " \r", + ) +} + +func TestPropagator_PositionWithinReasonableBounds(t *testing.T) { + l1, l2 := loadTLE(t) + sat, err := iss.LoadTLE(l1, l2) + require.NoError(t, err) + + when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC) + lat, lon, alt := iss.Propagate(sat, when) + + require.True(t, lat >= -90 && lat <= 90, "lat=%f", lat) + require.True(t, lon >= -180 && lon <= 180, "lon=%f", lon) + require.True( + t, + alt >= 350 && alt <= 500, + "ISS altitude is roughly 380-420 km, got %f", + alt, + ) +} + +func TestPropagator_LookAnglesInValidRange(t *testing.T) { + l1, l2 := loadTLE(t) + sat, err := iss.LoadTLE(l1, l2) + require.NoError(t, err) + + when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC) + az, el, rng := iss.LookAngles(sat, when, 40.7128, -74.0060, 0) + + require.True(t, az >= 0 && az <= 360, "az=%f", az) + require.True(t, el >= -90 && el <= 90, "el=%f", el) + require.Greater(t, rng, 0.0) +} + +func TestPropagator_BadTLEReturnsError(t *testing.T) { + _, err := iss.LoadTLE("not a tle", "really not a tle") + require.Error(t, err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json new file mode 100644 index 00000000..e451a0c3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json @@ -0,0 +1 @@ +{"name":"iss","id":25544,"latitude":10.63651293933,"longitude":-10.956952860344,"altitude":423.74642684314,"velocity":27564.677940445,"visibility":"daylight","footprint":4526.4746978082,"timestamp":1777710988,"daynum":2461162.8586574,"solar_lat":15.425197566858,"solar_lon":50.133029500693,"units":"kilometers"} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json new file mode 100644 index 00000000..ac67dea9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json @@ -0,0 +1 @@ +[{"OBJECT_NAME":"ISS (ZARYA)","OBJECT_ID":"1998-067A","EPOCH":"2026-05-02T03:14:52.956096","MEAN_MOTION":15.49057929,"ECCENTRICITY":0.0007234,"INCLINATION":51.6309,"RA_OF_ASC_NODE":168.049,"ARG_OF_PERICENTER":13.9157,"MEAN_ANOMALY":346.2029,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":25544,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":56461,"BSTAR":0.00014312,"MEAN_MOTION_DOT":7.461e-5,"MEAN_MOTION_DDOT":0}] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt new file mode 100644 index 00000000..b3934d64 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt @@ -0,0 +1,3 @@ +ISS (ZARYA) +1 25544U 98067A 26122.13533514 .00007461 00000+0 14312-3 0 9999 +2 25544 51.6309 168.0490 0007234 13.9157 346.2029 15.49057929564615 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go new file mode 100644 index 00000000..c548e90e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go @@ -0,0 +1,53 @@ +// ©AngelaMos | 2026 +// tle_store.go + +package iss + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + tleKey = "iss:tle" + tleTTL = 24 * time.Hour +) + +type TLEStore struct { + rdb *redis.Client +} + +func NewTLEStore(rdb *redis.Client) *TLEStore { + return &TLEStore{rdb: rdb} +} + +func (s *TLEStore) Save(ctx context.Context, tle TLE) error { + body, err := json.Marshal(tle) + if err != nil { + return fmt.Errorf("marshal tle: %w", err) + } + if err := s.rdb.Set(ctx, tleKey, body, tleTTL).Err(); err != nil { + return fmt.Errorf("save iss tle: %w", err) + } + return nil +} + +func (s *TLEStore) Load(ctx context.Context) (TLE, bool, error) { + body, err := s.rdb.Get(ctx, tleKey).Bytes() + if errors.Is(err, redis.Nil) { + return TLE{}, false, nil + } + if err != nil { + return TLE{}, false, fmt.Errorf("load iss tle: %w", err) + } + var tle TLE + if err := json.Unmarshal(body, &tle); err != nil { + return TLE{}, false, fmt.Errorf("unmarshal tle: %w", err) + } + return tle, true, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go new file mode 100644 index 00000000..7cd35b6d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// client.go + +package kev + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultKEVURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" + defaultKEVRate = time.Second + defaultKEVBudget = 5 + defaultKEVBreakerWin = 60 * time.Second +) + +type ClientConfig struct { + URL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.URL == "" { + cfg.URL = defaultKEVURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "kev", + BaseURL: cfg.URL, + Rate: rate.Every(defaultKEVRate), + Burst: 1, + ConsecutiveFailureBudget: defaultKEVBudget, + BreakerTimeout: defaultKEVBreakerWin, + }), + } +} + +type Catalog struct { + Title string `json:"title"` + CatalogVersion string `json:"catalogVersion"` + DateReleased string `json:"dateReleased"` + Count int `json:"count"` + Vulnerabilities []Vulnerability `json:"vulnerabilities"` +} + +type Vulnerability struct { + CveID string `json:"cveID"` + VendorProject string `json:"vendorProject"` + Product string `json:"product"` + VulnerabilityName string `json:"vulnerabilityName"` + DateAdded string `json:"dateAdded"` + DueDate string `json:"dueDate"` + KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse"` + ShortDescription string `json:"shortDescription"` + RequiredAction string `json:"requiredAction"` +} + +func (c *Client) FetchCatalog(ctx context.Context) (Catalog, error) { + resp, err := c.hx.Get(ctx, "", nil) + if err != nil { + return Catalog{}, fmt.Errorf("fetch kev catalog: %w", err) + } + defer resp.Body.Close() + + var cat Catalog + if err := json.NewDecoder(resp.Body).Decode(&cat); err != nil { + return Catalog{}, fmt.Errorf("decode kev: %w", err) + } + return cat, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go new file mode 100644 index 00000000..34beac94 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go @@ -0,0 +1,47 @@ +// ©AngelaMos | 2026 +// client_test.go + +package kev_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" +) + +func TestKEVClient_FetchCatalogDecodes(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/kev_catalog.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := kev.NewClient(kev.ClientConfig{URL: srv.URL}) + cat, err := c.FetchCatalog(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, cat.Vulnerabilities) + require.NotEmpty(t, cat.Vulnerabilities[0].CveID) +} + +func TestKEVClient_RejectsServerError(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }), + ) + defer srv.Close() + + c := kev.NewClient(kev.ClientConfig{URL: srv.URL}) + _, err := c.FetchCatalog(context.Background()) + require.Error(t, err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go new file mode 100644 index 00000000..56914eeb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go @@ -0,0 +1,140 @@ +// ©AngelaMos | 2026 +// collector.go + +package kev + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "kev" + defaultKEVCadence = time.Hour + dateLayout = "2006-01-02" +) + +type Fetcher interface { + FetchCatalog(ctx context.Context) (Catalog, error) +} + +type Repository interface { + Insert(ctx context.Context, row Row) error + KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultKEVCadence + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + cat, err := c.cfg.Fetcher.FetchCatalog(ctx) + if err != nil { + c.logger.Warn("kev fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(cat.Vulnerabilities)) + for _, v := range cat.Vulnerabilities { + ids = append(ids, v.CveID) + } + + known, err := c.cfg.Repo.KnownIDs(ctx, ids) + if err != nil { + c.logger.Warn("kev known ids", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + now := time.Now().UTC() + emitted := int64(0) + for _, v := range cat.Vulnerabilities { + if known[v.CveID] { + continue + } + + dateAdded, _ := time.Parse(dateLayout, v.DateAdded) + var dueDate *time.Time + if t, perr := time.Parse(dateLayout, v.DueDate); perr == nil { + dueDate = &t + } + + raw, _ := json.Marshal(v) + row := Row{ + CveID: v.CveID, + Vendor: v.VendorProject, + Product: v.Product, + VulnerabilityName: v.VulnerabilityName, + DateAdded: dateAdded, + DueDate: dueDate, + RansomwareUse: v.KnownRansomwareCampaignUse, + Payload: raw, + } + + if ierr := c.cfg.Repo.Insert(ctx, row); ierr != nil { + c.logger.Warn("kev insert", "id", v.CveID, "err", ierr) + continue + } + + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicKEVAdded, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(raw), + }) + emitted++ + } + c.cfg.State.RecordSuccess(ctx, Name, emitted) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go new file mode 100644 index 00000000..bcbd001a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go @@ -0,0 +1,155 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package kev_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type stubFetcher struct { + cat kev.Catalog + err error +} + +func (s *stubFetcher) FetchCatalog(context.Context) (kev.Catalog, error) { + return s.cat, s.err +} + +type stubKEVRepo struct { + mu sync.Mutex + inserted []string + known map[string]bool +} + +func (r *stubKEVRepo) Insert(_ context.Context, row kev.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserted = append(r.inserted, row.CveID) + if r.known == nil { + r.known = map[string]bool{} + } + r.known[row.CveID] = true + return nil +} + +func (r *stubKEVRepo) KnownIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]bool) + for _, id := range ids { + if r.known[id] { + out[id] = true + } + } + return out, nil +} + +func (r *stubKEVRepo) Inserted() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.inserted)) + copy(out, r.inserted) + return out +} + +type stubKEVEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *stubKEVEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *stubKEVEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type stubKEVState struct{} + +func (stubKEVState) RecordSuccess(context.Context, string, int64) {} +func (stubKEVState) RecordError(context.Context, string, string) {} + +func TestCollector_OnlyEmitsNewKEVs(t *testing.T) { + ftch := &stubFetcher{cat: kev.Catalog{Vulnerabilities: []kev.Vulnerability{ + {CveID: "CVE-2024-OLD", VendorProject: "X", DateAdded: "2026-04-01"}, + {CveID: "CVE-2024-NEW", VendorProject: "Y", DateAdded: "2026-05-01"}, + }}} + repo := &stubKEVRepo{known: map[string]bool{"CVE-2024-OLD": true}} + emt := &stubKEVEmitter{} + + c := kev.NewCollector(kev.CollectorConfig{ + Interval: 30 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: stubKEVState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + inserted := repo.Inserted() + require.NotEmpty(t, inserted) + for _, id := range inserted { + require.NotEqual(t, "CVE-2024-OLD", id) + require.Equal(t, "CVE-2024-NEW", id) + } + + evs := emt.Events() + require.NotEmpty(t, evs) + for _, ev := range evs { + require.Equal(t, events.TopicKEVAdded, ev.Topic) + require.Equal(t, kev.Name, ev.Source) + } +} + +func TestCollector_EmptyKnownInsertsAll(t *testing.T) { + ftch := &stubFetcher{cat: kev.Catalog{Vulnerabilities: []kev.Vulnerability{ + {CveID: "CVE-A", DateAdded: "2026-04-01"}, + {CveID: "CVE-B", DateAdded: "2026-04-02"}, + {CveID: "CVE-C", DateAdded: "2026-04-03"}, + }}} + repo := &stubKEVRepo{known: map[string]bool{}} + emt := &stubKEVEmitter{} + + c := kev.NewCollector(kev.CollectorConfig{ + Interval: 25 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: stubKEVState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 30*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Len(t, repo.Inserted(), 3) + require.Len(t, emt.Events(), 3) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go new file mode 100644 index 00000000..796ec5f1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go @@ -0,0 +1,108 @@ +// ©AngelaMos | 2026 +// repo.go + +package kev + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type Row struct { + CveID string `db:"cve_id"` + Vendor string `db:"vendor"` + Product string `db:"product"` + VulnerabilityName string `db:"vulnerability_name"` + DateAdded time.Time `db:"date_added"` + DueDate *time.Time `db:"due_date"` + RansomwareUse string `db:"ransomware_use"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Insert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO kev_entries + (cve_id, vendor, product, vulnerability_name, date_added, due_date, ransomware_use, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (cve_id) DO UPDATE SET + vendor = EXCLUDED.vendor, + product = EXCLUDED.product, + vulnerability_name = EXCLUDED.vulnerability_name, + date_added = EXCLUDED.date_added, + due_date = EXCLUDED.due_date, + ransomware_use = EXCLUDED.ransomware_use, + payload = EXCLUDED.payload`, + row.CveID, row.Vendor, row.Product, row.VulnerabilityName, + row.DateAdded, row.DueDate, row.RansomwareUse, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("insert kev %s: %w", row.CveID, err) + } + return nil +} + +func (r *Repo) KnownIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { + if len(ids) == 0 { + return map[string]bool{}, nil + } + var found []string + if err := r.db.SelectContext( + ctx, + &found, + `SELECT cve_id FROM kev_entries WHERE cve_id = ANY($1::text[])`, + pq.Array(ids), + ); err != nil { + return nil, fmt.Errorf("kev known ids: %w", err) + } + out := make(map[string]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) Count(ctx context.Context) (int64, error) { + var n int64 + if err := r.db.GetContext( + ctx, + &n, + `SELECT count(*) FROM kev_entries`, + ); err != nil { + return 0, fmt.Errorf("kev count: %w", err) + } + return n, nil +} + +func (r *Repo) RecentByDateAdded( + ctx context.Context, + limit int, +) ([]Row, error) { + if limit <= 0 { + limit = 50 + } + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT cve_id, vendor, product, vulnerability_name, date_added, due_date, + ransomware_use, payload + FROM kev_entries + ORDER BY date_added DESC, cve_id DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent kev: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go new file mode 100644 index 00000000..fe459437 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go @@ -0,0 +1,118 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package kev_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE kev_entries ( + cve_id text PRIMARY KEY, + vendor text NOT NULL DEFAULT '', + product text NOT NULL DEFAULT '', + vulnerability_name text NOT NULL DEFAULT '', + date_added date NOT NULL, + due_date date, + ransomware_use text NOT NULL DEFAULT '', + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_InsertAndKnownIDs(t *testing.T) { + db := setupDB(t) + repo := kev.NewRepo(db) + ctx := context.Background() + added := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC) + + require.NoError(t, repo.Insert(ctx, kev.Row{ + CveID: "CVE-2024-3094", + Vendor: "JiaT75", + Product: "xz", + VulnerabilityName: "Backdoor", + DateAdded: added, + Payload: json.RawMessage(`{"cveID":"CVE-2024-3094"}`), + })) + + known, err := repo.KnownIDs( + ctx, + []string{"CVE-2024-3094", "CVE-2024-MISSING"}, + ) + require.NoError(t, err) + require.True(t, known["CVE-2024-3094"]) + require.False(t, known["CVE-2024-MISSING"]) +} + +func TestRepo_InsertIsIdempotent(t *testing.T) { + db := setupDB(t) + repo := kev.NewRepo(db) + ctx := context.Background() + + row := kev.Row{ + CveID: "CVE-2024-X", + Vendor: "v1", + DateAdded: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), + Payload: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(ctx, row)) + + updated := row + updated.Vendor = "v2" + require.NoError(t, repo.Insert(ctx, updated)) + + count, err := repo.Count(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, count) +} + +func TestRepo_CountReflectsTotal(t *testing.T) { + db := setupDB(t) + repo := kev.NewRepo(db) + ctx := context.Background() + added := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + + for _, id := range []string{"CVE-A", "CVE-B", "CVE-C"} { + require.NoError(t, repo.Insert(ctx, kev.Row{ + CveID: id, DateAdded: added, Payload: json.RawMessage(`{}`), + })) + } + + count, err := repo.Count(ctx) + require.NoError(t, err) + require.EqualValues(t, 3, count) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json new file mode 100644 index 00000000..e4ce84f2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json @@ -0,0 +1,311 @@ +{ + "title": "CISA Catalog of Known Exploited Vulnerabilities", + "catalogVersion": "2026.05.01", + "dateReleased": "2026-05-01T18:00:49.4551Z", + "count": 20, + "vulnerabilities": [ + { + "cveID": "CVE-2026-31431", + "vendorProject": "Linux", + "product": "Kernel", + "vulnerabilityName": "Linux Kernel Incorrect Resource Transfer Between Spheres Vulnerability", + "dateAdded": "2026-05-01", + "shortDescription": "Linux Kernel contains an incorrect resource transfer between spheres vulnerability that could allow for privilege escalation.", + "requiredAction": "\"Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-15", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://lore.kernel.org/linux-cve-announce/2026042214-CVE-2026-31431-3d65@gregkh/; https://xint.io/blog/copy-fail-linux-distributions#the-fix-6 ; https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/about/ ; https://nvd.nist.gov/vuln/detail/CVE-2026-31431", + "cwes": [ + "CWE-699" + ] + }, + { + "cveID": "CVE-2026-41940", + "vendorProject": "WebPros", + "product": "cPanel & WHM and WP2 (WordPress Squared)", + "vulnerabilityName": "WebPros cPanel & WHM and WP2 (WordPress Squared) Missing Authentication for Critical Function Vulnerability", + "dateAdded": "2026-04-30", + "shortDescription": "WebPros cPanel & WHM (WebHost Manager) and WP2 (WordPress Squared) contain an authentication bypass vulnerability in the login flow that allows unauthenticated remote attackers to gain unauthorized access to the control panel.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-03", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://support.cpanel.net/hc/en-us/articles/40073787579671-cPanel-WHM-Security-Update-04-28-2026 ; https://docs.cpanel.net/release-notes/release-notes/ ; https://docs.wpsquared.com/changelogs/versions/changelog/#13617 ; https://nvd.nist.gov/vuln/detail/CVE-2026-41940\"", + "cwes": [ + "CWE-306" + ] + }, + { + "cveID": "CVE-2024-1708", + "vendorProject": "ConnectWise", + "product": "ScreenConnect", + "vulnerabilityName": "ConnectWise ScreenConnect Path Traversal Vulnerability", + "dateAdded": "2026-04-28", + "shortDescription": "ConnectWise ScreenConnect contains a path traversal vulnerability which could allow an attacker to execute remote code or directly impact confidential data and critical systems.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-12", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://www.connectwise.com/company/trust/security-bulletins/connectwise-screenconnect-23.9.8 ; https://nvd.nist.gov/vuln/detail/CVE-2024-1708", + "cwes": [ + "CWE-22" + ] + }, + { + "cveID": "CVE-2026-32202", + "vendorProject": "Microsoft", + "product": "Windows", + "vulnerabilityName": "Microsoft Windows Protection Mechanism Failure Vulnerability", + "dateAdded": "2026-04-28", + "shortDescription": "Microsoft Windows Shell contains a protection mechanism failure vulnerability that allows an unauthorized attacker to perform spoofing over a network.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-12", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2026-32202 ; https://nvd.nist.gov/vuln/detail/CVE-2026-32202", + "cwes": [ + "CWE-693" + ] + }, + { + "cveID": "CVE-2025-29635", + "vendorProject": "D-Link", + "product": "DIR-823X", + "vulnerabilityName": "D-Link DIR-823X Command Injection Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "D-Link DIR-823X contains a command injection vulnerability that allows an authorized attacker to execute arbitrary commands on remote devices by sending a POST request to /goform/set_prohibiting via the corresponding function. The impacted product could be end-of-life (EoL) and/or end-of-service (EoS). Users should discontinue product utilization.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://supportannouncement.us.dlink.com/security/publication.aspx?name=SAP10469 ; https://nvd.nist.gov/vuln/detail/CVE-2025-29635", + "cwes": [ + "CWE-77" + ] + }, + { + "cveID": "CVE-2024-7399", + "vendorProject": "Samsung", + "product": "MagicINFO 9 Server", + "vulnerabilityName": "Samsung MagicINFO 9 Server Path Traversal Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "Samsung MagicINFO 9 Server contains a path traversal vulnerability that could allow an attacker to write arbitrary files as system authority.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://security.samsungtv.com/securityUpdates ; https://nvd.nist.gov/vuln/detail/CVE-2024-7399", + "cwes": [ + "CWE-22", + "CWE-434" + ] + }, + { + "cveID": "CVE-2024-57728", + "vendorProject": "SimpleHelp ", + "product": "SimpleHelp", + "vulnerabilityName": "SimpleHelp Path Traversal Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "SimpleHelp contains a path traversal vulnerability that allows admin users to upload arbitrary files anywhere on the file system by uploading a crafted zip file (i.e. zip slip). This can be exploited to execute arbitrary code on the host in the context of the SimpleHelp server user.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://simple-help.com/kb---security-vulnerabilities-01-2025#security-vulnerabilities-in-simplehelp-5-5-7-and-earlier ; https://nvd.nist.gov/vuln/detail/CVE-2024-57728", + "cwes": [ + "CWE-22" + ] + }, + { + "cveID": "CVE-2024-57726", + "vendorProject": "SimpleHelp ", + "product": "SimpleHelp", + "vulnerabilityName": "SimpleHelp Missing Authorization Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "SimpleHelp contains a missing authorization vulnerability that could allow low-privileged technicians to create API keys with excessive permissions. These API keys can be used to escalate privileges to the server admin role.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://simple-help.com/kb---security-vulnerabilities-01-2025#security-vulnerabilities-in-simplehelp-5-5-7-and-earlier ; https://nvd.nist.gov/vuln/detail/CVE-2024-57726", + "cwes": [ + "CWE-862" + ] + }, + { + "cveID": "CVE-2026-39987", + "vendorProject": "Marimo", + "product": "Marimo", + "vulnerabilityName": "Marimo Remote Code Execution Vulnerability", + "dateAdded": "2026-04-23", + "shortDescription": "Marimo contains an pre-authorization remote code execution vulnerability, allowing an unauthenticated attacked to shell access and execute arbitrary system commands.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-07", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://github.com/marimo-team/marimo/security/advisories/GHSA-2679-6mx9-h9xc ; https://nvd.nist.gov/vuln/detail/CVE-2026-39987", + "cwes": [ + "CWE-306" + ] + }, + { + "cveID": "CVE-2026-33825", + "vendorProject": "Microsoft", + "product": "Defender", + "vulnerabilityName": "Microsoft Defender Insufficient Granularity of Access Control Vulnerability", + "dateAdded": "2026-04-22", + "shortDescription": "Microsoft Defender contains an insufficient granularity of access control vulnerability that could allow an authorized attacker to escalate privileges locally.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-06", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-33825 ; https://nvd.nist.gov/vuln/detail/CVE-2026-33825", + "cwes": [ + "CWE-1220" + ] + }, + { + "cveID": "CVE-2026-20122", + "vendorProject": "Cisco", + "product": "Catalyst SD-WAN Manger", + "vulnerabilityName": "Cisco Catalyst SD-WAN Manager Incorrect Use of Privileged APIs Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Cisco Catalyst SD-WAN Manager contains an incorrect use of privileged APIs vulnerability due to improper file handling on the API interface of an affected system. An attacker could exploit this vulnerability by uploading a malicious file on the local file system. A successful exploit could allow the attacker to overwrite arbitrary files on the affected system and gain vmanage user privileges.", + "requiredAction": "Please adhere to CISA’s guidelines to assess exposure and mitigate risks associated with Cisco SD-WAN devices as outlines in CISA’s Emergency Directive 26-03 (URL listed below in Notes) and CISA’s “Hunt & Hardening Guidance for Cisco SD-WAN Devices (URL listed below in Notes). Adhere to the applicable BOD 22-01 guidance for cloud services or discontinue use of the product if mitigations are not available.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "CISA Mitigation Instructions: https://www.cisa.gov/news-events/directives/ed-26-03-mitigate-vulnerabilities-cisco-sd-wan-systems ; https://www.cisa.gov/news-events/directives/supplemental-direction-ed-26-03-hunt-and-hardening-guidance-cisco-sd-wan-systems ; https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-authbp-qwCX8D4v ; https://nvd.nist.gov/vuln/detail/CVE-2026-20122", + "cwes": [ + "CWE-648" + ] + }, + { + "cveID": "CVE-2026-20133", + "vendorProject": "Cisco", + "product": "Catalyst SD-WAN Manager", + "vulnerabilityName": "Cisco Catalyst SD-WAN Manager Exposure of Sensitive Information to an Unauthorized Actor Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Cisco Catalyst SD-WAN Manager contains an exposure of sensitive information to an unauthorized actor vulnerability that could allow remote attackers to view sensitive information on affected systems.", + "requiredAction": "Please adhere to CISA’s guidelines to assess exposure and mitigate risks associated with Cisco SD-WAN devices as outlines in CISA’s Emergency Directive 26-03 (URL listed below in Notes) and CISA’s “Hunt & Hardening Guidance for Cisco SD-WAN Devices (URL listed below in Notes). Adhere to the applicable BOD 22-01 guidance for cloud services or discontinue use of the product if mitigations are not available.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "CISA Mitigation Instructions: https://www.cisa.gov/news-events/directives/ed-26-03-mitigate-vulnerabilities-cisco-sd-wan-systems ; https://www.cisa.gov/news-events/directives/supplemental-direction-ed-26-03-hunt-and-hardening-guidance-cisco-sd-wan-systems ; https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-authbp-qwCX8D4v ; https://nvd.nist.gov/vuln/detail/CVE-2026-20133", + "cwes": [ + "CWE-200" + ] + }, + { + "cveID": "CVE-2025-2749", + "vendorProject": "Kentico", + "product": "Kentico Xperience", + "vulnerabilityName": "Kentico Xperience Path Traversal Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Kentico Xperience contains a path traversal vulnerability that could allow an authenticated user's Staging Sync Server to upload arbitrary data to path relative locations.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://devnet.kentico.com/download/hotfixes ; https://nvd.nist.gov/vuln/detail/CVE-2025-2749", + "cwes": [ + "CWE-22", + "CWE-434" + ] + }, + { + "cveID": "CVE-2023-27351", + "vendorProject": "PaperCut", + "product": "NG/MF", + "vulnerabilityName": "PaperCut NG/MF Improper Authentication Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "PaperCut NG/MF contains an improper authentication vulnerability that could allow remote attackers to bypass authentication on affected installations via the SecurityRequestFilter class.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Known", + "notes": "https://www.papercut.com/kb/Main/PO-1216-and-PO-1219 ; https://nvd.nist.gov/vuln/detail/CVE-2023-27351", + "cwes": [ + "CWE-287" + ] + }, + { + "cveID": "CVE-2025-48700", + "vendorProject": "Synacor", + "product": "Zimbra Collaboration Suite (ZCS)", + "vulnerabilityName": "Synacor Zimbra Collaboration Suite (ZCS) Cross-site Scripting Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Synacor Zimbra Collaboration Suite (ZCS) contains a cross-site scripting vulnerability that could allow attackers to execute arbitrary JavaScript within the user's session, potentially leading to unauthorized access to sensitive information.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://wiki.zimbra.com/wiki/Zimbra_Security_Advisories ; https://nvd.nist.gov/vuln/detail/CVE-2025-48700", + "cwes": [ + "CWE-79" + ] + }, + { + "cveID": "CVE-2026-20128", + "vendorProject": "Cisco", + "product": "Catalyst SD-WAN Manager", + "vulnerabilityName": "Cisco Catalyst SD-WAN Manager Storing Passwords in a Recoverable Format Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Cisco Catalyst SD-WAN Manager contains a storing passwords in a recoverable format vulnerability that allows an authenticated, local attacker to gain DCA user privileges by accessing a credential file for the DCA user on the filesystem as a low-privileged user.", + "requiredAction": "Please adhere to CISA’s guidelines to assess exposure and mitigate risks associated with Cisco SD-WAN devices as outlines in CISA’s Emergency Directive 26-03 (URL listed below in Notes) and CISA’s “Hunt & Hardening Guidance for Cisco SD-WAN Devices (URL listed below in Notes). Adhere to the applicable BOD 22-01 guidance for cloud services or discontinue use of the product if mitigations are not available.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "CISA Mitigation Instructions: https://www.cisa.gov/news-events/directives/ed-26-03-mitigate-vulnerabilities-cisco-sd-wan-systems ; https://www.cisa.gov/news-events/directives/supplemental-direction-ed-26-03-hunt-and-hardening-guidance-cisco-sd-wan-systems ; https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-authbp-qwCX8D4v ; https://nvd.nist.gov/vuln/detail/CVE-2026-20128", + "cwes": [ + "CWE-257" + ] + }, + { + "cveID": "CVE-2025-32975", + "vendorProject": "Quest", + "product": "KACE Systems Management Appliance (SMA)", + "vulnerabilityName": "Quest KACE Systems Management Appliance (SMA) Improper Authentication Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Quest KACE Systems Management Appliance (SMA) contains an improper authentication vulnerability that could allow attackers to impersonate legitimate users without valid credentials.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://support.quest.com/kb/4379499/quest-response-to-kace-sma-vulnerabilities-cve-2025-32975-cve-2025-32976-cve-2025-32977-cve-2025-32978 ; https://nvd.nist.gov/vuln/detail/CVE-2025-32975", + "cwes": [ + "CWE-287" + ] + }, + { + "cveID": "CVE-2024-27199", + "vendorProject": "JetBrains", + "product": "TeamCity", + "vulnerabilityName": "JetBrains TeamCity Relative Path Traversal Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "JetBrains TeamCity contains a relative path traversal vulnerability that could allow limited admin actions to be performed.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Known", + "notes": "https://www.jetbrains.com/privacy-security/issues-fixed/ ; https://blog.jetbrains.com/teamcity/2024/03/additional-critical-security-issues-affecting-teamcity-on-premises-cve-2024-27198-and-cve-2024-27199-update-to-2023-11-4-now/ ; https://nvd.nist.gov/vuln/detail/CVE-2024-27199", + "cwes": [ + "CWE-23" + ] + }, + { + "cveID": "CVE-2026-34197", + "vendorProject": "Apache", + "product": "ActiveMQ", + "vulnerabilityName": "Apache ActiveMQ Improper Input Validation Vulnerability", + "dateAdded": "2026-04-16", + "shortDescription": "Apache ActiveMQ contains an improper input validation vulnerability that allows for code injection.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-04-30", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://activemq.apache.org/security-advisories.data/CVE-2026-34197-announcement.txt ; https://nvd.nist.gov/vuln/detail/CVE-2026-34197", + "cwes": [ + "CWE-20", + "CWE-94" + ] + }, + { + "cveID": "CVE-2009-0238", + "vendorProject": "Microsoft", + "product": "Office", + "vulnerabilityName": "Microsoft Office Remote Code Execution", + "dateAdded": "2026-04-14", + "shortDescription": "Microsoft Office Excel contains a remote code execution vulnerability that could allow an attacker to take complete control of an affected system if a user opens a specially crafted Excel file that includes a malformed object.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-04-28", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://learn.microsoft.com/en-us/security-updates/securitybulletins/2009/ms09-009 ; https://nvd.nist.gov/vuln/detail/CVE-2009-0238", + "cwes": [ + "CWE-94" + ] + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go new file mode 100644 index 00000000..61205939 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go @@ -0,0 +1,77 @@ +// ©AngelaMos | 2026 +// client.go + +package ransomware + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultRansomBaseURL = "https://api.ransomware.live" + pathRecentVictims = "/recentvictims" + defaultRansomRate = time.Second + defaultRansomBudget = 5 + defaultRansomBreakerWin = 60 * time.Second + idHashBytes = 16 +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultRansomBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "ransomware", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultRansomRate), + Burst: 1, + ConsecutiveFailureBudget: defaultRansomBudget, + BreakerTimeout: defaultRansomBreakerWin, + }), + } +} + +type Victim struct { + PostTitle string `json:"post_title"` + GroupName string `json:"group_name"` + Discovered time.Time `json:"discovered"` + Country string `json:"country"` + Activity string `json:"activity"` + Website string `json:"website"` + Description string `json:"description"` +} + +func (v Victim) ID() string { + h := sha256.Sum256( + []byte( + v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC(). + Format(time.RFC3339), + ), + ) + return hex.EncodeToString(h[:idHashBytes]) +} + +func (c *Client) FetchRecent(ctx context.Context) ([]Victim, error) { + var out []Victim + if err := c.hx.GetJSON(ctx, pathRecentVictims, nil, &out); err != nil { + return nil, fmt.Errorf("fetch recentvictims: %w", err) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go new file mode 100644 index 00000000..47bae130 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// client_test.go + +package ransomware_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" +) + +func TestClient_FetchRecentVictimsDecodes(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/recentvictims.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := ransomware.NewClient(ransomware.ClientConfig{BaseURL: srv.URL}) + vs, err := c.FetchRecent(context.Background()) + require.NoError(t, err) + require.Len(t, vs, 3) + require.Equal(t, "Acme Healthcare Corp", vs[0].PostTitle) + require.Equal(t, "lockbit", vs[0].GroupName) +} + +func TestVictim_IDIsStableAndCollisionFree(t *testing.T) { + d, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z") + v1 := ransomware.Victim{PostTitle: "X", GroupName: "lockbit", Discovered: d} + v2 := ransomware.Victim{PostTitle: "X", GroupName: "lockbit", Discovered: d} + v3 := ransomware.Victim{PostTitle: "Y", GroupName: "lockbit", Discovered: d} + + require.Equal(t, v1.ID(), v2.ID()) + require.NotEqual(t, v1.ID(), v3.ID()) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go new file mode 100644 index 00000000..74e158ae --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go @@ -0,0 +1,133 @@ +// ©AngelaMos | 2026 +// collector.go + +package ransomware + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "ransomware" + defaultRansomCadence = 15 * time.Minute +) + +type Fetcher interface { + FetchRecent(ctx context.Context) ([]Victim, error) +} + +type Repository interface { + Insert(ctx context.Context, row Row) error + KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultRansomCadence + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + vs, err := c.cfg.Fetcher.FetchRecent(ctx) + if err != nil { + c.logger.Warn("ransomware fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(vs)) + for _, v := range vs { + ids = append(ids, v.ID()) + } + + known, err := c.cfg.Repo.KnownIDs(ctx, ids) + if err != nil { + c.logger.Warn("ransomware known ids", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + now := time.Now().UTC() + emitted := int64(0) + for _, v := range vs { + id := v.ID() + if known[id] { + continue + } + + raw, _ := json.Marshal(v) + row := Row{ + ID: id, + PostTitle: v.PostTitle, + GroupName: v.GroupName, + DiscoveredAt: v.Discovered, + Country: v.Country, + Sector: v.Activity, + Payload: raw, + } + + if err := c.cfg.Repo.Insert(ctx, row); err != nil { + c.logger.Warn("ransomware insert", "id", id, "err", err) + continue + } + + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicRansomwareVictim, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(raw), + }) + emitted++ + } + c.cfg.State.RecordSuccess(ctx, Name, emitted) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go new file mode 100644 index 00000000..6889b234 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go @@ -0,0 +1,138 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package ransomware_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type stubFetcher struct { + victims []ransomware.Victim +} + +func (s *stubFetcher) FetchRecent( + context.Context, +) ([]ransomware.Victim, error) { + return s.victims, nil +} + +type stubRansomRepo struct { + mu sync.Mutex + inserted []string + known map[string]bool +} + +func (r *stubRansomRepo) Insert(_ context.Context, row ransomware.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserted = append(r.inserted, row.ID) + if r.known == nil { + r.known = map[string]bool{} + } + r.known[row.ID] = true + return nil +} + +func (r *stubRansomRepo) KnownIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]bool) + for _, id := range ids { + if r.known[id] { + out[id] = true + } + } + return out, nil +} + +func (r *stubRansomRepo) Inserted() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.inserted)) + copy(out, r.inserted) + return out +} + +type stubRansomEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *stubRansomEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *stubRansomEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type stubRansomState struct{} + +func (stubRansomState) RecordSuccess(context.Context, string, int64) {} +func (stubRansomState) RecordError(context.Context, string, string) {} + +func TestCollector_OnlyEmitsNewVictims(t *testing.T) { + now := time.Now().UTC() + known := ransomware.Victim{ + PostTitle: "Old", + GroupName: "lockbit", + Discovered: now.Add(-time.Hour), + } + new1 := ransomware.Victim{ + PostTitle: "Acme", + GroupName: "blackcat", + Discovered: now, + } + new2 := ransomware.Victim{ + PostTitle: "Banco", + GroupName: "play", + Discovered: now, + } + + ftch := &stubFetcher{victims: []ransomware.Victim{known, new1, new2}} + repo := &stubRansomRepo{known: map[string]bool{known.ID(): true}} + emt := &stubRansomEmitter{} + + c := ransomware.NewCollector(ransomware.CollectorConfig{ + Interval: 30 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: stubRansomState{}, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + inserted := repo.Inserted() + require.Len(t, inserted, 2) + + evs := emt.Events() + require.Len(t, evs, 2) + for _, ev := range evs { + require.Equal(t, events.TopicRansomwareVictim, ev.Topic) + require.Equal(t, ransomware.Name, ev.Source) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go new file mode 100644 index 00000000..fd957f57 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go @@ -0,0 +1,91 @@ +// ©AngelaMos | 2026 +// repo.go + +package ransomware + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type Row struct { + ID string `db:"id"` + PostTitle string `db:"post_title"` + GroupName string `db:"group_name"` + DiscoveredAt time.Time `db:"discovered_at"` + Country string `db:"country"` + Sector string `db:"sector"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Insert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO ransomware_victims + (id, post_title, group_name, discovered_at, country, sector, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING`, + row.ID, + row.PostTitle, + row.GroupName, + row.DiscoveredAt, + row.Country, + row.Sector, + []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("insert ransom %s: %w", row.ID, err) + } + return nil +} + +func (r *Repo) KnownIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { + if len(ids) == 0 { + return map[string]bool{}, nil + } + var found []string + if err := r.db.SelectContext( + ctx, + &found, + `SELECT id FROM ransomware_victims WHERE id = ANY($1::text[])`, + pq.Array(ids), + ); err != nil { + return nil, fmt.Errorf("ransom known ids: %w", err) + } + out := make(map[string]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) Recent(ctx context.Context, limit int) ([]Row, error) { + if limit <= 0 { + limit = 50 + } + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, post_title, group_name, discovered_at, country, sector, payload + FROM ransomware_victims + ORDER BY discovered_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent ransomware: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go new file mode 100644 index 00000000..70c1da24 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package ransomware_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE ransomware_victims ( + id text PRIMARY KEY, + post_title text NOT NULL, + group_name text NOT NULL, + discovered_at timestamptz NOT NULL, + country text NOT NULL DEFAULT '', + sector text NOT NULL DEFAULT '', + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_InsertIsIdempotent(t *testing.T) { + db := setupDB(t) + repo := ransomware.NewRepo(db) + ctx := context.Background() + + row := ransomware.Row{ + ID: "id-1", + PostTitle: "Acme Corp", + GroupName: "lockbit", + DiscoveredAt: time.Now().UTC(), + Country: "US", + Sector: "Healthcare", + Payload: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(ctx, row)) + require.NoError(t, repo.Insert(ctx, row)) + + known, err := repo.KnownIDs(ctx, []string{"id-1", "id-2"}) + require.NoError(t, err) + require.True(t, known["id-1"]) + require.False(t, known["id-2"]) +} + +func TestRepo_KnownIDsHandlesEmptySlice(t *testing.T) { + db := setupDB(t) + repo := ransomware.NewRepo(db) + ctx := context.Background() + + out, err := repo.KnownIDs(ctx, nil) + require.NoError(t, err) + require.Empty(t, out) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json new file mode 100644 index 00000000..1f290f3d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json @@ -0,0 +1,26 @@ +[ + { + "post_title": "Acme Healthcare Corp", + "group_name": "lockbit", + "discovered": "2026-05-01T14:32:00Z", + "country": "US", + "activity": "Healthcare", + "website": "acme-health.example.com", + "description": "Patient records claimed leaked" + }, + { + "post_title": "Northern Steel Manufacturing", + "group_name": "blackcat", + "discovered": "2026-05-01T11:08:00Z", + "country": "DE", + "activity": "Manufacturing", + "website": "northern-steel.example.de" + }, + { + "post_title": "Banco Nacional", + "group_name": "play", + "discovered": "2026-04-30T22:15:00Z", + "country": "AR", + "activity": "Finance" + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go new file mode 100644 index 00000000..ef2a9963 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go @@ -0,0 +1,107 @@ +// ©AngelaMos | 2026 +// repo.go + +package state + +import ( + "context" + "log/slog" + "time" + + "github.com/jmoiron/sqlx" +) + +type CollectorState string + +const ( + StateHealthy CollectorState = "healthy" + StateDegraded CollectorState = "degraded" +) + +type Row struct { + Name string `db:"name"` + State CollectorState `db:"state"` + LastSuccessAt *time.Time `db:"last_success_at"` + LastErrorAt *time.Time `db:"last_error_at"` + LastError string `db:"last_error"` + LastEventCount int64 `db:"last_event_count"` + UpdatedAt time.Time `db:"updated_at"` +} + +type Repo struct { + db *sqlx.DB + logger *slog.Logger +} + +func NewRepo(db *sqlx.DB) *Repo { + return &Repo{db: db, logger: slog.Default()} +} + +func NewRepoWithLogger(db *sqlx.DB, logger *slog.Logger) *Repo { + if logger == nil { + logger = slog.Default() + } + return &Repo{db: db, logger: logger} +} + +func (r *Repo) RecordSuccess( + ctx context.Context, + name string, + eventCount int64, +) { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO collector_state (name, state, last_success_at, last_event_count, updated_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (name) DO UPDATE SET + state = EXCLUDED.state, + last_success_at = EXCLUDED.last_success_at, + last_event_count = collector_state.last_event_count + EXCLUDED.last_event_count, + updated_at = EXCLUDED.updated_at`, + name, StateHealthy, eventCount, + ) + if err != nil { + r.logger.Warn("collector_state record success failed", + "collector", name, "err", err) + } +} + +func (r *Repo) RecordError(ctx context.Context, name, errMsg string) { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO collector_state (name, state, last_error_at, last_error, updated_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (name) DO UPDATE SET + state = EXCLUDED.state, + last_error_at = EXCLUDED.last_error_at, + last_error = EXCLUDED.last_error, + updated_at = EXCLUDED.updated_at`, + name, StateDegraded, errMsg, + ) + if err != nil { + r.logger.Warn("collector_state record error failed", + "collector", name, "err", err) + } +} + +func (r *Repo) Get(ctx context.Context, name string) (Row, bool, error) { + var row Row + err := r.db.GetContext(ctx, &row, ` + SELECT name, state, last_success_at, last_error_at, last_error, + last_event_count, updated_at + FROM collector_state WHERE name = $1`, name) + if err != nil { + return Row{}, false, err + } + return row, true, nil +} + +func (r *Repo) All(ctx context.Context) ([]Row, error) { + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT name, state, last_success_at, last_error_at, last_error, + last_event_count, updated_at + FROM collector_state ORDER BY name`) + if err != nil { + return nil, err + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go new file mode 100644 index 00000000..96a68d89 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go @@ -0,0 +1,113 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package state_test + +import ( + "context" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/state" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, + "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE collector_state ( + name text PRIMARY KEY, + state text NOT NULL, + last_success_at timestamptz, + last_error_at timestamptz, + last_error text NOT NULL DEFAULT '', + last_event_count bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() + )`) + require.NoError(t, err) + + return db +} + +func TestRepo_RecordSuccessThenError(t *testing.T) { + db := setupDB(t) + repo := state.NewRepo(db) + ctx := context.Background() + + repo.RecordSuccess(ctx, "dshield", 12) + + got, found, err := repo.Get(ctx, "dshield") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, "dshield", got.Name) + require.Equal(t, state.StateHealthy, got.State) + require.EqualValues(t, 12, got.LastEventCount) + require.NotNil(t, got.LastSuccessAt) + require.WithinDuration(t, time.Now(), *got.LastSuccessAt, 5*time.Second) + + repo.RecordError(ctx, "dshield", "upstream 503") + + got, found, err = repo.Get(ctx, "dshield") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, state.StateDegraded, got.State) + require.Equal(t, "upstream 503", got.LastError) + require.NotNil(t, got.LastErrorAt) + require.WithinDuration(t, time.Now(), *got.LastErrorAt, 5*time.Second) +} + +func TestRepo_SuccessAccumulatesCount(t *testing.T) { + db := setupDB(t) + repo := state.NewRepo(db) + ctx := context.Background() + + repo.RecordSuccess(ctx, "kev", 3) + repo.RecordSuccess(ctx, "kev", 5) + + got, found, err := repo.Get(ctx, "kev") + require.NoError(t, err) + require.True(t, found) + require.EqualValues(t, 8, got.LastEventCount) +} + +func TestRepo_AllReturnsRowsSorted(t *testing.T) { + db := setupDB(t) + repo := state.NewRepo(db) + ctx := context.Background() + + repo.RecordSuccess(ctx, "ransomware", 1) + repo.RecordSuccess(ctx, "cve", 1) + repo.RecordSuccess(ctx, "kev", 1) + + rows, err := repo.All(ctx) + require.NoError(t, err) + require.Len(t, rows, 3) + require.Equal(t, "cve", rows[0].Name) + require.Equal(t, "kev", rows[1].Name) + require.Equal(t, "ransomware", rows[2].Name) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go new file mode 100644 index 00000000..4a293f94 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go @@ -0,0 +1,271 @@ +// ©AngelaMos | 2026 +// client.go + +package swpc + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultSWPCBaseURL = "https://services.swpc.noaa.gov" + pathPlasma = "/products/solar-wind/plasma-5-minute.json" + pathMag = "/products/solar-wind/mag-5-minute.json" + pathKp = "/products/noaa-planetary-k-index.json" + pathXray = "/json/goes/primary/xrays-1-day.json" + pathAlerts = "/products/alerts.json" + defaultSWPCRate = 200 * time.Millisecond + defaultSWPCBurst = 5 + defaultSWPCBudget = 5 + defaultSWPCBreaker = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultSWPCBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "swpc", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultSWPCRate), + Burst: defaultSWPCBurst, + ConsecutiveFailureBudget: defaultSWPCBudget, + BreakerTimeout: defaultSWPCBreaker, + }), + } +} + +type PlasmaTick struct { + TimeTag time.Time + Density string + Speed string + Temperature string +} + +type MagTick struct { + TimeTag time.Time + BxGSM string + ByGSM string + BzGSM string + LonGSM string + LatGSM string + Bt string +} + +type KpTick struct { + TimeTag time.Time + Kp float64 + ARunning int + StationCount int +} + +type XrayTick struct { + TimeTag time.Time + Satellite int + Flux float64 + ObservedFlux float64 + Energy string +} + +type AlertItem struct { + ProductID string + IssueDatetime time.Time + Message string +} + +func (c *Client) FetchPlasma(ctx context.Context) ([]PlasmaTick, error) { + rows, err := c.fetchRowArray(ctx, pathPlasma) + if err != nil { + return nil, err + } + out := make([]PlasmaTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r["time_tag"]) + out = append(out, PlasmaTick{ + TimeTag: ts, + Density: r["density"], + Speed: r["speed"], + Temperature: r["temperature"], + }) + } + return out, nil +} + +func (c *Client) FetchMag(ctx context.Context) ([]MagTick, error) { + rows, err := c.fetchRowArray(ctx, pathMag) + if err != nil { + return nil, err + } + out := make([]MagTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r["time_tag"]) + out = append(out, MagTick{ + TimeTag: ts, + BxGSM: r["bx_gsm"], + ByGSM: r["by_gsm"], + BzGSM: r["bz_gsm"], + LonGSM: r["lon_gsm"], + LatGSM: r["lat_gsm"], + Bt: r["bt"], + }) + } + return out, nil +} + +type rawKp struct { + TimeTag string `json:"time_tag"` + Kp float64 `json:"Kp"` + ARunning int `json:"a_running"` + StationCount int `json:"station_count"` +} + +func (c *Client) FetchKp(ctx context.Context) ([]KpTick, error) { + var rows []rawKp + if err := c.hx.GetJSON(ctx, pathKp, nil, &rows); err != nil { + return nil, fmt.Errorf("fetch kp: %w", err) + } + out := make([]KpTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r.TimeTag) + out = append(out, KpTick{ + TimeTag: ts, + Kp: r.Kp, + ARunning: r.ARunning, + StationCount: r.StationCount, + }) + } + return out, nil +} + +type rawXray struct { + TimeTag string `json:"time_tag"` + Satellite int `json:"satellite"` + Flux float64 `json:"flux"` + ObservedFlux float64 `json:"observed_flux"` + Energy string `json:"energy"` +} + +func (c *Client) FetchXray(ctx context.Context) ([]XrayTick, error) { + var rows []rawXray + if err := c.hx.GetJSON(ctx, pathXray, nil, &rows); err != nil { + return nil, fmt.Errorf("fetch xray: %w", err) + } + out := make([]XrayTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r.TimeTag) + out = append(out, XrayTick{ + TimeTag: ts, + Satellite: r.Satellite, + Flux: r.Flux, + ObservedFlux: r.ObservedFlux, + Energy: r.Energy, + }) + } + return out, nil +} + +type rawAlert struct { + ProductID string `json:"product_id"` + IssueDatetime string `json:"issue_datetime"` + Message string `json:"message"` +} + +func (c *Client) FetchAlerts(ctx context.Context) ([]AlertItem, error) { + var rows []rawAlert + if err := c.hx.GetJSON(ctx, pathAlerts, nil, &rows); err != nil { + return nil, fmt.Errorf("fetch alerts: %w", err) + } + out := make([]AlertItem, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r.IssueDatetime) + out = append(out, AlertItem{ + ProductID: r.ProductID, + IssueDatetime: ts, + Message: r.Message, + }) + } + return out, nil +} + +func (c *Client) fetchRowArray( + ctx context.Context, + path string, +) ([]map[string]string, error) { + var raw [][]any + if err := c.hx.GetJSON(ctx, path, nil, &raw); err != nil { + return nil, fmt.Errorf("fetch row-array %s: %w", path, err) + } + if len(raw) < 2 { + return nil, nil + } + headers := make([]string, 0, len(raw[0])) + for _, h := range raw[0] { + if s, ok := h.(string); ok { + headers = append(headers, s) + } + } + out := make([]map[string]string, 0, len(raw)-1) + for _, row := range raw[1:] { + m := make(map[string]string, len(headers)) + for i, v := range row { + if i >= len(headers) { + break + } + m[headers[i]] = anyToString(v) + } + out = append(out, m) + } + return out, nil +} + +func anyToString(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case json.Number: + return x.String() + case bool: + return strconv.FormatBool(x) + default: + return "" + } +} + +var swpcTimeFormats = []string{ + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05.000", + "2006-01-02 15:04:05", +} + +func ParseTime(s string) (time.Time, error) { + if s == "" { + return time.Time{}, fmt.Errorf("empty swpc time") + } + for _, f := range swpcTimeFormats { + if t, err := time.Parse(f, s); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("unrecognized swpc time: %q", s) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go new file mode 100644 index 00000000..3bb76f70 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go @@ -0,0 +1,115 @@ +// ©AngelaMos | 2026 +// client_test.go + +package swpc_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" +) + +func newFakeServer(t *testing.T, route, fixture string) *httptest.Server { + t.Helper() + body, err := os.ReadFile("testdata/" + fixture) + require.NoError(t, err) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, route) { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + t.Cleanup(srv.Close) + return srv +} + +func TestClient_FetchPlasmaDecodesRowArray(t *testing.T) { + srv := newFakeServer(t, "plasma-5-minute", "plasma.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchPlasma(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].TimeTag.IsZero()) + require.NotEmpty(t, rows[0].Density) + require.NotEmpty(t, rows[0].Speed) +} + +func TestClient_FetchMagDecodesRowArray(t *testing.T) { + srv := newFakeServer(t, "mag-5-minute", "mag.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchMag(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) +} + +func TestClient_FetchKpDecodesObjectArray(t *testing.T) { + srv := newFakeServer(t, "noaa-planetary-k-index", "kp.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchKp(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].TimeTag.IsZero()) + require.GreaterOrEqual(t, rows[0].Kp, 0.0) +} + +func TestClient_FetchXrayDecodesObjectArray(t *testing.T) { + srv := newFakeServer(t, "xrays-1-day", "xray.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchXray(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].TimeTag.IsZero()) + require.Greater(t, rows[0].Flux, 0.0) +} + +func TestClient_FetchAlertsDecodes(t *testing.T) { + srv := newFakeServer(t, "alerts.json", "alerts.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchAlerts(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].IssueDatetime.IsZero()) + require.NotEmpty(t, rows[0].ProductID) + require.NotEmpty(t, rows[0].Message) +} + +func TestParseSWPCTime_AcceptsAllKnownFormats(t *testing.T) { + cases := []string{ + "2026-05-02 08:20:00.000", + "2026-04-25T00:00:00", + "2026-05-01T08:24:00Z", + "2026-05-01 15:50:32.247", + } + for _, s := range cases { + got, err := swpc.ParseTime(s) + require.NoError(t, err, "input %q", s) + require.False(t, got.IsZero()) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go new file mode 100644 index 00000000..f1062baf --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go @@ -0,0 +1,360 @@ +// ©AngelaMos | 2026 +// collector.go + +package swpc + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "strconv" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "swpc" + defaultFastInterval = time.Minute + defaultSlowInterval = 3 * time.Hour + + keyPlasma = "swpc:plasma" + keyMag = "swpc:mag" + keyKp = "swpc:kp" + keyXray = "swpc:xray" + keyAlerts = "swpc:alerts" + + xrayBaseB = 1e-7 + xrayBaseC = 1e-6 + xrayBaseM = 1e-5 + xrayBaseX = 1e-4 +) + +type Fetcher interface { + FetchPlasma(ctx context.Context) ([]PlasmaTick, error) + FetchMag(ctx context.Context) ([]MagTick, error) + FetchKp(ctx context.Context) ([]KpTick, error) + FetchXray(ctx context.Context) ([]XrayTick, error) + FetchAlerts(ctx context.Context) ([]AlertItem, error) +} + +type Ring interface { + Push(ctx context.Context, key string, score int64, payload []byte) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + FastInterval time.Duration + SlowInterval time.Duration + Fetcher Fetcher + Ring Ring + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger + + latestKp *KpTick + latestPlasma *PlasmaTick + latestMag *MagTick + latestXray *XrayTick +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.FastInterval <= 0 { + cfg.FastInterval = defaultFastInterval + } + if cfg.SlowInterval <= 0 { + cfg.SlowInterval = defaultSlowInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + fast := time.NewTicker(c.cfg.FastInterval) + defer fast.Stop() + slow := time.NewTicker(c.cfg.SlowInterval) + defer slow.Stop() + + c.tickFast(ctx) + c.tickSlow(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-fast.C: + c.tickFast(ctx) + case <-slow.C: + c.tickSlow(ctx) + } + } +} + +func (c *Collector) tickFast(ctx context.Context) { + pushed := int64(0) + hadError := false + + if n, err := c.pushPlasma(ctx); err != nil { + c.logger.Warn("swpc plasma", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + if n, err := c.pushMag(ctx); err != nil { + c.logger.Warn("swpc mag", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + if n, err := c.pushXray(ctx); err != nil { + c.logger.Warn("swpc xray", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + if n, err := c.pushAlerts(ctx); err != nil { + c.logger.Warn("swpc alerts", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + + if pushed > 0 { + c.emitCombined(pushed) + } + + if !hadError { + c.cfg.State.RecordSuccess(ctx, Name, pushed) + } +} + +func (c *Collector) tickSlow(ctx context.Context) { + n, err := c.pushKp(ctx) + if err != nil { + c.logger.Warn("swpc kp", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + if n > 0 { + c.emitCombined(n) + } +} + +func (c *Collector) emitCombined(pushed int64) { + body, _ := json.Marshal(c.buildPayload(pushed)) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicSpaceWeather, + Timestamp: time.Now().UTC(), + Source: Name, + Payload: json.RawMessage(body), + }) +} + +func (c *Collector) buildPayload(pushed int64) map[string]any { + out := map[string]any{ + "ts": time.Now().UTC(), + "pushed": pushed, + } + if c.latestKp != nil { + out["kp"] = c.latestKp.Kp + } + if c.latestPlasma != nil { + if v, ok := parseSWPCFloat(c.latestPlasma.Speed); ok { + out["speed_kms"] = v + } + if v, ok := parseSWPCFloat(c.latestPlasma.Density); ok { + out["density"] = v + } + } + if c.latestMag != nil { + if v, ok := parseSWPCFloat(c.latestMag.BzGSM); ok { + out["bz_gsm"] = v + } + } + if c.latestXray != nil && c.latestXray.Flux > 0 { + out["xray_flux"] = c.latestXray.Flux + if cls := classifyXray(c.latestXray.Flux); cls != "" { + out["xray_class"] = cls + } + } + return out +} + +func (c *Collector) pushPlasma(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchPlasma(ctx) + if err != nil { + return 0, err + } + if latest := lastNonZero( + rows, + func(r PlasmaTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { + c.latestPlasma = latest + } + return pushAll( + ctx, + c.cfg.Ring, + keyPlasma, + rows, + func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() }, + ) +} + +func (c *Collector) pushMag(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchMag(ctx) + if err != nil { + return 0, err + } + if latest := lastNonZero( + rows, + func(r MagTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { + c.latestMag = latest + } + return pushAll( + ctx, + c.cfg.Ring, + keyMag, + rows, + func(r MagTick) int64 { return r.TimeTag.UnixMilli() }, + ) +} + +func (c *Collector) pushKp(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchKp(ctx) + if err != nil { + return 0, err + } + if latest := lastNonZero( + rows, + func(r KpTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { + c.latestKp = latest + } + return pushAll( + ctx, + c.cfg.Ring, + keyKp, + rows, + func(r KpTick) int64 { return r.TimeTag.UnixMilli() }, + ) +} + +func (c *Collector) pushXray(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchXray(ctx) + if err != nil { + return 0, err + } + if latest := lastNonZero( + rows, + func(r XrayTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { + c.latestXray = latest + } + return pushAll( + ctx, + c.cfg.Ring, + keyXray, + rows, + func(r XrayTick) int64 { return r.TimeTag.UnixMilli() }, + ) +} + +func (c *Collector) pushAlerts(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchAlerts(ctx) + if err != nil { + return 0, err + } + return pushAll( + ctx, + c.cfg.Ring, + keyAlerts, + rows, + func(r AlertItem) int64 { return r.IssueDatetime.UnixMilli() }, + ) +} + +func pushAll[T any]( + ctx context.Context, + ring Ring, + key string, + rows []T, + score func(T) int64, +) (int64, error) { + pushed := int64(0) + for _, r := range rows { + s := score(r) + if s == 0 { + continue + } + body, _ := json.Marshal(r) + if err := ring.Push(ctx, key, s, body); err != nil { + return pushed, err + } + pushed++ + } + return pushed, nil +} + +func lastNonZero[T any](rows []T, ok func(T) bool) *T { + for i := len(rows) - 1; i >= 0; i-- { + if ok(rows[i]) { + r := rows[i] + return &r + } + } + return nil +} + +func parseSWPCFloat(s string) (float64, bool) { + if s == "" { + return 0, false + } + f, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(f) { + return 0, false + } + return f, true +} + +func classifyXray(flux float64) string { + if flux <= 0 { + return "" + } + if flux >= xrayBaseX { + return fmt.Sprintf("X%.1f", flux/xrayBaseX) + } + if flux >= xrayBaseM { + return fmt.Sprintf("M%.1f", flux/xrayBaseM) + } + if flux >= xrayBaseC { + return fmt.Sprintf("C%.1f", flux/xrayBaseC) + } + if flux >= xrayBaseB { + return fmt.Sprintf("B%.1f", flux/xrayBaseB) + } + return "" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go new file mode 100644 index 00000000..dbfbec1b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go @@ -0,0 +1,246 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package swpc_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + plasma []swpc.PlasmaTick + mag []swpc.MagTick + kp []swpc.KpTick + xray []swpc.XrayTick + alerts []swpc.AlertItem + err error +} + +func (f *fakeFetcher) FetchPlasma( + _ context.Context, +) ([]swpc.PlasmaTick, error) { + return f.plasma, f.err +} + +func (f *fakeFetcher) FetchMag( + _ context.Context, +) ([]swpc.MagTick, error) { + return f.mag, f.err +} + +func (f *fakeFetcher) FetchKp( + _ context.Context, +) ([]swpc.KpTick, error) { + return f.kp, f.err +} + +func (f *fakeFetcher) FetchXray(_ context.Context) ([]swpc.XrayTick, error) { + return f.xray, f.err +} + +func (f *fakeFetcher) FetchAlerts(_ context.Context) ([]swpc.AlertItem, error) { + return f.alerts, f.err +} + +type fakeRing struct { + mu sync.Mutex + pushes map[string]int +} + +func (r *fakeRing) Push( + _ context.Context, + key string, + _ int64, + _ []byte, +) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.pushes == nil { + r.pushes = make(map[string]int) + } + r.pushes[key]++ + return nil +} + +func (r *fakeRing) PushCount(key string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.pushes[key] +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ +} + +func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + plasma: []swpc.PlasmaTick{ + {TimeTag: now, Density: "2.94", Speed: "450", Temperature: "93030"}, + }, + mag: []swpc.MagTick{{TimeTag: now, Bt: "5.6"}}, + xray: []swpc.XrayTick{ + {TimeTag: now, Flux: 1e-7, Energy: "0.1-0.8nm"}, + }, + alerts: []swpc.AlertItem{ + {ProductID: "TIIA", IssueDatetime: now, Message: "test alert"}, + }, + kp: []swpc.KpTick{{TimeTag: now, Kp: 3.0}}, + } + ring := &fakeRing{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: 20 * time.Millisecond, + SlowInterval: 50 * time.Millisecond, + Fetcher: ftch, + Ring: ring, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 80*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, ring.PushCount("swpc:plasma"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:mag"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:xray"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:alerts"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:kp"), 1) + + require.GreaterOrEqual(t, emt.Count(), 1) + for _, ev := range emt.events { + require.Equal(t, events.TopicSpaceWeather, ev.Topic) + } + require.Positive(t, st.successes) + require.Equal(t, 0, st.failures) +} + +func TestCollector_EmitsRichPayloadWithLatestReadings(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + plasma: []swpc.PlasmaTick{ + {TimeTag: now.Add(-2 * time.Minute), Density: "1.0", Speed: "300"}, + {TimeTag: now, Density: "5.42", Speed: "487"}, + }, + mag: []swpc.MagTick{ + {TimeTag: now.Add(-2 * time.Minute), BzGSM: "1.5"}, + {TimeTag: now, BzGSM: "-3.21"}, + }, + xray: []swpc.XrayTick{ + {TimeTag: now.Add(-2 * time.Minute), Flux: 1e-7}, + {TimeTag: now, Flux: 2.5e-5}, + }, + alerts: []swpc.AlertItem{}, + kp: []swpc.KpTick{{TimeTag: now, Kp: 4.0}}, + } + ring := &fakeRing{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: 20 * time.Millisecond, + SlowInterval: 25 * time.Millisecond, + Fetcher: ftch, + Ring: ring, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, emt.Count(), 1) + + emt.mu.Lock() + last := emt.events[len(emt.events)-1] + emt.mu.Unlock() + + raw, ok := last.Payload.(json.RawMessage) + require.True(t, ok, "expected payload to be json.RawMessage") + var payload map[string]any + require.NoError(t, json.Unmarshal(raw, &payload)) + + require.InDelta(t, 487.0, payload["speed_kms"], 0.001) + require.InDelta(t, 5.42, payload["density"], 0.001) + require.InDelta(t, -3.21, payload["bz_gsm"], 0.001) + require.InDelta(t, 2.5e-5, payload["xray_flux"], 1e-9) + require.Equal(t, "M2.5", payload["xray_class"]) + require.InDelta(t, 4.0, payload["kp"], 0.001) +} + +func TestCollector_FetchErrorsRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + ring := &fakeRing{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: 20 * time.Millisecond, + SlowInterval: 50 * time.Millisecond, + Fetcher: ftch, + Ring: ring, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, ring.PushCount("swpc:plasma")) + require.Positive(t, st.failures) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json new file mode 100644 index 00000000..f8bc3a23 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json @@ -0,0 +1,17 @@ +[ + { + "product_id": "TIIA", + "issue_datetime": "2026-05-01 15:50:32.247", + "message": "Space Weather Message Code: ALTTP2\r\nSerial Number: 1481\r\nIssue Time: 2026 May 01 1550 UTC\r\n\r\nALERT: Type II Radio Emission\r\nBegin Time: 2026 May 01 1535 UTC\r\nEstimated Velocity: 593 km/s\r\n\r\n\r\n\r\nDescription: Type II emissions occur in association with eruptions on the sun and typically indicate a coronal mass ejection is associated with a flare event." + }, + { + "product_id": "K05A", + "issue_datetime": "2026-04-30 23:59:26.927", + "message": "Space Weather Message Code: ALTK05\r\nSerial Number: 2013\r\nIssue Time: 2026 Apr 30 2359 UTC\r\n\r\nALERT: Geomagnetic K-index of 5\r\n Threshold Reached: 2026 Apr 30 2359 UTC\r\nSynoptic Period: 2100-2400 UTC\r\n \r\nActive Warning: Yes\r\nNOAA Scale: G1 - Minor\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 60 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nSpacecraft - Minor impact on satellite operations possible.\r\nAurora - Aurora may be visible at high latitudes, i.e., northern tier of the U.S. such as northern Michigan and Maine." + }, + { + "product_id": "K04W", + "issue_datetime": "2026-04-30 23:11:27.400", + "message": "Space Weather Message Code: WARK04\r\nSerial Number: 5332\r\nIssue Time: 2026 Apr 30 2311 UTC\r\n\r\nEXTENDED WARNING: Geomagnetic K-index of 4 expected\r\nExtension to Serial Number: 5331\r\nValid From: 2026 Apr 30 2005 UTC\r\nNow Valid Until: 2026 May 01 1500 UTC\r\nWarning Condition: Persistence\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 65 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nAurora - Aurora may be visible at high latitudes such as Canada and Alaska." + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json new file mode 100644 index 00000000..f5e0e768 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json @@ -0,0 +1,32 @@ +[ + { + "time_tag": "2026-04-25T00:00:00", + "Kp": 3.33, + "a_running": 18, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T03:00:00", + "Kp": 1.33, + "a_running": 5, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T06:00:00", + "Kp": 1.33, + "a_running": 5, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T09:00:00", + "Kp": 0.67, + "a_running": 3, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T12:00:00", + "Kp": 1.33, + "a_running": 5, + "station_count": 8 + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json new file mode 100644 index 00000000..b594b512 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json @@ -0,0 +1,38 @@ +[ + [ + "time_tag", + "bx_gsm", + "by_gsm", + "bz_gsm", + "lon_gsm", + "lat_gsm", + "bt" + ], + [ + "2026-05-02 08:20:00.000", + "0.36", + "0.12", + "4.41", + "18.07", + "85.10", + "4.43" + ], + [ + "2026-05-02 08:21:00.000", + "-0.32", + "-0.19", + "4.50", + "210.52", + "85.21", + "4.52" + ], + [ + "2026-05-02 08:22:00.000", + "-0.63", + "0.12", + "4.53", + "168.89", + "81.97", + "4.57" + ] +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json new file mode 100644 index 00000000..4dac31f0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json @@ -0,0 +1,26 @@ +[ + [ + "time_tag", + "density", + "speed", + "temperature" + ], + [ + "2026-05-02 08:20:00.000", + "2.94", + "450.6", + "93030" + ], + [ + "2026-05-02 08:21:00.000", + "2.90", + "448.0", + "96988" + ], + [ + "2026-05-02 08:22:00.000", + "2.65", + "452.1", + "84580" + ] +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json new file mode 100644 index 00000000..dbf1971c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json @@ -0,0 +1,47 @@ +[ + { + "time_tag": "2026-05-01T08:24:00Z", + "satellite": 18, + "flux": 7.719492600699596E-9, + "observed_flux": 1.051609821445254E-8, + "electron_correction": 2.79660583579755E-9, + "electron_contaminaton": false, + "energy": "0.05-0.4nm" + }, + { + "time_tag": "2026-05-01T08:24:00Z", + "satellite": 18, + "flux": 9.1065174956384E-7, + "observed_flux": 9.206618756252283E-7, + "electron_correction": 1.0010085205180985E-8, + "electron_contaminaton": false, + "energy": "0.1-0.8nm" + }, + { + "time_tag": "2026-05-01T08:25:00Z", + "satellite": 18, + "flux": 8.121048722387059E-9, + "observed_flux": 1.0973010944326234E-8, + "electron_correction": 2.851962221939175E-9, + "electron_contaminaton": false, + "energy": "0.05-0.4nm" + }, + { + "time_tag": "2026-05-01T08:25:00Z", + "satellite": 18, + "flux": 9.191372782879625E-7, + "observed_flux": 9.28974202452082E-7, + "electron_correction": 9.836912617799953E-9, + "electron_contaminaton": false, + "energy": "0.1-0.8nm" + }, + { + "time_tag": "2026-05-01T08:26:00Z", + "satellite": 18, + "flux": 6.153450637924607E-9, + "observed_flux": 9.192835825899692E-9, + "electron_correction": 3.0393856320642954E-9, + "electron_contaminaton": false, + "energy": "0.05-0.4nm" + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go new file mode 100644 index 00000000..43f4e062 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// client.go + +package usgs + +import ( + "context" + "fmt" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultUSGSBaseURL = "https://earthquake.usgs.gov" + pathQuakes2_5_day = "/earthquakes/feed/v1.0/summary/2.5_day.geojson" + defaultUSGSRate = 100 * time.Millisecond + defaultUSGSBurst = 5 + defaultUSGSBudget = 5 + defaultUSGSBreaker = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultUSGSBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "usgs", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultUSGSRate), + Burst: defaultUSGSBurst, + ConsecutiveFailureBudget: defaultUSGSBudget, + BreakerTimeout: defaultUSGSBreaker, + }), + } +} + +type Feed struct { + Type string `json:"type"` + Features []Feature `json:"features"` +} + +type Feature struct { + Type string `json:"type"` + ID string `json:"id"` + Properties Properties `json:"properties"` + Geometry Geometry `json:"geometry"` +} + +type Properties struct { + Mag float64 `json:"mag"` + Place string `json:"place"` + Time int64 `json:"time"` + Updated int64 `json:"updated"` + Alert string `json:"alert"` + Tsunami int `json:"tsunami"` + URL string `json:"url"` + Type string `json:"type"` +} + +func (p Properties) OccurredAt() time.Time { + return time.UnixMilli(p.Time).UTC() +} + +type Geometry struct { + Type string `json:"type"` + Coordinates []float64 `json:"coordinates"` +} + +func (c *Client) Fetch(ctx context.Context) (Feed, error) { + var feed Feed + if err := c.hx.GetJSON(ctx, pathQuakes2_5_day, nil, &feed); err != nil { + return Feed{}, fmt.Errorf("fetch usgs feed: %w", err) + } + return feed, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go new file mode 100644 index 00000000..796bb6b1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// client_test.go + +package usgs_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" +) + +func TestClient_FetchDecodesFeatures(t *testing.T) { + body, err := os.ReadFile("testdata/2_5_day.geojson") + require.NoError(t, err) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/geo+json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := usgs.NewClient(usgs.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + feed, err := c.Fetch(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(feed.Features), 1) + + f := feed.Features[0] + require.NotEmpty(t, f.ID) + require.NotEmpty(t, f.Properties.Place) + require.GreaterOrEqual(t, f.Properties.Mag, 2.5) + require.Len(t, f.Geometry.Coordinates, 3) + require.False(t, f.Properties.OccurredAt().IsZero()) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go new file mode 100644 index 00000000..7ce3734f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// collector.go + +package usgs + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "usgs" + defaultUSGSInterval = time.Minute + defaultLogPlaceLimit = 64 +) + +type Fetcher interface { + Fetch(ctx context.Context) (Feed, error) +} + +type Repository interface { + Upsert(ctx context.Context, row Row) error + KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultUSGSInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + c.tick(ctx) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + feed, err := c.cfg.Fetcher.Fetch(ctx) + if err != nil { + c.logger.Warn("usgs fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(feed.Features)) + for _, f := range feed.Features { + ids = append(ids, f.ID) + } + known, err := c.cfg.Repo.KnownIDs(ctx, ids) + if err != nil { + c.logger.Warn("usgs known ids", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + emitted := int64(0) + now := time.Now().UTC() + for _, f := range feed.Features { + raw, _ := json.Marshal(f) + row := Row{ + ID: f.ID, + OccurredAt: f.Properties.OccurredAt(), + Mag: f.Properties.Mag, + Place: f.Properties.Place, + GeomLat: coord(f.Geometry.Coordinates, 1), + GeomLon: coord(f.Geometry.Coordinates, 0), + DepthKm: coord(f.Geometry.Coordinates, 2), + Payload: raw, + } + if err := c.cfg.Repo.Upsert(ctx, row); err != nil { + c.logger.Warn("usgs upsert", "id", row.ID, "err", err) + continue + } + if known[f.ID] { + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicEarthquake, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(raw), + }) + emitted++ + } + c.cfg.State.RecordSuccess(ctx, Name, emitted) +} + +func coord(c []float64, i int) float64 { + if i < len(c) { + return c[i] + } + return 0 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go new file mode 100644 index 00000000..9d9ee1c3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go @@ -0,0 +1,240 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package usgs_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + feed usgs.Feed + err error + mu sync.Mutex + n int +} + +func (f *fakeFetcher) Fetch(_ context.Context) (usgs.Feed, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.n++ + if f.err != nil { + return usgs.Feed{}, f.err + } + return f.feed, nil +} + +type fakeRepo struct { + mu sync.Mutex + known map[string]bool + upserts []usgs.Row +} + +func (r *fakeRepo) Upsert(_ context.Context, row usgs.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.upserts = append(r.upserts, row) + if r.known == nil { + r.known = make(map[string]bool) + } + r.known[row.ID] = true + return nil +} + +func (r *fakeRepo) KnownIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]bool, len(ids)) + for _, id := range ids { + if r.known[id] { + out[id] = true + } + } + return out, nil +} + +func (r *fakeRepo) Upserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.upserts) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int + lastErr string +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ +} + +func (s *recordingState) RecordError(_ context.Context, _, msg string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + s.lastErr = msg +} + +func TestCollector_TickPersistsAndEmitsNewQuakes(t *testing.T) { + feed := usgs.Feed{ + Type: "FeatureCollection", + Features: []usgs.Feature{ + { + ID: "q1", + Properties: usgs.Properties{ + Mag: 4.5, + Place: "test 1", + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{-120, 49, 5}}, + }, + { + ID: "q2", + Properties: usgs.Properties{ + Mag: 6.5, + Place: "test 2", + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{140, -30, 10}}, + }, + { + ID: "q3", + Properties: usgs.Properties{ + Mag: 3.0, + Place: "test 3", + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}, + }, + }, + } + ftch := &fakeFetcher{feed: feed} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := usgs.NewCollector(usgs.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 70*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual( + t, + repo.Upserts(), + 3, + "should upsert all 3 features at least once", + ) + require.GreaterOrEqual(t, emt.Count(), 3, "should emit 3 new-event events") + for _, ev := range emt.events { + require.Equal(t, events.TopicEarthquake, ev.Topic) + require.Equal(t, usgs.Name, ev.Source) + } + require.Positive(t, st.successes) + require.Equal(t, 0, st.failures) +} + +func TestCollector_FetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := usgs.NewCollector(usgs.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Upserts()) + require.Equal(t, 0, emt.Count()) + require.Positive(t, st.failures) + require.Contains(t, st.lastErr, "upstream 503") +} + +func TestCollector_KnownQuakesNotReEmitted(t *testing.T) { + feed := usgs.Feed{ + Features: []usgs.Feature{ + { + ID: "qx", + Properties: usgs.Properties{ + Mag: 4.5, + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}, + }, + }, + } + ftch := &fakeFetcher{feed: feed} + repo := &fakeRepo{known: map[string]bool{"qx": true}} + emt := &fakeEmitter{} + st := &recordingState{} + + c := usgs.NewCollector(usgs.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, emt.Count(), "known quake should not re-emit") +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go new file mode 100644 index 00000000..ce53a573 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go @@ -0,0 +1,103 @@ +// ©AngelaMos | 2026 +// repo.go + +package usgs + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type Row struct { + ID string `db:"id"` + OccurredAt time.Time `db:"occurred_at"` + Mag float64 `db:"mag"` + Place string `db:"place"` + GeomLon float64 `db:"geom_lon"` + GeomLat float64 `db:"geom_lat"` + DepthKm float64 `db:"depth_km"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Upsert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO earthquakes (id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + occurred_at = EXCLUDED.occurred_at, + mag = EXCLUDED.mag, + place = EXCLUDED.place, + geom_lon = EXCLUDED.geom_lon, + geom_lat = EXCLUDED.geom_lat, + depth_km = EXCLUDED.depth_km, + payload = EXCLUDED.payload`, + row.ID, row.OccurredAt, row.Mag, row.Place, + row.GeomLon, row.GeomLat, row.DepthKm, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("upsert earthquake %s: %w", row.ID, err) + } + return nil +} + +func (r *Repo) KnownIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { + out := make(map[string]bool, len(ids)) + if len(ids) == 0 { + return out, nil + } + var found []string + err := r.db.SelectContext(ctx, &found, + `SELECT id FROM earthquakes WHERE id = ANY($1)`, + pq.Array(ids), + ) + if err != nil { + return nil, fmt.Errorf("known earthquake ids: %w", err) + } + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) RecentByMag(ctx context.Context, limit int) ([]Row, error) { + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload + FROM earthquakes + ORDER BY mag DESC, occurred_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent earthquakes: %w", err) + } + return rows, nil +} + +func (r *Repo) RecentByTime(ctx context.Context, limit int) ([]Row, error) { + if limit <= 0 { + limit = 50 + } + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload + FROM earthquakes + ORDER BY occurred_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent earthquakes by time: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go new file mode 100644 index 00000000..e17aa447 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go @@ -0,0 +1,145 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package usgs_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE earthquakes ( + id text PRIMARY KEY, + occurred_at timestamptz NOT NULL, + mag numeric(3,1) NOT NULL, + place text, + geom_lon double precision NOT NULL, + geom_lat double precision NOT NULL, + depth_km double precision, + payload jsonb NOT NULL + );`) + require.NoError(t, err) + return db +} + +func TestRepo_UpsertOverwritesRevisions(t *testing.T) { + db := setupDB(t) + repo := usgs.NewRepo(db) + ctx := context.Background() + + row := usgs.Row{ + ID: "us6000abcd", + OccurredAt: time.Now().UTC().Add(-time.Hour), + Mag: 5.5, + Place: "100 km SW of test", + GeomLon: -130.5, + GeomLat: 49.2, + DepthKm: 10.0, + Payload: json.RawMessage(`{"v":1}`), + } + require.NoError(t, repo.Upsert(ctx, row)) + + row.Mag = 5.8 + row.Payload = json.RawMessage(`{"v":2}`) + require.NoError(t, repo.Upsert(ctx, row)) + + got, err := repo.RecentByMag(ctx, 1) + require.NoError(t, err) + require.Len(t, got, 1) + require.InDelta(t, 5.8, got[0].Mag, 0.0001) +} + +func TestRepo_KnownIDsBulkLookup(t *testing.T) { + db := setupDB(t) + repo := usgs.NewRepo(db) + ctx := context.Background() + + for _, id := range []string{"a", "b", "c"} { + require.NoError(t, repo.Upsert(ctx, usgs.Row{ + ID: id, OccurredAt: time.Now().UTC(), Mag: 4.5, + GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`), + })) + } + + known, err := repo.KnownIDs(ctx, []string{"a", "b", "x", "y"}) + require.NoError(t, err) + require.True(t, known["a"]) + require.True(t, known["b"]) + require.False(t, known["x"]) + require.False(t, known["y"]) +} + +func TestRepo_RecentByMagOrdered(t *testing.T) { + db := setupDB(t) + repo := usgs.NewRepo(db) + ctx := context.Background() + + now := time.Now().UTC() + rows := []usgs.Row{ + { + ID: "small", + OccurredAt: now, + Mag: 4.5, + GeomLon: 0, + GeomLat: 0, + Payload: json.RawMessage(`{}`), + }, + { + ID: "huge", + OccurredAt: now, + Mag: 7.8, + GeomLon: 0, + GeomLat: 0, + Payload: json.RawMessage(`{}`), + }, + { + ID: "mid", + OccurredAt: now, + Mag: 6.0, + GeomLon: 0, + GeomLat: 0, + Payload: json.RawMessage(`{}`), + }, + } + for _, r := range rows { + require.NoError(t, repo.Upsert(ctx, r)) + } + + got, err := repo.RecentByMag(ctx, 3) + require.NoError(t, err) + require.Len(t, got, 3) + require.Equal(t, "huge", got[0].ID) + require.Equal(t, "mid", got[1].ID) + require.Equal(t, "small", got[2].ID) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson new file mode 100644 index 00000000..60354c9e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson @@ -0,0 +1,141 @@ +{ + "type": "FeatureCollection", + "metadata": { + "generated": 1777710093000, + "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson", + "title": "USGS Magnitude 2.5+ Earthquakes, Past Day", + "status": 200, + "api": "2.4.0", + "count": 39 + }, + "features": [ + { + "type": "Feature", + "properties": { + "mag": 2.8, + "place": "18 km NNW of Jal, New Mexico", + "time": 1777707261749, + "updated": 1777707667040, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000shr9", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us7000shr9.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 121, + "net": "us", + "code": "7000shr9", + "ids": ",us7000shr9,", + "sources": ",us,", + "types": ",origin,phase-data,", + "nst": 35, + "dmin": 0.255, + "rms": 0.56, + "gap": 57, + "magType": "mb_lg", + "type": "earthquake", + "title": "M 2.8 - 18 km NNW of Jal, New Mexico" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -103.2923, + 32.2604, + 5.109 + ] + }, + "id": "us7000shr9" + }, + { + "type": "Feature", + "properties": { + "mag": 2.97, + "place": "27 km SW of Esperanza, Puerto Rico", + "time": 1777705903970, + "updated": 1777707214900, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/pr71515393", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/pr71515393.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 136, + "net": "pr", + "code": "71515393", + "ids": ",pr71515393,", + "sources": ",pr,", + "types": ",origin,phase-data,", + "nst": 15, + "dmin": 0.278, + "rms": 0.29, + "gap": 171, + "magType": "md", + "type": "earthquake", + "title": "M 3.0 - 27 km SW of Esperanza, Puerto Rico" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -65.6316666666667, + 17.898, + 7.84 + ] + }, + "id": "pr71515393" + }, + { + "type": "Feature", + "properties": { + "mag": 4.6, + "place": "79 km N of Calama, Chile", + "time": 1777702962852, + "updated": 1777704100040, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000shqp", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us7000shqp.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 326, + "net": "us", + "code": "7000shqp", + "ids": ",us7000shqp,", + "sources": ",us,", + "types": ",origin,phase-data,", + "nst": 58, + "dmin": 0.437, + "rms": 1.41, + "gap": 37, + "magType": "mb", + "type": "earthquake", + "title": "M 4.6 - 79 km N of Calama, Chile" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -68.7738, + -21.7539, + 115.882 + ] + }, + "id": "us7000shqp" + } + ], + "bbox": [ + -178.5514, + -57.7679, + 5.109, + 178.8233, + 61.621, + 532.819 + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go new file mode 100644 index 00000000..955194e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go @@ -0,0 +1,69 @@ +// ©AngelaMos | 2026 +// client.go + +package wikipedia + +import ( + "context" + "fmt" + "io" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultWikiBaseURL = "https://en.wikipedia.org" + pathAPI = "/w/api.php" + defaultWikiRate = 200 * time.Millisecond + defaultWikiBurst = 5 + defaultWikiBudget = 5 + defaultWikiBreaker = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultWikiBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "wikipedia", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultWikiRate), + Burst: defaultWikiBurst, + ConsecutiveFailureBudget: defaultWikiBudget, + BreakerTimeout: defaultWikiBreaker, + }), + } +} + +func (c *Client) Fetch(ctx context.Context) (Response, error) { + q := url.Values{} + q.Set("action", "parse") + q.Set("page", "Template:In_the_news") + q.Set("prop", "text|revid") + q.Set("format", "json") + + resp, err := c.hx.Get(ctx, pathAPI, q) + if err != nil { + return Response{}, fmt.Errorf("fetch wikipedia ITN: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return Response{}, fmt.Errorf("read wikipedia ITN body: %w", err) + } + return DecodeResponse(body) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go new file mode 100644 index 00000000..b6524f23 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// client_test.go + +package wikipedia_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" +) + +func TestClient_FetchDecodesITNResponse(t *testing.T) { + body, err := os.ReadFile("testdata/itn_response.json") + require.NoError(t, err) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/w/api.php", r.URL.Path) + require.Equal(t, "parse", r.URL.Query().Get("action")) + require.Equal(t, "Template:In_the_news", r.URL.Query().Get("page")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := wikipedia.NewClient(wikipedia.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + resp, err := c.Fetch(ctx) + require.NoError(t, err) + require.NotZero(t, resp.RevID) + require.NotEmpty(t, resp.HTML) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go new file mode 100644 index 00000000..a6c4cc85 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go @@ -0,0 +1,138 @@ +// ©AngelaMos | 2026 +// collector.go + +package wikipedia + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "wikipedia" + defaultWikiInterval = 5 * time.Minute + idHashBytes = 16 +) + +type Fetcher interface { + Fetch(ctx context.Context) (Response, error) +} + +type Repository interface { + RememberRevID(ctx context.Context, revID int64) error + LastRevID(ctx context.Context) (int64, bool, error) + Insert(ctx context.Context, e Entry) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultWikiInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + c.tick(ctx) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + resp, err := c.cfg.Fetcher.Fetch(ctx) + if err != nil { + c.logger.Warn("wikipedia fetch", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + last, found, err := c.cfg.Repo.LastRevID(ctx) + if err != nil { + c.logger.Warn("wikipedia revid lookup", "err", err) + c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + if found && last == resp.RevID { + c.cfg.State.RecordSuccess(ctx, Name, 0) + return + } + + entries := ParseEntries(resp.HTML) + now := time.Now().UTC() + emitted := int64(0) + for _, e := range entries { + id := entryID(e) + body, _ := json.Marshal(map[string]any{ + "text": e.Text, + "slug": e.ArticleSlug, + }) + entry := Entry{ + ID: id, + Headline: e.Text, + OccurredAt: now, + Payload: body, + } + if err := c.cfg.Repo.Insert(ctx, entry); err != nil { + c.logger.Warn("wikipedia insert", "id", id, "err", err) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicWikipediaITN, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(body), + }) + emitted++ + } + + if err := c.cfg.Repo.RememberRevID(ctx, resp.RevID); err != nil { + c.logger.Warn("wikipedia remember revid", "err", err) + } + c.cfg.State.RecordSuccess(ctx, Name, emitted) +} + +func entryID(e ITNEntry) string { + h := sha256.Sum256([]byte(e.Text + "|" + e.ArticleSlug)) + return hex.EncodeToString(h[:idHashBytes]) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go new file mode 100644 index 00000000..15451c66 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go @@ -0,0 +1,188 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package wikipedia_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + resp wikipedia.Response + err error +} + +func (f *fakeFetcher) Fetch(_ context.Context) (wikipedia.Response, error) { + return f.resp, f.err +} + +type fakeRepo struct { + mu sync.Mutex + revID int64 + revKnown bool + inserts []wikipedia.Entry + saveCalls int +} + +func (r *fakeRepo) RememberRevID(_ context.Context, revID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + r.revID = revID + r.revKnown = true + r.saveCalls++ + return nil +} + +func (r *fakeRepo) LastRevID(_ context.Context) (int64, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.revID, r.revKnown, nil +} + +func (r *fakeRepo) Insert(_ context.Context, e wikipedia.Entry) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserts = append(r.inserts, e) + return nil +} + +func (r *fakeRepo) Inserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.inserts) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ +} + +func TestCollector_NewRevidInsertsAndEmits(t *testing.T) { + resp := wikipedia.Response{ + RevID: 999, + HTML: `
    ` + + `
  • A long enough ITN headline mentioning subject A for context.
  • ` + + `
  • Another long enough ITN headline referencing subject B in the news.
  • ` + + `
`, + } + ftch := &fakeFetcher{resp: resp} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Inserts(), 2) + require.GreaterOrEqual(t, emt.Count(), 2) + for _, ev := range emt.events { + require.Equal(t, events.TopicWikipediaITN, ev.Topic) + } + require.Positive(t, st.successes) +} + +func TestCollector_RevIDUnchangedSkipsInsert(t *testing.T) { + resp := wikipedia.Response{ + RevID: 555, + HTML: ``, + } + ftch := &fakeFetcher{resp: resp} + repo := &fakeRepo{revID: 555, revKnown: true} + emt := &fakeEmitter{} + st := &recordingState{} + + c := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts()) + require.Equal(t, 0, emt.Count()) + require.Positive(t, st.successes) +} + +func TestCollector_FetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts()) + require.Positive(t, st.failures) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go new file mode 100644 index 00000000..6cde9e6a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go @@ -0,0 +1,82 @@ +// ©AngelaMos | 2026 +// parser.go + +package wikipedia + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type Response struct { + RevID int64 + HTML string +} + +type ITNEntry struct { + Text string + ArticleSlug string +} + +type rawAPI struct { + Parse struct { + Title string `json:"title"` + RevID int64 `json:"revid"` + Text struct { + Star string `json:"*"` + } `json:"text"` + } `json:"parse"` +} + +func DecodeResponse(body []byte) (Response, error) { + var r rawAPI + if err := json.Unmarshal(body, &r); err != nil { + return Response{}, fmt.Errorf("decode wikipedia response: %w", err) + } + return Response{RevID: r.Parse.RevID, HTML: r.Parse.Text.Star}, nil +} + +const minEntryLen = 50 + +func ParseEntries(html string) []ITNEntry { + if strings.TrimSpace(html) == "" { + return nil + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil + } + var out []ITNEntry + doc.Find("ul li").Each(func(_ int, s *goquery.Selection) { + text := strings.TrimSpace(s.Text()) + if text == "" || len(text) < minEntryLen || + !strings.Contains(text, " ") { + return + } + href, ok := s.Find("a").First().Attr("href") + if !ok { + return + } + slug := slugFromHref(href) + if slug == "" { + return + } + out = append(out, ITNEntry{Text: text, ArticleSlug: slug}) + }) + return out +} + +func slugFromHref(href string) string { + const prefix = "/wiki/" + if !strings.HasPrefix(href, prefix) { + return "" + } + slug := strings.TrimPrefix(href, prefix) + if hash := strings.Index(slug, "#"); hash >= 0 { + slug = slug[:hash] + } + return slug +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go new file mode 100644 index 00000000..901ec9bd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go @@ -0,0 +1,64 @@ +// ©AngelaMos | 2026 +// parser_test.go + +package wikipedia_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" +) + +func TestParser_ExtractsITNEntriesFromFixture(t *testing.T) { + body, err := os.ReadFile("testdata/itn_response.json") + require.NoError(t, err) + + resp, err := wikipedia.DecodeResponse(body) + require.NoError(t, err) + require.NotZero(t, resp.RevID) + + entries := wikipedia.ParseEntries(resp.HTML) + require.GreaterOrEqual(t, len(entries), 1) + for _, e := range entries { + require.NotEmpty(t, e.Text) + } + hasLink := false + for _, e := range entries { + if e.ArticleSlug != "" { + hasLink = true + break + } + } + require.True(t, hasLink, "at least one entry should carry an article slug") +} + +func TestParser_HandlesEmptyHTML(t *testing.T) { + entries := wikipedia.ParseEntries("") + require.Empty(t, entries) +} + +func TestParser_StripsHTMLTagsFromText(t *testing.T) { + entries := wikipedia.ParseEntries( + `
  • A reasonably long ITN-style sentence with bold and an inline linked phrase for context.
`, + ) + require.Len(t, entries, 1) + require.Contains(t, entries[0].Text, "linked phrase") + require.NotContains(t, entries[0].Text, "") + require.Equal(t, "Topic", entries[0].ArticleSlug) +} + +func TestParser_SkipsListItemsWithoutLinks(t *testing.T) { + entries := wikipedia.ParseEntries( + `
  • A long enough sentence with an actual linked article reference embedded.
  • Another long sentence that contains no link element at all in the body text.
`, + ) + require.Len( + t, + entries, + 1, + "items without an article slug should be filtered out", + ) + require.Equal(t, "Foo", entries[0].ArticleSlug) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go new file mode 100644 index 00000000..913ad301 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go @@ -0,0 +1,73 @@ +// ©AngelaMos | 2026 +// repo.go + +package wikipedia + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" +) + +const ( + keyRevID = "wiki:itn:revid" + sourceWikiITN = "wiki_itn" +) + +type Entry struct { + ID string + Headline string + OccurredAt time.Time + Payload json.RawMessage +} + +type Repo struct { + db *sqlx.DB + rdb *redis.Client +} + +func NewRepo(db *sqlx.DB, rdb *redis.Client) *Repo { + return &Repo{db: db, rdb: rdb} +} + +func (r *Repo) RememberRevID(ctx context.Context, revID int64) error { + if err := r.rdb.Set(ctx, keyRevID, strconv.FormatInt(revID, 10), 0). + Err(); err != nil { + return fmt.Errorf("save wiki revid: %w", err) + } + return nil +} + +func (r *Repo) LastRevID(ctx context.Context) (int64, bool, error) { + s, err := r.rdb.Get(ctx, keyRevID).Result() + if errors.Is(err, redis.Nil) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("load wiki revid: %w", err) + } + rev, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, false, fmt.Errorf("parse wiki revid %q: %w", s, err) + } + return rev, true, nil +} + +func (r *Repo) Insert(ctx context.Context, e Entry) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO world_events (id, source, occurred_at, headline, payload) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING`, + e.ID, sourceWikiITN, e.OccurredAt, e.Headline, []byte(e.Payload), + ) + if err != nil { + return fmt.Errorf("insert wiki event %s: %w", e.ID, err) + } + return nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json new file mode 100644 index 00000000..365f36b6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json @@ -0,0 +1 @@ +{"parse":{"title":"Template:In the news","pageid":482256,"revid":1352110642,"text":{"*":"
\n
\n\"Sabastian
Sabastian Sawe
\n
\n\n
\n
Ongoing:
\n
\n\n
\n
\n
\n\"\" Template documentation[view] [edit] [history] [purge]
\n\n\n
\"Click\nIn the news toolbox
\n
\n\n
For a talk-page template to indicate that a Wikipedia page has been mentioned by a media organisation, see Template:Press.
\n

\"In the news\" mentions and links to entries of timely interestencyclopedia articles that have been updated to reflect an important current event – rather than conventional news items. To prevent vandalism on the Main Page, it is a permanently protected template. See Wikipedia:In the news for more details. \n

See an error? Please note it on Wikipedia:Main Page/Errors for administrator assistance.\n

The template accepts one parameter: adding |nocurrenteventslink=1 removes the link to Portal:Current events. This is used on the current events page to hide the link there.\n

Admins: After updating please purge the cache of the Main Page so that the updated version appears.\n

In terms of Main Page balance, right now we're configured for widescreen monitors, which it appears most editors use. Non-widescreen users, please do not change the balance even if it looks off on your monitor.\n

Click here to purge the template's cache manually, if it does not appear to be updating on the Main Page after you edit it.\n

\n

Main page template. \"In the news\" mentions and links to entries of timely interest \u2013 encyclopedia articles that have been updated to reflect an important current event \u2013 rather than conventional news items. To prevent vandalism on the Main Page, it is a permanently protected template. See [[Wikipedia:In the news]] for more details.

Template parameters[Edit template data]

ParameterDescriptionTypeStatus
No 'Current events' linknocurrenteventslink

Removes the link to [[Portal:Current events]]. This is used on the current events page to hide the link there

Example
1
Auto value
1
Booleanoptional
\n
\n
\n
\n\n\n\n\n\n\n\n
"}}} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go new file mode 100644 index 00000000..2d0d114a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -0,0 +1,427 @@ +// ©AngelaMos | 2026 +// config.go + +package config + +import ( + "fmt" + "sync" + "time" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + JWT JWTConfig `koanf:"jwt"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Log LogConfig `koanf:"log"` + Otel OtelConfig `koanf:"otel"` + Collectors CollectorsConfig `koanf:"collectors"` + Notifications NotificationsConfig `koanf:"notifications"` +} + +type CollectorsConfig struct { + DShield SourceConfig `koanf:"dshield"` + CFRadar CFRadarConfig `koanf:"cfradar"` + CVE CVEConfig `koanf:"cve"` + KEV SourceConfig `koanf:"kev"` + Ransomware SourceConfig `koanf:"ransomware"` + GreyNoise GreyNoiseConfig `koanf:"greynoise"` + AbuseIPDB AbuseIPDBConfig `koanf:"abuseipdb"` + Coinbase CoinbaseConfig `koanf:"coinbase"` + USGS SourceConfig `koanf:"usgs"` + SWPC SWPCConfig `koanf:"swpc"` + Wikipedia SourceConfig `koanf:"wikipedia"` + GDELT SourceConfig `koanf:"gdelt"` + ISS ISSConfig `koanf:"iss"` +} + +type SourceConfig struct { + Enabled bool `koanf:"enabled"` + Interval time.Duration `koanf:"interval"` +} + +type CFRadarConfig struct { + Enabled bool `koanf:"enabled"` + Interval time.Duration `koanf:"interval"` + BearerToken string `koanf:"bearer_token"` + MinConfidence int `koanf:"min_confidence"` +} + +type CVEConfig struct { + Enabled bool `koanf:"enabled"` + Interval time.Duration `koanf:"interval"` + Window time.Duration `koanf:"window"` + NVDAPIKey string `koanf:"nvd_api_key"` +} + +type GreyNoiseConfig struct { + Enabled bool `koanf:"enabled"` + APIKey string `koanf:"api_key"` +} + +type AbuseIPDBConfig struct { + Enabled bool `koanf:"enabled"` + APIKey string `koanf:"api_key"` +} + +type CoinbaseConfig struct { + Enabled bool `koanf:"enabled"` + URL string `koanf:"url"` + ProductIDs []string `koanf:"product_ids"` + Throttle time.Duration `koanf:"throttle"` +} + +type SWPCConfig struct { + Enabled bool `koanf:"enabled"` + FastInterval time.Duration `koanf:"fast_interval"` + SlowInterval time.Duration `koanf:"slow_interval"` +} + +type ISSConfig struct { + Enabled bool `koanf:"enabled"` + PositionInterval time.Duration `koanf:"position_interval"` + TLEInterval time.Duration `koanf:"tle_interval"` +} + +type AppConfig struct { + Name string `koanf:"name"` + Version string `koanf:"version"` + Environment string `koanf:"environment"` + PublicURL string `koanf:"public_url"` + AdminEmail string `koanf:"admin_email"` +} + +type NotificationsConfig struct { + EncryptionKey string `koanf:"encryption_key"` +} + +type ServerConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` + TrustedProxyHops int `koanf:"trusted_proxy_hops"` + WSMaxConnsPerIP int `koanf:"ws_max_conns_per_ip"` + WSMaxSubscribers int `koanf:"ws_max_subscribers"` +} + +type DatabaseConfig struct { + URL string `koanf:"url"` + MaxOpenConns int `koanf:"max_open_conns"` + MaxIdleConns int `koanf:"max_idle_conns"` + ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"` +} + +type RedisConfig struct { + URL string `koanf:"url"` + PoolSize int `koanf:"pool_size"` + MinIdleConns int `koanf:"min_idle_conns"` +} + +type JWTConfig struct { + PrivateKeyPath string `koanf:"private_key_path"` + PublicKeyPath string `koanf:"public_key_path"` + AccessTokenExpire time.Duration `koanf:"access_token_expire"` + RefreshTokenExpire time.Duration `koanf:"refresh_token_expire"` + Issuer string `koanf:"issuer"` + Audience string `koanf:"audience"` +} + +type RateLimitConfig struct { + Requests int `koanf:"requests"` + Window time.Duration `koanf:"window"` + Burst int `koanf:"burst"` +} + +type CORSConfig struct { + AllowedOrigins []string `koanf:"allowed_origins"` + AllowedMethods []string `koanf:"allowed_methods"` + AllowedHeaders []string `koanf:"allowed_headers"` + AllowCredentials bool `koanf:"allow_credentials"` + MaxAge int `koanf:"max_age"` +} + +type LogConfig struct { + Level string `koanf:"level"` + Format string `koanf:"format"` +} + +type OtelConfig struct { + Endpoint string `koanf:"endpoint"` + ServiceName string `koanf:"service_name"` + Enabled bool `koanf:"enabled"` + Insecure bool `koanf:"insecure"` + SampleRate float64 `koanf:"sample_rate"` +} + +var ( + cfg *Config + once sync.Once +) + +func Load(configPath string) (*Config, error) { + var loadErr error + + once.Do(func() { + k := koanf.New(".") + + if err := loadDefaults(k); err != nil { + loadErr = fmt.Errorf("load defaults: %w", err) + return + } + + if configPath != "" { + if err := k.Load( + file.Provider(configPath), + yaml.Parser(), + ); err != nil { + loadErr = fmt.Errorf("load config file: %w", err) + return + } + } + + if err := k.Load( + env.Provider("", ".", envKeyReplacer), + nil, + ); err != nil { + loadErr = fmt.Errorf("load env vars: %w", err) + return + } + + cfg = &Config{} + if err := k.Unmarshal("", cfg); err != nil { + loadErr = fmt.Errorf("unmarshal config: %w", err) + return + } + + if err := validate(cfg); err != nil { + loadErr = fmt.Errorf("validate config: %w", err) + return + } + }) + + if loadErr != nil { + return nil, loadErr + } + + return cfg, nil +} + +func Get() *Config { + if cfg == nil { + panic("config not loaded: call Load() first") + } + return cfg +} + +func loadDefaults(k *koanf.Koanf) error { + defaults := map[string]any{ + "app.name": "Go Backend", + "app.version": "1.0.0", + "app.environment": "development", + + "server.host": "0.0.0.0", + "server.port": 8080, + "server.read_timeout": "30s", + "server.write_timeout": "30s", + "server.idle_timeout": "120s", + "server.shutdown_timeout": "15s", + "server.trusted_proxy_hops": 0, + "server.ws_max_conns_per_ip": 25, + "server.ws_max_subscribers": 5000, + + "database.max_open_conns": 25, + "database.max_idle_conns": 5, + "database.conn_max_lifetime": "1h", + "database.conn_max_idle_time": "30m", + + "redis.pool_size": 10, + "redis.min_idle_conns": 5, + + "jwt.access_token_expire": "15m", + "jwt.refresh_token_expire": "168h", + "jwt.issuer": "go-backend", + "jwt.audience": "go-backend-api", + "jwt.private_key_path": "keys/private.pem", + "jwt.public_key_path": "keys/public.pem", + + "rate_limit.requests": 100, + "rate_limit.window": "1m", + "rate_limit.burst": 20, + + "cors.allowed_origins": []string{"http://localhost:3000"}, + "cors.allowed_methods": []string{ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + }, + "cors.allowed_headers": []string{ + "Accept", + "Authorization", + "Content-Type", + "X-Request-ID", + }, + "cors.allow_credentials": true, + "cors.max_age": 300, + + "log.level": "info", + "log.format": "json", + + "otel.enabled": false, + "otel.insecure": true, + "otel.sample_rate": 0.1, + "otel.service_name": "go-backend", + + "collectors.dshield.enabled": true, + "collectors.dshield.interval": "1h", + "collectors.cfradar.enabled": true, + "collectors.cfradar.interval": "5m", + "collectors.cfradar.min_confidence": 7, + "collectors.cve.enabled": true, + "collectors.cve.interval": "5m", + "collectors.cve.window": "6m", + "collectors.kev.enabled": true, + "collectors.kev.interval": "1h", + "collectors.ransomware.enabled": true, + "collectors.ransomware.interval": "15m", + "collectors.greynoise.enabled": true, + "collectors.abuseipdb.enabled": true, + "collectors.coinbase.enabled": true, + "collectors.coinbase.url": "wss://advanced-trade-ws.coinbase.com", + "collectors.coinbase.product_ids": []string{"BTC-USD", "ETH-USD"}, + "collectors.coinbase.throttle": "250ms", + "collectors.usgs.enabled": true, + "collectors.usgs.interval": "1m", + "collectors.swpc.enabled": true, + "collectors.swpc.fast_interval": "1m", + "collectors.swpc.slow_interval": "3h", + "collectors.wikipedia.enabled": true, + "collectors.wikipedia.interval": "5m", + "collectors.gdelt.enabled": true, + "collectors.gdelt.interval": "15m", + "collectors.iss.enabled": true, + "collectors.iss.position_interval": "10s", + "collectors.iss.tle_interval": "24h", + } + + for key, value := range defaults { + if err := k.Set(key, value); err != nil { + return fmt.Errorf("set default %s: %w", key, err) + } + } + + return nil +} + +var envKeyMap = map[string]string{ + "DATABASE_URL": "database.url", + "REDIS_URL": "redis.url", + "ENVIRONMENT": "app.environment", + "HOST": "server.host", + "PORT": "server.port", + "TRUSTED_PROXY_HOPS": "server.trusted_proxy_hops", + "WS_MAX_CONNS_PER_IP": "server.ws_max_conns_per_ip", + "WS_MAX_SUBSCRIBERS": "server.ws_max_subscribers", + "LOG_LEVEL": "log.level", + "LOG_FORMAT": "log.format", + "JWT_PRIVATE_KEY_PATH": "jwt.private_key_path", + "JWT_PUBLIC_KEY_PATH": "jwt.public_key_path", + "JWT_ACCESS_TOKEN_EXPIRE": "jwt.access_token_expire", + "JWT_REFRESH_TOKEN_EXPIRE": "jwt.refresh_token_expire", + "JWT_ISSUER": "jwt.issuer", + "JWT_AUDIENCE": "jwt.audience", + "RATE_LIMIT_REQUESTS": "rate_limit.requests", + "RATE_LIMIT_WINDOW": "rate_limit.window", + "RATE_LIMIT_BURST": "rate_limit.burst", + "OTEL_ENDPOINT": "otel.endpoint", + "OTEL_EXPORTER_OTLP_ENDPOINT": "otel.endpoint", + "OTEL_SERVICE_NAME": "otel.service_name", + "OTEL_ENABLED": "otel.enabled", + "OTEL_INSECURE": "otel.insecure", + "OTEL_SAMPLE_RATE": "otel.sample_rate", + "NVD_API_KEY": "collectors.cve.nvd_api_key", + "CF_RADAR_TOKEN": "collectors.cfradar.bearer_token", + "GREYNOISE_API_KEY": "collectors.greynoise.api_key", + "ABUSEIPDB_API_KEY": "collectors.abuseipdb.api_key", + "NOTIFICATION_ENCRYPTION_KEY": "notifications.encryption_key", + "PUBLIC_URL": "app.public_url", + "ADMIN_EMAIL": "app.admin_email", +} + +func envKeyReplacer(s string) string { + if mapped, ok := envKeyMap[s]; ok { + return mapped + } + return "" +} + +func validate(c *Config) error { + if c.Database.URL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + + if c.Redis.URL == "" { + return fmt.Errorf("REDIS_URL is required") + } + + if c.JWT.PrivateKeyPath == "" { + return fmt.Errorf("JWT_PRIVATE_KEY_PATH is required") + } + + if c.JWT.PublicKeyPath == "" { + return fmt.Errorf("JWT_PUBLIC_KEY_PATH is required") + } + + if c.CORS.AllowCredentials { + for _, origin := range c.CORS.AllowedOrigins { + if origin == "*" { + return fmt.Errorf( + "CORS wildcard '*' cannot be used with AllowCredentials", + ) + } + } + } + + if c.App.Environment == "production" { + if c.Otel.Enabled && c.Otel.Insecure { + return fmt.Errorf("OTEL_INSECURE must be false in production") + } + } + + if c.Server.ReadTimeout <= 0 { + return fmt.Errorf("server.read_timeout must be positive") + } + + if c.Server.WriteTimeout <= 0 { + return fmt.Errorf("server.write_timeout must be positive") + } + + return nil +} + +func (c *Config) IsProduction() bool { + return c.App.Environment == "production" +} + +func (c *Config) IsDevelopment() bool { + return c.App.Environment == "development" +} + +func (s *ServerConfig) Address() string { + return fmt.Sprintf("%s:%d", s.Host, s.Port) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go new file mode 100644 index 00000000..470f711c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go @@ -0,0 +1,145 @@ +// AngelaMos | 2026 +// database.go + +package core + +import ( + "context" + "database/sql" + "fmt" + "math/rand/v2" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" +) + +type Database struct { + DB *sqlx.DB +} + +func NewDatabase( + ctx context.Context, + cfg config.DatabaseConfig, +) (*Database, error) { + db, err := sqlx.ConnectContext(ctx, "pgx", cfg.URL) + if err != nil { + return nil, fmt.Errorf("connect to database: %w", err) + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(jitteredDuration(cfg.ConnMaxLifetime)) + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := db.PingContext(pingCtx); err != nil { + _ = db.Close() //nolint:errcheck // cleanup on connection failure + return nil, fmt.Errorf("ping database: %w", err) + } + + return &Database{DB: db}, nil +} + +func (d *Database) Close() error { + if d.DB != nil { + return d.DB.Close() + } + return nil +} + +func (d *Database) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := d.DB.PingContext(pingCtx); err != nil { + return fmt.Errorf("database ping failed: %w", err) + } + + return nil +} + +func (d *Database) Stats() sql.DBStats { + return d.DB.Stats() +} + +type DBTX interface { + sqlx.ExtContext + sqlx.ExecerContext + GetContext(ctx context.Context, dest any, query string, args ...any) error + SelectContext( + ctx context.Context, + dest any, + query string, + args ...any, + ) error +} + +func InTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func InTxWithOptions( + ctx context.Context, + db *sqlx.DB, + opts *sql.TxOptions, + fn func(tx *sqlx.Tx) error, +) error { + tx, err := db.BeginTxx(ctx, opts) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func jitteredDuration(base time.Duration) time.Duration { + //nolint:gosec // G404: non-security-sensitive jitter for connection pool + jitter := time.Duration(rand.Int64N(int64(base / 7))) + return base + jitter +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go new file mode 100644 index 00000000..b478a6ee --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go @@ -0,0 +1,169 @@ +// AngelaMos | 2026 +// errors.go + +package core + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrDuplicateKey = errors.New("duplicate key violation") + ErrForeignKey = errors.New("foreign key violation") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrInternal = errors.New("internal server error") + ErrConflict = errors.New("resource conflict") + ErrRateLimited = errors.New("rate limit exceeded") + ErrTokenExpired = errors.New("token expired") + ErrTokenInvalid = errors.New("token invalid") + ErrTokenRevoked = errors.New("token revoked") +) + +type AppError struct { + Err error `json:"-"` + Message string `json:"message"` + StatusCode int `json:"-"` + Code string `json:"code"` +} + +func (e *AppError) Error() string { + if e.Message != "" { + return e.Message + } + if e.Err != nil { + return e.Err.Error() + } + return "unknown error" +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +func NewAppError( + err error, + message string, + statusCode int, + code string, +) *AppError { + return &AppError{ + Err: err, + Message: message, + StatusCode: statusCode, + Code: code, + } +} + +func NotFoundError(resource string) *AppError { + return &AppError{ + Err: ErrNotFound, + Message: fmt.Sprintf("%s not found", resource), + StatusCode: http.StatusNotFound, + Code: "NOT_FOUND", + } +} + +func DuplicateError(field string) *AppError { + return &AppError{ + Err: ErrDuplicateKey, + Message: fmt.Sprintf("%s already exists", field), + StatusCode: http.StatusConflict, + Code: "DUPLICATE", + } +} + +func ValidationError(message string) *AppError { + return &AppError{ + Err: ErrInvalidInput, + Message: message, + StatusCode: http.StatusBadRequest, + Code: "VALIDATION_ERROR", + } +} + +func UnauthorizedError(message string) *AppError { + if message == "" { + message = "authentication required" + } + return &AppError{ + Err: ErrUnauthorized, + Message: message, + StatusCode: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + } +} + +func ForbiddenError(message string) *AppError { + if message == "" { + message = "access denied" + } + return &AppError{ + Err: ErrForbidden, + Message: message, + StatusCode: http.StatusForbidden, + Code: "FORBIDDEN", + } +} + +func InternalError(err error) *AppError { + return &AppError{ + Err: err, + Message: "internal server error", + StatusCode: http.StatusInternalServerError, + Code: "INTERNAL_ERROR", + } +} + +func RateLimitError() *AppError { + return &AppError{ + Err: ErrRateLimited, + Message: "too many requests", + StatusCode: http.StatusTooManyRequests, + Code: "RATE_LIMITED", + } +} + +func TokenExpiredError() *AppError { + return &AppError{ + Err: ErrTokenExpired, + Message: "token has expired", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_EXPIRED", + } +} + +func TokenInvalidError() *AppError { + return &AppError{ + Err: ErrTokenInvalid, + Message: "invalid token", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_INVALID", + } +} + +func TokenRevokedError() *AppError { + return &AppError{ + Err: ErrTokenRevoked, + Message: "token has been revoked", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_REVOKED", + } +} + +func IsAppError(err error) bool { + var appErr *AppError + return errors.As(err, &appErr) +} + +func GetAppError(err error) *AppError { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr + } + return InternalError(err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go new file mode 100644 index 00000000..f7f86927 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go @@ -0,0 +1,63 @@ +// AngelaMos | 2026 +// redis.go + +package core + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" +) + +type Redis struct { + Client *redis.Client +} + +func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) { + opts, err := redis.ParseURL(cfg.URL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + + opts.PoolSize = cfg.PoolSize + opts.MinIdleConns = cfg.MinIdleConns + opts.PoolTimeout = 30 * time.Second + opts.ConnMaxIdleTime = 5 * time.Minute + + client := redis.NewClient(opts) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := client.Ping(pingCtx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + return &Redis{Client: client}, nil +} + +func (r *Redis) Close() error { + if r.Client != nil { + return r.Client.Close() + } + return nil +} + +func (r *Redis) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := r.Client.Ping(pingCtx).Err(); err != nil { + return fmt.Errorf("redis ping failed: %w", err) + } + + return nil +} + +func (r *Redis) PoolStats() *redis.PoolStats { + return r.Client.PoolStats() +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go new file mode 100644 index 00000000..e37ac737 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go @@ -0,0 +1,120 @@ +// AngelaMos | 2026 +// response.go + +package core + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +type Response struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type Meta struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Total int `json:"total,omitempty"` + TotalPages int `json:"total_pages,omitempty"` +} + +func JSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: status >= 200 && status < 300, + Data: data, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONWithMeta(w http.ResponseWriter, status int, data any, meta *Meta) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: true, + Data: data, + Meta: meta, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONError(w http.ResponseWriter, err error) { + appErr := GetAppError(err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(appErr.StatusCode) + + response := Response{ + Success: false, + Error: &Error{ + Code: appErr.Code, + Message: appErr.Message, + }, + } + + if encErr := json.NewEncoder(w).Encode(response); encErr != nil { + slog.Error("failed to encode error response", "error", encErr) + } +} + +func Created(w http.ResponseWriter, data any) { + JSON(w, http.StatusCreated, data) +} + +func OK(w http.ResponseWriter, data any) { + JSON(w, http.StatusOK, data) +} + +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +func BadRequest(w http.ResponseWriter, message string) { + JSONError(w, ValidationError(message)) +} + +func NotFound(w http.ResponseWriter, resource string) { + JSONError(w, NotFoundError(resource)) +} + +func Unauthorized(w http.ResponseWriter, message string) { + JSONError(w, UnauthorizedError(message)) +} + +func Forbidden(w http.ResponseWriter, message string) { + JSONError(w, ForbiddenError(message)) +} + +func InternalServerError(w http.ResponseWriter, err error) { + slog.Error("internal server error", "error", err) + JSONError(w, InternalError(err)) +} + +func Paginated(w http.ResponseWriter, data any, page, pageSize, total int) { + totalPages := (total + pageSize - 1) / pageSize + JSONWithMeta(w, http.StatusOK, data, &Meta{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go new file mode 100644 index 00000000..05065e1a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go @@ -0,0 +1,218 @@ +// AngelaMos | 2026 +// security.go + +package core + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 1 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + saltLength = 16 +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + hash := argon2.IDKey( + []byte(password), + salt, + argonTime, + argonMemory, + argonThreads, + argonKeyLen, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encoded := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, + argonTime, + argonThreads, + b64Salt, + b64Hash, + ) + + return encoded, nil +} + +func VerifyPassword(password, encodedHash string) (bool, error) { + params, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + otherHash := argon2.IDKey( + []byte(password), + salt, + params.time, + params.memory, + params.threads, + params.keyLen, + ) + + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} + +func VerifyPasswordWithRehash( + password, encodedHash string, +) (bool, string, error) { + valid, err := VerifyPassword(password, encodedHash) + if err != nil { + return false, "", err + } + + if !valid { + return false, "", nil + } + + if needsRehash(encodedHash) { + newHash, hashErr := HashPassword(password) + if hashErr != nil { + //nolint:nilerr // password verified successfully; rehash failure is non-critical + return true, "", nil + } + return true, newHash, nil + } + + return true, "", nil +} + +var dummyHash string + +func init() { + hash, err := HashPassword("dummy_password_for_timing_attack_prevention") + if err != nil { + panic(fmt.Sprintf("security: failed to generate dummy hash: %v", err)) + } + dummyHash = hash +} + +func VerifyPasswordTimingSafe( + password string, + encodedHash *string, +) (bool, string, error) { + hashToVerify := dummyHash + if encodedHash != nil && *encodedHash != "" { + hashToVerify = *encodedHash + } + + valid, newHash, err := VerifyPasswordWithRehash(password, hashToVerify) + + if encodedHash == nil || *encodedHash == "" { + return false, "", nil + } + + return valid, newHash, err +} + +type argonParams struct { + memory uint32 + time uint32 + threads uint8 + keyLen uint32 +} + +func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, nil, nil, fmt.Errorf("invalid hash format") + } + + if parts[1] != "argon2id" { + return nil, nil, nil, fmt.Errorf("unsupported algorithm: %s", parts[1]) + } + + var version int + _, err := fmt.Sscanf(parts[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid version: %w", err) + } + + if version != argon2.Version { + return nil, nil, nil, fmt.Errorf("incompatible version: %d", version) + } + + params := &argonParams{} + _, err = fmt.Sscanf( + parts[3], + "m=%d,t=%d,p=%d", + ¶ms.memory, + ¶ms.time, + ¶ms.threads, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode salt: %w", err) + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode hash: %w", err) + } + + //nolint:gosec // G115: hash length is always small (32 bytes for Argon2id) + params.keyLen = uint32(len(hash)) + + return params, salt, hash, nil +} + +func needsRehash(encodedHash string) bool { + params, _, _, err := decodeHash(encodedHash) + if err != nil { + return true + } + + return params.memory != argonMemory || + params.time != argonTime || + params.threads != argonThreads || + params.keyLen != argonKeyLen +} + +func GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func GenerateRefreshToken() (string, error) { + return GenerateSecureToken(32) +} + +func HashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func CompareTokenHash(token, hash string) bool { + tokenHash := HashToken(token) + return subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hash)) == 1 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go new file mode 100644 index 00000000..737ca1d5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go @@ -0,0 +1,147 @@ +// AngelaMos | 2026 +// telemetry.go + +package core + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" +) + +type Telemetry struct { + TracerProvider *sdktrace.TracerProvider + Tracer trace.Tracer +} + +func NewTelemetry( + ctx context.Context, + otelCfg config.OtelConfig, + appCfg config.AppConfig, +) (*Telemetry, error) { + if !otelCfg.Enabled || otelCfg.Endpoint == "" { + noopProvider := sdktrace.NewTracerProvider() + return &Telemetry{ + TracerProvider: noopProvider, + Tracer: noopProvider.Tracer(otelCfg.ServiceName), + }, nil + } + + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(otelCfg.Endpoint), + otlptracegrpc.WithTimeout(5 * time.Second), + } + + if otelCfg.Insecure { + opts = append( + opts, + otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), + ) + } else { + opts = append( + opts, + otlptracegrpc.WithTLSCredentials( + credentials.NewClientTLSFromCert(nil, ""), + ), + ) + } + + exporter, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create otlp exporter: %w", err) + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(otelCfg.ServiceName), + semconv.ServiceVersion(appCfg.Version), + attribute.String("environment", appCfg.Environment), + ), + resource.WithHost(), + resource.WithProcess(), + ) + if err != nil { + return nil, fmt.Errorf("create resource: %w", err) + } + + sampleRate := otelCfg.SampleRate + if sampleRate <= 0 || sampleRate > 1 { + sampleRate = 0.1 + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter, + sdktrace.WithBatchTimeout(5*time.Second), + sdktrace.WithMaxExportBatchSize(512), + ), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.ParentBased( + sdktrace.TraceIDRatioBased(sampleRate), + )), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &Telemetry{ + TracerProvider: tp, + Tracer: tp.Tracer(otelCfg.ServiceName), + }, nil +} + +func (t *Telemetry) Shutdown(ctx context.Context) error { + if t.TracerProvider == nil { + return nil + } + + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := t.TracerProvider.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown tracer provider: %w", err) + } + + return nil +} + +func SpanFromContext(ctx context.Context) trace.Span { + return trace.SpanFromContext(ctx) +} + +func TraceIDFromContext(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + return span.SpanContext().TraceID().String() + } + return "" +} + +func AddSpanEvent( + ctx context.Context, + name string, + attrs ...attribute.KeyValue, +) { + span := trace.SpanFromContext(ctx) + span.AddEvent(name, trace.WithAttributes(attrs...)) +} + +func SetSpanError(ctx context.Context, err error) { + span := trace.SpanFromContext(ctx) + span.RecordError(err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go new file mode 100644 index 00000000..f77cbd96 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go @@ -0,0 +1,42 @@ +// AngelaMos | 2026 +// validation.go + +package core + +import ( + "errors" + "strings" + + "github.com/go-playground/validator/v10" +) + +func FormatValidationError(err error) string { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + messages := make([]string, 0, len(ve)) + for _, fe := range ve { + messages = append(messages, FormatFieldError(fe)) + } + return strings.Join(messages, "; ") + } + return "validation failed" +} + +func FormatFieldError(fe validator.FieldError) string { + field := strings.ToLower(fe.Field()) + + switch fe.Tag() { + case "required": + return field + " is required" + case "email": + return field + " must be a valid email" + case "min": + return field + " must be at least " + fe.Param() + " characters" + case "max": + return field + " must be at most " + fe.Param() + " characters" + case "oneof": + return field + " must be one of: " + fe.Param() + default: + return field + " is invalid" + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go new file mode 100644 index 00000000..64c3ecaf --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go @@ -0,0 +1,106 @@ +// ©AngelaMos | 2026 +// client.go + +package abuseipdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultAIDBBaseURL = "https://api.abuseipdb.com" + pathV2Check = "/api/v2/check" + abuseipdbAPIHeader = "Key" + defaultAIDBRate = 1 * time.Second + defaultAIDBBudget = 3 + defaultAIDBBreakerWin = 5 * time.Minute + defaultAIDBRequestTO = 10 * time.Second + defaultAIDBMaxAgeDays = 90 +) + +var ErrUnknownIP = errors.New("abuseipdb: unknown ip") + +type ClientConfig struct { + BaseURL string + APIKey string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultAIDBBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "abuseipdb", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: abuseipdbAPIHeader, + Rate: rate.Every(defaultAIDBRate), + Burst: 1, + ConsecutiveFailureBudget: defaultAIDBBudget, + BreakerTimeout: defaultAIDBBreakerWin, + RequestTimeout: defaultAIDBRequestTO, + }), + } +} + +type checkEnvelope struct { + Data Verdict `json:"data"` +} + +type Verdict struct { + IPAddress string `json:"ipAddress"` + IsPublic bool `json:"isPublic"` + IPVersion int `json:"ipVersion"` + IsWhitelisted bool `json:"isWhitelisted"` + AbuseConfidenceScore int `json:"abuseConfidenceScore"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + UsageType string `json:"usageType"` + ISP string `json:"isp"` + Domain string `json:"domain"` + IsTor bool `json:"isTor"` + TotalReports int `json:"totalReports"` + NumDistinctUsers int `json:"numDistinctUsers"` + LastReportedAt string `json:"lastReportedAt"` +} + +func (c *Client) Lookup(ctx context.Context, ip string) (Verdict, error) { + q := url.Values{ + "ipAddress": []string{ip}, + "maxAgeInDays": []string{strconv.Itoa(defaultAIDBMaxAgeDays)}, + } + resp, err := c.hx.Get(ctx, pathV2Check, q) + if err != nil { + var se *httpx.StatusError + if errors.As(err, &se) && se.Code == http.StatusNotFound { + return Verdict{}, ErrUnknownIP + } + return Verdict{}, fmt.Errorf("abuseipdb lookup %s: %w", ip, err) + } + defer resp.Body.Close() + + var env checkEnvelope + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + return Verdict{}, fmt.Errorf("abuseipdb decode: %w", err) + } + if env.Data.IPAddress == "" { + return Verdict{}, ErrUnknownIP + } + return env.Data, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go new file mode 100644 index 00000000..8128b0d1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go @@ -0,0 +1,73 @@ +// ©AngelaMos | 2026 +// client_test.go + +package abuseipdb_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb" +) + +func TestClient_LookupDecodesVerdict(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/check", r.URL.Path) + require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress")) + require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays")) + require.Equal(t, "test-key", r.Header.Get("Key")) + body, err := os.ReadFile("testdata/check_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := abuseipdb.NewClient( + abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}, + ) + v, err := c.Lookup(context.Background(), "118.25.6.39") + require.NoError(t, err) + require.Equal(t, "118.25.6.39", v.IPAddress) + require.Equal(t, "CN", v.CountryCode) + require.Equal(t, 100, v.AbuseConfidenceScore) + require.Equal(t, "Tencent Cloud Computing", v.ISP) +} + +func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ) + defer srv.Close() + + c := abuseipdb.NewClient( + abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}, + ) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, abuseipdb.ErrUnknownIP) +} + +func TestClient_LookupReturnsErrUnknownIPOnEmptyData(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + }), + ) + defer srv.Close() + + c := abuseipdb.NewClient( + abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}, + ) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, abuseipdb.ErrUnknownIP) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json new file mode 100644 index 00000000..5d96ff02 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json @@ -0,0 +1,19 @@ +{ + "data": { + "ipAddress": "118.25.6.39", + "isPublic": true, + "ipVersion": 4, + "isWhitelisted": false, + "abuseConfidenceScore": 100, + "countryCode": "CN", + "countryName": "China", + "usageType": "Data Center/Web Hosting/Transit", + "isp": "Tencent Cloud Computing", + "domain": "tencent.com", + "hostnames": [], + "isTor": false, + "totalReports": 1, + "numDistinctUsers": 1, + "lastReportedAt": "2026-04-30T12:34:56+00:00" + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/cached.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/cached.go new file mode 100644 index 00000000..92b3a93f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/cached.go @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// cached.go + +package greynoise + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + cacheKeyPrefix = "gn:lookup:" + cacheHitTTL = 6 * time.Hour + cacheMissTTL = 30 * time.Minute + cacheMissValue = "__miss__" +) + +type Cached struct { + inner *Client + rdb *redis.Client +} + +func NewCached(inner *Client, rdb *redis.Client) *Cached { + return &Cached{inner: inner, rdb: rdb} +} + +func (c *Cached) Lookup(ctx context.Context, ip string) (Verdict, error) { + key := cacheKeyPrefix + ip + + if c.rdb != nil { + raw, err := c.rdb.Get(ctx, key).Result() + if err == nil { + if raw == cacheMissValue { + return Verdict{}, ErrUnknownIP + } + var v Verdict + if jerr := json.Unmarshal([]byte(raw), &v); jerr == nil { + return v, nil + } + } else if !errors.Is(err, redis.Nil) { + // Redis is degraded — fall through to live lookup. Don't cache + // the result either since the cache layer is itself unhealthy. + v, lerr := c.inner.Lookup(ctx, ip) + return v, lerr + } + } + + v, err := c.inner.Lookup(ctx, ip) + if err != nil { + if errors.Is(err, ErrUnknownIP) && c.rdb != nil { + _ = c.rdb.Set(ctx, key, cacheMissValue, cacheMissTTL).Err() + } + return Verdict{}, err + } + + if c.rdb != nil { + if payload, merr := json.Marshal(v); merr == nil { + _ = c.rdb.Set(ctx, key, string(payload), cacheHitTTL).Err() + } + } + return v, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go new file mode 100644 index 00000000..a4da1e5f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// client.go + +package greynoise + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultGNBaseURL = "https://api.greynoise.io" + pathCommunityLookup = "/v3/community/" + greynoiseAPIHeader = "key" + defaultGNRate = 2 * time.Second + defaultGNBudget = 3 + defaultGNBreakerWin = 5 * time.Minute + defaultGNRequestTO = 10 * time.Second +) + +var ErrUnknownIP = errors.New("greynoise: unknown ip") + +type ClientConfig struct { + BaseURL string + APIKey string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultGNBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "greynoise", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: greynoiseAPIHeader, + Rate: rate.Every(defaultGNRate), + Burst: 1, + ConsecutiveFailureBudget: defaultGNBudget, + BreakerTimeout: defaultGNBreakerWin, + RequestTimeout: defaultGNRequestTO, + }), + } +} + +type Verdict struct { + IP string `json:"ip"` + Noise bool `json:"noise"` + Riot bool `json:"riot"` + Classification string `json:"classification"` + Name string `json:"name"` + Link string `json:"link"` + LastSeen string `json:"last_seen"` + Message string `json:"message"` +} + +func (c *Client) Lookup(ctx context.Context, ip string) (Verdict, error) { + resp, err := c.hx.Get(ctx, pathCommunityLookup+url.PathEscape(ip), nil) + if err != nil { + var se *httpx.StatusError + if errors.As(err, &se) && se.Code == http.StatusNotFound { + return Verdict{}, ErrUnknownIP + } + return Verdict{}, fmt.Errorf("greynoise lookup %s: %w", ip, err) + } + defer resp.Body.Close() + + var v Verdict + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return Verdict{}, fmt.Errorf("greynoise decode: %w", err) + } + return v, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go new file mode 100644 index 00000000..59238534 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go @@ -0,0 +1,54 @@ +// ©AngelaMos | 2026 +// client_test.go + +package greynoise_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/greynoise" +) + +func TestClient_LookupDecodesVerdict(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path) + require.Equal(t, "test-key", r.Header.Get("key")) + body, err := os.ReadFile("testdata/ip_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) + defer srv.Close() + + c := greynoise.NewClient( + greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}, + ) + v, err := c.Lookup(context.Background(), "8.8.8.8") + require.NoError(t, err) + require.Equal(t, "8.8.8.8", v.IP) + require.True(t, v.Riot) + require.Equal(t, "benign", v.Classification) +} + +func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ) + defer srv.Close() + + c := greynoise.NewClient( + greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"}, + ) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, greynoise.ErrUnknownIP) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json new file mode 100644 index 00000000..ac414915 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json @@ -0,0 +1,10 @@ +{ + "ip": "8.8.8.8", + "noise": false, + "riot": true, + "classification": "benign", + "name": "Google Public DNS", + "link": "https://viz.greynoise.io/ip/8.8.8.8", + "last_seen": "2026-05-02", + "message": "Success" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go new file mode 100644 index 00000000..eb07a78b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go @@ -0,0 +1,13 @@ +// ©AngelaMos | 2026 +// event.go + +package events + +import "time" + +type Event struct { + Topic Topic `json:"topic"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Payload any `json:"payload"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go new file mode 100644 index 00000000..88072321 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go @@ -0,0 +1,48 @@ +// ©AngelaMos | 2026 +// event_test.go + +package events_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +func TestEvent_JSONRoundTrip(t *testing.T) { + now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + original := events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: now, + Source: "heartbeat", + Payload: map[string]any{"ts": now.Format(time.RFC3339Nano)}, + } + + raw, err := json.Marshal(original) + require.NoError(t, err) + + var decoded events.Event + require.NoError(t, json.Unmarshal(raw, &decoded)) + + require.Equal(t, original.Topic, decoded.Topic) + require.Equal(t, original.Source, decoded.Source) + require.True(t, original.Timestamp.Equal(decoded.Timestamp)) +} + +func TestTopic_IsValid(t *testing.T) { + require.True(t, events.TopicHeartbeat.IsValid()) + require.True(t, events.TopicCVENew.IsValid()) + require.False(t, events.Topic("bogus").IsValid()) +} + +func TestAllTopics_AllValid(t *testing.T) { + all := events.AllTopics() + require.NotEmpty(t, all) + for _, top := range all { + require.True(t, top.IsValid(), "topic %q should be valid", top) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go new file mode 100644 index 00000000..27e2c603 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go @@ -0,0 +1,60 @@ +// ©AngelaMos | 2026 +// topic.go + +package events + +type Topic string + +const ( + TopicHeartbeat Topic = "heartbeat" + TopicScanFirehose Topic = "scan_firehose" + TopicInternetOutage Topic = "internet_outage" + TopicBGPHijack Topic = "bgp_hijack" + TopicCVENew Topic = "cve_new" + TopicCVEUpdated Topic = "cve_updated" + TopicEPSS Topic = "epss" + TopicKEVAdded Topic = "kev_added" + TopicRansomwareVictim Topic = "ransomware_victim" + TopicCoinbasePrice Topic = "coinbase_price" + TopicEarthquake Topic = "earthquake" + TopicSpaceWeather Topic = "space_weather" + TopicWikipediaITN Topic = "wiki_itn" + TopicGDELTSpike Topic = "gdelt_spike" + TopicISSPosition Topic = "iss_position" + TopicCollectorState Topic = "collector_state" +) + +func (t Topic) String() string { return string(t) } + +func (t Topic) IsValid() bool { + switch t { + case TopicHeartbeat, TopicScanFirehose, TopicInternetOutage, TopicBGPHijack, + TopicCVENew, TopicCVEUpdated, TopicEPSS, TopicKEVAdded, + TopicRansomwareVictim, TopicCoinbasePrice, TopicEarthquake, + TopicSpaceWeather, TopicWikipediaITN, TopicGDELTSpike, + TopicISSPosition, TopicCollectorState: + return true + } + return false +} + +func AllTopics() []Topic { + return []Topic{ + TopicHeartbeat, + TopicScanFirehose, + TopicInternetOutage, + TopicBGPHijack, + TopicCVENew, + TopicCVEUpdated, + TopicEPSS, + TopicKEVAdded, + TopicRansomwareVictim, + TopicCoinbasePrice, + TopicEarthquake, + TopicSpaceWeather, + TopicWikipediaITN, + TopicGDELTSpike, + TopicISSPosition, + TopicCollectorState, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go new file mode 100644 index 00000000..e7003c6a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go @@ -0,0 +1,195 @@ +// AngelaMos | 2026 +// handler.go + +package health + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" +) + +type Checker interface { + Ping(ctx context.Context) error +} + +type Handler struct { + db Checker + redis Checker + ready atomic.Bool + shutdown atomic.Bool +} + +func NewHandler(db, redis Checker) *Handler { + h := &Handler{ + db: db, + redis: redis, + } + h.ready.Store(true) + return h +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/healthz", h.Liveness) + r.Get("/livez", h.Liveness) + r.Get("/readyz", h.Readiness) +} + +func (h *Handler) Liveness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + h.writeStatus(w, http.StatusOK, StatusResponse{ + Status: "ok", + }) +} + +func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + if !h.ready.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "not_ready", + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + checks := h.runHealthChecks(ctx) + + allHealthy := true + for _, check := range checks { + if !check.Healthy { + allHealthy = false + break + } + } + + status := "ok" + statusCode := http.StatusOK + if !allHealthy { + status = "degraded" + statusCode = http.StatusServiceUnavailable + } + + h.writeStatus(w, statusCode, ReadinessResponse{ + Status: status, + Checks: checks, + }) +} + +func (h *Handler) runHealthChecks(ctx context.Context) []HealthCheck { + var wg sync.WaitGroup + checks := make([]HealthCheck, 2) + + wg.Add(2) + + go func() { + defer wg.Done() + checks[0] = h.checkDatabase(ctx) + }() + + go func() { + defer wg.Done() + checks[1] = h.checkRedis(ctx) + }() + + wg.Wait() + return checks +} + +func (h *Handler) checkDatabase(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "database", + Healthy: true, + } + + if h.db == nil { + check.Healthy = false + check.Message = "database checker not configured" + return check + } + + start := time.Now() + err := h.db.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) checkRedis(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "redis", + Healthy: true, + } + + if h.redis == nil { + check.Healthy = false + check.Message = "redis checker not configured" + return check + } + + start := time.Now() + err := h.redis.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) SetReady(ready bool) { + h.ready.Store(ready) +} + +func (h *Handler) SetShutdown(shutdown bool) { + h.shutdown.Store(shutdown) +} + +func (h *Handler) writeStatus(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.WriteHeader(status) + //nolint:errcheck // best-effort response + _ = json.NewEncoder(w).Encode(data) +} + +type StatusResponse struct { + Status string `json:"status"` +} + +type ReadinessResponse struct { + Status string `json:"status"` + Checks []HealthCheck `json:"checks"` +} + +type HealthCheck struct { + Name string `json:"name"` + Healthy bool `json:"healthy"` + Latency string `json:"latency,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go new file mode 100644 index 00000000..0a176755 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go @@ -0,0 +1,225 @@ +// ©AngelaMos | 2026 +// client.go + +package httpx + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/cenkalti/backoff/v4" + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ratelimit" +) + +const ( + defaultUserAgent = "monitor-the-situation/1.0" + defaultRequestTimeout = 30 * time.Second + defaultMaxRetryElapsed = 60 * time.Second + defaultInitialRetryBackoff = 500 * time.Millisecond + defaultMaxRetryBackoff = 10 * time.Second + defaultAPIKeyHeader = "X-API-Key" + clientErrorBodyLimit = 4096 +) + +type Config struct { + Name string + BaseURL string + Headers http.Header + BearerToken string + APIKey string + APIKeyHeader string + Rate rate.Limit + Burst int + ConsecutiveFailureBudget uint32 + BreakerTimeout time.Duration + RequestTimeout time.Duration + MaxRetryElapsed time.Duration + UserAgent string +} + +type Client struct { + cfg Config + limiter *ratelimit.Limiter[*http.Response] + hc *http.Client +} + +type StatusError struct { + Code int + Body string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("client error %d: %s", e.Code, e.Body) +} + +func New(cfg Config) *Client { + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = defaultRequestTimeout + } + if cfg.MaxRetryElapsed <= 0 { + cfg.MaxRetryElapsed = defaultMaxRetryElapsed + } + if cfg.UserAgent == "" { + cfg.UserAgent = defaultUserAgent + } + limiter := ratelimit.New[*http.Response](ratelimit.Config{ + Name: cfg.Name, + Rate: cfg.Rate, + Burst: cfg.Burst, + ConsecutiveFailureBudget: cfg.ConsecutiveFailureBudget, + BreakerTimeout: cfg.BreakerTimeout, + }) + return &Client{ + cfg: cfg, + limiter: limiter, + hc: &http.Client{Timeout: cfg.RequestTimeout}, + } +} + +func (c *Client) GetJSON( + ctx context.Context, + path string, + query url.Values, + out any, +) error { + resp, err := c.Get(ctx, path, query) + if err != nil { + return err + } + defer drainAndClose(resp) + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) Get( + ctx context.Context, + path string, + query url.Values, +) (*http.Response, error) { + target, err := c.resolveURL(path, query) + if err != nil { + return nil, err + } + + bo := backoff.NewExponentialBackOff() + bo.MaxElapsedTime = c.cfg.MaxRetryElapsed + bo.InitialInterval = defaultInitialRetryBackoff + bo.MaxInterval = defaultMaxRetryBackoff + + var resp *http.Response + op := func() error { + r, opErr := c.limiter.Do( + ctx, + func(rctx context.Context) (*http.Response, error) { + return c.do(rctx, target) + }, + ) + if opErr != nil { + return opErr + } + switch { + case r.StatusCode == http.StatusTooManyRequests: + retryAfter := parseRetryAfter(r.Header.Get("Retry-After")) + drainAndClose(r) + if retryAfter > 0 { + select { + case <-ctx.Done(): + return backoff.Permanent(ctx.Err()) + case <-time.After(retryAfter): + } + } + return errors.New("upstream 429") + case r.StatusCode >= 500 && r.StatusCode <= 599: + drainAndClose(r) + return fmt.Errorf("upstream %d", r.StatusCode) + case r.StatusCode >= 400: + body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit)) + drainAndClose(r) + return backoff.Permanent( + &StatusError{Code: r.StatusCode, Body: string(body)}, + ) + } + resp = r + return nil + } + if err := backoff.Retry(op, backoff.WithContext(bo, ctx)); err != nil { + return nil, err + } + return resp, nil +} + +func (c *Client) do( + ctx context.Context, + target string, +) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.cfg.UserAgent) + for k, vs := range c.cfg.Headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + if c.cfg.BearerToken != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.BearerToken) + } + if c.cfg.APIKey != "" { + header := c.cfg.APIKeyHeader + if header == "" { + header = defaultAPIKeyHeader + } + req.Header.Set(header, c.cfg.APIKey) + } + return c.hc.Do(req) +} + +func (c *Client) resolveURL(path string, query url.Values) (string, error) { + base, err := url.Parse(c.cfg.BaseURL) + if err != nil { + return "", err + } + rel, err := url.Parse(path) + if err != nil { + return "", err + } + resolved := base.ResolveReference(rel) + if query != nil { + resolved.RawQuery = query.Encode() + } + return resolved.String(), nil +} + +func parseRetryAfter(v string) time.Duration { + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil { + return time.Duration(secs) * time.Second + } + if t, err := http.ParseTime(v); err == nil { + d := time.Until(t) + if d > 0 { + return d + } + } + return 0 +} + +func drainAndClose(r *http.Response) { + if r == nil || r.Body == nil { + return + } + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go new file mode 100644 index 00000000..ce592283 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go @@ -0,0 +1,185 @@ +// ©AngelaMos | 2026 +// client_test.go + +package httpx_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +func TestClient_GetJSONHonorsRateLimit(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"ok":true}`)) + }), + ) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "test", + BaseURL: srv.URL, + Rate: rate.Every(50 * time.Millisecond), + Burst: 1, + ConsecutiveFailureBudget: 5, + BreakerTimeout: time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + start := time.Now() + for i := 0; i < 3; i++ { + var out struct { + Ok bool `json:"ok"` + } + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.True(t, out.Ok) + } + require.GreaterOrEqual(t, time.Since(start), 100*time.Millisecond) + require.EqualValues(t, 3, hits.Load()) +} + +func TestClient_RetriesOn429WithRetryAfter(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }), + ) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "retry", + BaseURL: srv.URL, + Rate: rate.Inf, + Burst: 10, + ConsecutiveFailureBudget: 5, + BreakerTimeout: time.Second, + MaxRetryElapsed: 3 * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + var out struct { + Ok bool `json:"ok"` + } + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.True(t, out.Ok) + require.EqualValues(t, 2, hits.Load()) +} + +func TestClient_RetriesOn5xx(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) < 3 { + w.WriteHeader(http.StatusBadGateway) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }), + ) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "5xx", + BaseURL: srv.URL, + Rate: rate.Inf, + Burst: 10, + ConsecutiveFailureBudget: 10, + BreakerTimeout: time.Second, + MaxRetryElapsed: 5 * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + var out struct { + Ok bool `json:"ok"` + } + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.EqualValues(t, 3, hits.Load()) +} + +func TestClient_PermanentErrorOn4xx(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`bad`)) + }), + ) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "perm", + BaseURL: srv.URL, + Rate: rate.Inf, + Burst: 10, + ConsecutiveFailureBudget: 10, + BreakerTimeout: time.Second, + MaxRetryElapsed: 3 * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var out struct{} + err := c.GetJSON(ctx, "/", nil, &out) + require.Error(t, err) + require.EqualValues(t, 1, hits.Load()) +} + +func TestClient_AddsBearerAndAPIKeyHeaders(t *testing.T) { + var bearer, apiKey, ua string + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearer = r.Header.Get("Authorization") + apiKey = r.Header.Get("X-Custom-Key") + ua = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{}`)) + }), + ) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "headers", + BaseURL: srv.URL, + BearerToken: "bearer-abc", + APIKey: "key-xyz", + APIKeyHeader: "X-Custom-Key", + UserAgent: "monitor-test/1.0", + Rate: rate.Inf, + Burst: 1, + ConsecutiveFailureBudget: 5, + BreakerTimeout: time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + var out struct{} + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.Equal(t, "Bearer bearer-abc", bearer) + require.Equal(t, "key-xyz", apiKey) + require.Equal(t, "monitor-test/1.0", ua) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/intel/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/intel/handler.go new file mode 100644 index 00000000..9f9dc8af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/intel/handler.go @@ -0,0 +1,303 @@ +// ©AngelaMos | 2026 +// handler.go + +package intel + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +// Backfill endpoints. The dashboard's snapshot store keeps only the latest +// SINGLE event per topic, so on cold load the panels are nearly empty until +// the next collector tick fires (up to 2h for CVE). These endpoints read +// directly from the persistent stores so the panels populate immediately, +// and the WS path layers deltas on top. +// +// All routes are GET, public (same posture as /snapshot), with a small +// public cache to absorb the cold-load thundering herd if the dashboard +// happens to be popular. + +const ( + defaultLimit = 50 + maxLimit = 500 + cacheMaxAgeS = 30 + cacheControl = "public, max-age=30" + contentType = "Content-Type" + contentJSON = "application/json" +) + +type CVERepo interface { + RecentByLastModified(ctx context.Context, limit int) ([]cve.Row, error) +} + +type KEVRepo interface { + RecentByDateAdded(ctx context.Context, limit int) ([]kev.Row, error) +} + +type CFRadarRepo interface { + RecentHijacks(ctx context.Context, limit int) ([]cfradar.HijackRow, error) + RecentOutages(ctx context.Context, limit int) ([]cfradar.OutageRow, error) +} + +type RansomwareRepo interface { + Recent(ctx context.Context, limit int) ([]ransomware.Row, error) +} + +type USGSRepo interface { + RecentByTime(ctx context.Context, limit int) ([]usgs.Row, error) +} + +type Handler struct { + cveRepo CVERepo + kevRepo KEVRepo + cfradarRepo CFRadarRepo + ransomwareRepo RansomwareRepo + usgsRepo USGSRepo +} + +type HandlerConfig struct { + CVE CVERepo + KEV KEVRepo + CFRadar CFRadarRepo + Ransomware RansomwareRepo + USGS USGSRepo +} + +func NewHandler(cfg HandlerConfig) *Handler { + return &Handler{ + cveRepo: cfg.CVE, + kevRepo: cfg.KEV, + cfradarRepo: cfg.CFRadar, + ransomwareRepo: cfg.Ransomware, + usgsRepo: cfg.USGS, + } +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Route("/intel", func(r chi.Router) { + r.Get("/cves", h.recentCVEs) + r.Get("/kev", h.recentKEV) + r.Get("/hijacks", h.recentHijacks) + r.Get("/outages", h.recentOutages) + r.Get("/ransomware", h.recentRansomware) + r.Get("/quakes", h.recentQuakes) + }) +} + +func parseLimit(r *http.Request) int { + raw := r.URL.Query().Get("limit") + if raw == "" { + return defaultLimit + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultLimit + } + if n > maxLimit { + return maxLimit + } + return n +} + +func (h *Handler) recentCVEs(w http.ResponseWriter, r *http.Request) { + if h.cveRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.cveRepo.RecentByLastModified(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, intelCVERows(rows)) +} + +func (h *Handler) recentKEV(w http.ResponseWriter, r *http.Request) { + if h.kevRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.kevRepo.RecentByDateAdded(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, intelKEVRows(rows)) +} + +// recentHijacks returns the raw payload column (the same EnrichedHijack +// JSON the WS path emits at collector time) so the dashboard panel sees +// `prefixes` and `enrichment` alongside the row metadata. Building a +// flat DTO loses fields the panel renders → empty rows. +func (h *Handler) recentHijacks(w http.ResponseWriter, r *http.Request) { + if h.cfradarRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.cfradarRepo.RecentHijacks(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +// recentOutages returns the raw OutageAnnotation payload the WS path +// emits, for the same reason as recentHijacks. +func (h *Handler) recentOutages(w http.ResponseWriter, r *http.Request) { + if h.cfradarRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.cfradarRepo.RecentOutages(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +// recentRansomware returns the raw Victim payload the WS path emits, so +// dedupe by victimKey() works identically across cold-load and live. +func (h *Handler) recentRansomware(w http.ResponseWriter, r *http.Request) { + if h.ransomwareRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.ransomwareRepo.Recent(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +func (h *Handler) recentQuakes(w http.ResponseWriter, r *http.Request) { + if h.usgsRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.usgsRepo.RecentByTime(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + // Return the raw GeoJSON Feature shape the frontend (and the live WS + // path) already speak. Row.Payload IS the upstream feature. + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +// The collectors' Row types embed the raw upstream payload as +// json.RawMessage. The dashboard panels consume the upstream shape (CveID, +// Severity, etc.), so we forward the payloads — they're already the right +// shape for the existing zod schemas on the frontend. For panels that +// derive fields server-side (severity/cvss numeric vs the NVD nested +// structure) we expand back to the same flat shape the live WS event +// emits, so the frontend stores can dedupe by ID across cold-load and live +// streams without remembering two formats. + +type cveDTO struct { + CveID string `json:"CveID"` + Published time.Time `json:"Published"` + LastModified time.Time `json:"LastModified"` + Severity string `json:"Severity"` + CVSS float64 `json:"CVSS"` + EPSSScore *float64 `json:"EPSSScore"` + EPSSPercentile *float64 `json:"EPSSPercentile"` + InKEV bool `json:"InKEV"` + Payload json.RawMessage `json:"-"` +} + +func intelCVERows(rows []cve.Row) []cveDTO { + out := make([]cveDTO, 0, len(rows)) + for _, r := range rows { + out = append(out, cveDTO{ + CveID: r.CveID, + Published: r.Published, + LastModified: r.LastModified, + Severity: r.Severity, + CVSS: r.CVSS, + EPSSScore: r.EPSSScore, + EPSSPercentile: r.EPSSPercentile, + InKEV: r.InKEV, + }) + } + return out +} + +type kevDTO struct { + CveID string `json:"cveID"` + VendorProject string `json:"vendorProject"` + Product string `json:"product"` + VulnerabilityName string `json:"vulnerabilityName"` + DateAdded string `json:"dateAdded"` + DueDate string `json:"dueDate,omitempty"` + KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse,omitempty"` +} + +func intelKEVRows(rows []kev.Row) []kevDTO { + out := make([]kevDTO, 0, len(rows)) + for _, r := range rows { + dto := kevDTO{ + CveID: r.CveID, + VendorProject: r.Vendor, + Product: r.Product, + VulnerabilityName: r.VulnerabilityName, + DateAdded: r.DateAdded.Format(time.DateOnly), + KnownRansomwareCampaignUse: r.RansomwareUse, + } + if r.DueDate != nil { + dto.DueDate = r.DueDate.Format(time.DateOnly) + } + out = append(out, dto) + } + return out +} + +// Hijack/outage/ransomware endpoints all return the raw collector +// payload (the JSON the WS path emits), so the dashboard's panel-render +// fields like prefixes/enrichment survive the cold-load → live-stream +// transition without shape divergence. diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go new file mode 100644 index 00000000..0c4455b4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go @@ -0,0 +1,192 @@ +// AngelaMos | 2026 +// auth.go + +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +const ( + UserIDKey contextKey = "user_id" + UserRoleKey contextKey = "user_role" + UserTierKey contextKey = "user_tier" + ClaimsKey contextKey = "jwt_claims" +) + +type TokenVerifier interface { + VerifyAccessToken( + ctx context.Context, + token string, + ) (*AccessTokenClaims, error) +} + +type AccessTokenClaims struct { + UserID string + Role string + Tier string + TokenVersion int + JTI string + ExpiresAt time.Time +} + +func Authenticator(verifier TokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractToken(r) + + if token == "" { + core.JSONError( + w, + core.UnauthorizedError("missing authorization token"), + ) + return + } + + claims, err := verifier.VerifyAccessToken(r.Context(), token) + if err != nil { + handleAuthError(w, err) + return + } + + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserTierKey, claims.Tier) + ctx = context.WithValue(ctx, ClaimsKey, claims) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func OptionalAuth(verifier TokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractToken(r) + + if token != "" { + claims, err := verifier.VerifyAccessToken(r.Context(), token) + if err == nil { + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserTierKey, claims.Tier) + ctx = context.WithValue(ctx, ClaimsKey, claims) + r = r.WithContext(ctx) + } + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequireRole(roles ...string) func(http.Handler) http.Handler { + roleSet := make(map[string]struct{}, len(roles)) + for _, role := range roles { + roleSet[role] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := GetUserRole(r.Context()) + + if userRole == "" { + core.JSONError( + w, + core.UnauthorizedError("authentication required"), + ) + return + } + + if _, ok := roleSet[userRole]; !ok { + core.JSONError( + w, + core.ForbiddenError("insufficient permissions"), + ) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequireAdmin(next http.Handler) http.Handler { + return RequireRole("admin")(next) +} + +func ExtractToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "" + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + + return strings.TrimSpace(parts[1]) +} + +func handleAuthError(w http.ResponseWriter, err error) { + if core.IsAppError(err) { + core.JSONError(w, err) + return + } + + switch { + case errors.Is(err, core.ErrTokenExpired): + core.JSONError(w, core.TokenExpiredError()) + case errors.Is(err, core.ErrTokenRevoked): + core.JSONError(w, core.TokenRevokedError()) + case errors.Is(err, core.ErrTokenInvalid): + core.JSONError(w, core.TokenInvalidError()) + default: + core.JSONError(w, core.TokenInvalidError()) + } +} + +func GetUserID(ctx context.Context) string { + if id, ok := ctx.Value(UserIDKey).(string); ok { + return id + } + return "" +} + +func GetUserRole(ctx context.Context) string { + if role, ok := ctx.Value(UserRoleKey).(string); ok { + return role + } + return "" +} + +func GetUserTier(ctx context.Context) string { + if tier, ok := ctx.Value(UserTierKey).(string); ok { + return tier + } + return "" +} + +func GetClaims(ctx context.Context) *AccessTokenClaims { + if claims, ok := ctx.Value(ClaimsKey).(*AccessTokenClaims); ok { + return claims + } + return nil +} + +func IsAuthenticated(ctx context.Context) bool { + return GetUserID(ctx) != "" +} + +func IsAdmin(ctx context.Context) bool { + return GetUserRole(ctx) == "admin" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip.go new file mode 100644 index 00000000..e0170846 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip.go @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// clientip.go + +package middleware + +import ( + "net" + "net/http" + "strings" +) + +// ClientIP returns the request's source IP. If trustedHops > 0, it walks back +// that many hops from the right side of X-Forwarded-For (the closest hops are +// the most trusted, since each proxy appends to the right). With trustedHops +// == 0, X-Forwarded-For is ignored entirely and only RemoteAddr is honored — +// the safe default when the binary is reachable directly without a known +// proxy chain. Set trusted_proxy_hops in config to match your deployment +// (e.g., 1 behind nginx, 2 behind nginx+Cloudflare). +func ClientIP(r *http.Request, trustedHops int) string { + if trustedHops > 0 { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + for i := range ips { + ips[i] = strings.TrimSpace(ips[i]) + } + idx := len(ips) - trustedHops + if idx < 0 { + idx = 0 + } + if idx < len(ips) && ips[idx] != "" { + return ips[idx] + } + } + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + return xri + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip_test.go new file mode 100644 index 00000000..4f46f834 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip_test.go @@ -0,0 +1,61 @@ +// ©AngelaMos | 2026 +// clientip_test.go + +package middleware_test + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +func TestClientIP_TrustedHopsZero_IgnoresXFF(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + r.Header.Set("X-Real-IP", "1.2.3.4") + + require.Equal(t, "10.0.0.5", middleware.ClientIP(r, 0)) +} + +func TestClientIP_TrustedHopsOne_PeelsRightmost(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.1, 10.0.0.5") + + require.Equal(t, "10.0.0.5", middleware.ClientIP(r, 1)) +} + +func TestClientIP_TrustedHopsTwo_PeelsTwoFromRight(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.7, 10.0.0.5") + + require.Equal(t, "198.51.100.7", middleware.ClientIP(r, 2)) +} + +func TestClientIP_TrustedHopsExceedsList_ReturnsLeftmost(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.1, 10.0.0.5") + + require.Equal(t, "203.0.113.1", middleware.ClientIP(r, 5)) +} + +func TestClientIP_TrustedHopsOne_NoXFF_FallsBackToRemoteAddr(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + + require.Equal(t, "10.0.0.5", middleware.ClientIP(r, 1)) +} + +func TestClientIP_TrustedHopsOne_HonorsXRealIP(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Real-IP", "203.0.113.42") + + require.Equal(t, "203.0.113.42", middleware.ClientIP(r, 1)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go new file mode 100644 index 00000000..121e4dc9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go @@ -0,0 +1,103 @@ +// AngelaMos | 2026 +// headers.go + +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" +) + +func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("X-XSS-Protection", "1; mode=block") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + ) + + if isProduction { + h.Set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ) + } + + h.Set("Content-Security-Policy", buildCSP(isProduction)) + + next.ServeHTTP(w, r) + }) + } +} + +func buildCSP(isProduction bool) string { + directives := []string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + } + + if !isProduction { + directives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + } + + return strings.Join(directives, "; ") +} + +func CORS(cfg config.CORSConfig) func(http.Handler) http.Handler { + allowedOrigins := make(map[string]struct{}, len(cfg.AllowedOrigins)) + for _, origin := range cfg.AllowedOrigins { + allowedOrigins[origin] = struct{}{} + } + + methodsStr := strings.Join(cfg.AllowedMethods, ", ") + headersStr := strings.Join(cfg.AllowedHeaders, ", ") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" { + if _, ok := allowedOrigins[origin]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + + if cfg.AllowCredentials { + w.Header(). + Set("Access-Control-Allow-Credentials", "true") + } + } + } + + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", methodsStr) + w.Header().Set("Access-Control-Allow-Headers", headersStr) + + if cfg.MaxAge > 0 { + w.Header(). + Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge)) + } + + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go new file mode 100644 index 00000000..602ed4c7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go @@ -0,0 +1,99 @@ +// AngelaMos | 2026 +// logging.go + +package middleware + +import ( + "context" + "log/slog" + "net/http" + "time" + + "go.opentelemetry.io/otel/trace" +) + +type loggerKey struct{} + +func Logger(baseLogger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + requestID := GetRequestID(r.Context()) + + reqLogger := baseLogger.With( + slog.String("request_id", requestID), + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + ) + + if span := trace.SpanFromContext(r.Context()); span.SpanContext(). + IsValid() { + reqLogger = reqLogger.With( + slog.String( + "trace_id", + span.SpanContext().TraceID().String(), + ), + slog.String( + "span_id", + span.SpanContext().SpanID().String(), + ), + ) + } + + ctx := context.WithValue(r.Context(), loggerKey{}, reqLogger) + + ww := &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + next.ServeHTTP(ww, r.WithContext(ctx)) + + latency := time.Since(start) + + logLevel := slog.LevelInfo + if ww.status >= 500 { + logLevel = slog.LevelError + } else if ww.status >= 400 { + logLevel = slog.LevelWarn + } + + reqLogger.Log(r.Context(), logLevel, "request completed", + slog.Int("status", ww.status), + slog.Int("bytes", ww.bytes), + slog.Duration("latency", latency), + slog.String("user_agent", r.UserAgent()), + ) + }) + } +} + +func GetLogger(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +type responseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + n, err := rw.ResponseWriter.Write(b) + rw.bytes += n + return n, err +} + +func (rw *responseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go new file mode 100644 index 00000000..12afa67b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go @@ -0,0 +1,368 @@ +// AngelaMos | 2026 +// ratelimit.go + +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "sync" + "time" + + redis_rate "github.com/go-redis/redis_rate/v10" + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +type RateLimitConfig struct { + Limit redis_rate.Limit + KeyFunc func(*http.Request) string + FailOpen bool + BypassFunc func(*http.Request) bool + OnLimited func(http.ResponseWriter, *http.Request, *redis_rate.Result) +} + +type RateLimiter struct { + limiter *redis_rate.Limiter + fallback *localLimiter + config RateLimitConfig +} + +func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiter { + if cfg.KeyFunc == nil { + cfg.KeyFunc = KeyByIP + } + + return &RateLimiter{ + limiter: redis_rate.NewLimiter(rdb), + fallback: newLocalLimiter(), + config: cfg, + } +} + +func (rl *RateLimiter) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if rl.config.BypassFunc != nil && rl.config.BypassFunc(r) { + next.ServeHTTP(w, r) + return + } + + key := rl.config.KeyFunc(r) + res, err := rl.allow(r.Context(), key) + if err != nil { + if rl.config.FailOpen { + slog.Warn("rate limiter error, failing open", + "error", err, + "key", key, + ) + next.ServeHTTP(w, r) + return + } + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + + setRateLimitHeaders(w, res, rl.config.Limit) + + if res.Allowed == 0 { + if rl.config.OnLimited != nil { + rl.config.OnLimited(w, r, res) + return + } + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) allow( + ctx context.Context, + key string, +) (*redis_rate.Result, error) { + res, err := rl.limiter.Allow(ctx, key, rl.config.Limit) + if err != nil { + return rl.fallback.allow(key, rl.config.Limit) + } + return res, nil +} + +// KeyByIP keys requests by their RemoteAddr only — does NOT trust +// X-Forwarded-For. Use KeyByClientIP(trustedHops) when you have a known +// proxy chain to peel back. +func KeyByIP(r *http.Request) string { + return "ratelimit:ip:" + ClientIP(r, 0) +} + +// KeyByClientIP returns a KeyFunc that honors trustedHops of X-Forwarded-For. +func KeyByClientIP(trustedHops int) func(*http.Request) string { + return func(r *http.Request) string { + return "ratelimit:ip:" + ClientIP(r, trustedHops) + } +} + +func KeyByUser(r *http.Request) string { + if userID := GetUserID(r.Context()); userID != "" { + return "ratelimit:user:" + userID + } + return KeyByIP(r) +} + +func KeyByUserAndEndpoint(r *http.Request) string { + userKey := KeyByUser(r) + endpoint := normalizeEndpoint(r.URL.Path) + return fmt.Sprintf("%s:endpoint:%s", userKey, endpoint) +} + +func normalizeEndpoint(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + normalized := make([]string, 0, len(parts)) + + for _, part := range parts { + if isUUID(part) || isNumeric(part) { + normalized = append(normalized, "{id}") + } else { + normalized = append(normalized, part) + } + } + + return "/" + strings.Join(normalized, "/") +} + +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-' +} + +func isNumeric(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 +} + +func setRateLimitHeaders( + w http.ResponseWriter, + res *redis_rate.Result, + limit redis_rate.Limit, +) { + h := w.Header() + + h.Set("X-RateLimit-Limit", strconv.Itoa(limit.Rate)) + h.Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining)) + h.Set("X-RateLimit-Reset", strconv.FormatInt( + time.Now().Add(res.ResetAfter).Unix(), 10)) + + windowSecs := int(limit.Period.Seconds()) + h.Set("RateLimit-Policy", fmt.Sprintf(`%d;w=%d`, limit.Rate, windowSecs)) + h.Set( + "RateLimit", + fmt.Sprintf(`%d;t=%d`, res.Remaining, int(res.ResetAfter.Seconds())), + ) +} + +func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) { + retryAfter := int(res.RetryAfter.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + + response := map[string]any{ + "success": false, + "error": map[string]any{ + "code": "RATE_LIMITED", + "message": fmt.Sprintf( + "Rate limit exceeded. Retry after %d seconds.", + retryAfter, + ), + }, + } + + //nolint:errcheck // best-effort response write + _ = json.NewEncoder(w).Encode(response) +} + +type limiterEntry struct { + limiter *rate.Limiter + lastAccess int64 +} + +type localLimiter struct { + limiters sync.Map +} + +const ( + cleanupInterval = 5 * time.Minute + entryTTL = 10 * time.Minute +) + +func newLocalLimiter() *localLimiter { + l := &localLimiter{} + go l.cleanup() + return l +} + +func (l *localLimiter) cleanup() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for range ticker.C { + cutoff := time.Now().Add(-entryTTL).Unix() + l.limiters.Range(func(key, value any) bool { + entry, ok := value.(*limiterEntry) + if ok && entry.lastAccess < cutoff { + l.limiters.Delete(key) + } + return true + }) + } +} + +func (l *localLimiter) allow( + key string, + limit redis_rate.Limit, +) (*redis_rate.Result, error) { + ratePerSec := float64(limit.Rate) / limit.Period.Seconds() + now := time.Now().Unix() + + entryI, loaded := l.limiters.Load(key) + if !loaded { + newEntry := &limiterEntry{ + limiter: rate.NewLimiter( + rate.Limit(ratePerSec), + limit.Burst, + ), + lastAccess: now, + } + entryI, _ = l.limiters.LoadOrStore(key, newEntry) + } + + entry, ok := entryI.(*limiterEntry) + if !ok { + return nil, fmt.Errorf("invalid limiter entry type") + } + entry.lastAccess = now + + allowed := entry.limiter.Allow() + + remaining := int(entry.limiter.Tokens()) + if remaining < 0 { + remaining = 0 + } + + var retryAfter time.Duration + if !allowed { + retryAfter = time.Duration(float64(time.Second) / ratePerSec) + } else { + retryAfter = -1 + } + + allowedInt := 0 + if allowed { + allowedInt = 1 + } + + return &redis_rate.Result{ + Limit: limit, + Allowed: allowedInt, + Remaining: remaining, + RetryAfter: retryAfter, + ResetAfter: time.Duration(float64(time.Second) / ratePerSec), + }, nil +} + +type TierConfig struct { + RequestsPerMinute int + BurstSize int +} + +var DefaultTiers = map[string]TierConfig{ + "free": {RequestsPerMinute: 60, BurstSize: 10}, + "pro": {RequestsPerMinute: 600, BurstSize: 100}, + "enterprise": {RequestsPerMinute: 6000, BurstSize: 1000}, +} + +func TieredRateLimiter( + rdb *redis.Client, + tiers map[string]TierConfig, +) func(http.Handler) http.Handler { + limiter := redis_rate.NewLimiter(rdb) + fallback := newLocalLimiter() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := GetUserID(r.Context()) + tier := GetUserTier(r.Context()) + + if tier == "" { + tier = "free" + } + + config, ok := tiers[tier] + if !ok { + config = tiers["free"] + } + + limit := redis_rate.Limit{ + Rate: config.RequestsPerMinute, + Burst: config.BurstSize, + Period: time.Minute, + } + + key := fmt.Sprintf("ratelimit:user:%s", userID) + + res, err := limiter.Allow(r.Context(), key, limit) + if err != nil { + //nolint:errcheck // fallback never fails + res, _ = fallback.allow(key, limit) + } + + w.Header().Set("X-RateLimit-Tier", tier) + setRateLimitHeaders(w, res, limit) + + if res.Allowed == 0 { + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func PerMinute(rate, burst int) redis_rate.Limit { + return PerWindow(rate, burst, time.Minute) +} + +func PerSecond(rate, burst int) redis_rate.Limit { + return PerWindow(rate, burst, time.Second) +} + +func PerHour(rate, burst int) redis_rate.Limit { + return PerWindow(rate, burst, time.Hour) +} + +func PerWindow(rate, burst int, window time.Duration) redis_rate.Limit { + if window <= 0 { + window = time.Minute + } + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: window, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go new file mode 100644 index 00000000..77d98165 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// request_id.go + +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type contextKey string + +const RequestIDKey contextKey = "request_id" + +const RequestIDHeader = "X-Request-ID" + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get(RequestIDHeader) + + if requestID == "" { + requestID = uuid.New().String() + } + + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + + w.Header().Set(RequestIDHeader, requestID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetRequestID(ctx context.Context) string { + if id, ok := ctx.Value(RequestIDKey).(string); ok { + return id + } + return "" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/bridge.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/bridge.go new file mode 100644 index 00000000..147bfafb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/bridge.go @@ -0,0 +1,113 @@ +// ©AngelaMos | 2026 +// bridge.go + +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/alerts" +) + +// Bridge wires the notifications module into the alerts engine. It loads +// a user's configured channels (decrypting webhook URLs / Telegram bot +// tokens on demand) and routes outbound alerts to the right transport. +// +// Without this seam, the alerts engine would have to know about +// encryption/repo specifics; with it, alerts.Engine treats every +// destination as the same interface (alerts.Channel + Notifier). +type Bridge struct { + repo Repository + enc *Encryptor + sender *Sender + logger *slog.Logger +} + +func NewBridge( + repo Repository, + enc *Encryptor, + sender *Sender, + logger *slog.Logger, +) *Bridge { + if logger == nil { + logger = slog.Default() + } + return &Bridge{repo: repo, enc: enc, sender: sender, logger: logger} +} + +// LoadChannels implements alerts.ChannelLoader. Returns every linked +// destination for the user — Slack/Discord webhook channels plus the +// Telegram bot if it's been linked. Decryption errors mark the channel +// invalid and skip it. +func (b *Bridge) LoadChannels( + ctx context.Context, + userID string, +) ([]alerts.Channel, error) { + out := []alerts.Channel{} + + channels, err := b.repo.ListChannels(ctx, userID) + if err != nil { + return nil, fmt.Errorf("load webhook channels: %w", err) + } + for _, ch := range channels { + if ch.Invalid { + continue + } + raw, decErr := b.enc.Decrypt(ch.ConfigEnc, ch.Nonce) + if decErr != nil { + b.logger.Warn("alerts: channel decrypt failed", + "channel_id", ch.ID, "type", ch.Type, "err", decErr) + continue + } + var cfg webhookChannelConfig + if jErr := json.Unmarshal(raw, &cfg); jErr != nil { + b.logger.Warn("alerts: channel config unmarshal failed", + "channel_id", ch.ID, "type", ch.Type, "err", jErr) + continue + } + out = append(out, alerts.Channel{ + ID: ch.ID, + Type: ch.Type, + Label: ch.Label, + WebhookURL: cfg.WebhookURL, + }) + } + + tw, err := b.repo.GetTelegramWebhook(ctx, userID) + if err == nil && tw != nil && tw.IsLinked() { + botToken, derr := b.enc.Decrypt(tw.BotTokenEnc, tw.BotTokenNonce) + if derr == nil && tw.ChatID != nil { + out = append(out, alerts.Channel{ + ID: "telegram:" + userID, + Type: ChannelTypeTelegram, + Label: "Telegram", + BotToken: string(botToken), + ChatID: *tw.ChatID, + }) + } + } + + return out, nil +} + +// SendAlert implements alerts.Notifier. Routes by channel type to the +// matching transport on the Sender. +func (b *Bridge) SendAlert( + ctx context.Context, + ch alerts.Channel, + message string, +) error { + switch ch.Type { + case ChannelTypeSlack: + return b.sender.SendSlack(ctx, ch.WebhookURL, message) + case ChannelTypeDiscord: + return b.sender.SendDiscord(ctx, ch.WebhookURL, message) + case ChannelTypeTelegram: + return b.sender.SendTelegram(ctx, ch.BotToken, ch.ChatID, message) + default: + return fmt.Errorf("unsupported channel type: %s", ch.Type) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/crypto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/crypto.go new file mode 100644 index 00000000..b9d31340 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/crypto.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// crypto.go + +package notifications + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +type Encryptor struct { + key []byte +} + +func NewEncryptor(b64Key string) (*Encryptor, error) { + key, err := base64.StdEncoding.DecodeString(b64Key) + if err != nil { + return nil, fmt.Errorf("decode encryption key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf( + "encryption key must be 32 bytes, got %d", + len(key), + ) + } + return &Encryptor{key: key}, nil +} + +func (e *Encryptor) Encrypt( + plaintext []byte, +) (ciphertext, nonce []byte, err error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return nil, nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, fmt.Errorf("create GCM: %w", err) + } + + nonce = make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("generate nonce: %w", err) + } + + ciphertext = gcm.Seal(nil, nonce, plaintext, nil) + return ciphertext, nonce, nil +} + +func (e *Encryptor) Decrypt(ciphertext, nonce []byte) ([]byte, error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) + } + + return plaintext, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/dto.go new file mode 100644 index 00000000..0dac3340 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/dto.go @@ -0,0 +1,56 @@ +// ©AngelaMos | 2026 +// dto.go + +package notifications + +import "time" + +type ChannelResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Invalid bool `json:"invalid"` + CreatedAt time.Time `json:"created_at"` +} + +type TelegramStatusResponse struct { + Configured bool `json:"configured"` + Linked bool `json:"linked"` + PendingLink bool `json:"pending_link"` + WebhookURL string `json:"webhook_url,omitempty"` + WebhookRegistered bool `json:"webhook_registered"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +type RegisterTelegramResponse struct { + WebhookURL string `json:"webhook_url"` + WebhookRegistered bool `json:"webhook_registered"` +} + +type ChannelListResponse struct { + Channels []ChannelResponse `json:"channels"` + Telegram TelegramStatusResponse `json:"telegram"` +} + +type CreateChannelRequest struct { + Type string `json:"type" validate:"required,oneof=slack discord"` + Label string `json:"label" validate:"required,min=1,max=100"` + WebhookURL string `json:"webhook_url" validate:"required,url,max=2048"` +} + +type RegisterTelegramRequest struct { + BotToken string `json:"bot_token" validate:"required,min=10,max=200"` +} + +type webhookChannelConfig struct { + WebhookURL string `json:"webhook_url"` +} + +type telegramUpdate struct { + UpdateID int64 `json:"update_id"` + Message *struct { + Chat struct { + ID int64 `json:"id"` + } `json:"chat"` + } `json:"message"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/entity.go new file mode 100644 index 00000000..b6dc6678 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/entity.go @@ -0,0 +1,39 @@ +// ©AngelaMos | 2026 +// entity.go + +package notifications + +import "time" + +const ( + ChannelTypeSlack = "slack" + ChannelTypeDiscord = "discord" + ChannelTypeTelegram = "telegram" +) + +type Channel struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Type string `db:"type"` + Label string `db:"label"` + ConfigEnc []byte `db:"config_enc"` + Nonce []byte `db:"nonce"` + Invalid bool `db:"invalid"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type TelegramWebhook struct { + UserID string `db:"user_id"` + WebhookUUID string `db:"webhook_uuid"` + SecretToken string `db:"secret_token"` + BotTokenEnc []byte `db:"bot_token_enc"` + BotTokenNonce []byte `db:"bot_token_nonce"` + ChatID *int64 `db:"chat_id"` + PendingLink bool `db:"pending_link"` + CreatedAt time.Time `db:"created_at"` +} + +func (t *TelegramWebhook) IsLinked() bool { + return !t.PendingLink && t.ChatID != nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/handler.go new file mode 100644 index 00000000..2880a9a8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/handler.go @@ -0,0 +1,226 @@ +// ©AngelaMos | 2026 +// handler.go + +package notifications + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +const headerTelegramSecret = "X-Telegram-Bot-Api-Secret-Token" + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/notifications", func(r chi.Router) { + r.Post( + "/telegram/webhook/{uuid}", + h.HandleTelegramUpdate, + ) + + r.Group(func(r chi.Router) { + r.Use(authenticator) + + r.Get("/channels", h.ListChannels) + r.Post("/channels", h.CreateChannel) + r.Delete("/channels/{id}", h.DeleteChannel) + r.Post("/channels/{id}/test", h.TestChannel) + + r.Post("/telegram", h.RegisterTelegram) + r.Delete("/telegram", h.UnlinkTelegram) + r.Post("/telegram/test", h.TestTelegram) + r.Get("/telegram/status", h.GetTelegramStatus) + }) + }) +} + +func (h *Handler) ListChannels(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + resp, err := h.service.ListChannels(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, resp) +} + +func (h *Handler) CreateChannel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req CreateChannelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + ch, err := h.service.CreateChannel(r.Context(), userID, req) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Created(w, ch) +} + +func (h *Handler) DeleteChannel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + + if err := h.service.DeleteChannel(r.Context(), id, userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "channel") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) TestChannel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + + if err := h.service.TestChannel(r.Context(), id, userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "channel") + return + } + core.JSONError(w, core.NewAppError( + err, + "test notification failed: "+err.Error(), + http.StatusBadGateway, + "NOTIFICATION_FAILED", + )) + return + } + + core.NoContent(w) +} + +func (h *Handler) RegisterTelegram(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req RegisterTelegramRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + resp, err := h.service.RegisterTelegram(r.Context(), userID, req) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Created(w, resp) +} + +func (h *Handler) UnlinkTelegram(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.UnlinkTelegram(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "telegram channel") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) TestTelegram(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.TestTelegram(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "telegram channel") + return + } + core.JSONError(w, core.NewAppError( + err, + err.Error(), + http.StatusBadGateway, + "NOTIFICATION_FAILED", + )) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetTelegramStatus(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + status, err := h.service.GetTelegramStatus(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, status) +} + +func (h *Handler) HandleTelegramUpdate( + w http.ResponseWriter, + r *http.Request, +) { + webhookUUID := chi.URLParam(r, "uuid") + secretToken := r.Header.Get(headerTelegramSecret) + + var update telegramUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if err := h.service.HandleTelegramUpdate( + r.Context(), webhookUUID, secretToken, update, + ); err != nil { + if errors.Is(err, core.ErrNotFound) || + errors.Is(err, core.ErrForbidden) { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/repository.go new file mode 100644 index 00000000..c3215e14 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/repository.go @@ -0,0 +1,259 @@ +// ©AngelaMos | 2026 +// repository.go + +package notifications + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Repository interface { + ListChannels(ctx context.Context, userID string) ([]Channel, error) + GetChannel(ctx context.Context, id, userID string) (*Channel, error) + CreateChannel(ctx context.Context, ch *Channel) error + DeleteChannel(ctx context.Context, id, userID string) error + MarkChannelInvalid(ctx context.Context, id string) error + + GetTelegramWebhook( + ctx context.Context, + userID string, + ) (*TelegramWebhook, error) + GetTelegramWebhookByUUID( + ctx context.Context, + uuid string, + ) (*TelegramWebhook, error) + UpsertTelegramWebhook(ctx context.Context, tw *TelegramWebhook) error + UpdateTelegramChatID(ctx context.Context, uuid string, chatID int64) error + DeleteTelegramWebhook(ctx context.Context, userID string) error +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) ListChannels( + ctx context.Context, + userID string, +) ([]Channel, error) { + query := ` + SELECT id, user_id, type, label, config_enc, nonce, + invalid, created_at, updated_at + FROM alert_channels + WHERE user_id = $1 + ORDER BY created_at DESC` + + var channels []Channel + if err := r.db.SelectContext(ctx, &channels, query, userID); err != nil { + return nil, fmt.Errorf("list channels: %w", err) + } + return channels, nil +} + +func (r *repository) GetChannel( + ctx context.Context, + id, userID string, +) (*Channel, error) { + query := ` + SELECT id, user_id, type, label, config_enc, nonce, + invalid, created_at, updated_at + FROM alert_channels + WHERE id = $1 AND user_id = $2` + + var ch Channel + err := r.db.GetContext(ctx, &ch, query, id, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get channel: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get channel: %w", err) + } + return &ch, nil +} + +func (r *repository) CreateChannel(ctx context.Context, ch *Channel) error { + query := ` + INSERT INTO alert_channels + (id, user_id, type, label, config_enc, nonce) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at` + + err := r.db.GetContext(ctx, ch, query, + ch.ID, + ch.UserID, + ch.Type, + ch.Label, + ch.ConfigEnc, + ch.Nonce, + ) + if err != nil { + return fmt.Errorf("create channel: %w", err) + } + return nil +} + +func (r *repository) DeleteChannel( + ctx context.Context, + id, userID string, +) error { + query := `DELETE FROM alert_channels WHERE id = $1 AND user_id = $2` + + result, err := r.db.ExecContext(ctx, query, id, userID) + if err != nil { + return fmt.Errorf("delete channel: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete channel: %w", err) + } + if rows == 0 { + return fmt.Errorf("delete channel: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) MarkChannelInvalid(ctx context.Context, id string) error { + query := ` + UPDATE alert_channels + SET invalid = true, updated_at = NOW() + WHERE id = $1` + + _, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("mark channel invalid: %w", err) + } + return nil +} + +func (r *repository) GetTelegramWebhook( + ctx context.Context, + userID string, +) (*TelegramWebhook, error) { + query := ` + SELECT user_id, webhook_uuid, secret_token, bot_token_enc, + bot_token_nonce, chat_id, pending_link, created_at + FROM telegram_webhooks + WHERE user_id = $1` + + var tw TelegramWebhook + err := r.db.GetContext(ctx, &tw, query, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get telegram webhook: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get telegram webhook: %w", err) + } + return &tw, nil +} + +func (r *repository) GetTelegramWebhookByUUID( + ctx context.Context, + uuid string, +) (*TelegramWebhook, error) { + query := ` + SELECT user_id, webhook_uuid, secret_token, bot_token_enc, + bot_token_nonce, chat_id, pending_link, created_at + FROM telegram_webhooks + WHERE webhook_uuid = $1` + + var tw TelegramWebhook + err := r.db.GetContext(ctx, &tw, query, uuid) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf( + "get telegram webhook by uuid: %w", + core.ErrNotFound, + ) + } + if err != nil { + return nil, fmt.Errorf("get telegram webhook by uuid: %w", err) + } + return &tw, nil +} + +func (r *repository) UpsertTelegramWebhook( + ctx context.Context, + tw *TelegramWebhook, +) error { + query := ` + INSERT INTO telegram_webhooks + (user_id, webhook_uuid, secret_token, + bot_token_enc, bot_token_nonce, pending_link) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id) DO UPDATE + SET webhook_uuid = EXCLUDED.webhook_uuid, + secret_token = EXCLUDED.secret_token, + bot_token_enc = EXCLUDED.bot_token_enc, + bot_token_nonce = EXCLUDED.bot_token_nonce, + chat_id = NULL, + pending_link = true, + created_at = now() + RETURNING created_at` + + err := r.db.GetContext(ctx, &tw.CreatedAt, query, + tw.UserID, + tw.WebhookUUID, + tw.SecretToken, + tw.BotTokenEnc, + tw.BotTokenNonce, + tw.PendingLink, + ) + if err != nil { + return fmt.Errorf("upsert telegram webhook: %w", err) + } + return nil +} + +func (r *repository) UpdateTelegramChatID( + ctx context.Context, + uuid string, + chatID int64, +) error { + query := ` + UPDATE telegram_webhooks + SET chat_id = $2, pending_link = false + WHERE webhook_uuid = $1` + + result, err := r.db.ExecContext(ctx, query, uuid, chatID) + if err != nil { + return fmt.Errorf("update telegram chat_id: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update telegram chat_id: %w", err) + } + if rows == 0 { + return fmt.Errorf("update telegram chat_id: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) DeleteTelegramWebhook( + ctx context.Context, + userID string, +) error { + query := `DELETE FROM telegram_webhooks WHERE user_id = $1` + + result, err := r.db.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("delete telegram webhook: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete telegram webhook: %w", err) + } + if rows == 0 { + return fmt.Errorf("delete telegram webhook: %w", core.ErrNotFound) + } + return nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/sender.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/sender.go new file mode 100644 index 00000000..70a8f617 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/sender.go @@ -0,0 +1,223 @@ +// ©AngelaMos | 2026 +// sender.go + +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const ( + telegramAPIBase = "https://api.telegram.org" + senderTimeout = 10 * time.Second + testMessage = "Monitor the Situation — test notification. Channel configured successfully." +) + +type Sender struct { + client *http.Client +} + +func NewSender() *Sender { + return &Sender{ + client: &http.Client{Timeout: senderTimeout}, + } +} + +func (s *Sender) TestTelegram( + ctx context.Context, + botToken string, + chatID int64, +) error { + return s.sendTelegram(ctx, botToken, chatID, testMessage) +} + +// SendTelegram is the exported version used by the alerts engine. +func (s *Sender) SendTelegram( + ctx context.Context, + botToken string, + chatID int64, + text string, +) error { + return s.sendTelegram(ctx, botToken, chatID, text) +} + +// SendSlack posts a plain-text message to a Slack incoming-webhook URL. +func (s *Sender) SendSlack(ctx context.Context, webhookURL, text string) error { + payload, err := json.Marshal(map[string]string{"text": text}) + if err != nil { + return fmt.Errorf("marshal slack payload: %w", err) + } + return s.postWebhook(ctx, webhookURL, payload) +} + +// SendDiscord posts a plain-text message to a Discord webhook URL. +func (s *Sender) SendDiscord( + ctx context.Context, + webhookURL, text string, +) error { + payload, err := json.Marshal(map[string]string{"content": text}) + if err != nil { + return fmt.Errorf("marshal discord payload: %w", err) + } + return s.postWebhook(ctx, webhookURL, payload) +} + +func (s *Sender) sendTelegram( + ctx context.Context, + botToken string, + chatID int64, + text string, +) error { + url := fmt.Sprintf("%s/bot%s/sendMessage", telegramAPIBase, botToken) + + payload, err := json.Marshal(map[string]any{ + "chat_id": chatID, + "text": text, + }) + if err != nil { + return fmt.Errorf("marshal telegram payload: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, url, bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create telegram request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("send telegram: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "telegram sendMessage: unexpected status %d", + resp.StatusCode, + ) + } + return nil +} + +func (s *Sender) SetTelegramWebhook( + ctx context.Context, + botToken, webhookURL, secretToken string, +) error { + url := fmt.Sprintf("%s/bot%s/setWebhook", telegramAPIBase, botToken) + + payload, err := json.Marshal(map[string]any{ + "url": webhookURL, + "secret_token": secretToken, + "allowed_updates": []string{"message"}, + "drop_pending_updates": true, + }) + if err != nil { + return fmt.Errorf("marshal setWebhook payload: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, url, bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create setWebhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("setWebhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "setWebhook: unexpected status %d", + resp.StatusCode, + ) + } + return nil +} + +func (s *Sender) DeleteTelegramWebhook( + ctx context.Context, + botToken string, +) error { + url := fmt.Sprintf( + "%s/bot%s/deleteWebhook", + telegramAPIBase, + botToken, + ) + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, url, http.NoBody, + ) + if err != nil { + return fmt.Errorf("create deleteWebhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("deleteWebhook: %w", err) + } + defer resp.Body.Close() + + return nil +} + +func (s *Sender) TestSlack(ctx context.Context, webhookURL string) error { + payload, err := json.Marshal(map[string]string{ + "text": testMessage, + }) + if err != nil { + return fmt.Errorf("marshal slack payload: %w", err) + } + + return s.postWebhook(ctx, webhookURL, payload) +} + +func (s *Sender) TestDiscord(ctx context.Context, webhookURL string) error { + payload, err := json.Marshal(map[string]string{ + "content": testMessage, + }) + if err != nil { + return fmt.Errorf("marshal discord payload: %w", err) + } + + return s.postWebhook(ctx, webhookURL, payload) +} + +func (s *Sender) postWebhook( + ctx context.Context, + webhookURL string, + payload []byte, +) error { + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, webhookURL, bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("send webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf( + "webhook: unexpected status %d", + resp.StatusCode, + ) + } + return nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/service.go new file mode 100644 index 00000000..29a8d066 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/service.go @@ -0,0 +1,327 @@ +// ©AngelaMos | 2026 +// service.go + +package notifications + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + + "github.com/google/uuid" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Service struct { + repo Repository + enc *Encryptor + sender *Sender + publicURL string + logger *slog.Logger +} + +func NewService( + repo Repository, + enc *Encryptor, + sender *Sender, + publicURL string, + logger *slog.Logger, +) *Service { + return &Service{ + repo: repo, + enc: enc, + sender: sender, + publicURL: publicURL, + logger: logger, + } +} + +func (s *Service) ListChannels( + ctx context.Context, + userID string, +) (*ChannelListResponse, error) { + channels, err := s.repo.ListChannels(ctx, userID) + if err != nil { + return nil, err + } + + resp := &ChannelListResponse{ + Channels: make([]ChannelResponse, 0, len(channels)), + } + + for _, ch := range channels { + resp.Channels = append(resp.Channels, ChannelResponse{ + ID: ch.ID, + Type: ch.Type, + Label: ch.Label, + Invalid: ch.Invalid, + CreatedAt: ch.CreatedAt, + }) + } + + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if err != nil && !errors.Is(err, core.ErrNotFound) { + return nil, err + } + + if tw != nil { + resp.Telegram = TelegramStatusResponse{ + Configured: true, + Linked: tw.IsLinked(), + PendingLink: tw.PendingLink, + WebhookURL: s.webhookURL(tw.WebhookUUID), + CreatedAt: tw.CreatedAt, + } + } + + return resp, nil +} + +func (s *Service) CreateChannel( + ctx context.Context, + userID string, + req CreateChannelRequest, +) (*ChannelResponse, error) { + cfg := webhookChannelConfig{WebhookURL: req.WebhookURL} + + raw, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal channel config: %w", err) + } + + enc, nonce, err := s.enc.Encrypt(raw) + if err != nil { + return nil, fmt.Errorf("encrypt channel config: %w", err) + } + + ch := &Channel{ + ID: uuid.New().String(), + UserID: userID, + Type: req.Type, + Label: req.Label, + ConfigEnc: enc, + Nonce: nonce, + } + + if err := s.repo.CreateChannel(ctx, ch); err != nil { + return nil, err + } + + return &ChannelResponse{ + ID: ch.ID, + Type: ch.Type, + Label: ch.Label, + Invalid: ch.Invalid, + CreatedAt: ch.CreatedAt, + }, nil +} + +func (s *Service) DeleteChannel( + ctx context.Context, + id, userID string, +) error { + return s.repo.DeleteChannel(ctx, id, userID) +} + +func (s *Service) TestChannel( + ctx context.Context, + id, userID string, +) error { + ch, err := s.repo.GetChannel(ctx, id, userID) + if err != nil { + return err + } + + raw, err := s.enc.Decrypt(ch.ConfigEnc, ch.Nonce) + if err != nil { + return fmt.Errorf("decrypt channel config: %w", err) + } + + var cfg webhookChannelConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return fmt.Errorf("unmarshal channel config: %w", err) + } + + switch ch.Type { + case ChannelTypeSlack: + return s.sender.TestSlack(ctx, cfg.WebhookURL) + case ChannelTypeDiscord: + return s.sender.TestDiscord(ctx, cfg.WebhookURL) + default: + return fmt.Errorf("unsupported channel type: %s", ch.Type) + } +} + +func (s *Service) RegisterTelegram( + ctx context.Context, + userID string, + req RegisterTelegramRequest, +) (*RegisterTelegramResponse, error) { + encToken, nonce, err := s.enc.Encrypt([]byte(req.BotToken)) + if err != nil { + return nil, fmt.Errorf("encrypt bot token: %w", err) + } + + webhookUUID := uuid.New().String() + secretToken, err := randomHex(32) + if err != nil { + return nil, fmt.Errorf("generate secret token: %w", err) + } + + tw := &TelegramWebhook{ + UserID: userID, + WebhookUUID: webhookUUID, + SecretToken: secretToken, + BotTokenEnc: encToken, + BotTokenNonce: nonce, + PendingLink: true, + } + + if err := s.repo.UpsertTelegramWebhook(ctx, tw); err != nil { + return nil, err + } + + wURL := s.webhookURL(webhookUUID) + + webhookRegistered := true + if err := s.sender.SetTelegramWebhook( + ctx, req.BotToken, wURL, secretToken, + ); err != nil { + webhookRegistered = false + s.logger.Warn( + "failed to auto-register telegram webhook", + "error", err, + "webhook_url", wURL, + ) + } + + return &RegisterTelegramResponse{ + WebhookURL: wURL, + WebhookRegistered: webhookRegistered, + }, nil +} + +func (s *Service) GetTelegramStatus( + ctx context.Context, + userID string, +) (*TelegramStatusResponse, error) { + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if errors.Is(err, core.ErrNotFound) { + return &TelegramStatusResponse{Configured: false}, nil + } + if err != nil { + return nil, err + } + + return &TelegramStatusResponse{ + Configured: true, + Linked: tw.IsLinked(), + PendingLink: tw.PendingLink, + WebhookURL: s.webhookURL(tw.WebhookUUID), + CreatedAt: tw.CreatedAt, + }, nil +} + +func (s *Service) UnlinkTelegram(ctx context.Context, userID string) error { + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if errors.Is(err, core.ErrNotFound) { + return core.ErrNotFound + } + if err != nil { + return err + } + + botToken, err := s.enc.Decrypt(tw.BotTokenEnc, tw.BotTokenNonce) + if err == nil { + if delErr := s.sender.DeleteTelegramWebhook( + ctx, string(botToken), + ); delErr != nil { + s.logger.Warn( + "failed to delete telegram webhook on unlink", + "error", delErr, + ) + } + } + + return s.repo.DeleteTelegramWebhook(ctx, userID) +} + +func (s *Service) TestTelegram(ctx context.Context, userID string) error { + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if errors.Is(err, core.ErrNotFound) { + return fmt.Errorf( + "no telegram channel configured: %w", + core.ErrNotFound, + ) + } + if err != nil { + return err + } + + if !tw.IsLinked() { + return fmt.Errorf( + "telegram not linked yet: send any message to your bot first", + ) + } + + botToken, err := s.enc.Decrypt(tw.BotTokenEnc, tw.BotTokenNonce) + if err != nil { + return fmt.Errorf("decrypt bot token: %w", err) + } + + return s.sender.TestTelegram(ctx, string(botToken), *tw.ChatID) +} + +func (s *Service) HandleTelegramUpdate( + ctx context.Context, + webhookUUID, secretToken string, + update telegramUpdate, +) error { + tw, err := s.repo.GetTelegramWebhookByUUID(ctx, webhookUUID) + if errors.Is(err, core.ErrNotFound) { + return core.ErrNotFound + } + if err != nil { + return err + } + + if subtle.ConstantTimeCompare( + []byte(tw.SecretToken), + []byte(secretToken), + ) != 1 { + return core.ErrForbidden + } + + if update.Message == nil { + return nil + } + + chatID := update.Message.Chat.ID + if chatID == 0 { + return nil + } + + return s.repo.UpdateTelegramChatID(ctx, webhookUUID, chatID) +} + +func (s *Service) webhookURL(webhookUUID string) string { + return fmt.Sprintf( + "%s/api/v1/notifications/telegram/webhook/%s", + s.publicURL, + webhookUUID, + ) +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go new file mode 100644 index 00000000..4e969bbb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go @@ -0,0 +1,73 @@ +// ©AngelaMos | 2026 +// limiter.go + +package ratelimit + +import ( + "context" + "log/slog" + "time" + + "github.com/sony/gobreaker/v2" + "golang.org/x/time/rate" +) + +type Config struct { + Name string + Rate rate.Limit + Burst int + ConsecutiveFailureBudget uint32 + BreakerTimeout time.Duration + OnStateChange func(name string, from, to gobreaker.State) +} + +type Limiter[T any] struct { + name string + rl *rate.Limiter + cb *gobreaker.CircuitBreaker[T] +} + +func New[T any](cfg Config) *Limiter[T] { + if cfg.ConsecutiveFailureBudget == 0 { + cfg.ConsecutiveFailureBudget = 5 + } + if cfg.BreakerTimeout == 0 { + cfg.BreakerTimeout = 30 * time.Second + } + settings := gobreaker.Settings{ + Name: cfg.Name, + Timeout: cfg.BreakerTimeout, + ReadyToTrip: func(c gobreaker.Counts) bool { + return c.ConsecutiveFailures >= cfg.ConsecutiveFailureBudget + }, + OnStateChange: func(name string, from, to gobreaker.State) { + slog.Warn("circuit breaker state change", + "name", name, "from", from.String(), "to", to.String()) + if cfg.OnStateChange != nil { + cfg.OnStateChange(name, from, to) + } + }, + } + return &Limiter[T]{ + name: cfg.Name, + rl: rate.NewLimiter(cfg.Rate, cfg.Burst), + cb: gobreaker.NewCircuitBreaker[T](settings), + } +} + +func (l *Limiter[T]) Do( + ctx context.Context, + fn func(context.Context) (T, error), +) (T, error) { + var zero T + if err := l.rl.Wait(ctx); err != nil { + return zero, err + } + return l.cb.Execute(func() (T, error) { return fn(ctx) }) +} + +func (l *Limiter[T]) AdjustRate(newRate rate.Limit) { + l.rl.SetLimit(newRate) +} + +func (l *Limiter[T]) Name() string { return l.name } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go new file mode 100644 index 00000000..6002e1f4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// limiter_test.go + +package ratelimit_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/sony/gobreaker/v2" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ratelimit" +) + +func TestLimiter_AllowsAtNominalRate(t *testing.T) { + lim := ratelimit.New[string](ratelimit.Config{ + Name: "test-allow", + Rate: rate.Every(20 * time.Millisecond), + Burst: 4, + ConsecutiveFailureBudget: 5, + BreakerTimeout: 100 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + calls := 0 + for i := 0; i < 4; i++ { + _, err := lim.Do(ctx, func(_ context.Context) (string, error) { + calls++ + return "ok", nil + }) + require.NoError(t, err) + } + require.Equal(t, 4, calls) +} + +func TestLimiter_OpensBreakerOnRepeatedFailures(t *testing.T) { + lim := ratelimit.New[string](ratelimit.Config{ + Name: "test-fail", + Rate: rate.Every(2 * time.Millisecond), + Burst: 10, + ConsecutiveFailureBudget: 3, + BreakerTimeout: 50 * time.Millisecond, + }) + + ctx := context.Background() + failure := errors.New("upstream broken") + + for i := 0; i < 3; i++ { + _, _ = lim.Do( + ctx, + func(_ context.Context) (string, error) { return "", failure }, + ) + } + + called := false + _, err := lim.Do(ctx, func(_ context.Context) (string, error) { + called = true + return "", nil + }) + require.False( + t, + called, + "should not have called function — breaker should be open", + ) + require.ErrorIs(t, err, gobreaker.ErrOpenState) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go new file mode 100644 index 00000000..9d6ed444 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go @@ -0,0 +1,92 @@ +// ©AngelaMos | 2026 +// ring.go + +package redisring + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + defaultRetention = 24 * time.Hour +) + +type Config struct { + Retention time.Duration +} + +type Client struct { + rc *redis.Client + retention time.Duration +} + +func New(rc *redis.Client, cfg Config) *Client { + if cfg.Retention <= 0 { + cfg.Retention = defaultRetention + } + return &Client{rc: rc, retention: cfg.Retention} +} + +func (c *Client) Push( + ctx context.Context, + key string, + score int64, + payload []byte, +) error { + cutoff := time.UnixMilli(score).Add(-c.retention).UnixMilli() + pipe := c.rc.Pipeline() + pipe.ZAdd(ctx, key, redis.Z{Score: float64(score), Member: payload}) + pipe.ZRemRangeByScore(ctx, key, "-inf", "("+strconv.FormatInt(cutoff, 10)) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("ring push %s @ %d: %w", key, score, err) + } + return nil +} + +func (c *Client) Recent( + ctx context.Context, + key string, + n int, +) ([][]byte, error) { + if n <= 0 { + return nil, nil + } + res, err := c.rc.ZRevRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: "-inf", + Max: "+inf", + Offset: 0, + Count: int64(n), + }).Result() + if err != nil { + return nil, fmt.Errorf("ring recent %s: %w", key, err) + } + out := make([][]byte, 0, len(res)) + for _, s := range res { + out = append(out, []byte(s)) + } + return out, nil +} + +func (c *Client) Range( + ctx context.Context, + key string, + fromScore, toScore int64, +) ([][]byte, error) { + res, err := c.rc.ZRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: strconv.FormatInt(fromScore, 10), + Max: strconv.FormatInt(toScore, 10), + }).Result() + if err != nil { + return nil, fmt.Errorf("ring range %s: %w", key, err) + } + out := make([][]byte, 0, len(res)) + for _, s := range res { + out = append(out, []byte(s)) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go new file mode 100644 index 00000000..7955222e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go @@ -0,0 +1,111 @@ +// ©AngelaMos | 2026 +// ring_test.go + +package redisring_test + +import ( + "context" + "fmt" + "testing" + "time" + + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring" +) + +func setupRedis(t *testing.T) *goredis.Client { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + rc, err := tcredis.Run(ctx, "redis:7-alpine") + require.NoError(t, err) + t.Cleanup(func() { _ = rc.Terminate(context.Background()) }) + + uri, err := rc.ConnectionString(ctx) + require.NoError(t, err) + opt, err := goredis.ParseURL(uri) + require.NoError(t, err) + c := goredis.NewClient(opt) + t.Cleanup(func() { _ = c.Close() }) + return c +} + +func TestRing_PushAndRecentRoundtrips(t *testing.T) { + c := setupRedis(t) + r := redisring.New(c, redisring.Config{Retention: 24 * time.Hour}) + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 5; i++ { + ts := now.Add(-time.Duration(i) * time.Minute) + require.NoError( + t, + r.Push( + ctx, + "test:key", + ts.UnixMilli(), + []byte(fmt.Sprintf(`{"i":%d}`, i)), + ), + ) + } + + got, err := r.Recent(ctx, "test:key", 3) + require.NoError(t, err) + require.Len(t, got, 3) +} + +func TestRing_RetentionPrunesOldEntries(t *testing.T) { + c := setupRedis(t) + r := redisring.New(c, redisring.Config{Retention: 1 * time.Hour}) + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 5; i++ { + old := now.Add(-2 * time.Hour).Add(-time.Duration(i) * time.Minute) + require.NoError( + t, + r.Push( + ctx, + "test:key", + old.UnixMilli(), + []byte(fmt.Sprintf(`old-%d`, i)), + ), + ) + } + require.NoError(t, r.Push(ctx, "test:key", now.UnixMilli(), []byte("now"))) + + got, err := r.Recent(ctx, "test:key", 100) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "now", string(got[0])) +} + +func TestRing_RangeReturnsItemsWithinScoreWindow(t *testing.T) { + c := setupRedis(t) + r := redisring.New(c, redisring.Config{Retention: 24 * time.Hour}) + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 10; i++ { + ts := now.Add(-time.Duration(i) * time.Minute) + require.NoError( + t, + r.Push( + ctx, + "test:key", + ts.UnixMilli(), + []byte(fmt.Sprintf("i=%d", i)), + ), + ) + } + + from := now.Add(-3 * time.Minute).UnixMilli() + to := now.UnixMilli() + got, err := r.Range(ctx, "test:key", from, to) + require.NoError(t, err) + require.Len(t, got, 4, "expected items in [now-3m, now] inclusive") +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go new file mode 100644 index 00000000..1c39db94 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go @@ -0,0 +1,108 @@ +// AngelaMos | 2026 +// server.go + +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" +) + +type Server struct { + httpServer *http.Server + router *chi.Mux + config config.ServerConfig + healthHandler *health.Handler + logger *slog.Logger +} + +type Config struct { + ServerConfig config.ServerConfig + HealthHandler *health.Handler + Logger *slog.Logger +} + +func New(cfg Config) *Server { + router := chi.NewRouter() + + router.Use(chimw.CleanPath) + router.Use(chimw.StripSlashes) + + return &Server{ + httpServer: &http.Server{ + Addr: cfg.ServerConfig.Address(), + Handler: router, + ReadTimeout: cfg.ServerConfig.ReadTimeout, + WriteTimeout: cfg.ServerConfig.WriteTimeout, + IdleTimeout: cfg.ServerConfig.IdleTimeout, + }, + router: router, + config: cfg.ServerConfig, + healthHandler: cfg.HealthHandler, + logger: cfg.Logger, + } +} + +func (s *Server) Router() *chi.Mux { + return s.router +} + +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", + "addr", s.config.Address(), + "read_timeout", s.config.ReadTimeout, + "write_timeout", s.config.WriteTimeout, + "idle_timeout", s.config.IdleTimeout, + ) + + if err := s.httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server error: %w", err) + } + + return nil +} + +func (s *Server) Shutdown(ctx context.Context, drainDelay time.Duration) error { + s.logger.Info("initiating graceful shutdown") + + s.logger.Info("marking server as not ready") + if s.healthHandler != nil { + s.healthHandler.SetReady(false) + s.healthHandler.SetShutdown(true) + } + + s.logger.Info("waiting for load balancer to drain", + "delay", drainDelay, + ) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(drainDelay): + } + + s.logger.Info("stopping HTTP server") + shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) + defer cancel() + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("http server shutdown: %w", err) + } + + s.logger.Info("HTTP server stopped gracefully") + return nil +} + +func (s *Server) Address() string { + return s.httpServer.Addr +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go new file mode 100644 index 00000000..1393f2d5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go @@ -0,0 +1,26 @@ +// ©AngelaMos | 2026 +// handler.go + +package snapshot + +import ( + "net/http" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Handler struct { + store *Store +} + +func NewHandler(store *Store) *Handler { return &Handler{store: store} } + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + all, err := h.store.GetAll(r.Context()) + if err != nil { + core.InternalServerError(w, err) + return + } + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + core.OK(w, all) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go new file mode 100644 index 00000000..5c899c42 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go @@ -0,0 +1,27 @@ +// ©AngelaMos | 2026 +// persister.go + +package snapshot + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type StorePersister struct { + Store *Store +} + +func (s StorePersister) Save(ctx context.Context, ev events.Event) error { + raw, err := json.Marshal(ev.Payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + if ev.Topic == events.TopicCoinbasePrice { + return s.Store.MergeSymbolMap(ctx, ev.Topic, raw) + } + return s.Store.PutLatest(ctx, ev.Topic, raw) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go new file mode 100644 index 00000000..25fc629f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go @@ -0,0 +1,89 @@ +// ©AngelaMos | 2026 +// snapshot_test.go + +package snapshot_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot" +) + +func startRedis(t *testing.T) *redis.Client { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + c, err := tcredis.Run(ctx, "redis:7-alpine") + require.NoError(t, err) + t.Cleanup(func() { + _ = c.Terminate(context.Background()) + }) + + endpoint, err := c.Endpoint(ctx, "") + require.NoError(t, err) + rdb := redis.NewClient(&redis.Options{Addr: endpoint}) + t.Cleanup(func() { _ = rdb.Close() }) + + return rdb +} + +func TestStore_PutLatestAndGetAll(t *testing.T) { + rdb := startRedis(t) + store := snapshot.NewStore(rdb) + + ctx := context.Background() + require.NoError( + t, + store.PutLatest( + ctx, + events.TopicHeartbeat, + json.RawMessage(`{"ts":"2026-05-01T00:00:00Z"}`), + ), + ) + require.NoError( + t, + store.PutLatest( + ctx, + events.TopicCVENew, + json.RawMessage(`{"id":"CVE-2026-9999"}`), + ), + ) + + all, err := store.GetAll(ctx) + require.NoError(t, err) + require.Contains(t, all, string(events.TopicHeartbeat)) + require.Contains(t, all, string(events.TopicCVENew)) + + var hb map[string]string + require.NoError(t, json.Unmarshal(all[string(events.TopicHeartbeat)], &hb)) + require.Equal(t, "2026-05-01T00:00:00Z", hb["ts"]) +} + +func TestStorePersister_SavesEventPayload(t *testing.T) { + rdb := startRedis(t) + store := snapshot.NewStore(rdb) + persister := snapshot.StorePersister{Store: store} + + ctx := context.Background() + ev := events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + Source: "heartbeat", + Payload: map[string]string{"ts": "2026-05-01T00:00:00Z"}, + } + require.NoError(t, persister.Save(ctx, ev)) + + all, err := store.GetAll(ctx) + require.NoError(t, err) + require.Contains(t, all, string(events.TopicHeartbeat)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go new file mode 100644 index 00000000..541c95aa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// store.go + +package snapshot + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + keyPrefix = "state:" + keyHashPrice = "state:coinbase_price" + scanCount = 100 + wrongTypeMarker = "WRONGTYPE" +) + +type Store struct { + rdb *redis.Client +} + +func NewStore(rdb *redis.Client) *Store { return &Store{rdb: rdb} } + +func (s *Store) PutLatest( + ctx context.Context, + topic events.Topic, + payload json.RawMessage, +) error { + if err := s.rdb.Set(ctx, keyPrefix+string(topic), []byte(payload), 0). + Err(); err != nil { + return fmt.Errorf("redis set %s: %w", topic, err) + } + return nil +} + +func (s *Store) MergeSymbolMap( + ctx context.Context, + topic events.Topic, + payload json.RawMessage, +) error { + var tick struct { + Symbol string `json:"symbol"` + } + if err := json.Unmarshal(payload, &tick); err != nil || tick.Symbol == "" { + return fmt.Errorf("coinbase merge: no symbol in payload") + } + + key := keyPrefix + string(topic) + err := s.rdb.HSet(ctx, key, tick.Symbol, []byte(payload)).Err() + if err == nil { + return nil + } + if !isWrongType(err) { + return fmt.Errorf("redis hset %s/%s: %w", topic, tick.Symbol, err) + } + if delErr := s.rdb.Del(ctx, key).Err(); delErr != nil { + return fmt.Errorf("redis del legacy %s: %w", key, delErr) + } + if err := s.rdb.HSet(ctx, key, tick.Symbol, []byte(payload)). + Err(); err != nil { + return fmt.Errorf( + "redis hset %s/%s after migration: %w", + topic, + tick.Symbol, + err, + ) + } + return nil +} + +func isWrongType(err error) bool { + return err != nil && strings.Contains(err.Error(), wrongTypeMarker) +} + +func (s *Store) GetAll( + ctx context.Context, +) (map[string]json.RawMessage, error) { + out := map[string]json.RawMessage{} + var cursor uint64 + for { + keys, next, err := s.rdb.Scan(ctx, cursor, keyPrefix+"*", scanCount). + Result() + if err != nil { + return nil, fmt.Errorf("scan state: %w", err) + } + for _, k := range keys { + topic := k[len(keyPrefix):] + val, err := s.readKey(ctx, k) + if err != nil { + continue + } + out[topic] = val + } + if next == 0 { + break + } + cursor = next + } + return out, nil +} + +func (s *Store) readKey( + ctx context.Context, + key string, +) (json.RawMessage, error) { + t, err := s.rdb.Type(ctx, key).Result() + if err != nil { + return nil, err + } + switch t { + case "string": + v, err := s.rdb.Get(ctx, key).Bytes() + if err != nil { + return nil, err + } + return json.RawMessage(v), nil + case "hash": + fields, err := s.rdb.HGetAll(ctx, key).Result() + if err != nil { + return nil, err + } + m := make(map[string]json.RawMessage, len(fields)) + for k, v := range fields { + m[k] = json.RawMessage(v) + } + return json.Marshal(m) + } + return nil, fmt.Errorf("unsupported redis type %q for key %s", t, key) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go new file mode 100644 index 00000000..011142ca --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go @@ -0,0 +1,95 @@ +// AngelaMos | 2026 +// dto.go + +package user + +import ( + "time" +) + +type CreateUserRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` +} + +type UpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` +} + +type UpdateEmailRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewEmail string `json:"new_email" validate:"required,email,max=255"` +} + +type AdminUpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` + Tier *string `json:"tier,omitempty" validate:"omitempty,oneof=free pro enterprise"` +} + +type AdminCreateUserRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` + Tier *string `json:"tier,omitempty" validate:"omitempty,oneof=free pro enterprise"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Tier string `json:"tier"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserListResponse struct { + Users []UserResponse `json:"users"` +} + +type ListUsersParams struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Search string `json:"search"` + Role string `json:"role"` + Tier string `json:"tier"` +} + +func (p *ListUsersParams) Normalize() { + if p.Page < 1 { + p.Page = 1 + } + if p.PageSize < 1 { + p.PageSize = 20 + } + if p.PageSize > 100 { + p.PageSize = 100 + } +} + +func (p *ListUsersParams) Offset() int { + return (p.Page - 1) * p.PageSize +} + +func ToUserResponse(u *User) UserResponse { + return UserResponse{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Role: u.Role, + Tier: u.Tier, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +func ToUserResponseList(users []User) []UserResponse { + responses := make([]UserResponse, 0, len(users)) + for _, u := range users { + responses = append(responses, ToUserResponse(&u)) + } + return responses +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go new file mode 100644 index 00000000..f830b054 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// entity.go + +package user + +import ( + "time" +) + +type User struct { + ID string `db:"id"` + Email string `db:"email"` + PasswordHash string `db:"password_hash"` + Name string `db:"name"` + Role string `db:"role"` + Tier string `db:"tier"` + TokenVersion int `db:"token_version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` +} + +func (u *User) IsDeleted() bool { + return u.DeletedAt != nil +} + +func (u *User) IsAdmin() bool { + return u.Role == RoleAdmin +} + +const ( + RoleUser = "user" + RoleAdmin = "admin" +) + +const ( + TierFree = "free" + TierPro = "pro" + TierEnterprise = "enterprise" +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go new file mode 100644 index 00000000..76915993 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go @@ -0,0 +1,318 @@ +// AngelaMos | 2026 +// handler.go + +package user + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/users", func(r chi.Router) { + r.Use(authenticator) + + r.Get("/me", h.GetMe) + r.Put("/me", h.UpdateMe) + r.Put("/me/email", h.UpdateMyEmail) + r.Delete("/me", h.DeleteMe) + }) +} + +func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + user, err := h.service.GetMe(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateMe(r.Context(), userID, req) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateMyEmail changes the authenticated user's email after re-verifying +// their password. +func (h *Handler) UpdateMyEmail(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req UpdateEmailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateEmail( + r.Context(), + userID, + req.CurrentPassword, + req.NewEmail, + ) + if err != nil { + if errors.Is(err, core.ErrUnauthorized) { + core.JSONError( + w, + core.UnauthorizedError("current password is incorrect"), + ) + return + } + if errors.Is(err, core.ErrDuplicateKey) { + core.JSONError(w, core.DuplicateError("email")) + return + } + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + if errors.Is(err, core.ErrInvalidInput) { + core.BadRequest(w, err.Error()) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +func (h *Handler) DeleteMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.DeleteMe(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +// RegisterAdminRoutes registers admin-only user management endpoints. +func (h *Handler) RegisterAdminRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin/users", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/", h.ListUsers) + r.Post("/", h.AdminCreateUser) + r.Get("/{userID}", h.GetUser) + r.Put("/{userID}", h.AdminUpdateUser) + r.Delete("/{userID}", h.DeleteUser) + }) +} + +// ListUsers returns a paginated list of users with optional filtering. +func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { + params := ListUsersParams{ + Page: parseIntQuery(r, "page", 1), + PageSize: parseIntQuery(r, "page_size", 20), + Search: r.URL.Query().Get("search"), + Role: r.URL.Query().Get("role"), + Tier: r.URL.Query().Get("tier"), + } + + users, total, err := h.service.ListUsers(r.Context(), params) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Paginated( + w, + ToUserResponseList(users), + params.Page, + params.PageSize, + total, + ) +} + +// GetUser returns a specific user by ID (admin only). +func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + user, err := h.service.GetUser(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// AdminUpdateUser updates a specific user — name, role, tier (admin only). +func (h *Handler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req AdminUpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.AdminUpdateUser(r.Context(), userID, req) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + if errors.Is(err, core.ErrInvalidInput) { + core.BadRequest(w, err.Error()) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// AdminCreateUser creates a new user (admin only). +func (h *Handler) AdminCreateUser(w http.ResponseWriter, r *http.Request) { + var req AdminCreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.AdminCreateUser(r.Context(), req) + if err != nil { + if errors.Is(err, core.ErrDuplicateKey) { + core.JSONError(w, core.DuplicateError("email")) + return + } + if errors.Is(err, core.ErrInvalidInput) { + core.BadRequest(w, err.Error()) + return + } + core.InternalServerError(w, err) + return + } + + core.Created(w, ToUserResponse(user)) +} + +// DeleteUser soft deletes a user account (admin only). +func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { + requesterID := middleware.GetUserID(r.Context()) + targetID := chi.URLParam(r, "userID") + + if err := h.service.CanDeleteUser( + r.Context(), + requesterID, + targetID, + ); err != nil { + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "insufficient permissions") + return + } + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + if err := h.service.DeleteUser(r.Context(), targetID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func parseIntQuery(r *http.Request, key string, defaultVal int) int { + val := r.URL.Query().Get(key) + if val == "" { + return defaultVal + } + + parsed, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + + return parsed +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go new file mode 100644 index 00000000..22b4cd94 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go @@ -0,0 +1,314 @@ +// AngelaMos | 2026 +// repository.go + +package user + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Repository interface { + Create(ctx context.Context, user *User) error + GetByID(ctx context.Context, id string) (*User, error) + GetByEmail(ctx context.Context, email string) (*User, error) + Update(ctx context.Context, user *User) error + UpdatePassword(ctx context.Context, id, passwordHash string) error + UpdateEmail(ctx context.Context, id, email string) error + IncrementTokenVersion(ctx context.Context, id string) error + SoftDelete(ctx context.Context, id string) error + List(ctx context.Context, params ListUsersParams) ([]User, int, error) + ExistsByEmail(ctx context.Context, email string) (bool, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) Create(ctx context.Context, user *User) error { + query := ` + INSERT INTO users (id, email, password_hash, name, role, tier) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at, token_version` + + err := r.db.GetContext(ctx, user, query, + user.ID, + user.Email, + user.PasswordHash, + user.Name, + user.Role, + user.Tier, + ) + if err != nil { + if isDuplicateKeyError(err) { + return fmt.Errorf("create user: %w", core.ErrDuplicateKey) + } + return fmt.Errorf("create user: %w", err) + } + + return nil +} + +func (r *repository) GetByID(ctx context.Context, id string) (*User, error) { + query := ` + SELECT id, email, password_hash, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE id = $1 AND deleted_at IS NULL` + + var user User + err := r.db.GetContext(ctx, &user, query, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get user: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return &user, nil +} + +func (r *repository) GetByEmail( + ctx context.Context, + email string, +) (*User, error) { + query := ` + SELECT id, email, password_hash, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE email = $1 AND deleted_at IS NULL` + + var user User + err := r.db.GetContext(ctx, &user, query, email) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get user by email: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + + return &user, nil +} + +func (r *repository) Update(ctx context.Context, user *User) error { + query := ` + UPDATE users + SET name = $2, role = $3, tier = $4, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + RETURNING updated_at` + + err := r.db.GetContext(ctx, &user.UpdatedAt, query, + user.ID, + user.Name, + user.Role, + user.Tier, + ) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("update user: %w", core.ErrNotFound) + } + if err != nil { + return fmt.Errorf("update user: %w", err) + } + + return nil +} + +func (r *repository) UpdatePassword( + ctx context.Context, + id, passwordHash string, +) error { + query := ` + UPDATE users + SET password_hash = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id, passwordHash) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + if rows == 0 { + return fmt.Errorf("update password: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) UpdateEmail(ctx context.Context, id, email string) error { + query := ` + UPDATE users + SET email = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id, email) + if err != nil { + if isDuplicateKeyError(err) { + return fmt.Errorf("update email: %w", core.ErrDuplicateKey) + } + return fmt.Errorf("update email: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update email: %w", err) + } + if rows == 0 { + return fmt.Errorf("update email: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) IncrementTokenVersion( + ctx context.Context, + id string, +) error { + query := ` + UPDATE users + SET token_version = token_version + 1, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + if rows == 0 { + return fmt.Errorf("increment token version: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) SoftDelete(ctx context.Context, id string) error { + query := ` + UPDATE users + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if rows == 0 { + return fmt.Errorf("delete user: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) List( + ctx context.Context, + params ListUsersParams, +) ([]User, int, error) { + params.Normalize() + + var conditions []string + var args []any + argIdx := 1 + + conditions = append(conditions, "deleted_at IS NULL") + + if params.Search != "" { + conditions = append(conditions, fmt.Sprintf( + "(email ILIKE $%d OR name ILIKE $%d)", argIdx, argIdx)) + args = append(args, "%"+escapeLike(params.Search)+"%") + argIdx++ + } + + if params.Role != "" { + conditions = append(conditions, fmt.Sprintf("role = $%d", argIdx)) + args = append(args, params.Role) + argIdx++ + } + + if params.Tier != "" { + conditions = append(conditions, fmt.Sprintf("tier = $%d", argIdx)) + args = append(args, params.Tier) + argIdx++ + } + + whereClause := strings.Join(conditions, " AND ") + + countQuery := fmt.Sprintf( + "SELECT COUNT(*) FROM users WHERE %s", + whereClause, + ) + var total int + if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + + query := fmt.Sprintf(` + SELECT id, email, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d`, + whereClause, argIdx, argIdx+1) + + args = append(args, params.PageSize, params.Offset()) + + var users []User + if err := r.db.SelectContext(ctx, &users, query, args...); err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + + return users, total, nil +} + +func (r *repository) ExistsByEmail( + ctx context.Context, + email string, +) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND deleted_at IS NULL)` + + var exists bool + if err := r.db.GetContext(ctx, &exists, query, email); err != nil { + return false, fmt.Errorf("check email exists: %w", err) + } + + return exists, nil +} + +func isDuplicateKeyError(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} + +func escapeLike(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "%", "\\%") + s = strings.ReplaceAll(s, "_", "\\_") + return s +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go new file mode 100644 index 00000000..5bb1d20a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go @@ -0,0 +1,350 @@ +// AngelaMos | 2026 +// service.go + +package user + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetByID( + ctx context.Context, + id string, +) (*auth.UserInfo, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) GetByEmail( + ctx context.Context, + email string, +) (*auth.UserInfo, error) { + user, err := s.repo.GetByEmail(ctx, strings.ToLower(email)) + if err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) Create( + ctx context.Context, + email, passwordHash, name string, +) (*auth.UserInfo, error) { + user := &User{ + ID: uuid.New().String(), + Email: strings.ToLower(email), + PasswordHash: passwordHash, + Name: name, + Role: RoleUser, + Tier: TierFree, + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) IncrementTokenVersion( + ctx context.Context, + userID string, +) error { + return s.repo.IncrementTokenVersion(ctx, userID) +} + +func (s *Service) UpdatePassword( + ctx context.Context, + userID, passwordHash string, +) error { + return s.repo.UpdatePassword(ctx, userID, passwordHash) +} + +// SetRole flips a user's role. Used by ADMIN_EMAIL bootstrapping in +// auth.Service: the configured email gets promoted to admin on every +// login/register so a fresh DB after env changes still has an admin. +func (s *Service) SetRole(ctx context.Context, userID, role string) error { + if role != RoleUser && role != RoleAdmin { + return fmt.Errorf( + "set role: invalid role %q: %w", + role, + core.ErrInvalidInput, + ) + } + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return err + } + user.Role = role + return s.repo.Update(ctx, user) +} + +// UpdateEmail changes a user's email after re-verifying their password. +// Email is normalized to lowercase. Returns ErrDuplicateKey if the new +// email is already taken by another account. +func (s *Service) UpdateEmail( + ctx context.Context, + userID, currentPassword, newEmail string, +) (*User, error) { + newEmail = strings.ToLower(strings.TrimSpace(newEmail)) + if newEmail == "" { + return nil, fmt.Errorf("update email: empty: %w", core.ErrInvalidInput) + } + + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + valid, _, err := core.VerifyPasswordWithRehash( + currentPassword, + user.PasswordHash, + ) + if err != nil { + return nil, fmt.Errorf("verify password: %w", err) + } + if !valid { + return nil, fmt.Errorf("update email: %w", core.ErrUnauthorized) + } + + if user.Email == newEmail { + return user, nil + } + + if err := s.repo.UpdateEmail(ctx, userID, newEmail); err != nil { + return nil, err + } + user.Email = newEmail + return user, nil +} + +func (s *Service) GetUser(ctx context.Context, id string) (*User, error) { + return s.repo.GetByID(ctx, id) +} + +func (s *Service) UpdateUser( + ctx context.Context, + id string, + req UpdateUserRequest, +) (*User, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + user.Name = *req.Name + } + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) AdminUpdateUser( + ctx context.Context, + id string, + req AdminUpdateUserRequest, +) (*User, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + user.Name = *req.Name + } + if req.Role != nil { + if *req.Role != RoleUser && *req.Role != RoleAdmin { + return nil, fmt.Errorf( + "update role: invalid role %q: %w", + *req.Role, + core.ErrInvalidInput, + ) + } + user.Role = *req.Role + } + if req.Tier != nil { + if *req.Tier != TierFree && *req.Tier != TierPro && + *req.Tier != TierEnterprise { + return nil, fmt.Errorf( + "update tier: invalid tier %q: %w", + *req.Tier, + core.ErrInvalidInput, + ) + } + user.Tier = *req.Tier + } + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) AdminCreateUser( + ctx context.Context, + req AdminCreateUserRequest, +) (*User, error) { + hash, err := core.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user := &User{ + ID: uuid.New().String(), + Email: strings.ToLower(req.Email), + PasswordHash: hash, + Name: req.Name, + Role: RoleUser, + Tier: TierFree, + } + if req.Role != nil { + if *req.Role != RoleUser && *req.Role != RoleAdmin { + return nil, fmt.Errorf( + "create user: invalid role %q: %w", + *req.Role, + core.ErrInvalidInput, + ) + } + user.Role = *req.Role + } + if req.Tier != nil { + if *req.Tier != TierFree && *req.Tier != TierPro && + *req.Tier != TierEnterprise { + return nil, fmt.Errorf( + "create user: invalid tier %q: %w", + *req.Tier, + core.ErrInvalidInput, + ) + } + user.Tier = *req.Tier + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) DeleteUser(ctx context.Context, id string) error { + return s.repo.SoftDelete(ctx, id) +} + +func (s *Service) ListUsers( + ctx context.Context, + params ListUsersParams, +) ([]User, int, error) { + return s.repo.List(ctx, params) +} + +func (s *Service) GetMe(ctx context.Context, userID string) (*User, error) { + if userID == "" { + return nil, fmt.Errorf("get me: %w", core.ErrUnauthorized) + } + + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateMe( + ctx context.Context, + userID string, + req UpdateUserRequest, +) (*User, error) { + if userID == "" { + return nil, fmt.Errorf("update me: %w", core.ErrUnauthorized) + } + + return s.UpdateUser(ctx, userID, req) +} + +func (s *Service) DeleteMe(ctx context.Context, userID string) error { + if userID == "" { + return fmt.Errorf("delete me: %w", core.ErrUnauthorized) + } + + return s.repo.SoftDelete(ctx, userID) +} + +func (s *Service) EmailExists( + ctx context.Context, + email string, +) (bool, error) { + exists, err := s.repo.ExistsByEmail(ctx, email) + if err != nil { + return false, err + } + return exists, nil +} + +func (s *Service) CanDeleteUser( + ctx context.Context, + requesterID, targetID string, +) error { + if requesterID == targetID { + return nil + } + + requester, err := s.repo.GetByID(ctx, requesterID) + if err != nil { + return err + } + + if !requester.IsAdmin() { + return fmt.Errorf("delete user: %w", core.ErrForbidden) + } + + target, err := s.repo.GetByID(ctx, targetID) + if err != nil { + return err + } + + if target.IsAdmin() { + return fmt.Errorf("cannot delete admin users: %w", core.ErrForbidden) + } + + return nil +} + +func toUserInfo(u *User) *auth.UserInfo { + return &auth.UserInfo{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + PasswordHash: u.PasswordHash, + Role: u.Role, + Tier: u.Tier, + TokenVersion: u.TokenVersion, + } +} + +var _ auth.UserProvider = (*Service)(nil) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go new file mode 100644 index 00000000..24d99dbc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go @@ -0,0 +1,14 @@ +// ©AngelaMos | 2026 +// broadcaster.go + +package ws + +import "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + +type HubBroadcaster struct { + Hub *Hub +} + +func (b HubBroadcaster) Broadcast(topic string, payload []byte) { + b.Hub.Broadcast(events.Topic(topic), payload) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go new file mode 100644 index 00000000..0f4b4c65 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go @@ -0,0 +1,34 @@ +// ©AngelaMos | 2026 +// envelope.go + +package ws + +import ( + "bytes" + "encoding/json" + "time" +) + +func EncodeEnvelope(channel string, payload []byte) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString(`{"ch":`) + chRaw, err := json.Marshal(channel) + if err != nil { + return nil, err + } + buf.Write(chRaw) + buf.WriteString(`,"data":`) + if len(payload) == 0 { + buf.WriteString("null") + } else { + buf.Write(payload) + } + buf.WriteString(`,"ts":`) + tsRaw, err := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + buf.Write(tsRaw) + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go new file mode 100644 index 00000000..474c29af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go @@ -0,0 +1,36 @@ +// ©AngelaMos | 2026 +// envelope_test.go + +package ws_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +func TestEncodeEnvelope(t *testing.T) { + out, err := ws.EncodeEnvelope( + "heartbeat", + []byte(`{"ts":"2026-05-01T00:00:00Z"}`), + ) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + require.Equal(t, "heartbeat", got["ch"]) + require.Contains(t, got, "data") + require.Contains(t, got, "ts") +} + +func TestEncodeEnvelope_NilPayload(t *testing.T) { + out, err := ws.EncodeEnvelope("heartbeat", nil) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + require.Nil(t, got["data"]) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go new file mode 100644 index 00000000..21ffcf16 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go @@ -0,0 +1,104 @@ +// ©AngelaMos | 2026 +// handler.go + +package ws + +import ( + "net/http" + "strings" + "sync" + "sync/atomic" + + cdrws "github.com/coder/websocket" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +// defaultMaxConnsPerIP is permissive — a single user with React +// StrictMode in dev (effect runs twice), two tabs, and a brief reconnect +// race already burns ~6 simultaneous connections from one IP. The hub- +// level MaxSubscribers (default 5000) is the real DoS guard; this just +// blocks a single host from running thousands of sockets. +const defaultMaxConnsPerIP = 25 + +type HandlerConfig struct { + Hub *Hub + MaxConnsPerIP int + TrustedProxyHops int +} + +type Handler struct { + hub *Hub + maxConnsPerIP int + trustedProxyHops int + perIP sync.Map +} + +func NewHandler(hub *Hub) *Handler { + return NewHandlerWithConfig(HandlerConfig{Hub: hub}) +} + +func NewHandlerWithConfig(cfg HandlerConfig) *Handler { + if cfg.MaxConnsPerIP <= 0 { + cfg.MaxConnsPerIP = defaultMaxConnsPerIP + } + return &Handler{ + hub: cfg.Hub, + maxConnsPerIP: cfg.MaxConnsPerIP, + trustedProxyHops: cfg.TrustedProxyHops, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ip := middleware.ClientIP(r, h.trustedProxyHops) + counter := h.counterFor(ip) + if counter.Add(1) > int32(h.maxConnsPerIP) { + counter.Add(-1) + http.Error(w, "too many connections", http.StatusTooManyRequests) + return + } + defer counter.Add(-1) + + c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{ + InsecureSkipVerify: false, + }) + if err != nil { + return + } + + topics := parseTopics(r.URL.Query().Get("topics")) + _ = h.hub.Serve(r.Context(), c, topics) +} + +func (h *Handler) counterFor(ip string) *atomic.Int32 { + if existing, ok := h.perIP.Load(ip); ok { + if c, ok := existing.(*atomic.Int32); ok { + return c + } + } + fresh := &atomic.Int32{} + actual, _ := h.perIP.LoadOrStore(ip, fresh) + if c, ok := actual.(*atomic.Int32); ok { + return c + } + return fresh +} + +func parseTopics(raw string) []events.Topic { + if raw == "" { + return events.AllTopics() + } + parts := strings.Split(raw, ",") + out := make([]events.Topic, 0, len(parts)) + for _, p := range parts { + t := events.Topic(strings.TrimSpace(p)) + if t.IsValid() { + out = append(out, t) + } + } + if len(out) == 0 { + return events.AllTopics() + } + return out +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler_test.go new file mode 100644 index 00000000..0bddc13a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler_test.go @@ -0,0 +1,101 @@ +// ©AngelaMos | 2026 +// handler_test.go + +package ws_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + cdrws "github.com/coder/websocket" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +func TestHandler_PerIPCap_RejectsBeyondLimit(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + h := ws.NewHandlerWithConfig(ws.HandlerConfig{ + Hub: hub, + MaxConnsPerIP: 2, + }) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.RemoteAddr = "203.0.113.7:1111" + h.ServeHTTP(w, r) + }), + ) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + conns := make([]*cdrws.Conn, 0, 2) + for i := 0; i < 2; i++ { + c, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + conns = append(conns, c) + } + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 2 + }, time.Second, 10*time.Millisecond) + + _, resp, err := cdrws.Dial(ctx, wsURL, nil) + require.Error(t, err, "third connection from same IP must be rejected") + if resp != nil { + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + } + + for _, c := range conns { + _ = c.CloseNow() + } +} + +func TestHandler_HubCapacity_RefusesNewConn(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{MaxSubscribers: 1}) + h := ws.NewHandlerWithConfig(ws.HandlerConfig{ + Hub: hub, + MaxConnsPerIP: 100, + }) + var ipMu sync.Mutex + ipCounter := 0 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ipMu.Lock() + ipCounter++ + r.RemoteAddr = "10.0.0." + string(rune('0'+ipCounter)) + ":1111" + ipMu.Unlock() + h.ServeHTTP(w, r) + }), + ) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + c1, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = c1.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + c2, _, err := cdrws.Dial(ctx, wsURL, nil) + if err == nil { + _, _, readErr := c2.Read(ctx) + require.Error( + t, + readErr, + "second connection should be closed by hub at capacity", + ) + _ = c2.CloseNow() + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go new file mode 100644 index 00000000..62f8581a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go @@ -0,0 +1,184 @@ +// ©AngelaMos | 2026 +// hub.go + +package ws + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" + + cdrws "github.com/coder/websocket" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + // Per-connection outbound buffer. Must absorb peak event rate × + // browser stutter window: heartbeat (5s) + coinbase (~8/s peak) + + // ISS (10s) + occasional bursts of CVE/KEV/ransomware. 16 is too + // small — a single browser GC pause fills it and triggers slow- + // consumer close, which the user sees as "NO SIGNAL". + defaultSubscriberBuf = 256 + defaultPingInterval = 30 * time.Second + defaultPingTimeout = 10 * time.Second + defaultWriteTimeout = 5 * time.Second + defaultMaxSubs = 5000 +) + +// ErrAtCapacity signals the hub has reached MaxSubscribers and refuses +// to accept new connections. +var ErrAtCapacity = errors.New("ws hub at capacity") + +type HubConfig struct { + SubscriberBufferSize int + PingInterval time.Duration + PingTimeout time.Duration + WriteTimeout time.Duration + MaxSubscribers int + Logger *slog.Logger +} + +type Hub struct { + mu sync.Mutex + subs map[*subscriber]struct{} + bufSize int + pingInterval time.Duration + pingTimeout time.Duration + writeTimeout time.Duration + maxSubscribers int + logger *slog.Logger +} + +func NewHub(cfg HubConfig) *Hub { + if cfg.SubscriberBufferSize <= 0 { + cfg.SubscriberBufferSize = defaultSubscriberBuf + } + if cfg.PingInterval <= 0 { + cfg.PingInterval = defaultPingInterval + } + if cfg.PingTimeout <= 0 { + cfg.PingTimeout = defaultPingTimeout + } + if cfg.WriteTimeout <= 0 { + cfg.WriteTimeout = defaultWriteTimeout + } + if cfg.MaxSubscribers <= 0 { + cfg.MaxSubscribers = defaultMaxSubs + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Hub{ + subs: make(map[*subscriber]struct{}), + bufSize: cfg.SubscriberBufferSize, + pingInterval: cfg.PingInterval, + pingTimeout: cfg.PingTimeout, + writeTimeout: cfg.WriteTimeout, + maxSubscribers: cfg.MaxSubscribers, + logger: logger, + } +} + +func (h *Hub) Broadcast(topic events.Topic, payload []byte) { + env, err := EncodeEnvelope(string(topic), payload) + if err != nil { + h.logger.Error("encode envelope", "err", err, "topic", topic) + return + } + h.mu.Lock() + defer h.mu.Unlock() + for sub := range h.subs { + if _, ok := sub.topics[topic]; !ok { + continue + } + select { + case sub.msgs <- env: + default: + go sub.closeSlow() + } + } +} + +func (h *Hub) Serve( + ctx context.Context, + c *cdrws.Conn, + topics []events.Topic, +) error { + sub := newSubscriber(topics, h.bufSize, func() { + _ = c.Close(cdrws.StatusPolicyViolation, "slow consumer") + }) + if !h.add(sub) { + _ = c.Close(cdrws.StatusTryAgainLater, "server at capacity") + return ErrAtCapacity + } + defer h.remove(sub) + + // Drain client→server messages without closing on receive. The + // dashboard's WS protocol is currently server-push only (server + // broadcasts, client listens), but the frontend's setReady() handshake + // sends a `{"op":"init"}` line. The previous c.CloseRead(ctx) call + // killed the connection with StatusPolicyViolation on receiving any + // data message — turning every client init into a forced disconnect + // and reconnect-loop. This loop reads and discards instead, leaving + // ping/pong + close frames intact. + connCtx, cancelRead := context.WithCancel(ctx) + defer cancelRead() + go func() { + for { + if _, _, err := c.Read(connCtx); err != nil { + cancelRead() + return + } + } + }() + pingT := time.NewTicker(h.pingInterval) + defer pingT.Stop() + + for { + select { + case msg := <-sub.msgs: + wctx, cancel := context.WithTimeout(connCtx, h.writeTimeout) + err := c.Write(wctx, cdrws.MessageText, msg) + cancel() + if err != nil { + return err + } + case <-pingT.C: + pctx, cancel := context.WithTimeout(connCtx, h.pingTimeout) + err := c.Ping(pctx) + cancel() + if err != nil { + return err + } + case <-connCtx.Done(): + _ = c.Close(cdrws.StatusNormalClosure, "") + return connCtx.Err() + } + } +} + +func (h *Hub) add(sub *subscriber) bool { + h.mu.Lock() + defer h.mu.Unlock() + if len(h.subs) >= h.maxSubscribers { + return false + } + h.subs[sub] = struct{}{} + return true +} + +func (h *Hub) remove(sub *subscriber) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.subs, sub) +} + +func (h *Hub) SubscriberCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.subs) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go new file mode 100644 index 00000000..d2ed8274 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go @@ -0,0 +1,194 @@ +// ©AngelaMos | 2026 +// hub_test.go + +package ws_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + cdrws "github.com/coder/websocket" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +func TestHub_SubscribeAndReceive(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"x"}`)) + + _, msg, err := conn.Read(ctx) + require.NoError(t, err) + require.Contains(t, string(msg), `"ch":"heartbeat"`) +} + +func TestHub_TopicFiltering(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + hub.Broadcast(events.TopicCVENew, []byte(`{"id":"CVE-2026-0001"}`)) + hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"y"}`)) + + readCtx, readCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer readCancel() + + _, msg, err := conn.Read(readCtx) + require.NoError(t, err) + require.Contains(t, string(msg), `"ch":"heartbeat"`) + require.NotContains(t, string(msg), "CVE-2026-0001") +} + +// TestHub_ClientMessageDoesNotKillConnection: regression for the bug +// where Hub.Serve used c.CloseRead(ctx), which silently closes the +// connection with StatusPolicyViolation on receiving any client→server +// message. The frontend's setReady() handshake sends a `{"op":"init"}` +// line, which would trigger this and put the dashboard into a 10-second +// flap loop. Hub now drains client messages instead. +func TestHub_ClientMessageDoesNotKillConnection(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + require.NoError( + t, + conn.Write(ctx, cdrws.MessageText, []byte(`{"op":"init"}`)), + ) + + // Server must keep delivering events — pre-fix this Broadcast would + // race with the Policy-Violation close from CloseRead and frequently + // fail to reach the client. + time.Sleep(100 * time.Millisecond) + hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"after-init"}`)) + + readCtx, readCancel := context.WithTimeout(ctx, time.Second) + defer readCancel() + _, msg, err := conn.Read(readCtx) + require.NoError(t, err, "connection must survive client init message") + require.Contains(t, string(msg), "after-init") + require.Equal(t, 1, hub.SubscriberCount()) +} + +func TestHub_SlowConsumerClosed(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{SubscriberBufferSize: 2}) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + for i := 0; i < 200; i++ { + hub.Broadcast(events.TopicHeartbeat, []byte(`{"i":1}`)) + } + + var ce cdrws.CloseError + for { + _, _, err = conn.Read(ctx) + if err == nil { + continue + } + if errors.As(err, &ce) { + break + } + t.Fatalf("expected close error, got %v", err) + } + require.Equal(t, cdrws.StatusPolicyViolation, ce.Code) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go new file mode 100644 index 00000000..ed4a6388 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go @@ -0,0 +1,28 @@ +// ©AngelaMos | 2026 +// subscriber.go + +package ws + +import "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + +type subscriber struct { + topics map[events.Topic]struct{} + msgs chan []byte + closeSlow func() +} + +func newSubscriber( + topics []events.Topic, + bufSize int, + closeSlow func(), +) *subscriber { + t := make(map[events.Topic]struct{}, len(topics)) + for _, top := range topics { + t[top] = struct{}{} + } + return &subscriber{ + topics: t, + msgs: make(chan []byte, bufSize), + closeSlow: closeSlow, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql new file mode 100644 index 00000000..1ddf345e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql @@ -0,0 +1,52 @@ +-- ©AngelaMos | 2026 +-- 0001_template_baseline.sql + +-- +goose Up +-- +goose StatementBegin +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL UNIQUE, + password_hash text NOT NULL, + name text NOT NULL DEFAULT '', + role text NOT NULL DEFAULT 'user', + tier text NOT NULL DEFAULT 'free', + token_version integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +CREATE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_role ON users (role) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_created ON users (created_at DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE refresh_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + family_id uuid NOT NULL, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + is_used boolean NOT NULL DEFAULT false, + used_at timestamptz, + revoked_at timestamptz, + replaced_by_id uuid, + user_agent text NOT NULL DEFAULT '', + ip_address text NOT NULL DEFAULT '' +); +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id); +CREATE INDEX idx_refresh_tokens_family ON refresh_tokens (family_id); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens (token_hash); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql new file mode 100644 index 00000000..40f1598b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql @@ -0,0 +1,110 @@ +-- ©AngelaMos | 2026 +-- 0002_alerts.sql + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE alert_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + topic text NOT NULL, + predicate text NOT NULL, + cooldown_sec integer NOT NULL DEFAULT 300, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX idx_alert_rules_user ON alert_rules(user_id) WHERE enabled = true; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE alert_channels ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type text NOT NULL, + label text NOT NULL, + config_enc bytea NOT NULL, + nonce bytea NOT NULL, + invalid boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX idx_alert_channels_user ON alert_channels(user_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE alert_rule_channels ( + rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES alert_channels(id) ON DELETE CASCADE, + PRIMARY KEY (rule_id, channel_id) +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE alert_history ( + id bigserial PRIMARY KEY, + rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fired_at timestamptz NOT NULL DEFAULT now(), + payload jsonb NOT NULL, + delivered_to text[] NOT NULL DEFAULT '{}', + delivery_errors jsonb +); +CREATE INDEX idx_alert_history_user_time ON alert_history(user_id, fired_at DESC); +CREATE INDEX idx_alert_history_fired_brin ON alert_history USING BRIN (fired_at); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE telegram_webhooks ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + webhook_uuid uuid NOT NULL UNIQUE, + secret_token text NOT NULL, + bot_token_enc bytea NOT NULL, + bot_token_nonce bytea NOT NULL, + chat_id bigint, + pending_link boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE user_preferences ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + watched_country text, + watched_asns integer[] NOT NULL DEFAULT '{}', + observer_lat double precision, + observer_lon double precision, + observer_alt_m double precision, + digest_enabled boolean NOT NULL DEFAULT false, + digest_local_tz text NOT NULL DEFAULT 'UTC', + digest_local_time time NOT NULL DEFAULT '08:00', + digest_window_hours integer NOT NULL DEFAULT 24, + digest_channel_ids uuid[] NOT NULL DEFAULT '{}', + chime_enabled boolean NOT NULL DEFAULT true, + chime_audio_object text, + presentation_default boolean NOT NULL DEFAULT false, + updated_at timestamptz NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE digest_runs ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fired_date date NOT NULL, + fired_at timestamptz NOT NULL DEFAULT now(), + status text NOT NULL, + error text, + PRIMARY KEY (user_id, fired_date) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS digest_runs; +DROP TABLE IF EXISTS user_preferences; +DROP TABLE IF EXISTS telegram_webhooks; +DROP TABLE IF EXISTS alert_history; +DROP TABLE IF EXISTS alert_rule_channels; +DROP TABLE IF EXISTS alert_channels; +DROP TABLE IF EXISTS alert_rules; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql new file mode 100644 index 00000000..89565858 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql @@ -0,0 +1,151 @@ +-- ©AngelaMos | 2026 +-- 0003_panel_data.sql + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE cve_events ( + cve_id text PRIMARY KEY, + published timestamptz, + last_modified timestamptz NOT NULL, + severity text, + cvss_score numeric(3,1), + epss_score numeric(5,4), + epss_percentile numeric(5,4), + in_kev boolean NOT NULL DEFAULT false, + payload jsonb NOT NULL +); +CREATE INDEX idx_cve_lastmod_brin ON cve_events USING BRIN (last_modified); +CREATE INDEX idx_cve_severity_lastmod ON cve_events (severity, last_modified DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE kev_entries ( + cve_id text PRIMARY KEY, + vendor text, + product text, + vulnerability_name text, + date_added date NOT NULL, + due_date date, + ransomware_use text, + payload jsonb NOT NULL +); +CREATE INDEX idx_kev_dateadded ON kev_entries (date_added DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE earthquakes ( + id text PRIMARY KEY, + occurred_at timestamptz NOT NULL, + mag numeric(3,1) NOT NULL, + place text, + geom_lon double precision NOT NULL, + geom_lat double precision NOT NULL, + depth_km double precision, + payload jsonb NOT NULL +); +CREATE INDEX idx_quakes_time_brin ON earthquakes USING BRIN (occurred_at); +CREATE INDEX idx_quakes_mag ON earthquakes (mag DESC, occurred_at DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE ransomware_victims ( + id text PRIMARY KEY, + post_title text NOT NULL, + group_name text NOT NULL, + discovered_at timestamptz NOT NULL, + country text, + sector text, + payload jsonb NOT NULL +); +CREATE INDEX idx_rans_time_brin ON ransomware_victims USING BRIN (discovered_at); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE btc_eth_ticks ( + symbol text NOT NULL, + ts timestamptz NOT NULL, + price numeric(18,8) NOT NULL, + volume_24h numeric(20,8), + PRIMARY KEY (symbol, ts) +); +CREATE INDEX idx_ticks_time_brin ON btc_eth_ticks USING BRIN (ts); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE btc_eth_minute ( + symbol text NOT NULL, + minute timestamptz NOT NULL, + open numeric(18,8) NOT NULL, + high numeric(18,8) NOT NULL, + low numeric(18,8) NOT NULL, + close numeric(18,8) NOT NULL, + volume numeric(20,8), + PRIMARY KEY (symbol, minute) +); +CREATE INDEX idx_minute_time_brin ON btc_eth_minute USING BRIN (minute); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE outage_events ( + id text PRIMARY KEY, + started_at timestamptz NOT NULL, + ended_at timestamptz, + locations text[] NOT NULL DEFAULT '{}', + asns integer[] NOT NULL DEFAULT '{}', + cause text, + outage_type text, + payload jsonb NOT NULL +); +CREATE INDEX idx_outage_start_brin ON outage_events USING BRIN (started_at); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE bgp_hijack_events ( + id bigint PRIMARY KEY, + detected_at timestamptz NOT NULL, + started_at timestamptz NOT NULL, + duration_sec integer, + confidence smallint, + hijacker_asn integer, + victim_asns integer[] NOT NULL DEFAULT '{}', + prefixes cidr[] NOT NULL DEFAULT '{}', + payload jsonb NOT NULL +); +CREATE INDEX idx_hijack_time_brin ON bgp_hijack_events USING BRIN (detected_at); +CREATE INDEX idx_hijack_conf_time ON bgp_hijack_events (confidence DESC, detected_at DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE dshield_snapshots ( + ts timestamptz NOT NULL, + kind text NOT NULL, + payload jsonb NOT NULL, + PRIMARY KEY (ts, kind) +); +CREATE INDEX idx_dshield_ts_brin ON dshield_snapshots USING BRIN (ts); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE world_events ( + id text PRIMARY KEY, + source text NOT NULL, + occurred_at timestamptz NOT NULL, + headline text NOT NULL, + payload jsonb NOT NULL +); +CREATE INDEX idx_world_time_brin ON world_events USING BRIN (occurred_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS world_events; +DROP TABLE IF EXISTS dshield_snapshots; +DROP TABLE IF EXISTS bgp_hijack_events; +DROP TABLE IF EXISTS outage_events; +DROP TABLE IF EXISTS btc_eth_minute; +DROP TABLE IF EXISTS btc_eth_ticks; +DROP TABLE IF EXISTS ransomware_victims; +DROP TABLE IF EXISTS earthquakes; +DROP TABLE IF EXISTS kev_entries; +DROP TABLE IF EXISTS cve_events; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql new file mode 100644 index 00000000..a3d4e89e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql @@ -0,0 +1,20 @@ +-- ©AngelaMos | 2026 +-- 0004_collector_state.sql + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE collector_state ( + name text PRIMARY KEY, + state text NOT NULL, + last_success_at timestamptz, + last_error_at timestamptz, + last_error text, + last_event_count bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS collector_state; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0005_minute_bar_volume_rename.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0005_minute_bar_volume_rename.sql new file mode 100644 index 00000000..a643d4c8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0005_minute_bar_volume_rename.sql @@ -0,0 +1,18 @@ +-- ©AngelaMos | 2026 +-- 0005_minute_bar_volume_rename.sql + +-- The Coinbase ticker channel publishes a rolling 24-hour volume, not per-trade +-- size, so the `volume` column on btc_eth_minute was always a snapshot of the +-- 24h figure at the close of that minute — never per-minute volume. Renaming +-- to `volume_24h_at_close` so the column name stops lying. Per-minute volume +-- requires the level2 / market_trades channel; that's a separate change. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE btc_eth_minute RENAME COLUMN volume TO volume_24h_at_close; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE btc_eth_minute RENAME COLUMN volume_24h_at_close TO volume; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml new file mode 100644 index 00000000..d84e01f1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml @@ -0,0 +1,22 @@ +# ©AngelaMos | 2026 +# cloudflared.compose.yml + +services: + cloudflared: + image: cloudflare/cloudflared:latest + container_name: ${APP_NAME:-monitor}-tunnel + command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - backend + depends_on: + nginx: + condition: service_started + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + restart: unless-stopped diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml new file mode 100644 index 00000000..5c6db56a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml @@ -0,0 +1,130 @@ +# ©AngelaMos | 2026 +# compose.yml + +name: ${APP_NAME:-monitor} + +services: + nginx: + build: + context: . + dockerfile: conf/docker/prod/vite.docker + container_name: ${APP_NAME:-monitor}-nginx + ports: + - "${NGINX_HOST_PORT:-8432}:80" + depends_on: + backend: + condition: service_healthy + networks: + - frontend + - backend + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + + backend: + build: + context: . + dockerfile: conf/docker/prod/go.docker + container_name: ${APP_NAME:-monitor}-backend + expose: + - "8080" + env_file: + - .env.prod + environment: + - APP_ENVIRONMENT=production + - DATABASE_URL=postgres://${POSTGRES_USER:-monitor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-monitor}?sslmode=disable + - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + deploy: + resources: + limits: + cpus: '2.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + healthcheck: + test: ["CMD", "/app", "-healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + restart: unless-stopped + + postgres: + image: postgres:17-alpine + container_name: ${APP_NAME:-monitor}-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-monitor} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-monitor} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - backend + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-monitor} -d ${POSTGRES_DB:-monitor}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-monitor}-redis + command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} + volumes: + - redisdata:/data + networks: + - backend + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + healthcheck: + test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a ${REDIS_PASSWORD}} ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +networks: + frontend: + driver: bridge + ipam: + config: + - subnet: 10.203.83.0/24 + backend: + driver: bridge + ipam: + config: + - subnet: 10.203.84.0/24 + +volumes: + pgdata: + redisdata: diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker new file mode 100644 index 00000000..557fd500 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker @@ -0,0 +1,19 @@ +# ©AngelaMos | 2026 +# go.docker + +FROM golang:1.25-bookworm + +WORKDIR /app + +RUN go install github.com/air-verse/air@latest \ + && go install github.com/pressly/goose/v3/cmd/goose@latest + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +ENV GO_ENV=development \ + PATH=/go/bin:$PATH + +EXPOSE 8080 + +CMD ["air", "-c", ".air.toml"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker new file mode 100644 index 00000000..4c09838b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker @@ -0,0 +1,14 @@ +# ©AngelaMos | 2026 +# vite.docker + +FROM node:24-alpine + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +ENV NODE_ENV=development + +EXPOSE 5173 + +CMD ["sh", "-c", "pnpm install && pnpm dev --host 0.0.0.0"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker new file mode 100644 index 00000000..d903317f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker @@ -0,0 +1,26 @@ +# ©AngelaMos | 2026 +# go.docker + +FROM golang:1.25-bookworm AS builder + +WORKDIR /build + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +COPY backend/ . + +RUN CGO_ENABLED=0 GOOS=linux \ + go build -trimpath -ldflags="-s -w" -o /app ./cmd/api + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /app /app +COPY --from=builder /build/migrations /migrations +COPY --from=builder /build/config.yaml /config.yaml + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/app", "-config", "/config.yaml"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker new file mode 100644 index 00000000..9d5bf2b5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker @@ -0,0 +1,26 @@ +# ©AngelaMos | 2026 +# vite.docker + +FROM node:24-alpine AS build + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +COPY frontend/package.json frontend/pnpm-lock.yaml* ./ + +RUN pnpm install --frozen-lockfile || pnpm install + +COPY frontend/ . + +RUN pnpm build + +FROM nginx:1.27-alpine + +COPY conf/nginx/nginx.prod.conf /etc/nginx/nginx.conf +COPY conf/nginx/prod.nginx /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx new file mode 100644 index 00000000..776d701e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx @@ -0,0 +1,66 @@ +# ©AngelaMos | 2026 +# dev.nginx + +server { + listen 80; + listen [::]:80; + server_name _; + + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + location /api/v1/ws { + proxy_pass http://backend/v1/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding off; + } + + location /api/ { + limit_req zone=api_limit burst=50 nodelay; + limit_conn conn_limit 20; + + proxy_pass http://backend/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_request_buffering off; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location / { + proxy_pass http://frontend_dev; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 60s; + proxy_buffering off; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf new file mode 100644 index 00000000..d3250f70 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf @@ -0,0 +1,72 @@ +# ©AngelaMos | 2026 +# nginx.conf + +worker_processes auto; +worker_rlimit_nofile 4096; + +events { + worker_connections 2048; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + log_format main_timed '$remote_addr - $remote_user [$time_iso8601] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main_timed; + error_log /var/log/nginx/error.log info; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + client_max_body_size 2m; + client_body_buffer_size 16k; + client_header_buffer_size 4k; + large_client_header_buffers 4 16k; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + font/woff2 + text/css + text/plain + text/xml; + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + upstream backend { + server backend:8080; + keepalive 32; + } + + upstream frontend_dev { + server frontend:5173; + keepalive 16; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf new file mode 100644 index 00000000..cee366f2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf @@ -0,0 +1,64 @@ +# ©AngelaMos | 2026 +# nginx.prod.conf + +worker_processes auto; +worker_rlimit_nofile 8192; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + log_format main_timed '$remote_addr - $remote_user [$time_iso8601] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + client_max_body_size 2m; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + font/woff2 + text/css + text/plain + text/xml; + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + upstream backend { + server backend:8080; + keepalive 64; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx new file mode 100644 index 00000000..5364b191 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx @@ -0,0 +1,85 @@ +# ©AngelaMos | 2026 +# prod.nginx + +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + location /api/v1/ws { + proxy_pass http://backend/v1/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding off; + } + + location /api/ { + limit_req zone=api_limit burst=20 nodelay; + limit_conn conn_limit 50; + + proxy_pass http://backend/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering on; + proxy_buffers 8 32k; + proxy_buffer_size 4k; + + proxy_connect_timeout 60s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + location / { + add_header Cache-Control "no-cache, must-revalidate"; + try_files $uri $uri/ /index.html; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml new file mode 100644 index 00000000..88cec2b0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml @@ -0,0 +1,132 @@ +# ©AngelaMos | 2026 +# dev.compose.yml + +name: ${APP_NAME:-monitor}-dev + +services: + nginx: + image: nginx:1.27-alpine + container_name: ${APP_NAME:-monitor}-nginx-dev + ports: + - "${NGINX_HOST_PORT:-8432}:80" + volumes: + - ./conf/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./conf/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_started + networks: + - frontend + - backend + restart: unless-stopped + + backend: + build: + context: . + dockerfile: conf/docker/dev/go.docker + container_name: ${APP_NAME:-monitor}-backend-dev + ports: + - "${BACKEND_HOST_PORT:-5432}:8080" + volumes: + - ./backend:/app + - go-mod-cache:/go/pkg/mod + env_file: + - .env + environment: + - APP_ENVIRONMENT=${APP_ENVIRONMENT:-development} + - DATABASE_URL=postgres://${POSTGRES_USER:-monitor}:${POSTGRES_PASSWORD:-monitor}@postgres:5432/${POSTGRES_DB:-monitor}?sslmode=disable + - REDIS_URL=redis://:${REDIS_PASSWORD:-monitor}@redis:6379/0 + # nginx adds one XFF hop in dev. Without this the WS per-IP cap + # buckets every connection under nginx's container IP and runs out + # at 5 → "CONNECTING" stuck on the dashboard. + - TRUSTED_PROXY_HOPS=1 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + frontend: + build: + context: . + dockerfile: conf/docker/dev/vite.docker + container_name: ${APP_NAME:-monitor}-frontend-dev + ports: + - "${FRONTEND_HOST_PORT:-3432}:5173" + volumes: + - ./frontend:/app + - frontend-node-modules:/app/node_modules + environment: + - VITE_API_URL=${VITE_API_URL:-/api} + networks: + - frontend + restart: unless-stopped + + postgres: + image: postgres:17-alpine + container_name: ${APP_NAME:-monitor}-postgres-dev + ports: + - "${POSTGRES_HOST_PORT:-4432}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-monitor} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-monitor} + POSTGRES_DB: ${POSTGRES_DB:-monitor} + volumes: + - pgdata-dev:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-monitor} -d ${POSTGRES_DB:-monitor}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-monitor}-redis-dev + ports: + - "${REDIS_HOST_PORT:-6432}:6379" + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-monitor} + volumes: + - redisdata-dev:/data + networks: + - backend + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-monitor} + healthcheck: + test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +networks: + frontend: + driver: bridge + ipam: + config: + - subnet: 10.201.71.0/24 + backend: + driver: bridge + ipam: + config: + - subnet: 10.201.72.0/24 + +volumes: + pgdata-dev: + redisdata-dev: + go-mod-cache: + frontend-node-modules: diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +dist +.git +.gitignore +*.md +.env* +.vscode +.idea +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.vite + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore new file mode 100644 index 00000000..37da03e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore @@ -0,0 +1,22 @@ +# ©AngelaMos | 2025 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css + +# Error system styles - ignore from linting +src/core/app/_toastStyles.scss diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json new file mode 100644 index 00000000..b830269a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.json", + "!.pnpm-store" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html new file mode 100644 index 00000000..8f5c2dc0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + I glow in the dark + + + + +
+ + + diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json new file mode 100644 index 00000000..f6b6e3e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json @@ -0,0 +1,64 @@ +{ + "name": "monitor-the-situation-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc -b", + "lint:scss": "stylelint '**/*.scss'", + "lint:scss:fix": "stylelint '**/*.scss' --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource/geist-mono": "^5.2.7", + "@fontsource/inter": "^5.2.8", + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.0", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", + "react": "^19.2.1", + "react-dom": "^19.2.0", + "react-error-boundary": "^6.0.0", + "react-globe.gl": "^2.37.1", + "react-icon": "^1.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.1.1", + "sonner": "^2.0.7", + "three": "^0.184.0", + "zod": "^4.1.13", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tanstack/react-query-devtools": "^5.91.1", + "@types/d3-array": "^3.2.2", + "@types/d3-scale": "^4.0.9", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.1.5", + "happy-dom": "^20.9.0", + "sass": "^1.95.0", + "stylelint": "^16.26.1", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^16.0.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5", + "vite-tsconfig-paths": "^5.1.0", + "vitest": "^4.1.5" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..5e02bb22 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml @@ -0,0 +1,3467 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/geist-mono': + specifier: ^5.2.7 + version: 5.2.7 + '@fontsource/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.1) + axios: + specifier: ^1.13.0 + version: 1.13.2 + d3-array: + specifier: ^3.2.4 + version: 3.2.4 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.0 + version: 19.2.1(react@19.2.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.2.1) + react-globe.gl: + specifier: ^2.37.1 + version: 2.37.1(react@19.2.1) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + three: + specifier: ^0.184.0 + version: 0.184.0 + zod: + specifier: ^4.1.13 + version: 4.1.13 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/d3-array': + specifier: ^3.2.2 + version: 3.2.2 + '@types/d3-scale': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + '@vitest/ui': + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + sass: + specifier: ^1.95.0 + version: 1.95.0 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@24.10.2)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.6': + resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} + + '@cacheable/utils@2.3.2': + resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@fontsource/geist-mono@5.2.7': + resolution: {integrity: sha512-xVPVFISJg/K0VVd+aQN0Y7X/sw9hUcJPyDWFJ5GpyU3bHELhoRsJkPSRSHXW32mOi0xZCUQDOaPj1sqIFJ1FGg==} + + '@fontsource/inter@5.2.8': + resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + + '@turf/boolean-point-in-polygon@7.3.5': + resolution: {integrity: sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==} + + '@turf/helpers@7.3.5': + resolution: {integrity: sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==} + + '@turf/invariant@7.3.5': + resolution: {integrity: sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==} + + '@tweenjs/tween.js@25.0.0': + resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/node@24.10.2': + resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/ui@4.1.5': + resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==} + peerDependencies: + vitest: 4.1.5 + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + accessor-fn@1.5.3: + resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==} + engines: {node: '>=12'} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.0: + resolution: {integrity: sha512-HHiAvOBmlcR2f3SQ7kdlYD8+AUJG+wlFZ/Ze8tl1Vzvz0MdOh8IYA/EFU4ve8t1/sZ0j4MGi7ST5MoTwHessQA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo-voronoi@2.1.0: + resolution: {integrity: sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-octree@1.1.0: + resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-tricontour@1.1.0: + resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==} + engines: {node: '>=12'} + + data-bind-mapper@1.0.3: + resolution: {integrity: sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + float-tooltip@1.7.5: + resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==} + engines: {node: '>=12'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + frame-ticker@1.0.3: + resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globe.gl@2.45.3: + resolution: {integrity: sha512-oR7iZBjD60ltBcRlHFvlEzKGL8PMfk2Xl19vSfu1SN4SWXAs3NB3kfelanL+wj5XgG2rTQiNtPQncCR2O9oj+w==} + engines: {node: '>=12'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + h3-js@4.4.0: + resolution: {integrity: sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==} + engines: {node: '>=4', npm: '>=3', yarn: '>=1.3.0'} + + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.3.0: + resolution: {integrity: sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.14.0: + resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + index-array-by@1.4.2: + resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==} + engines: {node: '>=12'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jerrypick@1.1.2: + resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==} + engines: {node: '>=12'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kapsule@1.16.3: + resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==} + engines: {node: '>=12'} + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + point-in-polygon-hao@1.2.4: + resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==} + + polished@4.3.1: + resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} + engines: {node: '>=10'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.1: + resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + + react-globe.gl@2.37.1: + resolution: {integrity: sha512-/YvnaB+ID+WTBTx5Qa67fGkGhco2jPlKg4jbXNTNWSl22/it7GrfByZ4nksgUDy4176VWuHh4BFvQg+hziSk8Q==} + engines: {node: '>=12'} + peerDependencies: + react: '*' + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-kapsule@2.5.7: + resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.13.1' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.10.1: + resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.95.0: + resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simplesignal@2.1.7: + resolution: {integrity: sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.13.0: + resolution: {integrity: sha512-kZPwFUJkfup2gP1enlrS2h9U5+T5wFoqzJ1n/56AlpwSj28kmFe7ww/QFydvPsg5gLjWchAwWWBLtterynZrOw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + three-conic-polygon-geometry@2.1.2: + resolution: {integrity: sha512-NaP3RWLJIyPGI+zyaZwd0Yj6rkoxm4FJHqAX1Enb4L64oNYLCn4bz1ESgOEYavgcUwCNYINu1AgEoUBJr1wZcA==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.72.0' + + three-geojson-geometry@2.1.1: + resolution: {integrity: sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.72.0' + + three-globe@2.45.2: + resolution: {integrity: sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.154' + + three-render-objects@1.41.1: + resolution: {integrity: sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.179' + + three-slippy-map-globe@1.0.6: + resolution: {integrity: sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.154' + + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@cacheable/memory@2.0.6': + dependencies: + '@cacheable/utils': 2.3.2 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.14.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.2': + dependencies: + hashery: 1.3.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@fontsource-variable/inter@5.2.8': {} + + '@fontsource/geist-mono@5.2.7': {} + + '@fontsource/inter@5.2.8': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.3.0 + hookified: 1.14.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.0': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@standard-schema/spec@1.1.0': {} + + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@19.2.1) + react: 19.2.1 + + '@tanstack/react-query@5.90.12(react@19.2.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 19.2.1 + + '@turf/boolean-point-in-polygon@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 + '@types/geojson': 7946.0.16 + point-in-polygon-hao: 1.2.4 + tslib: 2.8.1 + + '@turf/helpers@7.3.5': + dependencies: + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/invariant@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@tweenjs/tween.js@25.0.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-time@3.0.4': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/node@24.10.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.2 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/ui@4.1.5(vitest@4.1.5)': + dependencies: + '@vitest/utils': 4.1.5 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@24.10.2)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accessor-fn@1.5.3: {} + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + + cacheable@2.3.0: + dependencies: + '@cacheable/memory': 2.0.6 + '@cacheable/utils': 2.3.2 + hookified: 1.14.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001759: {} + + chai@6.2.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.2: {} + + d3-geo-voronoi@2.1.0: + dependencies: + d3-array: 3.2.4 + d3-delaunay: 6.0.4 + d3-geo: 3.1.1 + d3-tricontour: 1.1.0 + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-octree@1.1.0: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-tricontour@1.1.0: + dependencies: + d3-delaunay: 6.0.4 + d3-scale: 4.0.2 + + data-bind-mapper@1.0.3: + dependencies: + accessor-fn: 1.5.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + earcut@3.0.2: {} + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.0 + flatted: 3.3.3 + hookified: 1.14.0 + + flatted@3.3.3: {} + + flatted@3.4.2: {} + + float-tooltip@1.7.5: + dependencies: + d3-selection: 3.0.0 + kapsule: 1.16.3 + preact: 10.29.1 + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + frame-ticker@1.0.3: + dependencies: + simplesignal: 2.1.7 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globe.gl@2.45.3: + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.3 + kapsule: 1.16.3 + three: 0.184.0 + three-globe: 2.45.2(three@0.184.0) + three-render-objects: 1.41.1(three@0.184.0) + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + h3-js@4.4.0: {} + + happy-dom@20.9.0: + dependencies: + '@types/node': 24.10.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.3.0: + dependencies: + hookified: 1.14.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.14.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + index-array-by@1.4.2: {} + + ini@1.3.8: {} + + internmap@2.0.3: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + jerrypick@1.1.2: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + kapsule@1.16.3: + dependencies: + lodash-es: 4.18.1 + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash-es@4.18.1: {} + + lodash.truncate@4.4.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + obug@2.1.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + point-in-polygon-hao@1.2.4: + dependencies: + robust-predicates: 3.0.3 + + polished@4.3.1: + dependencies: + '@babel/runtime': 7.28.4 + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.14.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-error-boundary@6.0.0(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.1 + + react-globe.gl@2.37.1(react@19.2.1): + dependencies: + globe.gl: 2.45.3 + prop-types: 15.8.1 + react: 19.2.1 + react-kapsule: 2.5.7(react@19.2.1) + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + + react-is@16.13.1: {} + + react-kapsule@2.5.7(react@19.2.1): + dependencies: + jerrypick: 1.1.2 + react: 19.2.1 + + react-refresh@0.18.0: {} + + react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + cookie: 1.1.1 + react: 19.2.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.1(react@19.2.1) + + react@19.2.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + robust-predicates@3.0.3: {} + + rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + sass: 1.95.0 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.95.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simplesignal@2.1.7: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.13.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.13.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + three-conic-polygon-geometry@2.1.2(three@0.184.0): + dependencies: + '@turf/boolean-point-in-polygon': 7.3.5 + d3-array: 3.2.4 + d3-geo: 3.1.1 + d3-geo-voronoi: 2.1.0 + d3-scale: 4.0.2 + delaunator: 5.1.0 + earcut: 3.0.2 + three: 0.184.0 + + three-geojson-geometry@2.1.1(three@0.184.0): + dependencies: + d3-geo: 3.1.1 + d3-interpolate: 3.0.1 + earcut: 3.0.2 + three: 0.184.0 + + three-globe@2.45.2(three@0.184.0): + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.3 + d3-array: 3.2.4 + d3-color: 3.1.0 + d3-geo: 3.1.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + data-bind-mapper: 1.0.3 + frame-ticker: 1.0.3 + h3-js: 4.4.0 + index-array-by: 1.4.2 + kapsule: 1.16.3 + three: 0.184.0 + three-conic-polygon-geometry: 2.1.2(three@0.184.0) + three-geojson-geometry: 2.1.1(three@0.184.0) + three-slippy-map-globe: 1.0.6(three@0.184.0) + tinycolor2: 1.6.0 + + three-render-objects@1.41.1(three@0.184.0): + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.3 + float-tooltip: 1.7.5 + kapsule: 1.16.3 + polished: 4.3.1 + three: 0.184.0 + + three-slippy-map-globe@1.0.6(three@0.184.0): + dependencies: + d3-geo: 3.1.1 + d3-octree: 1.1.0 + d3-scale: 4.0.2 + three: 0.184.0 + + three@0.184.0: {} + + tinybench@2.9.0: {} + + tinycolor2@1.6.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + - typescript + + vitest@4.1.5(@types/node@24.10.2)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.2 + '@vitest/ui': 4.1.5(vitest@4.1.5) + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + + whatwg-mimetype@3.0.0: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + ws@8.20.0: {} + + yallist@3.1.1: {} + + zod@4.1.13: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.1 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 00000000..eab0c0d1 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 00000000..bd9a021e Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..de02be2f Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..e24c97d0 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..16f48daf Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..ca6f8de8 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..1dd91123 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/chip-in-my-brain.mp3 b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/chip-in-my-brain.mp3 new file mode 100644 index 00000000..15b7b3a9 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/chip-in-my-brain.mp3 differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json new file mode 100644 index 00000000..8b08c621 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json @@ -0,0 +1,26032 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Afghanistan", + "SOV_A3": "AFG", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Afghanistan", + "ADM0_A3": "AFG", + "GEOU_DIF": 0, + "GEOUNIT": "Afghanistan", + "GU_A3": "AFG", + "SU_DIF": 0, + "SUBUNIT": "Afghanistan", + "SU_A3": "AFG", + "BRK_DIFF": 0, + "NAME": "Afghanistan", + "NAME_LONG": "Afghanistan", + "BRK_A3": "AFG", + "BRK_NAME": "Afghanistan", + "BRK_GROUP": null, + "ABBREV": "Afg.", + "POSTAL": "AF", + "FORMAL_EN": "Islamic State of Afghanistan", + "FORMAL_FR": null, + "NAME_CIAWF": "Afghanistan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Afghanistan", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 6, + "MAPCOLOR9": 8, + "MAPCOLOR13": 7, + "POP_EST": 34124811, + "POP_RANK": 15, + "GDP_MD_EST": 64080, + "POP_YEAR": 2017, + "LASTCENSUS": 1979, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "AF", + "ISO_A2": "AF", + "ISO_A3": "AFG", + "ISO_A3_EH": "AFG", + "ISO_N3": "004", + "UN_A3": "004", + "WB_A2": "AF", + "WB_A3": "AFG", + "WOE_ID": 23424739, + "WOE_ID_EH": 23424739, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "AFG", + "ADM0_A3_US": "AFG", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [60.52843, 29.318572, 75.158028, 38.486282], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [61.210817, 35.650072], + [62.230651, 35.270664], + [62.984662, 35.404041], + [63.193538, 35.857166], + [63.982896, 36.007957], + [64.546479, 36.312073], + [64.746105, 37.111818], + [65.588948, 37.305217], + [65.745631, 37.661164], + [66.217385, 37.39379], + [66.518607, 37.362784], + [67.075782, 37.356144], + [67.83, 37.144994], + [68.135562, 37.023115], + [68.859446, 37.344336], + [69.196273, 37.151144], + [69.518785, 37.608997], + [70.116578, 37.588223], + [70.270574, 37.735165], + [70.376304, 38.138396], + [70.806821, 38.486282], + [71.348131, 38.258905], + [71.239404, 37.953265], + [71.541918, 37.905774], + [71.448693, 37.065645], + [71.844638, 36.738171], + [72.193041, 36.948288], + [72.63689, 37.047558], + [73.260056, 37.495257], + [73.948696, 37.421566], + [74.980002, 37.41999], + [75.158028, 37.133031], + [74.575893, 37.020841], + [74.067552, 36.836176], + [72.920025, 36.720007], + [71.846292, 36.509942], + [71.262348, 36.074388], + [71.498768, 35.650563], + [71.613076, 35.153203], + [71.115019, 34.733126], + [71.156773, 34.348911], + [70.881803, 33.988856], + [69.930543, 34.02012], + [70.323594, 33.358533], + [69.687147, 33.105499], + [69.262522, 32.501944], + [69.317764, 31.901412], + [68.926677, 31.620189], + [68.556932, 31.71331], + [67.792689, 31.58293], + [67.683394, 31.303154], + [66.938891, 31.304911], + [66.381458, 30.738899], + [66.346473, 29.887943], + [65.046862, 29.472181], + [64.350419, 29.560031], + [64.148002, 29.340819], + [63.550261, 29.468331], + [62.549857, 29.318572], + [60.874248, 29.829239], + [61.781222, 30.73585], + [61.699314, 31.379506], + [60.941945, 31.548075], + [60.863655, 32.18292], + [60.536078, 32.981269], + [60.9637, 33.528832], + [60.52843, 33.676446], + [60.803193, 34.404102], + [61.210817, 35.650072] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Angola", + "SOV_A3": "AGO", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Angola", + "ADM0_A3": "AGO", + "GEOU_DIF": 0, + "GEOUNIT": "Angola", + "GU_A3": "AGO", + "SU_DIF": 0, + "SUBUNIT": "Angola", + "SU_A3": "AGO", + "BRK_DIFF": 0, + "NAME": "Angola", + "NAME_LONG": "Angola", + "BRK_A3": "AGO", + "BRK_NAME": "Angola", + "BRK_GROUP": null, + "ABBREV": "Ang.", + "POSTAL": "AO", + "FORMAL_EN": "People's Republic of Angola", + "FORMAL_FR": null, + "NAME_CIAWF": "Angola", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Angola", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 6, + "MAPCOLOR13": 1, + "POP_EST": 29310273, + "POP_RANK": 15, + "GDP_MD_EST": 189000, + "POP_YEAR": 2017, + "LASTCENSUS": 1970, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "AO", + "ISO_A2": "AO", + "ISO_A3": "AGO", + "ISO_A3_EH": "AGO", + "ISO_N3": "024", + "UN_A3": "024", + "WB_A2": "AO", + "WB_A3": "AGO", + "WOE_ID": 23424745, + "WOE_ID_EH": 23424745, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "AGO", + "ADM0_A3_US": "AGO", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [11.640096, -17.930636, 24.079905, -4.438023], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [23.904154, -11.722282], + [24.079905, -12.191297], + [23.930922, -12.565848], + [24.016137, -12.911046], + [21.933886, -12.898437], + [21.887843, -16.08031], + [22.562478, -16.898451], + [23.215048, -17.523116], + [21.377176, -17.930636], + [18.956187, -17.789095], + [18.263309, -17.309951], + [14.209707, -17.353101], + [14.058501, -17.423381], + [13.462362, -16.971212], + [12.814081, -16.941343], + [12.215461, -17.111668], + [11.734199, -17.301889], + [11.640096, -16.673142], + [11.778537, -15.793816], + [12.123581, -14.878316], + [12.175619, -14.449144], + [12.500095, -13.5477], + [12.738479, -13.137906], + [13.312914, -12.48363], + [13.633721, -12.038645], + [13.738728, -11.297863], + [13.686379, -10.731076], + [13.387328, -10.373578], + [13.120988, -9.766897], + [12.87537, -9.166934], + [12.929061, -8.959091], + [13.236433, -8.562629], + [12.93304, -7.596539], + [12.728298, -6.927122], + [12.227347, -6.294448], + [12.322432, -6.100092], + [12.735171, -5.965682], + [13.024869, -5.984389], + [13.375597, -5.864241], + [16.326528, -5.87747], + [16.57318, -6.622645], + [16.860191, -7.222298], + [17.089996, -7.545689], + [17.47297, -8.068551], + [18.134222, -7.987678], + [18.464176, -7.847014], + [19.016752, -7.988246], + [19.166613, -7.738184], + [19.417502, -7.155429], + [20.037723, -7.116361], + [20.091622, -6.94309], + [20.601823, -6.939318], + [20.514748, -7.299606], + [21.728111, -7.290872], + [21.746456, -7.920085], + [21.949131, -8.305901], + [21.801801, -8.908707], + [21.875182, -9.523708], + [22.208753, -9.894796], + [22.155268, -11.084801], + [22.402798, -10.993075], + [22.837345, -11.017622], + [23.456791, -10.867863], + [23.912215, -10.926826], + [24.017894, -11.237298], + [23.904154, -11.722282] + ] + ], + [ + [ + [12.182337, -5.789931], + [11.914963, -5.037987], + [12.318608, -4.60623], + [12.62076, -4.438023], + [12.995517, -4.781103], + [12.631612, -4.991271], + [12.468004, -5.248362], + [12.436688, -5.684304], + [12.182337, -5.789931] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Albania", + "SOV_A3": "ALB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Albania", + "ADM0_A3": "ALB", + "GEOU_DIF": 0, + "GEOUNIT": "Albania", + "GU_A3": "ALB", + "SU_DIF": 0, + "SUBUNIT": "Albania", + "SU_A3": "ALB", + "BRK_DIFF": 0, + "NAME": "Albania", + "NAME_LONG": "Albania", + "BRK_A3": "ALB", + "BRK_NAME": "Albania", + "BRK_GROUP": null, + "ABBREV": "Alb.", + "POSTAL": "AL", + "FORMAL_EN": "Republic of Albania", + "FORMAL_FR": null, + "NAME_CIAWF": "Albania", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Albania", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 6, + "POP_EST": 3047987, + "POP_RANK": 12, + "GDP_MD_EST": 33900, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "AL", + "ISO_A2": "AL", + "ISO_A3": "ALB", + "ISO_A3_EH": "ALB", + "ISO_N3": "008", + "UN_A3": "008", + "WB_A2": "AL", + "WB_A3": "ALB", + "WOE_ID": 23424742, + "WOE_ID_EH": 23424742, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ALB", + "ADM0_A3_US": "ALB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [19.304486, 39.624998, 21.02004, 42.688247], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [21.02004, 40.842727], + [20.99999, 40.580004], + [20.674997, 40.435], + [20.615, 40.110007], + [20.150016, 39.624998], + [19.98, 39.694993], + [19.960002, 39.915006], + [19.406082, 40.250773], + [19.319059, 40.72723], + [19.40355, 41.409566], + [19.540027, 41.719986], + [19.371769, 41.877548], + [19.371768, 41.877551], + [19.304486, 42.195745], + [19.738051, 42.688247], + [19.801613, 42.500093], + [20.0707, 42.58863], + [20.283755, 42.32026], + [20.52295, 42.21787], + [20.590247, 41.855409], + [20.590247, 41.855404], + [20.463175, 41.515089], + [20.605182, 41.086226], + [21.02004, 40.842727] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "United Arab Emirates", + "SOV_A3": "ARE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "United Arab Emirates", + "ADM0_A3": "ARE", + "GEOU_DIF": 0, + "GEOUNIT": "United Arab Emirates", + "GU_A3": "ARE", + "SU_DIF": 0, + "SUBUNIT": "United Arab Emirates", + "SU_A3": "ARE", + "BRK_DIFF": 0, + "NAME": "United Arab Emirates", + "NAME_LONG": "United Arab Emirates", + "BRK_A3": "ARE", + "BRK_NAME": "United Arab Emirates", + "BRK_GROUP": null, + "ABBREV": "U.A.E.", + "POSTAL": "AE", + "FORMAL_EN": "United Arab Emirates", + "FORMAL_FR": null, + "NAME_CIAWF": "United Arab Emirates", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "United Arab Emirates", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 3, + "POP_EST": 6072475, + "POP_RANK": 13, + "GDP_MD_EST": 667200, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "AE", + "ISO_A2": "AE", + "ISO_A3": "ARE", + "ISO_A3_EH": "ARE", + "ISO_N3": "784", + "UN_A3": "784", + "WB_A2": "AE", + "WB_A3": "ARE", + "WOE_ID": 23424738, + "WOE_ID_EH": 23424738, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ARE", + "ADM0_A3_US": "ARE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 20, + "LONG_LEN": 20, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [51.579519, 22.496948, 56.396847, 26.055464], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.579519, 24.245497], + [51.757441, 24.294073], + [51.794389, 24.019826], + [52.577081, 24.177439], + [53.404007, 24.151317], + [54.008001, 24.121758], + [54.693024, 24.797892], + [55.439025, 25.439145], + [56.070821, 26.055464], + [56.261042, 25.714606], + [56.396847, 24.924732], + [55.886233, 24.920831], + [55.804119, 24.269604], + [55.981214, 24.130543], + [55.528632, 23.933604], + [55.525841, 23.524869], + [55.234489, 23.110993], + [55.208341, 22.70833], + [55.006803, 22.496948], + [52.000733, 23.001154], + [51.617708, 24.014219], + [51.579519, 24.245497] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Argentina", + "SOV_A3": "ARG", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Argentina", + "ADM0_A3": "ARG", + "GEOU_DIF": 0, + "GEOUNIT": "Argentina", + "GU_A3": "ARG", + "SU_DIF": 0, + "SUBUNIT": "Argentina", + "SU_A3": "ARG", + "BRK_DIFF": 0, + "NAME": "Argentina", + "NAME_LONG": "Argentina", + "BRK_A3": "ARG", + "BRK_NAME": "Argentina", + "BRK_GROUP": null, + "ABBREV": "Arg.", + "POSTAL": "AR", + "FORMAL_EN": "Argentine Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Argentina", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Argentina", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 13, + "POP_EST": 44293293, + "POP_RANK": 15, + "GDP_MD_EST": 879400, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "AR", + "ISO_A2": "AR", + "ISO_A3": "ARG", + "ISO_A3_EH": "ARG", + "ISO_N3": "032", + "UN_A3": "032", + "WB_A2": "AR", + "WB_A3": "ARG", + "WOE_ID": 23424747, + "WOE_ID_EH": 23424747, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ARG", + "ADM0_A3_US": "ARG", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [-73.415436, -55.25, -53.628349, -21.83231], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-66.95992, -54.89681], + [-67.56244, -54.87001], + [-68.63335, -54.8695], + [-68.63401, -52.63637], + [-68.25, -53.1], + [-67.75, -53.85], + [-66.45, -54.45], + [-65.05, -54.7], + [-65.5, -55.2], + [-66.45, -55.25], + [-66.95992, -54.89681] + ] + ], + [ + [ + [-68.571545, -52.299444], + [-69.498362, -52.142761], + [-71.914804, -52.009022], + [-72.329404, -51.425956], + [-72.309974, -50.67701], + [-72.975747, -50.74145], + [-73.328051, -50.378785], + [-73.415436, -49.318436], + [-72.648247, -48.878618], + [-72.331161, -48.244238], + [-72.447355, -47.738533], + [-71.917258, -46.884838], + [-71.552009, -45.560733], + [-71.659316, -44.973689], + [-71.222779, -44.784243], + [-71.329801, -44.407522], + [-71.793623, -44.207172], + [-71.464056, -43.787611], + [-71.915424, -43.408565], + [-72.148898, -42.254888], + [-71.746804, -42.051386], + [-71.915734, -40.832339], + [-71.680761, -39.808164], + [-71.413517, -38.916022], + [-70.814664, -38.552995], + [-71.118625, -37.576827], + [-71.121881, -36.658124], + [-70.364769, -36.005089], + [-70.388049, -35.169688], + [-69.817309, -34.193571], + [-69.814777, -33.273886], + [-70.074399, -33.09121], + [-70.535069, -31.36501], + [-69.919008, -30.336339], + [-70.01355, -29.367923], + [-69.65613, -28.459141], + [-69.001235, -27.521214], + [-68.295542, -26.89934], + [-68.5948, -26.506909], + [-68.386001, -26.185016], + [-68.417653, -24.518555], + [-67.328443, -24.025303], + [-66.985234, -22.986349], + [-67.106674, -22.735925], + [-66.273339, -21.83231], + [-64.964892, -22.075862], + [-64.377021, -22.798091], + [-63.986838, -21.993644], + [-62.846468, -22.034985], + [-62.685057, -22.249029], + [-60.846565, -23.880713], + [-60.028966, -24.032796], + [-58.807128, -24.771459], + [-57.777217, -25.16234], + [-57.63366, -25.603657], + [-58.618174, -27.123719], + [-57.60976, -27.395899], + [-56.486702, -27.548499], + [-55.695846, -27.387837], + [-54.788795, -26.621786], + [-54.625291, -25.739255], + [-54.13005, -25.547639], + [-53.628349, -26.124865], + [-53.648735, -26.923473], + [-54.490725, -27.474757], + [-55.162286, -27.881915], + [-56.2909, -28.852761], + [-57.625133, -30.216295], + [-57.874937, -31.016556], + [-58.14244, -32.044504], + [-58.132648, -33.040567], + [-58.349611, -33.263189], + [-58.427074, -33.909454], + [-58.495442, -34.43149], + [-57.22583, -35.288027], + [-57.362359, -35.97739], + [-56.737487, -36.413126], + [-56.788285, -36.901572], + [-57.749157, -38.183871], + [-59.231857, -38.72022], + [-61.237445, -38.928425], + [-62.335957, -38.827707], + [-62.125763, -39.424105], + [-62.330531, -40.172586], + [-62.145994, -40.676897], + [-62.745803, -41.028761], + [-63.770495, -41.166789], + [-64.73209, -40.802677], + [-65.118035, -41.064315], + [-64.978561, -42.058001], + [-64.303408, -42.359016], + [-63.755948, -42.043687], + [-63.458059, -42.563138], + [-64.378804, -42.873558], + [-65.181804, -43.495381], + [-65.328823, -44.501366], + [-65.565269, -45.036786], + [-66.509966, -45.039628], + [-67.293794, -45.551896], + [-67.580546, -46.301773], + [-66.597066, -47.033925], + [-65.641027, -47.236135], + [-65.985088, -48.133289], + [-67.166179, -48.697337], + [-67.816088, -49.869669], + [-68.728745, -50.264218], + [-69.138539, -50.73251], + [-68.815561, -51.771104], + [-68.149995, -52.349983], + [-68.571545, -52.299444] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Armenia", + "SOV_A3": "ARM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Armenia", + "ADM0_A3": "ARM", + "GEOU_DIF": 0, + "GEOUNIT": "Armenia", + "GU_A3": "ARM", + "SU_DIF": 0, + "SUBUNIT": "Armenia", + "SU_A3": "ARM", + "BRK_DIFF": 0, + "NAME": "Armenia", + "NAME_LONG": "Armenia", + "BRK_A3": "ARM", + "BRK_NAME": "Armenia", + "BRK_GROUP": null, + "ABBREV": "Arm.", + "POSTAL": "ARM", + "FORMAL_EN": "Republic of Armenia", + "FORMAL_FR": null, + "NAME_CIAWF": "Armenia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Armenia", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 2, + "MAPCOLOR13": 10, + "POP_EST": 3045191, + "POP_RANK": 12, + "GDP_MD_EST": 26300, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "AM", + "ISO_A2": "AM", + "ISO_A3": "ARM", + "ISO_A3_EH": "ARM", + "ISO_N3": "051", + "UN_A3": "051", + "WB_A2": "AM", + "WB_A3": "ARM", + "WOE_ID": 23424743, + "WOE_ID_EH": 23424743, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ARM", + "ADM0_A3_US": "ARM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [43.582746, 38.741201, 46.50572, 41.248129], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [43.582746, 41.092143], + [44.97248, 41.248129], + [45.179496, 40.985354], + [45.560351, 40.81229], + [45.359175, 40.561504], + [45.891907, 40.218476], + [45.610012, 39.899994], + [46.034534, 39.628021], + [46.483499, 39.464155], + [46.50572, 38.770605], + [46.143623, 38.741201], + [45.735379, 39.319719], + [45.739978, 39.473999], + [45.298145, 39.471751], + [45.001987, 39.740004], + [44.79399, 39.713003], + [44.400009, 40.005], + [43.656436, 40.253564], + [43.752658, 40.740201], + [43.582746, 41.092143] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Antarctica", + "SOV_A3": "ATA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Indeterminate", + "ADMIN": "Antarctica", + "ADM0_A3": "ATA", + "GEOU_DIF": 0, + "GEOUNIT": "Antarctica", + "GU_A3": "ATA", + "SU_DIF": 0, + "SUBUNIT": "Antarctica", + "SU_A3": "ATA", + "BRK_DIFF": 0, + "NAME": "Antarctica", + "NAME_LONG": "Antarctica", + "BRK_A3": "ATA", + "BRK_NAME": "Antarctica", + "BRK_GROUP": null, + "ABBREV": "Ant.", + "POSTAL": "AQ", + "FORMAL_EN": null, + "FORMAL_FR": null, + "NAME_CIAWF": null, + "NOTE_ADM0": null, + "NOTE_BRK": "Multiple claims held in abeyance", + "NAME_SORT": "Antarctica", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 5, + "MAPCOLOR9": 1, + "MAPCOLOR13": -99, + "POP_EST": 4050, + "POP_RANK": 4, + "GDP_MD_EST": 810, + "POP_YEAR": 2013, + "LASTCENSUS": -99, + "GDP_YEAR": 2013, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "AY", + "ISO_A2": "AQ", + "ISO_A3": "ATA", + "ISO_A3_EH": "ATA", + "ISO_N3": "010", + "UN_A3": "-099", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": 28289409, + "WOE_ID_EH": 28289409, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ATA", + "ADM0_A3_US": "ATA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Antarctica", + "REGION_UN": "Antarctica", + "SUBREGION": "Antarctica", + "REGION_WB": "Antarctica", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-180, -90, 180, -63.27066], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-59.572095, -80.040179], + [-59.865849, -80.549657], + [-60.159656, -81.000327], + [-62.255393, -80.863178], + [-64.488125, -80.921934], + [-65.741666, -80.588827], + [-65.741666, -80.549657], + [-66.290031, -80.255773], + [-64.037688, -80.294944], + [-61.883246, -80.39287], + [-61.138976, -79.981371], + [-60.610119, -79.628679], + [-59.572095, -80.040179] + ] + ], + [ + [ + [-159.208184, -79.497059], + [-161.127601, -79.634209], + [-162.439847, -79.281465], + [-163.027408, -78.928774], + [-163.066604, -78.869966], + [-163.712896, -78.595667], + [-163.712896, -78.595667], + [-163.105801, -78.223338], + [-161.245113, -78.380176], + [-160.246208, -78.693645], + [-159.482405, -79.046338], + [-159.208184, -79.497059] + ] + ], + [ + [ + [-45.154758, -78.04707], + [-43.920828, -78.478103], + [-43.48995, -79.08556], + [-43.372438, -79.516645], + [-43.333267, -80.026123], + [-44.880537, -80.339644], + [-46.506174, -80.594357], + [-48.386421, -80.829485], + [-50.482107, -81.025442], + [-52.851988, -80.966685], + [-54.164259, -80.633528], + [-53.987991, -80.222028], + [-51.853134, -79.94773], + [-50.991326, -79.614623], + [-50.364595, -79.183487], + [-49.914131, -78.811209], + [-49.306959, -78.458569], + [-48.660616, -78.047018], + [-48.660616, -78.047019], + [-48.151396, -78.04707], + [-46.662857, -77.831476], + [-45.154758, -78.04707] + ] + ], + [ + [ + [-121.211511, -73.50099], + [-119.918851, -73.657725], + [-118.724143, -73.481353], + [-119.292119, -73.834097], + [-120.232217, -74.08881], + [-121.62283, -74.010468], + [-122.621735, -73.657778], + [-122.621735, -73.657777], + [-122.406245, -73.324619], + [-121.211511, -73.50099] + ] + ], + [ + [ + [-125.559566, -73.481353], + [-124.031882, -73.873268], + [-124.619469, -73.834097], + [-125.912181, -73.736118], + [-127.28313, -73.461769], + [-127.28313, -73.461768], + [-126.558472, -73.246226], + [-125.559566, -73.481353] + ] + ], + [ + [ + [-98.98155, -71.933334], + [-97.884743, -72.070535], + [-96.787937, -71.952971], + [-96.20035, -72.521205], + [-96.983765, -72.442864], + [-98.198083, -72.482035], + [-99.432013, -72.442864], + [-100.783455, -72.50162], + [-101.801868, -72.305663], + [-102.330725, -71.894164], + [-102.330725, -71.894164], + [-101.703967, -71.717792], + [-100.430919, -71.854993], + [-98.98155, -71.933334] + ] + ], + [ + [ + [-68.451346, -70.955823], + [-68.333834, -71.406493], + [-68.510128, -71.798407], + [-68.784297, -72.170736], + [-69.959471, -72.307885], + [-71.075889, -72.503842], + [-72.388134, -72.484257], + [-71.8985, -72.092343], + [-73.073622, -72.229492], + [-74.19004, -72.366693], + [-74.953895, -72.072757], + [-75.012625, -71.661258], + [-73.915819, -71.269345], + [-73.915819, -71.269344], + [-73.230331, -71.15178], + [-72.074717, -71.190951], + [-71.780962, -70.681473], + [-71.72218, -70.309196], + [-71.741791, -69.505782], + [-71.173815, -69.035475], + [-70.253252, -68.87874], + [-69.724447, -69.251017], + [-69.489422, -69.623346], + [-69.058518, -70.074016], + [-68.725541, -70.505153], + [-68.451346, -70.955823] + ] + ], + [ + [ + [-58.614143, -64.152467], + [-59.045073, -64.36801], + [-59.789342, -64.211223], + [-60.611928, -64.309202], + [-61.297416, -64.54433], + [-62.0221, -64.799094], + [-62.51176, -65.09303], + [-62.648858, -65.484942], + [-62.590128, -65.857219], + [-62.120079, -66.190326], + [-62.805567, -66.425505], + [-63.74569, -66.503847], + [-64.294106, -66.837004], + [-64.881693, -67.150474], + [-65.508425, -67.58161], + [-65.665082, -67.953887], + [-65.312545, -68.365335], + [-64.783715, -68.678908], + [-63.961103, -68.913984], + [-63.1973, -69.227556], + [-62.785955, -69.619419], + [-62.570516, -69.991747], + [-62.276736, -70.383661], + [-61.806661, -70.716768], + [-61.512906, -71.089045], + [-61.375809, -72.010074], + [-61.081977, -72.382351], + [-61.003661, -72.774265], + [-60.690269, -73.166179], + [-60.827367, -73.695242], + [-61.375809, -74.106742], + [-61.96337, -74.439848], + [-63.295201, -74.576997], + [-63.74569, -74.92974], + [-64.352836, -75.262847], + [-65.860987, -75.635124], + [-67.192818, -75.79191], + [-68.446282, -76.007452], + [-69.797724, -76.222995], + [-70.600724, -76.634494], + [-72.206776, -76.673665], + [-73.969536, -76.634494], + [-75.555977, -76.712887], + [-77.24037, -76.712887], + [-76.926979, -77.104802], + [-75.399294, -77.28107], + [-74.282876, -77.55542], + [-73.656119, -77.908112], + [-74.772536, -78.221633], + [-76.4961, -78.123654], + [-77.925858, -78.378419], + [-77.984666, -78.789918], + [-78.023785, -79.181833], + [-76.848637, -79.514939], + [-76.633224, -79.887216], + [-75.360097, -80.259545], + [-73.244852, -80.416331], + [-71.442946, -80.69063], + [-70.013163, -81.004151], + [-68.191646, -81.317672], + [-65.704279, -81.474458], + [-63.25603, -81.748757], + [-61.552026, -82.042692], + [-59.691416, -82.37585], + [-58.712121, -82.846106], + [-58.222487, -83.218434], + [-57.008117, -82.865691], + [-55.362894, -82.571755], + [-53.619771, -82.258235], + [-51.543644, -82.003521], + [-49.76135, -81.729171], + [-47.273931, -81.709586], + [-44.825708, -81.846735], + [-42.808363, -82.081915], + [-42.16202, -81.65083], + [-40.771433, -81.356894], + [-38.244818, -81.337309], + [-36.26667, -81.121715], + [-34.386397, -80.906172], + [-32.310296, -80.769023], + [-30.097098, -80.592651], + [-28.549802, -80.337938], + [-29.254901, -79.985195], + [-29.685805, -79.632503], + [-29.685805, -79.260226], + [-31.624808, -79.299397], + [-33.681324, -79.456132], + [-35.639912, -79.456132], + [-35.914107, -79.083855], + [-35.77701, -78.339248], + [-35.326546, -78.123654], + [-33.896763, -77.888526], + [-32.212369, -77.65345], + [-30.998051, -77.359515], + [-29.783732, -77.065579], + [-28.882779, -76.673665], + [-27.511752, -76.497345], + [-26.160336, -76.360144], + [-25.474822, -76.281803], + [-23.927552, -76.24258], + [-22.458598, -76.105431], + [-21.224694, -75.909474], + [-20.010375, -75.674346], + [-18.913543, -75.439218], + [-17.522982, -75.125698], + [-16.641589, -74.79254], + [-15.701491, -74.498604], + [-15.40771, -74.106742], + [-16.46532, -73.871614], + [-16.112784, -73.460114], + [-15.446855, -73.146542], + [-14.408805, -72.950585], + [-13.311973, -72.715457], + [-12.293508, -72.401936], + [-11.510067, -72.010074], + [-11.020433, -71.539767], + [-10.295774, -71.265416], + [-9.101015, -71.324224], + [-8.611381, -71.65733], + [-7.416622, -71.696501], + [-7.377451, -71.324224], + [-6.868232, -70.93231], + [-5.790985, -71.030289], + [-5.536375, -71.402617], + [-4.341667, -71.461373], + [-3.048981, -71.285053], + [-1.795492, -71.167438], + [-0.659489, -71.226246], + [-0.228637, -71.637745], + [0.868195, -71.304639], + [1.886686, -71.128267], + [3.022638, -70.991118], + [4.139055, -70.853917], + [5.157546, -70.618789], + [6.273912, -70.462055], + [7.13572, -70.246512], + [7.742866, -69.893769], + [8.48711, -70.148534], + [9.525135, -70.011333], + [10.249845, -70.48164], + [10.817821, -70.834332], + [11.953824, -70.638375], + [12.404287, -70.246512], + [13.422778, -69.972162], + [14.734998, -70.030918], + [15.126757, -70.403247], + [15.949342, -70.030918], + [17.026589, -69.913354], + [18.201711, -69.874183], + [19.259373, -69.893769], + [20.375739, -70.011333], + [21.452985, -70.07014], + [21.923034, -70.403247], + [22.569403, -70.697182], + [23.666184, -70.520811], + [24.841357, -70.48164], + [25.977309, -70.48164], + [27.093726, -70.462055], + [28.09258, -70.324854], + [29.150242, -70.20729], + [30.031583, -69.93294], + [30.971733, -69.75662], + [31.990172, -69.658641], + [32.754053, -69.384291], + [33.302443, -68.835642], + [33.870419, -68.502588], + [34.908495, -68.659271], + [35.300202, -69.012014], + [36.16201, -69.247142], + [37.200035, -69.168748], + [37.905108, -69.52144], + [38.649404, -69.776205], + [39.667894, -69.541077], + [40.020431, -69.109941], + [40.921358, -68.933621], + [41.959434, -68.600514], + [42.938702, -68.463313], + [44.113876, -68.267408], + [44.897291, -68.051866], + [45.719928, -67.816738], + [46.503343, -67.601196], + [47.44344, -67.718759], + [48.344419, -67.366068], + [48.990736, -67.091718], + [49.930885, -67.111303], + [50.753471, -66.876175], + [50.949325, -66.523484], + [51.791547, -66.249133], + [52.614133, -66.053176], + [53.613038, -65.89639], + [54.53355, -65.818049], + [55.414943, -65.876805], + [56.355041, -65.974783], + [57.158093, -66.249133], + [57.255968, -66.680218], + [58.137361, -67.013324], + [58.744508, -67.287675], + [59.939318, -67.405239], + [60.605221, -67.679589], + [61.427806, -67.953887], + [62.387489, -68.012695], + [63.19049, -67.816738], + [64.052349, -67.405239], + [64.992447, -67.620729], + [65.971715, -67.738345], + [66.911864, -67.855909], + [67.891133, -67.934302], + [68.890038, -67.934302], + [69.712624, -68.972791], + [69.673453, -69.227556], + [69.555941, -69.678226], + [68.596258, -69.93294], + [67.81274, -70.305268], + [67.949889, -70.697182], + [69.066307, -70.677545], + [68.929157, -71.069459], + [68.419989, -71.441788], + [67.949889, -71.853287], + [68.71377, -72.166808], + [69.869307, -72.264787], + [71.024895, -72.088415], + [71.573285, -71.696501], + [71.906288, -71.324224], + [72.454627, -71.010703], + [73.08141, -70.716768], + [73.33602, -70.364024], + [73.864877, -69.874183], + [74.491557, -69.776205], + [75.62756, -69.737034], + [76.626465, -69.619419], + [77.644904, -69.462684], + [78.134539, -69.07077], + [78.428371, -68.698441], + [79.113859, -68.326216], + [80.093127, -68.071503], + [80.93535, -67.875546], + [81.483792, -67.542388], + [82.051767, -67.366068], + [82.776426, -67.209282], + [83.775331, -67.30726], + [84.676206, -67.209282], + [85.655527, -67.091718], + [86.752359, -67.150474], + [87.477017, -66.876175], + [87.986289, -66.209911], + [88.358411, -66.484261], + [88.828408, -66.954568], + [89.67063, -67.150474], + [90.630365, -67.228867], + [91.5901, -67.111303], + [92.608539, -67.189696], + [93.548637, -67.209282], + [94.17542, -67.111303], + [95.017591, -67.170111], + [95.781472, -67.385653], + [96.682399, -67.248504], + [97.759646, -67.248504], + [98.68021, -67.111303], + [99.718182, -67.248504], + [100.384188, -66.915346], + [100.893356, -66.58224], + [101.578896, -66.30789], + [102.832411, -65.563284], + [103.478676, -65.700485], + [104.242557, -65.974783], + [104.90846, -66.327527], + [106.181561, -66.934931], + [107.160881, -66.954568], + [108.081393, -66.954568], + [109.15864, -66.837004], + [110.235835, -66.699804], + [111.058472, -66.425505], + [111.74396, -66.13157], + [112.860378, -66.092347], + [113.604673, -65.876805], + [114.388088, -66.072762], + [114.897308, -66.386283], + [115.602381, -66.699804], + [116.699161, -66.660633], + [117.384701, -66.915346], + [118.57946, -67.170111], + [119.832924, -67.268089], + [120.871, -67.189696], + [121.654415, -66.876175], + [122.320369, -66.562654], + [123.221296, -66.484261], + [124.122274, -66.621462], + [125.160247, -66.719389], + [126.100396, -66.562654], + [127.001427, -66.562654], + [127.882768, -66.660633], + [128.80328, -66.758611], + [129.704259, -66.58224], + [130.781454, -66.425505], + [131.799945, -66.386283], + [132.935896, -66.386283], + [133.85646, -66.288304], + [134.757387, -66.209963], + [135.031582, -65.72007], + [135.070753, -65.308571], + [135.697485, -65.582869], + [135.873805, -66.033591], + [136.206705, -66.44509], + [136.618049, -66.778197], + [137.460271, -66.954568], + [138.596223, -66.895761], + [139.908442, -66.876175], + [140.809421, -66.817367], + [142.121692, -66.817367], + [143.061842, -66.797782], + [144.374061, -66.837004], + [145.490427, -66.915346], + [146.195552, -67.228867], + [145.999699, -67.601196], + [146.646067, -67.895131], + [147.723263, -68.130259], + [148.839629, -68.385024], + [150.132314, -68.561292], + [151.483705, -68.71813], + [152.502247, -68.874813], + [153.638199, -68.894502], + [154.284567, -68.561292], + [155.165857, -68.835642], + [155.92979, -69.149215], + [156.811132, -69.384291], + [158.025528, -69.482269], + [159.181013, -69.599833], + [159.670699, -69.991747], + [160.80665, -70.226875], + [161.570479, -70.579618], + [162.686897, -70.736353], + [163.842434, -70.716768], + [164.919681, -70.775524], + [166.11444, -70.755938], + [167.309095, -70.834332], + [168.425616, -70.971481], + [169.463589, -71.20666], + [170.501665, -71.402617], + [171.20679, -71.696501], + [171.089227, -72.088415], + [170.560422, -72.441159], + [170.109958, -72.891829], + [169.75737, -73.24452], + [169.287321, -73.65602], + [167.975101, -73.812806], + [167.387489, -74.165498], + [166.094803, -74.38104], + [165.644391, -74.772954], + [164.958851, -75.145283], + [164.234193, -75.458804], + [163.822797, -75.870303], + [163.568239, -76.24258], + [163.47026, -76.693302], + [163.489897, -77.065579], + [164.057873, -77.457442], + [164.273363, -77.82977], + [164.743464, -78.182514], + [166.604126, -78.319611], + [166.995781, -78.750748], + [165.193876, -78.907483], + [163.666217, -79.123025], + [161.766385, -79.162248], + [160.924162, -79.730482], + [160.747894, -80.200737], + [160.316964, -80.573066], + [159.788211, -80.945395], + [161.120016, -81.278501], + [161.629287, -81.690001], + [162.490992, -82.062278], + [163.705336, -82.395435], + [165.095949, -82.708956], + [166.604126, -83.022477], + [168.895665, -83.335998], + [169.404782, -83.825891], + [172.283934, -84.041433], + [172.477049, -84.117914], + [173.224083, -84.41371], + [175.985672, -84.158997], + [178.277212, -84.472518], + [180, -84.71338], + [180, -90], + [-180, -90], + [-180, -84.71338], + [-179.942499, -84.721443], + [-179.058677, -84.139412], + [-177.256772, -84.452933], + [-177.140807, -84.417941], + [-176.084673, -84.099259], + [-175.947235, -84.110449], + [-175.829882, -84.117914], + [-174.382503, -84.534323], + [-173.116559, -84.117914], + [-172.889106, -84.061019], + [-169.951223, -83.884647], + [-168.999989, -84.117914], + [-168.530199, -84.23739], + [-167.022099, -84.570497], + [-164.182144, -84.82521], + [-161.929775, -85.138731], + [-158.07138, -85.37391], + [-155.192253, -85.09956], + [-150.942099, -85.295517], + [-148.533073, -85.609038], + [-145.888918, -85.315102], + [-143.107718, -85.040752], + [-142.892279, -84.570497], + [-146.829068, -84.531274], + [-150.060732, -84.296146], + [-150.902928, -83.904232], + [-153.586201, -83.68869], + [-153.409907, -83.23802], + [-153.037759, -82.82652], + [-152.665637, -82.454192], + [-152.861517, -82.042692], + [-154.526299, -81.768394], + [-155.29018, -81.41565], + [-156.83745, -81.102129], + [-154.408787, -81.160937], + [-152.097662, -81.004151], + [-150.648293, -81.337309], + [-148.865998, -81.043373], + [-147.22075, -80.671045], + [-146.417749, -80.337938], + [-146.770286, -79.926439], + [-148.062947, -79.652089], + [-149.531901, -79.358205], + [-151.588416, -79.299397], + [-153.390322, -79.162248], + [-155.329376, -79.064269], + [-155.975668, -78.69194], + [-157.268302, -78.378419], + [-158.051768, -78.025676], + [-158.365134, -76.889207], + [-157.875474, -76.987238], + [-156.974573, -77.300759], + [-155.329376, -77.202728], + [-153.742832, -77.065579], + [-152.920247, -77.496664], + [-151.33378, -77.398737], + [-150.00195, -77.183143], + [-148.748486, -76.908845], + [-147.612483, -76.575738], + [-146.104409, -76.47776], + [-146.143528, -76.105431], + [-146.496091, -75.733154], + [-146.20231, -75.380411], + [-144.909624, -75.204039], + [-144.322037, -75.537197], + [-142.794353, -75.34124], + [-141.638764, -75.086475], + [-140.209007, -75.06689], + [-138.85759, -74.968911], + [-137.5062, -74.733783], + [-136.428901, -74.518241], + [-135.214583, -74.302699], + [-134.431194, -74.361455], + [-133.745654, -74.439848], + [-132.257168, -74.302699], + [-130.925311, -74.479019], + [-129.554284, -74.459433], + [-128.242038, -74.322284], + [-126.890622, -74.420263], + [-125.402082, -74.518241], + [-124.011496, -74.479019], + [-122.562152, -74.498604], + [-121.073613, -74.518241], + [-119.70256, -74.479019], + [-118.684145, -74.185083], + [-117.469801, -74.028348], + [-116.216312, -74.243891], + [-115.021552, -74.067519], + [-113.944331, -73.714828], + [-113.297988, -74.028348], + [-112.945452, -74.38104], + [-112.299083, -74.714198], + [-111.261059, -74.420263], + [-110.066325, -74.79254], + [-108.714909, -74.910103], + [-107.559346, -75.184454], + [-106.149148, -75.125698], + [-104.876074, -74.949326], + [-103.367949, -74.988497], + [-102.016507, -75.125698], + [-100.645531, -75.302018], + [-100.1167, -74.870933], + [-100.763043, -74.537826], + [-101.252703, -74.185083], + [-102.545337, -74.106742], + [-103.113313, -73.734413], + [-103.328752, -73.362084], + [-103.681289, -72.61753], + [-102.917485, -72.754679], + [-101.60524, -72.813436], + [-100.312528, -72.754679], + [-99.13738, -72.911414], + [-98.118889, -73.20535], + [-97.688037, -73.558041], + [-96.336595, -73.616849], + [-95.043961, -73.4797], + [-93.672907, -73.283743], + [-92.439003, -73.166179], + [-91.420564, -73.401307], + [-90.088733, -73.322914], + [-89.226951, -72.558722], + [-88.423951, -73.009393], + [-87.268337, -73.185764], + [-86.014822, -73.087786], + [-85.192236, -73.4797], + [-83.879991, -73.518871], + [-82.665646, -73.636434], + [-81.470913, -73.851977], + [-80.687447, -73.4797], + [-80.295791, -73.126956], + [-79.296886, -73.518871], + [-77.925858, -73.420892], + [-76.907367, -73.636434], + [-76.221879, -73.969541], + [-74.890049, -73.871614], + [-73.852024, -73.65602], + [-72.833533, -73.401307], + [-71.619215, -73.264157], + [-70.209042, -73.146542], + [-68.935916, -73.009393], + [-67.956622, -72.79385], + [-67.369061, -72.480329], + [-67.134036, -72.049244], + [-67.251548, -71.637745], + [-67.56494, -71.245831], + [-67.917477, -70.853917], + [-68.230843, -70.462055], + [-68.485452, -70.109311], + [-68.544209, -69.717397], + [-68.446282, -69.325535], + [-67.976233, -68.953206], + [-67.5845, -68.541707], + [-67.427843, -68.149844], + [-67.62367, -67.718759], + [-67.741183, -67.326845], + [-67.251548, -66.876175], + [-66.703184, -66.58224], + [-66.056815, -66.209963], + [-65.371327, -65.89639], + [-64.568276, -65.602506], + [-64.176542, -65.171423], + [-63.628152, -64.897073], + [-63.001394, -64.642308], + [-62.041686, -64.583552], + [-61.414928, -64.270031], + [-60.709855, -64.074074], + [-59.887269, -63.95651], + [-59.162585, -63.701745], + [-58.594557, -63.388224], + [-57.811143, -63.27066], + [-57.223582, -63.525425], + [-57.59573, -63.858532], + [-58.614143, -64.152467] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "France", + "SOV_A3": "FR1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Dependency", + "ADMIN": "French Southern and Antarctic Lands", + "ADM0_A3": "ATF", + "GEOU_DIF": 0, + "GEOUNIT": "French Southern and Antarctic Lands", + "GU_A3": "ATF", + "SU_DIF": 0, + "SUBUNIT": "French Southern and Antarctic Lands", + "SU_A3": "ATF", + "BRK_DIFF": 0, + "NAME": "Fr. S. Antarctic Lands", + "NAME_LONG": "French Southern and Antarctic Lands", + "BRK_A3": "ATF", + "BRK_NAME": "Fr. S. and Antarctic Lands", + "BRK_GROUP": null, + "ABBREV": "Fr. S.A.L.", + "POSTAL": "TF", + "FORMAL_EN": "Territory of the French Southern and Antarctic Lands", + "FORMAL_FR": null, + "NAME_CIAWF": null, + "NOTE_ADM0": "Fr.", + "NOTE_BRK": null, + "NAME_SORT": "French Southern and Antarctic Lands", + "NAME_ALT": null, + "MAPCOLOR7": 7, + "MAPCOLOR8": 5, + "MAPCOLOR9": 9, + "MAPCOLOR13": 11, + "POP_EST": 140, + "POP_RANK": 1, + "GDP_MD_EST": 16, + "POP_YEAR": 2017, + "LASTCENSUS": -99, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "FS", + "ISO_A2": "TF", + "ISO_A3": "ATF", + "ISO_A3_EH": "ATF", + "ISO_N3": "260", + "UN_A3": "-099", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": 28289406, + "WOE_ID_EH": 28289406, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ATF", + "ADM0_A3_US": "ATF", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Seven seas (open ocean)", + "REGION_UN": "Seven seas (open ocean)", + "SUBREGION": "Seven seas (open ocean)", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 22, + "LONG_LEN": 35, + "ABBREV_LEN": 10, + "TINY": 2, + "HOMEPART": -99, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [68.72, -49.775, 70.56, -48.625], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [68.935, -48.625], + [69.58, -48.94], + [70.525, -49.065], + [70.56, -49.255], + [70.28, -49.71], + [68.745, -49.775], + [68.72, -49.2425], + [68.8675, -48.83], + [68.935, -48.625] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Australia", + "SOV_A3": "AU1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "Australia", + "ADM0_A3": "AUS", + "GEOU_DIF": 0, + "GEOUNIT": "Australia", + "GU_A3": "AUS", + "SU_DIF": 0, + "SUBUNIT": "Australia", + "SU_A3": "AUS", + "BRK_DIFF": 0, + "NAME": "Australia", + "NAME_LONG": "Australia", + "BRK_A3": "AUS", + "BRK_NAME": "Australia", + "BRK_GROUP": null, + "ABBREV": "Auz.", + "POSTAL": "AU", + "FORMAL_EN": "Commonwealth of Australia", + "FORMAL_FR": null, + "NAME_CIAWF": "Australia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Australia", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 7, + "POP_EST": 23232413, + "POP_RANK": 15, + "GDP_MD_EST": 1189000, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "AS", + "ISO_A2": "AU", + "ISO_A3": "AUS", + "ISO_A3_EH": "AUS", + "ISO_N3": "036", + "UN_A3": "036", + "WB_A2": "AU", + "WB_A3": "AUS", + "WOE_ID": -90, + "WOE_ID_EH": 23424748, + "WOE_NOTE": "Includes Ashmore and Cartier Islands (23424749) and Coral Sea Islands (23424790).", + "ADM0_A3_IS": "AUS", + "ADM0_A3_US": "AUS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Australia and New Zealand", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 5.7 + }, + "bbox": [113.338953, -43.634597, 153.569469, -10.668186], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [145.397978, -40.792549], + [146.364121, -41.137695], + [146.908584, -41.000546], + [147.689259, -40.808258], + [148.289068, -40.875438], + [148.359865, -42.062445], + [148.017301, -42.407024], + [147.914052, -43.211522], + [147.564564, -42.937689], + [146.870343, -43.634597], + [146.663327, -43.580854], + [146.048378, -43.549745], + [145.43193, -42.693776], + [145.29509, -42.03361], + [144.718071, -41.162552], + [144.743755, -40.703975], + [145.397978, -40.792549] + ] + ], + [ + [ + [143.561811, -13.763656], + [143.922099, -14.548311], + [144.563714, -14.171176], + [144.894908, -14.594458], + [145.374724, -14.984976], + [145.271991, -15.428205], + [145.48526, -16.285672], + [145.637033, -16.784918], + [145.888904, -16.906926], + [146.160309, -17.761655], + [146.063674, -18.280073], + [146.387478, -18.958274], + [147.471082, -19.480723], + [148.177602, -19.955939], + [148.848414, -20.39121], + [148.717465, -20.633469], + [149.28942, -21.260511], + [149.678337, -22.342512], + [150.077382, -22.122784], + [150.482939, -22.556142], + [150.727265, -22.402405], + [150.899554, -23.462237], + [151.609175, -24.076256], + [152.07354, -24.457887], + [152.855197, -25.267501], + [153.136162, -26.071173], + [153.161949, -26.641319], + [153.092909, -27.2603], + [153.569469, -28.110067], + [153.512108, -28.995077], + [153.339095, -29.458202], + [153.069241, -30.35024], + [153.089602, -30.923642], + [152.891578, -31.640446], + [152.450002, -32.550003], + [151.709117, -33.041342], + [151.343972, -33.816023], + [151.010555, -34.31036], + [150.714139, -35.17346], + [150.32822, -35.671879], + [150.075212, -36.420206], + [149.946124, -37.109052], + [149.997284, -37.425261], + [149.423882, -37.772681], + [148.304622, -37.809061], + [147.381733, -38.219217], + [146.922123, -38.606532], + [146.317922, -39.035757], + [145.489652, -38.593768], + [144.876976, -38.417448], + [145.032212, -37.896188], + [144.485682, -38.085324], + [143.609974, -38.809465], + [142.745427, -38.538268], + [142.17833, -38.380034], + [141.606582, -38.308514], + [140.638579, -38.019333], + [139.992158, -37.402936], + [139.806588, -36.643603], + [139.574148, -36.138362], + [139.082808, -35.732754], + [138.120748, -35.612296], + [138.449462, -35.127261], + [138.207564, -34.384723], + [137.71917, -35.076825], + [136.829406, -35.260535], + [137.352371, -34.707339], + [137.503886, -34.130268], + [137.890116, -33.640479], + [137.810328, -32.900007], + [136.996837, -33.752771], + [136.372069, -34.094766], + [135.989043, -34.890118], + [135.208213, -34.47867], + [135.239218, -33.947953], + [134.613417, -33.222778], + [134.085904, -32.848072], + [134.273903, -32.617234], + [132.990777, -32.011224], + [132.288081, -31.982647], + [131.326331, -31.495803], + [129.535794, -31.590423], + [128.240938, -31.948489], + [127.102867, -32.282267], + [126.148714, -32.215966], + [125.088623, -32.728751], + [124.221648, -32.959487], + [124.028947, -33.483847], + [123.659667, -33.890179], + [122.811036, -33.914467], + [122.183064, -34.003402], + [121.299191, -33.821036], + [120.580268, -33.930177], + [119.893695, -33.976065], + [119.298899, -34.509366], + [119.007341, -34.464149], + [118.505718, -34.746819], + [118.024972, -35.064733], + [117.295507, -35.025459], + [116.625109, -35.025097], + [115.564347, -34.386428], + [115.026809, -34.196517], + [115.048616, -33.623425], + [115.545123, -33.487258], + [115.714674, -33.259572], + [115.679379, -32.900369], + [115.801645, -32.205062], + [115.689611, -31.612437], + [115.160909, -30.601594], + [114.997043, -30.030725], + [115.040038, -29.461095], + [114.641974, -28.810231], + [114.616498, -28.516399], + [114.173579, -28.118077], + [114.048884, -27.334765], + [113.477498, -26.543134], + [113.338953, -26.116545], + [113.778358, -26.549025], + [113.440962, -25.621278], + [113.936901, -25.911235], + [114.232852, -26.298446], + [114.216161, -25.786281], + [113.721255, -24.998939], + [113.625344, -24.683971], + [113.393523, -24.384764], + [113.502044, -23.80635], + [113.706993, -23.560215], + [113.843418, -23.059987], + [113.736552, -22.475475], + [114.149756, -21.755881], + [114.225307, -22.517488], + [114.647762, -21.82952], + [115.460167, -21.495173], + [115.947373, -21.068688], + [116.711615, -20.701682], + [117.166316, -20.623599], + [117.441545, -20.746899], + [118.229559, -20.374208], + [118.836085, -20.263311], + [118.987807, -20.044203], + [119.252494, -19.952942], + [119.805225, -19.976506], + [120.85622, -19.683708], + [121.399856, -19.239756], + [121.655138, -18.705318], + [122.241665, -18.197649], + [122.286624, -17.798603], + [122.312772, -17.254967], + [123.012574, -16.4052], + [123.433789, -17.268558], + [123.859345, -17.069035], + [123.503242, -16.596506], + [123.817073, -16.111316], + [124.258287, -16.327944], + [124.379726, -15.56706], + [124.926153, -15.0751], + [125.167275, -14.680396], + [125.670087, -14.51007], + [125.685796, -14.230656], + [126.125149, -14.347341], + [126.142823, -14.095987], + [126.582589, -13.952791], + [127.065867, -13.817968], + [127.804633, -14.276906], + [128.35969, -14.86917], + [128.985543, -14.875991], + [129.621473, -14.969784], + [129.4096, -14.42067], + [129.888641, -13.618703], + [130.339466, -13.357376], + [130.183506, -13.10752], + [130.617795, -12.536392], + [131.223495, -12.183649], + [131.735091, -12.302453], + [132.575298, -12.114041], + [132.557212, -11.603012], + [131.824698, -11.273782], + [132.357224, -11.128519], + [133.019561, -11.376411], + [133.550846, -11.786515], + [134.393068, -12.042365], + [134.678632, -11.941183], + [135.298491, -12.248606], + [135.882693, -11.962267], + [136.258381, -12.049342], + [136.492475, -11.857209], + [136.95162, -12.351959], + [136.685125, -12.887223], + [136.305407, -13.29123], + [135.961758, -13.324509], + [136.077617, -13.724278], + [135.783836, -14.223989], + [135.428664, -14.715432], + [135.500184, -14.997741], + [136.295175, -15.550265], + [137.06536, -15.870762], + [137.580471, -16.215082], + [138.303217, -16.807604], + [138.585164, -16.806622], + [139.108543, -17.062679], + [139.260575, -17.371601], + [140.215245, -17.710805], + [140.875463, -17.369069], + [141.07111, -16.832047], + [141.274095, -16.38887], + [141.398222, -15.840532], + [141.702183, -15.044921], + [141.56338, -14.561333], + [141.63552, -14.270395], + [141.519869, -13.698078], + [141.65092, -12.944688], + [141.842691, -12.741548], + [141.68699, -12.407614], + [141.928629, -11.877466], + [142.118488, -11.328042], + [142.143706, -11.042737], + [142.51526, -10.668186], + [142.79731, -11.157355], + [142.866763, -11.784707], + [143.115947, -11.90563], + [143.158632, -12.325656], + [143.522124, -12.834358], + [143.597158, -13.400422], + [143.561811, -13.763656] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Austria", + "SOV_A3": "AUT", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Austria", + "ADM0_A3": "AUT", + "GEOU_DIF": 0, + "GEOUNIT": "Austria", + "GU_A3": "AUT", + "SU_DIF": 0, + "SUBUNIT": "Austria", + "SU_A3": "AUT", + "BRK_DIFF": 0, + "NAME": "Austria", + "NAME_LONG": "Austria", + "BRK_A3": "AUT", + "BRK_NAME": "Austria", + "BRK_GROUP": null, + "ABBREV": "Aust.", + "POSTAL": "A", + "FORMAL_EN": "Republic of Austria", + "FORMAL_FR": null, + "NAME_CIAWF": "Austria", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Austria", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 4, + "POP_EST": 8754413, + "POP_RANK": 13, + "GDP_MD_EST": 416600, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "AU", + "ISO_A2": "AT", + "ISO_A3": "AUT", + "ISO_A3_EH": "AUT", + "ISO_N3": "040", + "UN_A3": "040", + "WB_A2": "AT", + "WB_A3": "AUT", + "WOE_ID": 23424750, + "WOE_ID_EH": 23424750, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "AUT", + "ADM0_A3_US": "AUT", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [9.47997, 46.431817, 16.979667, 49.039074], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [16.979667, 48.123497], + [16.903754, 47.714866], + [16.340584, 47.712902], + [16.534268, 47.496171], + [16.202298, 46.852386], + [16.011664, 46.683611], + [15.137092, 46.658703], + [14.632472, 46.431817], + [13.806475, 46.509306], + [12.376485, 46.767559], + [12.153088, 47.115393], + [11.164828, 46.941579], + [11.048556, 46.751359], + [10.442701, 46.893546], + [9.932448, 46.920728], + [9.47997, 47.10281], + [9.632932, 47.347601], + [9.594226, 47.525058], + [9.896068, 47.580197], + [10.402084, 47.302488], + [10.544504, 47.566399], + [11.426414, 47.523766], + [12.141357, 47.703083], + [12.62076, 47.672388], + [12.932627, 47.467646], + [13.025851, 47.637584], + [12.884103, 48.289146], + [13.243357, 48.416115], + [13.595946, 48.877172], + [14.338898, 48.555305], + [14.901447, 48.964402], + [15.253416, 49.039074], + [16.029647, 48.733899], + [16.499283, 48.785808], + [16.960288, 48.596982], + [16.879983, 48.470013], + [16.979667, 48.123497] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Azerbaijan", + "SOV_A3": "AZE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Azerbaijan", + "ADM0_A3": "AZE", + "GEOU_DIF": 0, + "GEOUNIT": "Azerbaijan", + "GU_A3": "AZE", + "SU_DIF": 0, + "SUBUNIT": "Azerbaijan", + "SU_A3": "AZE", + "BRK_DIFF": 0, + "NAME": "Azerbaijan", + "NAME_LONG": "Azerbaijan", + "BRK_A3": "AZE", + "BRK_NAME": "Azerbaijan", + "BRK_GROUP": null, + "ABBREV": "Aze.", + "POSTAL": "AZ", + "FORMAL_EN": "Republic of Azerbaijan", + "FORMAL_FR": null, + "NAME_CIAWF": "Azerbaijan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Azerbaijan", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 6, + "MAPCOLOR9": 5, + "MAPCOLOR13": 8, + "POP_EST": 9961396, + "POP_RANK": 13, + "GDP_MD_EST": 167900, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "AJ", + "ISO_A2": "AZ", + "ISO_A3": "AZE", + "ISO_A3_EH": "AZE", + "ISO_N3": "031", + "UN_A3": "031", + "WB_A2": "AZ", + "WB_A3": "AZE", + "WOE_ID": 23424741, + "WOE_ID_EH": 23424741, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "AZE", + "ADM0_A3_US": "AZE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [44.79399, 38.270378, 50.392821, 41.860675], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [46.50572, 38.770605], + [46.483499, 39.464155], + [46.034534, 39.628021], + [45.610012, 39.899994], + [45.891907, 40.218476], + [45.359175, 40.561504], + [45.560351, 40.81229], + [45.179496, 40.985354], + [44.97248, 41.248129], + [45.217426, 41.411452], + [45.962601, 41.123873], + [46.501637, 41.064445], + [46.637908, 41.181673], + [46.145432, 41.722802], + [46.404951, 41.860675], + [46.686071, 41.827137], + [47.373315, 41.219732], + [47.815666, 41.151416], + [47.987283, 41.405819], + [48.584353, 41.808869], + [49.110264, 41.282287], + [49.618915, 40.572924], + [50.08483, 40.526157], + [50.392821, 40.256561], + [49.569202, 40.176101], + [49.395259, 39.399482], + [49.223228, 39.049219], + [48.856532, 38.815486], + [48.883249, 38.320245], + [48.634375, 38.270378], + [48.010744, 38.794015], + [48.355529, 39.288765], + [48.060095, 39.582235], + [47.685079, 39.508364], + [46.50572, 38.770605] + ] + ], + [ + [ + [44.79399, 39.713003], + [45.001987, 39.740004], + [45.298145, 39.471751], + [45.739978, 39.473999], + [45.735379, 39.319719], + [46.143623, 38.741201], + [45.457722, 38.874139], + [44.952688, 39.335765], + [44.79399, 39.713003] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Burundi", + "SOV_A3": "BDI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Burundi", + "ADM0_A3": "BDI", + "GEOU_DIF": 0, + "GEOUNIT": "Burundi", + "GU_A3": "BDI", + "SU_DIF": 0, + "SUBUNIT": "Burundi", + "SU_A3": "BDI", + "BRK_DIFF": 0, + "NAME": "Burundi", + "NAME_LONG": "Burundi", + "BRK_A3": "BDI", + "BRK_NAME": "Burundi", + "BRK_GROUP": null, + "ABBREV": "Bur.", + "POSTAL": "BI", + "FORMAL_EN": "Republic of Burundi", + "FORMAL_FR": null, + "NAME_CIAWF": "Burundi", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Burundi", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 8, + "POP_EST": 11466756, + "POP_RANK": 14, + "GDP_MD_EST": 7892, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "BY", + "ISO_A2": "BI", + "ISO_A3": "BDI", + "ISO_A3_EH": "BDI", + "ISO_N3": "108", + "UN_A3": "108", + "WB_A2": "BI", + "WB_A3": "BDI", + "WOE_ID": 23424774, + "WOE_ID_EH": 23424774, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BDI", + "ADM0_A3_US": "BDI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [29.024926, -4.499983, 30.75224, -2.348487], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [29.339998, -4.499983], + [29.276384, -3.293907], + [29.024926, -2.839258], + [29.632176, -2.917858], + [29.938359, -2.348487], + [30.469674, -2.413855], + [30.52766, -2.80762], + [30.74301, -3.03431], + [30.75224, -3.35931], + [30.50554, -3.56858], + [30.11632, -4.09012], + [29.753512, -4.452389], + [29.339998, -4.499983] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Belgium", + "SOV_A3": "BEL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Belgium", + "ADM0_A3": "BEL", + "GEOU_DIF": 0, + "GEOUNIT": "Belgium", + "GU_A3": "BEL", + "SU_DIF": 0, + "SUBUNIT": "Belgium", + "SU_A3": "BEL", + "BRK_DIFF": 0, + "NAME": "Belgium", + "NAME_LONG": "Belgium", + "BRK_A3": "BEL", + "BRK_NAME": "Belgium", + "BRK_GROUP": null, + "ABBREV": "Belg.", + "POSTAL": "B", + "FORMAL_EN": "Kingdom of Belgium", + "FORMAL_FR": null, + "NAME_CIAWF": "Belgium", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Belgium", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 1, + "MAPCOLOR13": 8, + "POP_EST": 11491346, + "POP_RANK": 14, + "GDP_MD_EST": 508600, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "BE", + "ISO_A2": "BE", + "ISO_A3": "BEL", + "ISO_A3_EH": "BEL", + "ISO_N3": "056", + "UN_A3": "056", + "WB_A2": "BE", + "WB_A3": "BEL", + "WOE_ID": 23424757, + "WOE_ID_EH": 23424757, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BEL", + "ADM0_A3_US": "BEL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [2.513573, 49.529484, 6.156658, 51.475024], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [4.047071, 51.267259], + [4.973991, 51.475024], + [5.606976, 51.037298], + [6.156658, 50.803721], + [6.043073, 50.128052], + [5.782417, 50.090328], + [5.674052, 49.529484], + [4.799222, 49.985373], + [4.286023, 49.907497], + [3.588184, 50.378992], + [3.123252, 50.780363], + [2.658422, 50.796848], + [2.513573, 51.148506], + [3.314971, 51.345781], + [3.315011, 51.345777], + [3.314971, 51.345755], + [4.047071, 51.267259] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Benin", + "SOV_A3": "BEN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Benin", + "ADM0_A3": "BEN", + "GEOU_DIF": 0, + "GEOUNIT": "Benin", + "GU_A3": "BEN", + "SU_DIF": 0, + "SUBUNIT": "Benin", + "SU_A3": "BEN", + "BRK_DIFF": 0, + "NAME": "Benin", + "NAME_LONG": "Benin", + "BRK_A3": "BEN", + "BRK_NAME": "Benin", + "BRK_GROUP": null, + "ABBREV": "Benin", + "POSTAL": "BJ", + "FORMAL_EN": "Republic of Benin", + "FORMAL_FR": null, + "NAME_CIAWF": "Benin", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Benin", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 12, + "POP_EST": 11038805, + "POP_RANK": 14, + "GDP_MD_EST": 24310, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "BN", + "ISO_A2": "BJ", + "ISO_A3": "BEN", + "ISO_A3_EH": "BEN", + "ISO_N3": "204", + "UN_A3": "204", + "WB_A2": "BJ", + "WB_A3": "BEN", + "WOE_ID": 23424764, + "WOE_ID_EH": 23424764, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BEN", + "ADM0_A3_US": "BEN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [0.772336, 6.142158, 3.797112, 12.235636], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [2.691702, 6.258817], + [1.865241, 6.142158], + [1.618951, 6.832038], + [1.664478, 9.12859], + [1.463043, 9.334624], + [1.425061, 9.825395], + [1.077795, 10.175607], + [0.772336, 10.470808], + [0.899563, 10.997339], + [1.24347, 11.110511], + [1.447178, 11.547719], + [1.935986, 11.64115], + [2.154474, 11.94015], + [2.490164, 12.233052], + [2.848643, 12.235636], + [3.61118, 11.660167], + [3.572216, 11.327939], + [3.797112, 10.734746], + [3.60007, 10.332186], + [3.705438, 10.06321], + [3.220352, 9.444153], + [2.912308, 9.137608], + [2.723793, 8.506845], + [2.749063, 7.870734], + [2.691702, 6.258817] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Burkina Faso", + "SOV_A3": "BFA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Burkina Faso", + "ADM0_A3": "BFA", + "GEOU_DIF": 0, + "GEOUNIT": "Burkina Faso", + "GU_A3": "BFA", + "SU_DIF": 0, + "SUBUNIT": "Burkina Faso", + "SU_A3": "BFA", + "BRK_DIFF": 0, + "NAME": "Burkina Faso", + "NAME_LONG": "Burkina Faso", + "BRK_A3": "BFA", + "BRK_NAME": "Burkina Faso", + "BRK_GROUP": null, + "ABBREV": "B.F.", + "POSTAL": "BF", + "FORMAL_EN": "Burkina Faso", + "FORMAL_FR": null, + "NAME_CIAWF": "Burkina Faso", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Burkina Faso", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 1, + "MAPCOLOR9": 5, + "MAPCOLOR13": 11, + "POP_EST": 20107509, + "POP_RANK": 15, + "GDP_MD_EST": 32990, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "UV", + "ISO_A2": "BF", + "ISO_A3": "BFA", + "ISO_A3_EH": "BFA", + "ISO_N3": "854", + "UN_A3": "854", + "WB_A2": "BF", + "WB_A3": "BFA", + "WOE_ID": 23424978, + "WOE_ID_EH": 23424978, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BFA", + "ADM0_A3_US": "BFA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 12, + "LONG_LEN": 12, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-5.470565, 9.610835, 2.177108, 15.116158], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [2.154474, 11.94015], + [1.935986, 11.64115], + [1.447178, 11.547719], + [1.24347, 11.110511], + [0.899563, 10.997339], + [0.023803, 11.018682], + [-0.438702, 11.098341], + [-0.761576, 10.93693], + [-1.203358, 11.009819], + [-2.940409, 10.96269], + [-2.963896, 10.395335], + [-2.827496, 9.642461], + [-3.511899, 9.900326], + [-3.980449, 9.862344], + [-4.330247, 9.610835], + [-4.779884, 9.821985], + [-4.954653, 10.152714], + [-5.404342, 10.370737], + [-5.470565, 10.95127], + [-5.197843, 11.375146], + [-5.220942, 11.713859], + [-4.427166, 12.542646], + [-4.280405, 13.228444], + [-4.006391, 13.472485], + [-3.522803, 13.337662], + [-3.103707, 13.541267], + [-2.967694, 13.79815], + [-2.191825, 14.246418], + [-2.001035, 14.559008], + [-1.066363, 14.973815], + [-0.515854, 15.116158], + [-0.266257, 14.924309], + [0.374892, 14.928908], + [0.295646, 14.444235], + [0.429928, 13.988733], + [0.993046, 13.33575], + [1.024103, 12.851826], + [2.177108, 12.625018], + [2.154474, 11.94015] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Bangladesh", + "SOV_A3": "BGD", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Bangladesh", + "ADM0_A3": "BGD", + "GEOU_DIF": 0, + "GEOUNIT": "Bangladesh", + "GU_A3": "BGD", + "SU_DIF": 0, + "SUBUNIT": "Bangladesh", + "SU_A3": "BGD", + "BRK_DIFF": 0, + "NAME": "Bangladesh", + "NAME_LONG": "Bangladesh", + "BRK_A3": "BGD", + "BRK_NAME": "Bangladesh", + "BRK_GROUP": null, + "ABBREV": "Bang.", + "POSTAL": "BD", + "FORMAL_EN": "People's Republic of Bangladesh", + "FORMAL_FR": null, + "NAME_CIAWF": "Bangladesh", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Bangladesh", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 4, + "MAPCOLOR9": 7, + "MAPCOLOR13": 7, + "POP_EST": 157826578, + "POP_RANK": 17, + "GDP_MD_EST": 628400, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "BG", + "ISO_A2": "BD", + "ISO_A3": "BGD", + "ISO_A3_EH": "BGD", + "ISO_N3": "050", + "UN_A3": "050", + "WB_A2": "BD", + "WB_A3": "BGD", + "WOE_ID": 23424759, + "WOE_ID_EH": 23424759, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BGD", + "ADM0_A3_US": "BGD", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [88.084422, 20.670883, 92.672721, 26.446526], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [92.672721, 22.041239], + [92.652257, 21.324048], + [92.303234, 21.475485], + [92.368554, 20.670883], + [92.082886, 21.192195], + [92.025215, 21.70157], + [91.834891, 22.182936], + [91.417087, 22.765019], + [90.496006, 22.805017], + [90.586957, 22.392794], + [90.272971, 21.836368], + [89.847467, 22.039146], + [89.70205, 21.857116], + [89.418863, 21.966179], + [89.031961, 22.055708], + [88.876312, 22.879146], + [88.52977, 23.631142], + [88.69994, 24.233715], + [88.084422, 24.501657], + [88.306373, 24.866079], + [88.931554, 25.238692], + [88.209789, 25.768066], + [88.563049, 26.446526], + [89.355094, 26.014407], + [89.832481, 25.965082], + [89.920693, 25.26975], + [90.872211, 25.132601], + [91.799596, 25.147432], + [92.376202, 24.976693], + [91.915093, 24.130414], + [91.46773, 24.072639], + [91.158963, 23.503527], + [91.706475, 22.985264], + [91.869928, 23.624346], + [92.146035, 23.627499], + [92.672721, 22.041239] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Bulgaria", + "SOV_A3": "BGR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Bulgaria", + "ADM0_A3": "BGR", + "GEOU_DIF": 0, + "GEOUNIT": "Bulgaria", + "GU_A3": "BGR", + "SU_DIF": 0, + "SUBUNIT": "Bulgaria", + "SU_A3": "BGR", + "BRK_DIFF": 0, + "NAME": "Bulgaria", + "NAME_LONG": "Bulgaria", + "BRK_A3": "BGR", + "BRK_NAME": "Bulgaria", + "BRK_GROUP": null, + "ABBREV": "Bulg.", + "POSTAL": "BG", + "FORMAL_EN": "Republic of Bulgaria", + "FORMAL_FR": null, + "NAME_CIAWF": "Bulgaria", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Bulgaria", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 5, + "MAPCOLOR9": 1, + "MAPCOLOR13": 8, + "POP_EST": 7101510, + "POP_RANK": 13, + "GDP_MD_EST": 143100, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BU", + "ISO_A2": "BG", + "ISO_A3": "BGR", + "ISO_A3_EH": "BGR", + "ISO_N3": "100", + "UN_A3": "100", + "WB_A2": "BG", + "WB_A3": "BGR", + "WOE_ID": 23424771, + "WOE_ID_EH": 23424771, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BGR", + "ADM0_A3_US": "BGR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [22.380526, 41.234486, 28.558081, 44.234923], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [22.65715, 44.234923], + [22.944832, 43.823785], + [23.332302, 43.897011], + [24.100679, 43.741051], + [25.569272, 43.688445], + [26.065159, 43.943494], + [27.2424, 44.175986], + [27.970107, 43.812468], + [28.558081, 43.707462], + [28.039095, 43.293172], + [27.673898, 42.577892], + [27.99672, 42.007359], + [27.135739, 42.141485], + [26.117042, 41.826905], + [26.106138, 41.328899], + [25.197201, 41.234486], + [24.492645, 41.583896], + [23.692074, 41.309081], + [22.952377, 41.337994], + [22.881374, 41.999297], + [22.380526, 42.32026], + [22.545012, 42.461362], + [22.436595, 42.580321], + [22.604801, 42.898519], + [22.986019, 43.211161], + [22.500157, 43.642814], + [22.410446, 44.008063], + [22.65715, 44.234923] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "The Bahamas", + "SOV_A3": "BHS", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "The Bahamas", + "ADM0_A3": "BHS", + "GEOU_DIF": 0, + "GEOUNIT": "The Bahamas", + "GU_A3": "BHS", + "SU_DIF": 0, + "SUBUNIT": "The Bahamas", + "SU_A3": "BHS", + "BRK_DIFF": 0, + "NAME": "Bahamas", + "NAME_LONG": "Bahamas", + "BRK_A3": "BHS", + "BRK_NAME": "Bahamas", + "BRK_GROUP": null, + "ABBREV": "Bhs.", + "POSTAL": "BS", + "FORMAL_EN": "Commonwealth of the Bahamas", + "FORMAL_FR": null, + "NAME_CIAWF": "Bahamas, The", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Bahamas, The", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 1, + "MAPCOLOR9": 2, + "MAPCOLOR13": 5, + "POP_EST": 329988, + "POP_RANK": 10, + "GDP_MD_EST": 9066, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "BF", + "ISO_A2": "BS", + "ISO_A3": "BHS", + "ISO_A3_EH": "BHS", + "ISO_N3": "044", + "UN_A3": "044", + "WB_A2": "BS", + "WB_A3": "BHS", + "WOE_ID": 23424758, + "WOE_ID_EH": 23424758, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BHS", + "ADM0_A3_US": "BHS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-78.98, 23.71, -77, 27.04], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-77.53466, 23.75975], + [-77.78, 23.71], + [-78.03405, 24.28615], + [-78.40848, 24.57564], + [-78.19087, 25.2103], + [-77.89, 25.17], + [-77.54, 24.34], + [-77.53466, 23.75975] + ] + ], + [ + [ + [-77.82, 26.58], + [-78.91, 26.42], + [-78.98, 26.79], + [-78.51, 26.87], + [-77.85, 26.84], + [-77.82, 26.58] + ] + ], + [ + [ + [-77, 26.59], + [-77.17255, 25.87918], + [-77.35641, 26.00735], + [-77.34, 26.53], + [-77.78802, 26.92516], + [-77.79, 27.04], + [-77, 26.59] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Bosnia and Herzegovina", + "SOV_A3": "BIH", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Bosnia and Herzegovina", + "ADM0_A3": "BIH", + "GEOU_DIF": 0, + "GEOUNIT": "Bosnia and Herzegovina", + "GU_A3": "BIH", + "SU_DIF": 0, + "SUBUNIT": "Bosnia and Herzegovina", + "SU_A3": "BIH", + "BRK_DIFF": 0, + "NAME": "Bosnia and Herz.", + "NAME_LONG": "Bosnia and Herzegovina", + "BRK_A3": "BIH", + "BRK_NAME": "Bosnia and Herz.", + "BRK_GROUP": null, + "ABBREV": "B.H.", + "POSTAL": "BiH", + "FORMAL_EN": "Bosnia and Herzegovina", + "FORMAL_FR": null, + "NAME_CIAWF": "Bosnia and Herzegovina", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Bosnia and Herzegovina", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 1, + "MAPCOLOR9": 1, + "MAPCOLOR13": 2, + "POP_EST": 3856181, + "POP_RANK": 12, + "GDP_MD_EST": 42530, + "POP_YEAR": 2017, + "LASTCENSUS": 1991, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BK", + "ISO_A2": "BA", + "ISO_A3": "BIH", + "ISO_A3_EH": "BIH", + "ISO_N3": "070", + "UN_A3": "070", + "WB_A2": "BA", + "WB_A3": "BIH", + "WOE_ID": 23424761, + "WOE_ID_EH": 23424761, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BIH", + "ADM0_A3_US": "BIH", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 16, + "LONG_LEN": 22, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9.5 + }, + "bbox": [15.750026, 42.65, 19.59976, 45.233777], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [19.36803, 44.863], + [19.11761, 44.42307], + [19.59976, 44.03847], + [19.454, 43.5681], + [19.21852, 43.52384], + [19.03165, 43.43253], + [18.70648, 43.20011], + [18.56, 42.65], + [17.674922, 43.028563], + [17.297373, 43.446341], + [16.916156, 43.667722], + [16.456443, 44.04124], + [16.23966, 44.351143], + [15.750026, 44.818712], + [15.959367, 45.233777], + [16.318157, 45.004127], + [16.534939, 45.211608], + [17.002146, 45.233777], + [17.861783, 45.06774], + [18.553214, 45.08159], + [19.005485, 44.860234], + [19.00548, 44.86023], + [19.36803, 44.863] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Belarus", + "SOV_A3": "BLR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Belarus", + "ADM0_A3": "BLR", + "GEOU_DIF": 0, + "GEOUNIT": "Belarus", + "GU_A3": "BLR", + "SU_DIF": 0, + "SUBUNIT": "Belarus", + "SU_A3": "BLR", + "BRK_DIFF": 0, + "NAME": "Belarus", + "NAME_LONG": "Belarus", + "BRK_A3": "BLR", + "BRK_NAME": "Belarus", + "BRK_GROUP": null, + "ABBREV": "Bela.", + "POSTAL": "BY", + "FORMAL_EN": "Republic of Belarus", + "FORMAL_FR": null, + "NAME_CIAWF": "Belarus", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Belarus", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 1, + "MAPCOLOR9": 5, + "MAPCOLOR13": 11, + "POP_EST": 9549747, + "POP_RANK": 13, + "GDP_MD_EST": 165400, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BO", + "ISO_A2": "BY", + "ISO_A3": "BLR", + "ISO_A3_EH": "BLR", + "ISO_N3": "112", + "UN_A3": "112", + "WB_A2": "BY", + "WB_A3": "BLR", + "WOE_ID": 23424765, + "WOE_ID_EH": 23424765, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BLR", + "ADM0_A3_US": "BLR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [23.199494, 51.319503, 32.693643, 56.16913], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.484128, 53.912498], + [24.450684, 53.905702], + [25.536354, 54.282423], + [25.768433, 54.846963], + [26.588279, 55.167176], + [26.494331, 55.615107], + [27.10246, 55.783314], + [28.176709, 56.16913], + [29.229513, 55.918344], + [29.371572, 55.670091], + [29.896294, 55.789463], + [30.873909, 55.550976], + [30.971836, 55.081548], + [30.757534, 54.811771], + [31.384472, 54.157056], + [31.791424, 53.974639], + [31.731273, 53.794029], + [32.405599, 53.618045], + [32.693643, 53.351421], + [32.304519, 53.132726], + [31.49764, 53.16743], + [31.305201, 53.073996], + [31.540018, 52.742052], + [31.78597, 52.10168], + [31.785992, 52.101678], + [30.927549, 52.042353], + [30.619454, 51.822806], + [30.555117, 51.319503], + [30.157364, 51.416138], + [29.254938, 51.368234], + [28.992835, 51.602044], + [28.617613, 51.427714], + [28.241615, 51.572227], + [27.454066, 51.592303], + [26.337959, 51.832289], + [25.327788, 51.910656], + [24.553106, 51.888461], + [24.005078, 51.617444], + [23.527071, 51.578454], + [23.508002, 52.023647], + [23.199494, 52.486977], + [23.799199, 52.691099], + [23.804935, 53.089731], + [23.527536, 53.470122], + [23.484128, 53.912498] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Belize", + "SOV_A3": "BLZ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Belize", + "ADM0_A3": "BLZ", + "GEOU_DIF": 0, + "GEOUNIT": "Belize", + "GU_A3": "BLZ", + "SU_DIF": 0, + "SUBUNIT": "Belize", + "SU_A3": "BLZ", + "BRK_DIFF": 0, + "NAME": "Belize", + "NAME_LONG": "Belize", + "BRK_A3": "BLZ", + "BRK_NAME": "Belize", + "BRK_GROUP": null, + "ABBREV": "Belize", + "POSTAL": "BZ", + "FORMAL_EN": "Belize", + "FORMAL_FR": null, + "NAME_CIAWF": "Belize", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Belize", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 5, + "MAPCOLOR13": 7, + "POP_EST": 360346, + "POP_RANK": 10, + "GDP_MD_EST": 3088, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BH", + "ISO_A2": "BZ", + "ISO_A3": "BLZ", + "ISO_A3_EH": "BLZ", + "ISO_N3": "084", + "UN_A3": "084", + "WB_A2": "BZ", + "WB_A3": "BLZ", + "WOE_ID": 23424760, + "WOE_ID_EH": 23424760, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BLZ", + "ADM0_A3_US": "BLZ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [-89.229122, 15.886938, -88.106813, 18.499982], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-89.14308, 17.808319], + [-89.150909, 17.955468], + [-89.029857, 18.001511], + [-88.848344, 17.883198], + [-88.490123, 18.486831], + [-88.300031, 18.499982], + [-88.296336, 18.353273], + [-88.106813, 18.348674], + [-88.123479, 18.076675], + [-88.285355, 17.644143], + [-88.197867, 17.489475], + [-88.302641, 17.131694], + [-88.239518, 17.036066], + [-88.355428, 16.530774], + [-88.551825, 16.265467], + [-88.732434, 16.233635], + [-88.930613, 15.887273], + [-89.229122, 15.886938], + [-89.150806, 17.015577], + [-89.14308, 17.808319] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Bolivia", + "SOV_A3": "BOL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Bolivia", + "ADM0_A3": "BOL", + "GEOU_DIF": 0, + "GEOUNIT": "Bolivia", + "GU_A3": "BOL", + "SU_DIF": 0, + "SUBUNIT": "Bolivia", + "SU_A3": "BOL", + "BRK_DIFF": 0, + "NAME": "Bolivia", + "NAME_LONG": "Bolivia", + "BRK_A3": "BOL", + "BRK_NAME": "Bolivia", + "BRK_GROUP": null, + "ABBREV": "Bolivia", + "POSTAL": "BO", + "FORMAL_EN": "Plurinational State of Bolivia", + "FORMAL_FR": null, + "NAME_CIAWF": "Bolivia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Bolivia", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 5, + "MAPCOLOR9": 2, + "MAPCOLOR13": 3, + "POP_EST": 11138234, + "POP_RANK": 14, + "GDP_MD_EST": 78350, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BL", + "ISO_A2": "BO", + "ISO_A3": "BOL", + "ISO_A3_EH": "BOL", + "ISO_N3": "068", + "UN_A3": "068", + "WB_A2": "BO", + "WB_A3": "BOL", + "WOE_ID": 23424762, + "WOE_ID_EH": 23424762, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BOL", + "ADM0_A3_US": "BOL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 7, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7.5 + }, + "bbox": [-69.590424, -22.872919, -57.498371, -9.761988], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-62.685057, -22.249029], + [-62.846468, -22.034985], + [-63.986838, -21.993644], + [-64.377021, -22.798091], + [-64.964892, -22.075862], + [-66.273339, -21.83231], + [-67.106674, -22.735925], + [-67.82818, -22.872919], + [-68.219913, -21.494347], + [-68.757167, -20.372658], + [-68.442225, -19.405068], + [-68.966818, -18.981683], + [-69.100247, -18.260125], + [-69.590424, -17.580012], + [-68.959635, -16.500698], + [-69.389764, -15.660129], + [-69.160347, -15.323974], + [-69.339535, -14.953195], + [-68.948887, -14.453639], + [-68.929224, -13.602684], + [-68.88008, -12.899729], + [-68.66508, -12.5613], + [-69.529678, -10.951734], + [-68.786158, -11.03638], + [-68.271254, -11.014521], + [-68.048192, -10.712059], + [-67.173801, -10.306812], + [-66.646908, -9.931331], + [-65.338435, -9.761988], + [-65.444837, -10.511451], + [-65.321899, -10.895872], + [-65.402281, -11.56627], + [-64.316353, -12.461978], + [-63.196499, -12.627033], + [-62.80306, -13.000653], + [-62.127081, -13.198781], + [-61.713204, -13.489202], + [-61.084121, -13.479384], + [-60.503304, -13.775955], + [-60.459198, -14.354007], + [-60.264326, -14.645979], + [-60.251149, -15.077219], + [-60.542966, -15.09391], + [-60.15839, -16.258284], + [-58.24122, -16.299573], + [-58.388058, -16.877109], + [-58.280804, -17.27171], + [-57.734558, -17.552468], + [-57.498371, -18.174188], + [-57.676009, -18.96184], + [-57.949997, -19.400004], + [-57.853802, -19.969995], + [-58.166392, -20.176701], + [-58.183471, -19.868399], + [-59.115042, -19.356906], + [-60.043565, -19.342747], + [-61.786326, -19.633737], + [-62.265961, -20.513735], + [-62.291179, -21.051635], + [-62.685057, -22.249029] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Brazil", + "SOV_A3": "BRA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Brazil", + "ADM0_A3": "BRA", + "GEOU_DIF": 0, + "GEOUNIT": "Brazil", + "GU_A3": "BRA", + "SU_DIF": 0, + "SUBUNIT": "Brazil", + "SU_A3": "BRA", + "BRK_DIFF": 0, + "NAME": "Brazil", + "NAME_LONG": "Brazil", + "BRK_A3": "BRA", + "BRK_NAME": "Brazil", + "BRK_GROUP": null, + "ABBREV": "Brazil", + "POSTAL": "BR", + "FORMAL_EN": "Federative Republic of Brazil", + "FORMAL_FR": null, + "NAME_CIAWF": "Brazil", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Brazil", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 6, + "MAPCOLOR9": 5, + "MAPCOLOR13": 7, + "POP_EST": 207353391, + "POP_RANK": 17, + "GDP_MD_EST": 3081000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "3. Emerging region: BRIC", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BR", + "ISO_A2": "BR", + "ISO_A3": "BRA", + "ISO_A3_EH": "BRA", + "ISO_N3": "076", + "UN_A3": "076", + "WB_A2": "BR", + "WB_A3": "BRA", + "WOE_ID": 23424768, + "WOE_ID_EH": 23424768, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BRA", + "ADM0_A3_US": "BRA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 5.7 + }, + "bbox": [-73.987235, -33.768378, -34.729993, 5.244486], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-57.625133, -30.216295], + [-56.2909, -28.852761], + [-55.162286, -27.881915], + [-54.490725, -27.474757], + [-53.648735, -26.923473], + [-53.628349, -26.124865], + [-54.13005, -25.547639], + [-54.625291, -25.739255], + [-54.428946, -25.162185], + [-54.293476, -24.5708], + [-54.29296, -24.021014], + [-54.652834, -23.839578], + [-55.027902, -24.001274], + [-55.400747, -23.956935], + [-55.517639, -23.571998], + [-55.610683, -22.655619], + [-55.797958, -22.35693], + [-56.473317, -22.0863], + [-56.88151, -22.282154], + [-57.937156, -22.090176], + [-57.870674, -20.732688], + [-58.166392, -20.176701], + [-57.853802, -19.969995], + [-57.949997, -19.400004], + [-57.676009, -18.96184], + [-57.498371, -18.174188], + [-57.734558, -17.552468], + [-58.280804, -17.27171], + [-58.388058, -16.877109], + [-58.24122, -16.299573], + [-60.15839, -16.258284], + [-60.542966, -15.09391], + [-60.251149, -15.077219], + [-60.264326, -14.645979], + [-60.459198, -14.354007], + [-60.503304, -13.775955], + [-61.084121, -13.479384], + [-61.713204, -13.489202], + [-62.127081, -13.198781], + [-62.80306, -13.000653], + [-63.196499, -12.627033], + [-64.316353, -12.461978], + [-65.402281, -11.56627], + [-65.321899, -10.895872], + [-65.444837, -10.511451], + [-65.338435, -9.761988], + [-66.646908, -9.931331], + [-67.173801, -10.306812], + [-68.048192, -10.712059], + [-68.271254, -11.014521], + [-68.786158, -11.03638], + [-69.529678, -10.951734], + [-70.093752, -11.123972], + [-70.548686, -11.009147], + [-70.481894, -9.490118], + [-71.302412, -10.079436], + [-72.184891, -10.053598], + [-72.563033, -9.520194], + [-73.226713, -9.462213], + [-73.015383, -9.032833], + [-73.571059, -8.424447], + [-73.987235, -7.52383], + [-73.723401, -7.340999], + [-73.724487, -6.918595], + [-73.120027, -6.629931], + [-73.219711, -6.089189], + [-72.964507, -5.741251], + [-72.891928, -5.274561], + [-71.748406, -4.593983], + [-70.928843, -4.401591], + [-70.794769, -4.251265], + [-69.893635, -4.298187], + [-69.444102, -1.556287], + [-69.420486, -1.122619], + [-69.577065, -0.549992], + [-70.020656, -0.185156], + [-70.015566, 0.541414], + [-69.452396, 0.706159], + [-69.252434, 0.602651], + [-69.218638, 0.985677], + [-69.804597, 1.089081], + [-69.816973, 1.714805], + [-67.868565, 1.692455], + [-67.53781, 2.037163], + [-67.259998, 1.719999], + [-67.065048, 1.130112], + [-66.876326, 1.253361], + [-66.325765, 0.724452], + [-65.548267, 0.789254], + [-65.354713, 1.095282], + [-64.611012, 1.328731], + [-64.199306, 1.492855], + [-64.083085, 1.916369], + [-63.368788, 2.2009], + [-63.422867, 2.411068], + [-64.269999, 2.497006], + [-64.408828, 3.126786], + [-64.368494, 3.79721], + [-64.816064, 4.056445], + [-64.628659, 4.148481], + [-63.888343, 4.02053], + [-63.093198, 3.770571], + [-62.804533, 4.006965], + [-62.08543, 4.162124], + [-60.966893, 4.536468], + [-60.601179, 4.918098], + [-60.733574, 5.200277], + [-60.213683, 5.244486], + [-59.980959, 5.014061], + [-60.111002, 4.574967], + [-59.767406, 4.423503], + [-59.53804, 3.958803], + [-59.815413, 3.606499], + [-59.974525, 2.755233], + [-59.718546, 2.24963], + [-59.646044, 1.786894], + [-59.030862, 1.317698], + [-58.540013, 1.268088], + [-58.429477, 1.463942], + [-58.11345, 1.507195], + [-57.660971, 1.682585], + [-57.335823, 1.948538], + [-56.782704, 1.863711], + [-56.539386, 1.899523], + [-55.995698, 1.817667], + [-55.9056, 2.021996], + [-56.073342, 2.220795], + [-55.973322, 2.510364], + [-55.569755, 2.421506], + [-55.097587, 2.523748], + [-54.524754, 2.311849], + [-54.088063, 2.105557], + [-53.778521, 2.376703], + [-53.554839, 2.334897], + [-53.418465, 2.053389], + [-52.939657, 2.124858], + [-52.556425, 2.504705], + [-52.249338, 3.241094], + [-51.657797, 4.156232], + [-51.317146, 4.203491], + [-51.069771, 3.650398], + [-50.508875, 1.901564], + [-49.974076, 1.736483], + [-49.947101, 1.04619], + [-50.699251, 0.222984], + [-50.388211, -0.078445], + [-48.620567, -0.235489], + [-48.584497, -1.237805], + [-47.824956, -0.581618], + [-46.566584, -0.941028], + [-44.905703, -1.55174], + [-44.417619, -2.13775], + [-44.581589, -2.691308], + [-43.418791, -2.38311], + [-41.472657, -2.912018], + [-39.978665, -2.873054], + [-38.500383, -3.700652], + [-37.223252, -4.820946], + [-36.452937, -5.109404], + [-35.597796, -5.149504], + [-35.235389, -5.464937], + [-34.89603, -6.738193], + [-34.729993, -7.343221], + [-35.128212, -8.996401], + [-35.636967, -9.649282], + [-37.046519, -11.040721], + [-37.683612, -12.171195], + [-38.423877, -13.038119], + [-38.673887, -13.057652], + [-38.953276, -13.79337], + [-38.882298, -15.667054], + [-39.161092, -17.208407], + [-39.267339, -17.867746], + [-39.583521, -18.262296], + [-39.760823, -19.599113], + [-40.774741, -20.904512], + [-40.944756, -21.937317], + [-41.754164, -22.370676], + [-41.988284, -22.97007], + [-43.074704, -22.967693], + [-44.647812, -23.351959], + [-45.352136, -23.796842], + [-46.472093, -24.088969], + [-47.648972, -24.885199], + [-48.495458, -25.877025], + [-48.641005, -26.623698], + [-48.474736, -27.175912], + [-48.66152, -28.186135], + [-48.888457, -28.674115], + [-49.587329, -29.224469], + [-50.696874, -30.984465], + [-51.576226, -31.777698], + [-52.256081, -32.24537], + [-52.7121, -33.196578], + [-53.373662, -33.768378], + [-53.650544, -33.202004], + [-53.209589, -32.727666], + [-53.787952, -32.047243], + [-54.572452, -31.494511], + [-55.60151, -30.853879], + [-55.973245, -30.883076], + [-56.976026, -30.109686], + [-57.625133, -30.216295] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Brunei", + "SOV_A3": "BRN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Brunei", + "ADM0_A3": "BRN", + "GEOU_DIF": 0, + "GEOUNIT": "Brunei", + "GU_A3": "BRN", + "SU_DIF": 0, + "SUBUNIT": "Brunei", + "SU_A3": "BRN", + "BRK_DIFF": 0, + "NAME": "Brunei", + "NAME_LONG": "Brunei Darussalam", + "BRK_A3": "BRN", + "BRK_NAME": "Brunei", + "BRK_GROUP": null, + "ABBREV": "Brunei", + "POSTAL": "BN", + "FORMAL_EN": "Negara Brunei Darussalam", + "FORMAL_FR": null, + "NAME_CIAWF": "Brunei", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Brunei", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 6, + "MAPCOLOR9": 6, + "MAPCOLOR13": 12, + "POP_EST": 443593, + "POP_RANK": 10, + "GDP_MD_EST": 33730, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "BX", + "ISO_A2": "BN", + "ISO_A3": "BRN", + "ISO_A3_EH": "BRN", + "ISO_N3": "096", + "UN_A3": "096", + "WB_A2": "BN", + "WB_A3": "BRN", + "WOE_ID": 23424773, + "WOE_ID_EH": 23424773, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BRN", + "ADM0_A3_US": "BRN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 6, + "LONG_LEN": 17, + "ABBREV_LEN": 6, + "TINY": 2, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [114.204017, 4.007637, 115.45071, 5.44773], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [114.204017, 4.525874], + [114.599961, 4.900011], + [115.45071, 5.44773], + [115.4057, 4.955228], + [115.347461, 4.316636], + [114.869557, 4.348314], + [114.659596, 4.007637], + [114.204017, 4.525874] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Bhutan", + "SOV_A3": "BTN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Bhutan", + "ADM0_A3": "BTN", + "GEOU_DIF": 0, + "GEOUNIT": "Bhutan", + "GU_A3": "BTN", + "SU_DIF": 0, + "SUBUNIT": "Bhutan", + "SU_A3": "BTN", + "BRK_DIFF": 0, + "NAME": "Bhutan", + "NAME_LONG": "Bhutan", + "BRK_A3": "BTN", + "BRK_NAME": "Bhutan", + "BRK_GROUP": null, + "ABBREV": "Bhutan", + "POSTAL": "BT", + "FORMAL_EN": "Kingdom of Bhutan", + "FORMAL_FR": null, + "NAME_CIAWF": "Bhutan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Bhutan", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 6, + "MAPCOLOR9": 1, + "MAPCOLOR13": 8, + "POP_EST": 758288, + "POP_RANK": 11, + "GDP_MD_EST": 6432, + "POP_YEAR": 2017, + "LASTCENSUS": 2005, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BT", + "ISO_A2": "BT", + "ISO_A3": "BTN", + "ISO_A3_EH": "BTN", + "ISO_N3": "064", + "UN_A3": "064", + "WB_A2": "BT", + "WB_A3": "BTN", + "WOE_ID": 23424770, + "WOE_ID_EH": 23424770, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BTN", + "ADM0_A3_US": "BTN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [88.814248, 26.719403, 92.103712, 28.296439], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [91.696657, 27.771742], + [92.103712, 27.452614], + [92.033484, 26.83831], + [91.217513, 26.808648], + [90.373275, 26.875724], + [89.744528, 26.719403], + [88.835643, 27.098966], + [88.814248, 27.299316], + [89.47581, 28.042759], + [90.015829, 28.296439], + [90.730514, 28.064954], + [91.258854, 28.040614], + [91.696657, 27.771742] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Botswana", + "SOV_A3": "BWA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Botswana", + "ADM0_A3": "BWA", + "GEOU_DIF": 0, + "GEOUNIT": "Botswana", + "GU_A3": "BWA", + "SU_DIF": 0, + "SUBUNIT": "Botswana", + "SU_A3": "BWA", + "BRK_DIFF": 0, + "NAME": "Botswana", + "NAME_LONG": "Botswana", + "BRK_A3": "BWA", + "BRK_NAME": "Botswana", + "BRK_GROUP": null, + "ABBREV": "Bwa.", + "POSTAL": "BW", + "FORMAL_EN": "Republic of Botswana", + "FORMAL_FR": null, + "NAME_CIAWF": "Botswana", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Botswana", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 5, + "MAPCOLOR9": 7, + "MAPCOLOR13": 3, + "POP_EST": 2214858, + "POP_RANK": 12, + "GDP_MD_EST": 35900, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BC", + "ISO_A2": "BW", + "ISO_A3": "BWA", + "ISO_A3_EH": "BWA", + "ISO_N3": "072", + "UN_A3": "072", + "WB_A2": "BW", + "WB_A3": "BWA", + "WOE_ID": 23424755, + "WOE_ID_EH": 23424755, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "BWA", + "ADM0_A3_US": "BWA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Southern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [19.895458, -26.828543, 29.432188, -17.661816], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [29.432188, -22.091313], + [28.017236, -22.827754], + [27.11941, -23.574323], + [26.786407, -24.240691], + [26.485753, -24.616327], + [25.941652, -24.696373], + [25.765849, -25.174845], + [25.664666, -25.486816], + [25.025171, -25.71967], + [24.211267, -25.670216], + [23.73357, -25.390129], + [23.312097, -25.26869], + [22.824271, -25.500459], + [22.579532, -25.979448], + [22.105969, -26.280256], + [21.605896, -26.726534], + [20.889609, -26.828543], + [20.66647, -26.477453], + [20.758609, -25.868136], + [20.165726, -24.917962], + [19.895768, -24.76779], + [19.895458, -21.849157], + [20.881134, -21.814327], + [20.910641, -18.252219], + [21.65504, -18.219146], + [23.196858, -17.869038], + [23.579006, -18.281261], + [24.217365, -17.889347], + [24.520705, -17.887125], + [25.084443, -17.661816], + [25.264226, -17.73654], + [25.649163, -18.536026], + [25.850391, -18.714413], + [26.164791, -19.293086], + [27.296505, -20.39152], + [27.724747, -20.499059], + [27.727228, -20.851802], + [28.02137, -21.485975], + [28.794656, -21.639454], + [29.432188, -22.091313] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Central African Republic", + "SOV_A3": "CAF", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Central African Republic", + "ADM0_A3": "CAF", + "GEOU_DIF": 0, + "GEOUNIT": "Central African Republic", + "GU_A3": "CAF", + "SU_DIF": 0, + "SUBUNIT": "Central African Republic", + "SU_A3": "CAF", + "BRK_DIFF": 0, + "NAME": "Central African Rep.", + "NAME_LONG": "Central African Republic", + "BRK_A3": "CAF", + "BRK_NAME": "Central African Rep.", + "BRK_GROUP": null, + "ABBREV": "C.A.R.", + "POSTAL": "CF", + "FORMAL_EN": "Central African Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Central African Republic", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Central African Republic", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 6, + "MAPCOLOR9": 6, + "MAPCOLOR13": 9, + "POP_EST": 5625118, + "POP_RANK": 13, + "GDP_MD_EST": 3206, + "POP_YEAR": 2017, + "LASTCENSUS": 2003, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "CT", + "ISO_A2": "CF", + "ISO_A3": "CAF", + "ISO_A3_EH": "CAF", + "ISO_N3": "140", + "UN_A3": "140", + "WB_A2": "CF", + "WB_A3": "CAF", + "WOE_ID": 23424792, + "WOE_ID_EH": 23424792, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CAF", + "ADM0_A3_US": "CAF", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 20, + "LONG_LEN": 24, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [14.459407, 2.26764, 27.374226, 11.142395], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [15.27946, 7.421925], + [16.106232, 7.497088], + [16.290562, 7.754307], + [16.456185, 7.734774], + [16.705988, 7.508328], + [17.96493, 7.890914], + [18.389555, 8.281304], + [18.911022, 8.630895], + [18.81201, 8.982915], + [19.094008, 9.074847], + [20.059685, 9.012706], + [21.000868, 9.475985], + [21.723822, 10.567056], + [22.231129, 10.971889], + [22.864165, 11.142395], + [22.977544, 10.714463], + [23.554304, 10.089255], + [23.55725, 9.681218], + [23.394779, 9.265068], + [23.459013, 8.954286], + [23.805813, 8.666319], + [24.567369, 8.229188], + [25.114932, 7.825104], + [25.124131, 7.500085], + [25.796648, 6.979316], + [26.213418, 6.546603], + [26.465909, 5.946717], + [27.213409, 5.550953], + [27.374226, 5.233944], + [27.044065, 5.127853], + [26.402761, 5.150875], + [25.650455, 5.256088], + [25.278798, 5.170408], + [25.128833, 4.927245], + [24.805029, 4.897247], + [24.410531, 5.108784], + [23.297214, 4.609693], + [22.84148, 4.710126], + [22.704124, 4.633051], + [22.405124, 4.02916], + [21.659123, 4.224342], + [20.927591, 4.322786], + [20.290679, 4.691678], + [19.467784, 5.031528], + [18.932312, 4.709506], + [18.542982, 4.201785], + [18.453065, 3.504386], + [17.8099, 3.560196], + [17.133042, 3.728197], + [16.537058, 3.198255], + [16.012852, 2.26764], + [15.907381, 2.557389], + [15.862732, 3.013537], + [15.405396, 3.335301], + [15.03622, 3.851367], + [14.950953, 4.210389], + [14.478372, 4.732605], + [14.558936, 5.030598], + [14.459407, 5.451761], + [14.53656, 6.226959], + [14.776545, 6.408498], + [15.27946, 7.421925] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Canada", + "SOV_A3": "CAN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Canada", + "ADM0_A3": "CAN", + "GEOU_DIF": 0, + "GEOUNIT": "Canada", + "GU_A3": "CAN", + "SU_DIF": 0, + "SUBUNIT": "Canada", + "SU_A3": "CAN", + "BRK_DIFF": 0, + "NAME": "Canada", + "NAME_LONG": "Canada", + "BRK_A3": "CAN", + "BRK_NAME": "Canada", + "BRK_GROUP": null, + "ABBREV": "Can.", + "POSTAL": "CA", + "FORMAL_EN": "Canada", + "FORMAL_FR": null, + "NAME_CIAWF": "Canada", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Canada", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 2, + "POP_EST": 35623680, + "POP_RANK": 15, + "GDP_MD_EST": 1674000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "CA", + "ISO_A2": "CA", + "ISO_A3": "CAN", + "ISO_A3_EH": "CAN", + "ISO_N3": "124", + "UN_A3": "124", + "WB_A2": "CA", + "WB_A3": "CAN", + "WOE_ID": 23424775, + "WOE_ID_EH": 23424775, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CAN", + "ADM0_A3_US": "CAN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Northern America", + "REGION_WB": "North America", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 5.7 + }, + "bbox": [-140.99778, 41.675105, -52.648099, 83.23324], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-63.6645, 46.55001], + [-62.9393, 46.41587], + [-62.01208, 46.44314], + [-62.50391, 46.03339], + [-62.87433, 45.96818], + [-64.1428, 46.39265], + [-64.39261, 46.72747], + [-64.01486, 47.03601], + [-63.6645, 46.55001] + ] + ], + [ + [ + [-61.806305, 49.10506], + [-62.29318, 49.08717], + [-63.58926, 49.40069], + [-64.51912, 49.87304], + [-64.17322, 49.95718], + [-62.85829, 49.70641], + [-61.835585, 49.28855], + [-61.806305, 49.10506] + ] + ], + [ + [ + [-123.510002, 48.510011], + [-124.012891, 48.370846], + [-125.655013, 48.825005], + [-125.954994, 49.179996], + [-126.850004, 49.53], + [-127.029993, 49.814996], + [-128.059336, 49.994959], + [-128.444584, 50.539138], + [-128.358414, 50.770648], + [-127.308581, 50.552574], + [-126.695001, 50.400903], + [-125.755007, 50.295018], + [-125.415002, 49.950001], + [-124.920768, 49.475275], + [-123.922509, 49.062484], + [-123.510002, 48.510011] + ] + ], + [ + [ + [-56.134036, 50.68701], + [-56.795882, 49.812309], + [-56.143105, 50.150117], + [-55.471492, 49.935815], + [-55.822401, 49.587129], + [-54.935143, 49.313011], + [-54.473775, 49.556691], + [-53.476549, 49.249139], + [-53.786014, 48.516781], + [-53.086134, 48.687804], + [-52.958648, 48.157164], + [-52.648099, 47.535548], + [-53.069158, 46.655499], + [-53.521456, 46.618292], + [-54.178936, 46.807066], + [-53.961869, 47.625207], + [-54.240482, 47.752279], + [-55.400773, 46.884994], + [-55.997481, 46.91972], + [-55.291219, 47.389562], + [-56.250799, 47.632545], + [-57.325229, 47.572807], + [-59.266015, 47.603348], + [-59.419494, 47.899454], + [-58.796586, 48.251525], + [-59.231625, 48.523188], + [-58.391805, 49.125581], + [-57.35869, 50.718274], + [-56.73865, 51.287438], + [-55.870977, 51.632094], + [-55.406974, 51.588273], + [-55.600218, 51.317075], + [-56.134036, 50.68701] + ] + ], + [ + [ + [-133.180004, 54.169975], + [-132.710008, 54.040009], + [-131.74999, 54.120004], + [-132.04948, 52.984621], + [-131.179043, 52.180433], + [-131.57783, 52.182371], + [-132.180428, 52.639707], + [-132.549992, 53.100015], + [-133.054611, 53.411469], + [-133.239664, 53.85108], + [-133.180004, 54.169975] + ] + ], + [ + [ + [-79.26582, 62.158675], + [-79.65752, 61.63308], + [-80.09956, 61.7181], + [-80.36215, 62.01649], + [-80.315395, 62.085565], + [-79.92939, 62.3856], + [-79.52002, 62.36371], + [-79.26582, 62.158675] + ] + ], + [ + [ + [-81.89825, 62.7108], + [-83.06857, 62.15922], + [-83.77462, 62.18231], + [-83.99367, 62.4528], + [-83.25048, 62.91409], + [-81.87699, 62.90458], + [-81.89825, 62.7108] + ] + ], + [ + [ + [-85.161308, 65.657285], + [-84.975764, 65.217518], + [-84.464012, 65.371772], + [-83.882626, 65.109618], + [-82.787577, 64.766693], + [-81.642014, 64.455136], + [-81.55344, 63.979609], + [-80.817361, 64.057486], + [-80.103451, 63.725981], + [-80.99102, 63.411246], + [-82.547178, 63.651722], + [-83.108798, 64.101876], + [-84.100417, 63.569712], + [-85.523405, 63.052379], + [-85.866769, 63.637253], + [-87.221983, 63.541238], + [-86.35276, 64.035833], + [-86.224886, 64.822917], + [-85.883848, 65.738778], + [-85.161308, 65.657285] + ] + ], + [ + [ + [-75.86588, 67.14886], + [-76.98687, 67.09873], + [-77.2364, 67.58809], + [-76.81166, 68.14856], + [-75.89521, 68.28721], + [-75.1145, 68.01036], + [-75.10333, 67.58202], + [-75.21597, 67.44425], + [-75.86588, 67.14886] + ] + ], + [ + [ + [-95.647681, 69.10769], + [-96.269521, 68.75704], + [-97.617401, 69.06003], + [-98.431801, 68.9507], + [-99.797401, 69.40003], + [-98.917401, 69.71003], + [-98.218261, 70.14354], + [-97.157401, 69.86003], + [-96.557401, 69.68003], + [-96.257401, 69.49003], + [-95.647681, 69.10769] + ] + ], + [ + [ + [-68.23444, 47.35486], + [-68.905, 47.185], + [-69.237216, 47.447781], + [-69.99997, 46.69307], + [-70.305, 45.915], + [-70.66, 45.46], + [-71.08482, 45.30524], + [-71.405, 45.255], + [-71.50506, 45.0082], + [-73.34783, 45.00738], + [-74.867, 45.00048], + [-75.31821, 44.81645], + [-76.375, 44.09631], + [-76.5, 44.018459], + [-76.820034, 43.628784], + [-77.737885, 43.629056], + [-78.72028, 43.625089], + [-79.171674, 43.466339], + [-79.01, 43.27], + [-78.92, 42.965], + [-78.939362, 42.863611], + [-80.247448, 42.3662], + [-81.277747, 42.209026], + [-82.439278, 41.675105], + [-82.690089, 41.675105], + [-83.02981, 41.832796], + [-83.142, 41.975681], + [-83.12, 42.08], + [-82.9, 42.43], + [-82.43, 42.98], + [-82.137642, 43.571088], + [-82.337763, 44.44], + [-82.550925, 45.347517], + [-83.592851, 45.816894], + [-83.469551, 45.994686], + [-83.616131, 46.116927], + [-83.890765, 46.116927], + [-84.091851, 46.275419], + [-84.14212, 46.512226], + [-84.3367, 46.40877], + [-84.6049, 46.4396], + [-84.543749, 46.538684], + [-84.779238, 46.637102], + [-84.87608, 46.900083], + [-85.652363, 47.220219], + [-86.461991, 47.553338], + [-87.439793, 47.94], + [-88.378114, 48.302918], + [-89.272917, 48.019808], + [-89.6, 48.01], + [-90.83, 48.27], + [-91.64, 48.14], + [-92.61, 48.45], + [-93.63087, 48.60926], + [-94.32914, 48.67074], + [-94.64, 48.84], + [-94.81758, 49.38905], + [-95.15609, 49.38425], + [-95.15907, 49], + [-97.22872, 49.0007], + [-100.65, 49], + [-104.04826, 48.99986], + [-107.05, 49], + [-110.05, 49], + [-113, 49], + [-116.04818, 49], + [-117.03121, 49], + [-120, 49], + [-122.84, 49], + [-122.97421, 49.002538], + [-124.91024, 49.98456], + [-125.62461, 50.41656], + [-127.43561, 50.83061], + [-127.99276, 51.71583], + [-127.85032, 52.32961], + [-129.12979, 52.75538], + [-129.30523, 53.56159], + [-130.51497, 54.28757], + [-130.536109, 54.802754], + [-130.53611, 54.80278], + [-129.98, 55.285], + [-130.00778, 55.91583], + [-131.70781, 56.55212], + [-132.73042, 57.69289], + [-133.35556, 58.41028], + [-134.27111, 58.86111], + [-134.945, 59.27056], + [-135.47583, 59.78778], + [-136.47972, 59.46389], + [-137.4525, 58.905], + [-138.34089, 59.56211], + [-139.039, 60], + [-140.013, 60.27682], + [-140.99778, 60.30639], + [-140.9925, 66.00003], + [-140.986, 69.712], + [-140.985988, 69.711998], + [-139.12052, 69.47102], + [-137.54636, 68.99002], + [-136.50358, 68.89804], + [-135.62576, 69.31512], + [-134.41464, 69.62743], + [-132.92925, 69.50534], + [-131.43136, 69.94451], + [-129.79471, 70.19369], + [-129.10773, 69.77927], + [-128.36156, 70.01286], + [-128.13817, 70.48384], + [-127.44712, 70.37721], + [-125.75632, 69.48058], + [-124.42483, 70.1584], + [-124.28968, 69.39969], + [-123.06108, 69.56372], + [-122.6835, 69.85553], + [-121.47226, 69.79778], + [-119.94288, 69.37786], + [-117.60268, 69.01128], + [-116.22643, 68.84151], + [-115.2469, 68.90591], + [-113.89794, 68.3989], + [-115.30489, 67.90261], + [-113.49727, 67.68815], + [-110.798, 67.80612], + [-109.94619, 67.98104], + [-108.8802, 67.38144], + [-107.79239, 67.88736], + [-108.81299, 68.31164], + [-108.16721, 68.65392], + [-106.95, 68.7], + [-106.15, 68.8], + [-105.34282, 68.56122], + [-104.33791, 68.018], + [-103.22115, 68.09775], + [-101.45433, 67.64689], + [-99.90195, 67.80566], + [-98.4432, 67.78165], + [-98.5586, 68.40394], + [-97.66948, 68.57864], + [-96.11991, 68.23939], + [-96.12588, 67.29338], + [-95.48943, 68.0907], + [-94.685, 68.06383], + [-94.23282, 69.06903], + [-95.30408, 69.68571], + [-96.47131, 70.08976], + [-96.39115, 71.19482], + [-95.2088, 71.92053], + [-93.88997, 71.76015], + [-92.87818, 71.31869], + [-91.51964, 70.19129], + [-92.40692, 69.69997], + [-90.5471, 69.49766], + [-90.55151, 68.47499], + [-89.21515, 69.25873], + [-88.01966, 68.61508], + [-88.31749, 67.87338], + [-87.35017, 67.19872], + [-86.30607, 67.92146], + [-85.57664, 68.78456], + [-85.52197, 69.88211], + [-84.10081, 69.80539], + [-82.62258, 69.65826], + [-81.28043, 69.16202], + [-81.2202, 68.66567], + [-81.96436, 68.13253], + [-81.25928, 67.59716], + [-81.38653, 67.11078], + [-83.34456, 66.41154], + [-84.73542, 66.2573], + [-85.76943, 66.55833], + [-86.0676, 66.05625], + [-87.03143, 65.21297], + [-87.32324, 64.77563], + [-88.48296, 64.09897], + [-89.91444, 64.03273], + [-90.70398, 63.61017], + [-90.77004, 62.96021], + [-91.93342, 62.83508], + [-93.15698, 62.02469], + [-94.24153, 60.89865], + [-94.62931, 60.11021], + [-94.6846, 58.94882], + [-93.21502, 58.78212], + [-92.76462, 57.84571], + [-92.29703, 57.08709], + [-90.89769, 57.28468], + [-89.03953, 56.85172], + [-88.03978, 56.47162], + [-87.32421, 55.99914], + [-86.07121, 55.72383], + [-85.01181, 55.3026], + [-83.36055, 55.24489], + [-82.27285, 55.14832], + [-82.4362, 54.28227], + [-82.12502, 53.27703], + [-81.40075, 52.15788], + [-79.91289, 51.20842], + [-79.14301, 51.53393], + [-78.60191, 52.56208], + [-79.12421, 54.14145], + [-79.82958, 54.66772], + [-78.22874, 55.13645], + [-77.0956, 55.83741], + [-76.54137, 56.53423], + [-76.62319, 57.20263], + [-77.30226, 58.05209], + [-78.51688, 58.80458], + [-77.33676, 59.85261], + [-77.77272, 60.75788], + [-78.10687, 62.31964], + [-77.41067, 62.55053], + [-75.69621, 62.2784], + [-74.6682, 62.18111], + [-73.83988, 62.4438], + [-72.90853, 62.10507], + [-71.67708, 61.52535], + [-71.37369, 61.13717], + [-69.59042, 61.06141], + [-69.62033, 60.22125], + [-69.2879, 58.95736], + [-68.37455, 58.80106], + [-67.64976, 58.21206], + [-66.20178, 58.76731], + [-65.24517, 59.87071], + [-64.58352, 60.33558], + [-63.80475, 59.4426], + [-62.50236, 58.16708], + [-61.39655, 56.96745], + [-61.79866, 56.33945], + [-60.46853, 55.77548], + [-59.56962, 55.20407], + [-57.97508, 54.94549], + [-57.3332, 54.6265], + [-56.93689, 53.78032], + [-56.15811, 53.64749], + [-55.75632, 53.27036], + [-55.68338, 52.14664], + [-56.40916, 51.7707], + [-57.12691, 51.41972], + [-58.77482, 51.0643], + [-60.03309, 50.24277], + [-61.72366, 50.08046], + [-63.86251, 50.29099], + [-65.36331, 50.2982], + [-66.39905, 50.22897], + [-67.23631, 49.51156], + [-68.51114, 49.06836], + [-69.95362, 47.74488], + [-71.10458, 46.82171], + [-70.25522, 46.98606], + [-68.65, 48.3], + [-66.55243, 49.1331], + [-65.05626, 49.23278], + [-64.17099, 48.74248], + [-65.11545, 48.07085], + [-64.79854, 46.99297], + [-64.47219, 46.23849], + [-63.17329, 45.73902], + [-61.52072, 45.88377], + [-60.51815, 47.00793], + [-60.4486, 46.28264], + [-59.80287, 45.9204], + [-61.03988, 45.26525], + [-63.25471, 44.67014], + [-64.24656, 44.26553], + [-65.36406, 43.54523], + [-66.1234, 43.61867], + [-66.16173, 44.46512], + [-64.42549, 45.29204], + [-66.02605, 45.25931], + [-67.13741, 45.13753], + [-67.79134, 45.70281], + [-67.79046, 47.06636], + [-68.23444, 47.35486] + ] + ], + [ + [ + [-114.16717, 73.12145], + [-114.66634, 72.65277], + [-112.44102, 72.9554], + [-111.05039, 72.4504], + [-109.92035, 72.96113], + [-109.00654, 72.63335], + [-108.18835, 71.65089], + [-107.68599, 72.06548], + [-108.39639, 73.08953], + [-107.51645, 73.23598], + [-106.52259, 73.07601], + [-105.40246, 72.67259], + [-104.77484, 71.6984], + [-104.46476, 70.99297], + [-102.78537, 70.49776], + [-100.98078, 70.02432], + [-101.08929, 69.58447], + [-102.73116, 69.50402], + [-102.09329, 69.11962], + [-102.43024, 68.75282], + [-104.24, 68.91], + [-105.96, 69.18], + [-107.12254, 69.11922], + [-109, 68.78], + [-111.534149, 68.630059], + [-113.3132, 68.53554], + [-113.85496, 69.00744], + [-115.22, 69.28], + [-116.10794, 69.16821], + [-117.34, 69.96], + [-116.67473, 70.06655], + [-115.13112, 70.2373], + [-113.72141, 70.19237], + [-112.4161, 70.36638], + [-114.35, 70.6], + [-116.48684, 70.52045], + [-117.9048, 70.54056], + [-118.43238, 70.9092], + [-116.11311, 71.30918], + [-117.65568, 71.2952], + [-119.40199, 71.55859], + [-118.56267, 72.30785], + [-117.86642, 72.70594], + [-115.18909, 73.31459], + [-114.16717, 73.12145] + ] + ], + [ + [ + [-104.5, 73.42], + [-105.38, 72.76], + [-106.94, 73.46], + [-106.6, 73.6], + [-105.26, 73.64], + [-104.5, 73.42] + ] + ], + [ + [ + [-76.34, 73.102685], + [-76.251404, 72.826385], + [-77.314438, 72.855545], + [-78.39167, 72.876656], + [-79.486252, 72.742203], + [-79.775833, 72.802902], + [-80.876099, 73.333183], + [-80.833885, 73.693184], + [-80.353058, 73.75972], + [-78.064438, 73.651932], + [-76.34, 73.102685] + ] + ], + [ + [ + [-86.562179, 73.157447], + [-85.774371, 72.534126], + [-84.850112, 73.340278], + [-82.31559, 73.750951], + [-80.600088, 72.716544], + [-80.748942, 72.061907], + [-78.770639, 72.352173], + [-77.824624, 72.749617], + [-75.605845, 72.243678], + [-74.228616, 71.767144], + [-74.099141, 71.33084], + [-72.242226, 71.556925], + [-71.200015, 70.920013], + [-68.786054, 70.525024], + [-67.91497, 70.121948], + [-66.969033, 69.186087], + [-68.805123, 68.720198], + [-66.449866, 68.067163], + [-64.862314, 67.847539], + [-63.424934, 66.928473], + [-61.851981, 66.862121], + [-62.163177, 66.160251], + [-63.918444, 64.998669], + [-65.14886, 65.426033], + [-66.721219, 66.388041], + [-68.015016, 66.262726], + [-68.141287, 65.689789], + [-67.089646, 65.108455], + [-65.73208, 64.648406], + [-65.320168, 64.382737], + [-64.669406, 63.392927], + [-65.013804, 62.674185], + [-66.275045, 62.945099], + [-68.783186, 63.74567], + [-67.369681, 62.883966], + [-66.328297, 62.280075], + [-66.165568, 61.930897], + [-68.877367, 62.330149], + [-71.023437, 62.910708], + [-72.235379, 63.397836], + [-71.886278, 63.679989], + [-73.378306, 64.193963], + [-74.834419, 64.679076], + [-74.818503, 64.389093], + [-77.70998, 64.229542], + [-78.555949, 64.572906], + [-77.897281, 65.309192], + [-76.018274, 65.326969], + [-73.959795, 65.454765], + [-74.293883, 65.811771], + [-73.944912, 66.310578], + [-72.651167, 67.284576], + [-72.92606, 67.726926], + [-73.311618, 68.069437], + [-74.843307, 68.554627], + [-76.869101, 68.894736], + [-76.228649, 69.147769], + [-77.28737, 69.76954], + [-78.168634, 69.826488], + [-78.957242, 70.16688], + [-79.492455, 69.871808], + [-81.305471, 69.743185], + [-84.944706, 69.966634], + [-87.060003, 70.260001], + [-88.681713, 70.410741], + [-89.51342, 70.762038], + [-88.467721, 71.218186], + [-89.888151, 71.222552], + [-90.20516, 72.235074], + [-89.436577, 73.129464], + [-88.408242, 73.537889], + [-85.826151, 73.803816], + [-86.562179, 73.157447] + ] + ], + [ + [ + [-100.35642, 73.84389], + [-99.16387, 73.63339], + [-97.38, 73.76], + [-97.12, 73.47], + [-98.05359, 72.99052], + [-96.54, 72.56], + [-96.72, 71.66], + [-98.35966, 71.27285], + [-99.32286, 71.35639], + [-100.01482, 71.73827], + [-102.5, 72.51], + [-102.48, 72.83], + [-100.43836, 72.70588], + [-101.54, 73.36], + [-100.35642, 73.84389] + ] + ], + [ + [ + [-93.196296, 72.771992], + [-94.269047, 72.024596], + [-95.409856, 72.061881], + [-96.033745, 72.940277], + [-96.018268, 73.43743], + [-95.495793, 73.862417], + [-94.503658, 74.134907], + [-92.420012, 74.100025], + [-90.509793, 73.856732], + [-92.003965, 72.966244], + [-93.196296, 72.771992] + ] + ], + [ + [ + [-120.46, 71.383602], + [-123.09219, 70.90164], + [-123.62, 71.34], + [-125.928949, 71.868688], + [-125.5, 72.292261], + [-124.80729, 73.02256], + [-123.94, 73.68], + [-124.91775, 74.29275], + [-121.53788, 74.44893], + [-120.10978, 74.24135], + [-117.55564, 74.18577], + [-116.58442, 73.89607], + [-115.51081, 73.47519], + [-116.76794, 73.22292], + [-119.22, 72.52], + [-120.46, 71.82], + [-120.46, 71.383602] + ] + ], + [ + [ + [-93.612756, 74.979997], + [-94.156909, 74.592347], + [-95.608681, 74.666864], + [-96.820932, 74.927623], + [-96.288587, 75.377828], + [-94.85082, 75.647218], + [-93.977747, 75.29649], + [-93.612756, 74.979997] + ] + ], + [ + [ + [-98.5, 76.72], + [-97.735585, 76.25656], + [-97.704415, 75.74344], + [-98.16, 75], + [-99.80874, 74.89744], + [-100.88366, 75.05736], + [-100.86292, 75.64075], + [-102.50209, 75.5638], + [-102.56552, 76.3366], + [-101.48973, 76.30537], + [-99.98349, 76.64634], + [-98.57699, 76.58859], + [-98.5, 76.72] + ] + ], + [ + [ + [-108.21141, 76.20168], + [-107.81943, 75.84552], + [-106.92893, 76.01282], + [-105.881, 75.9694], + [-105.70498, 75.47951], + [-106.31347, 75.00527], + [-109.7, 74.85], + [-112.22307, 74.41696], + [-113.74381, 74.39427], + [-113.87135, 74.72029], + [-111.79421, 75.1625], + [-116.31221, 75.04343], + [-117.7104, 75.2222], + [-116.34602, 76.19903], + [-115.40487, 76.47887], + [-112.59056, 76.14134], + [-110.81422, 75.54919], + [-109.0671, 75.47321], + [-110.49726, 76.42982], + [-109.5811, 76.79417], + [-108.54859, 76.67832], + [-108.21141, 76.20168] + ] + ], + [ + [ + [-94.684086, 77.097878], + [-93.573921, 76.776296], + [-91.605023, 76.778518], + [-90.741846, 76.449597], + [-90.969661, 76.074013], + [-89.822238, 75.847774], + [-89.187083, 75.610166], + [-87.838276, 75.566189], + [-86.379192, 75.482421], + [-84.789625, 75.699204], + [-82.753445, 75.784315], + [-81.128531, 75.713983], + [-80.057511, 75.336849], + [-79.833933, 74.923127], + [-80.457771, 74.657304], + [-81.948843, 74.442459], + [-83.228894, 74.564028], + [-86.097452, 74.410032], + [-88.15035, 74.392307], + [-89.764722, 74.515555], + [-92.422441, 74.837758], + [-92.768285, 75.38682], + [-92.889906, 75.882655], + [-93.893824, 76.319244], + [-95.962457, 76.441381], + [-97.121379, 76.751078], + [-96.745123, 77.161389], + [-94.684086, 77.097878] + ] + ], + [ + [ + [-116.198587, 77.645287], + [-116.335813, 76.876962], + [-117.106051, 76.530032], + [-118.040412, 76.481172], + [-119.899318, 76.053213], + [-121.499995, 75.900019], + [-122.854924, 76.116543], + [-122.854925, 76.116543], + [-121.157535, 76.864508], + [-119.103939, 77.51222], + [-117.570131, 77.498319], + [-116.198587, 77.645287] + ] + ], + [ + [ + [-93.840003, 77.519997], + [-94.295608, 77.491343], + [-96.169654, 77.555111], + [-96.436304, 77.834629], + [-94.422577, 77.820005], + [-93.720656, 77.634331], + [-93.840003, 77.519997] + ] + ], + [ + [ + [-110.186938, 77.697015], + [-112.051191, 77.409229], + [-113.534279, 77.732207], + [-112.724587, 78.05105], + [-111.264443, 78.152956], + [-109.854452, 77.996325], + [-110.186938, 77.697015] + ] + ], + [ + [ + [-109.663146, 78.601973], + [-110.881314, 78.40692], + [-112.542091, 78.407902], + [-112.525891, 78.550555], + [-111.50001, 78.849994], + [-110.963661, 78.804441], + [-109.663146, 78.601973] + ] + ], + [ + [ + [-95.830295, 78.056941], + [-97.309843, 77.850597], + [-98.124289, 78.082857], + [-98.552868, 78.458105], + [-98.631984, 78.87193], + [-97.337231, 78.831984], + [-96.754399, 78.765813], + [-95.559278, 78.418315], + [-95.830295, 78.056941] + ] + ], + [ + [ + [-100.060192, 78.324754], + [-99.670939, 77.907545], + [-101.30394, 78.018985], + [-102.949809, 78.343229], + [-105.176133, 78.380332], + [-104.210429, 78.67742], + [-105.41958, 78.918336], + [-105.492289, 79.301594], + [-103.529282, 79.165349], + [-100.825158, 78.800462], + [-100.060192, 78.324754] + ] + ], + [ + [ + [-87.02, 79.66], + [-85.81435, 79.3369], + [-87.18756, 79.0393], + [-89.03535, 78.28723], + [-90.80436, 78.21533], + [-92.87669, 78.34333], + [-93.95116, 78.75099], + [-93.93574, 79.11373], + [-93.14524, 79.3801], + [-94.974, 79.37248], + [-96.07614, 79.70502], + [-96.70972, 80.15777], + [-96.01644, 80.60233], + [-95.32345, 80.90729], + [-94.29843, 80.97727], + [-94.73542, 81.20646], + [-92.40984, 81.25739], + [-91.13289, 80.72345], + [-89.45, 80.509322], + [-87.81, 80.32], + [-87.02, 79.66] + ] + ], + [ + [ + [-68.5, 83.106322], + [-65.82735, 83.02801], + [-63.68, 82.9], + [-61.85, 82.6286], + [-61.89388, 82.36165], + [-64.334, 81.92775], + [-66.75342, 81.72527], + [-67.65755, 81.50141], + [-65.48031, 81.50657], + [-67.84, 80.9], + [-69.4697, 80.61683], + [-71.18, 79.8], + [-73.2428, 79.63415], + [-73.88, 79.430162], + [-76.90773, 79.32309], + [-75.52924, 79.19766], + [-76.22046, 79.01907], + [-75.39345, 78.52581], + [-76.34354, 78.18296], + [-77.88851, 77.89991], + [-78.36269, 77.50859], + [-79.75951, 77.20968], + [-79.61965, 76.98336], + [-77.91089, 77.022045], + [-77.88911, 76.777955], + [-80.56125, 76.17812], + [-83.17439, 76.45403], + [-86.11184, 76.29901], + [-87.6, 76.42], + [-89.49068, 76.47239], + [-89.6161, 76.95213], + [-87.76739, 77.17833], + [-88.26, 77.9], + [-87.65, 77.970222], + [-84.97634, 77.53873], + [-86.34, 78.18], + [-87.96192, 78.37181], + [-87.15198, 78.75867], + [-85.37868, 78.9969], + [-85.09495, 79.34543], + [-86.50734, 79.73624], + [-86.93179, 80.25145], + [-84.19844, 80.20836], + [-83.408696, 80.1], + [-81.84823, 80.46442], + [-84.1, 80.58], + [-87.59895, 80.51627], + [-89.36663, 80.85569], + [-90.2, 81.26], + [-91.36786, 81.5531], + [-91.58702, 81.89429], + [-90.1, 82.085], + [-88.93227, 82.11751], + [-86.97024, 82.27961], + [-85.5, 82.652273], + [-84.260005, 82.6], + [-83.18, 82.32], + [-82.42, 82.86], + [-81.1, 83.02], + [-79.30664, 83.13056], + [-76.25, 83.172059], + [-75.71878, 83.06404], + [-72.83153, 83.23324], + [-70.665765, 83.169781], + [-68.5, 83.106322] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Switzerland", + "SOV_A3": "CHE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Switzerland", + "ADM0_A3": "CHE", + "GEOU_DIF": 0, + "GEOUNIT": "Switzerland", + "GU_A3": "CHE", + "SU_DIF": 0, + "SUBUNIT": "Switzerland", + "SU_A3": "CHE", + "BRK_DIFF": 0, + "NAME": "Switzerland", + "NAME_LONG": "Switzerland", + "BRK_A3": "CHE", + "BRK_NAME": "Switzerland", + "BRK_GROUP": null, + "ABBREV": "Switz.", + "POSTAL": "CH", + "FORMAL_EN": "Swiss Confederation", + "FORMAL_FR": null, + "NAME_CIAWF": "Switzerland", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Switzerland", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 2, + "MAPCOLOR9": 7, + "MAPCOLOR13": 3, + "POP_EST": 8236303, + "POP_RANK": 13, + "GDP_MD_EST": 496300, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "SZ", + "ISO_A2": "CH", + "ISO_A3": "CHE", + "ISO_A3_EH": "CHE", + "ISO_N3": "756", + "UN_A3": "756", + "WB_A2": "CH", + "WB_A3": "CHE", + "WOE_ID": 23424957, + "WOE_ID_EH": 23424957, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CHE", + "ADM0_A3_US": "CHE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [6.022609, 45.776948, 10.442701, 47.830828], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [9.594226, 47.525058], + [9.632932, 47.347601], + [9.47997, 47.10281], + [9.932448, 46.920728], + [10.442701, 46.893546], + [10.363378, 46.483571], + [9.922837, 46.314899], + [9.182882, 46.440215], + [8.966306, 46.036932], + [8.489952, 46.005151], + [8.31663, 46.163642], + [7.755992, 45.82449], + [7.273851, 45.776948], + [6.843593, 45.991147], + [6.5001, 46.429673], + [6.022609, 46.27299], + [6.037389, 46.725779], + [6.768714, 47.287708], + [6.736571, 47.541801], + [7.192202, 47.449766], + [7.466759, 47.620582], + [8.317301, 47.61358], + [8.522612, 47.830828], + [9.594226, 47.525058] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Chile", + "SOV_A3": "CHL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Chile", + "ADM0_A3": "CHL", + "GEOU_DIF": 0, + "GEOUNIT": "Chile", + "GU_A3": "CHL", + "SU_DIF": 0, + "SUBUNIT": "Chile", + "SU_A3": "CHL", + "BRK_DIFF": 0, + "NAME": "Chile", + "NAME_LONG": "Chile", + "BRK_A3": "CHL", + "BRK_NAME": "Chile", + "BRK_GROUP": null, + "ABBREV": "Chile", + "POSTAL": "CL", + "FORMAL_EN": "Republic of Chile", + "FORMAL_FR": null, + "NAME_CIAWF": "Chile", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Chile", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 1, + "MAPCOLOR9": 5, + "MAPCOLOR13": 9, + "POP_EST": 17789267, + "POP_RANK": 14, + "GDP_MD_EST": 436100, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CI", + "ISO_A2": "CL", + "ISO_A3": "CHL", + "ISO_A3_EH": "CHL", + "ISO_N3": "152", + "UN_A3": "152", + "WB_A2": "CL", + "WB_A3": "CHL", + "WOE_ID": 23424782, + "WOE_ID_EH": 23424782, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CHL", + "ADM0_A3_US": "CHL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [-75.644395, -55.61183, -66.95992, -17.580012], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-68.63401, -52.63637], + [-68.63335, -54.8695], + [-67.56244, -54.87001], + [-66.95992, -54.89681], + [-67.29103, -55.30124], + [-68.14863, -55.61183], + [-68.639991, -55.580018], + [-69.2321, -55.49906], + [-69.95809, -55.19843], + [-71.00568, -55.05383], + [-72.2639, -54.49514], + [-73.2852, -53.95752], + [-74.66253, -52.83749], + [-73.8381, -53.04743], + [-72.43418, -53.7154], + [-71.10773, -54.07433], + [-70.59178, -53.61583], + [-70.26748, -52.93123], + [-69.34565, -52.5183], + [-68.63401, -52.63637] + ] + ], + [ + [ + [-67.106674, -22.735925], + [-66.985234, -22.986349], + [-67.328443, -24.025303], + [-68.417653, -24.518555], + [-68.386001, -26.185016], + [-68.5948, -26.506909], + [-68.295542, -26.89934], + [-69.001235, -27.521214], + [-69.65613, -28.459141], + [-70.01355, -29.367923], + [-69.919008, -30.336339], + [-70.535069, -31.36501], + [-70.074399, -33.09121], + [-69.814777, -33.273886], + [-69.817309, -34.193571], + [-70.388049, -35.169688], + [-70.364769, -36.005089], + [-71.121881, -36.658124], + [-71.118625, -37.576827], + [-70.814664, -38.552995], + [-71.413517, -38.916022], + [-71.680761, -39.808164], + [-71.915734, -40.832339], + [-71.746804, -42.051386], + [-72.148898, -42.254888], + [-71.915424, -43.408565], + [-71.464056, -43.787611], + [-71.793623, -44.207172], + [-71.329801, -44.407522], + [-71.222779, -44.784243], + [-71.659316, -44.973689], + [-71.552009, -45.560733], + [-71.917258, -46.884838], + [-72.447355, -47.738533], + [-72.331161, -48.244238], + [-72.648247, -48.878618], + [-73.415436, -49.318436], + [-73.328051, -50.378785], + [-72.975747, -50.74145], + [-72.309974, -50.67701], + [-72.329404, -51.425956], + [-71.914804, -52.009022], + [-69.498362, -52.142761], + [-68.571545, -52.299444], + [-69.461284, -52.291951], + [-69.94278, -52.537931], + [-70.845102, -52.899201], + [-71.006332, -53.833252], + [-71.429795, -53.856455], + [-72.557943, -53.53141], + [-73.702757, -52.835069], + [-73.702757, -52.83507], + [-74.946763, -52.262754], + [-75.260026, -51.629355], + [-74.976632, -51.043396], + [-75.479754, -50.378372], + [-75.608015, -48.673773], + [-75.18277, -47.711919], + [-74.126581, -46.939253], + [-75.644395, -46.647643], + [-74.692154, -45.763976], + [-74.351709, -44.103044], + [-73.240356, -44.454961], + [-72.717804, -42.383356], + [-73.3889, -42.117532], + [-73.701336, -43.365776], + [-74.331943, -43.224958], + [-74.017957, -41.794813], + [-73.677099, -39.942213], + [-73.217593, -39.258689], + [-73.505559, -38.282883], + [-73.588061, -37.156285], + [-73.166717, -37.12378], + [-72.553137, -35.50884], + [-71.861732, -33.909093], + [-71.43845, -32.418899], + [-71.668721, -30.920645], + [-71.370083, -30.095682], + [-71.489894, -28.861442], + [-70.905124, -27.64038], + [-70.724954, -25.705924], + [-70.403966, -23.628997], + [-70.091246, -21.393319], + [-70.16442, -19.756468], + [-70.372572, -18.347975], + [-69.858444, -18.092694], + [-69.590424, -17.580012], + [-69.100247, -18.260125], + [-68.966818, -18.981683], + [-68.442225, -19.405068], + [-68.757167, -20.372658], + [-68.219913, -21.494347], + [-67.82818, -22.872919], + [-67.106674, -22.735925] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "China", + "SOV_A3": "CH1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "China", + "ADM0_A3": "CHN", + "GEOU_DIF": 0, + "GEOUNIT": "China", + "GU_A3": "CHN", + "SU_DIF": 0, + "SUBUNIT": "China", + "SU_A3": "CHN", + "BRK_DIFF": 0, + "NAME": "China", + "NAME_LONG": "China", + "BRK_A3": "CHN", + "BRK_NAME": "China", + "BRK_GROUP": null, + "ABBREV": "China", + "POSTAL": "CN", + "FORMAL_EN": "People's Republic of China", + "FORMAL_FR": null, + "NAME_CIAWF": "China", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "China", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 4, + "MAPCOLOR9": 4, + "MAPCOLOR13": 3, + "POP_EST": 1379302771, + "POP_RANK": 18, + "GDP_MD_EST": 21140000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "3. Emerging region: BRIC", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CH", + "ISO_A2": "CN", + "ISO_A3": "CHN", + "ISO_A3_EH": "CHN", + "ISO_N3": "156", + "UN_A3": "156", + "WB_A2": "CN", + "WB_A3": "CHN", + "WOE_ID": 23424781, + "WOE_ID_EH": 23424781, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CHN", + "ADM0_A3_US": "CHN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 5.7 + }, + "bbox": [73.675379, 18.197701, 135.026311, 53.4588], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [75.158028, 37.133031], + [74.980002, 37.41999], + [74.829986, 37.990007], + [74.864816, 38.378846], + [74.257514, 38.606507], + [73.928852, 38.505815], + [73.675379, 39.431237], + [73.960013, 39.660008], + [73.822244, 39.893973], + [74.776862, 40.366425], + [75.467828, 40.562072], + [76.526368, 40.427946], + [76.904484, 41.066486], + [78.187197, 41.185316], + [78.543661, 41.582243], + [80.11943, 42.123941], + [80.25999, 42.349999], + [80.18015, 42.920068], + [80.866206, 43.180362], + [79.966106, 44.917517], + [81.947071, 45.317027], + [82.458926, 45.53965], + [83.180484, 47.330031], + [85.16429, 47.000956], + [85.720484, 47.452969], + [85.768233, 48.455751], + [86.598776, 48.549182], + [87.35997, 49.214981], + [87.751264, 49.297198], + [88.013832, 48.599463], + [88.854298, 48.069082], + [90.280826, 47.693549], + [90.970809, 46.888146], + [90.585768, 45.719716], + [90.94554, 45.286073], + [92.133891, 45.115076], + [93.480734, 44.975472], + [94.688929, 44.352332], + [95.306875, 44.241331], + [95.762455, 43.319449], + [96.349396, 42.725635], + [97.451757, 42.74889], + [99.515817, 42.524691], + [100.845866, 42.663804], + [101.83304, 42.514873], + [103.312278, 41.907468], + [104.522282, 41.908347], + [104.964994, 41.59741], + [106.129316, 42.134328], + [107.744773, 42.481516], + [109.243596, 42.519446], + [110.412103, 42.871234], + [111.129682, 43.406834], + [111.829588, 43.743118], + [111.667737, 44.073176], + [111.348377, 44.457442], + [111.873306, 45.102079], + [112.436062, 45.011646], + [113.463907, 44.808893], + [114.460332, 45.339817], + [115.985096, 45.727235], + [116.717868, 46.388202], + [117.421701, 46.672733], + [118.874326, 46.805412], + [119.66327, 46.69268], + [119.772824, 47.048059], + [118.866574, 47.74706], + [118.064143, 48.06673], + [117.295507, 47.697709], + [116.308953, 47.85341], + [115.742837, 47.726545], + [115.485282, 48.135383], + [116.191802, 49.134598], + [116.678801, 49.888531], + [117.879244, 49.510983], + [119.288461, 50.142883], + [119.27939, 50.58292], + [120.18208, 51.64355], + [120.7382, 51.96411], + [120.725789, 52.516226], + [120.177089, 52.753886], + [121.003085, 53.251401], + [122.245748, 53.431726], + [123.57147, 53.4588], + [125.068211, 53.161045], + [125.946349, 52.792799], + [126.564399, 51.784255], + [126.939157, 51.353894], + [127.287456, 50.739797], + [127.6574, 49.76027], + [129.397818, 49.4406], + [130.582293, 48.729687], + [130.98726, 47.79013], + [132.50669, 47.78896], + [133.373596, 48.183442], + [135.026311, 48.47823], + [134.50081, 47.57845], + [134.11235, 47.21248], + [133.769644, 46.116927], + [133.09712, 45.14409], + [131.883454, 45.321162], + [131.02519, 44.96796], + [131.288555, 44.11152], + [131.144688, 42.92999], + [130.633866, 42.903015], + [130.64, 42.395024], + [129.994267, 42.985387], + [129.596669, 42.424982], + [128.052215, 41.994285], + [128.208433, 41.466772], + [127.343783, 41.503152], + [126.869083, 41.816569], + [126.182045, 41.107336], + [125.079942, 40.569824], + [124.265625, 39.928493], + [122.86757, 39.637788], + [122.131388, 39.170452], + [121.054554, 38.897471], + [121.585995, 39.360854], + [121.376757, 39.750261], + [122.168595, 40.422443], + [121.640359, 40.94639], + [120.768629, 40.593388], + [119.639602, 39.898056], + [119.023464, 39.252333], + [118.042749, 39.204274], + [117.532702, 38.737636], + [118.059699, 38.061476], + [118.87815, 37.897325], + [118.911636, 37.448464], + [119.702802, 37.156389], + [120.823457, 37.870428], + [121.711259, 37.481123], + [122.357937, 37.454484], + [122.519995, 36.930614], + [121.104164, 36.651329], + [120.637009, 36.11144], + [119.664562, 35.609791], + [119.151208, 34.909859], + [120.227525, 34.360332], + [120.620369, 33.376723], + [121.229014, 32.460319], + [121.908146, 31.692174], + [121.891919, 30.949352], + [121.264257, 30.676267], + [121.503519, 30.142915], + [122.092114, 29.83252], + [121.938428, 29.018022], + [121.684439, 28.225513], + [121.125661, 28.135673], + [120.395473, 27.053207], + [119.585497, 25.740781], + [118.656871, 24.547391], + [117.281606, 23.624501], + [115.890735, 22.782873], + [114.763827, 22.668074], + [114.152547, 22.22376], + [113.80678, 22.54834], + [113.241078, 22.051367], + [111.843592, 21.550494], + [110.785466, 21.397144], + [110.444039, 20.341033], + [109.889861, 20.282457], + [109.627655, 21.008227], + [109.864488, 21.395051], + [108.522813, 21.715212], + [108.05018, 21.55238], + [107.04342, 21.811899], + [106.567273, 22.218205], + [106.725403, 22.794268], + [105.811247, 22.976892], + [105.329209, 23.352063], + [104.476858, 22.81915], + [103.504515, 22.703757], + [102.706992, 22.708795], + [102.170436, 22.464753], + [101.652018, 22.318199], + [101.80312, 21.174367], + [101.270026, 21.201652], + [101.180005, 21.436573], + [101.150033, 21.849984], + [100.416538, 21.558839], + [99.983489, 21.742937], + [99.240899, 22.118314], + [99.531992, 22.949039], + [98.898749, 23.142722], + [98.660262, 24.063286], + [97.60472, 23.897405], + [97.724609, 25.083637], + [98.671838, 25.918703], + [98.712094, 26.743536], + [98.68269, 27.508812], + [98.246231, 27.747221], + [97.911988, 28.335945], + [97.327114, 28.261583], + [96.248833, 28.411031], + [96.586591, 28.83098], + [96.117679, 29.452802], + [95.404802, 29.031717], + [94.56599, 29.277438], + [93.413348, 28.640629], + [92.503119, 27.896876], + [91.696657, 27.771742], + [91.258854, 28.040614], + [90.730514, 28.064954], + [90.015829, 28.296439], + [89.47581, 28.042759], + [88.814248, 27.299316], + [88.730326, 28.086865], + [88.120441, 27.876542], + [86.954517, 27.974262], + [85.82332, 28.203576], + [85.011638, 28.642774], + [84.23458, 28.839894], + [83.898993, 29.320226], + [83.337115, 29.463732], + [82.327513, 30.115268], + [81.525804, 30.422717], + [81.111256, 30.183481], + [79.721367, 30.882715], + [78.738894, 31.515906], + [78.458446, 32.618164], + [79.176129, 32.48378], + [79.208892, 32.994395], + [78.811086, 33.506198], + [78.912269, 34.321936], + [77.837451, 35.49401], + [76.192848, 35.898403], + [75.896897, 36.666806], + [75.158028, 37.133031] + ] + ], + [ + [ + [110.339188, 18.678395], + [109.47521, 18.197701], + [108.655208, 18.507682], + [108.626217, 19.367888], + [109.119056, 19.821039], + [110.211599, 20.101254], + [110.786551, 20.077534], + [111.010051, 19.69593], + [110.570647, 19.255879], + [110.339188, 18.678395] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Ivory Coast", + "SOV_A3": "CIV", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Ivory Coast", + "ADM0_A3": "CIV", + "GEOU_DIF": 0, + "GEOUNIT": "Ivory Coast", + "GU_A3": "CIV", + "SU_DIF": 0, + "SUBUNIT": "Ivory Coast", + "SU_A3": "CIV", + "BRK_DIFF": 0, + "NAME": "Côte d'Ivoire", + "NAME_LONG": "Côte d'Ivoire", + "BRK_A3": "CIV", + "BRK_NAME": "Côte d'Ivoire", + "BRK_GROUP": null, + "ABBREV": "I.C.", + "POSTAL": "CI", + "FORMAL_EN": "Republic of Ivory Coast", + "FORMAL_FR": "Republic of Cote D'Ivoire", + "NAME_CIAWF": "Cote D'ivoire", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Côte d'Ivoire", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 6, + "MAPCOLOR9": 3, + "MAPCOLOR13": 3, + "POP_EST": 24184810, + "POP_RANK": 15, + "GDP_MD_EST": 87120, + "POP_YEAR": 2017, + "LASTCENSUS": 1998, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "IV", + "ISO_A2": "CI", + "ISO_A3": "CIV", + "ISO_A3_EH": "CIV", + "ISO_N3": "384", + "UN_A3": "384", + "WB_A2": "CI", + "WB_A3": "CIV", + "WOE_ID": 23424854, + "WOE_ID_EH": 23424854, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CIV", + "ADM0_A3_US": "CIV", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 13, + "LONG_LEN": 13, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-8.60288, 4.338288, -2.56219, 10.524061], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-5.404342, 10.370737], + [-4.954653, 10.152714], + [-4.779884, 9.821985], + [-4.330247, 9.610835], + [-3.980449, 9.862344], + [-3.511899, 9.900326], + [-2.827496, 9.642461], + [-2.56219, 8.219628], + [-2.983585, 7.379705], + [-3.24437, 6.250472], + [-2.810701, 5.389051], + [-2.856125, 4.994476], + [-3.311084, 4.984296], + [-4.00882, 5.179813], + [-4.649917, 5.168264], + [-5.834496, 4.993701], + [-6.528769, 4.705088], + [-7.518941, 4.338288], + [-7.712159, 4.364566], + [-7.635368, 5.188159], + [-7.539715, 5.313345], + [-7.570153, 5.707352], + [-7.993693, 6.12619], + [-8.311348, 6.193033], + [-8.60288, 6.467564], + [-8.385452, 6.911801], + [-8.485446, 7.395208], + [-8.439298, 7.686043], + [-8.280703, 7.68718], + [-8.221792, 8.123329], + [-8.299049, 8.316444], + [-8.203499, 8.455453], + [-7.8321, 8.575704], + [-8.079114, 9.376224], + [-8.309616, 9.789532], + [-8.229337, 10.12902], + [-8.029944, 10.206535], + [-7.89959, 10.297382], + [-7.622759, 10.147236], + [-6.850507, 10.138994], + [-6.666461, 10.430811], + [-6.493965, 10.411303], + [-6.205223, 10.524061], + [-6.050452, 10.096361], + [-5.816926, 10.222555], + [-5.404342, 10.370737] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Cameroon", + "SOV_A3": "CMR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Cameroon", + "ADM0_A3": "CMR", + "GEOU_DIF": 0, + "GEOUNIT": "Cameroon", + "GU_A3": "CMR", + "SU_DIF": 0, + "SUBUNIT": "Cameroon", + "SU_A3": "CMR", + "BRK_DIFF": 0, + "NAME": "Cameroon", + "NAME_LONG": "Cameroon", + "BRK_A3": "CMR", + "BRK_NAME": "Cameroon", + "BRK_GROUP": null, + "ABBREV": "Cam.", + "POSTAL": "CM", + "FORMAL_EN": "Republic of Cameroon", + "FORMAL_FR": null, + "NAME_CIAWF": "Cameroon", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Cameroon", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 3, + "POP_EST": 24994885, + "POP_RANK": 15, + "GDP_MD_EST": 77240, + "POP_YEAR": 2017, + "LASTCENSUS": 2005, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CM", + "ISO_A2": "CM", + "ISO_A3": "CMR", + "ISO_A3_EH": "CMR", + "ISO_N3": "120", + "UN_A3": "120", + "WB_A2": "CM", + "WB_A3": "CMR", + "WOE_ID": 23424785, + "WOE_ID_EH": 23424785, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CMR", + "ADM0_A3_US": "CMR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [8.488816, 1.727673, 16.012852, 12.859396], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [15.27946, 7.421925], + [14.776545, 6.408498], + [14.53656, 6.226959], + [14.459407, 5.451761], + [14.558936, 5.030598], + [14.478372, 4.732605], + [14.950953, 4.210389], + [15.03622, 3.851367], + [15.405396, 3.335301], + [15.862732, 3.013537], + [15.907381, 2.557389], + [16.012852, 2.26764], + [15.940919, 1.727673], + [15.146342, 1.964015], + [14.337813, 2.227875], + [13.075822, 2.267097], + [12.951334, 2.321616], + [12.35938, 2.192812], + [11.751665, 2.326758], + [11.276449, 2.261051], + [9.649158, 2.283866], + [9.795196, 3.073404], + [9.404367, 3.734527], + [8.948116, 3.904129], + [8.744924, 4.352215], + [8.488816, 4.495617], + [8.500288, 4.771983], + [8.757533, 5.479666], + [9.233163, 6.444491], + [9.522706, 6.453482], + [10.118277, 7.03877], + [10.497375, 7.055358], + [11.058788, 6.644427], + [11.745774, 6.981383], + [11.839309, 7.397042], + [12.063946, 7.799808], + [12.218872, 8.305824], + [12.753672, 8.717763], + [12.955468, 9.417772], + [13.1676, 9.640626], + [13.308676, 10.160362], + [13.57295, 10.798566], + [14.415379, 11.572369], + [14.468192, 11.904752], + [14.577178, 12.085361], + [14.181336, 12.483657], + [14.213531, 12.802035], + [14.495787, 12.859396], + [14.89336, 12.21905], + [14.960152, 11.555574], + [14.923565, 10.891325], + [15.467873, 9.982337], + [14.909354, 9.992129], + [14.627201, 9.920919], + [14.171466, 10.021378], + [13.954218, 9.549495], + [14.544467, 8.965861], + [14.979996, 8.796104], + [15.120866, 8.38215], + [15.436092, 7.692812], + [15.27946, 7.421925] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Democratic Republic of the Congo", + "SOV_A3": "COD", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Democratic Republic of the Congo", + "ADM0_A3": "COD", + "GEOU_DIF": 0, + "GEOUNIT": "Democratic Republic of the Congo", + "GU_A3": "COD", + "SU_DIF": 0, + "SUBUNIT": "Democratic Republic of the Congo", + "SU_A3": "COD", + "BRK_DIFF": 0, + "NAME": "Dem. Rep. Congo", + "NAME_LONG": "Democratic Republic of the Congo", + "BRK_A3": "COD", + "BRK_NAME": "Democratic Republic of the Congo", + "BRK_GROUP": null, + "ABBREV": "D.R.C.", + "POSTAL": "DRC", + "FORMAL_EN": "Democratic Republic of the Congo", + "FORMAL_FR": null, + "NAME_CIAWF": "Congo, Democratic Republic of the", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Congo, Dem. Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 4, + "MAPCOLOR9": 4, + "MAPCOLOR13": 7, + "POP_EST": 83301151, + "POP_RANK": 16, + "GDP_MD_EST": 66010, + "POP_YEAR": 2017, + "LASTCENSUS": 1984, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "CG", + "ISO_A2": "CD", + "ISO_A3": "COD", + "ISO_A3_EH": "COD", + "ISO_N3": "180", + "UN_A3": "180", + "WB_A2": "ZR", + "WB_A3": "ZAR", + "WOE_ID": 23424780, + "WOE_ID_EH": 23424780, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "COD", + "ADM0_A3_US": "COD", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 15, + "LONG_LEN": 32, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [12.182337, -13.257227, 31.174149, 5.256088], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.912215, -10.926826], + [23.456791, -10.867863], + [22.837345, -11.017622], + [22.402798, -10.993075], + [22.155268, -11.084801], + [22.208753, -9.894796], + [21.875182, -9.523708], + [21.801801, -8.908707], + [21.949131, -8.305901], + [21.746456, -7.920085], + [21.728111, -7.290872], + [20.514748, -7.299606], + [20.601823, -6.939318], + [20.091622, -6.94309], + [20.037723, -7.116361], + [19.417502, -7.155429], + [19.166613, -7.738184], + [19.016752, -7.988246], + [18.464176, -7.847014], + [18.134222, -7.987678], + [17.47297, -8.068551], + [17.089996, -7.545689], + [16.860191, -7.222298], + [16.57318, -6.622645], + [16.326528, -5.87747], + [13.375597, -5.864241], + [13.024869, -5.984389], + [12.735171, -5.965682], + [12.322432, -6.100092], + [12.182337, -5.789931], + [12.436688, -5.684304], + [12.468004, -5.248362], + [12.631612, -4.991271], + [12.995517, -4.781103], + [13.25824, -4.882957], + [13.600235, -4.500138], + [14.144956, -4.510009], + [14.209035, -4.793092], + [14.582604, -4.970239], + [15.170992, -4.343507], + [15.75354, -3.855165], + [16.00629, -3.535133], + [15.972803, -2.712392], + [16.407092, -1.740927], + [16.865307, -1.225816], + [17.523716, -0.74383], + [17.638645, -0.424832], + [17.663553, -0.058084], + [17.82654, 0.288923], + [17.774192, 0.855659], + [17.898835, 1.741832], + [18.094276, 2.365722], + [18.393792, 2.900443], + [18.453065, 3.504386], + [18.542982, 4.201785], + [18.932312, 4.709506], + [19.467784, 5.031528], + [20.290679, 4.691678], + [20.927591, 4.322786], + [21.659123, 4.224342], + [22.405124, 4.02916], + [22.704124, 4.633051], + [22.84148, 4.710126], + [23.297214, 4.609693], + [24.410531, 5.108784], + [24.805029, 4.897247], + [25.128833, 4.927245], + [25.278798, 5.170408], + [25.650455, 5.256088], + [26.402761, 5.150875], + [27.044065, 5.127853], + [27.374226, 5.233944], + [27.979977, 4.408413], + [28.428994, 4.287155], + [28.696678, 4.455077], + [29.159078, 4.389267], + [29.715995, 4.600805], + [29.9535, 4.173699], + [30.833852, 3.509172], + [30.83386, 3.509166], + [30.773347, 2.339883], + [31.174149, 2.204465], + [30.85267, 1.849396], + [30.468508, 1.583805], + [30.086154, 1.062313], + [29.875779, 0.59738], + [29.819503, -0.20531], + [29.587838, -0.587406], + [29.579466, -1.341313], + [29.291887, -1.620056], + [29.254835, -2.21511], + [29.117479, -2.292211], + [29.024926, -2.839258], + [29.276384, -3.293907], + [29.339998, -4.499983], + [29.519987, -5.419979], + [29.419993, -5.939999], + [29.620032, -6.520015], + [30.199997, -7.079981], + [30.740015, -8.340007], + [30.74001, -8.340006], + [30.346086, -8.238257], + [29.002912, -8.407032], + [28.734867, -8.526559], + [28.449871, -9.164918], + [28.673682, -9.605925], + [28.49607, -10.789884], + [28.372253, -11.793647], + [28.642417, -11.971569], + [29.341548, -12.360744], + [29.616001, -12.178895], + [29.699614, -13.257227], + [28.934286, -13.248958], + [28.523562, -12.698604], + [28.155109, -12.272481], + [27.388799, -12.132747], + [27.16442, -11.608748], + [26.553088, -11.92444], + [25.75231, -11.784965], + [25.418118, -11.330936], + [24.78317, -11.238694], + [24.314516, -11.262826], + [24.257155, -10.951993], + [23.912215, -10.926826] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Republic of the Congo", + "SOV_A3": "COG", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Republic of the Congo", + "ADM0_A3": "COG", + "GEOU_DIF": 0, + "GEOUNIT": "Republic of the Congo", + "GU_A3": "COG", + "SU_DIF": 0, + "SUBUNIT": "Republic of the Congo", + "SU_A3": "COG", + "BRK_DIFF": 0, + "NAME": "Congo", + "NAME_LONG": "Republic of the Congo", + "BRK_A3": "COG", + "BRK_NAME": "Republic of the Congo", + "BRK_GROUP": null, + "ABBREV": "Rep. Congo", + "POSTAL": "CG", + "FORMAL_EN": "Republic of the Congo", + "FORMAL_FR": null, + "NAME_CIAWF": "Congo, Republic of the", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Congo, Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 10, + "POP_EST": 4954674, + "POP_RANK": 12, + "GDP_MD_EST": 30270, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CF", + "ISO_A2": "CG", + "ISO_A3": "COG", + "ISO_A3_EH": "COG", + "ISO_N3": "178", + "UN_A3": "178", + "WB_A2": "CG", + "WB_A3": "COG", + "WOE_ID": 23424779, + "WOE_ID_EH": 23424779, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "COG", + "ADM0_A3_US": "COG", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 21, + "ABBREV_LEN": 10, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [11.093773, -5.037987, 18.453065, 3.728197], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [12.995517, -4.781103], + [12.62076, -4.438023], + [12.318608, -4.60623], + [11.914963, -5.037987], + [11.093773, -3.978827], + [11.855122, -3.426871], + [11.478039, -2.765619], + [11.820964, -2.514161], + [12.495703, -2.391688], + [12.575284, -1.948511], + [13.109619, -2.42874], + [13.992407, -2.470805], + [14.29921, -1.998276], + [14.425456, -1.333407], + [14.316418, -0.552627], + [13.843321, 0.038758], + [14.276266, 1.19693], + [14.026669, 1.395677], + [13.282631, 1.314184], + [13.003114, 1.830896], + [13.075822, 2.267097], + [14.337813, 2.227875], + [15.146342, 1.964015], + [15.940919, 1.727673], + [16.012852, 2.26764], + [16.537058, 3.198255], + [17.133042, 3.728197], + [17.8099, 3.560196], + [18.453065, 3.504386], + [18.393792, 2.900443], + [18.094276, 2.365722], + [17.898835, 1.741832], + [17.774192, 0.855659], + [17.82654, 0.288923], + [17.663553, -0.058084], + [17.638645, -0.424832], + [17.523716, -0.74383], + [16.865307, -1.225816], + [16.407092, -1.740927], + [15.972803, -2.712392], + [16.00629, -3.535133], + [15.75354, -3.855165], + [15.170992, -4.343507], + [14.582604, -4.970239], + [14.209035, -4.793092], + [14.144956, -4.510009], + [13.600235, -4.500138], + [13.25824, -4.882957], + [12.995517, -4.781103] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Colombia", + "SOV_A3": "COL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Colombia", + "ADM0_A3": "COL", + "GEOU_DIF": 0, + "GEOUNIT": "Colombia", + "GU_A3": "COL", + "SU_DIF": 0, + "SUBUNIT": "Colombia", + "SU_A3": "COL", + "BRK_DIFF": 0, + "NAME": "Colombia", + "NAME_LONG": "Colombia", + "BRK_A3": "COL", + "BRK_NAME": "Colombia", + "BRK_GROUP": null, + "ABBREV": "Col.", + "POSTAL": "CO", + "FORMAL_EN": "Republic of Colombia", + "FORMAL_FR": null, + "NAME_CIAWF": "Colombia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Colombia", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 1, + "POP_EST": 47698524, + "POP_RANK": 15, + "GDP_MD_EST": 688000, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CO", + "ISO_A2": "CO", + "ISO_A3": "COL", + "ISO_A3_EH": "COL", + "ISO_N3": "170", + "UN_A3": "170", + "WB_A2": "CO", + "WB_A3": "COL", + "WOE_ID": 23424787, + "WOE_ID_EH": 23424787, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "COL", + "ADM0_A3_US": "COL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [-78.990935, -4.298187, -66.876326, 12.437303], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-66.876326, 1.253361], + [-67.065048, 1.130112], + [-67.259998, 1.719999], + [-67.53781, 2.037163], + [-67.868565, 1.692455], + [-69.816973, 1.714805], + [-69.804597, 1.089081], + [-69.218638, 0.985677], + [-69.252434, 0.602651], + [-69.452396, 0.706159], + [-70.015566, 0.541414], + [-70.020656, -0.185156], + [-69.577065, -0.549992], + [-69.420486, -1.122619], + [-69.444102, -1.556287], + [-69.893635, -4.298187], + [-70.394044, -3.766591], + [-70.692682, -3.742872], + [-70.047709, -2.725156], + [-70.813476, -2.256865], + [-71.413646, -2.342802], + [-71.774761, -2.16979], + [-72.325787, -2.434218], + [-73.070392, -2.308954], + [-73.659504, -1.260491], + [-74.122395, -1.002833], + [-74.441601, -0.53082], + [-75.106625, -0.057205], + [-75.373223, -0.152032], + [-75.801466, 0.084801], + [-76.292314, 0.416047], + [-76.57638, 0.256936], + [-77.424984, 0.395687], + [-77.668613, 0.825893], + [-77.855061, 0.809925], + [-78.855259, 1.380924], + [-78.990935, 1.69137], + [-78.617831, 1.766404], + [-78.662118, 2.267355], + [-78.42761, 2.629556], + [-77.931543, 2.696606], + [-77.510431, 3.325017], + [-77.12769, 3.849636], + [-77.496272, 4.087606], + [-77.307601, 4.667984], + [-77.533221, 5.582812], + [-77.318815, 5.845354], + [-77.476661, 6.691116], + [-77.881571, 7.223771], + [-77.753414, 7.70984], + [-77.431108, 7.638061], + [-77.242566, 7.935278], + [-77.474723, 8.524286], + [-77.353361, 8.670505], + [-76.836674, 8.638749], + [-76.086384, 9.336821], + [-75.6746, 9.443248], + [-75.664704, 9.774003], + [-75.480426, 10.61899], + [-74.906895, 11.083045], + [-74.276753, 11.102036], + [-74.197223, 11.310473], + [-73.414764, 11.227015], + [-72.627835, 11.731972], + [-72.238195, 11.95555], + [-71.75409, 12.437303], + [-71.399822, 12.376041], + [-71.137461, 12.112982], + [-71.331584, 11.776284], + [-71.973922, 11.608672], + [-72.227575, 11.108702], + [-72.614658, 10.821975], + [-72.905286, 10.450344], + [-73.027604, 9.73677], + [-73.304952, 9.152], + [-72.78873, 9.085027], + [-72.660495, 8.625288], + [-72.439862, 8.405275], + [-72.360901, 8.002638], + [-72.479679, 7.632506], + [-72.444487, 7.423785], + [-72.198352, 7.340431], + [-71.960176, 6.991615], + [-70.674234, 7.087785], + [-70.093313, 6.960376], + [-69.38948, 6.099861], + [-68.985319, 6.206805], + [-68.265052, 6.153268], + [-67.695087, 6.267318], + [-67.34144, 6.095468], + [-67.521532, 5.55687], + [-67.744697, 5.221129], + [-67.823012, 4.503937], + [-67.621836, 3.839482], + [-67.337564, 3.542342], + [-67.303173, 3.318454], + [-67.809938, 2.820655], + [-67.447092, 2.600281], + [-67.181294, 2.250638], + [-66.876326, 1.253361] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Costa Rica", + "SOV_A3": "CRI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Costa Rica", + "ADM0_A3": "CRI", + "GEOU_DIF": 0, + "GEOUNIT": "Costa Rica", + "GU_A3": "CRI", + "SU_DIF": 0, + "SUBUNIT": "Costa Rica", + "SU_A3": "CRI", + "BRK_DIFF": 0, + "NAME": "Costa Rica", + "NAME_LONG": "Costa Rica", + "BRK_A3": "CRI", + "BRK_NAME": "Costa Rica", + "BRK_GROUP": null, + "ABBREV": "C.R.", + "POSTAL": "CR", + "FORMAL_EN": "Republic of Costa Rica", + "FORMAL_FR": null, + "NAME_CIAWF": "Costa Rica", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Costa Rica", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 4, + "MAPCOLOR13": 2, + "POP_EST": 4930258, + "POP_RANK": 12, + "GDP_MD_EST": 79260, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CS", + "ISO_A2": "CR", + "ISO_A3": "CRI", + "ISO_A3_EH": "CRI", + "ISO_N3": "188", + "UN_A3": "188", + "WB_A2": "CR", + "WB_A3": "CRI", + "WOE_ID": 23424791, + "WOE_ID_EH": 23424791, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CRI", + "ADM0_A3_US": "CRI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-85.941725, 8.225028, -82.546196, 11.217119], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-82.965783, 8.225028], + [-83.508437, 8.446927], + [-83.711474, 8.656836], + [-83.596313, 8.830443], + [-83.632642, 9.051386], + [-83.909886, 9.290803], + [-84.303402, 9.487354], + [-84.647644, 9.615537], + [-84.713351, 9.908052], + [-84.97566, 10.086723], + [-84.911375, 9.795992], + [-85.110923, 9.55704], + [-85.339488, 9.834542], + [-85.660787, 9.933347], + [-85.797445, 10.134886], + [-85.791709, 10.439337], + [-85.659314, 10.754331], + [-85.941725, 10.895278], + [-85.71254, 11.088445], + [-85.561852, 11.217119], + [-84.903003, 10.952303], + [-84.673069, 11.082657], + [-84.355931, 10.999226], + [-84.190179, 10.79345], + [-83.895054, 10.726839], + [-83.655612, 10.938764], + [-83.40232, 10.395438], + [-83.015677, 9.992982], + [-82.546196, 9.566135], + [-82.932891, 9.476812], + [-82.927155, 9.07433], + [-82.719183, 8.925709], + [-82.868657, 8.807266], + [-82.829771, 8.626295], + [-82.913176, 8.423517], + [-82.965783, 8.225028] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Cuba", + "SOV_A3": "CUB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Cuba", + "ADM0_A3": "CUB", + "GEOU_DIF": 0, + "GEOUNIT": "Cuba", + "GU_A3": "CUB", + "SU_DIF": 0, + "SUBUNIT": "Cuba", + "SU_A3": "CUB", + "BRK_DIFF": 0, + "NAME": "Cuba", + "NAME_LONG": "Cuba", + "BRK_A3": "CUB", + "BRK_NAME": "Cuba", + "BRK_GROUP": null, + "ABBREV": "Cuba", + "POSTAL": "CU", + "FORMAL_EN": "Republic of Cuba", + "FORMAL_FR": null, + "NAME_CIAWF": "Cuba", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Cuba", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 5, + "MAPCOLOR9": 3, + "MAPCOLOR13": 4, + "POP_EST": 11147407, + "POP_RANK": 14, + "GDP_MD_EST": 132900, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CU", + "ISO_A2": "CU", + "ISO_A3": "CUB", + "ISO_A3_EH": "CUB", + "ISO_N3": "192", + "UN_A3": "192", + "WB_A2": "CU", + "WB_A3": "CUB", + "WOE_ID": 23424793, + "WOE_ID_EH": 23424793, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CUB", + "ADM0_A3_US": "CUB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-84.974911, 19.855481, -74.178025, 23.188611], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-82.268151, 23.188611], + [-81.404457, 23.117271], + [-80.618769, 23.10598], + [-79.679524, 22.765303], + [-79.281486, 22.399202], + [-78.347434, 22.512166], + [-77.993296, 22.277194], + [-77.146422, 21.657851], + [-76.523825, 21.20682], + [-76.19462, 21.220565], + [-75.598222, 21.016624], + [-75.67106, 20.735091], + [-74.933896, 20.693905], + [-74.178025, 20.284628], + [-74.296648, 20.050379], + [-74.961595, 19.923435], + [-75.63468, 19.873774], + [-76.323656, 19.952891], + [-77.755481, 19.855481], + [-77.085108, 20.413354], + [-77.492655, 20.673105], + [-78.137292, 20.739949], + [-78.482827, 21.028613], + [-78.719867, 21.598114], + [-79.285, 21.559175], + [-80.217475, 21.827324], + [-80.517535, 22.037079], + [-81.820943, 22.192057], + [-82.169992, 22.387109], + [-81.795002, 22.636965], + [-82.775898, 22.68815], + [-83.494459, 22.168518], + [-83.9088, 22.154565], + [-84.052151, 21.910575], + [-84.54703, 21.801228], + [-84.974911, 21.896028], + [-84.447062, 22.20495], + [-84.230357, 22.565755], + [-83.77824, 22.788118], + [-83.267548, 22.983042], + [-82.510436, 23.078747], + [-82.268151, 23.188611] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Northern Cyprus", + "SOV_A3": "CYN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Northern Cyprus", + "ADM0_A3": "CYN", + "GEOU_DIF": 0, + "GEOUNIT": "Northern Cyprus", + "GU_A3": "CYN", + "SU_DIF": 0, + "SUBUNIT": "Northern Cyprus", + "SU_A3": "CYN", + "BRK_DIFF": 1, + "NAME": "N. Cyprus", + "NAME_LONG": "Northern Cyprus", + "BRK_A3": "B20", + "BRK_NAME": "N. Cyprus", + "BRK_GROUP": null, + "ABBREV": "N. Cy.", + "POSTAL": "CN", + "FORMAL_EN": "Turkish Republic of Northern Cyprus", + "FORMAL_FR": null, + "NAME_CIAWF": null, + "NOTE_ADM0": "Self admin.", + "NOTE_BRK": "Self admin.; Claimed by Cyprus", + "NAME_SORT": "Cyprus, Northern", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 4, + "MAPCOLOR13": 8, + "POP_EST": 265100, + "POP_RANK": 10, + "GDP_MD_EST": 3600, + "POP_YEAR": 2013, + "LASTCENSUS": -99, + "GDP_YEAR": 2013, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "-99", + "ISO_A2": "-99", + "ISO_A3": "-99", + "ISO_A3_EH": "-99", + "ISO_N3": "-99", + "UN_A3": "-099", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": -90, + "WOE_ID_EH": 23424995, + "WOE_NOTE": "WOE lists as subunit of united Cyprus", + "ADM0_A3_IS": "CYP", + "ADM0_A3_US": "CYP", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 9, + "LONG_LEN": 15, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 6, + "MAX_LABEL": 10 + }, + "bbox": [32.73178, 35.000345, 34.576474, 35.671596], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [32.73178, 35.140026], + [32.802474, 35.145504], + [32.946961, 35.386703], + [33.667227, 35.373216], + [34.576474, 35.671596], + [33.900804, 35.245756], + [33.973617, 35.058506], + [33.86644, 35.093595], + [33.675392, 35.017863], + [33.525685, 35.038688], + [33.475817, 35.000345], + [33.455922, 35.101424], + [33.383833, 35.162712], + [33.190977, 35.173125], + [32.919572, 35.087833], + [32.73178, 35.140026] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Cyprus", + "SOV_A3": "CYP", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Cyprus", + "ADM0_A3": "CYP", + "GEOU_DIF": 0, + "GEOUNIT": "Cyprus", + "GU_A3": "CYP", + "SU_DIF": 0, + "SUBUNIT": "Cyprus", + "SU_A3": "CYP", + "BRK_DIFF": 0, + "NAME": "Cyprus", + "NAME_LONG": "Cyprus", + "BRK_A3": "CYP", + "BRK_NAME": "Cyprus", + "BRK_GROUP": null, + "ABBREV": "Cyp.", + "POSTAL": "CY", + "FORMAL_EN": "Republic of Cyprus", + "FORMAL_FR": null, + "NAME_CIAWF": "Cyprus", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Cyprus", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 7, + "POP_EST": 1221549, + "POP_RANK": 12, + "GDP_MD_EST": 29260, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "CY", + "ISO_A2": "CY", + "ISO_A3": "CYP", + "ISO_A3_EH": "CYP", + "ISO_N3": "196", + "UN_A3": "196", + "WB_A2": "CY", + "WB_A3": "CYP", + "WOE_ID": -90, + "WOE_ID_EH": 23424994, + "WOE_NOTE": "WOE lists as subunit of united Cyprus", + "ADM0_A3_IS": "CYP", + "ADM0_A3_US": "CYP", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9.5 + }, + "bbox": [32.256667, 34.571869, 34.004881, 35.173125], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [32.73178, 35.140026], + [32.919572, 35.087833], + [33.190977, 35.173125], + [33.383833, 35.162712], + [33.455922, 35.101424], + [33.475817, 35.000345], + [33.525685, 35.038688], + [33.675392, 35.017863], + [33.86644, 35.093595], + [33.973617, 35.058506], + [34.004881, 34.978098], + [32.979827, 34.571869], + [32.490296, 34.701655], + [32.256667, 35.103232], + [32.73178, 35.140026] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Czechia", + "SOV_A3": "CZE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Czechia", + "ADM0_A3": "CZE", + "GEOU_DIF": 0, + "GEOUNIT": "Czechia", + "GU_A3": "CZE", + "SU_DIF": 0, + "SUBUNIT": "Czechia", + "SU_A3": "CZE", + "BRK_DIFF": 0, + "NAME": "Czechia", + "NAME_LONG": "Czech Republic", + "BRK_A3": "CZE", + "BRK_NAME": "Czechia", + "BRK_GROUP": null, + "ABBREV": "Cz.", + "POSTAL": "CZ", + "FORMAL_EN": "Czech Republic", + "FORMAL_FR": "la République tchèque", + "NAME_CIAWF": "Czechia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Czechia", + "NAME_ALT": "Česko", + "MAPCOLOR7": 1, + "MAPCOLOR8": 1, + "MAPCOLOR9": 2, + "MAPCOLOR13": 6, + "POP_EST": 10674723, + "POP_RANK": 14, + "GDP_MD_EST": 350900, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "EZ", + "ISO_A2": "CZ", + "ISO_A3": "CZE", + "ISO_A3_EH": "CZE", + "ISO_N3": "203", + "UN_A3": "203", + "WB_A2": "CZ", + "WB_A3": "CZE", + "WOE_ID": 23424810, + "WOE_ID_EH": 23424810, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "CZE", + "ADM0_A3_US": "CZE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 14, + "ABBREV_LEN": 3, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [12.240111, 48.555305, 18.853144, 51.117268], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [16.960288, 48.596982], + [16.499283, 48.785808], + [16.029647, 48.733899], + [15.253416, 49.039074], + [14.901447, 48.964402], + [14.338898, 48.555305], + [13.595946, 48.877172], + [13.031329, 49.307068], + [12.521024, 49.547415], + [12.415191, 49.969121], + [12.240111, 50.266338], + [12.966837, 50.484076], + [13.338132, 50.733234], + [14.056228, 50.926918], + [14.307013, 51.117268], + [14.570718, 51.002339], + [15.016996, 51.106674], + [15.490972, 50.78473], + [16.238627, 50.697733], + [16.176253, 50.422607], + [16.719476, 50.215747], + [16.868769, 50.473974], + [17.554567, 50.362146], + [17.649445, 50.049038], + [18.392914, 49.988629], + [18.853144, 49.49623], + [18.554971, 49.495015], + [18.399994, 49.315001], + [18.170498, 49.271515], + [18.104973, 49.043983], + [17.913512, 48.996493], + [17.886485, 48.903475], + [17.545007, 48.800019], + [17.101985, 48.816969], + [16.960288, 48.596982] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Germany", + "SOV_A3": "DEU", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Germany", + "ADM0_A3": "DEU", + "GEOU_DIF": 0, + "GEOUNIT": "Germany", + "GU_A3": "DEU", + "SU_DIF": 0, + "SUBUNIT": "Germany", + "SU_A3": "DEU", + "BRK_DIFF": 0, + "NAME": "Germany", + "NAME_LONG": "Germany", + "BRK_A3": "DEU", + "BRK_NAME": "Germany", + "BRK_GROUP": null, + "ABBREV": "Ger.", + "POSTAL": "D", + "FORMAL_EN": "Federal Republic of Germany", + "FORMAL_FR": null, + "NAME_CIAWF": "Germany", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Germany", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 5, + "MAPCOLOR9": 5, + "MAPCOLOR13": 1, + "POP_EST": 80594017, + "POP_RANK": 16, + "GDP_MD_EST": 3979000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "GM", + "ISO_A2": "DE", + "ISO_A3": "DEU", + "ISO_A3_EH": "DEU", + "ISO_N3": "276", + "UN_A3": "276", + "WB_A2": "DE", + "WB_A3": "DEU", + "WOE_ID": 23424829, + "WOE_ID_EH": 23424829, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "DEU", + "ADM0_A3_US": "DEU", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [5.988658, 47.302488, 15.016996, 54.983104], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [13.595946, 48.877172], + [13.243357, 48.416115], + [12.884103, 48.289146], + [13.025851, 47.637584], + [12.932627, 47.467646], + [12.62076, 47.672388], + [12.141357, 47.703083], + [11.426414, 47.523766], + [10.544504, 47.566399], + [10.402084, 47.302488], + [9.896068, 47.580197], + [9.594226, 47.525058], + [8.522612, 47.830828], + [8.317301, 47.61358], + [7.466759, 47.620582], + [7.593676, 48.333019], + [8.099279, 49.017784], + [6.65823, 49.201958], + [6.18632, 49.463803], + [6.242751, 49.902226], + [6.043073, 50.128052], + [6.156658, 50.803721], + [5.988658, 51.851616], + [6.589397, 51.852029], + [6.84287, 52.22844], + [7.092053, 53.144043], + [6.90514, 53.482162], + [7.100425, 53.693932], + [7.936239, 53.748296], + [8.121706, 53.527792], + [8.800734, 54.020786], + [8.572118, 54.395646], + [8.526229, 54.962744], + [9.282049, 54.830865], + [9.921906, 54.983104], + [9.93958, 54.596642], + [10.950112, 54.363607], + [10.939467, 54.008693], + [11.956252, 54.196486], + [12.51844, 54.470371], + [13.647467, 54.075511], + [14.119686, 53.757029], + [14.353315, 53.248171], + [14.074521, 52.981263], + [14.4376, 52.62485], + [14.685026, 52.089947], + [14.607098, 51.745188], + [15.016996, 51.106674], + [14.570718, 51.002339], + [14.307013, 51.117268], + [14.056228, 50.926918], + [13.338132, 50.733234], + [12.966837, 50.484076], + [12.240111, 50.266338], + [12.415191, 49.969121], + [12.521024, 49.547415], + [13.031329, 49.307068], + [13.595946, 48.877172] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Djibouti", + "SOV_A3": "DJI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Djibouti", + "ADM0_A3": "DJI", + "GEOU_DIF": 0, + "GEOUNIT": "Djibouti", + "GU_A3": "DJI", + "SU_DIF": 0, + "SUBUNIT": "Djibouti", + "SU_A3": "DJI", + "BRK_DIFF": 0, + "NAME": "Djibouti", + "NAME_LONG": "Djibouti", + "BRK_A3": "DJI", + "BRK_NAME": "Djibouti", + "BRK_GROUP": null, + "ABBREV": "Dji.", + "POSTAL": "DJ", + "FORMAL_EN": "Republic of Djibouti", + "FORMAL_FR": null, + "NAME_CIAWF": "Djibouti", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Djibouti", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 4, + "MAPCOLOR13": 8, + "POP_EST": 865267, + "POP_RANK": 11, + "GDP_MD_EST": 3345, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "DJ", + "ISO_A2": "DJ", + "ISO_A3": "DJI", + "ISO_A3_EH": "DJI", + "ISO_N3": "262", + "UN_A3": "262", + "WB_A2": "DJ", + "WB_A3": "DJI", + "WOE_ID": 23424797, + "WOE_ID_EH": 23424797, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "DJI", + "ADM0_A3_US": "DJI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [41.66176, 10.926879, 43.317852, 12.699639], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [43.081226, 12.699639], + [43.317852, 12.390148], + [43.286381, 11.974928], + [42.715874, 11.735641], + [43.145305, 11.46204], + [42.776852, 10.926879], + [42.55493, 11.10511], + [42.31414, 11.0342], + [41.75557, 11.05091], + [41.73959, 11.35511], + [41.66176, 11.6312], + [42, 12.1], + [42.35156, 12.54223], + [42.779642, 12.455416], + [43.081226, 12.699639] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Denmark", + "SOV_A3": "DN1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "Denmark", + "ADM0_A3": "DNK", + "GEOU_DIF": 0, + "GEOUNIT": "Denmark", + "GU_A3": "DNK", + "SU_DIF": 0, + "SUBUNIT": "Denmark", + "SU_A3": "DNK", + "BRK_DIFF": 0, + "NAME": "Denmark", + "NAME_LONG": "Denmark", + "BRK_A3": "DNK", + "BRK_NAME": "Denmark", + "BRK_GROUP": null, + "ABBREV": "Den.", + "POSTAL": "DK", + "FORMAL_EN": "Kingdom of Denmark", + "FORMAL_FR": null, + "NAME_CIAWF": "Denmark", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Denmark", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 12, + "POP_EST": 5605948, + "POP_RANK": 13, + "GDP_MD_EST": 264800, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "DA", + "ISO_A2": "DK", + "ISO_A3": "DNK", + "ISO_A3_EH": "DNK", + "ISO_N3": "208", + "UN_A3": "208", + "WB_A2": "DK", + "WB_A3": "DNK", + "WOE_ID": 23424796, + "WOE_ID_EH": 23424796, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "DNK", + "ADM0_A3_US": "DNK", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [8.089977, 54.800015, 12.690006, 57.730017], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [9.921906, 54.983104], + [9.282049, 54.830865], + [8.526229, 54.962744], + [8.120311, 55.517723], + [8.089977, 56.540012], + [8.256582, 56.809969], + [8.543438, 57.110003], + [9.424469, 57.172066], + [9.775559, 57.447941], + [10.580006, 57.730017], + [10.546106, 57.215733], + [10.25, 56.890016], + [10.369993, 56.609982], + [10.912182, 56.458621], + [10.667804, 56.081383], + [10.369993, 56.190007], + [9.649985, 55.469999], + [9.921906, 54.983104] + ] + ], + [ + [ + [12.690006, 55.609991], + [12.089991, 54.800015], + [11.043543, 55.364864], + [10.903914, 55.779955], + [12.370904, 56.111407], + [12.690006, 55.609991] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Dominican Republic", + "SOV_A3": "DOM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Dominican Republic", + "ADM0_A3": "DOM", + "GEOU_DIF": 0, + "GEOUNIT": "Dominican Republic", + "GU_A3": "DOM", + "SU_DIF": 0, + "SUBUNIT": "Dominican Republic", + "SU_A3": "DOM", + "BRK_DIFF": 0, + "NAME": "Dominican Rep.", + "NAME_LONG": "Dominican Republic", + "BRK_A3": "DOM", + "BRK_NAME": "Dominican Rep.", + "BRK_GROUP": null, + "ABBREV": "Dom. Rep.", + "POSTAL": "DO", + "FORMAL_EN": "Dominican Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Dominican Republic", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Dominican Republic", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 7, + "POP_EST": 10734247, + "POP_RANK": 14, + "GDP_MD_EST": 161900, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "DR", + "ISO_A2": "DO", + "ISO_A3": "DOM", + "ISO_A3_EH": "DOM", + "ISO_N3": "214", + "UN_A3": "214", + "WB_A2": "DO", + "WB_A3": "DOM", + "WOE_ID": 23424800, + "WOE_ID_EH": 23424800, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "DOM", + "ADM0_A3_US": "DOM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 14, + "LONG_LEN": 18, + "ABBREV_LEN": 9, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9.5 + }, + "bbox": [-71.945112, 17.598564, -68.317943, 19.884911], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-71.712361, 19.714456], + [-71.587304, 19.884911], + [-70.806706, 19.880286], + [-70.214365, 19.622885], + [-69.950815, 19.648], + [-69.76925, 19.293267], + [-69.222126, 19.313214], + [-69.254346, 19.015196], + [-68.809412, 18.979074], + [-68.317943, 18.612198], + [-68.689316, 18.205142], + [-69.164946, 18.422648], + [-69.623988, 18.380713], + [-69.952934, 18.428307], + [-70.133233, 18.245915], + [-70.517137, 18.184291], + [-70.669298, 18.426886], + [-70.99995, 18.283329], + [-71.40021, 17.598564], + [-71.657662, 17.757573], + [-71.708305, 18.044997], + [-71.687738, 18.31666], + [-71.945112, 18.6169], + [-71.701303, 18.785417], + [-71.624873, 19.169838], + [-71.712361, 19.714456] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Algeria", + "SOV_A3": "DZA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Algeria", + "ADM0_A3": "DZA", + "GEOU_DIF": 0, + "GEOUNIT": "Algeria", + "GU_A3": "DZA", + "SU_DIF": 0, + "SUBUNIT": "Algeria", + "SU_A3": "DZA", + "BRK_DIFF": 0, + "NAME": "Algeria", + "NAME_LONG": "Algeria", + "BRK_A3": "DZA", + "BRK_NAME": "Algeria", + "BRK_GROUP": null, + "ABBREV": "Alg.", + "POSTAL": "DZ", + "FORMAL_EN": "People's Democratic Republic of Algeria", + "FORMAL_FR": null, + "NAME_CIAWF": "Algeria", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Algeria", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 1, + "MAPCOLOR9": 6, + "MAPCOLOR13": 3, + "POP_EST": 40969443, + "POP_RANK": 15, + "GDP_MD_EST": 609400, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "AG", + "ISO_A2": "DZ", + "ISO_A3": "DZA", + "ISO_A3_EH": "DZA", + "ISO_N3": "012", + "UN_A3": "012", + "WB_A2": "DZ", + "WB_A3": "DZA", + "WOE_ID": 23424740, + "WOE_ID_EH": 23424740, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "DZA", + "ADM0_A3_US": "DZA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [-8.6844, 19.057364, 11.999506, 37.118381], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [4.267419, 19.155265], + [3.158133, 19.057364], + [3.146661, 19.693579], + [2.683588, 19.85623], + [2.060991, 20.142233], + [1.823228, 20.610809], + [-1.550055, 22.792666], + [-4.923337, 24.974574], + [-8.6844, 27.395744], + [-8.665124, 27.589479], + [-8.66559, 27.656426], + [-8.674116, 28.841289], + [-7.059228, 29.579228], + [-6.060632, 29.7317], + [-5.242129, 30.000443], + [-4.859646, 30.501188], + [-3.690441, 30.896952], + [-3.647498, 31.637294], + [-3.06898, 31.724498], + [-2.616605, 32.094346], + [-1.307899, 32.262889], + [-1.124551, 32.651522], + [-1.388049, 32.864015], + [-1.733455, 33.919713], + [-1.792986, 34.527919], + [-2.169914, 35.168396], + [-1.208603, 35.714849], + [-0.127454, 35.888662], + [0.503877, 36.301273], + [1.466919, 36.605647], + [3.161699, 36.783905], + [4.815758, 36.865037], + [5.32012, 36.716519], + [6.26182, 37.110655], + [7.330385, 37.118381], + [7.737078, 36.885708], + [8.420964, 36.946427], + [8.217824, 36.433177], + [8.376368, 35.479876], + [8.140981, 34.655146], + [7.524482, 34.097376], + [7.612642, 33.344115], + [8.430473, 32.748337], + [8.439103, 32.506285], + [9.055603, 32.102692], + [9.48214, 30.307556], + [9.805634, 29.424638], + [9.859998, 28.95999], + [9.683885, 28.144174], + [9.756128, 27.688259], + [9.629056, 27.140953], + [9.716286, 26.512206], + [9.319411, 26.094325], + [9.910693, 25.365455], + [9.948261, 24.936954], + [10.303847, 24.379313], + [10.771364, 24.562532], + [11.560669, 24.097909], + [11.999506, 23.471668], + [8.572893, 21.565661], + [5.677566, 19.601207], + [4.267419, 19.155265] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Ecuador", + "SOV_A3": "ECU", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Ecuador", + "ADM0_A3": "ECU", + "GEOU_DIF": 0, + "GEOUNIT": "Ecuador", + "GU_A3": "ECU", + "SU_DIF": 0, + "SUBUNIT": "Ecuador", + "SU_A3": "ECU", + "BRK_DIFF": 0, + "NAME": "Ecuador", + "NAME_LONG": "Ecuador", + "BRK_A3": "ECU", + "BRK_NAME": "Ecuador", + "BRK_GROUP": null, + "ABBREV": "Ecu.", + "POSTAL": "EC", + "FORMAL_EN": "Republic of Ecuador", + "FORMAL_FR": null, + "NAME_CIAWF": "Ecuador", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Ecuador", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 5, + "MAPCOLOR9": 2, + "MAPCOLOR13": 12, + "POP_EST": 16290913, + "POP_RANK": 14, + "GDP_MD_EST": 182400, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "EC", + "ISO_A2": "EC", + "ISO_A3": "ECU", + "ISO_A3_EH": "ECU", + "ISO_N3": "218", + "UN_A3": "218", + "WB_A2": "EC", + "WB_A3": "ECU", + "WOE_ID": 23424801, + "WOE_ID_EH": 23424801, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ECU", + "ADM0_A3_US": "ECU", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-80.967765, -4.959129, -75.233723, 1.380924], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-78.855259, 1.380924], + [-77.855061, 0.809925], + [-77.668613, 0.825893], + [-77.424984, 0.395687], + [-76.57638, 0.256936], + [-76.292314, 0.416047], + [-75.801466, 0.084801], + [-75.373223, -0.152032], + [-75.233723, -0.911417], + [-75.544996, -1.56161], + [-76.635394, -2.608678], + [-77.837905, -3.003021], + [-78.450684, -3.873097], + [-78.639897, -4.547784], + [-79.205289, -4.959129], + [-79.624979, -4.454198], + [-80.028908, -4.346091], + [-80.442242, -4.425724], + [-80.469295, -4.059287], + [-80.184015, -3.821162], + [-80.302561, -3.404856], + [-79.770293, -2.657512], + [-79.986559, -2.220794], + [-80.368784, -2.685159], + [-80.967765, -2.246943], + [-80.764806, -1.965048], + [-80.933659, -1.057455], + [-80.58337, -0.906663], + [-80.399325, -0.283703], + [-80.020898, 0.36034], + [-80.09061, 0.768429], + [-79.542762, 0.982938], + [-78.855259, 1.380924] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Egypt", + "SOV_A3": "EGY", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Egypt", + "ADM0_A3": "EGY", + "GEOU_DIF": 0, + "GEOUNIT": "Egypt", + "GU_A3": "EGY", + "SU_DIF": 0, + "SUBUNIT": "Egypt", + "SU_A3": "EGY", + "BRK_DIFF": 0, + "NAME": "Egypt", + "NAME_LONG": "Egypt", + "BRK_A3": "EGY", + "BRK_NAME": "Egypt", + "BRK_GROUP": null, + "ABBREV": "Egypt", + "POSTAL": "EG", + "FORMAL_EN": "Arab Republic of Egypt", + "FORMAL_FR": null, + "NAME_CIAWF": "Egypt", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Egypt, Arab Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 6, + "MAPCOLOR9": 7, + "MAPCOLOR13": 2, + "POP_EST": 97041072, + "POP_RANK": 16, + "GDP_MD_EST": 1105000, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "EG", + "ISO_A2": "EG", + "ISO_A3": "EGY", + "ISO_A3_EH": "EGY", + "ISO_N3": "818", + "UN_A3": "818", + "WB_A2": "EG", + "WB_A3": "EGY", + "WOE_ID": 23424802, + "WOE_ID_EH": 23424802, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "EGY", + "ADM0_A3_US": "EGY", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [24.70007, 22, 36.86623, 31.58568], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [36.86623, 22], + [32.9, 22], + [29.02, 22], + [25, 22], + [25, 25.6825], + [25, 29.238655], + [24.70007, 30.04419], + [24.95762, 30.6616], + [24.80287, 31.08929], + [25.16482, 31.56915], + [26.49533, 31.58568], + [27.45762, 31.32126], + [28.45048, 31.02577], + [28.91353, 30.87005], + [29.68342, 31.18686], + [30.09503, 31.4734], + [30.97693, 31.55586], + [31.68796, 31.4296], + [31.96041, 30.9336], + [32.19247, 31.26034], + [32.99392, 31.02407], + [33.7734, 30.96746], + [34.265435, 31.219357], + [34.26544, 31.21936], + [34.823243, 29.761081], + [34.9226, 29.50133], + [34.64174, 29.09942], + [34.42655, 28.34399], + [34.15451, 27.8233], + [33.92136, 27.6487], + [33.58811, 27.97136], + [33.13676, 28.41765], + [32.42323, 29.85108], + [32.32046, 29.76043], + [32.73482, 28.70523], + [33.34876, 27.69989], + [34.10455, 26.14227], + [34.47387, 25.59856], + [34.79507, 25.03375], + [35.69241, 23.92671], + [35.49372, 23.75237], + [35.52598, 23.10244], + [36.69069, 22.20485], + [36.86623, 22] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Eritrea", + "SOV_A3": "ERI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Eritrea", + "ADM0_A3": "ERI", + "GEOU_DIF": 0, + "GEOUNIT": "Eritrea", + "GU_A3": "ERI", + "SU_DIF": 0, + "SUBUNIT": "Eritrea", + "SU_A3": "ERI", + "BRK_DIFF": 0, + "NAME": "Eritrea", + "NAME_LONG": "Eritrea", + "BRK_A3": "ERI", + "BRK_NAME": "Eritrea", + "BRK_GROUP": null, + "ABBREV": "Erit.", + "POSTAL": "ER", + "FORMAL_EN": "State of Eritrea", + "FORMAL_FR": null, + "NAME_CIAWF": "Eritrea", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Eritrea", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 2, + "MAPCOLOR13": 12, + "POP_EST": 5918919, + "POP_RANK": 13, + "GDP_MD_EST": 9169, + "POP_YEAR": 2017, + "LASTCENSUS": 1984, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "ER", + "ISO_A2": "ER", + "ISO_A3": "ERI", + "ISO_A3_EH": "ERI", + "ISO_N3": "232", + "UN_A3": "232", + "WB_A2": "ER", + "WB_A3": "ERI", + "WOE_ID": 23424806, + "WOE_ID_EH": 23424806, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ERI", + "ADM0_A3_US": "ERI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [36.32322, 12.455416, 43.081226, 17.998307], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [43.081226, 12.699639], + [42.779642, 12.455416], + [42.35156, 12.54223], + [42.00975, 12.86582], + [41.59856, 13.45209], + [41.1552, 13.77333], + [40.8966, 14.11864], + [40.02625, 14.51959], + [39.34061, 14.53155], + [39.0994, 14.74064], + [38.51295, 14.50547], + [37.90607, 14.95943], + [37.59377, 14.2131], + [36.42951, 14.42211], + [36.32322, 14.82249], + [36.75389, 16.29186], + [36.85253, 16.95655], + [37.16747, 17.26314], + [37.904, 17.42754], + [38.41009, 17.998307], + [38.990623, 16.840626], + [39.26611, 15.922723], + [39.814294, 15.435647], + [41.179275, 14.49108], + [41.734952, 13.921037], + [42.276831, 13.343992], + [42.589576, 13.000421], + [43.081226, 12.699639] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Spain", + "SOV_A3": "ESP", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Spain", + "ADM0_A3": "ESP", + "GEOU_DIF": 0, + "GEOUNIT": "Spain", + "GU_A3": "ESP", + "SU_DIF": 0, + "SUBUNIT": "Spain", + "SU_A3": "ESP", + "BRK_DIFF": 0, + "NAME": "Spain", + "NAME_LONG": "Spain", + "BRK_A3": "ESP", + "BRK_NAME": "Spain", + "BRK_GROUP": null, + "ABBREV": "Sp.", + "POSTAL": "E", + "FORMAL_EN": "Kingdom of Spain", + "FORMAL_FR": null, + "NAME_CIAWF": "Spain", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Spain", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 5, + "MAPCOLOR9": 5, + "MAPCOLOR13": 5, + "POP_EST": 48958159, + "POP_RANK": 15, + "GDP_MD_EST": 1690000, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "SP", + "ISO_A2": "ES", + "ISO_A3": "ESP", + "ISO_A3_EH": "ESP", + "ISO_N3": "724", + "UN_A3": "724", + "WB_A2": "ES", + "WB_A3": "ESP", + "WOE_ID": 23424950, + "WOE_ID_EH": 23424950, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ESP", + "ADM0_A3_US": "ESP", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 3, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [-9.392884, 35.94685, 3.039484, 43.748338], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-9.034818, 41.880571], + [-8.984433, 42.592775], + [-9.392884, 43.026625], + [-7.97819, 43.748338], + [-6.754492, 43.567909], + [-5.411886, 43.57424], + [-4.347843, 43.403449], + [-3.517532, 43.455901], + [-1.901351, 43.422802], + [-1.502771, 43.034014], + [0.338047, 42.579546], + [0.701591, 42.795734], + [1.826793, 42.343385], + [2.985999, 42.473015], + [3.039484, 41.89212], + [2.091842, 41.226089], + [0.810525, 41.014732], + [0.721331, 40.678318], + [0.106692, 40.123934], + [-0.278711, 39.309978], + [0.111291, 38.738514], + [-0.467124, 38.292366], + [-0.683389, 37.642354], + [-1.438382, 37.443064], + [-2.146453, 36.674144], + [-3.415781, 36.6589], + [-4.368901, 36.677839], + [-4.995219, 36.324708], + [-5.37716, 35.94685], + [-5.866432, 36.029817], + [-6.236694, 36.367677], + [-6.520191, 36.942913], + [-7.453726, 37.097788], + [-7.537105, 37.428904], + [-7.166508, 37.803894], + [-7.029281, 38.075764], + [-7.374092, 38.373059], + [-7.098037, 39.030073], + [-7.498632, 39.629571], + [-7.066592, 39.711892], + [-7.026413, 40.184524], + [-6.86402, 40.330872], + [-6.851127, 41.111083], + [-6.389088, 41.381815], + [-6.668606, 41.883387], + [-7.251309, 41.918346], + [-7.422513, 41.792075], + [-8.013175, 41.790886], + [-8.263857, 42.280469], + [-8.671946, 42.134689], + [-9.034818, 41.880571] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Estonia", + "SOV_A3": "EST", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Estonia", + "ADM0_A3": "EST", + "GEOU_DIF": 0, + "GEOUNIT": "Estonia", + "GU_A3": "EST", + "SU_DIF": 0, + "SUBUNIT": "Estonia", + "SU_A3": "EST", + "BRK_DIFF": 0, + "NAME": "Estonia", + "NAME_LONG": "Estonia", + "BRK_A3": "EST", + "BRK_NAME": "Estonia", + "BRK_GROUP": null, + "ABBREV": "Est.", + "POSTAL": "EST", + "FORMAL_EN": "Republic of Estonia", + "FORMAL_FR": null, + "NAME_CIAWF": "Estonia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Estonia", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 1, + "MAPCOLOR13": 10, + "POP_EST": 1251581, + "POP_RANK": 12, + "GDP_MD_EST": 38700, + "POP_YEAR": 2017, + "LASTCENSUS": 2000, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "EN", + "ISO_A2": "EE", + "ISO_A3": "EST", + "ISO_A3_EH": "EST", + "ISO_N3": "233", + "UN_A3": "233", + "WB_A2": "EE", + "WB_A3": "EST", + "WOE_ID": 23424805, + "WOE_ID_EH": 23424805, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "EST", + "ADM0_A3_US": "EST", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [23.339795, 57.474528, 28.131699, 59.61109], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [24.312863, 57.793424], + [24.428928, 58.383413], + [24.061198, 58.257375], + [23.42656, 58.612753], + [23.339795, 59.18724], + [24.604214, 59.465854], + [25.864189, 59.61109], + [26.949136, 59.445803], + [27.981114, 59.475388], + [27.981127, 59.475373], + [27.98112, 59.47537], + [28.131699, 59.300825], + [27.42015, 58.72457], + [27.716686, 57.791899], + [27.288185, 57.474528], + [26.463532, 57.476389], + [25.60281, 57.847529], + [25.164594, 57.970157], + [24.312863, 57.793424] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Ethiopia", + "SOV_A3": "ETH", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Ethiopia", + "ADM0_A3": "ETH", + "GEOU_DIF": 0, + "GEOUNIT": "Ethiopia", + "GU_A3": "ETH", + "SU_DIF": 0, + "SUBUNIT": "Ethiopia", + "SU_A3": "ETH", + "BRK_DIFF": 0, + "NAME": "Ethiopia", + "NAME_LONG": "Ethiopia", + "BRK_A3": "ETH", + "BRK_NAME": "Ethiopia", + "BRK_GROUP": null, + "ABBREV": "Eth.", + "POSTAL": "ET", + "FORMAL_EN": "Federal Democratic Republic of Ethiopia", + "FORMAL_FR": null, + "NAME_CIAWF": "Ethiopia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Ethiopia", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 13, + "POP_EST": 105350020, + "POP_RANK": 17, + "GDP_MD_EST": 174700, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "ET", + "ISO_A2": "ET", + "ISO_A3": "ETH", + "ISO_A3_EH": "ETH", + "ISO_N3": "231", + "UN_A3": "231", + "WB_A2": "ET", + "WB_A3": "ETH", + "WOE_ID": 23424808, + "WOE_ID_EH": 23424808, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ETH", + "ADM0_A3_US": "ETH", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [32.95418, 3.42206, 47.78942, 14.95943], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [42.35156, 12.54223], + [42, 12.1], + [41.66176, 11.6312], + [41.73959, 11.35511], + [41.75557, 11.05091], + [42.31414, 11.0342], + [42.55493, 11.10511], + [42.776852, 10.926879], + [42.55876, 10.57258], + [42.92812, 10.02194], + [43.29699, 9.54048], + [43.67875, 9.18358], + [46.94834, 7.99688], + [47.78942, 8.003], + [44.9636, 5.00162], + [43.66087, 4.95755], + [42.76967, 4.25259], + [42.12861, 4.23413], + [41.855083, 3.918912], + [41.1718, 3.91909], + [40.76848, 4.25702], + [39.85494, 3.83879], + [39.559384, 3.42206], + [38.89251, 3.50074], + [38.67114, 3.61607], + [38.43697, 3.58851], + [38.120915, 3.598605], + [36.855093, 4.447864], + [36.159079, 4.447864], + [35.817448, 4.776966], + [35.817448, 5.338232], + [35.298007, 5.506], + [34.70702, 6.59422], + [34.25032, 6.82607], + [34.0751, 7.22595], + [33.56829, 7.71334], + [32.95418, 7.78497], + [33.2948, 8.35458], + [33.8255, 8.37916], + [33.97498, 8.68456], + [33.96162, 9.58358], + [34.25745, 10.63009], + [34.73115, 10.91017], + [34.83163, 11.31896], + [35.26049, 12.08286], + [35.86363, 12.57828], + [36.27022, 13.56333], + [36.42951, 14.42211], + [37.59377, 14.2131], + [37.90607, 14.95943], + [38.51295, 14.50547], + [39.0994, 14.74064], + [39.34061, 14.53155], + [40.02625, 14.51959], + [40.8966, 14.11864], + [41.1552, 13.77333], + [41.59856, 13.45209], + [42.00975, 12.86582], + [42.35156, 12.54223] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Finland", + "SOV_A3": "FI1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "Finland", + "ADM0_A3": "FIN", + "GEOU_DIF": 0, + "GEOUNIT": "Finland", + "GU_A3": "FIN", + "SU_DIF": 0, + "SUBUNIT": "Finland", + "SU_A3": "FIN", + "BRK_DIFF": 0, + "NAME": "Finland", + "NAME_LONG": "Finland", + "BRK_A3": "FIN", + "BRK_NAME": "Finland", + "BRK_GROUP": null, + "ABBREV": "Fin.", + "POSTAL": "FIN", + "FORMAL_EN": "Republic of Finland", + "FORMAL_FR": null, + "NAME_CIAWF": "Finland", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Finland", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 4, + "MAPCOLOR13": 6, + "POP_EST": 5491218, + "POP_RANK": 13, + "GDP_MD_EST": 224137, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "FI", + "ISO_A2": "FI", + "ISO_A3": "FIN", + "ISO_A3_EH": "FIN", + "ISO_N3": "246", + "UN_A3": "246", + "WB_A2": "FI", + "WB_A3": "FIN", + "WOE_ID": 23424812, + "WOE_ID_EH": 23424812, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "FIN", + "ADM0_A3_US": "FIN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [20.645593, 59.846373, 31.516092, 70.164193], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.903379, 66.006927], + [23.56588, 66.396051], + [23.539473, 67.936009], + [21.978535, 68.616846], + [20.645593, 69.106247], + [21.244936, 69.370443], + [22.356238, 68.841741], + [23.66205, 68.891247], + [24.735679, 68.649557], + [25.689213, 69.092114], + [26.179622, 69.825299], + [27.732292, 70.164193], + [29.015573, 69.766491], + [28.59193, 69.064777], + [28.445944, 68.364613], + [29.977426, 67.698297], + [29.054589, 66.944286], + [30.21765, 65.80598], + [29.54443, 64.948672], + [30.444685, 64.204453], + [30.035872, 63.552814], + [31.516092, 62.867687], + [31.139991, 62.357693], + [30.211107, 61.780028], + [28.07, 60.50352], + [28.070002, 60.503519], + [28.069998, 60.503517], + [26.255173, 60.423961], + [24.496624, 60.057316], + [22.869695, 59.846373], + [22.290764, 60.391921], + [21.322244, 60.72017], + [21.544866, 61.705329], + [21.059211, 62.607393], + [21.536029, 63.189735], + [22.442744, 63.81781], + [24.730512, 64.902344], + [25.398068, 65.111427], + [25.294043, 65.534346], + [23.903379, 66.006927] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Fiji", + "SOV_A3": "FJI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Fiji", + "ADM0_A3": "FJI", + "GEOU_DIF": 0, + "GEOUNIT": "Fiji", + "GU_A3": "FJI", + "SU_DIF": 0, + "SUBUNIT": "Fiji", + "SU_A3": "FJI", + "BRK_DIFF": 0, + "NAME": "Fiji", + "NAME_LONG": "Fiji", + "BRK_A3": "FJI", + "BRK_NAME": "Fiji", + "BRK_GROUP": null, + "ABBREV": "Fiji", + "POSTAL": "FJ", + "FORMAL_EN": "Republic of Fiji", + "FORMAL_FR": null, + "NAME_CIAWF": "Fiji", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Fiji", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 1, + "MAPCOLOR9": 2, + "MAPCOLOR13": 2, + "POP_EST": 920938, + "POP_RANK": 11, + "GDP_MD_EST": 8374, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "FJ", + "ISO_A2": "FJ", + "ISO_A3": "FJI", + "ISO_A3_EH": "FJI", + "ISO_N3": "242", + "UN_A3": "242", + "WB_A2": "FJ", + "WB_A3": "FJI", + "WOE_ID": 23424813, + "WOE_ID_EH": 23424813, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "FJI", + "ADM0_A3_US": "FJI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Melanesia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-180, -18.28799, 180, -16.020882], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [178.3736, -17.33992], + [178.71806, -17.62846], + [178.55271, -18.15059], + [177.93266, -18.28799], + [177.38146, -18.16432], + [177.28504, -17.72465], + [177.67087, -17.38114], + [178.12557, -17.50481], + [178.3736, -17.33992] + ] + ], + [ + [ + [179.364143, -16.801354], + [178.725059, -17.012042], + [178.596839, -16.63915], + [179.096609, -16.433984], + [179.413509, -16.379054], + [180, -16.067133], + [180, -16.555217], + [179.364143, -16.801354] + ] + ], + [ + [ + [-179.917369, -16.501783], + [-180, -16.555217], + [-180, -16.067133], + [-179.79332, -16.020882], + [-179.917369, -16.501783] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "United Kingdom", + "SOV_A3": "GB1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Dependency", + "ADMIN": "Falkland Islands", + "ADM0_A3": "FLK", + "GEOU_DIF": 0, + "GEOUNIT": "Falkland Islands", + "GU_A3": "FLK", + "SU_DIF": 0, + "SUBUNIT": "Falkland Islands", + "SU_A3": "FLK", + "BRK_DIFF": 1, + "NAME": "Falkland Is.", + "NAME_LONG": "Falkland Islands", + "BRK_A3": "B12", + "BRK_NAME": "Falkland Is.", + "BRK_GROUP": null, + "ABBREV": "Flk. Is.", + "POSTAL": "FK", + "FORMAL_EN": "Falkland Islands", + "FORMAL_FR": null, + "NAME_CIAWF": "Falkland Islands (Islas Malvinas)", + "NOTE_ADM0": "U.K.", + "NOTE_BRK": "Admin. by U.K.; Claimed by Argentina", + "NAME_SORT": "Falkland Islands", + "NAME_ALT": "Islas Malvinas", + "MAPCOLOR7": 6, + "MAPCOLOR8": 6, + "MAPCOLOR9": 6, + "MAPCOLOR13": 3, + "POP_EST": 2931, + "POP_RANK": 4, + "GDP_MD_EST": 281.8, + "POP_YEAR": 2014, + "LASTCENSUS": -99, + "GDP_YEAR": 2012, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "FK", + "ISO_A2": "FK", + "ISO_A3": "FLK", + "ISO_A3_EH": "FLK", + "ISO_N3": "238", + "UN_A3": "238", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": 23424814, + "WOE_ID_EH": 23424814, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "FLK", + "ADM0_A3_US": "FLK", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 12, + "LONG_LEN": 16, + "ABBREV_LEN": 8, + "TINY": -99, + "HOMEPART": -99, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9 + }, + "bbox": [-61.2, -52.3, -57.75, -51.1], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-61.2, -51.85], + [-60, -51.25], + [-59.15, -51.5], + [-58.55, -51.1], + [-57.75, -51.55], + [-58.05, -51.9], + [-59.4, -52.2], + [-59.85, -51.85], + [-60.7, -52.3], + [-61.2, -51.85] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "France", + "SOV_A3": "FR1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "France", + "ADM0_A3": "FRA", + "GEOU_DIF": 0, + "GEOUNIT": "France", + "GU_A3": "FRA", + "SU_DIF": 0, + "SUBUNIT": "France", + "SU_A3": "FRA", + "BRK_DIFF": 0, + "NAME": "France", + "NAME_LONG": "France", + "BRK_A3": "FRA", + "BRK_NAME": "France", + "BRK_GROUP": null, + "ABBREV": "Fr.", + "POSTAL": "F", + "FORMAL_EN": "French Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "France", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "France", + "NAME_ALT": null, + "MAPCOLOR7": 7, + "MAPCOLOR8": 5, + "MAPCOLOR9": 9, + "MAPCOLOR13": 11, + "POP_EST": 67106161, + "POP_RANK": 16, + "GDP_MD_EST": 2699000, + "POP_YEAR": 2017, + "LASTCENSUS": -99, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "FR", + "ISO_A2": "-99", + "ISO_A3": "-99", + "ISO_A3_EH": "-99", + "ISO_N3": "250", + "UN_A3": "250", + "WB_A2": "FR", + "WB_A3": "FRA", + "WOE_ID": -90, + "WOE_ID_EH": 23424819, + "WOE_NOTE": "Includes only Metropolitan France (including Corsica)", + "ADM0_A3_IS": "FRA", + "ADM0_A3_US": "FRA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 3, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [-54.524754, 2.053389, 9.560016, 51.148506], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [2.513573, 51.148506], + [2.658422, 50.796848], + [3.123252, 50.780363], + [3.588184, 50.378992], + [4.286023, 49.907497], + [4.799222, 49.985373], + [5.674052, 49.529484], + [5.897759, 49.442667], + [6.18632, 49.463803], + [6.65823, 49.201958], + [8.099279, 49.017784], + [7.593676, 48.333019], + [7.466759, 47.620582], + [7.192202, 47.449766], + [6.736571, 47.541801], + [6.768714, 47.287708], + [6.037389, 46.725779], + [6.022609, 46.27299], + [6.5001, 46.429673], + [6.843593, 45.991147], + [6.802355, 45.70858], + [7.096652, 45.333099], + [6.749955, 45.028518], + [7.007562, 44.254767], + [7.549596, 44.127901], + [7.435185, 43.693845], + [6.529245, 43.128892], + [4.556963, 43.399651], + [3.100411, 43.075201], + [2.985999, 42.473015], + [1.826793, 42.343385], + [0.701591, 42.795734], + [0.338047, 42.579546], + [-1.502771, 43.034014], + [-1.901351, 43.422802], + [-1.384225, 44.02261], + [-1.193798, 46.014918], + [-2.225724, 47.064363], + [-2.963276, 47.570327], + [-4.491555, 47.954954], + [-4.59235, 48.68416], + [-3.295814, 48.901692], + [-1.616511, 48.644421], + [-1.933494, 49.776342], + [-0.989469, 49.347376], + [1.338761, 50.127173], + [1.639001, 50.946606], + [2.513573, 51.148506] + ] + ], + [ + [ + [-51.657797, 4.156232], + [-52.249338, 3.241094], + [-52.556425, 2.504705], + [-52.939657, 2.124858], + [-53.418465, 2.053389], + [-53.554839, 2.334897], + [-53.778521, 2.376703], + [-54.088063, 2.105557], + [-54.524754, 2.311849], + [-54.27123, 2.738748], + [-54.184284, 3.194172], + [-54.011504, 3.62257], + [-54.399542, 4.212611], + [-54.478633, 4.896756], + [-53.958045, 5.756548], + [-53.618453, 5.646529], + [-52.882141, 5.409851], + [-51.823343, 4.565768], + [-51.657797, 4.156232] + ] + ], + [ + [ + [9.560016, 42.152492], + [9.229752, 41.380007], + [8.775723, 41.583612], + [8.544213, 42.256517], + [8.746009, 42.628122], + [9.390001, 43.009985], + [9.560016, 42.152492] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Gabon", + "SOV_A3": "GAB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Gabon", + "ADM0_A3": "GAB", + "GEOU_DIF": 0, + "GEOUNIT": "Gabon", + "GU_A3": "GAB", + "SU_DIF": 0, + "SUBUNIT": "Gabon", + "SU_A3": "GAB", + "BRK_DIFF": 0, + "NAME": "Gabon", + "NAME_LONG": "Gabon", + "BRK_A3": "GAB", + "BRK_NAME": "Gabon", + "BRK_GROUP": null, + "ABBREV": "Gabon", + "POSTAL": "GA", + "FORMAL_EN": "Gabonese Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Gabon", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Gabon", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 5, + "POP_EST": 1772255, + "POP_RANK": 12, + "GDP_MD_EST": 35980, + "POP_YEAR": 2017, + "LASTCENSUS": 2003, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "GB", + "ISO_A2": "GA", + "ISO_A3": "GAB", + "ISO_A3_EH": "GAB", + "ISO_N3": "266", + "UN_A3": "266", + "WB_A2": "GA", + "WB_A3": "GAB", + "WOE_ID": 23424822, + "WOE_ID_EH": 23424822, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GAB", + "ADM0_A3_US": "GAB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": 3, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [8.797996, -3.978827, 14.425456, 2.326758], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.276449, 2.261051], + [11.751665, 2.326758], + [12.35938, 2.192812], + [12.951334, 2.321616], + [13.075822, 2.267097], + [13.003114, 1.830896], + [13.282631, 1.314184], + [14.026669, 1.395677], + [14.276266, 1.19693], + [13.843321, 0.038758], + [14.316418, -0.552627], + [14.425456, -1.333407], + [14.29921, -1.998276], + [13.992407, -2.470805], + [13.109619, -2.42874], + [12.575284, -1.948511], + [12.495703, -2.391688], + [11.820964, -2.514161], + [11.478039, -2.765619], + [11.855122, -3.426871], + [11.093773, -3.978827], + [10.066135, -2.969483], + [9.405245, -2.144313], + [8.797996, -1.111301], + [8.830087, -0.779074], + [9.04842, -0.459351], + [9.291351, 0.268666], + [9.492889, 1.01012], + [9.830284, 1.067894], + [11.285079, 1.057662], + [11.276449, 2.261051] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "United Kingdom", + "SOV_A3": "GB1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "United Kingdom", + "ADM0_A3": "GBR", + "GEOU_DIF": 0, + "GEOUNIT": "United Kingdom", + "GU_A3": "GBR", + "SU_DIF": 0, + "SUBUNIT": "United Kingdom", + "SU_A3": "GBR", + "BRK_DIFF": 0, + "NAME": "United Kingdom", + "NAME_LONG": "United Kingdom", + "BRK_A3": "GBR", + "BRK_NAME": "United Kingdom", + "BRK_GROUP": null, + "ABBREV": "U.K.", + "POSTAL": "GB", + "FORMAL_EN": "United Kingdom of Great Britain and Northern Ireland", + "FORMAL_FR": null, + "NAME_CIAWF": "United Kingdom", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "United Kingdom", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 6, + "MAPCOLOR9": 6, + "MAPCOLOR13": 3, + "POP_EST": 64769452, + "POP_RANK": 16, + "GDP_MD_EST": 2788000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "UK", + "ISO_A2": "GB", + "ISO_A3": "GBR", + "ISO_A3_EH": "GBR", + "ISO_N3": "826", + "UN_A3": "826", + "WB_A2": "GB", + "WB_A3": "GBR", + "WOE_ID": -90, + "WOE_ID_EH": 23424975, + "WOE_NOTE": "Eh ID includes Channel Islands and Isle of Man. UK constituent countries of England (24554868), Wales (12578049), Scotland (12578048), and Northern Ireland (20070563).", + "ADM0_A3_IS": "GBR", + "ADM0_A3_US": "GBR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 14, + "LONG_LEN": 14, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [-7.572168, 49.96, 1.681531, 58.635], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-6.197885, 53.867565], + [-6.95373, 54.073702], + [-7.572168, 54.059956], + [-7.366031, 54.595841], + [-7.572168, 55.131622], + [-6.733847, 55.17286], + [-5.661949, 54.554603], + [-6.197885, 53.867565] + ] + ], + [ + [ + [-3.005005, 58.635], + [-4.073828, 57.553025], + [-3.055002, 57.690019], + [-1.959281, 57.6848], + [-2.219988, 56.870017], + [-3.119003, 55.973793], + [-2.085009, 55.909998], + [-2.005676, 55.804903], + [-1.114991, 54.624986], + [-0.430485, 54.464376], + [0.184981, 53.325014], + [0.469977, 52.929999], + [1.681531, 52.73952], + [1.559988, 52.099998], + [1.050562, 51.806761], + [1.449865, 51.289428], + [0.550334, 50.765739], + [-0.787517, 50.774989], + [-2.489998, 50.500019], + [-2.956274, 50.69688], + [-3.617448, 50.228356], + [-4.542508, 50.341837], + [-5.245023, 49.96], + [-5.776567, 50.159678], + [-4.30999, 51.210001], + [-3.414851, 51.426009], + [-3.422719, 51.426848], + [-4.984367, 51.593466], + [-5.267296, 51.9914], + [-4.222347, 52.301356], + [-4.770013, 52.840005], + [-4.579999, 53.495004], + [-3.093831, 53.404547], + [-3.09208, 53.404441], + [-2.945009, 53.985], + [-3.614701, 54.600937], + [-3.630005, 54.615013], + [-4.844169, 54.790971], + [-5.082527, 55.061601], + [-4.719112, 55.508473], + [-5.047981, 55.783986], + [-5.586398, 55.311146], + [-5.644999, 56.275015], + [-6.149981, 56.78501], + [-5.786825, 57.818848], + [-5.009999, 58.630013], + [-4.211495, 58.550845], + [-3.005005, 58.635] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Georgia", + "SOV_A3": "GEO", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Georgia", + "ADM0_A3": "GEO", + "GEOU_DIF": 0, + "GEOUNIT": "Georgia", + "GU_A3": "GEO", + "SU_DIF": 0, + "SUBUNIT": "Georgia", + "SU_A3": "GEO", + "BRK_DIFF": 0, + "NAME": "Georgia", + "NAME_LONG": "Georgia", + "BRK_A3": "GEO", + "BRK_NAME": "Georgia", + "BRK_GROUP": null, + "ABBREV": "Geo.", + "POSTAL": "GE", + "FORMAL_EN": "Georgia", + "FORMAL_FR": null, + "NAME_CIAWF": "Georgia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Georgia", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 2, + "POP_EST": 4926330, + "POP_RANK": 12, + "GDP_MD_EST": 37270, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "GG", + "ISO_A2": "GE", + "ISO_A3": "GEO", + "ISO_A3_EH": "GEO", + "ISO_N3": "268", + "UN_A3": "268", + "WB_A2": "GE", + "WB_A3": "GEO", + "WOE_ID": 23424823, + "WOE_ID_EH": 23424823, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GEO", + "ADM0_A3_US": "GEO", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [39.955009, 41.064445, 46.637908, 43.553104], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [44.97248, 41.248129], + [43.582746, 41.092143], + [42.619549, 41.583173], + [41.554084, 41.535656], + [41.703171, 41.962943], + [41.45347, 42.645123], + [40.875469, 43.013628], + [40.321394, 43.128634], + [39.955009, 43.434998], + [40.076965, 43.553104], + [40.92219, 43.38215], + [42.3944, 43.2203], + [43.75599, 42.74083], + [43.93121, 42.55496], + [44.537623, 42.711993], + [45.470279, 42.502781], + [45.7764, 42.09244], + [46.404951, 41.860675], + [46.145432, 41.722802], + [46.637908, 41.181673], + [46.501637, 41.064445], + [45.962601, 41.123873], + [45.217426, 41.411452], + [44.97248, 41.248129] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Ghana", + "SOV_A3": "GHA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Ghana", + "ADM0_A3": "GHA", + "GEOU_DIF": 0, + "GEOUNIT": "Ghana", + "GU_A3": "GHA", + "SU_DIF": 0, + "SUBUNIT": "Ghana", + "SU_A3": "GHA", + "BRK_DIFF": 0, + "NAME": "Ghana", + "NAME_LONG": "Ghana", + "BRK_A3": "GHA", + "BRK_NAME": "Ghana", + "BRK_GROUP": null, + "ABBREV": "Ghana", + "POSTAL": "GH", + "FORMAL_EN": "Republic of Ghana", + "FORMAL_FR": null, + "NAME_CIAWF": "Ghana", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Ghana", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 3, + "MAPCOLOR9": 1, + "MAPCOLOR13": 4, + "POP_EST": 27499924, + "POP_RANK": 15, + "GDP_MD_EST": 120800, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "GH", + "ISO_A2": "GH", + "ISO_A3": "GHA", + "ISO_A3_EH": "GHA", + "ISO_N3": "288", + "UN_A3": "288", + "WB_A2": "GH", + "WB_A3": "GHA", + "WOE_ID": 23424824, + "WOE_ID_EH": 23424824, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GHA", + "ADM0_A3_US": "GHA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-3.24437, 4.710462, 1.060122, 11.098341], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-2.827496, 9.642461], + [-2.963896, 10.395335], + [-2.940409, 10.96269], + [-1.203358, 11.009819], + [-0.761576, 10.93693], + [-0.438702, 11.098341], + [0.023803, 11.018682], + [-0.049785, 10.706918], + [0.36758, 10.191213], + [0.365901, 9.465004], + [0.461192, 8.677223], + [0.712029, 8.312465], + [0.490957, 7.411744], + [0.570384, 6.914359], + [0.836931, 6.279979], + [1.060122, 5.928837], + [-0.507638, 5.343473], + [-1.063625, 5.000548], + [-1.964707, 4.710462], + [-2.856125, 4.994476], + [-2.810701, 5.389051], + [-3.24437, 6.250472], + [-2.983585, 7.379705], + [-2.56219, 8.219628], + [-2.827496, 9.642461] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Guinea", + "SOV_A3": "GIN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Guinea", + "ADM0_A3": "GIN", + "GEOU_DIF": 0, + "GEOUNIT": "Guinea", + "GU_A3": "GIN", + "SU_DIF": 0, + "SUBUNIT": "Guinea", + "SU_A3": "GIN", + "BRK_DIFF": 0, + "NAME": "Guinea", + "NAME_LONG": "Guinea", + "BRK_A3": "GIN", + "BRK_NAME": "Guinea", + "BRK_GROUP": null, + "ABBREV": "Gin.", + "POSTAL": "GN", + "FORMAL_EN": "Republic of Guinea", + "FORMAL_FR": null, + "NAME_CIAWF": "Guinea", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Guinea", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 7, + "MAPCOLOR13": 2, + "POP_EST": 12413867, + "POP_RANK": 14, + "GDP_MD_EST": 16080, + "POP_YEAR": 2017, + "LASTCENSUS": 1996, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "GV", + "ISO_A2": "GN", + "ISO_A3": "GIN", + "ISO_A3_EH": "GIN", + "ISO_N3": "324", + "UN_A3": "324", + "WB_A2": "GN", + "WB_A3": "GIN", + "WOE_ID": 23424835, + "WOE_ID_EH": 23424835, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GIN", + "ADM0_A3_US": "GIN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-15.130311, 7.309037, -7.8321, 12.586183], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-8.029944, 10.206535], + [-8.229337, 10.12902], + [-8.309616, 9.789532], + [-8.079114, 9.376224], + [-7.8321, 8.575704], + [-8.203499, 8.455453], + [-8.299049, 8.316444], + [-8.221792, 8.123329], + [-8.280703, 7.68718], + [-8.439298, 7.686043], + [-8.722124, 7.711674], + [-8.926065, 7.309037], + [-9.208786, 7.313921], + [-9.403348, 7.526905], + [-9.33728, 7.928534], + [-9.755342, 8.541055], + [-10.016567, 8.428504], + [-10.230094, 8.406206], + [-10.505477, 8.348896], + [-10.494315, 8.715541], + [-10.65477, 8.977178], + [-10.622395, 9.26791], + [-10.839152, 9.688246], + [-11.117481, 10.045873], + [-11.917277, 10.046984], + [-12.150338, 9.858572], + [-12.425929, 9.835834], + [-12.596719, 9.620188], + [-12.711958, 9.342712], + [-13.24655, 8.903049], + [-13.685154, 9.494744], + [-14.074045, 9.886167], + [-14.330076, 10.01572], + [-14.579699, 10.214467], + [-14.693232, 10.656301], + [-14.839554, 10.876572], + [-15.130311, 11.040412], + [-14.685687, 11.527824], + [-14.382192, 11.509272], + [-14.121406, 11.677117], + [-13.9008, 11.678719], + [-13.743161, 11.811269], + [-13.828272, 12.142644], + [-13.718744, 12.247186], + [-13.700476, 12.586183], + [-13.217818, 12.575874], + [-12.499051, 12.33209], + [-12.278599, 12.35444], + [-12.203565, 12.465648], + [-11.658301, 12.386583], + [-11.513943, 12.442988], + [-11.456169, 12.076834], + [-11.297574, 12.077971], + [-11.036556, 12.211245], + [-10.87083, 12.177887], + [-10.593224, 11.923975], + [-10.165214, 11.844084], + [-9.890993, 12.060479], + [-9.567912, 12.194243], + [-9.327616, 12.334286], + [-9.127474, 12.30806], + [-8.905265, 12.088358], + [-8.786099, 11.812561], + [-8.376305, 11.393646], + [-8.581305, 11.136246], + [-8.620321, 10.810891], + [-8.407311, 10.909257], + [-8.282357, 10.792597], + [-8.335377, 10.494812], + [-8.029944, 10.206535] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Gambia", + "SOV_A3": "GMB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Gambia", + "ADM0_A3": "GMB", + "GEOU_DIF": 0, + "GEOUNIT": "Gambia", + "GU_A3": "GMB", + "SU_DIF": 0, + "SUBUNIT": "Gambia", + "SU_A3": "GMB", + "BRK_DIFF": 0, + "NAME": "Gambia", + "NAME_LONG": "The Gambia", + "BRK_A3": "GMB", + "BRK_NAME": "Gambia", + "BRK_GROUP": null, + "ABBREV": "Gambia", + "POSTAL": "GM", + "FORMAL_EN": "Republic of the Gambia", + "FORMAL_FR": null, + "NAME_CIAWF": "Gambia, The", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Gambia, The", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 8, + "POP_EST": 2051363, + "POP_RANK": 12, + "GDP_MD_EST": 3387, + "POP_YEAR": 2017, + "LASTCENSUS": 2003, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "GA", + "ISO_A2": "GM", + "ISO_A3": "GMB", + "ISO_A3_EH": "GMB", + "ISO_N3": "270", + "UN_A3": "270", + "WB_A2": "GM", + "WB_A3": "GMB", + "WOE_ID": 23424821, + "WOE_ID_EH": 23424821, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GMB", + "ADM0_A3_US": "GMB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 10, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [-16.841525, 13.130284, -13.844963, 13.876492], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-16.841525, 13.151394], + [-16.713729, 13.594959], + [-15.624596, 13.623587], + [-15.39877, 13.860369], + [-15.081735, 13.876492], + [-14.687031, 13.630357], + [-14.376714, 13.62568], + [-14.046992, 13.794068], + [-13.844963, 13.505042], + [-14.277702, 13.280585], + [-14.712197, 13.298207], + [-15.141163, 13.509512], + [-15.511813, 13.27857], + [-15.691001, 13.270353], + [-15.931296, 13.130284], + [-16.841525, 13.151394] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Guinea-Bissau", + "SOV_A3": "GNB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Guinea-Bissau", + "ADM0_A3": "GNB", + "GEOU_DIF": 0, + "GEOUNIT": "Guinea-Bissau", + "GU_A3": "GNB", + "SU_DIF": 0, + "SUBUNIT": "Guinea-Bissau", + "SU_A3": "GNB", + "BRK_DIFF": 0, + "NAME": "Guinea-Bissau", + "NAME_LONG": "Guinea-Bissau", + "BRK_A3": "GNB", + "BRK_NAME": "Guinea-Bissau", + "BRK_GROUP": null, + "ABBREV": "GnB.", + "POSTAL": "GW", + "FORMAL_EN": "Republic of Guinea-Bissau", + "FORMAL_FR": null, + "NAME_CIAWF": "Guinea-Bissau", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Guinea-Bissau", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 5, + "MAPCOLOR9": 3, + "MAPCOLOR13": 4, + "POP_EST": 1792338, + "POP_RANK": 12, + "GDP_MD_EST": 2851, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "PU", + "ISO_A2": "GW", + "ISO_A3": "GNB", + "ISO_A3_EH": "GNB", + "ISO_N3": "624", + "UN_A3": "624", + "WB_A2": "GW", + "WB_A3": "GNB", + "WOE_ID": 23424929, + "WOE_ID_EH": 23424929, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GNB", + "ADM0_A3_US": "GNB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 13, + "LONG_LEN": 13, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [-16.677452, 11.040412, -13.700476, 12.62817], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-13.700476, 12.586183], + [-13.718744, 12.247186], + [-13.828272, 12.142644], + [-13.743161, 11.811269], + [-13.9008, 11.678719], + [-14.121406, 11.677117], + [-14.382192, 11.509272], + [-14.685687, 11.527824], + [-15.130311, 11.040412], + [-15.66418, 11.458474], + [-16.085214, 11.524594], + [-16.314787, 11.806515], + [-16.308947, 11.958702], + [-16.613838, 12.170911], + [-16.677452, 12.384852], + [-16.147717, 12.547762], + [-15.816574, 12.515567], + [-15.548477, 12.62817], + [-13.700476, 12.586183] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Equatorial Guinea", + "SOV_A3": "GNQ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Equatorial Guinea", + "ADM0_A3": "GNQ", + "GEOU_DIF": 0, + "GEOUNIT": "Equatorial Guinea", + "GU_A3": "GNQ", + "SU_DIF": 0, + "SUBUNIT": "Equatorial Guinea", + "SU_A3": "GNQ", + "BRK_DIFF": 0, + "NAME": "Eq. Guinea", + "NAME_LONG": "Equatorial Guinea", + "BRK_A3": "GNQ", + "BRK_NAME": "Eq. Guinea", + "BRK_GROUP": null, + "ABBREV": "Eq. G.", + "POSTAL": "GQ", + "FORMAL_EN": "Republic of Equatorial Guinea", + "FORMAL_FR": null, + "NAME_CIAWF": "Equatorial Guinea", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Equatorial Guinea", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 4, + "MAPCOLOR13": 8, + "POP_EST": 778358, + "POP_RANK": 11, + "GDP_MD_EST": 31770, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "EK", + "ISO_A2": "GQ", + "ISO_A3": "GNQ", + "ISO_A3_EH": "GNQ", + "ISO_N3": "226", + "UN_A3": "226", + "WB_A2": "GQ", + "WB_A3": "GNQ", + "WOE_ID": 23424804, + "WOE_ID_EH": 23424804, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GNQ", + "ADM0_A3_US": "GNQ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 10, + "LONG_LEN": 17, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [9.305613, 1.01012, 11.285079, 2.283866], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [9.649158, 2.283866], + [11.276449, 2.261051], + [11.285079, 1.057662], + [9.830284, 1.067894], + [9.492889, 1.01012], + [9.305613, 1.160911], + [9.649158, 2.283866] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Greece", + "SOV_A3": "GRC", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Greece", + "ADM0_A3": "GRC", + "GEOU_DIF": 0, + "GEOUNIT": "Greece", + "GU_A3": "GRC", + "SU_DIF": 0, + "SUBUNIT": "Greece", + "SU_A3": "GRC", + "BRK_DIFF": 0, + "NAME": "Greece", + "NAME_LONG": "Greece", + "BRK_A3": "GRC", + "BRK_NAME": "Greece", + "BRK_GROUP": null, + "ABBREV": "Greece", + "POSTAL": "GR", + "FORMAL_EN": "Hellenic Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Greece", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Greece", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 9, + "POP_EST": 10768477, + "POP_RANK": 14, + "GDP_MD_EST": 290500, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "GR", + "ISO_A2": "GR", + "ISO_A3": "GRC", + "ISO_A3_EH": "GRC", + "ISO_N3": "300", + "UN_A3": "300", + "WB_A2": "GR", + "WB_A3": "GRC", + "WOE_ID": 23424833, + "WOE_ID_EH": 23424833, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GRC", + "ADM0_A3_US": "GRC", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [20.150016, 34.919988, 26.604196, 41.826905], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [20.150016, 39.624998], + [20.615, 40.110007], + [20.674997, 40.435], + [20.99999, 40.580004], + [21.02004, 40.842727], + [21.674161, 40.931275], + [22.055378, 41.149866], + [22.597308, 41.130487], + [22.76177, 41.3048], + [22.952377, 41.337994], + [23.692074, 41.309081], + [24.492645, 41.583896], + [25.197201, 41.234486], + [26.106138, 41.328899], + [26.117042, 41.826905], + [26.604196, 41.562115], + [26.294602, 40.936261], + [26.056942, 40.824123], + [25.447677, 40.852545], + [24.925848, 40.947062], + [23.714811, 40.687129], + [24.407999, 40.124993], + [23.899968, 39.962006], + [23.342999, 39.960998], + [22.813988, 40.476005], + [22.626299, 40.256561], + [22.849748, 39.659311], + [23.350027, 39.190011], + [22.973099, 38.970903], + [23.530016, 38.510001], + [24.025025, 38.219993], + [24.040011, 37.655015], + [23.115003, 37.920011], + [23.409972, 37.409991], + [22.774972, 37.30501], + [23.154225, 36.422506], + [22.490028, 36.41], + [21.670026, 36.844986], + [21.295011, 37.644989], + [21.120034, 38.310323], + [20.730032, 38.769985], + [20.217712, 39.340235], + [20.150016, 39.624998] + ] + ], + [ + [ + [23.69998, 35.705004], + [24.246665, 35.368022], + [25.025015, 35.424996], + [25.769208, 35.354018], + [25.745023, 35.179998], + [26.290003, 35.29999], + [26.164998, 35.004995], + [24.724982, 34.919988], + [24.735007, 35.084991], + [23.514978, 35.279992], + [23.69998, 35.705004] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Denmark", + "SOV_A3": "DN1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "Greenland", + "ADM0_A3": "GRL", + "GEOU_DIF": 0, + "GEOUNIT": "Greenland", + "GU_A3": "GRL", + "SU_DIF": 0, + "SUBUNIT": "Greenland", + "SU_A3": "GRL", + "BRK_DIFF": 0, + "NAME": "Greenland", + "NAME_LONG": "Greenland", + "BRK_A3": "GRL", + "BRK_NAME": "Greenland", + "BRK_GROUP": null, + "ABBREV": "Grlnd.", + "POSTAL": "GL", + "FORMAL_EN": "Greenland", + "FORMAL_FR": null, + "NAME_CIAWF": "Greenland", + "NOTE_ADM0": "Den.", + "NOTE_BRK": null, + "NAME_SORT": "Greenland", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 12, + "POP_EST": 57713, + "POP_RANK": 8, + "GDP_MD_EST": 2173, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2015, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "GL", + "ISO_A2": "GL", + "ISO_A3": "GRL", + "ISO_A3_EH": "GRL", + "ISO_N3": "304", + "UN_A3": "304", + "WB_A2": "GL", + "WB_A3": "GRL", + "WOE_ID": 23424828, + "WOE_ID_EH": 23424828, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GRL", + "ADM0_A3_US": "GRL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Northern America", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": -99, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [-73.297, 60.03676, -12.20855, 83.64513], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-46.76379, 82.62796], + [-43.40644, 83.22516], + [-39.89753, 83.18018], + [-38.62214, 83.54905], + [-35.08787, 83.64513], + [-27.10046, 83.51966], + [-20.84539, 82.72669], + [-22.69182, 82.34165], + [-26.51753, 82.29765], + [-31.9, 82.2], + [-31.39646, 82.02154], + [-27.85666, 82.13178], + [-24.84448, 81.78697], + [-22.90328, 82.09317], + [-22.07175, 81.73449], + [-23.16961, 81.15271], + [-20.62363, 81.52462], + [-15.76818, 81.91245], + [-12.77018, 81.71885], + [-12.20855, 81.29154], + [-16.28533, 80.58004], + [-16.85, 80.35], + [-20.04624, 80.17708], + [-17.73035, 80.12912], + [-18.9, 79.4], + [-19.70499, 78.75128], + [-19.67353, 77.63859], + [-18.47285, 76.98565], + [-20.03503, 76.94434], + [-21.67944, 76.62795], + [-19.83407, 76.09808], + [-19.59896, 75.24838], + [-20.66818, 75.15585], + [-19.37281, 74.29561], + [-21.59422, 74.22382], + [-20.43454, 73.81713], + [-20.76234, 73.46436], + [-22.17221, 73.30955], + [-23.56593, 73.30663], + [-22.31311, 72.62928], + [-22.29954, 72.18409], + [-24.27834, 72.59788], + [-24.79296, 72.3302], + [-23.44296, 72.08016], + [-22.13281, 71.46898], + [-21.75356, 70.66369], + [-23.53603, 70.471], + [-24.30702, 70.85649], + [-25.54341, 71.43094], + [-25.20135, 70.75226], + [-26.36276, 70.22646], + [-23.72742, 70.18401], + [-22.34902, 70.12946], + [-25.02927, 69.2588], + [-27.74737, 68.47046], + [-30.67371, 68.12503], + [-31.77665, 68.12078], + [-32.81105, 67.73547], + [-34.20196, 66.67974], + [-36.35284, 65.9789], + [-37.04378, 65.93768], + [-38.37505, 65.69213], + [-39.81222, 65.45848], + [-40.66899, 64.83997], + [-40.68281, 64.13902], + [-41.1887, 63.48246], + [-42.81938, 62.68233], + [-42.41666, 61.90093], + [-42.86619, 61.07404], + [-43.3784, 60.09772], + [-44.7875, 60.03676], + [-46.26364, 60.85328], + [-48.26294, 60.85843], + [-49.23308, 61.40681], + [-49.90039, 62.38336], + [-51.63325, 63.62691], + [-52.14014, 64.27842], + [-52.27659, 65.1767], + [-53.66166, 66.09957], + [-53.30161, 66.8365], + [-53.96911, 67.18899], + [-52.9804, 68.35759], + [-51.47536, 68.72958], + [-51.08041, 69.14781], + [-50.87122, 69.9291], + [-52.013585, 69.574925], + [-52.55792, 69.42616], + [-53.45629, 69.283625], + [-54.68336, 69.61003], + [-54.75001, 70.28932], + [-54.35884, 70.821315], + [-53.431315, 70.835755], + [-51.39014, 70.56978], + [-53.10937, 71.20485], + [-54.00422, 71.54719], + [-55, 71.406537], + [-55.83468, 71.65444], + [-54.71819, 72.58625], + [-55.32634, 72.95861], + [-56.12003, 73.64977], + [-57.32363, 74.71026], + [-58.59679, 75.09861], + [-58.58516, 75.51727], + [-61.26861, 76.10238], + [-63.39165, 76.1752], + [-66.06427, 76.13486], + [-68.50438, 76.06141], + [-69.66485, 76.37975], + [-71.40257, 77.00857], + [-68.77671, 77.32312], + [-66.76397, 77.37595], + [-71.04293, 77.63595], + [-73.297, 78.04419], + [-73.15938, 78.43271], + [-69.37345, 78.91388], + [-65.7107, 79.39436], + [-65.3239, 79.75814], + [-68.02298, 80.11721], + [-67.15129, 80.51582], + [-63.68925, 81.21396], + [-62.23444, 81.3211], + [-62.65116, 81.77042], + [-60.28249, 82.03363], + [-57.20744, 82.19074], + [-54.13442, 82.19962], + [-53.04328, 81.88833], + [-50.39061, 82.43883], + [-48.00386, 82.06481], + [-46.59984, 81.985945], + [-44.523, 81.6607], + [-46.9007, 82.19979], + [-46.76379, 82.62796] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Guatemala", + "SOV_A3": "GTM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Guatemala", + "ADM0_A3": "GTM", + "GEOU_DIF": 0, + "GEOUNIT": "Guatemala", + "GU_A3": "GTM", + "SU_DIF": 0, + "SUBUNIT": "Guatemala", + "SU_A3": "GTM", + "BRK_DIFF": 0, + "NAME": "Guatemala", + "NAME_LONG": "Guatemala", + "BRK_A3": "GTM", + "BRK_NAME": "Guatemala", + "BRK_GROUP": null, + "ABBREV": "Guat.", + "POSTAL": "GT", + "FORMAL_EN": "Republic of Guatemala", + "FORMAL_FR": null, + "NAME_CIAWF": "Guatemala", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Guatemala", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 3, + "MAPCOLOR9": 3, + "MAPCOLOR13": 6, + "POP_EST": 15460732, + "POP_RANK": 14, + "GDP_MD_EST": 131800, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "GT", + "ISO_A2": "GT", + "ISO_A3": "GTM", + "ISO_A3_EH": "GTM", + "ISO_N3": "320", + "UN_A3": "320", + "WB_A2": "GT", + "WB_A3": "GTM", + "WOE_ID": 23424834, + "WOE_ID_EH": 23424834, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GTM", + "ADM0_A3_US": "GTM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 5, + "TINY": 4, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-92.229249, 13.735338, -88.225023, 17.819326], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-89.14308, 17.808319], + [-89.150806, 17.015577], + [-89.229122, 15.886938], + [-88.930613, 15.887273], + [-88.604586, 15.70638], + [-88.518364, 15.855389], + [-88.225023, 15.727722], + [-88.68068, 15.346247], + [-89.154811, 15.066419], + [-89.22522, 14.874286], + [-89.145535, 14.678019], + [-89.353326, 14.424133], + [-89.587343, 14.362586], + [-89.534219, 14.244816], + [-89.721934, 14.134228], + [-90.064678, 13.88197], + [-90.095555, 13.735338], + [-90.608624, 13.909771], + [-91.23241, 13.927832], + [-91.689747, 14.126218], + [-92.22775, 14.538829], + [-92.20323, 14.830103], + [-92.087216, 15.064585], + [-92.229249, 15.251447], + [-91.74796, 16.066565], + [-90.464473, 16.069562], + [-90.438867, 16.41011], + [-90.600847, 16.470778], + [-90.711822, 16.687483], + [-91.08167, 16.918477], + [-91.453921, 17.252177], + [-91.002269, 17.254658], + [-91.00152, 17.817595], + [-90.067934, 17.819326], + [-89.14308, 17.808319] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Guyana", + "SOV_A3": "GUY", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Guyana", + "ADM0_A3": "GUY", + "GEOU_DIF": 0, + "GEOUNIT": "Guyana", + "GU_A3": "GUY", + "SU_DIF": 0, + "SUBUNIT": "Guyana", + "SU_A3": "GUY", + "BRK_DIFF": 0, + "NAME": "Guyana", + "NAME_LONG": "Guyana", + "BRK_A3": "GUY", + "BRK_NAME": "Guyana", + "BRK_GROUP": null, + "ABBREV": "Guy.", + "POSTAL": "GY", + "FORMAL_EN": "Co-operative Republic of Guyana", + "FORMAL_FR": null, + "NAME_CIAWF": "Guyana", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Guyana", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 4, + "MAPCOLOR13": 8, + "POP_EST": 737718, + "POP_RANK": 11, + "GDP_MD_EST": 6093, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "GY", + "ISO_A2": "GY", + "ISO_A3": "GUY", + "ISO_A3_EH": "GUY", + "ISO_N3": "328", + "UN_A3": "328", + "WB_A2": "GY", + "WB_A3": "GUY", + "WOE_ID": 23424836, + "WOE_ID_EH": 23424836, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "GUY", + "ADM0_A3_US": "GUY", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-61.410303, 1.268088, -56.539386, 8.367035], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-56.539386, 1.899523], + [-56.782704, 1.863711], + [-57.335823, 1.948538], + [-57.660971, 1.682585], + [-58.11345, 1.507195], + [-58.429477, 1.463942], + [-58.540013, 1.268088], + [-59.030862, 1.317698], + [-59.646044, 1.786894], + [-59.718546, 2.24963], + [-59.974525, 2.755233], + [-59.815413, 3.606499], + [-59.53804, 3.958803], + [-59.767406, 4.423503], + [-60.111002, 4.574967], + [-59.980959, 5.014061], + [-60.213683, 5.244486], + [-60.733574, 5.200277], + [-61.410303, 5.959068], + [-61.139415, 6.234297], + [-61.159336, 6.696077], + [-60.543999, 6.856584], + [-60.295668, 7.043911], + [-60.637973, 7.415], + [-60.550588, 7.779603], + [-59.758285, 8.367035], + [-59.101684, 7.999202], + [-58.482962, 7.347691], + [-58.454876, 6.832787], + [-58.078103, 6.809094], + [-57.542219, 6.321268], + [-57.147436, 5.97315], + [-57.307246, 5.073567], + [-57.914289, 4.812626], + [-57.86021, 4.576801], + [-58.044694, 4.060864], + [-57.601569, 3.334655], + [-57.281433, 3.333492], + [-57.150098, 2.768927], + [-56.539386, 1.899523] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Honduras", + "SOV_A3": "HND", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Honduras", + "ADM0_A3": "HND", + "GEOU_DIF": 0, + "GEOUNIT": "Honduras", + "GU_A3": "HND", + "SU_DIF": 0, + "SUBUNIT": "Honduras", + "SU_A3": "HND", + "BRK_DIFF": 0, + "NAME": "Honduras", + "NAME_LONG": "Honduras", + "BRK_A3": "HND", + "BRK_NAME": "Honduras", + "BRK_GROUP": null, + "ABBREV": "Hond.", + "POSTAL": "HN", + "FORMAL_EN": "Republic of Honduras", + "FORMAL_FR": null, + "NAME_CIAWF": "Honduras", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Honduras", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 5, + "MAPCOLOR9": 2, + "MAPCOLOR13": 5, + "POP_EST": 9038741, + "POP_RANK": 13, + "GDP_MD_EST": 43190, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "HO", + "ISO_A2": "HN", + "ISO_A3": "HND", + "ISO_A3_EH": "HND", + "ISO_N3": "340", + "UN_A3": "340", + "WB_A2": "HN", + "WB_A3": "HND", + "WOE_ID": 23424841, + "WOE_ID_EH": 23424841, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "HND", + "ADM0_A3_US": "HND", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9.5 + }, + "bbox": [-89.353326, 12.984686, -83.147219, 16.005406], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-89.353326, 14.424133], + [-89.145535, 14.678019], + [-89.22522, 14.874286], + [-89.154811, 15.066419], + [-88.68068, 15.346247], + [-88.225023, 15.727722], + [-88.121153, 15.688655], + [-87.901813, 15.864458], + [-87.61568, 15.878799], + [-87.522921, 15.797279], + [-87.367762, 15.84694], + [-86.903191, 15.756713], + [-86.440946, 15.782835], + [-86.119234, 15.893449], + [-86.001954, 16.005406], + [-85.683317, 15.953652], + [-85.444004, 15.885749], + [-85.182444, 15.909158], + [-84.983722, 15.995923], + [-84.52698, 15.857224], + [-84.368256, 15.835158], + [-84.063055, 15.648244], + [-83.773977, 15.424072], + [-83.410381, 15.270903], + [-83.147219, 14.995829], + [-83.489989, 15.016267], + [-83.628585, 14.880074], + [-83.975721, 14.749436], + [-84.228342, 14.748764], + [-84.449336, 14.621614], + [-84.649582, 14.666805], + [-84.820037, 14.819587], + [-84.924501, 14.790493], + [-85.052787, 14.551541], + [-85.148751, 14.560197], + [-85.165365, 14.35437], + [-85.514413, 14.079012], + [-85.698665, 13.960078], + [-85.801295, 13.836055], + [-86.096264, 14.038187], + [-86.312142, 13.771356], + [-86.520708, 13.778487], + [-86.755087, 13.754845], + [-86.733822, 13.263093], + [-86.880557, 13.254204], + [-87.005769, 13.025794], + [-87.316654, 12.984686], + [-87.489409, 13.297535], + [-87.793111, 13.38448], + [-87.723503, 13.78505], + [-87.859515, 13.893312], + [-88.065343, 13.964626], + [-88.503998, 13.845486], + [-88.541231, 13.980155], + [-88.843073, 14.140507], + [-89.058512, 14.340029], + [-89.353326, 14.424133] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Croatia", + "SOV_A3": "HRV", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Croatia", + "ADM0_A3": "HRV", + "GEOU_DIF": 0, + "GEOUNIT": "Croatia", + "GU_A3": "HRV", + "SU_DIF": 0, + "SUBUNIT": "Croatia", + "SU_A3": "HRV", + "BRK_DIFF": 0, + "NAME": "Croatia", + "NAME_LONG": "Croatia", + "BRK_A3": "HRV", + "BRK_NAME": "Croatia", + "BRK_GROUP": null, + "ABBREV": "Cro.", + "POSTAL": "HR", + "FORMAL_EN": "Republic of Croatia", + "FORMAL_FR": null, + "NAME_CIAWF": "Croatia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Croatia", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 4, + "MAPCOLOR9": 5, + "MAPCOLOR13": 1, + "POP_EST": 4292095, + "POP_RANK": 12, + "GDP_MD_EST": 94240, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "HR", + "ISO_A2": "HR", + "ISO_A3": "HRV", + "ISO_A3_EH": "HRV", + "ISO_N3": "191", + "UN_A3": "191", + "WB_A2": "HR", + "WB_A3": "HRV", + "WOE_ID": 23424843, + "WOE_ID_EH": 23424843, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "HRV", + "ADM0_A3_US": "HRV", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [13.656976, 42.479991, 19.390476, 46.503751], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [19.005485, 44.860234], + [18.553214, 45.08159], + [17.861783, 45.06774], + [17.002146, 45.233777], + [16.534939, 45.211608], + [16.318157, 45.004127], + [15.959367, 45.233777], + [15.750026, 44.818712], + [16.23966, 44.351143], + [16.456443, 44.04124], + [16.916156, 43.667722], + [17.297373, 43.446341], + [17.674922, 43.028563], + [18.56, 42.65], + [18.450017, 42.479992], + [18.450016, 42.479991], + [17.50997, 42.849995], + [16.930006, 43.209998], + [16.015385, 43.507215], + [15.174454, 44.243191], + [15.37625, 44.317915], + [14.920309, 44.738484], + [14.901602, 45.07606], + [14.258748, 45.233777], + [13.952255, 44.802124], + [13.656976, 45.136935], + [13.679403, 45.484149], + [13.71506, 45.500324], + [14.411968, 45.466166], + [14.595109, 45.634941], + [14.935244, 45.471695], + [15.327675, 45.452316], + [15.323954, 45.731783], + [15.67153, 45.834154], + [15.768733, 46.238108], + [16.564808, 46.503751], + [16.882515, 46.380632], + [17.630066, 45.951769], + [18.456062, 45.759481], + [18.829825, 45.908872], + [19.072769, 45.521511], + [19.390476, 45.236516], + [19.005485, 44.860234] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Haiti", + "SOV_A3": "HTI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Haiti", + "ADM0_A3": "HTI", + "GEOU_DIF": 0, + "GEOUNIT": "Haiti", + "GU_A3": "HTI", + "SU_DIF": 0, + "SUBUNIT": "Haiti", + "SU_A3": "HTI", + "BRK_DIFF": 0, + "NAME": "Haiti", + "NAME_LONG": "Haiti", + "BRK_A3": "HTI", + "BRK_NAME": "Haiti", + "BRK_GROUP": null, + "ABBREV": "Haiti", + "POSTAL": "HT", + "FORMAL_EN": "Republic of Haiti", + "FORMAL_FR": null, + "NAME_CIAWF": "Haiti", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Haiti", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 1, + "MAPCOLOR9": 7, + "MAPCOLOR13": 2, + "POP_EST": 10646714, + "POP_RANK": 14, + "GDP_MD_EST": 19340, + "POP_YEAR": 2017, + "LASTCENSUS": 2003, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "HA", + "ISO_A2": "HT", + "ISO_A3": "HTI", + "ISO_A3_EH": "HTI", + "ISO_N3": "332", + "UN_A3": "332", + "WB_A2": "HT", + "WB_A3": "HTI", + "WOE_ID": 23424839, + "WOE_ID_EH": 23424839, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "HTI", + "ADM0_A3_US": "HTI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-74.458034, 18.030993, -71.624873, 19.915684], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-71.712361, 19.714456], + [-71.624873, 19.169838], + [-71.701303, 18.785417], + [-71.945112, 18.6169], + [-71.687738, 18.31666], + [-71.708305, 18.044997], + [-72.372476, 18.214961], + [-72.844411, 18.145611], + [-73.454555, 18.217906], + [-73.922433, 18.030993], + [-74.458034, 18.34255], + [-74.369925, 18.664908], + [-73.449542, 18.526053], + [-72.694937, 18.445799], + [-72.334882, 18.668422], + [-72.79165, 19.101625], + [-72.784105, 19.483591], + [-73.415022, 19.639551], + [-73.189791, 19.915684], + [-72.579673, 19.871501], + [-71.712361, 19.714456] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Hungary", + "SOV_A3": "HUN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Hungary", + "ADM0_A3": "HUN", + "GEOU_DIF": 0, + "GEOUNIT": "Hungary", + "GU_A3": "HUN", + "SU_DIF": 0, + "SUBUNIT": "Hungary", + "SU_A3": "HUN", + "BRK_DIFF": 0, + "NAME": "Hungary", + "NAME_LONG": "Hungary", + "BRK_A3": "HUN", + "BRK_NAME": "Hungary", + "BRK_GROUP": null, + "ABBREV": "Hun.", + "POSTAL": "HU", + "FORMAL_EN": "Republic of Hungary", + "FORMAL_FR": null, + "NAME_CIAWF": "Hungary", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Hungary", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 6, + "MAPCOLOR9": 1, + "MAPCOLOR13": 5, + "POP_EST": 9850845, + "POP_RANK": 13, + "GDP_MD_EST": 267600, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "HU", + "ISO_A2": "HU", + "ISO_A3": "HUN", + "ISO_A3_EH": "HUN", + "ISO_N3": "348", + "UN_A3": "348", + "WB_A2": "HU", + "WB_A3": "HUN", + "WOE_ID": 23424844, + "WOE_ID_EH": 23424844, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "HUN", + "ADM0_A3_US": "HUN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [16.202298, 45.759481, 22.710531, 48.623854], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [16.202298, 46.852386], + [16.534268, 47.496171], + [16.340584, 47.712902], + [16.903754, 47.714866], + [16.979667, 48.123497], + [17.488473, 47.867466], + [17.857133, 47.758429], + [18.696513, 47.880954], + [18.777025, 48.081768], + [19.174365, 48.111379], + [19.661364, 48.266615], + [19.769471, 48.202691], + [20.239054, 48.327567], + [20.473562, 48.56285], + [20.801294, 48.623854], + [21.872236, 48.319971], + [22.085608, 48.422264], + [22.64082, 48.15024], + [22.710531, 47.882194], + [22.099768, 47.672439], + [21.626515, 46.994238], + [21.021952, 46.316088], + [20.220192, 46.127469], + [19.596045, 46.17173], + [18.829838, 45.908878], + [18.829825, 45.908872], + [18.456062, 45.759481], + [17.630066, 45.951769], + [16.882515, 46.380632], + [16.564808, 46.503751], + [16.370505, 46.841327], + [16.202298, 46.852386] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Indonesia", + "SOV_A3": "IDN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Indonesia", + "ADM0_A3": "IDN", + "GEOU_DIF": 0, + "GEOUNIT": "Indonesia", + "GU_A3": "IDN", + "SU_DIF": 0, + "SUBUNIT": "Indonesia", + "SU_A3": "IDN", + "BRK_DIFF": 0, + "NAME": "Indonesia", + "NAME_LONG": "Indonesia", + "BRK_A3": "IDN", + "BRK_NAME": "Indonesia", + "BRK_GROUP": null, + "ABBREV": "Indo.", + "POSTAL": "INDO", + "FORMAL_EN": "Republic of Indonesia", + "FORMAL_FR": null, + "NAME_CIAWF": "Indonesia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Indonesia", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 6, + "MAPCOLOR9": 6, + "MAPCOLOR13": 11, + "POP_EST": 260580739, + "POP_RANK": 17, + "GDP_MD_EST": 3028000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "4. Emerging region: MIKT", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "ID", + "ISO_A2": "ID", + "ISO_A3": "IDN", + "ISO_A3_EH": "IDN", + "ISO_N3": "360", + "UN_A3": "360", + "WB_A2": "ID", + "WB_A3": "IDN", + "WOE_ID": 23424846, + "WOE_ID_EH": 23424846, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "IDN", + "ADM0_A3_US": "IDN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [95.293026, -10.359987, 141.033852, 5.479821], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [120.715609, -10.239581], + [120.295014, -10.25865], + [118.967808, -9.557969], + [119.90031, -9.36134], + [120.425756, -9.665921], + [120.775502, -9.969675], + [120.715609, -10.239581] + ] + ], + [ + [ + [124.968682, -8.89279], + [125.07002, -9.089987], + [125.08852, -9.393173], + [124.43595, -10.140001], + [123.579982, -10.359987], + [123.459989, -10.239995], + [123.550009, -9.900016], + [123.980009, -9.290027], + [124.968682, -8.89279] + ] + ], + [ + [ + [117.900018, -8.095681], + [118.260616, -8.362383], + [118.87846, -8.280683], + [119.126507, -8.705825], + [117.970402, -8.906639], + [117.277731, -9.040895], + [116.740141, -9.032937], + [117.083737, -8.457158], + [117.632024, -8.449303], + [117.900018, -8.095681] + ] + ], + [ + [ + [122.903537, -8.094234], + [122.756983, -8.649808], + [121.254491, -8.933666], + [119.924391, -8.810418], + [119.920929, -8.444859], + [120.715092, -8.236965], + [121.341669, -8.53674], + [122.007365, -8.46062], + [122.903537, -8.094234] + ] + ], + [ + [ + [108.623479, -6.777674], + [110.539227, -6.877358], + [110.759576, -6.465186], + [112.614811, -6.946036], + [112.978768, -7.594213], + [114.478935, -7.776528], + [115.705527, -8.370807], + [114.564511, -8.751817], + [113.464734, -8.348947], + [112.559672, -8.376181], + [111.522061, -8.302129], + [110.58615, -8.122605], + [109.427667, -7.740664], + [108.693655, -7.6416], + [108.277763, -7.766657], + [106.454102, -7.3549], + [106.280624, -6.9249], + [105.365486, -6.851416], + [106.051646, -5.895919], + [107.265009, -5.954985], + [108.072091, -6.345762], + [108.486846, -6.421985], + [108.623479, -6.777674] + ] + ], + [ + [ + [134.724624, -6.214401], + [134.210134, -6.895238], + [134.112776, -6.142467], + [134.290336, -5.783058], + [134.499625, -5.445042], + [134.727002, -5.737582], + [134.724624, -6.214401] + ] + ], + [ + [ + [127.249215, -3.459065], + [126.874923, -3.790983], + [126.183802, -3.607376], + [125.989034, -3.177273], + [127.000651, -3.129318], + [127.249215, -3.459065] + ] + ], + [ + [ + [130.471344, -3.093764], + [130.834836, -3.858472], + [129.990547, -3.446301], + [129.155249, -3.362637], + [128.590684, -3.428679], + [127.898891, -3.393436], + [128.135879, -2.84365], + [129.370998, -2.802154], + [130.471344, -3.093764] + ] + ], + [ + [ + [141.00021, -2.600151], + [141.017057, -5.859022], + [141.033852, -9.117893], + [140.143415, -8.297168], + [139.127767, -8.096043], + [138.881477, -8.380935], + [137.614474, -8.411683], + [138.039099, -7.597882], + [138.668621, -7.320225], + [138.407914, -6.232849], + [137.92784, -5.393366], + [135.98925, -4.546544], + [135.164598, -4.462931], + [133.66288, -3.538853], + [133.367705, -4.024819], + [132.983956, -4.112979], + [132.756941, -3.746283], + [132.753789, -3.311787], + [131.989804, -2.820551], + [133.066845, -2.460418], + [133.780031, -2.479848], + [133.696212, -2.214542], + [132.232373, -2.212526], + [131.836222, -1.617162], + [130.94284, -1.432522], + [130.519558, -0.93772], + [131.867538, -0.695461], + [132.380116, -0.369538], + [133.985548, -0.78021], + [134.143368, -1.151867], + [134.422627, -2.769185], + [135.457603, -3.367753], + [136.293314, -2.307042], + [137.440738, -1.703513], + [138.329727, -1.702686], + [139.184921, -2.051296], + [139.926684, -2.409052], + [141.00021, -2.600151] + ] + ], + [ + [ + [125.240501, 1.419836], + [124.437035, 0.427881], + [123.685505, 0.235593], + [122.723083, 0.431137], + [121.056725, 0.381217], + [120.183083, 0.237247], + [120.04087, -0.519658], + [120.935905, -1.408906], + [121.475821, -0.955962], + [123.340565, -0.615673], + [123.258399, -1.076213], + [122.822715, -0.930951], + [122.38853, -1.516858], + [121.508274, -1.904483], + [122.454572, -3.186058], + [122.271896, -3.5295], + [123.170963, -4.683693], + [123.162333, -5.340604], + [122.628515, -5.634591], + [122.236394, -5.282933], + [122.719569, -4.464172], + [121.738234, -4.851331], + [121.489463, -4.574553], + [121.619171, -4.188478], + [120.898182, -3.602105], + [120.972389, -2.627643], + [120.305453, -2.931604], + [120.390047, -4.097579], + [120.430717, -5.528241], + [119.796543, -5.6734], + [119.366906, -5.379878], + [119.653606, -4.459417], + [119.498835, -3.494412], + [119.078344, -3.487022], + [118.767769, -2.801999], + [119.180974, -2.147104], + [119.323394, -1.353147], + [119.825999, 0.154254], + [120.035702, 0.566477], + [120.885779, 1.309223], + [121.666817, 1.013944], + [122.927567, 0.875192], + [124.077522, 0.917102], + [125.065989, 1.643259], + [125.240501, 1.419836] + ] + ], + [ + [ + [128.688249, 1.132386], + [128.635952, 0.258486], + [128.12017, 0.356413], + [127.968034, -0.252077], + [128.379999, -0.780004], + [128.100016, -0.899996], + [127.696475, -0.266598], + [127.39949, 1.011722], + [127.600512, 1.810691], + [127.932378, 2.174596], + [128.004156, 1.628531], + [128.594559, 1.540811], + [128.688249, 1.132386] + ] + ], + [ + [ + [109.66326, 2.006467], + [109.830227, 1.338136], + [110.514061, 0.773131], + [111.159138, 0.976478], + [111.797548, 0.904441], + [112.380252, 1.410121], + [112.859809, 1.49779], + [113.80585, 1.217549], + [114.621355, 1.430688], + [115.134037, 2.821482], + [115.519078, 3.169238], + [115.865517, 4.306559], + [117.015214, 4.306094], + [117.882035, 4.137551], + [117.313232, 3.234428], + [118.04833, 2.28769], + [117.875627, 1.827641], + [118.996747, 0.902219], + [117.811858, 0.784242], + [117.478339, 0.102475], + [117.521644, -0.803723], + [116.560048, -1.487661], + [116.533797, -2.483517], + [116.148084, -4.012726], + [116.000858, -3.657037], + [114.864803, -4.106984], + [114.468652, -3.495704], + [113.755672, -3.43917], + [113.256994, -3.118776], + [112.068126, -3.478392], + [111.703291, -2.994442], + [111.04824, -3.049426], + [110.223846, -2.934032], + [110.070936, -1.592874], + [109.571948, -1.314907], + [109.091874, -0.459507], + [108.952658, 0.415375], + [109.069136, 1.341934], + [109.66326, 2.006467] + ] + ], + [ + [ + [105.817655, -5.852356], + [104.710384, -5.873285], + [103.868213, -5.037315], + [102.584261, -4.220259], + [102.156173, -3.614146], + [101.399113, -2.799777], + [100.902503, -2.050262], + [100.141981, -0.650348], + [99.26374, 0.183142], + [98.970011, 1.042882], + [98.601351, 1.823507], + [97.699598, 2.453184], + [97.176942, 3.308791], + [96.424017, 3.86886], + [95.380876, 4.970782], + [95.293026, 5.479821], + [95.936863, 5.439513], + [97.484882, 5.246321], + [98.369169, 4.26837], + [99.142559, 3.59035], + [99.693998, 3.174329], + [100.641434, 2.099381], + [101.658012, 2.083697], + [102.498271, 1.3987], + [103.07684, 0.561361], + [103.838396, 0.104542], + [103.437645, -0.711946], + [104.010789, -1.059212], + [104.369991, -1.084843], + [104.53949, -1.782372], + [104.887893, -2.340425], + [105.622111, -2.428844], + [106.108593, -3.061777], + [105.857446, -4.305525], + [105.817655, -5.852356] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "India", + "SOV_A3": "IND", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "India", + "ADM0_A3": "IND", + "GEOU_DIF": 0, + "GEOUNIT": "India", + "GU_A3": "IND", + "SU_DIF": 0, + "SUBUNIT": "India", + "SU_A3": "IND", + "BRK_DIFF": 0, + "NAME": "India", + "NAME_LONG": "India", + "BRK_A3": "IND", + "BRK_NAME": "India", + "BRK_GROUP": null, + "ABBREV": "India", + "POSTAL": "IND", + "FORMAL_EN": "Republic of India", + "FORMAL_FR": null, + "NAME_CIAWF": "India", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "India", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 3, + "MAPCOLOR9": 2, + "MAPCOLOR13": 2, + "POP_EST": 1281935911, + "POP_RANK": 18, + "GDP_MD_EST": 8721000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "3. Emerging region: BRIC", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "IN", + "ISO_A2": "IN", + "ISO_A3": "IND", + "ISO_A3_EH": "IND", + "ISO_N3": "356", + "UN_A3": "356", + "WB_A2": "IN", + "WB_A3": "IND", + "WOE_ID": 23424848, + "WOE_ID_EH": 23424848, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "IND", + "ADM0_A3_US": "IND", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [68.176645, 7.965535, 97.402561, 35.49401], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [92.672721, 22.041239], + [92.146035, 23.627499], + [91.869928, 23.624346], + [91.706475, 22.985264], + [91.158963, 23.503527], + [91.46773, 24.072639], + [91.915093, 24.130414], + [92.376202, 24.976693], + [91.799596, 25.147432], + [90.872211, 25.132601], + [89.920693, 25.26975], + [89.832481, 25.965082], + [89.355094, 26.014407], + [88.563049, 26.446526], + [88.209789, 25.768066], + [88.931554, 25.238692], + [88.306373, 24.866079], + [88.084422, 24.501657], + [88.69994, 24.233715], + [88.52977, 23.631142], + [88.876312, 22.879146], + [89.031961, 22.055708], + [88.888766, 21.690588], + [88.208497, 21.703172], + [86.975704, 21.495562], + [87.033169, 20.743308], + [86.499351, 20.151638], + [85.060266, 19.478579], + [83.941006, 18.30201], + [83.189217, 17.671221], + [82.192792, 17.016636], + [82.191242, 16.556664], + [81.692719, 16.310219], + [80.791999, 15.951972], + [80.324896, 15.899185], + [80.025069, 15.136415], + [80.233274, 13.835771], + [80.286294, 13.006261], + [79.862547, 12.056215], + [79.857999, 10.357275], + [79.340512, 10.308854], + [78.885345, 9.546136], + [79.18972, 9.216544], + [78.277941, 8.933047], + [77.941165, 8.252959], + [77.539898, 7.965535], + [76.592979, 8.899276], + [76.130061, 10.29963], + [75.746467, 11.308251], + [75.396101, 11.781245], + [74.864816, 12.741936], + [74.616717, 13.992583], + [74.443859, 14.617222], + [73.534199, 15.990652], + [73.119909, 17.92857], + [72.820909, 19.208234], + [72.824475, 20.419503], + [72.630533, 21.356009], + [71.175273, 20.757441], + [70.470459, 20.877331], + [69.16413, 22.089298], + [69.644928, 22.450775], + [69.349597, 22.84318], + [68.176645, 23.691965], + [68.842599, 24.359134], + [71.04324, 24.356524], + [70.844699, 25.215102], + [70.282873, 25.722229], + [70.168927, 26.491872], + [69.514393, 26.940966], + [70.616496, 27.989196], + [71.777666, 27.91318], + [72.823752, 28.961592], + [73.450638, 29.976413], + [74.42138, 30.979815], + [74.405929, 31.692639], + [75.258642, 32.271105], + [74.451559, 32.7649], + [74.104294, 33.441473], + [73.749948, 34.317699], + [74.240203, 34.748887], + [75.757061, 34.504923], + [76.871722, 34.653544], + [77.837451, 35.49401], + [78.912269, 34.321936], + [78.811086, 33.506198], + [79.208892, 32.994395], + [79.176129, 32.48378], + [78.458446, 32.618164], + [78.738894, 31.515906], + [79.721367, 30.882715], + [81.111256, 30.183481], + [80.476721, 29.729865], + [80.088425, 28.79447], + [81.057203, 28.416095], + [81.999987, 27.925479], + [83.304249, 27.364506], + [84.675018, 27.234901], + [85.251779, 26.726198], + [86.024393, 26.630985], + [87.227472, 26.397898], + [88.060238, 26.414615], + [88.174804, 26.810405], + [88.043133, 27.445819], + [88.120441, 27.876542], + [88.730326, 28.086865], + [88.814248, 27.299316], + [88.835643, 27.098966], + [89.744528, 26.719403], + [90.373275, 26.875724], + [91.217513, 26.808648], + [92.033484, 26.83831], + [92.103712, 27.452614], + [91.696657, 27.771742], + [92.503119, 27.896876], + [93.413348, 28.640629], + [94.56599, 29.277438], + [95.404802, 29.031717], + [96.117679, 29.452802], + [96.586591, 28.83098], + [96.248833, 28.411031], + [97.327114, 28.261583], + [97.402561, 27.882536], + [97.051989, 27.699059], + [97.133999, 27.083774], + [96.419366, 27.264589], + [95.124768, 26.573572], + [95.155153, 26.001307], + [94.603249, 25.162495], + [94.552658, 24.675238], + [94.106742, 23.850741], + [93.325188, 24.078556], + [93.286327, 23.043658], + [93.060294, 22.703111], + [93.166128, 22.27846], + [92.672721, 22.041239] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Ireland", + "SOV_A3": "IRL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Ireland", + "ADM0_A3": "IRL", + "GEOU_DIF": 0, + "GEOUNIT": "Ireland", + "GU_A3": "IRL", + "SU_DIF": 0, + "SUBUNIT": "Ireland", + "SU_A3": "IRL", + "BRK_DIFF": 0, + "NAME": "Ireland", + "NAME_LONG": "Ireland", + "BRK_A3": "IRL", + "BRK_NAME": "Ireland", + "BRK_GROUP": null, + "ABBREV": "Ire.", + "POSTAL": "IRL", + "FORMAL_EN": "Ireland", + "FORMAL_FR": null, + "NAME_CIAWF": "Ireland", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Ireland", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 3, + "MAPCOLOR9": 2, + "MAPCOLOR13": 2, + "POP_EST": 5011102, + "POP_RANK": 13, + "GDP_MD_EST": 322000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "EI", + "ISO_A2": "IE", + "ISO_A3": "IRL", + "ISO_A3_EH": "IRL", + "ISO_N3": "372", + "UN_A3": "372", + "WB_A2": "IE", + "WB_A3": "IRL", + "WOE_ID": 23424803, + "WOE_ID_EH": 23424803, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "IRL", + "ADM0_A3_US": "IRL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-9.977086, 51.669301, -6.032985, 55.131622], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-7.572168, 55.131622], + [-7.366031, 54.595841], + [-7.572168, 54.059956], + [-6.95373, 54.073702], + [-6.197885, 53.867565], + [-6.032985, 53.153164], + [-6.788857, 52.260118], + [-8.561617, 51.669301], + [-9.977086, 51.820455], + [-9.166283, 52.864629], + [-9.688525, 53.881363], + [-8.327987, 54.664519], + [-7.572168, 55.131622] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Iran", + "SOV_A3": "IRN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Iran", + "ADM0_A3": "IRN", + "GEOU_DIF": 0, + "GEOUNIT": "Iran", + "GU_A3": "IRN", + "SU_DIF": 0, + "SUBUNIT": "Iran", + "SU_A3": "IRN", + "BRK_DIFF": 0, + "NAME": "Iran", + "NAME_LONG": "Iran", + "BRK_A3": "IRN", + "BRK_NAME": "Iran", + "BRK_GROUP": null, + "ABBREV": "Iran", + "POSTAL": "IRN", + "FORMAL_EN": "Islamic Republic of Iran", + "FORMAL_FR": null, + "NAME_CIAWF": "Iran", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Iran, Islamic Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 3, + "MAPCOLOR9": 4, + "MAPCOLOR13": 13, + "POP_EST": 82021564, + "POP_RANK": 16, + "GDP_MD_EST": 1459000, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "IR", + "ISO_A2": "IR", + "ISO_A3": "IRN", + "ISO_A3_EH": "IRN", + "ISO_N3": "364", + "UN_A3": "364", + "WB_A2": "IR", + "WB_A3": "IRN", + "WOE_ID": 23424851, + "WOE_ID_EH": 23424851, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "IRN", + "ADM0_A3_US": "IRN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2.5, + "MAX_LABEL": 6.7 + }, + "bbox": [44.109225, 25.078237, 63.316632, 39.713003], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [61.210817, 35.650072], + [60.803193, 34.404102], + [60.52843, 33.676446], + [60.9637, 33.528832], + [60.536078, 32.981269], + [60.863655, 32.18292], + [60.941945, 31.548075], + [61.699314, 31.379506], + [61.781222, 30.73585], + [60.874248, 29.829239], + [61.369309, 29.303276], + [61.771868, 28.699334], + [62.72783, 28.259645], + [62.755426, 27.378923], + [63.233898, 27.217047], + [63.316632, 26.756532], + [61.874187, 26.239975], + [61.497363, 25.078237], + [59.616134, 25.380157], + [58.525761, 25.609962], + [57.397251, 25.739902], + [56.970766, 26.966106], + [56.492139, 27.143305], + [55.72371, 26.964633], + [54.71509, 26.480658], + [53.493097, 26.812369], + [52.483598, 27.580849], + [51.520763, 27.86569], + [50.852948, 28.814521], + [50.115009, 30.147773], + [49.57685, 29.985715], + [48.941333, 30.31709], + [48.567971, 29.926778], + [48.014568, 30.452457], + [48.004698, 30.985137], + [47.685286, 30.984853], + [47.849204, 31.709176], + [47.334661, 32.469155], + [46.109362, 33.017287], + [45.416691, 33.967798], + [45.64846, 34.748138], + [46.151788, 35.093259], + [46.07634, 35.677383], + [45.420618, 35.977546], + [44.772677, 37.170437], + [44.77267, 37.17045], + [44.225756, 37.971584], + [44.421403, 38.281281], + [44.109225, 39.428136], + [44.79399, 39.713003], + [44.952688, 39.335765], + [45.457722, 38.874139], + [46.143623, 38.741201], + [46.50572, 38.770605], + [47.685079, 39.508364], + [48.060095, 39.582235], + [48.355529, 39.288765], + [48.010744, 38.794015], + [48.634375, 38.270378], + [48.883249, 38.320245], + [49.199612, 37.582874], + [50.147771, 37.374567], + [50.842354, 36.872814], + [52.264025, 36.700422], + [53.82579, 36.965031], + [53.921598, 37.198918], + [54.800304, 37.392421], + [55.511578, 37.964117], + [56.180375, 37.935127], + [56.619366, 38.121394], + [57.330434, 38.029229], + [58.436154, 37.522309], + [59.234762, 37.412988], + [60.377638, 36.527383], + [61.123071, 36.491597], + [61.210817, 35.650072] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Iraq", + "SOV_A3": "IRQ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Iraq", + "ADM0_A3": "IRQ", + "GEOU_DIF": 0, + "GEOUNIT": "Iraq", + "GU_A3": "IRQ", + "SU_DIF": 0, + "SUBUNIT": "Iraq", + "SU_A3": "IRQ", + "BRK_DIFF": 0, + "NAME": "Iraq", + "NAME_LONG": "Iraq", + "BRK_A3": "IRQ", + "BRK_NAME": "Iraq", + "BRK_GROUP": null, + "ABBREV": "Iraq", + "POSTAL": "IRQ", + "FORMAL_EN": "Republic of Iraq", + "FORMAL_FR": null, + "NAME_CIAWF": "Iraq", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Iraq", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 3, + "MAPCOLOR13": 1, + "POP_EST": 39192111, + "POP_RANK": 15, + "GDP_MD_EST": 596700, + "POP_YEAR": 2017, + "LASTCENSUS": 1997, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "IZ", + "ISO_A2": "IQ", + "ISO_A3": "IRQ", + "ISO_A3_EH": "IRQ", + "ISO_N3": "368", + "UN_A3": "368", + "WB_A2": "IQ", + "WB_A3": "IRQ", + "WOE_ID": 23424855, + "WOE_ID_EH": 23424855, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "IRQ", + "ADM0_A3_US": "IRQ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7.5 + }, + "bbox": [38.792341, 29.099025, 48.567971, 37.385264], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [44.772677, 37.170437], + [45.420618, 35.977546], + [46.07634, 35.677383], + [46.151788, 35.093259], + [45.64846, 34.748138], + [45.416691, 33.967798], + [46.109362, 33.017287], + [47.334661, 32.469155], + [47.849204, 31.709176], + [47.685286, 30.984853], + [48.004698, 30.985137], + [48.014568, 30.452457], + [48.567971, 29.926778], + [47.974519, 29.975819], + [47.302622, 30.05907], + [46.568713, 29.099025], + [44.709499, 29.178891], + [41.889981, 31.190009], + [40.399994, 31.889992], + [39.195468, 32.161009], + [38.792341, 33.378686], + [41.006159, 34.419372], + [41.383965, 35.628317], + [41.289707, 36.358815], + [41.837064, 36.605854], + [42.349591, 37.229873], + [42.779126, 37.385264], + [43.942259, 37.256228], + [44.293452, 37.001514], + [44.772677, 37.170437] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Iceland", + "SOV_A3": "ISL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Iceland", + "ADM0_A3": "ISL", + "GEOU_DIF": 0, + "GEOUNIT": "Iceland", + "GU_A3": "ISL", + "SU_DIF": 0, + "SUBUNIT": "Iceland", + "SU_A3": "ISL", + "BRK_DIFF": 0, + "NAME": "Iceland", + "NAME_LONG": "Iceland", + "BRK_A3": "ISL", + "BRK_NAME": "Iceland", + "BRK_GROUP": null, + "ABBREV": "Iceland", + "POSTAL": "IS", + "FORMAL_EN": "Republic of Iceland", + "FORMAL_FR": null, + "NAME_CIAWF": "Iceland", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Iceland", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 4, + "MAPCOLOR13": 9, + "POP_EST": 339747, + "POP_RANK": 10, + "GDP_MD_EST": 16150, + "POP_YEAR": 2017, + "LASTCENSUS": -99, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "IC", + "ISO_A2": "IS", + "ISO_A3": "ISL", + "ISO_A3_EH": "ISL", + "ISO_N3": "352", + "UN_A3": "352", + "WB_A2": "IS", + "WB_A3": "ISL", + "WOE_ID": 23424845, + "WOE_ID_EH": 23424845, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ISL", + "ADM0_A3_US": "ISL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 7, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [-24.326184, 63.496383, -13.609732, 66.526792], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-14.508695, 66.455892], + [-14.739637, 65.808748], + [-13.609732, 65.126671], + [-14.909834, 64.364082], + [-17.794438, 63.678749], + [-18.656246, 63.496383], + [-19.972755, 63.643635], + [-22.762972, 63.960179], + [-21.778484, 64.402116], + [-23.955044, 64.89113], + [-22.184403, 65.084968], + [-22.227423, 65.378594], + [-24.326184, 65.611189], + [-23.650515, 66.262519], + [-22.134922, 66.410469], + [-20.576284, 65.732112], + [-19.056842, 66.276601], + [-17.798624, 65.993853], + [-16.167819, 66.526792], + [-14.508695, 66.455892] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Israel", + "SOV_A3": "IS1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "Israel", + "ADM0_A3": "ISR", + "GEOU_DIF": 0, + "GEOUNIT": "Israel", + "GU_A3": "ISR", + "SU_DIF": 0, + "SUBUNIT": "Israel", + "SU_A3": "ISR", + "BRK_DIFF": 0, + "NAME": "Israel", + "NAME_LONG": "Israel", + "BRK_A3": "ISR", + "BRK_NAME": "Israel", + "BRK_GROUP": null, + "ABBREV": "Isr.", + "POSTAL": "IS", + "FORMAL_EN": "State of Israel", + "FORMAL_FR": null, + "NAME_CIAWF": "Israel", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Israel", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 9, + "POP_EST": 8299706, + "POP_RANK": 13, + "GDP_MD_EST": 297000, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "-99", + "ISO_A2": "IL", + "ISO_A3": "ISR", + "ISO_A3_EH": "ISR", + "ISO_N3": "376", + "UN_A3": "376", + "WB_A2": "IL", + "WB_A3": "ISR", + "WOE_ID": 23424852, + "WOE_ID_EH": 23424852, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ISR", + "ADM0_A3_US": "ISR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [34.265433, 29.501326, 35.836397, 33.277426], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [34.823243, 29.761081], + [34.26544, 31.21936], + [34.265435, 31.219357], + [34.265433, 31.219361], + [34.556372, 31.548824], + [34.488107, 31.605539], + [34.752587, 32.072926], + [34.955417, 32.827376], + [35.098457, 33.080539], + [35.126053, 33.0909], + [35.460709, 33.08904], + [35.552797, 33.264275], + [35.821101, 33.277426], + [35.836397, 32.868123], + [35.700798, 32.716014], + [35.719918, 32.709192], + [35.545665, 32.393992], + [35.18393, 32.532511], + [34.974641, 31.866582], + [35.225892, 31.754341], + [34.970507, 31.616778], + [34.927408, 31.353435], + [35.397561, 31.489086], + [35.420918, 31.100066], + [34.922603, 29.501326], + [34.823243, 29.761081] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Italy", + "SOV_A3": "ITA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Italy", + "ADM0_A3": "ITA", + "GEOU_DIF": 0, + "GEOUNIT": "Italy", + "GU_A3": "ITA", + "SU_DIF": 0, + "SUBUNIT": "Italy", + "SU_A3": "ITA", + "BRK_DIFF": 0, + "NAME": "Italy", + "NAME_LONG": "Italy", + "BRK_A3": "ITA", + "BRK_NAME": "Italy", + "BRK_GROUP": null, + "ABBREV": "Italy", + "POSTAL": "I", + "FORMAL_EN": "Italian Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Italy", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Italy", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 7, + "MAPCOLOR9": 8, + "MAPCOLOR13": 7, + "POP_EST": 62137802, + "POP_RANK": 16, + "GDP_MD_EST": 2221000, + "POP_YEAR": 2017, + "LASTCENSUS": 2012, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "IT", + "ISO_A2": "IT", + "ISO_A3": "ITA", + "ISO_A3_EH": "ITA", + "ISO_N3": "380", + "UN_A3": "380", + "WB_A2": "IT", + "WB_A3": "ITA", + "WOE_ID": 23424853, + "WOE_ID_EH": 23424853, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ITA", + "ADM0_A3_US": "ITA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [6.749955, 36.619987, 18.480247, 47.115393], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [10.442701, 46.893546], + [11.048556, 46.751359], + [11.164828, 46.941579], + [12.153088, 47.115393], + [12.376485, 46.767559], + [13.806475, 46.509306], + [13.69811, 46.016778], + [13.93763, 45.591016], + [13.141606, 45.736692], + [12.328581, 45.381778], + [12.383875, 44.885374], + [12.261453, 44.600482], + [12.589237, 44.091366], + [13.526906, 43.587727], + [14.029821, 42.761008], + [15.14257, 41.95514], + [15.926191, 41.961315], + [16.169897, 41.740295], + [15.889346, 41.541082], + [16.785002, 41.179606], + [17.519169, 40.877143], + [18.376687, 40.355625], + [18.480247, 40.168866], + [18.293385, 39.810774], + [17.73838, 40.277671], + [16.869596, 40.442235], + [16.448743, 39.795401], + [17.17149, 39.4247], + [17.052841, 38.902871], + [16.635088, 38.843572], + [16.100961, 37.985899], + [15.684087, 37.908849], + [15.687963, 38.214593], + [15.891981, 38.750942], + [16.109332, 38.964547], + [15.718814, 39.544072], + [15.413613, 40.048357], + [14.998496, 40.172949], + [14.703268, 40.60455], + [14.060672, 40.786348], + [13.627985, 41.188287], + [12.888082, 41.25309], + [12.106683, 41.704535], + [11.191906, 42.355425], + [10.511948, 42.931463], + [10.200029, 43.920007], + [9.702488, 44.036279], + [8.888946, 44.366336], + [8.428561, 44.231228], + [7.850767, 43.767148], + [7.435185, 43.693845], + [7.549596, 44.127901], + [7.007562, 44.254767], + [6.749955, 45.028518], + [7.096652, 45.333099], + [6.802355, 45.70858], + [6.843593, 45.991147], + [7.273851, 45.776948], + [7.755992, 45.82449], + [8.31663, 46.163642], + [8.489952, 46.005151], + [8.966306, 46.036932], + [9.182882, 46.440215], + [9.922837, 46.314899], + [10.363378, 46.483571], + [10.442701, 46.893546] + ] + ], + [ + [ + [15.520376, 38.231155], + [15.160243, 37.444046], + [15.309898, 37.134219], + [15.099988, 36.619987], + [14.335229, 36.996631], + [13.826733, 37.104531], + [12.431004, 37.61295], + [12.570944, 38.126381], + [13.741156, 38.034966], + [14.761249, 38.143874], + [15.520376, 38.231155] + ] + ], + [ + [ + [9.210012, 41.209991], + [9.809975, 40.500009], + [9.669519, 39.177376], + [9.214818, 39.240473], + [8.806936, 38.906618], + [8.428302, 39.171847], + [8.388253, 40.378311], + [8.159998, 40.950007], + [8.709991, 40.899984], + [9.210012, 41.209991] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Jamaica", + "SOV_A3": "JAM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Jamaica", + "ADM0_A3": "JAM", + "GEOU_DIF": 0, + "GEOUNIT": "Jamaica", + "GU_A3": "JAM", + "SU_DIF": 0, + "SUBUNIT": "Jamaica", + "SU_A3": "JAM", + "BRK_DIFF": 0, + "NAME": "Jamaica", + "NAME_LONG": "Jamaica", + "BRK_A3": "JAM", + "BRK_NAME": "Jamaica", + "BRK_GROUP": null, + "ABBREV": "Jam.", + "POSTAL": "J", + "FORMAL_EN": "Jamaica", + "FORMAL_FR": null, + "NAME_CIAWF": "Jamaica", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Jamaica", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 4, + "MAPCOLOR13": 10, + "POP_EST": 2990561, + "POP_RANK": 12, + "GDP_MD_EST": 25390, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "JM", + "ISO_A2": "JM", + "ISO_A3": "JAM", + "ISO_A3_EH": "JAM", + "ISO_N3": "388", + "UN_A3": "388", + "WB_A2": "JM", + "WB_A3": "JAM", + "WOE_ID": 23424858, + "WOE_ID_EH": 23424858, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "JAM", + "ADM0_A3_US": "JAM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-78.337719, 17.701116, -76.199659, 18.524218], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.569601, 18.490525], + [-76.896619, 18.400867], + [-76.365359, 18.160701], + [-76.199659, 17.886867], + [-76.902561, 17.868238], + [-77.206341, 17.701116], + [-77.766023, 17.861597], + [-78.337719, 18.225968], + [-78.217727, 18.454533], + [-77.797365, 18.524218], + [-77.569601, 18.490525] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Jordan", + "SOV_A3": "JOR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Jordan", + "ADM0_A3": "JOR", + "GEOU_DIF": 0, + "GEOUNIT": "Jordan", + "GU_A3": "JOR", + "SU_DIF": 0, + "SUBUNIT": "Jordan", + "SU_A3": "JOR", + "BRK_DIFF": 0, + "NAME": "Jordan", + "NAME_LONG": "Jordan", + "BRK_A3": "JOR", + "BRK_NAME": "Jordan", + "BRK_GROUP": null, + "ABBREV": "Jord.", + "POSTAL": "J", + "FORMAL_EN": "Hashemite Kingdom of Jordan", + "FORMAL_FR": null, + "NAME_CIAWF": "Jordan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Jordan", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 3, + "MAPCOLOR9": 4, + "MAPCOLOR13": 4, + "POP_EST": 10248069, + "POP_RANK": 14, + "GDP_MD_EST": 86190, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "JO", + "ISO_A2": "JO", + "ISO_A3": "JOR", + "ISO_A3_EH": "JOR", + "ISO_N3": "400", + "UN_A3": "400", + "WB_A2": "JO", + "WB_A3": "JOR", + "WOE_ID": 23424860, + "WOE_ID_EH": 23424860, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "JOR", + "ADM0_A3_US": "JOR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [34.922603, 29.197495, 39.195468, 33.378686], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [38.792341, 33.378686], + [39.195468, 32.161009], + [39.004886, 32.010217], + [37.002166, 31.508413], + [37.998849, 30.5085], + [37.66812, 30.338665], + [37.503582, 30.003776], + [36.740528, 29.865283], + [36.501214, 29.505254], + [36.068941, 29.197495], + [34.956037, 29.356555], + [34.922603, 29.501326], + [35.420918, 31.100066], + [35.397561, 31.489086], + [35.545252, 31.782505], + [35.545665, 32.393992], + [35.719918, 32.709192], + [36.834062, 32.312938], + [38.792341, 33.378686] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Japan", + "SOV_A3": "JPN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Japan", + "ADM0_A3": "JPN", + "GEOU_DIF": 0, + "GEOUNIT": "Japan", + "GU_A3": "JPN", + "SU_DIF": 0, + "SUBUNIT": "Japan", + "SU_A3": "JPN", + "BRK_DIFF": 0, + "NAME": "Japan", + "NAME_LONG": "Japan", + "BRK_A3": "JPN", + "BRK_NAME": "Japan", + "BRK_GROUP": null, + "ABBREV": "Japan", + "POSTAL": "J", + "FORMAL_EN": "Japan", + "FORMAL_FR": null, + "NAME_CIAWF": "Japan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Japan", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 3, + "MAPCOLOR9": 5, + "MAPCOLOR13": 4, + "POP_EST": 126451398, + "POP_RANK": 17, + "GDP_MD_EST": 4932000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "JA", + "ISO_A2": "JP", + "ISO_A3": "JPN", + "ISO_A3_EH": "JPN", + "ISO_N3": "392", + "UN_A3": "392", + "WB_A2": "JP", + "WB_A3": "JPN", + "WOE_ID": 23424856, + "WOE_ID_EH": 23424856, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "JPN", + "ADM0_A3_US": "JPN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 7 + }, + "bbox": [129.408463, 31.029579, 145.543137, 45.551483], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [134.638428, 34.149234], + [134.766379, 33.806335], + [134.203416, 33.201178], + [133.79295, 33.521985], + [133.280268, 33.28957], + [133.014858, 32.704567], + [132.363115, 32.989382], + [132.371176, 33.463642], + [132.924373, 34.060299], + [133.492968, 33.944621], + [133.904106, 34.364931], + [134.638428, 34.149234] + ] + ], + [ + [ + [140.976388, 37.142074], + [140.59977, 36.343983], + [140.774074, 35.842877], + [140.253279, 35.138114], + [138.975528, 34.6676], + [137.217599, 34.606286], + [135.792983, 33.464805], + [135.120983, 33.849071], + [135.079435, 34.596545], + [133.340316, 34.375938], + [132.156771, 33.904933], + [130.986145, 33.885761], + [132.000036, 33.149992], + [131.33279, 31.450355], + [130.686318, 31.029579], + [130.20242, 31.418238], + [130.447676, 32.319475], + [129.814692, 32.61031], + [129.408463, 33.296056], + [130.353935, 33.604151], + [130.878451, 34.232743], + [131.884229, 34.749714], + [132.617673, 35.433393], + [134.608301, 35.731618], + [135.677538, 35.527134], + [136.723831, 37.304984], + [137.390612, 36.827391], + [138.857602, 37.827485], + [139.426405, 38.215962], + [140.05479, 39.438807], + [139.883379, 40.563312], + [140.305783, 41.195005], + [141.368973, 41.37856], + [141.914263, 39.991616], + [141.884601, 39.180865], + [140.959489, 38.174001], + [140.976388, 37.142074] + ] + ], + [ + [ + [143.910162, 44.1741], + [144.613427, 43.960883], + [145.320825, 44.384733], + [145.543137, 43.262088], + [144.059662, 42.988358], + [143.18385, 41.995215], + [141.611491, 42.678791], + [141.067286, 41.584594], + [139.955106, 41.569556], + [139.817544, 42.563759], + [140.312087, 43.333273], + [141.380549, 43.388825], + [141.671952, 44.772125], + [141.967645, 45.551483], + [143.14287, 44.510358], + [143.910162, 44.1741] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Kazakhstan", + "SOV_A3": "KAZ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Kazakhstan", + "ADM0_A3": "KAZ", + "GEOU_DIF": 0, + "GEOUNIT": "Kazakhstan", + "GU_A3": "KAZ", + "SU_DIF": 0, + "SUBUNIT": "Kazakhstan", + "SU_A3": "KAZ", + "BRK_DIFF": 0, + "NAME": "Kazakhstan", + "NAME_LONG": "Kazakhstan", + "BRK_A3": "KAZ", + "BRK_NAME": "Kazakhstan", + "BRK_GROUP": null, + "ABBREV": "Kaz.", + "POSTAL": "KZ", + "FORMAL_EN": "Republic of Kazakhstan", + "FORMAL_FR": null, + "NAME_CIAWF": "Kazakhstan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Kazakhstan", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 1, + "MAPCOLOR9": 6, + "MAPCOLOR13": 1, + "POP_EST": 18556698, + "POP_RANK": 14, + "GDP_MD_EST": 460700, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "KZ", + "ISO_A2": "KZ", + "ISO_A3": "KAZ", + "ISO_A3_EH": "KAZ", + "ISO_N3": "398", + "UN_A3": "398", + "WB_A2": "KZ", + "WB_A3": "KAZ", + "WOE_ID": -90, + "WOE_ID_EH": 23424871, + "WOE_NOTE": "Includes Baykonur Cosmodrome as an admin-1", + "ADM0_A3_IS": "KAZ", + "ADM0_A3_US": "KAZ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Central Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [46.466446, 40.662325, 87.35997, 55.38525], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [87.35997, 49.214981], + [86.598776, 48.549182], + [85.768233, 48.455751], + [85.720484, 47.452969], + [85.16429, 47.000956], + [83.180484, 47.330031], + [82.458926, 45.53965], + [81.947071, 45.317027], + [79.966106, 44.917517], + [80.866206, 43.180362], + [80.18015, 42.920068], + [80.25999, 42.349999], + [79.643645, 42.496683], + [79.142177, 42.856092], + [77.658392, 42.960686], + [76.000354, 42.988022], + [75.636965, 42.8779], + [74.212866, 43.298339], + [73.645304, 43.091272], + [73.489758, 42.500894], + [71.844638, 42.845395], + [71.186281, 42.704293], + [70.962315, 42.266154], + [70.388965, 42.081308], + [69.070027, 41.384244], + [68.632483, 40.668681], + [68.259896, 40.662325], + [67.985856, 41.135991], + [66.714047, 41.168444], + [66.510649, 41.987644], + [66.023392, 41.994646], + [66.098012, 42.99766], + [64.900824, 43.728081], + [63.185787, 43.650075], + [62.0133, 43.504477], + [61.05832, 44.405817], + [60.239972, 44.784037], + [58.689989, 45.500014], + [58.503127, 45.586804], + [55.928917, 44.995858], + [55.968191, 41.308642], + [55.455251, 41.259859], + [54.755345, 42.043971], + [54.079418, 42.324109], + [52.944293, 42.116034], + [52.50246, 41.783316], + [52.446339, 42.027151], + [52.692112, 42.443895], + [52.501426, 42.792298], + [51.342427, 43.132975], + [50.891292, 44.031034], + [50.339129, 44.284016], + [50.305643, 44.609836], + [51.278503, 44.514854], + [51.316899, 45.245998], + [52.16739, 45.408391], + [53.040876, 45.259047], + [53.220866, 46.234646], + [53.042737, 46.853006], + [52.042023, 46.804637], + [51.191945, 47.048705], + [50.034083, 46.60899], + [49.10116, 46.39933], + [48.59325, 46.56104], + [48.694734, 47.075628], + [48.05725, 47.74377], + [47.31524, 47.71585], + [46.466446, 48.394152], + [47.043672, 49.152039], + [46.751596, 49.356006], + [47.54948, 50.454698], + [48.577841, 49.87476], + [48.702382, 50.605128], + [50.766648, 51.692762], + [52.328724, 51.718652], + [54.532878, 51.02624], + [55.71694, 50.62171], + [56.77798, 51.04355], + [58.36332, 51.06364], + [59.642282, 50.545442], + [59.932807, 50.842194], + [61.337424, 50.79907], + [61.588003, 51.272659], + [59.967534, 51.96042], + [60.927269, 52.447548], + [60.739993, 52.719986], + [61.699986, 52.979996], + [60.978066, 53.664993], + [61.4366, 54.00625], + [65.178534, 54.354228], + [65.66687, 54.60125], + [68.1691, 54.970392], + [69.068167, 55.38525], + [70.865267, 55.169734], + [71.180131, 54.133285], + [72.22415, 54.376655], + [73.508516, 54.035617], + [73.425679, 53.48981], + [74.38482, 53.54685], + [76.8911, 54.490524], + [76.525179, 54.177003], + [77.800916, 53.404415], + [80.03556, 50.864751], + [80.568447, 51.388336], + [81.945986, 50.812196], + [83.383004, 51.069183], + [83.935115, 50.889246], + [84.416377, 50.3114], + [85.11556, 50.117303], + [85.54127, 49.692859], + [86.829357, 49.826675], + [87.35997, 49.214981] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Kenya", + "SOV_A3": "KEN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Kenya", + "ADM0_A3": "KEN", + "GEOU_DIF": 0, + "GEOUNIT": "Kenya", + "GU_A3": "KEN", + "SU_DIF": 0, + "SUBUNIT": "Kenya", + "SU_A3": "KEN", + "BRK_DIFF": 0, + "NAME": "Kenya", + "NAME_LONG": "Kenya", + "BRK_A3": "KEN", + "BRK_NAME": "Kenya", + "BRK_GROUP": null, + "ABBREV": "Ken.", + "POSTAL": "KE", + "FORMAL_EN": "Republic of Kenya", + "FORMAL_FR": null, + "NAME_CIAWF": "Kenya", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Kenya", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 2, + "MAPCOLOR9": 7, + "MAPCOLOR13": 3, + "POP_EST": 47615739, + "POP_RANK": 15, + "GDP_MD_EST": 152700, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "KE", + "ISO_A2": "KE", + "ISO_A3": "KEN", + "ISO_A3_EH": "KEN", + "ISO_N3": "404", + "UN_A3": "404", + "WB_A2": "KE", + "WB_A3": "KEN", + "WOE_ID": 23424863, + "WOE_ID_EH": 23424863, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "KEN", + "ADM0_A3_US": "KEN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [33.893569, -4.67677, 41.855083, 5.506], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [35.298007, 5.506], + [35.817448, 5.338232], + [35.817448, 4.776966], + [36.159079, 4.447864], + [36.855093, 4.447864], + [38.120915, 3.598605], + [38.43697, 3.58851], + [38.67114, 3.61607], + [38.89251, 3.50074], + [39.559384, 3.42206], + [39.85494, 3.83879], + [40.76848, 4.25702], + [41.1718, 3.91909], + [41.855083, 3.918912], + [40.98105, 2.78452], + [40.993, -0.85829], + [41.58513, -1.68325], + [40.88477, -2.08255], + [40.63785, -2.49979], + [40.26304, -2.57309], + [40.12119, -3.27768], + [39.80006, -3.68116], + [39.60489, -4.34653], + [39.20222, -4.67677], + [37.7669, -3.67712], + [37.69869, -3.09699], + [34.07262, -1.05982], + [33.903711, -0.95], + [33.893569, 0.109814], + [34.18, 0.515], + [34.6721, 1.17694], + [35.03599, 1.90584], + [34.59607, 3.05374], + [34.47913, 3.5556], + [34.005, 4.249885], + [34.620196, 4.847123], + [35.298007, 5.506] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Kyrgyzstan", + "SOV_A3": "KGZ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Kyrgyzstan", + "ADM0_A3": "KGZ", + "GEOU_DIF": 0, + "GEOUNIT": "Kyrgyzstan", + "GU_A3": "KGZ", + "SU_DIF": 0, + "SUBUNIT": "Kyrgyzstan", + "SU_A3": "KGZ", + "BRK_DIFF": 0, + "NAME": "Kyrgyzstan", + "NAME_LONG": "Kyrgyzstan", + "BRK_A3": "KGZ", + "BRK_NAME": "Kyrgyzstan", + "BRK_GROUP": null, + "ABBREV": "Kgz.", + "POSTAL": "KG", + "FORMAL_EN": "Kyrgyz Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Kyrgyzstan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Kyrgyz Republic", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 7, + "MAPCOLOR9": 7, + "MAPCOLOR13": 6, + "POP_EST": 5789122, + "POP_RANK": 13, + "GDP_MD_EST": 21010, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "KG", + "ISO_A2": "KG", + "ISO_A3": "KGZ", + "ISO_A3_EH": "KGZ", + "ISO_N3": "417", + "UN_A3": "417", + "WB_A2": "KG", + "WB_A3": "KGZ", + "WOE_ID": 23424864, + "WOE_ID_EH": 23424864, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "KGZ", + "ADM0_A3_US": "KGZ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Central Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [69.464887, 39.279463, 80.25999, 43.298339], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [80.25999, 42.349999], + [80.11943, 42.123941], + [78.543661, 41.582243], + [78.187197, 41.185316], + [76.904484, 41.066486], + [76.526368, 40.427946], + [75.467828, 40.562072], + [74.776862, 40.366425], + [73.822244, 39.893973], + [73.960013, 39.660008], + [73.675379, 39.431237], + [71.784694, 39.279463], + [70.549162, 39.604198], + [69.464887, 39.526683], + [69.55961, 40.103211], + [70.648019, 39.935754], + [71.014198, 40.244366], + [71.774875, 40.145844], + [73.055417, 40.866033], + [71.870115, 41.3929], + [71.157859, 41.143587], + [70.420022, 41.519998], + [71.259248, 42.167711], + [70.962315, 42.266154], + [71.186281, 42.704293], + [71.844638, 42.845395], + [73.489758, 42.500894], + [73.645304, 43.091272], + [74.212866, 43.298339], + [75.636965, 42.8779], + [76.000354, 42.988022], + [77.658392, 42.960686], + [79.142177, 42.856092], + [79.643645, 42.496683], + [80.25999, 42.349999] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Cambodia", + "SOV_A3": "KHM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Cambodia", + "ADM0_A3": "KHM", + "GEOU_DIF": 0, + "GEOUNIT": "Cambodia", + "GU_A3": "KHM", + "SU_DIF": 0, + "SUBUNIT": "Cambodia", + "SU_A3": "KHM", + "BRK_DIFF": 0, + "NAME": "Cambodia", + "NAME_LONG": "Cambodia", + "BRK_A3": "KHM", + "BRK_NAME": "Cambodia", + "BRK_GROUP": null, + "ABBREV": "Camb.", + "POSTAL": "KH", + "FORMAL_EN": "Kingdom of Cambodia", + "FORMAL_FR": null, + "NAME_CIAWF": "Cambodia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Cambodia", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 6, + "MAPCOLOR13": 5, + "POP_EST": 16204486, + "POP_RANK": 14, + "GDP_MD_EST": 58940, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "CB", + "ISO_A2": "KH", + "ISO_A3": "KHM", + "ISO_A3_EH": "KHM", + "ISO_N3": "116", + "UN_A3": "116", + "WB_A2": "KH", + "WB_A3": "KHM", + "WOE_ID": 23424776, + "WOE_ID_EH": 23424776, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "KHM", + "ADM0_A3_US": "KHM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [102.348099, 10.486544, 107.614548, 14.570584], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [102.584932, 12.186595], + [102.348099, 13.394247], + [102.988422, 14.225721], + [104.281418, 14.416743], + [105.218777, 14.273212], + [106.043946, 13.881091], + [106.496373, 14.570584], + [107.382727, 14.202441], + [107.614548, 13.535531], + [107.491403, 12.337206], + [105.810524, 11.567615], + [106.24967, 10.961812], + [105.199915, 10.88931], + [104.334335, 10.486544], + [103.49728, 10.632555], + [103.09069, 11.153661], + [102.584932, 12.186595] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "South Korea", + "SOV_A3": "KOR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "South Korea", + "ADM0_A3": "KOR", + "GEOU_DIF": 0, + "GEOUNIT": "South Korea", + "GU_A3": "KOR", + "SU_DIF": 0, + "SUBUNIT": "South Korea", + "SU_A3": "KOR", + "BRK_DIFF": 0, + "NAME": "South Korea", + "NAME_LONG": "Republic of Korea", + "BRK_A3": "KOR", + "BRK_NAME": "Republic of Korea", + "BRK_GROUP": null, + "ABBREV": "S.K.", + "POSTAL": "KR", + "FORMAL_EN": "Republic of Korea", + "FORMAL_FR": null, + "NAME_CIAWF": "Korea, South", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Korea, Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 1, + "MAPCOLOR13": 5, + "POP_EST": 51181299, + "POP_RANK": 16, + "GDP_MD_EST": 1929000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "4. Emerging region: MIKT", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "KS", + "ISO_A2": "KR", + "ISO_A3": "KOR", + "ISO_A3_EH": "KOR", + "ISO_N3": "410", + "UN_A3": "410", + "WB_A2": "KR", + "WB_A3": "KOR", + "WOE_ID": 23424868, + "WOE_ID_EH": 23424868, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "KOR", + "ADM0_A3_US": "KOR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 11, + "LONG_LEN": 17, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [126.117398, 34.390046, 129.468304, 38.612243], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [128.349716, 38.612243], + [129.21292, 37.432392], + [129.46045, 36.784189], + [129.468304, 35.632141], + [129.091377, 35.082484], + [128.18585, 34.890377], + [127.386519, 34.475674], + [126.485748, 34.390046], + [126.37392, 34.93456], + [126.559231, 35.684541], + [126.117398, 36.725485], + [126.860143, 36.893924], + [126.174759, 37.749686], + [126.237339, 37.840378], + [126.68372, 37.804773], + [127.073309, 38.256115], + [127.780035, 38.304536], + [128.205746, 38.370397], + [128.349716, 38.612243] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Kosovo", + "SOV_A3": "KOS", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Kosovo", + "ADM0_A3": "KOS", + "GEOU_DIF": 0, + "GEOUNIT": "Kosovo", + "GU_A3": "KOS", + "SU_DIF": 0, + "SUBUNIT": "Kosovo", + "SU_A3": "KOS", + "BRK_DIFF": 0, + "NAME": "Kosovo", + "NAME_LONG": "Kosovo", + "BRK_A3": "KOS", + "BRK_NAME": "Kosovo", + "BRK_GROUP": null, + "ABBREV": "Kos.", + "POSTAL": "KO", + "FORMAL_EN": "Republic of Kosovo", + "FORMAL_FR": null, + "NAME_CIAWF": "Kosovo", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Kosovo", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 11, + "POP_EST": 1895250, + "POP_RANK": 12, + "GDP_MD_EST": 18490, + "POP_YEAR": 2017, + "LASTCENSUS": 1981, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "KV", + "ISO_A2": "XK", + "ISO_A3": "-99", + "ISO_A3_EH": "-99", + "ISO_N3": "-99", + "UN_A3": "-099", + "WB_A2": "KV", + "WB_A3": "KSV", + "WOE_ID": -90, + "WOE_ID_EH": 29389201, + "WOE_NOTE": "Subunit of Serbia in WOE still; should include 29389201, 29389207, 29389218, 29389209 and 29389214.", + "ADM0_A3_IS": "KOS", + "ADM0_A3_US": "KOS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [20.0707, 41.84711, 21.77505, 43.27205], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [20.590247, 41.855409], + [20.52295, 42.21787], + [20.283755, 42.32026], + [20.0707, 42.58863], + [20.25758, 42.81275], + [20.49679, 42.88469], + [20.63508, 43.21671], + [20.81448, 43.27205], + [20.95651, 43.13094], + [21.143395, 43.068685], + [21.27421, 42.90959], + [21.43866, 42.86255], + [21.63302, 42.67717], + [21.77505, 42.6827], + [21.66292, 42.43922], + [21.54332, 42.32025], + [21.576636, 42.245224], + [21.3527, 42.2068], + [20.76216, 42.05186], + [20.71731, 41.84711], + [20.590247, 41.855409] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Kuwait", + "SOV_A3": "KWT", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Kuwait", + "ADM0_A3": "KWT", + "GEOU_DIF": 0, + "GEOUNIT": "Kuwait", + "GU_A3": "KWT", + "SU_DIF": 0, + "SUBUNIT": "Kuwait", + "SU_A3": "KWT", + "BRK_DIFF": 0, + "NAME": "Kuwait", + "NAME_LONG": "Kuwait", + "BRK_A3": "KWT", + "BRK_NAME": "Kuwait", + "BRK_GROUP": null, + "ABBREV": "Kwt.", + "POSTAL": "KW", + "FORMAL_EN": "State of Kuwait", + "FORMAL_FR": null, + "NAME_CIAWF": "Kuwait", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Kuwait", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 2, + "POP_EST": 2875422, + "POP_RANK": 12, + "GDP_MD_EST": 301100, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "KU", + "ISO_A2": "KW", + "ISO_A3": "KWT", + "ISO_A3_EH": "KWT", + "ISO_N3": "414", + "UN_A3": "414", + "WB_A2": "KW", + "WB_A3": "KWT", + "WOE_ID": 23424870, + "WOE_ID_EH": 23424870, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "KWT", + "ADM0_A3_US": "KWT", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [46.568713, 28.526063, 48.416094, 30.05907], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [46.568713, 29.099025], + [47.302622, 30.05907], + [47.974519, 29.975819], + [48.183189, 29.534477], + [48.093943, 29.306299], + [48.416094, 28.552004], + [47.708851, 28.526063], + [47.459822, 29.002519], + [46.568713, 29.099025] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Laos", + "SOV_A3": "LAO", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Laos", + "ADM0_A3": "LAO", + "GEOU_DIF": 0, + "GEOUNIT": "Laos", + "GU_A3": "LAO", + "SU_DIF": 0, + "SUBUNIT": "Laos", + "SU_A3": "LAO", + "BRK_DIFF": 0, + "NAME": "Laos", + "NAME_LONG": "Lao PDR", + "BRK_A3": "LAO", + "BRK_NAME": "Laos", + "BRK_GROUP": null, + "ABBREV": "Laos", + "POSTAL": "LA", + "FORMAL_EN": "Lao People's Democratic Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Laos", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Lao PDR", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 1, + "MAPCOLOR9": 1, + "MAPCOLOR13": 9, + "POP_EST": 7126706, + "POP_RANK": 13, + "GDP_MD_EST": 40960, + "POP_YEAR": 2017, + "LASTCENSUS": 2005, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "LA", + "ISO_A2": "LA", + "ISO_A3": "LAO", + "ISO_A3_EH": "LAO", + "ISO_N3": "418", + "UN_A3": "418", + "WB_A2": "LA", + "WB_A3": "LAO", + "WOE_ID": 23424872, + "WOE_ID_EH": 23424872, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LAO", + "ADM0_A3_US": "LAO", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 4, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [100.115988, 13.881091, 107.564525, 22.464753], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [101.180005, 21.436573], + [101.270026, 21.201652], + [101.80312, 21.174367], + [101.652018, 22.318199], + [102.170436, 22.464753], + [102.754896, 21.675137], + [103.203861, 20.766562], + [104.435, 20.758733], + [104.822574, 19.886642], + [104.183388, 19.624668], + [103.896532, 19.265181], + [105.094598, 18.666975], + [105.925762, 17.485315], + [106.556008, 16.604284], + [107.312706, 15.908538], + [107.564525, 15.202173], + [107.382727, 14.202441], + [106.496373, 14.570584], + [106.043946, 13.881091], + [105.218777, 14.273212], + [105.544338, 14.723934], + [105.589039, 15.570316], + [104.779321, 16.441865], + [104.716947, 17.428859], + [103.956477, 18.240954], + [103.200192, 18.309632], + [102.998706, 17.961695], + [102.413005, 17.932782], + [102.113592, 18.109102], + [101.059548, 17.512497], + [101.035931, 18.408928], + [101.282015, 19.462585], + [100.606294, 19.508344], + [100.548881, 20.109238], + [100.115988, 20.41785], + [100.329101, 20.786122], + [101.180005, 21.436573] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Lebanon", + "SOV_A3": "LBN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Lebanon", + "ADM0_A3": "LBN", + "GEOU_DIF": 0, + "GEOUNIT": "Lebanon", + "GU_A3": "LBN", + "SU_DIF": 0, + "SUBUNIT": "Lebanon", + "SU_A3": "LBN", + "BRK_DIFF": 0, + "NAME": "Lebanon", + "NAME_LONG": "Lebanon", + "BRK_A3": "LBN", + "BRK_NAME": "Lebanon", + "BRK_GROUP": null, + "ABBREV": "Leb.", + "POSTAL": "LB", + "FORMAL_EN": "Lebanese Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Lebanon", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Lebanon", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 4, + "MAPCOLOR9": 4, + "MAPCOLOR13": 12, + "POP_EST": 6229794, + "POP_RANK": 13, + "GDP_MD_EST": 85160, + "POP_YEAR": 2017, + "LASTCENSUS": 1970, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "LE", + "ISO_A2": "LB", + "ISO_A3": "LBN", + "ISO_A3_EH": "LBN", + "ISO_N3": "422", + "UN_A3": "422", + "WB_A2": "LB", + "WB_A3": "LBN", + "WOE_ID": 23424873, + "WOE_ID_EH": 23424873, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LBN", + "ADM0_A3_US": "LBN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": 4, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [35.126053, 33.08904, 36.61175, 34.644914], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [35.821101, 33.277426], + [35.552797, 33.264275], + [35.460709, 33.08904], + [35.126053, 33.0909], + [35.482207, 33.90545], + [35.979592, 34.610058], + [35.998403, 34.644914], + [36.448194, 34.593935], + [36.61175, 34.201789], + [36.06646, 33.824912], + [35.821101, 33.277426] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Liberia", + "SOV_A3": "LBR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Liberia", + "ADM0_A3": "LBR", + "GEOU_DIF": 0, + "GEOUNIT": "Liberia", + "GU_A3": "LBR", + "SU_DIF": 0, + "SUBUNIT": "Liberia", + "SU_A3": "LBR", + "BRK_DIFF": 0, + "NAME": "Liberia", + "NAME_LONG": "Liberia", + "BRK_A3": "LBR", + "BRK_NAME": "Liberia", + "BRK_GROUP": null, + "ABBREV": "Liberia", + "POSTAL": "LR", + "FORMAL_EN": "Republic of Liberia", + "FORMAL_FR": null, + "NAME_CIAWF": "Liberia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Liberia", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 3, + "MAPCOLOR9": 4, + "MAPCOLOR13": 9, + "POP_EST": 4689021, + "POP_RANK": 12, + "GDP_MD_EST": 3881, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "LI", + "ISO_A2": "LR", + "ISO_A3": "LBR", + "ISO_A3_EH": "LBR", + "ISO_N3": "430", + "UN_A3": "430", + "WB_A2": "LR", + "WB_A3": "LBR", + "WOE_ID": 23424876, + "WOE_ID_EH": 23424876, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LBR", + "ADM0_A3_US": "LBR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 7, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-11.438779, 4.355755, -7.539715, 8.541055], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-8.439298, 7.686043], + [-8.485446, 7.395208], + [-8.385452, 6.911801], + [-8.60288, 6.467564], + [-8.311348, 6.193033], + [-7.993693, 6.12619], + [-7.570153, 5.707352], + [-7.539715, 5.313345], + [-7.635368, 5.188159], + [-7.712159, 4.364566], + [-7.974107, 4.355755], + [-9.004794, 4.832419], + [-9.91342, 5.593561], + [-10.765384, 6.140711], + [-11.438779, 6.785917], + [-11.199802, 7.105846], + [-11.146704, 7.396706], + [-10.695595, 7.939464], + [-10.230094, 8.406206], + [-10.016567, 8.428504], + [-9.755342, 8.541055], + [-9.33728, 7.928534], + [-9.403348, 7.526905], + [-9.208786, 7.313921], + [-8.926065, 7.309037], + [-8.722124, 7.711674], + [-8.439298, 7.686043] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Libya", + "SOV_A3": "LBY", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Libya", + "ADM0_A3": "LBY", + "GEOU_DIF": 0, + "GEOUNIT": "Libya", + "GU_A3": "LBY", + "SU_DIF": 0, + "SUBUNIT": "Libya", + "SU_A3": "LBY", + "BRK_DIFF": 0, + "NAME": "Libya", + "NAME_LONG": "Libya", + "BRK_A3": "LBY", + "BRK_NAME": "Libya", + "BRK_GROUP": null, + "ABBREV": "Libya", + "POSTAL": "LY", + "FORMAL_EN": "Libya", + "FORMAL_FR": null, + "NAME_CIAWF": "Libya", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Libya", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 11, + "POP_EST": 6653210, + "POP_RANK": 13, + "GDP_MD_EST": 90890, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "LY", + "ISO_A2": "LY", + "ISO_A3": "LBY", + "ISO_A3_EH": "LBY", + "ISO_N3": "434", + "UN_A3": "434", + "WB_A2": "LY", + "WB_A3": "LBY", + "WOE_ID": 23424882, + "WOE_ID_EH": 23424882, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LBY", + "ADM0_A3_US": "LBY", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [9.319411, 19.58047, 25.16482, 33.136996], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.999506, 23.471668], + [11.560669, 24.097909], + [10.771364, 24.562532], + [10.303847, 24.379313], + [9.948261, 24.936954], + [9.910693, 25.365455], + [9.319411, 26.094325], + [9.716286, 26.512206], + [9.629056, 27.140953], + [9.756128, 27.688259], + [9.683885, 28.144174], + [9.859998, 28.95999], + [9.805634, 29.424638], + [9.48214, 30.307556], + [9.970017, 30.539325], + [10.056575, 30.961831], + [9.950225, 31.37607], + [10.636901, 31.761421], + [10.94479, 32.081815], + [11.432253, 32.368903], + [11.488787, 33.136996], + [12.66331, 32.79278], + [13.08326, 32.87882], + [13.91868, 32.71196], + [15.24563, 32.26508], + [15.71394, 31.37626], + [16.61162, 31.18218], + [18.02109, 30.76357], + [19.08641, 30.26639], + [19.57404, 30.52582], + [20.05335, 30.98576], + [19.82033, 31.75179], + [20.13397, 32.2382], + [20.85452, 32.7068], + [21.54298, 32.8432], + [22.89576, 32.63858], + [23.2368, 32.19149], + [23.60913, 32.18726], + [23.9275, 32.01667], + [24.92114, 31.89936], + [25.16482, 31.56915], + [24.80287, 31.08929], + [24.95762, 30.6616], + [24.70007, 30.04419], + [25, 29.238655], + [25, 25.6825], + [25, 22], + [25, 20.00304], + [23.85, 20], + [23.83766, 19.58047], + [19.84926, 21.49509], + [15.86085, 23.40972], + [14.8513, 22.86295], + [14.143871, 22.491289], + [13.581425, 23.040506], + [11.999506, 23.471668] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Sri Lanka", + "SOV_A3": "LKA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Sri Lanka", + "ADM0_A3": "LKA", + "GEOU_DIF": 0, + "GEOUNIT": "Sri Lanka", + "GU_A3": "LKA", + "SU_DIF": 0, + "SUBUNIT": "Sri Lanka", + "SU_A3": "LKA", + "BRK_DIFF": 0, + "NAME": "Sri Lanka", + "NAME_LONG": "Sri Lanka", + "BRK_A3": "LKA", + "BRK_NAME": "Sri Lanka", + "BRK_GROUP": null, + "ABBREV": "Sri L.", + "POSTAL": "LK", + "FORMAL_EN": "Democratic Socialist Republic of Sri Lanka", + "FORMAL_FR": null, + "NAME_CIAWF": "Sri Lanka", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Sri Lanka", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 5, + "MAPCOLOR9": 4, + "MAPCOLOR13": 9, + "POP_EST": 22409381, + "POP_RANK": 15, + "GDP_MD_EST": 236700, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "CE", + "ISO_A2": "LK", + "ISO_A3": "LKA", + "ISO_A3_EH": "LKA", + "ISO_N3": "144", + "UN_A3": "144", + "WB_A2": "LK", + "WB_A3": "LKA", + "WOE_ID": 23424778, + "WOE_ID_EH": 23424778, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LKA", + "ADM0_A3_US": "LKA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [79.695167, 5.96837, 81.787959, 9.824078], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [81.787959, 7.523055], + [81.637322, 6.481775], + [81.21802, 6.197141], + [80.348357, 5.96837], + [79.872469, 6.763463], + [79.695167, 8.200843], + [80.147801, 9.824078], + [80.838818, 9.268427], + [81.304319, 8.564206], + [81.787959, 7.523055] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Lesotho", + "SOV_A3": "LSO", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Lesotho", + "ADM0_A3": "LSO", + "GEOU_DIF": 0, + "GEOUNIT": "Lesotho", + "GU_A3": "LSO", + "SU_DIF": 0, + "SUBUNIT": "Lesotho", + "SU_A3": "LSO", + "BRK_DIFF": 0, + "NAME": "Lesotho", + "NAME_LONG": "Lesotho", + "BRK_A3": "LSO", + "BRK_NAME": "Lesotho", + "BRK_GROUP": null, + "ABBREV": "Les.", + "POSTAL": "LS", + "FORMAL_EN": "Kingdom of Lesotho", + "FORMAL_FR": null, + "NAME_CIAWF": "Lesotho", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Lesotho", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 5, + "MAPCOLOR9": 2, + "MAPCOLOR13": 8, + "POP_EST": 1958042, + "POP_RANK": 12, + "GDP_MD_EST": 6019, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "LT", + "ISO_A2": "LS", + "ISO_A3": "LSO", + "ISO_A3_EH": "LSO", + "ISO_N3": "426", + "UN_A3": "426", + "WB_A2": "LS", + "WB_A3": "LSO", + "WOE_ID": 23424880, + "WOE_ID_EH": 23424880, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LSO", + "ADM0_A3_US": "LSO", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Southern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [26.999262, -30.645106, 29.325166, -28.647502], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [28.978263, -28.955597], + [29.325166, -29.257387], + [29.018415, -29.743766], + [28.8484, -30.070051], + [28.291069, -30.226217], + [28.107205, -30.545732], + [27.749397, -30.645106], + [26.999262, -29.875954], + [27.532511, -29.242711], + [28.074338, -28.851469], + [28.5417, -28.647502], + [28.978263, -28.955597] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Lithuania", + "SOV_A3": "LTU", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Lithuania", + "ADM0_A3": "LTU", + "GEOU_DIF": 0, + "GEOUNIT": "Lithuania", + "GU_A3": "LTU", + "SU_DIF": 0, + "SUBUNIT": "Lithuania", + "SU_A3": "LTU", + "BRK_DIFF": 0, + "NAME": "Lithuania", + "NAME_LONG": "Lithuania", + "BRK_A3": "LTU", + "BRK_NAME": "Lithuania", + "BRK_GROUP": null, + "ABBREV": "Lith.", + "POSTAL": "LT", + "FORMAL_EN": "Republic of Lithuania", + "FORMAL_FR": null, + "NAME_CIAWF": "Lithuania", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Lithuania", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 3, + "MAPCOLOR13": 9, + "POP_EST": 2823859, + "POP_RANK": 12, + "GDP_MD_EST": 85620, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "LH", + "ISO_A2": "LT", + "ISO_A3": "LTU", + "ISO_A3_EH": "LTU", + "ISO_N3": "440", + "UN_A3": "440", + "WB_A2": "LT", + "WB_A3": "LTU", + "WOE_ID": 23424875, + "WOE_ID_EH": 23424875, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LTU", + "ADM0_A3_US": "LTU", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [21.0558, 53.905702, 26.588279, 56.372528], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [26.494331, 55.615107], + [26.588279, 55.167176], + [25.768433, 54.846963], + [25.536354, 54.282423], + [24.450684, 53.905702], + [23.484128, 53.912498], + [23.243987, 54.220567], + [22.731099, 54.327537], + [22.651052, 54.582741], + [22.757764, 54.856574], + [22.315724, 55.015299], + [21.268449, 55.190482], + [21.0558, 56.031076], + [22.201157, 56.337802], + [23.878264, 56.273671], + [24.860684, 56.372528], + [25.000934, 56.164531], + [25.533047, 56.100297], + [26.494331, 55.615107] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Luxembourg", + "SOV_A3": "LUX", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Luxembourg", + "ADM0_A3": "LUX", + "GEOU_DIF": 0, + "GEOUNIT": "Luxembourg", + "GU_A3": "LUX", + "SU_DIF": 0, + "SUBUNIT": "Luxembourg", + "SU_A3": "LUX", + "BRK_DIFF": 0, + "NAME": "Luxembourg", + "NAME_LONG": "Luxembourg", + "BRK_A3": "LUX", + "BRK_NAME": "Luxembourg", + "BRK_GROUP": null, + "ABBREV": "Lux.", + "POSTAL": "L", + "FORMAL_EN": "Grand Duchy of Luxembourg", + "FORMAL_FR": null, + "NAME_CIAWF": "Luxembourg", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Luxembourg", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 7, + "MAPCOLOR9": 3, + "MAPCOLOR13": 7, + "POP_EST": 594130, + "POP_RANK": 11, + "GDP_MD_EST": 58740, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "LU", + "ISO_A2": "LU", + "ISO_A3": "LUX", + "ISO_A3_EH": "LUX", + "ISO_N3": "442", + "UN_A3": "442", + "WB_A2": "LU", + "WB_A3": "LUX", + "WOE_ID": 23424881, + "WOE_ID_EH": 23424881, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LUX", + "ADM0_A3_US": "LUX", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": 5, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5.7, + "MAX_LABEL": 10 + }, + "bbox": [5.674052, 49.442667, 6.242751, 50.128052], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [5.674052, 49.529484], + [5.782417, 50.090328], + [6.043073, 50.128052], + [6.242751, 49.902226], + [6.18632, 49.463803], + [5.897759, 49.442667], + [5.674052, 49.529484] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Latvia", + "SOV_A3": "LVA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Latvia", + "ADM0_A3": "LVA", + "GEOU_DIF": 0, + "GEOUNIT": "Latvia", + "GU_A3": "LVA", + "SU_DIF": 0, + "SUBUNIT": "Latvia", + "SU_A3": "LVA", + "BRK_DIFF": 0, + "NAME": "Latvia", + "NAME_LONG": "Latvia", + "BRK_A3": "LVA", + "BRK_NAME": "Latvia", + "BRK_GROUP": null, + "ABBREV": "Lat.", + "POSTAL": "LV", + "FORMAL_EN": "Republic of Latvia", + "FORMAL_FR": null, + "NAME_CIAWF": "Latvia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Latvia", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 7, + "MAPCOLOR9": 6, + "MAPCOLOR13": 13, + "POP_EST": 1944643, + "POP_RANK": 12, + "GDP_MD_EST": 50650, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "LG", + "ISO_A2": "LV", + "ISO_A3": "LVA", + "ISO_A3_EH": "LVA", + "ISO_N3": "428", + "UN_A3": "428", + "WB_A2": "LV", + "WB_A3": "LVA", + "WOE_ID": 23424874, + "WOE_ID_EH": 23424874, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "LVA", + "ADM0_A3_US": "LVA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [21.0558, 55.615107, 28.176709, 57.970157], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [28.176709, 56.16913], + [27.10246, 55.783314], + [26.494331, 55.615107], + [25.533047, 56.100297], + [25.000934, 56.164531], + [24.860684, 56.372528], + [23.878264, 56.273671], + [22.201157, 56.337802], + [21.0558, 56.031076], + [21.090424, 56.783873], + [21.581866, 57.411871], + [22.524341, 57.753374], + [23.318453, 57.006236], + [24.12073, 57.025693], + [24.312863, 57.793424], + [25.164594, 57.970157], + [25.60281, 57.847529], + [26.463532, 57.476389], + [27.288185, 57.474528], + [27.770016, 57.244258], + [27.855282, 56.759326], + [28.176709, 56.16913] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Morocco", + "SOV_A3": "MAR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Morocco", + "ADM0_A3": "MAR", + "GEOU_DIF": 0, + "GEOUNIT": "Morocco", + "GU_A3": "MAR", + "SU_DIF": 0, + "SUBUNIT": "Morocco", + "SU_A3": "MAR", + "BRK_DIFF": 0, + "NAME": "Morocco", + "NAME_LONG": "Morocco", + "BRK_A3": "MAR", + "BRK_NAME": "Morocco", + "BRK_GROUP": null, + "ABBREV": "Mor.", + "POSTAL": "MA", + "FORMAL_EN": "Kingdom of Morocco", + "FORMAL_FR": null, + "NAME_CIAWF": "Morocco", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Morocco", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 9, + "POP_EST": 33986655, + "POP_RANK": 15, + "GDP_MD_EST": 282800, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MO", + "ISO_A2": "MA", + "ISO_A3": "MAR", + "ISO_A3_EH": "MAR", + "ISO_N3": "504", + "UN_A3": "504", + "WB_A2": "MA", + "WB_A3": "MAR", + "WOE_ID": 23424893, + "WOE_ID_EH": 23424893, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MAR", + "ADM0_A3_US": "MAR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-17.020428, 21.420734, -1.124551, 35.759988], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-2.169914, 35.168396], + [-1.792986, 34.527919], + [-1.733455, 33.919713], + [-1.388049, 32.864015], + [-1.124551, 32.651522], + [-1.307899, 32.262889], + [-2.616605, 32.094346], + [-3.06898, 31.724498], + [-3.647498, 31.637294], + [-3.690441, 30.896952], + [-4.859646, 30.501188], + [-5.242129, 30.000443], + [-6.060632, 29.7317], + [-7.059228, 29.579228], + [-8.674116, 28.841289], + [-8.66559, 27.656426], + [-8.817828, 27.656426], + [-8.794884, 27.120696], + [-9.413037, 27.088476], + [-9.735343, 26.860945], + [-10.189424, 26.860945], + [-10.551263, 26.990808], + [-11.392555, 26.883424], + [-11.71822, 26.104092], + [-12.030759, 26.030866], + [-12.500963, 24.770116], + [-13.89111, 23.691009], + [-14.221168, 22.310163], + [-14.630833, 21.86094], + [-14.750955, 21.5006], + [-17.002962, 21.420734], + [-17.020428, 21.42231], + [-16.973248, 21.885745], + [-16.589137, 22.158234], + [-16.261922, 22.67934], + [-16.326414, 23.017768], + [-15.982611, 23.723358], + [-15.426004, 24.359134], + [-15.089332, 24.520261], + [-14.824645, 25.103533], + [-14.800926, 25.636265], + [-14.43994, 26.254418], + [-13.773805, 26.618892], + [-13.139942, 27.640148], + [-13.121613, 27.654148], + [-12.618837, 28.038186], + [-11.688919, 28.148644], + [-10.900957, 28.832142], + [-10.399592, 29.098586], + [-9.564811, 29.933574], + [-9.814718, 31.177736], + [-9.434793, 32.038096], + [-9.300693, 32.564679], + [-8.657476, 33.240245], + [-7.654178, 33.697065], + [-6.912544, 34.110476], + [-6.244342, 35.145865], + [-5.929994, 35.759988], + [-5.193863, 35.755182], + [-4.591006, 35.330712], + [-3.640057, 35.399855], + [-2.604306, 35.179093], + [-2.169914, 35.168396] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Moldova", + "SOV_A3": "MDA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Moldova", + "ADM0_A3": "MDA", + "GEOU_DIF": 0, + "GEOUNIT": "Moldova", + "GU_A3": "MDA", + "SU_DIF": 0, + "SUBUNIT": "Moldova", + "SU_A3": "MDA", + "BRK_DIFF": 0, + "NAME": "Moldova", + "NAME_LONG": "Moldova", + "BRK_A3": "MDA", + "BRK_NAME": "Moldova", + "BRK_GROUP": null, + "ABBREV": "Mda.", + "POSTAL": "MD", + "FORMAL_EN": "Republic of Moldova", + "FORMAL_FR": null, + "NAME_CIAWF": "Moldova", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Moldova", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 5, + "MAPCOLOR9": 4, + "MAPCOLOR13": 12, + "POP_EST": 3474121, + "POP_RANK": 12, + "GDP_MD_EST": 18540, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MD", + "ISO_A2": "MD", + "ISO_A3": "MDA", + "ISO_A3_EH": "MDA", + "ISO_N3": "498", + "UN_A3": "498", + "WB_A2": "MD", + "WB_A3": "MDA", + "WOE_ID": 23424885, + "WOE_ID_EH": 23424885, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MDA", + "ADM0_A3_US": "MDA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [26.619337, 45.488283, 30.024659, 48.467119], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [26.619337, 48.220726], + [26.857824, 48.368211], + [27.522537, 48.467119], + [28.259547, 48.155562], + [28.670891, 48.118149], + [29.122698, 47.849095], + [29.050868, 47.510227], + [29.415135, 47.346645], + [29.559674, 46.928583], + [29.908852, 46.674361], + [29.83821, 46.525326], + [30.024659, 46.423937], + [29.759972, 46.349988], + [29.170654, 46.379262], + [29.072107, 46.517678], + [28.862972, 46.437889], + [28.933717, 46.25883], + [28.659987, 45.939987], + [28.485269, 45.596907], + [28.233554, 45.488283], + [28.054443, 45.944586], + [28.160018, 46.371563], + [28.12803, 46.810476], + [27.551166, 47.405117], + [27.233873, 47.826771], + [26.924176, 48.123264], + [26.619337, 48.220726] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Madagascar", + "SOV_A3": "MDG", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Madagascar", + "ADM0_A3": "MDG", + "GEOU_DIF": 0, + "GEOUNIT": "Madagascar", + "GU_A3": "MDG", + "SU_DIF": 0, + "SUBUNIT": "Madagascar", + "SU_A3": "MDG", + "BRK_DIFF": 0, + "NAME": "Madagascar", + "NAME_LONG": "Madagascar", + "BRK_A3": "MDG", + "BRK_NAME": "Madagascar", + "BRK_GROUP": null, + "ABBREV": "Mad.", + "POSTAL": "MG", + "FORMAL_EN": "Republic of Madagascar", + "FORMAL_FR": null, + "NAME_CIAWF": "Madagascar", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Madagascar", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 5, + "MAPCOLOR9": 2, + "MAPCOLOR13": 3, + "POP_EST": 25054161, + "POP_RANK": 15, + "GDP_MD_EST": 36860, + "POP_YEAR": 2017, + "LASTCENSUS": 1993, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "MA", + "ISO_A2": "MG", + "ISO_A3": "MDG", + "ISO_A3_EH": "MDG", + "ISO_N3": "450", + "UN_A3": "450", + "WB_A2": "MG", + "WB_A3": "MDG", + "WOE_ID": 23424883, + "WOE_ID_EH": 23424883, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MDG", + "ADM0_A3_US": "MDG", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [43.254187, -25.601434, 50.476537, -12.040557], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [49.543519, -12.469833], + [49.808981, -12.895285], + [50.056511, -13.555761], + [50.217431, -14.758789], + [50.476537, -15.226512], + [50.377111, -15.706069], + [50.200275, -16.000263], + [49.860606, -15.414253], + [49.672607, -15.710204], + [49.863344, -16.451037], + [49.774564, -16.875042], + [49.498612, -17.106036], + [49.435619, -17.953064], + [49.041792, -19.118781], + [48.548541, -20.496888], + [47.930749, -22.391501], + [47.547723, -23.781959], + [47.095761, -24.94163], + [46.282478, -25.178463], + [45.409508, -25.601434], + [44.833574, -25.346101], + [44.03972, -24.988345], + [43.763768, -24.460677], + [43.697778, -23.574116], + [43.345654, -22.776904], + [43.254187, -22.057413], + [43.433298, -21.336475], + [43.893683, -21.163307], + [43.89637, -20.830459], + [44.374325, -20.072366], + [44.464397, -19.435454], + [44.232422, -18.961995], + [44.042976, -18.331387], + [43.963084, -17.409945], + [44.312469, -16.850496], + [44.446517, -16.216219], + [44.944937, -16.179374], + [45.502732, -15.974373], + [45.872994, -15.793454], + [46.312243, -15.780018], + [46.882183, -15.210182], + [47.70513, -14.594303], + [48.005215, -14.091233], + [47.869047, -13.663869], + [48.293828, -13.784068], + [48.84506, -13.089175], + [48.863509, -12.487868], + [49.194651, -12.040557], + [49.543519, -12.469833] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Mexico", + "SOV_A3": "MEX", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Mexico", + "ADM0_A3": "MEX", + "GEOU_DIF": 0, + "GEOUNIT": "Mexico", + "GU_A3": "MEX", + "SU_DIF": 0, + "SUBUNIT": "Mexico", + "SU_A3": "MEX", + "BRK_DIFF": 0, + "NAME": "Mexico", + "NAME_LONG": "Mexico", + "BRK_A3": "MEX", + "BRK_NAME": "Mexico", + "BRK_GROUP": null, + "ABBREV": "Mex.", + "POSTAL": "MX", + "FORMAL_EN": "United Mexican States", + "FORMAL_FR": null, + "NAME_CIAWF": "Mexico", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Mexico", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 1, + "MAPCOLOR9": 7, + "MAPCOLOR13": 3, + "POP_EST": 124574795, + "POP_RANK": 17, + "GDP_MD_EST": 2307000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "4. Emerging region: MIKT", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MX", + "ISO_A2": "MX", + "ISO_A3": "MEX", + "ISO_A3_EH": "MEX", + "ISO_N3": "484", + "UN_A3": "484", + "WB_A2": "MX", + "WB_A3": "MEX", + "WOE_ID": 23424900, + "WOE_ID_EH": 23424900, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MEX", + "ADM0_A3_US": "MEX", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 6.7 + }, + "bbox": [-117.12776, 14.538829, -86.811982, 32.72083], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-88.300031, 18.499982], + [-88.490123, 18.486831], + [-88.848344, 17.883198], + [-89.029857, 18.001511], + [-89.150909, 17.955468], + [-89.14308, 17.808319], + [-90.067934, 17.819326], + [-91.00152, 17.817595], + [-91.002269, 17.254658], + [-91.453921, 17.252177], + [-91.08167, 16.918477], + [-90.711822, 16.687483], + [-90.600847, 16.470778], + [-90.438867, 16.41011], + [-90.464473, 16.069562], + [-91.74796, 16.066565], + [-92.229249, 15.251447], + [-92.087216, 15.064585], + [-92.20323, 14.830103], + [-92.22775, 14.538829], + [-93.359464, 15.61543], + [-93.875169, 15.940164], + [-94.691656, 16.200975], + [-95.250227, 16.128318], + [-96.053382, 15.752088], + [-96.557434, 15.653515], + [-97.263592, 15.917065], + [-98.01303, 16.107312], + [-98.947676, 16.566043], + [-99.697397, 16.706164], + [-100.829499, 17.171071], + [-101.666089, 17.649026], + [-101.918528, 17.91609], + [-102.478132, 17.975751], + [-103.50099, 18.292295], + [-103.917527, 18.748572], + [-104.99201, 19.316134], + [-105.493038, 19.946767], + [-105.731396, 20.434102], + [-105.397773, 20.531719], + [-105.500661, 20.816895], + [-105.270752, 21.076285], + [-105.265817, 21.422104], + [-105.603161, 21.871146], + [-105.693414, 22.26908], + [-106.028716, 22.773752], + [-106.90998, 23.767774], + [-107.915449, 24.548915], + [-108.401905, 25.172314], + [-109.260199, 25.580609], + [-109.444089, 25.824884], + [-109.291644, 26.442934], + [-109.801458, 26.676176], + [-110.391732, 27.162115], + [-110.641019, 27.859876], + [-111.178919, 27.941241], + [-111.759607, 28.467953], + [-112.228235, 28.954409], + [-112.271824, 29.266844], + [-112.809594, 30.021114], + [-113.163811, 30.786881], + [-113.148669, 31.170966], + [-113.871881, 31.567608], + [-114.205737, 31.524045], + [-114.776451, 31.799532], + [-114.9367, 31.393485], + [-114.771232, 30.913617], + [-114.673899, 30.162681], + [-114.330974, 29.750432], + [-113.588875, 29.061611], + [-113.424053, 28.826174], + [-113.271969, 28.754783], + [-113.140039, 28.411289], + [-112.962298, 28.42519], + [-112.761587, 27.780217], + [-112.457911, 27.525814], + [-112.244952, 27.171727], + [-111.616489, 26.662817], + [-111.284675, 25.73259], + [-110.987819, 25.294606], + [-110.710007, 24.826004], + [-110.655049, 24.298595], + [-110.172856, 24.265548], + [-109.771847, 23.811183], + [-109.409104, 23.364672], + [-109.433392, 23.185588], + [-109.854219, 22.818272], + [-110.031392, 22.823078], + [-110.295071, 23.430973], + [-110.949501, 24.000964], + [-111.670568, 24.484423], + [-112.182036, 24.738413], + [-112.148989, 25.470125], + [-112.300711, 26.012004], + [-112.777297, 26.32196], + [-113.464671, 26.768186], + [-113.59673, 26.63946], + [-113.848937, 26.900064], + [-114.465747, 27.14209], + [-115.055142, 27.722727], + [-114.982253, 27.7982], + [-114.570366, 27.741485], + [-114.199329, 28.115003], + [-114.162018, 28.566112], + [-114.931842, 29.279479], + [-115.518654, 29.556362], + [-115.887365, 30.180794], + [-116.25835, 30.836464], + [-116.721526, 31.635744], + [-117.12776, 32.53534], + [-115.99135, 32.61239], + [-114.72139, 32.72083], + [-114.815, 32.52528], + [-113.30498, 32.03914], + [-111.02361, 31.33472], + [-109.035, 31.34194], + [-108.24194, 31.34222], + [-108.24, 31.754854], + [-106.50759, 31.75452], + [-106.1429, 31.39995], + [-105.63159, 31.08383], + [-105.03737, 30.64402], + [-104.70575, 30.12173], + [-104.45697, 29.57196], + [-103.94, 29.27], + [-103.11, 28.97], + [-102.48, 29.76], + [-101.6624, 29.7793], + [-100.9576, 29.38071], + [-100.45584, 28.69612], + [-100.11, 28.11], + [-99.52, 27.54], + [-99.3, 26.84], + [-99.02, 26.37], + [-98.24, 26.06], + [-97.53, 25.84], + [-97.140008, 25.869997], + [-97.528072, 24.992144], + [-97.702946, 24.272343], + [-97.776042, 22.93258], + [-97.872367, 22.444212], + [-97.699044, 21.898689], + [-97.38896, 21.411019], + [-97.189333, 20.635433], + [-96.525576, 19.890931], + [-96.292127, 19.320371], + [-95.900885, 18.828024], + [-94.839063, 18.562717], + [-94.42573, 18.144371], + [-93.548651, 18.423837], + [-92.786114, 18.524839], + [-92.037348, 18.704569], + [-91.407903, 18.876083], + [-90.77187, 19.28412], + [-90.53359, 19.867418], + [-90.451476, 20.707522], + [-90.278618, 20.999855], + [-89.601321, 21.261726], + [-88.543866, 21.493675], + [-87.658417, 21.458846], + [-87.05189, 21.543543], + [-86.811982, 21.331515], + [-86.845908, 20.849865], + [-87.383291, 20.255405], + [-87.621054, 19.646553], + [-87.43675, 19.472403], + [-87.58656, 19.04013], + [-87.837191, 18.259816], + [-88.090664, 18.516648], + [-88.300031, 18.499982] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Macedonia", + "SOV_A3": "MKD", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Macedonia", + "ADM0_A3": "MKD", + "GEOU_DIF": 0, + "GEOUNIT": "Macedonia", + "GU_A3": "MKD", + "SU_DIF": 0, + "SUBUNIT": "Macedonia", + "SU_A3": "MKD", + "BRK_DIFF": 0, + "NAME": "Macedonia", + "NAME_LONG": "Macedonia", + "BRK_A3": "MKD", + "BRK_NAME": "Macedonia", + "BRK_GROUP": null, + "ABBREV": "Mkd.", + "POSTAL": "MK", + "FORMAL_EN": "Former Yugoslav Republic of Macedonia", + "FORMAL_FR": null, + "NAME_CIAWF": "Macedonia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Macedonia, FYR", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 3, + "MAPCOLOR9": 7, + "MAPCOLOR13": 3, + "POP_EST": 2103721, + "POP_RANK": 12, + "GDP_MD_EST": 29520, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MK", + "ISO_A2": "MK", + "ISO_A3": "MKD", + "ISO_A3_EH": "MKD", + "ISO_N3": "807", + "UN_A3": "807", + "WB_A2": "MK", + "WB_A3": "MKD", + "WOE_ID": 23424890, + "WOE_ID_EH": 23424890, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MKD", + "ADM0_A3_US": "MKD", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [20.463175, 40.842727, 22.952377, 42.32026], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [21.02004, 40.842727], + [20.605182, 41.086226], + [20.463175, 41.515089], + [20.590247, 41.855404], + [20.590247, 41.855409], + [20.71731, 41.84711], + [20.76216, 42.05186], + [21.3527, 42.2068], + [21.576636, 42.245224], + [21.91708, 42.30364], + [22.380526, 42.32026], + [22.881374, 41.999297], + [22.952377, 41.337994], + [22.76177, 41.3048], + [22.597308, 41.130487], + [22.055378, 41.149866], + [21.674161, 40.931275], + [21.02004, 40.842727] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Mali", + "SOV_A3": "MLI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Mali", + "ADM0_A3": "MLI", + "GEOU_DIF": 0, + "GEOUNIT": "Mali", + "GU_A3": "MLI", + "SU_DIF": 0, + "SUBUNIT": "Mali", + "SU_A3": "MLI", + "BRK_DIFF": 0, + "NAME": "Mali", + "NAME_LONG": "Mali", + "BRK_A3": "MLI", + "BRK_NAME": "Mali", + "BRK_GROUP": null, + "ABBREV": "Mali", + "POSTAL": "ML", + "FORMAL_EN": "Republic of Mali", + "FORMAL_FR": null, + "NAME_CIAWF": "Mali", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Mali", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 7, + "POP_EST": 17885245, + "POP_RANK": 14, + "GDP_MD_EST": 38090, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "ML", + "ISO_A2": "ML", + "ISO_A3": "MLI", + "ISO_A3_EH": "MLI", + "ISO_N3": "466", + "UN_A3": "466", + "WB_A2": "ML", + "WB_A3": "MLI", + "WOE_ID": 23424891, + "WOE_ID_EH": 23424891, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MLI", + "ADM0_A3_US": "MLI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [-12.17075, 10.096361, 4.27021, 24.974574], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [0.374892, 14.928908], + [-0.266257, 14.924309], + [-0.515854, 15.116158], + [-1.066363, 14.973815], + [-2.001035, 14.559008], + [-2.191825, 14.246418], + [-2.967694, 13.79815], + [-3.103707, 13.541267], + [-3.522803, 13.337662], + [-4.006391, 13.472485], + [-4.280405, 13.228444], + [-4.427166, 12.542646], + [-5.220942, 11.713859], + [-5.197843, 11.375146], + [-5.470565, 10.95127], + [-5.404342, 10.370737], + [-5.816926, 10.222555], + [-6.050452, 10.096361], + [-6.205223, 10.524061], + [-6.493965, 10.411303], + [-6.666461, 10.430811], + [-6.850507, 10.138994], + [-7.622759, 10.147236], + [-7.89959, 10.297382], + [-8.029944, 10.206535], + [-8.335377, 10.494812], + [-8.282357, 10.792597], + [-8.407311, 10.909257], + [-8.620321, 10.810891], + [-8.581305, 11.136246], + [-8.376305, 11.393646], + [-8.786099, 11.812561], + [-8.905265, 12.088358], + [-9.127474, 12.30806], + [-9.327616, 12.334286], + [-9.567912, 12.194243], + [-9.890993, 12.060479], + [-10.165214, 11.844084], + [-10.593224, 11.923975], + [-10.87083, 12.177887], + [-11.036556, 12.211245], + [-11.297574, 12.077971], + [-11.456169, 12.076834], + [-11.513943, 12.442988], + [-11.467899, 12.754519], + [-11.553398, 13.141214], + [-11.927716, 13.422075], + [-12.124887, 13.994727], + [-12.17075, 14.616834], + [-11.834208, 14.799097], + [-11.666078, 15.388208], + [-11.349095, 15.411256], + [-10.650791, 15.132746], + [-10.086846, 15.330486], + [-9.700255, 15.264107], + [-9.550238, 15.486497], + [-5.537744, 15.50169], + [-5.315277, 16.201854], + [-5.488523, 16.325102], + [-5.971129, 20.640833], + [-6.453787, 24.956591], + [-4.923337, 24.974574], + [-1.550055, 22.792666], + [1.823228, 20.610809], + [2.060991, 20.142233], + [2.683588, 19.85623], + [3.146661, 19.693579], + [3.158133, 19.057364], + [4.267419, 19.155265], + [4.27021, 16.852227], + [3.723422, 16.184284], + [3.638259, 15.56812], + [2.749993, 15.409525], + [1.385528, 15.323561], + [1.015783, 14.968182], + [0.374892, 14.928908] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Myanmar", + "SOV_A3": "MMR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Myanmar", + "ADM0_A3": "MMR", + "GEOU_DIF": 0, + "GEOUNIT": "Myanmar", + "GU_A3": "MMR", + "SU_DIF": 0, + "SUBUNIT": "Myanmar", + "SU_A3": "MMR", + "BRK_DIFF": 0, + "NAME": "Myanmar", + "NAME_LONG": "Myanmar", + "BRK_A3": "MMR", + "BRK_NAME": "Myanmar", + "BRK_GROUP": null, + "ABBREV": "Myan.", + "POSTAL": "MM", + "FORMAL_EN": "Republic of the Union of Myanmar", + "FORMAL_FR": null, + "NAME_CIAWF": "Burma", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Myanmar", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 13, + "POP_EST": 55123814, + "POP_RANK": 16, + "GDP_MD_EST": 311100, + "POP_YEAR": 2017, + "LASTCENSUS": 1983, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "BM", + "ISO_A2": "MM", + "ISO_A3": "MMR", + "ISO_A3_EH": "MMR", + "ISO_N3": "104", + "UN_A3": "104", + "WB_A2": "MM", + "WB_A3": "MMR", + "WOE_ID": 23424763, + "WOE_ID_EH": 23424763, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MMR", + "ADM0_A3_US": "MMR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [92.303234, 9.93296, 101.180005, 28.335945], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [92.368554, 20.670883], + [92.303234, 21.475485], + [92.652257, 21.324048], + [92.672721, 22.041239], + [93.166128, 22.27846], + [93.060294, 22.703111], + [93.286327, 23.043658], + [93.325188, 24.078556], + [94.106742, 23.850741], + [94.552658, 24.675238], + [94.603249, 25.162495], + [95.155153, 26.001307], + [95.124768, 26.573572], + [96.419366, 27.264589], + [97.133999, 27.083774], + [97.051989, 27.699059], + [97.402561, 27.882536], + [97.327114, 28.261583], + [97.911988, 28.335945], + [98.246231, 27.747221], + [98.68269, 27.508812], + [98.712094, 26.743536], + [98.671838, 25.918703], + [97.724609, 25.083637], + [97.60472, 23.897405], + [98.660262, 24.063286], + [98.898749, 23.142722], + [99.531992, 22.949039], + [99.240899, 22.118314], + [99.983489, 21.742937], + [100.416538, 21.558839], + [101.150033, 21.849984], + [101.180005, 21.436573], + [100.329101, 20.786122], + [100.115988, 20.41785], + [99.543309, 20.186598], + [98.959676, 19.752981], + [98.253724, 19.708203], + [97.797783, 18.62708], + [97.375896, 18.445438], + [97.859123, 17.567946], + [98.493761, 16.837836], + [98.903348, 16.177824], + [98.537376, 15.308497], + [98.192074, 15.123703], + [98.430819, 14.622028], + [99.097755, 13.827503], + [99.212012, 13.269294], + [99.196354, 12.804748], + [99.587286, 11.892763], + [99.038121, 10.960546], + [98.553551, 9.93296], + [98.457174, 10.675266], + [98.764546, 11.441292], + [98.428339, 12.032987], + [98.509574, 13.122378], + [98.103604, 13.64046], + [97.777732, 14.837286], + [97.597072, 16.100568], + [97.16454, 16.928734], + [96.505769, 16.427241], + [95.369352, 15.71439], + [94.808405, 15.803454], + [94.188804, 16.037936], + [94.533486, 17.27724], + [94.324817, 18.213514], + [93.540988, 19.366493], + [93.663255, 19.726962], + [93.078278, 19.855145], + [92.368554, 20.670883] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Montenegro", + "SOV_A3": "MNE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Montenegro", + "ADM0_A3": "MNE", + "GEOU_DIF": 0, + "GEOUNIT": "Montenegro", + "GU_A3": "MNE", + "SU_DIF": 0, + "SUBUNIT": "Montenegro", + "SU_A3": "MNE", + "BRK_DIFF": 0, + "NAME": "Montenegro", + "NAME_LONG": "Montenegro", + "BRK_A3": "MNE", + "BRK_NAME": "Montenegro", + "BRK_GROUP": null, + "ABBREV": "Mont.", + "POSTAL": "ME", + "FORMAL_EN": "Montenegro", + "FORMAL_FR": null, + "NAME_CIAWF": "Montenegro", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Montenegro", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 4, + "MAPCOLOR13": 5, + "POP_EST": 642550, + "POP_RANK": 11, + "GDP_MD_EST": 10610, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MJ", + "ISO_A2": "ME", + "ISO_A3": "MNE", + "ISO_A3_EH": "MNE", + "ISO_N3": "499", + "UN_A3": "499", + "WB_A2": "ME", + "WB_A3": "MNE", + "WOE_ID": 20069817, + "WOE_ID_EH": 20069817, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MNE", + "ADM0_A3_US": "MNE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [18.450017, 41.877551, 20.3398, 43.52384], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [20.0707, 42.58863], + [19.801613, 42.500093], + [19.738051, 42.688247], + [19.304486, 42.195745], + [19.371768, 41.877551], + [19.16246, 41.95502], + [18.88214, 42.28151], + [18.450017, 42.479992], + [18.56, 42.65], + [18.70648, 43.20011], + [19.03165, 43.43253], + [19.21852, 43.52384], + [19.48389, 43.35229], + [19.63, 43.21378], + [19.95857, 43.10604], + [20.3398, 42.89852], + [20.25758, 42.81275], + [20.0707, 42.58863] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Mongolia", + "SOV_A3": "MNG", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Mongolia", + "ADM0_A3": "MNG", + "GEOU_DIF": 0, + "GEOUNIT": "Mongolia", + "GU_A3": "MNG", + "SU_DIF": 0, + "SUBUNIT": "Mongolia", + "SU_A3": "MNG", + "BRK_DIFF": 0, + "NAME": "Mongolia", + "NAME_LONG": "Mongolia", + "BRK_A3": "MNG", + "BRK_NAME": "Mongolia", + "BRK_GROUP": null, + "ABBREV": "Mong.", + "POSTAL": "MN", + "FORMAL_EN": "Mongolia", + "FORMAL_FR": null, + "NAME_CIAWF": "Mongolia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Mongolia", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 5, + "MAPCOLOR9": 5, + "MAPCOLOR13": 6, + "POP_EST": 3068243, + "POP_RANK": 12, + "GDP_MD_EST": 37000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MG", + "ISO_A2": "MN", + "ISO_A3": "MNG", + "ISO_A3_EH": "MNG", + "ISO_N3": "496", + "UN_A3": "496", + "WB_A2": "MN", + "WB_A3": "MNG", + "WOE_ID": 23424887, + "WOE_ID_EH": 23424887, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MNG", + "ADM0_A3_US": "MNG", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [87.751264, 41.59741, 119.772824, 52.047366], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [116.678801, 49.888531], + [116.191802, 49.134598], + [115.485282, 48.135383], + [115.742837, 47.726545], + [116.308953, 47.85341], + [117.295507, 47.697709], + [118.064143, 48.06673], + [118.866574, 47.74706], + [119.772824, 47.048059], + [119.66327, 46.69268], + [118.874326, 46.805412], + [117.421701, 46.672733], + [116.717868, 46.388202], + [115.985096, 45.727235], + [114.460332, 45.339817], + [113.463907, 44.808893], + [112.436062, 45.011646], + [111.873306, 45.102079], + [111.348377, 44.457442], + [111.667737, 44.073176], + [111.829588, 43.743118], + [111.129682, 43.406834], + [110.412103, 42.871234], + [109.243596, 42.519446], + [107.744773, 42.481516], + [106.129316, 42.134328], + [104.964994, 41.59741], + [104.522282, 41.908347], + [103.312278, 41.907468], + [101.83304, 42.514873], + [100.845866, 42.663804], + [99.515817, 42.524691], + [97.451757, 42.74889], + [96.349396, 42.725635], + [95.762455, 43.319449], + [95.306875, 44.241331], + [94.688929, 44.352332], + [93.480734, 44.975472], + [92.133891, 45.115076], + [90.94554, 45.286073], + [90.585768, 45.719716], + [90.970809, 46.888146], + [90.280826, 47.693549], + [88.854298, 48.069082], + [88.013832, 48.599463], + [87.751264, 49.297198], + [88.805567, 49.470521], + [90.713667, 50.331812], + [92.234712, 50.802171], + [93.10421, 50.49529], + [94.147566, 50.480537], + [94.815949, 50.013433], + [95.81402, 49.97746], + [97.25976, 49.72605], + [98.231762, 50.422401], + [97.82574, 51.010995], + [98.861491, 52.047366], + [99.981732, 51.634006], + [100.88948, 51.516856], + [102.06521, 51.25991], + [102.25589, 50.51056], + [103.676545, 50.089966], + [104.62158, 50.27532], + [105.886591, 50.406019], + [106.888804, 50.274296], + [107.868176, 49.793705], + [108.475167, 49.282548], + [109.402449, 49.292961], + [110.662011, 49.130128], + [111.581231, 49.377968], + [112.89774, 49.543565], + [114.362456, 50.248303], + [114.96211, 50.140247], + [115.485695, 49.805177], + [116.678801, 49.888531] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Mozambique", + "SOV_A3": "MOZ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Mozambique", + "ADM0_A3": "MOZ", + "GEOU_DIF": 0, + "GEOUNIT": "Mozambique", + "GU_A3": "MOZ", + "SU_DIF": 0, + "SUBUNIT": "Mozambique", + "SU_A3": "MOZ", + "BRK_DIFF": 0, + "NAME": "Mozambique", + "NAME_LONG": "Mozambique", + "BRK_A3": "MOZ", + "BRK_NAME": "Mozambique", + "BRK_GROUP": null, + "ABBREV": "Moz.", + "POSTAL": "MZ", + "FORMAL_EN": "Republic of Mozambique", + "FORMAL_FR": null, + "NAME_CIAWF": "Mozambique", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Mozambique", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 2, + "MAPCOLOR9": 1, + "MAPCOLOR13": 4, + "POP_EST": 26573706, + "POP_RANK": 15, + "GDP_MD_EST": 35010, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "MZ", + "ISO_A2": "MZ", + "ISO_A3": "MOZ", + "ISO_A3_EH": "MOZ", + "ISO_N3": "508", + "UN_A3": "508", + "WB_A2": "MZ", + "WB_A3": "MOZ", + "WOE_ID": 23424902, + "WOE_ID_EH": 23424902, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MOZ", + "ADM0_A3_US": "MOZ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [30.179481, -26.742192, 40.775475, -10.317096], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [34.559989, -11.52002], + [35.312398, -11.439146], + [36.514082, -11.720938], + [36.775151, -11.594537], + [37.47129, -11.56876], + [37.82764, -11.26879], + [38.427557, -11.285202], + [39.521, -10.89688], + [40.31659, -10.3171], + [40.316586, -10.317098], + [40.316589, -10.317096], + [40.478387, -10.765441], + [40.437253, -11.761711], + [40.560811, -12.639177], + [40.59962, -14.201975], + [40.775475, -14.691764], + [40.477251, -15.406294], + [40.089264, -16.100774], + [39.452559, -16.720891], + [38.538351, -17.101023], + [37.411133, -17.586368], + [36.281279, -18.659688], + [35.896497, -18.84226], + [35.1984, -19.552811], + [34.786383, -19.784012], + [34.701893, -20.497043], + [35.176127, -21.254361], + [35.373428, -21.840837], + [35.385848, -22.14], + [35.562546, -22.09], + [35.533935, -23.070788], + [35.371774, -23.535359], + [35.60747, -23.706563], + [35.458746, -24.12261], + [35.040735, -24.478351], + [34.215824, -24.816314], + [33.01321, -25.357573], + [32.574632, -25.727318], + [32.660363, -26.148584], + [32.915955, -26.215867], + [32.83012, -26.742192], + [32.071665, -26.73382], + [31.985779, -26.29178], + [31.837778, -25.843332], + [31.752408, -25.484284], + [31.930589, -24.369417], + [31.670398, -23.658969], + [31.191409, -22.25151], + [32.244988, -21.116489], + [32.508693, -20.395292], + [32.659743, -20.30429], + [32.772708, -19.715592], + [32.611994, -19.419383], + [32.654886, -18.67209], + [32.849861, -17.979057], + [32.847639, -16.713398], + [32.328239, -16.392074], + [31.852041, -16.319417], + [31.636498, -16.07199], + [31.173064, -15.860944], + [30.338955, -15.880839], + [30.274256, -15.507787], + [30.179481, -14.796099], + [33.214025, -13.97186], + [33.7897, -14.451831], + [34.064825, -14.35995], + [34.459633, -14.61301], + [34.517666, -15.013709], + [34.307291, -15.478641], + [34.381292, -16.18356], + [35.03381, -16.8013], + [35.339063, -16.10744], + [35.771905, -15.896859], + [35.686845, -14.611046], + [35.267956, -13.887834], + [34.907151, -13.565425], + [34.559989, -13.579998], + [34.280006, -12.280025], + [34.559989, -11.52002] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Mauritania", + "SOV_A3": "MRT", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Mauritania", + "ADM0_A3": "MRT", + "GEOU_DIF": 0, + "GEOUNIT": "Mauritania", + "GU_A3": "MRT", + "SU_DIF": 0, + "SUBUNIT": "Mauritania", + "SU_A3": "MRT", + "BRK_DIFF": 0, + "NAME": "Mauritania", + "NAME_LONG": "Mauritania", + "BRK_A3": "MRT", + "BRK_NAME": "Mauritania", + "BRK_GROUP": null, + "ABBREV": "Mrt.", + "POSTAL": "MR", + "FORMAL_EN": "Islamic Republic of Mauritania", + "FORMAL_FR": null, + "NAME_CIAWF": "Mauritania", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Mauritania", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 3, + "MAPCOLOR9": 2, + "MAPCOLOR13": 1, + "POP_EST": 3758571, + "POP_RANK": 12, + "GDP_MD_EST": 16710, + "POP_YEAR": 2017, + "LASTCENSUS": 2000, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "MR", + "ISO_A2": "MR", + "ISO_A3": "MRT", + "ISO_A3_EH": "MRT", + "ISO_N3": "478", + "UN_A3": "478", + "WB_A2": "MR", + "WB_A3": "MRT", + "WOE_ID": 23424896, + "WOE_ID_EH": 23424896, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MRT", + "ADM0_A3_US": "MRT", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-17.063423, 14.616834, -4.923337, 27.395744], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-8.6844, 27.395744], + [-4.923337, 24.974574], + [-6.453787, 24.956591], + [-5.971129, 20.640833], + [-5.488523, 16.325102], + [-5.315277, 16.201854], + [-5.537744, 15.50169], + [-9.550238, 15.486497], + [-9.700255, 15.264107], + [-10.086846, 15.330486], + [-10.650791, 15.132746], + [-11.349095, 15.411256], + [-11.666078, 15.388208], + [-11.834208, 14.799097], + [-12.17075, 14.616834], + [-12.830658, 15.303692], + [-13.435738, 16.039383], + [-14.099521, 16.304302], + [-14.577348, 16.598264], + [-15.135737, 16.587282], + [-15.623666, 16.369337], + [-16.12069, 16.455663], + [-16.463098, 16.135036], + [-16.549708, 16.673892], + [-16.270552, 17.166963], + [-16.146347, 18.108482], + [-16.256883, 19.096716], + [-16.377651, 19.593817], + [-16.277838, 20.092521], + [-16.536324, 20.567866], + [-17.063423, 20.999752], + [-16.845194, 21.333323], + [-12.929102, 21.327071], + [-13.118754, 22.77122], + [-12.874222, 23.284832], + [-11.937224, 23.374594], + [-11.969419, 25.933353], + [-8.687294, 25.881056], + [-8.6844, 27.395744] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Malawi", + "SOV_A3": "MWI", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Malawi", + "ADM0_A3": "MWI", + "GEOU_DIF": 0, + "GEOUNIT": "Malawi", + "GU_A3": "MWI", + "SU_DIF": 0, + "SUBUNIT": "Malawi", + "SU_A3": "MWI", + "BRK_DIFF": 0, + "NAME": "Malawi", + "NAME_LONG": "Malawi", + "BRK_A3": "MWI", + "BRK_NAME": "Malawi", + "BRK_GROUP": null, + "ABBREV": "Mal.", + "POSTAL": "MW", + "FORMAL_EN": "Republic of Malawi", + "FORMAL_FR": null, + "NAME_CIAWF": "Malawi", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Malawi", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 3, + "MAPCOLOR9": 4, + "MAPCOLOR13": 5, + "POP_EST": 19196246, + "POP_RANK": 14, + "GDP_MD_EST": 21200, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "MI", + "ISO_A2": "MW", + "ISO_A3": "MWI", + "ISO_A3_EH": "MWI", + "ISO_N3": "454", + "UN_A3": "454", + "WB_A2": "MW", + "WB_A3": "MWI", + "WOE_ID": 23424889, + "WOE_ID_EH": 23424889, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MWI", + "ADM0_A3_US": "MWI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [32.688165, -16.8013, 35.771905, -9.230599], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [34.559989, -11.52002], + [34.280006, -12.280025], + [34.559989, -13.579998], + [34.907151, -13.565425], + [35.267956, -13.887834], + [35.686845, -14.611046], + [35.771905, -15.896859], + [35.339063, -16.10744], + [35.03381, -16.8013], + [34.381292, -16.18356], + [34.307291, -15.478641], + [34.517666, -15.013709], + [34.459633, -14.61301], + [34.064825, -14.35995], + [33.7897, -14.451831], + [33.214025, -13.97186], + [32.688165, -13.712858], + [32.991764, -12.783871], + [33.306422, -12.435778], + [33.114289, -11.607198], + [33.31531, -10.79655], + [33.485688, -10.525559], + [33.231388, -9.676722], + [32.759375, -9.230599], + [33.73972, -9.41715], + [33.940838, -9.693674], + [34.28, -10.16], + [34.559989, -11.52002] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Malaysia", + "SOV_A3": "MYS", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Malaysia", + "ADM0_A3": "MYS", + "GEOU_DIF": 0, + "GEOUNIT": "Malaysia", + "GU_A3": "MYS", + "SU_DIF": 0, + "SUBUNIT": "Malaysia", + "SU_A3": "MYS", + "BRK_DIFF": 0, + "NAME": "Malaysia", + "NAME_LONG": "Malaysia", + "BRK_A3": "MYS", + "BRK_NAME": "Malaysia", + "BRK_GROUP": null, + "ABBREV": "Malay.", + "POSTAL": "MY", + "FORMAL_EN": "Malaysia", + "FORMAL_FR": null, + "NAME_CIAWF": "Malaysia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Malaysia", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 4, + "MAPCOLOR9": 3, + "MAPCOLOR13": 6, + "POP_EST": 31381992, + "POP_RANK": 15, + "GDP_MD_EST": 863000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "MY", + "ISO_A2": "MY", + "ISO_A3": "MYS", + "ISO_A3_EH": "MYS", + "ISO_N3": "458", + "UN_A3": "458", + "WB_A2": "MY", + "WB_A3": "MYS", + "WOE_ID": 23424901, + "WOE_ID_EH": 23424901, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MYS", + "ADM0_A3_US": "MYS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [100.085757, 0.773131, 119.181904, 6.928053], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [114.204017, 4.525874], + [114.659596, 4.007637], + [114.869557, 4.348314], + [115.347461, 4.316636], + [115.4057, 4.955228], + [115.45071, 5.44773], + [116.220741, 6.143191], + [116.725103, 6.924771], + [117.129626, 6.928053], + [117.643393, 6.422166], + [117.689075, 5.98749], + [118.347691, 5.708696], + [119.181904, 5.407836], + [119.110694, 5.016128], + [118.439727, 4.966519], + [118.618321, 4.478202], + [117.882035, 4.137551], + [117.015214, 4.306094], + [115.865517, 4.306559], + [115.519078, 3.169238], + [115.134037, 2.821482], + [114.621355, 1.430688], + [113.80585, 1.217549], + [112.859809, 1.49779], + [112.380252, 1.410121], + [111.797548, 0.904441], + [111.159138, 0.976478], + [110.514061, 0.773131], + [109.830227, 1.338136], + [109.66326, 2.006467], + [110.396135, 1.663775], + [111.168853, 1.850637], + [111.370081, 2.697303], + [111.796928, 2.885897], + [112.995615, 3.102395], + [113.712935, 3.893509], + [114.204017, 4.525874] + ] + ], + [ + [ + [102.141187, 6.221636], + [102.371147, 6.128205], + [102.961705, 5.524495], + [103.381215, 4.855001], + [103.438575, 4.181606], + [103.332122, 3.726698], + [103.429429, 3.382869], + [103.502448, 2.791019], + [103.854674, 2.515454], + [104.247932, 1.631141], + [104.228811, 1.293048], + [103.519707, 1.226334], + [102.573615, 1.967115], + [101.390638, 2.760814], + [101.27354, 3.270292], + [100.695435, 3.93914], + [100.557408, 4.76728], + [100.196706, 5.312493], + [100.30626, 6.040562], + [100.085757, 6.464489], + [100.259596, 6.642825], + [101.075516, 6.204867], + [101.154219, 5.691384], + [101.814282, 5.810808], + [102.141187, 6.221636] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Namibia", + "SOV_A3": "NAM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Namibia", + "ADM0_A3": "NAM", + "GEOU_DIF": 0, + "GEOUNIT": "Namibia", + "GU_A3": "NAM", + "SU_DIF": 0, + "SUBUNIT": "Namibia", + "SU_A3": "NAM", + "BRK_DIFF": 0, + "NAME": "Namibia", + "NAME_LONG": "Namibia", + "BRK_A3": "NAM", + "BRK_NAME": "Namibia", + "BRK_GROUP": null, + "ABBREV": "Nam.", + "POSTAL": "NA", + "FORMAL_EN": "Republic of Namibia", + "FORMAL_FR": null, + "NAME_CIAWF": "Namibia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Namibia", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 1, + "MAPCOLOR9": 1, + "MAPCOLOR13": 7, + "POP_EST": 2484780, + "POP_RANK": 12, + "GDP_MD_EST": 25990, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "WA", + "ISO_A2": "NA", + "ISO_A3": "NAM", + "ISO_A3_EH": "NAM", + "ISO_N3": "516", + "UN_A3": "516", + "WB_A2": "NA", + "WB_A3": "NAM", + "WOE_ID": 23424987, + "WOE_ID_EH": 23424987, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NAM", + "ADM0_A3_US": "NAM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Southern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7.5 + }, + "bbox": [11.734199, -29.045462, 25.084443, -16.941343], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.734199, -17.301889], + [12.215461, -17.111668], + [12.814081, -16.941343], + [13.462362, -16.971212], + [14.058501, -17.423381], + [14.209707, -17.353101], + [18.263309, -17.309951], + [18.956187, -17.789095], + [21.377176, -17.930636], + [23.215048, -17.523116], + [24.033862, -17.295843], + [24.682349, -17.353411], + [25.07695, -17.578823], + [25.084443, -17.661816], + [24.520705, -17.887125], + [24.217365, -17.889347], + [23.579006, -18.281261], + [23.196858, -17.869038], + [21.65504, -18.219146], + [20.910641, -18.252219], + [20.881134, -21.814327], + [19.895458, -21.849157], + [19.895768, -24.76779], + [19.894734, -28.461105], + [19.002127, -28.972443], + [18.464899, -29.045462], + [17.836152, -28.856378], + [17.387497, -28.783514], + [17.218929, -28.355943], + [16.824017, -28.082162], + [16.344977, -28.576705], + [15.601818, -27.821247], + [15.210472, -27.090956], + [14.989711, -26.117372], + [14.743214, -25.39292], + [14.408144, -23.853014], + [14.385717, -22.656653], + [14.257714, -22.111208], + [13.868642, -21.699037], + [13.352498, -20.872834], + [12.826845, -19.673166], + [12.608564, -19.045349], + [11.794919, -18.069129], + [11.734199, -17.301889] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "France", + "SOV_A3": "FR1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Dependency", + "ADMIN": "New Caledonia", + "ADM0_A3": "NCL", + "GEOU_DIF": 0, + "GEOUNIT": "New Caledonia", + "GU_A3": "NCL", + "SU_DIF": 0, + "SUBUNIT": "New Caledonia", + "SU_A3": "NCL", + "BRK_DIFF": 0, + "NAME": "New Caledonia", + "NAME_LONG": "New Caledonia", + "BRK_A3": "NCL", + "BRK_NAME": "New Caledonia", + "BRK_GROUP": null, + "ABBREV": "New C.", + "POSTAL": "NC", + "FORMAL_EN": "New Caledonia", + "FORMAL_FR": "Nouvelle-Calédonie", + "NAME_CIAWF": "New Caledonia", + "NOTE_ADM0": "Fr.", + "NOTE_BRK": null, + "NAME_SORT": "New Caledonia", + "NAME_ALT": null, + "MAPCOLOR7": 7, + "MAPCOLOR8": 5, + "MAPCOLOR9": 9, + "MAPCOLOR13": 11, + "POP_EST": 279070, + "POP_RANK": 10, + "GDP_MD_EST": 10770, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "NC", + "ISO_A2": "NC", + "ISO_A3": "NCL", + "ISO_A3_EH": "NCL", + "ISO_N3": "540", + "UN_A3": "540", + "WB_A2": "NC", + "WB_A3": "NCL", + "WOE_ID": 23424903, + "WOE_ID_EH": 23424903, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NCL", + "ADM0_A3_US": "NCL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Melanesia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 13, + "LONG_LEN": 13, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": -99, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.6, + "MAX_LABEL": 8 + }, + "bbox": [164.029606, -22.399976, 167.120011, -20.105646], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [165.77999, -21.080005], + [166.599991, -21.700019], + [167.120011, -22.159991], + [166.740035, -22.399976], + [166.189732, -22.129708], + [165.474375, -21.679607], + [164.829815, -21.14982], + [164.167995, -20.444747], + [164.029606, -20.105646], + [164.459967, -20.120012], + [165.020036, -20.459991], + [165.460009, -20.800022], + [165.77999, -21.080005] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Niger", + "SOV_A3": "NER", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Niger", + "ADM0_A3": "NER", + "GEOU_DIF": 0, + "GEOUNIT": "Niger", + "GU_A3": "NER", + "SU_DIF": 0, + "SUBUNIT": "Niger", + "SU_A3": "NER", + "BRK_DIFF": 0, + "NAME": "Niger", + "NAME_LONG": "Niger", + "BRK_A3": "NER", + "BRK_NAME": "Niger", + "BRK_GROUP": null, + "ABBREV": "Niger", + "POSTAL": "NE", + "FORMAL_EN": "Republic of Niger", + "FORMAL_FR": null, + "NAME_CIAWF": "Niger", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Niger", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 5, + "MAPCOLOR9": 3, + "MAPCOLOR13": 13, + "POP_EST": 19245344, + "POP_RANK": 14, + "GDP_MD_EST": 20150, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "NG", + "ISO_A2": "NE", + "ISO_A3": "NER", + "ISO_A3_EH": "NER", + "ISO_N3": "562", + "UN_A3": "562", + "WB_A2": "NE", + "WB_A3": "NER", + "WOE_ID": 23424906, + "WOE_ID_EH": 23424906, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NER", + "ADM0_A3_US": "NER", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [0.295646, 11.660167, 15.903247, 23.471668], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [3.61118, 11.660167], + [2.848643, 12.235636], + [2.490164, 12.233052], + [2.154474, 11.94015], + [2.177108, 12.625018], + [1.024103, 12.851826], + [0.993046, 13.33575], + [0.429928, 13.988733], + [0.295646, 14.444235], + [0.374892, 14.928908], + [1.015783, 14.968182], + [1.385528, 15.323561], + [2.749993, 15.409525], + [3.638259, 15.56812], + [3.723422, 16.184284], + [4.27021, 16.852227], + [4.267419, 19.155265], + [5.677566, 19.601207], + [8.572893, 21.565661], + [11.999506, 23.471668], + [13.581425, 23.040506], + [14.143871, 22.491289], + [14.8513, 22.86295], + [15.096888, 21.308519], + [15.47106, 21.04845], + [15.487148, 20.730415], + [15.903247, 20.387619], + [15.685741, 19.95718], + [15.300441, 17.92795], + [15.247731, 16.627306], + [13.97217, 15.68437], + [13.540394, 14.367134], + [13.956699, 13.996691], + [13.954477, 13.353449], + [14.595781, 13.330427], + [14.495787, 12.859396], + [14.213531, 12.802035], + [14.181336, 12.483657], + [13.995353, 12.461565], + [13.318702, 13.556356], + [13.083987, 13.596147], + [12.302071, 13.037189], + [11.527803, 13.32898], + [10.989593, 13.387323], + [10.701032, 13.246918], + [10.114814, 13.277252], + [9.524928, 12.851102], + [9.014933, 12.826659], + [7.804671, 13.343527], + [7.330747, 13.098038], + [6.820442, 13.115091], + [6.445426, 13.492768], + [5.443058, 13.865924], + [4.368344, 13.747482], + [4.107946, 13.531216], + [3.967283, 12.956109], + [3.680634, 12.552903], + [3.61118, 11.660167] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Nigeria", + "SOV_A3": "NGA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Nigeria", + "ADM0_A3": "NGA", + "GEOU_DIF": 0, + "GEOUNIT": "Nigeria", + "GU_A3": "NGA", + "SU_DIF": 0, + "SUBUNIT": "Nigeria", + "SU_A3": "NGA", + "BRK_DIFF": 0, + "NAME": "Nigeria", + "NAME_LONG": "Nigeria", + "BRK_A3": "NGA", + "BRK_NAME": "Nigeria", + "BRK_GROUP": null, + "ABBREV": "Nigeria", + "POSTAL": "NG", + "FORMAL_EN": "Federal Republic of Nigeria", + "FORMAL_FR": null, + "NAME_CIAWF": "Nigeria", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Nigeria", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 2, + "POP_EST": 190632261, + "POP_RANK": 17, + "GDP_MD_EST": 1089000, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "NI", + "ISO_A2": "NG", + "ISO_A3": "NGA", + "ISO_A3_EH": "NGA", + "ISO_N3": "566", + "UN_A3": "566", + "WB_A2": "NG", + "WB_A3": "NGA", + "WOE_ID": 23424908, + "WOE_ID_EH": 23424908, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NGA", + "ADM0_A3_US": "NGA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 7, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [2.691702, 4.240594, 14.577178, 13.865924], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [2.691702, 6.258817], + [2.749063, 7.870734], + [2.723793, 8.506845], + [2.912308, 9.137608], + [3.220352, 9.444153], + [3.705438, 10.06321], + [3.60007, 10.332186], + [3.797112, 10.734746], + [3.572216, 11.327939], + [3.61118, 11.660167], + [3.680634, 12.552903], + [3.967283, 12.956109], + [4.107946, 13.531216], + [4.368344, 13.747482], + [5.443058, 13.865924], + [6.445426, 13.492768], + [6.820442, 13.115091], + [7.330747, 13.098038], + [7.804671, 13.343527], + [9.014933, 12.826659], + [9.524928, 12.851102], + [10.114814, 13.277252], + [10.701032, 13.246918], + [10.989593, 13.387323], + [11.527803, 13.32898], + [12.302071, 13.037189], + [13.083987, 13.596147], + [13.318702, 13.556356], + [13.995353, 12.461565], + [14.181336, 12.483657], + [14.577178, 12.085361], + [14.468192, 11.904752], + [14.415379, 11.572369], + [13.57295, 10.798566], + [13.308676, 10.160362], + [13.1676, 9.640626], + [12.955468, 9.417772], + [12.753672, 8.717763], + [12.218872, 8.305824], + [12.063946, 7.799808], + [11.839309, 7.397042], + [11.745774, 6.981383], + [11.058788, 6.644427], + [10.497375, 7.055358], + [10.118277, 7.03877], + [9.522706, 6.453482], + [9.233163, 6.444491], + [8.757533, 5.479666], + [8.500288, 4.771983], + [7.462108, 4.412108], + [7.082596, 4.464689], + [6.698072, 4.240594], + [5.898173, 4.262453], + [5.362805, 4.887971], + [5.033574, 5.611802], + [4.325607, 6.270651], + [3.57418, 6.2583], + [2.691702, 6.258817] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Nicaragua", + "SOV_A3": "NIC", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Nicaragua", + "ADM0_A3": "NIC", + "GEOU_DIF": 0, + "GEOUNIT": "Nicaragua", + "GU_A3": "NIC", + "SU_DIF": 0, + "SUBUNIT": "Nicaragua", + "SU_A3": "NIC", + "BRK_DIFF": 0, + "NAME": "Nicaragua", + "NAME_LONG": "Nicaragua", + "BRK_A3": "NIC", + "BRK_NAME": "Nicaragua", + "BRK_GROUP": null, + "ABBREV": "Nic.", + "POSTAL": "NI", + "FORMAL_EN": "Republic of Nicaragua", + "FORMAL_FR": null, + "NAME_CIAWF": "Nicaragua", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Nicaragua", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 9, + "POP_EST": 6025951, + "POP_RANK": 13, + "GDP_MD_EST": 33550, + "POP_YEAR": 2017, + "LASTCENSUS": 2005, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "NU", + "ISO_A2": "NI", + "ISO_A3": "NIC", + "ISO_A3_EH": "NIC", + "ISO_N3": "558", + "UN_A3": "558", + "WB_A2": "NI", + "WB_A3": "NIC", + "WOE_ID": 23424915, + "WOE_ID_EH": 23424915, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NIC", + "ADM0_A3_US": "NIC", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-87.668493, 10.726839, -83.147219, 15.016267], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-83.655612, 10.938764], + [-83.895054, 10.726839], + [-84.190179, 10.79345], + [-84.355931, 10.999226], + [-84.673069, 11.082657], + [-84.903003, 10.952303], + [-85.561852, 11.217119], + [-85.71254, 11.088445], + [-86.058488, 11.403439], + [-86.52585, 11.806877], + [-86.745992, 12.143962], + [-87.167516, 12.458258], + [-87.668493, 12.90991], + [-87.557467, 13.064552], + [-87.392386, 12.914018], + [-87.316654, 12.984686], + [-87.005769, 13.025794], + [-86.880557, 13.254204], + [-86.733822, 13.263093], + [-86.755087, 13.754845], + [-86.520708, 13.778487], + [-86.312142, 13.771356], + [-86.096264, 14.038187], + [-85.801295, 13.836055], + [-85.698665, 13.960078], + [-85.514413, 14.079012], + [-85.165365, 14.35437], + [-85.148751, 14.560197], + [-85.052787, 14.551541], + [-84.924501, 14.790493], + [-84.820037, 14.819587], + [-84.649582, 14.666805], + [-84.449336, 14.621614], + [-84.228342, 14.748764], + [-83.975721, 14.749436], + [-83.628585, 14.880074], + [-83.489989, 15.016267], + [-83.147219, 14.995829], + [-83.233234, 14.899866], + [-83.284162, 14.676624], + [-83.182126, 14.310703], + [-83.4125, 13.970078], + [-83.519832, 13.567699], + [-83.552207, 13.127054], + [-83.498515, 12.869292], + [-83.473323, 12.419087], + [-83.626104, 12.32085], + [-83.719613, 11.893124], + [-83.650858, 11.629032], + [-83.85547, 11.373311], + [-83.808936, 11.103044], + [-83.655612, 10.938764] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Netherlands", + "SOV_A3": "NL1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "Netherlands", + "ADM0_A3": "NLD", + "GEOU_DIF": 0, + "GEOUNIT": "Netherlands", + "GU_A3": "NLD", + "SU_DIF": 0, + "SUBUNIT": "Netherlands", + "SU_A3": "NLD", + "BRK_DIFF": 0, + "NAME": "Netherlands", + "NAME_LONG": "Netherlands", + "BRK_A3": "NLD", + "BRK_NAME": "Netherlands", + "BRK_GROUP": null, + "ABBREV": "Neth.", + "POSTAL": "NL", + "FORMAL_EN": "Kingdom of the Netherlands", + "FORMAL_FR": null, + "NAME_CIAWF": "Netherlands", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Netherlands", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 9, + "POP_EST": 17084719, + "POP_RANK": 14, + "GDP_MD_EST": 870800, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "NL", + "ISO_A2": "NL", + "ISO_A3": "NLD", + "ISO_A3_EH": "NLD", + "ISO_N3": "528", + "UN_A3": "528", + "WB_A2": "NL", + "WB_A3": "NLD", + "WOE_ID": -90, + "WOE_ID_EH": 23424909, + "WOE_NOTE": "Doesn't include new former units of Netherlands Antilles (24549811, 24549808, and 24549809)", + "ADM0_A3_IS": "NLD", + "ADM0_A3_US": "NLD", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Western Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [3.314971, 50.803721, 7.092053, 53.510403], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [6.156658, 50.803721], + [5.606976, 51.037298], + [4.973991, 51.475024], + [4.047071, 51.267259], + [3.314971, 51.345755], + [3.315011, 51.345777], + [3.830289, 51.620545], + [4.705997, 53.091798], + [6.074183, 53.510403], + [6.90514, 53.482162], + [7.092053, 53.144043], + [6.84287, 52.22844], + [6.589397, 51.852029], + [5.988658, 51.851616], + [6.156658, 50.803721] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Norway", + "SOV_A3": "NOR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Norway", + "ADM0_A3": "NOR", + "GEOU_DIF": 0, + "GEOUNIT": "Norway", + "GU_A3": "NOR", + "SU_DIF": 0, + "SUBUNIT": "Norway", + "SU_A3": "NOR", + "BRK_DIFF": 0, + "NAME": "Norway", + "NAME_LONG": "Norway", + "BRK_A3": "NOR", + "BRK_NAME": "Norway", + "BRK_GROUP": null, + "ABBREV": "Nor.", + "POSTAL": "N", + "FORMAL_EN": "Kingdom of Norway", + "FORMAL_FR": null, + "NAME_CIAWF": "Norway", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Norway", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 3, + "MAPCOLOR9": 8, + "MAPCOLOR13": 12, + "POP_EST": 5320045, + "POP_RANK": 13, + "GDP_MD_EST": 364700, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "-99", + "ISO_A2": "-99", + "ISO_A3": "-99", + "ISO_A3_EH": "-99", + "ISO_N3": "-99", + "UN_A3": "-99", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": -90, + "WOE_ID_EH": 23424910, + "WOE_NOTE": "Does not include Svalbard, Jan Mayen, or Bouvet Islands (28289410).", + "ADM0_A3_IS": "NOR", + "ADM0_A3_US": "NOR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [4.992078, 58.078884, 31.293418, 80.657144], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [28.59193, 69.064777], + [29.015573, 69.766491], + [27.732292, 70.164193], + [26.179622, 69.825299], + [25.689213, 69.092114], + [24.735679, 68.649557], + [23.66205, 68.891247], + [22.356238, 68.841741], + [21.244936, 69.370443], + [20.645593, 69.106247], + [20.025269, 69.065139], + [19.87856, 68.407194], + [17.993868, 68.567391], + [17.729182, 68.010552], + [16.768879, 68.013937], + [16.108712, 67.302456], + [15.108411, 66.193867], + [13.55569, 64.787028], + [13.919905, 64.445421], + [13.571916, 64.049114], + [12.579935, 64.066219], + [11.930569, 63.128318], + [11.992064, 61.800362], + [12.631147, 61.293572], + [12.300366, 60.117933], + [11.468272, 59.432393], + [11.027369, 58.856149], + [10.356557, 59.469807], + [8.382, 58.313288], + [7.048748, 58.078884], + [5.665835, 58.588155], + [5.308234, 59.663232], + [4.992078, 61.970998], + [5.9129, 62.614473], + [8.553411, 63.454008], + [10.527709, 64.486038], + [12.358347, 65.879726], + [14.761146, 67.810642], + [16.435927, 68.563205], + [19.184028, 69.817444], + [21.378416, 70.255169], + [23.023742, 70.202072], + [24.546543, 71.030497], + [26.37005, 70.986262], + [28.165547, 71.185474], + [31.293418, 70.453788], + [30.005435, 70.186259], + [31.101042, 69.558101], + [29.39955, 69.15692], + [28.59193, 69.064777] + ] + ], + [ + [ + [24.72412, 77.85385], + [22.49032, 77.44493], + [20.72601, 77.67704], + [21.41611, 77.93504], + [20.8119, 78.25463], + [22.88426, 78.45494], + [23.28134, 78.07954], + [24.72412, 77.85385] + ] + ], + [ + [ + [18.25183, 79.70175], + [21.54383, 78.95611], + [19.02737, 78.5626], + [18.47172, 77.82669], + [17.59441, 77.63796], + [17.1182, 76.80941], + [15.91315, 76.77045], + [13.76259, 77.38035], + [14.66956, 77.73565], + [13.1706, 78.02493], + [11.22231, 78.8693], + [10.44453, 79.65239], + [13.17077, 80.01046], + [13.71852, 79.66039], + [15.14282, 79.67431], + [15.52255, 80.01608], + [16.99085, 80.05086], + [18.25183, 79.70175] + ] + ], + [ + [ + [25.447625, 80.40734], + [27.407506, 80.056406], + [25.924651, 79.517834], + [23.024466, 79.400012], + [20.075188, 79.566823], + [19.897266, 79.842362], + [18.462264, 79.85988], + [17.368015, 80.318896], + [20.455992, 80.598156], + [21.907945, 80.357679], + [22.919253, 80.657144], + [25.447625, 80.40734] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Nepal", + "SOV_A3": "NPL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Nepal", + "ADM0_A3": "NPL", + "GEOU_DIF": 0, + "GEOUNIT": "Nepal", + "GU_A3": "NPL", + "SU_DIF": 0, + "SUBUNIT": "Nepal", + "SU_A3": "NPL", + "BRK_DIFF": 0, + "NAME": "Nepal", + "NAME_LONG": "Nepal", + "BRK_A3": "NPL", + "BRK_NAME": "Nepal", + "BRK_GROUP": null, + "ABBREV": "Nepal", + "POSTAL": "NP", + "FORMAL_EN": "Nepal", + "FORMAL_FR": null, + "NAME_CIAWF": "Nepal", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Nepal", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 12, + "POP_EST": 29384297, + "POP_RANK": 15, + "GDP_MD_EST": 71520, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "NP", + "ISO_A2": "NP", + "ISO_A3": "NPL", + "ISO_A3_EH": "NPL", + "ISO_N3": "524", + "UN_A3": "524", + "WB_A2": "NP", + "WB_A3": "NPL", + "WOE_ID": 23424911, + "WOE_ID_EH": 23424911, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NPL", + "ADM0_A3_US": "NPL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [80.088425, 26.397898, 88.174804, 30.422717], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [81.111256, 30.183481], + [81.525804, 30.422717], + [82.327513, 30.115268], + [83.337115, 29.463732], + [83.898993, 29.320226], + [84.23458, 28.839894], + [85.011638, 28.642774], + [85.82332, 28.203576], + [86.954517, 27.974262], + [88.120441, 27.876542], + [88.043133, 27.445819], + [88.174804, 26.810405], + [88.060238, 26.414615], + [87.227472, 26.397898], + [86.024393, 26.630985], + [85.251779, 26.726198], + [84.675018, 27.234901], + [83.304249, 27.364506], + [81.999987, 27.925479], + [81.057203, 28.416095], + [80.088425, 28.79447], + [80.476721, 29.729865], + [81.111256, 30.183481] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "New Zealand", + "SOV_A3": "NZ1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "New Zealand", + "ADM0_A3": "NZL", + "GEOU_DIF": 0, + "GEOUNIT": "New Zealand", + "GU_A3": "NZL", + "SU_DIF": 0, + "SUBUNIT": "New Zealand", + "SU_A3": "NZL", + "BRK_DIFF": 0, + "NAME": "New Zealand", + "NAME_LONG": "New Zealand", + "BRK_A3": "NZL", + "BRK_NAME": "New Zealand", + "BRK_GROUP": null, + "ABBREV": "N.Z.", + "POSTAL": "NZ", + "FORMAL_EN": "New Zealand", + "FORMAL_FR": null, + "NAME_CIAWF": "New Zealand", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "New Zealand", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 3, + "MAPCOLOR9": 4, + "MAPCOLOR13": 4, + "POP_EST": 4510327, + "POP_RANK": 12, + "GDP_MD_EST": 174800, + "POP_YEAR": 2017, + "LASTCENSUS": 2006, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "NZ", + "ISO_A2": "NZ", + "ISO_A3": "NZL", + "ISO_A3_EH": "NZL", + "ISO_N3": "554", + "UN_A3": "554", + "WB_A2": "NZ", + "WB_A3": "NZL", + "WOE_ID": 23424916, + "WOE_ID_EH": 23424916, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "NZL", + "ADM0_A3_US": "NZL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Australia and New Zealand", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 6.7 + }, + "bbox": [166.509144, -46.641235, 178.517094, -34.450662], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [173.020375, -40.919052], + [173.247234, -41.331999], + [173.958405, -40.926701], + [174.247587, -41.349155], + [174.248517, -41.770008], + [173.876447, -42.233184], + [173.22274, -42.970038], + [172.711246, -43.372288], + [173.080113, -43.853344], + [172.308584, -43.865694], + [171.452925, -44.242519], + [171.185138, -44.897104], + [170.616697, -45.908929], + [169.831422, -46.355775], + [169.332331, -46.641235], + [168.411354, -46.619945], + [167.763745, -46.290197], + [166.676886, -46.219917], + [166.509144, -45.852705], + [167.046424, -45.110941], + [168.303763, -44.123973], + [168.949409, -43.935819], + [169.667815, -43.555326], + [170.52492, -43.031688], + [171.12509, -42.512754], + [171.569714, -41.767424], + [171.948709, -41.514417], + [172.097227, -40.956104], + [172.79858, -40.493962], + [173.020375, -40.919052] + ] + ], + [ + [ + [174.612009, -36.156397], + [175.336616, -37.209098], + [175.357596, -36.526194], + [175.808887, -36.798942], + [175.95849, -37.555382], + [176.763195, -37.881253], + [177.438813, -37.961248], + [178.010354, -37.579825], + [178.517094, -37.695373], + [178.274731, -38.582813], + [177.97046, -39.166343], + [177.206993, -39.145776], + [176.939981, -39.449736], + [177.032946, -39.879943], + [176.885824, -40.065978], + [176.508017, -40.604808], + [176.01244, -41.289624], + [175.239567, -41.688308], + [175.067898, -41.425895], + [174.650973, -41.281821], + [175.22763, -40.459236], + [174.900157, -39.908933], + [173.824047, -39.508854], + [173.852262, -39.146602], + [174.574802, -38.797683], + [174.743474, -38.027808], + [174.697017, -37.381129], + [174.292028, -36.711092], + [174.319004, -36.534824], + [173.840997, -36.121981], + [173.054171, -35.237125], + [172.636005, -34.529107], + [173.007042, -34.450662], + [173.551298, -35.006183], + [174.32939, -35.265496], + [174.612009, -36.156397] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Oman", + "SOV_A3": "OMN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Oman", + "ADM0_A3": "OMN", + "GEOU_DIF": 0, + "GEOUNIT": "Oman", + "GU_A3": "OMN", + "SU_DIF": 0, + "SUBUNIT": "Oman", + "SU_A3": "OMN", + "BRK_DIFF": 0, + "NAME": "Oman", + "NAME_LONG": "Oman", + "BRK_A3": "OMN", + "BRK_NAME": "Oman", + "BRK_GROUP": null, + "ABBREV": "Oman", + "POSTAL": "OM", + "FORMAL_EN": "Sultanate of Oman", + "FORMAL_FR": null, + "NAME_CIAWF": "Oman", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Oman", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 6, + "POP_EST": 3424386, + "POP_RANK": 12, + "GDP_MD_EST": 173100, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "MU", + "ISO_A2": "OM", + "ISO_A3": "OMN", + "ISO_A3_EH": "OMN", + "ISO_N3": "512", + "UN_A3": "512", + "WB_A2": "OM", + "WB_A3": "OMN", + "WOE_ID": 23424898, + "WOE_ID_EH": 23424898, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "OMN", + "ADM0_A3_US": "OMN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [52.00001, 16.651051, 59.80806, 26.395934], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [56.261042, 25.714606], + [56.070821, 26.055464], + [56.362017, 26.395934], + [56.485679, 26.309118], + [56.391421, 25.895991], + [56.261042, 25.714606] + ] + ], + [ + [ + [55.208341, 22.70833], + [55.234489, 23.110993], + [55.525841, 23.524869], + [55.528632, 23.933604], + [55.981214, 24.130543], + [55.804119, 24.269604], + [55.886233, 24.920831], + [56.396847, 24.924732], + [56.84514, 24.241673], + [57.403453, 23.878594], + [58.136948, 23.747931], + [58.729211, 23.565668], + [59.180502, 22.992395], + [59.450098, 22.660271], + [59.80806, 22.533612], + [59.806148, 22.310525], + [59.442191, 21.714541], + [59.282408, 21.433886], + [58.861141, 21.114035], + [58.487986, 20.428986], + [58.034318, 20.481437], + [57.826373, 20.243002], + [57.665762, 19.736005], + [57.7887, 19.06757], + [57.694391, 18.94471], + [57.234264, 18.947991], + [56.609651, 18.574267], + [56.512189, 18.087113], + [56.283521, 17.876067], + [55.661492, 17.884128], + [55.269939, 17.632309], + [55.2749, 17.228354], + [54.791002, 16.950697], + [54.239253, 17.044981], + [53.570508, 16.707663], + [53.108573, 16.651051], + [52.782184, 17.349742], + [52.00001, 19.000003], + [54.999982, 19.999994], + [55.666659, 22.000001], + [55.208341, 22.70833] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Pakistan", + "SOV_A3": "PAK", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Pakistan", + "ADM0_A3": "PAK", + "GEOU_DIF": 0, + "GEOUNIT": "Pakistan", + "GU_A3": "PAK", + "SU_DIF": 0, + "SUBUNIT": "Pakistan", + "SU_A3": "PAK", + "BRK_DIFF": 0, + "NAME": "Pakistan", + "NAME_LONG": "Pakistan", + "BRK_A3": "PAK", + "BRK_NAME": "Pakistan", + "BRK_GROUP": null, + "ABBREV": "Pak.", + "POSTAL": "PK", + "FORMAL_EN": "Islamic Republic of Pakistan", + "FORMAL_FR": null, + "NAME_CIAWF": "Pakistan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Pakistan", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 11, + "POP_EST": 204924861, + "POP_RANK": 17, + "GDP_MD_EST": 988200, + "POP_YEAR": 2017, + "LASTCENSUS": 1998, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "PK", + "ISO_A2": "PK", + "ISO_A3": "PAK", + "ISO_A3_EH": "PAK", + "ISO_N3": "586", + "UN_A3": "586", + "WB_A2": "PK", + "WB_A3": "PAK", + "WOE_ID": 23424922, + "WOE_ID_EH": 23424922, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PAK", + "ADM0_A3_US": "PAK", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Southern Asia", + "REGION_WB": "South Asia", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [60.874248, 23.691965, 77.837451, 37.133031], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [60.874248, 29.829239], + [62.549857, 29.318572], + [63.550261, 29.468331], + [64.148002, 29.340819], + [64.350419, 29.560031], + [65.046862, 29.472181], + [66.346473, 29.887943], + [66.381458, 30.738899], + [66.938891, 31.304911], + [67.683394, 31.303154], + [67.792689, 31.58293], + [68.556932, 31.71331], + [68.926677, 31.620189], + [69.317764, 31.901412], + [69.262522, 32.501944], + [69.687147, 33.105499], + [70.323594, 33.358533], + [69.930543, 34.02012], + [70.881803, 33.988856], + [71.156773, 34.348911], + [71.115019, 34.733126], + [71.613076, 35.153203], + [71.498768, 35.650563], + [71.262348, 36.074388], + [71.846292, 36.509942], + [72.920025, 36.720007], + [74.067552, 36.836176], + [74.575893, 37.020841], + [75.158028, 37.133031], + [75.896897, 36.666806], + [76.192848, 35.898403], + [77.837451, 35.49401], + [76.871722, 34.653544], + [75.757061, 34.504923], + [74.240203, 34.748887], + [73.749948, 34.317699], + [74.104294, 33.441473], + [74.451559, 32.7649], + [75.258642, 32.271105], + [74.405929, 31.692639], + [74.42138, 30.979815], + [73.450638, 29.976413], + [72.823752, 28.961592], + [71.777666, 27.91318], + [70.616496, 27.989196], + [69.514393, 26.940966], + [70.168927, 26.491872], + [70.282873, 25.722229], + [70.844699, 25.215102], + [71.04324, 24.356524], + [68.842599, 24.359134], + [68.176645, 23.691965], + [67.443667, 23.944844], + [67.145442, 24.663611], + [66.372828, 25.425141], + [64.530408, 25.237039], + [62.905701, 25.218409], + [61.497363, 25.078237], + [61.874187, 26.239975], + [63.316632, 26.756532], + [63.233898, 27.217047], + [62.755426, 27.378923], + [62.72783, 28.259645], + [61.771868, 28.699334], + [61.369309, 29.303276], + [60.874248, 29.829239] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Panama", + "SOV_A3": "PAN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Panama", + "ADM0_A3": "PAN", + "GEOU_DIF": 0, + "GEOUNIT": "Panama", + "GU_A3": "PAN", + "SU_DIF": 0, + "SUBUNIT": "Panama", + "SU_A3": "PAN", + "BRK_DIFF": 0, + "NAME": "Panama", + "NAME_LONG": "Panama", + "BRK_A3": "PAN", + "BRK_NAME": "Panama", + "BRK_GROUP": null, + "ABBREV": "Pan.", + "POSTAL": "PA", + "FORMAL_EN": "Republic of Panama", + "FORMAL_FR": null, + "NAME_CIAWF": "Panama", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Panama", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 4, + "MAPCOLOR9": 6, + "MAPCOLOR13": 3, + "POP_EST": 3753142, + "POP_RANK": 12, + "GDP_MD_EST": 93120, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "PM", + "ISO_A2": "PA", + "ISO_A3": "PAN", + "ISO_A3_EH": "PAN", + "ISO_N3": "591", + "UN_A3": "591", + "WB_A2": "PA", + "WB_A3": "PAN", + "WOE_ID": 23424924, + "WOE_ID_EH": 23424924, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PAN", + "ADM0_A3_US": "PAN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-82.965783, 7.220541, -77.242566, 9.61161], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.353361, 8.670505], + [-77.474723, 8.524286], + [-77.242566, 7.935278], + [-77.431108, 7.638061], + [-77.753414, 7.70984], + [-77.881571, 7.223771], + [-78.214936, 7.512255], + [-78.429161, 8.052041], + [-78.182096, 8.319182], + [-78.435465, 8.387705], + [-78.622121, 8.718124], + [-79.120307, 8.996092], + [-79.557877, 8.932375], + [-79.760578, 8.584515], + [-80.164481, 8.333316], + [-80.382659, 8.298409], + [-80.480689, 8.090308], + [-80.00369, 7.547524], + [-80.276671, 7.419754], + [-80.421158, 7.271572], + [-80.886401, 7.220541], + [-81.059543, 7.817921], + [-81.189716, 7.647906], + [-81.519515, 7.70661], + [-81.721311, 8.108963], + [-82.131441, 8.175393], + [-82.390934, 8.292362], + [-82.820081, 8.290864], + [-82.850958, 8.073823], + [-82.965783, 8.225028], + [-82.913176, 8.423517], + [-82.829771, 8.626295], + [-82.868657, 8.807266], + [-82.719183, 8.925709], + [-82.927155, 9.07433], + [-82.932891, 9.476812], + [-82.546196, 9.566135], + [-82.187123, 9.207449], + [-82.207586, 8.995575], + [-81.808567, 8.950617], + [-81.714154, 9.031955], + [-81.439287, 8.786234], + [-80.947302, 8.858504], + [-80.521901, 9.111072], + [-79.9146, 9.312765], + [-79.573303, 9.61161], + [-79.021192, 9.552931], + [-79.05845, 9.454565], + [-78.500888, 9.420459], + [-78.055928, 9.24773], + [-77.729514, 8.946844], + [-77.353361, 8.670505] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Peru", + "SOV_A3": "PER", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Peru", + "ADM0_A3": "PER", + "GEOU_DIF": 0, + "GEOUNIT": "Peru", + "GU_A3": "PER", + "SU_DIF": 0, + "SUBUNIT": "Peru", + "SU_A3": "PER", + "BRK_DIFF": 0, + "NAME": "Peru", + "NAME_LONG": "Peru", + "BRK_A3": "PER", + "BRK_NAME": "Peru", + "BRK_GROUP": null, + "ABBREV": "Peru", + "POSTAL": "PE", + "FORMAL_EN": "Republic of Peru", + "FORMAL_FR": null, + "NAME_CIAWF": "Peru", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Peru", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 4, + "MAPCOLOR9": 4, + "MAPCOLOR13": 11, + "POP_EST": 31036656, + "POP_RANK": 15, + "GDP_MD_EST": 410400, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "PE", + "ISO_A2": "PE", + "ISO_A3": "PER", + "ISO_A3_EH": "PER", + "ISO_N3": "604", + "UN_A3": "604", + "WB_A2": "PE", + "WB_A3": "PER", + "WOE_ID": 23424919, + "WOE_ID_EH": 23424919, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PER", + "ADM0_A3_US": "PER", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [-81.410943, -18.347975, -68.66508, -0.057205], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-69.529678, -10.951734], + [-68.66508, -12.5613], + [-68.88008, -12.899729], + [-68.929224, -13.602684], + [-68.948887, -14.453639], + [-69.339535, -14.953195], + [-69.160347, -15.323974], + [-69.389764, -15.660129], + [-68.959635, -16.500698], + [-69.590424, -17.580012], + [-69.858444, -18.092694], + [-70.372572, -18.347975], + [-71.37525, -17.773799], + [-71.462041, -17.363488], + [-73.44453, -16.359363], + [-75.237883, -15.265683], + [-76.009205, -14.649286], + [-76.423469, -13.823187], + [-76.259242, -13.535039], + [-77.106192, -12.222716], + [-78.092153, -10.377712], + [-79.036953, -8.386568], + [-79.44592, -7.930833], + [-79.760578, -7.194341], + [-80.537482, -6.541668], + [-81.249996, -6.136834], + [-80.926347, -5.690557], + [-81.410943, -4.736765], + [-81.09967, -4.036394], + [-80.302561, -3.404856], + [-80.184015, -3.821162], + [-80.469295, -4.059287], + [-80.442242, -4.425724], + [-80.028908, -4.346091], + [-79.624979, -4.454198], + [-79.205289, -4.959129], + [-78.639897, -4.547784], + [-78.450684, -3.873097], + [-77.837905, -3.003021], + [-76.635394, -2.608678], + [-75.544996, -1.56161], + [-75.233723, -0.911417], + [-75.373223, -0.152032], + [-75.106625, -0.057205], + [-74.441601, -0.53082], + [-74.122395, -1.002833], + [-73.659504, -1.260491], + [-73.070392, -2.308954], + [-72.325787, -2.434218], + [-71.774761, -2.16979], + [-71.413646, -2.342802], + [-70.813476, -2.256865], + [-70.047709, -2.725156], + [-70.692682, -3.742872], + [-70.394044, -3.766591], + [-69.893635, -4.298187], + [-70.794769, -4.251265], + [-70.928843, -4.401591], + [-71.748406, -4.593983], + [-72.891928, -5.274561], + [-72.964507, -5.741251], + [-73.219711, -6.089189], + [-73.120027, -6.629931], + [-73.724487, -6.918595], + [-73.723401, -7.340999], + [-73.987235, -7.52383], + [-73.571059, -8.424447], + [-73.015383, -9.032833], + [-73.226713, -9.462213], + [-72.563033, -9.520194], + [-72.184891, -10.053598], + [-71.302412, -10.079436], + [-70.481894, -9.490118], + [-70.548686, -11.009147], + [-70.093752, -11.123972], + [-69.529678, -10.951734] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Philippines", + "SOV_A3": "PHL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Philippines", + "ADM0_A3": "PHL", + "GEOU_DIF": 0, + "GEOUNIT": "Philippines", + "GU_A3": "PHL", + "SU_DIF": 0, + "SUBUNIT": "Philippines", + "SU_A3": "PHL", + "BRK_DIFF": 0, + "NAME": "Philippines", + "NAME_LONG": "Philippines", + "BRK_A3": "PHL", + "BRK_NAME": "Philippines", + "BRK_GROUP": null, + "ABBREV": "Phil.", + "POSTAL": "PH", + "FORMAL_EN": "Republic of the Philippines", + "FORMAL_FR": null, + "NAME_CIAWF": "Philippines", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Philippines", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 8, + "POP_EST": 104256076, + "POP_RANK": 17, + "GDP_MD_EST": 801900, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "RP", + "ISO_A2": "PH", + "ISO_A3": "PHL", + "ISO_A3_EH": "PHL", + "ISO_N3": "608", + "UN_A3": "608", + "WB_A2": "PH", + "WB_A3": "PHL", + "WOE_ID": 23424934, + "WOE_ID_EH": 23424934, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PHL", + "ADM0_A3_US": "PHL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [117.174275, 5.581003, 126.537424, 18.505227], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [126.376814, 8.414706], + [126.478513, 7.750354], + [126.537424, 7.189381], + [126.196773, 6.274294], + [125.831421, 7.293715], + [125.363852, 6.786485], + [125.683161, 6.049657], + [125.396512, 5.581003], + [124.219788, 6.161355], + [123.93872, 6.885136], + [124.243662, 7.36061], + [123.610212, 7.833527], + [123.296071, 7.418876], + [122.825506, 7.457375], + [122.085499, 6.899424], + [121.919928, 7.192119], + [122.312359, 8.034962], + [122.942398, 8.316237], + [123.487688, 8.69301], + [123.841154, 8.240324], + [124.60147, 8.514158], + [124.764612, 8.960409], + [125.471391, 8.986997], + [125.412118, 9.760335], + [126.222714, 9.286074], + [126.306637, 8.782487], + [126.376814, 8.414706] + ] + ], + [ + [ + [123.982438, 10.278779], + [123.623183, 9.950091], + [123.309921, 9.318269], + [122.995883, 9.022189], + [122.380055, 9.713361], + [122.586089, 9.981045], + [122.837081, 10.261157], + [122.947411, 10.881868], + [123.49885, 10.940624], + [123.337774, 10.267384], + [124.077936, 11.232726], + [123.982438, 10.278779] + ] + ], + [ + [ + [118.504581, 9.316383], + [117.174275, 8.3675], + [117.664477, 9.066889], + [118.386914, 9.6845], + [118.987342, 10.376292], + [119.511496, 11.369668], + [119.689677, 10.554291], + [119.029458, 10.003653], + [118.504581, 9.316383] + ] + ], + [ + [ + [121.883548, 11.891755], + [122.483821, 11.582187], + [123.120217, 11.58366], + [123.100838, 11.165934], + [122.637714, 10.741308], + [122.00261, 10.441017], + [121.967367, 10.905691], + [122.03837, 11.415841], + [121.883548, 11.891755] + ] + ], + [ + [ + [125.502552, 12.162695], + [125.783465, 11.046122], + [125.011884, 11.311455], + [125.032761, 10.975816], + [125.277449, 10.358722], + [124.801819, 10.134679], + [124.760168, 10.837995], + [124.459101, 10.88993], + [124.302522, 11.495371], + [124.891013, 11.415583], + [124.87799, 11.79419], + [124.266762, 12.557761], + [125.227116, 12.535721], + [125.502552, 12.162695] + ] + ], + [ + [ + [121.527394, 13.06959], + [121.26219, 12.20556], + [120.833896, 12.704496], + [120.323436, 13.466413], + [121.180128, 13.429697], + [121.527394, 13.06959] + ] + ], + [ + [ + [121.321308, 18.504065], + [121.937601, 18.218552], + [122.246006, 18.47895], + [122.336957, 18.224883], + [122.174279, 17.810283], + [122.515654, 17.093505], + [122.252311, 16.262444], + [121.662786, 15.931018], + [121.50507, 15.124814], + [121.728829, 14.328376], + [122.258925, 14.218202], + [122.701276, 14.336541], + [123.950295, 13.782131], + [123.855107, 13.237771], + [124.181289, 12.997527], + [124.077419, 12.536677], + [123.298035, 13.027526], + [122.928652, 13.55292], + [122.671355, 13.185836], + [122.03465, 13.784482], + [121.126385, 13.636687], + [120.628637, 13.857656], + [120.679384, 14.271016], + [120.991819, 14.525393], + [120.693336, 14.756671], + [120.564145, 14.396279], + [120.070429, 14.970869], + [119.920929, 15.406347], + [119.883773, 16.363704], + [120.286488, 16.034629], + [120.390047, 17.599081], + [120.715867, 18.505227], + [121.321308, 18.504065] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Papua New Guinea", + "SOV_A3": "PNG", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Papua New Guinea", + "ADM0_A3": "PNG", + "GEOU_DIF": 0, + "GEOUNIT": "Papua New Guinea", + "GU_A3": "PNG", + "SU_DIF": 1, + "SUBUNIT": "Papua New Guinea", + "SU_A3": "PN1", + "BRK_DIFF": 0, + "NAME": "Papua New Guinea", + "NAME_LONG": "Papua New Guinea", + "BRK_A3": "PN1", + "BRK_NAME": "Papua New Guinea", + "BRK_GROUP": null, + "ABBREV": "P.N.G.", + "POSTAL": "PG", + "FORMAL_EN": "Independent State of Papua New Guinea", + "FORMAL_FR": null, + "NAME_CIAWF": "Papua New Guinea", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Papua New Guinea", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 1, + "POP_EST": 6909701, + "POP_RANK": 13, + "GDP_MD_EST": 28020, + "POP_YEAR": 2017, + "LASTCENSUS": 2000, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "PP", + "ISO_A2": "PG", + "ISO_A3": "PNG", + "ISO_A3_EH": "PNG", + "ISO_N3": "598", + "UN_A3": "598", + "WB_A2": "PG", + "WB_A3": "PNG", + "WOE_ID": 23424926, + "WOE_ID_EH": 23424926, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PNG", + "ADM0_A3_US": "PNG", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Melanesia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 16, + "LONG_LEN": 16, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2.5, + "MAX_LABEL": 7.5 + }, + "bbox": [141.00021, -10.652476, 156.019965, -2.500002], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [141.033852, -9.117893], + [141.017057, -5.859022], + [141.00021, -2.600151], + [142.735247, -3.289153], + [144.583971, -3.861418], + [145.27318, -4.373738], + [145.829786, -4.876498], + [145.981922, -5.465609], + [147.648073, -6.083659], + [147.891108, -6.614015], + [146.970905, -6.721657], + [147.191874, -7.388024], + [148.084636, -8.044108], + [148.734105, -9.104664], + [149.306835, -9.071436], + [149.266631, -9.514406], + [150.038728, -9.684318], + [149.738798, -9.872937], + [150.801628, -10.293687], + [150.690575, -10.582713], + [150.028393, -10.652476], + [149.78231, -10.393267], + [148.923138, -10.280923], + [147.913018, -10.130441], + [147.135443, -9.492444], + [146.567881, -8.942555], + [146.048481, -8.067414], + [144.744168, -7.630128], + [143.897088, -7.91533], + [143.286376, -8.245491], + [143.413913, -8.983069], + [142.628431, -9.326821], + [142.068259, -9.159596], + [141.033852, -9.117893] + ] + ], + [ + [ + [155.880026, -6.819997], + [155.599991, -6.919991], + [155.166994, -6.535931], + [154.729192, -5.900828], + [154.514114, -5.139118], + [154.652504, -5.042431], + [154.759991, -5.339984], + [155.062918, -5.566792], + [155.547746, -6.200655], + [156.019965, -6.540014], + [155.880026, -6.819997] + ] + ], + [ + [ + [151.982796, -5.478063], + [151.459107, -5.56028], + [151.30139, -5.840728], + [150.754447, -6.083763], + [150.241197, -6.317754], + [149.709963, -6.316513], + [148.890065, -6.02604], + [148.318937, -5.747142], + [148.401826, -5.437756], + [149.298412, -5.583742], + [149.845562, -5.505503], + [149.99625, -5.026101], + [150.139756, -5.001348], + [150.236908, -5.53222], + [150.807467, -5.455842], + [151.089672, -5.113693], + [151.647881, -4.757074], + [151.537862, -4.167807], + [152.136792, -4.14879], + [152.338743, -4.312966], + [152.318693, -4.867661], + [151.982796, -5.478063] + ] + ], + [ + [ + [153.140038, -4.499983], + [152.827292, -4.766427], + [152.638673, -4.176127], + [152.406026, -3.789743], + [151.953237, -3.462062], + [151.384279, -3.035422], + [150.66205, -2.741486], + [150.939965, -2.500002], + [151.479984, -2.779985], + [151.820015, -2.999972], + [152.239989, -3.240009], + [152.640017, -3.659983], + [153.019994, -3.980015], + [153.140038, -4.499983] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Poland", + "SOV_A3": "POL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Poland", + "ADM0_A3": "POL", + "GEOU_DIF": 0, + "GEOUNIT": "Poland", + "GU_A3": "POL", + "SU_DIF": 0, + "SUBUNIT": "Poland", + "SU_A3": "POL", + "BRK_DIFF": 0, + "NAME": "Poland", + "NAME_LONG": "Poland", + "BRK_A3": "POL", + "BRK_NAME": "Poland", + "BRK_GROUP": null, + "ABBREV": "Pol.", + "POSTAL": "PL", + "FORMAL_EN": "Republic of Poland", + "FORMAL_FR": null, + "NAME_CIAWF": "Poland", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Poland", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 7, + "MAPCOLOR9": 1, + "MAPCOLOR13": 2, + "POP_EST": 38476269, + "POP_RANK": 15, + "GDP_MD_EST": 1052000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "PL", + "ISO_A2": "PL", + "ISO_A3": "POL", + "ISO_A3_EH": "POL", + "ISO_N3": "616", + "UN_A3": "616", + "WB_A2": "PL", + "WB_A3": "POL", + "WOE_ID": 23424923, + "WOE_ID_EH": 23424923, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "POL", + "ADM0_A3_US": "POL", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [14.074521, 49.027395, 24.029986, 54.851536], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.484128, 53.912498], + [23.527536, 53.470122], + [23.804935, 53.089731], + [23.799199, 52.691099], + [23.199494, 52.486977], + [23.508002, 52.023647], + [23.527071, 51.578454], + [24.029986, 50.705407], + [23.922757, 50.424881], + [23.426508, 50.308506], + [22.51845, 49.476774], + [22.776419, 49.027395], + [22.558138, 49.085738], + [21.607808, 49.470107], + [20.887955, 49.328772], + [20.415839, 49.431453], + [19.825023, 49.217125], + [19.320713, 49.571574], + [18.909575, 49.435846], + [18.853144, 49.49623], + [18.392914, 49.988629], + [17.649445, 50.049038], + [17.554567, 50.362146], + [16.868769, 50.473974], + [16.719476, 50.215747], + [16.176253, 50.422607], + [16.238627, 50.697733], + [15.490972, 50.78473], + [15.016996, 51.106674], + [14.607098, 51.745188], + [14.685026, 52.089947], + [14.4376, 52.62485], + [14.074521, 52.981263], + [14.353315, 53.248171], + [14.119686, 53.757029], + [14.8029, 54.050706], + [16.363477, 54.513159], + [17.622832, 54.851536], + [18.620859, 54.682606], + [18.696255, 54.438719], + [19.66064, 54.426084], + [20.892245, 54.312525], + [22.731099, 54.327537], + [23.243987, 54.220567], + [23.484128, 53.912498] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "United States of America", + "SOV_A3": "US1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Dependency", + "ADMIN": "Puerto Rico", + "ADM0_A3": "PRI", + "GEOU_DIF": 0, + "GEOUNIT": "Puerto Rico", + "GU_A3": "PRI", + "SU_DIF": 0, + "SUBUNIT": "Puerto Rico", + "SU_A3": "PRI", + "BRK_DIFF": 0, + "NAME": "Puerto Rico", + "NAME_LONG": "Puerto Rico", + "BRK_A3": "PRI", + "BRK_NAME": "Puerto Rico", + "BRK_GROUP": null, + "ABBREV": "P.R.", + "POSTAL": "PR", + "FORMAL_EN": "Commonwealth of Puerto Rico", + "FORMAL_FR": null, + "NAME_CIAWF": "Puerto Rico", + "NOTE_ADM0": "Commonwealth of U.S.A.", + "NOTE_BRK": null, + "NAME_SORT": "Puerto Rico", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 5, + "MAPCOLOR9": 1, + "MAPCOLOR13": 1, + "POP_EST": 3351827, + "POP_RANK": 12, + "GDP_MD_EST": 131000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "RQ", + "ISO_A2": "PR", + "ISO_A3": "PRI", + "ISO_A3_EH": "PRI", + "ISO_N3": "630", + "UN_A3": "630", + "WB_A2": "PR", + "WB_A3": "PRI", + "WOE_ID": 23424935, + "WOE_ID_EH": 23424935, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PRI", + "ADM0_A3_US": "PRI", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": -99, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-67.242428, 17.946553, -65.591004, 18.520601], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-66.282434, 18.514762], + [-65.771303, 18.426679], + [-65.591004, 18.228035], + [-65.847164, 17.975906], + [-66.599934, 17.981823], + [-67.184162, 17.946553], + [-67.242428, 18.37446], + [-67.100679, 18.520601], + [-66.282434, 18.514762] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "North Korea", + "SOV_A3": "PRK", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "North Korea", + "ADM0_A3": "PRK", + "GEOU_DIF": 0, + "GEOUNIT": "North Korea", + "GU_A3": "PRK", + "SU_DIF": 0, + "SUBUNIT": "North Korea", + "SU_A3": "PRK", + "BRK_DIFF": 0, + "NAME": "North Korea", + "NAME_LONG": "Dem. Rep. Korea", + "BRK_A3": "PRK", + "BRK_NAME": "Dem. Rep. Korea", + "BRK_GROUP": null, + "ABBREV": "N.K.", + "POSTAL": "KP", + "FORMAL_EN": "Democratic People's Republic of Korea", + "FORMAL_FR": null, + "NAME_CIAWF": "Korea, North", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Korea, Dem. Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 5, + "MAPCOLOR9": 3, + "MAPCOLOR13": 9, + "POP_EST": 25248140, + "POP_RANK": 15, + "GDP_MD_EST": 40000, + "POP_YEAR": 2013, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "KN", + "ISO_A2": "KP", + "ISO_A3": "PRK", + "ISO_A3_EH": "PRK", + "ISO_N3": "408", + "UN_A3": "408", + "WB_A2": "KP", + "WB_A3": "PRK", + "WOE_ID": 23424865, + "WOE_ID_EH": 23424865, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PRK", + "ADM0_A3_US": "PRK", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 11, + "LONG_LEN": 15, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [124.265625, 37.669071, 130.780007, 42.985387], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [124.265625, 39.928493], + [125.079942, 40.569824], + [126.182045, 41.107336], + [126.869083, 41.816569], + [127.343783, 41.503152], + [128.208433, 41.466772], + [128.052215, 41.994285], + [129.596669, 42.424982], + [129.994267, 42.985387], + [130.64, 42.395024], + [130.64, 42.395], + [130.779992, 42.22001], + [130.400031, 42.280004], + [129.965949, 41.941368], + [129.667362, 41.601104], + [129.705189, 40.882828], + [129.188115, 40.661808], + [129.0104, 40.485436], + [128.633368, 40.189847], + [127.967414, 40.025413], + [127.533436, 39.75685], + [127.50212, 39.323931], + [127.385434, 39.213472], + [127.783343, 39.050898], + [128.349716, 38.612243], + [128.205746, 38.370397], + [127.780035, 38.304536], + [127.073309, 38.256115], + [126.68372, 37.804773], + [126.237339, 37.840378], + [126.174759, 37.749686], + [125.689104, 37.94001], + [125.568439, 37.752089], + [125.27533, 37.669071], + [125.240087, 37.857224], + [124.981033, 37.948821], + [124.712161, 38.108346], + [124.985994, 38.548474], + [125.221949, 38.665857], + [125.132859, 38.848559], + [125.38659, 39.387958], + [125.321116, 39.551385], + [124.737482, 39.660344], + [124.265625, 39.928493] + ] + ], + [ + [ + [130.780005, 42.22001], + [130.780007, 42.220007], + [130.780004, 42.220008], + [130.780005, 42.22001] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Portugal", + "SOV_A3": "PRT", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Portugal", + "ADM0_A3": "PRT", + "GEOU_DIF": 0, + "GEOUNIT": "Portugal", + "GU_A3": "PRT", + "SU_DIF": 1, + "SUBUNIT": "Portugal", + "SU_A3": "PR1", + "BRK_DIFF": 0, + "NAME": "Portugal", + "NAME_LONG": "Portugal", + "BRK_A3": "PR1", + "BRK_NAME": "Portugal", + "BRK_GROUP": null, + "ABBREV": "Port.", + "POSTAL": "P", + "FORMAL_EN": "Portuguese Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Portugal", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Portugal", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 7, + "MAPCOLOR9": 1, + "MAPCOLOR13": 4, + "POP_EST": 10839514, + "POP_RANK": 14, + "GDP_MD_EST": 297100, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "PO", + "ISO_A2": "PT", + "ISO_A3": "PRT", + "ISO_A3_EH": "PRT", + "ISO_N3": "620", + "UN_A3": "620", + "WB_A2": "PT", + "WB_A3": "PRT", + "WOE_ID": 23424925, + "WOE_ID_EH": 23424925, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PRT", + "ADM0_A3_US": "PRT", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-9.526571, 36.838269, -6.389088, 42.280469], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-9.034818, 41.880571], + [-8.671946, 42.134689], + [-8.263857, 42.280469], + [-8.013175, 41.790886], + [-7.422513, 41.792075], + [-7.251309, 41.918346], + [-6.668606, 41.883387], + [-6.389088, 41.381815], + [-6.851127, 41.111083], + [-6.86402, 40.330872], + [-7.026413, 40.184524], + [-7.066592, 39.711892], + [-7.498632, 39.629571], + [-7.098037, 39.030073], + [-7.374092, 38.373059], + [-7.029281, 38.075764], + [-7.166508, 37.803894], + [-7.537105, 37.428904], + [-7.453726, 37.097788], + [-7.855613, 36.838269], + [-8.382816, 36.97888], + [-8.898857, 36.868809], + [-8.746101, 37.651346], + [-8.839998, 38.266243], + [-9.287464, 38.358486], + [-9.526571, 38.737429], + [-9.446989, 39.392066], + [-9.048305, 39.755093], + [-8.977353, 40.159306], + [-8.768684, 40.760639], + [-8.790853, 41.184334], + [-8.990789, 41.543459], + [-9.034818, 41.880571] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Paraguay", + "SOV_A3": "PRY", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Paraguay", + "ADM0_A3": "PRY", + "GEOU_DIF": 0, + "GEOUNIT": "Paraguay", + "GU_A3": "PRY", + "SU_DIF": 0, + "SUBUNIT": "Paraguay", + "SU_A3": "PRY", + "BRK_DIFF": 0, + "NAME": "Paraguay", + "NAME_LONG": "Paraguay", + "BRK_A3": "PRY", + "BRK_NAME": "Paraguay", + "BRK_GROUP": null, + "ABBREV": "Para.", + "POSTAL": "PY", + "FORMAL_EN": "Republic of Paraguay", + "FORMAL_FR": null, + "NAME_CIAWF": "Paraguay", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Paraguay", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 6, + "MAPCOLOR13": 2, + "POP_EST": 6943739, + "POP_RANK": 13, + "GDP_MD_EST": 64670, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "PA", + "ISO_A2": "PY", + "ISO_A3": "PRY", + "ISO_A3_EH": "PRY", + "ISO_N3": "600", + "UN_A3": "600", + "WB_A2": "PY", + "WB_A3": "PRY", + "WOE_ID": 23424917, + "WOE_ID_EH": 23424917, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PRY", + "ADM0_A3_US": "PRY", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-62.685057, -27.548499, -54.29296, -19.342747], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-54.625291, -25.739255], + [-54.788795, -26.621786], + [-55.695846, -27.387837], + [-56.486702, -27.548499], + [-57.60976, -27.395899], + [-58.618174, -27.123719], + [-57.63366, -25.603657], + [-57.777217, -25.16234], + [-58.807128, -24.771459], + [-60.028966, -24.032796], + [-60.846565, -23.880713], + [-62.685057, -22.249029], + [-62.291179, -21.051635], + [-62.265961, -20.513735], + [-61.786326, -19.633737], + [-60.043565, -19.342747], + [-59.115042, -19.356906], + [-58.183471, -19.868399], + [-58.166392, -20.176701], + [-57.870674, -20.732688], + [-57.937156, -22.090176], + [-56.88151, -22.282154], + [-56.473317, -22.0863], + [-55.797958, -22.35693], + [-55.610683, -22.655619], + [-55.517639, -23.571998], + [-55.400747, -23.956935], + [-55.027902, -24.001274], + [-54.652834, -23.839578], + [-54.29296, -24.021014], + [-54.293476, -24.5708], + [-54.428946, -25.162185], + [-54.625291, -25.739255] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Israel", + "SOV_A3": "IS1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Disputed", + "ADMIN": "Palestine", + "ADM0_A3": "PSX", + "GEOU_DIF": 0, + "GEOUNIT": "Palestine", + "GU_A3": "PSX", + "SU_DIF": 0, + "SUBUNIT": "Palestine", + "SU_A3": "PSX", + "BRK_DIFF": 0, + "NAME": "Palestine", + "NAME_LONG": "Palestine", + "BRK_A3": "PSX", + "BRK_NAME": "Palestine", + "BRK_GROUP": null, + "ABBREV": "Pal.", + "POSTAL": "PAL", + "FORMAL_EN": "West Bank and Gaza", + "FORMAL_FR": null, + "NAME_CIAWF": null, + "NOTE_ADM0": "Partial self-admin.", + "NOTE_BRK": "Partial self-admin.", + "NAME_SORT": "Palestine (West Bank and Gaza)", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 5, + "MAPCOLOR13": 8, + "POP_EST": 4543126, + "POP_RANK": 12, + "GDP_MD_EST": 21220.77, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "-99", + "ISO_A2": "PS", + "ISO_A3": "PSE", + "ISO_A3_EH": "PSE", + "ISO_N3": "275", + "UN_A3": "275", + "WB_A2": "GZ", + "WB_A3": "WBG", + "WOE_ID": 28289408, + "WOE_ID_EH": 28289408, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "PSE", + "ADM0_A3_US": "PSX", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": -99, + "MIN_ZOOM": 7, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9.5 + }, + "bbox": [34.927408, 31.353435, 35.545665, 32.532511], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [35.397561, 31.489086], + [34.927408, 31.353435], + [34.970507, 31.616778], + [35.225892, 31.754341], + [34.974641, 31.866582], + [35.18393, 32.532511], + [35.545665, 32.393992], + [35.545252, 31.782505], + [35.397561, 31.489086] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Qatar", + "SOV_A3": "QAT", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Qatar", + "ADM0_A3": "QAT", + "GEOU_DIF": 0, + "GEOUNIT": "Qatar", + "GU_A3": "QAT", + "SU_DIF": 0, + "SUBUNIT": "Qatar", + "SU_A3": "QAT", + "BRK_DIFF": 0, + "NAME": "Qatar", + "NAME_LONG": "Qatar", + "BRK_A3": "QAT", + "BRK_NAME": "Qatar", + "BRK_GROUP": null, + "ABBREV": "Qatar", + "POSTAL": "QA", + "FORMAL_EN": "State of Qatar", + "FORMAL_FR": null, + "NAME_CIAWF": "Qatar", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Qatar", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 4, + "POP_EST": 2314307, + "POP_RANK": 12, + "GDP_MD_EST": 334500, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "QA", + "ISO_A2": "QA", + "ISO_A3": "QAT", + "ISO_A3_EH": "QAT", + "ISO_N3": "634", + "UN_A3": "634", + "WB_A2": "QA", + "WB_A3": "QAT", + "WOE_ID": 23424930, + "WOE_ID_EH": 23424930, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "QAT", + "ADM0_A3_US": "QAT", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [50.743911, 24.556331, 51.6067, 26.114582], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [50.810108, 24.754743], + [50.743911, 25.482424], + [51.013352, 26.006992], + [51.286462, 26.114582], + [51.589079, 25.801113], + [51.6067, 25.21567], + [51.389608, 24.627386], + [51.112415, 24.556331], + [50.810108, 24.754743] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Romania", + "SOV_A3": "ROU", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Romania", + "ADM0_A3": "ROU", + "GEOU_DIF": 0, + "GEOUNIT": "Romania", + "GU_A3": "ROU", + "SU_DIF": 0, + "SUBUNIT": "Romania", + "SU_A3": "ROU", + "BRK_DIFF": 0, + "NAME": "Romania", + "NAME_LONG": "Romania", + "BRK_A3": "ROU", + "BRK_NAME": "Romania", + "BRK_GROUP": null, + "ABBREV": "Rom.", + "POSTAL": "RO", + "FORMAL_EN": "Romania", + "FORMAL_FR": null, + "NAME_CIAWF": "Romania", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Romania", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 3, + "MAPCOLOR13": 13, + "POP_EST": 21529967, + "POP_RANK": 15, + "GDP_MD_EST": 441000, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "RO", + "ISO_A2": "RO", + "ISO_A3": "ROU", + "ISO_A3_EH": "ROU", + "ISO_N3": "642", + "UN_A3": "642", + "WB_A2": "RO", + "WB_A3": "ROM", + "WOE_ID": 23424933, + "WOE_ID_EH": 23424933, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ROU", + "ADM0_A3_US": "ROU", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [20.220192, 43.688445, 29.626543, 48.220881], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [28.558081, 43.707462], + [27.970107, 43.812468], + [27.2424, 44.175986], + [26.065159, 43.943494], + [25.569272, 43.688445], + [24.100679, 43.741051], + [23.332302, 43.897011], + [22.944832, 43.823785], + [22.65715, 44.234923], + [22.474008, 44.409228], + [22.705726, 44.578003], + [22.459022, 44.702517], + [22.145088, 44.478422], + [21.562023, 44.768947], + [21.483526, 45.18117], + [20.874313, 45.416375], + [20.762175, 45.734573], + [20.220192, 46.127469], + [21.021952, 46.316088], + [21.626515, 46.994238], + [22.099768, 47.672439], + [22.710531, 47.882194], + [23.142236, 48.096341], + [23.760958, 47.985598], + [24.402056, 47.981878], + [24.866317, 47.737526], + [25.207743, 47.891056], + [25.945941, 47.987149], + [26.19745, 48.220881], + [26.619337, 48.220726], + [26.924176, 48.123264], + [27.233873, 47.826771], + [27.551166, 47.405117], + [28.12803, 46.810476], + [28.160018, 46.371563], + [28.054443, 45.944586], + [28.233554, 45.488283], + [28.679779, 45.304031], + [29.149725, 45.464925], + [29.603289, 45.293308], + [29.626543, 45.035391], + [29.141612, 44.82021], + [28.837858, 44.913874], + [28.558081, 43.707462] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Russia", + "SOV_A3": "RUS", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Russia", + "ADM0_A3": "RUS", + "GEOU_DIF": 0, + "GEOUNIT": "Russia", + "GU_A3": "RUS", + "SU_DIF": 0, + "SUBUNIT": "Russia", + "SU_A3": "RUS", + "BRK_DIFF": 0, + "NAME": "Russia", + "NAME_LONG": "Russian Federation", + "BRK_A3": "RUS", + "BRK_NAME": "Russia", + "BRK_GROUP": null, + "ABBREV": "Rus.", + "POSTAL": "RUS", + "FORMAL_EN": "Russian Federation", + "FORMAL_FR": null, + "NAME_CIAWF": "Russia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Russian Federation", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 5, + "MAPCOLOR9": 7, + "MAPCOLOR13": 7, + "POP_EST": 142257519, + "POP_RANK": 17, + "GDP_MD_EST": 3745000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "3. Emerging region: BRIC", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "RS", + "ISO_A2": "RU", + "ISO_A3": "RUS", + "ISO_A3_EH": "RUS", + "ISO_N3": "643", + "UN_A3": "643", + "WB_A2": "RU", + "WB_A3": "RUS", + "WOE_ID": 23424936, + "WOE_ID_EH": 23424936, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "RUS", + "ADM0_A3_US": "RUS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 18, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 5.2 + }, + "bbox": [-180, 41.151416, 180, 81.2504], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [48.584353, 41.808869], + [47.987283, 41.405819], + [47.815666, 41.151416], + [47.373315, 41.219732], + [46.686071, 41.827137], + [46.404951, 41.860675], + [45.7764, 42.09244], + [45.470279, 42.502781], + [44.537623, 42.711993], + [43.93121, 42.55496], + [43.75599, 42.74083], + [42.3944, 43.2203], + [40.92219, 43.38215], + [40.076965, 43.553104], + [39.955009, 43.434998], + [38.68, 44.28], + [37.53912, 44.65721], + [36.67546, 45.24469], + [37.40317, 45.40451], + [38.23295, 46.24087], + [37.67372, 46.63657], + [39.14767, 47.04475], + [39.1212, 47.26336], + [38.223538, 47.10219], + [38.255112, 47.5464], + [38.77057, 47.82562], + [39.738278, 47.898937], + [39.89562, 48.23241], + [39.67465, 48.78382], + [40.080789, 49.30743], + [40.06904, 49.60105], + [38.594988, 49.926462], + [38.010631, 49.915662], + [37.39346, 50.383953], + [36.626168, 50.225591], + [35.356116, 50.577197], + [35.37791, 50.77394], + [35.022183, 51.207572], + [34.224816, 51.255993], + [34.141978, 51.566413], + [34.391731, 51.768882], + [33.7527, 52.335075], + [32.715761, 52.238465], + [32.412058, 52.288695], + [32.15944, 52.06125], + [31.785992, 52.101678], + [31.78597, 52.10168], + [31.540018, 52.742052], + [31.305201, 53.073996], + [31.49764, 53.16743], + [32.304519, 53.132726], + [32.693643, 53.351421], + [32.405599, 53.618045], + [31.731273, 53.794029], + [31.791424, 53.974639], + [31.384472, 54.157056], + [30.757534, 54.811771], + [30.971836, 55.081548], + [30.873909, 55.550976], + [29.896294, 55.789463], + [29.371572, 55.670091], + [29.229513, 55.918344], + [28.176709, 56.16913], + [27.855282, 56.759326], + [27.770016, 57.244258], + [27.288185, 57.474528], + [27.716686, 57.791899], + [27.42015, 58.72457], + [28.131699, 59.300825], + [27.98112, 59.47537], + [27.981127, 59.475373], + [29.1177, 60.02805], + [28.070002, 60.503519], + [28.07, 60.50352], + [30.211107, 61.780028], + [31.139991, 62.357693], + [31.516092, 62.867687], + [30.035872, 63.552814], + [30.444685, 64.204453], + [29.54443, 64.948672], + [30.21765, 65.80598], + [29.054589, 66.944286], + [29.977426, 67.698297], + [28.445944, 68.364613], + [28.59193, 69.064777], + [29.39955, 69.15692], + [31.101042, 69.558101], + [31.10108, 69.55811], + [32.13272, 69.90595], + [33.77547, 69.30142], + [36.51396, 69.06342], + [40.29234, 67.9324], + [41.05987, 67.45713], + [41.12595, 66.79158], + [40.01583, 66.26618], + [38.38295, 65.99953], + [33.91871, 66.75961], + [33.18444, 66.63253], + [34.81477, 65.90015], + [34.878574, 65.436213], + [34.94391, 64.41437], + [36.23129, 64.10945], + [37.01273, 63.84983], + [37.14197, 64.33471], + [36.539579, 64.76446], + [37.17604, 65.14322], + [39.59345, 64.52079], + [40.4356, 64.76446], + [39.7626, 65.49682], + [42.09309, 66.47623], + [43.01604, 66.41858], + [43.94975, 66.06908], + [44.53226, 66.75634], + [43.69839, 67.35245], + [44.18795, 67.95051], + [43.45282, 68.57079], + [46.25, 68.25], + [46.82134, 67.68997], + [45.55517, 67.56652], + [45.56202, 67.01005], + [46.34915, 66.66767], + [47.89416, 66.88455], + [48.13876, 67.52238], + [50.22766, 67.99867], + [53.71743, 68.85738], + [54.47171, 68.80815], + [53.48582, 68.20131], + [54.72628, 68.09702], + [55.44268, 68.43866], + [57.31702, 68.46628], + [58.802, 68.88082], + [59.94142, 68.27844], + [61.07784, 68.94069], + [60.03, 69.52], + [60.55, 69.85], + [63.504, 69.54739], + [64.888115, 69.234835], + [68.51216, 68.09233], + [69.18068, 68.61563], + [68.16444, 69.14436], + [68.13522, 69.35649], + [66.93008, 69.45461], + [67.25976, 69.92873], + [66.72492, 70.70889], + [66.69466, 71.02897], + [68.54006, 71.9345], + [69.19636, 72.84336], + [69.94, 73.04], + [72.58754, 72.77629], + [72.79603, 72.22006], + [71.84811, 71.40898], + [72.47011, 71.09019], + [72.79188, 70.39114], + [72.5647, 69.02085], + [73.66787, 68.4079], + [73.2387, 67.7404], + [71.28, 66.32], + [72.42301, 66.17267], + [72.82077, 66.53267], + [73.92099, 66.78946], + [74.18651, 67.28429], + [75.052, 67.76047], + [74.46926, 68.32899], + [74.93584, 68.98918], + [73.84236, 69.07146], + [73.60187, 69.62763], + [74.3998, 70.63175], + [73.1011, 71.44717], + [74.89082, 72.12119], + [74.65926, 72.83227], + [75.15801, 72.85497], + [75.68351, 72.30056], + [75.28898, 71.33556], + [76.35911, 71.15287], + [75.90313, 71.87401], + [77.57665, 72.26717], + [79.65202, 72.32011], + [81.5, 71.75], + [80.61071, 72.58285], + [80.51109, 73.6482], + [82.25, 73.85], + [84.65526, 73.80591], + [86.8223, 73.93688], + [86.00956, 74.45967], + [87.16682, 75.11643], + [88.31571, 75.14393], + [90.26, 75.64], + [92.90058, 75.77333], + [93.23421, 76.0472], + [95.86, 76.14], + [96.67821, 75.91548], + [98.92254, 76.44689], + [100.75967, 76.43028], + [101.03532, 76.86189], + [101.99084, 77.28754], + [104.3516, 77.69792], + [106.06664, 77.37389], + [104.705, 77.1274], + [106.97013, 76.97419], + [107.24, 76.48], + [108.1538, 76.72335], + [111.07726, 76.71], + [113.33151, 76.22224], + [114.13417, 75.84764], + [113.88539, 75.32779], + [112.77918, 75.03186], + [110.15125, 74.47673], + [109.4, 74.18], + [110.64, 74.04], + [112.11919, 73.78774], + [113.01954, 73.97693], + [113.52958, 73.33505], + [113.96881, 73.59488], + [115.56782, 73.75285], + [118.77633, 73.58772], + [119.02, 73.12], + [123.20066, 72.97122], + [123.25777, 73.73503], + [125.38, 73.56], + [126.97644, 73.56549], + [128.59126, 73.03871], + [129.05157, 72.39872], + [128.46, 71.98], + [129.71599, 71.19304], + [131.28858, 70.78699], + [132.2535, 71.8363], + [133.85766, 71.38642], + [135.56193, 71.65525], + [137.49755, 71.34763], + [138.23409, 71.62803], + [139.86983, 71.48783], + [139.14791, 72.41619], + [140.46817, 72.84941], + [149.5, 72.2], + [150.35118, 71.60643], + [152.9689, 70.84222], + [157.00688, 71.03141], + [158.99779, 70.86672], + [159.83031, 70.45324], + [159.70866, 69.72198], + [160.94053, 69.43728], + [162.27907, 69.64204], + [164.05248, 69.66823], + [165.94037, 69.47199], + [167.83567, 69.58269], + [169.57763, 68.6938], + [170.81688, 69.01363], + [170.0082, 69.65276], + [170.45345, 70.09703], + [173.64391, 69.81743], + [175.72403, 69.87725], + [178.6, 69.4], + [180, 68.963636], + [180, 64.979709], + [179.99281, 64.97433], + [178.7072, 64.53493], + [177.41128, 64.60821], + [178.313, 64.07593], + [178.90825, 63.25197], + [179.37034, 62.98262], + [179.48636, 62.56894], + [179.22825, 62.3041], + [177.3643, 62.5219], + [174.56929, 61.76915], + [173.68013, 61.65261], + [172.15, 60.95], + [170.6985, 60.33618], + [170.33085, 59.88177], + [168.90046, 60.57355], + [166.29498, 59.78855], + [165.84, 60.16], + [164.87674, 59.7316], + [163.53929, 59.86871], + [163.21711, 59.21101], + [162.01733, 58.24328], + [162.05297, 57.83912], + [163.19191, 57.61503], + [163.05794, 56.15924], + [162.12958, 56.12219], + [161.70146, 55.28568], + [162.11749, 54.85514], + [160.36877, 54.34433], + [160.02173, 53.20257], + [158.53094, 52.95868], + [158.23118, 51.94269], + [156.78979, 51.01105], + [156.42, 51.7], + [155.99182, 53.15895], + [155.43366, 55.38103], + [155.91442, 56.76792], + [156.75815, 57.3647], + [156.81035, 57.83204], + [158.36433, 58.05575], + [160.15064, 59.31477], + [161.87204, 60.343], + [163.66969, 61.1409], + [164.47355, 62.55061], + [163.25842, 62.46627], + [162.65791, 61.6425], + [160.12148, 60.54423], + [159.30232, 61.77396], + [156.72068, 61.43442], + [154.21806, 59.75818], + [155.04375, 59.14495], + [152.81185, 58.88385], + [151.26573, 58.78089], + [151.33815, 59.50396], + [149.78371, 59.65573], + [148.54481, 59.16448], + [145.48722, 59.33637], + [142.19782, 59.03998], + [138.95848, 57.08805], + [135.12619, 54.72959], + [136.70171, 54.60355], + [137.19342, 53.97732], + [138.1647, 53.75501], + [138.80463, 54.25455], + [139.90151, 54.18968], + [141.34531, 53.08957], + [141.37923, 52.23877], + [140.59742, 51.23967], + [140.51308, 50.04553], + [140.06193, 48.44671], + [138.55472, 46.99965], + [138.21971, 46.30795], + [136.86232, 45.1435], + [135.51535, 43.989], + [134.86939, 43.39821], + [133.53687, 42.81147], + [132.90627, 42.79849], + [132.27807, 43.28456], + [130.93587, 42.55274], + [130.780005, 42.22001], + [130.780004, 42.220008], + [130.78, 42.22], + [130.779992, 42.22001], + [130.64, 42.395], + [130.64, 42.395024], + [130.633866, 42.903015], + [131.144688, 42.92999], + [131.288555, 44.11152], + [131.02519, 44.96796], + [131.883454, 45.321162], + [133.09712, 45.14409], + [133.769644, 46.116927], + [134.11235, 47.21248], + [134.50081, 47.57845], + [135.026311, 48.47823], + [133.373596, 48.183442], + [132.50669, 47.78896], + [130.98726, 47.79013], + [130.582293, 48.729687], + [129.397818, 49.4406], + [127.6574, 49.76027], + [127.287456, 50.739797], + [126.939157, 51.353894], + [126.564399, 51.784255], + [125.946349, 52.792799], + [125.068211, 53.161045], + [123.57147, 53.4588], + [122.245748, 53.431726], + [121.003085, 53.251401], + [120.177089, 52.753886], + [120.725789, 52.516226], + [120.7382, 51.96411], + [120.18208, 51.64355], + [119.27939, 50.58292], + [119.288461, 50.142883], + [117.879244, 49.510983], + [116.678801, 49.888531], + [115.485695, 49.805177], + [114.96211, 50.140247], + [114.362456, 50.248303], + [112.89774, 49.543565], + [111.581231, 49.377968], + [110.662011, 49.130128], + [109.402449, 49.292961], + [108.475167, 49.282548], + [107.868176, 49.793705], + [106.888804, 50.274296], + [105.886591, 50.406019], + [104.62158, 50.27532], + [103.676545, 50.089966], + [102.25589, 50.51056], + [102.06521, 51.25991], + [100.88948, 51.516856], + [99.981732, 51.634006], + [98.861491, 52.047366], + [97.82574, 51.010995], + [98.231762, 50.422401], + [97.25976, 49.72605], + [95.81402, 49.97746], + [94.815949, 50.013433], + [94.147566, 50.480537], + [93.10421, 50.49529], + [92.234712, 50.802171], + [90.713667, 50.331812], + [88.805567, 49.470521], + [87.751264, 49.297198], + [87.35997, 49.214981], + [86.829357, 49.826675], + [85.54127, 49.692859], + [85.11556, 50.117303], + [84.416377, 50.3114], + [83.935115, 50.889246], + [83.383004, 51.069183], + [81.945986, 50.812196], + [80.568447, 51.388336], + [80.03556, 50.864751], + [77.800916, 53.404415], + [76.525179, 54.177003], + [76.8911, 54.490524], + [74.38482, 53.54685], + [73.425679, 53.48981], + [73.508516, 54.035617], + [72.22415, 54.376655], + [71.180131, 54.133285], + [70.865267, 55.169734], + [69.068167, 55.38525], + [68.1691, 54.970392], + [65.66687, 54.60125], + [65.178534, 54.354228], + [61.4366, 54.00625], + [60.978066, 53.664993], + [61.699986, 52.979996], + [60.739993, 52.719986], + [60.927269, 52.447548], + [59.967534, 51.96042], + [61.588003, 51.272659], + [61.337424, 50.79907], + [59.932807, 50.842194], + [59.642282, 50.545442], + [58.36332, 51.06364], + [56.77798, 51.04355], + [55.71694, 50.62171], + [54.532878, 51.02624], + [52.328724, 51.718652], + [50.766648, 51.692762], + [48.702382, 50.605128], + [48.577841, 49.87476], + [47.54948, 50.454698], + [46.751596, 49.356006], + [47.043672, 49.152039], + [46.466446, 48.394152], + [47.31524, 47.71585], + [48.05725, 47.74377], + [48.694734, 47.075628], + [48.59325, 46.56104], + [49.10116, 46.39933], + [48.64541, 45.80629], + [47.67591, 45.64149], + [46.68201, 44.6092], + [47.59094, 43.66016], + [47.49252, 42.98658], + [48.58437, 41.80888], + [48.584353, 41.808869] + ] + ], + [ + [ + [21.268449, 55.190482], + [22.315724, 55.015299], + [22.757764, 54.856574], + [22.651052, 54.582741], + [22.731099, 54.327537], + [20.892245, 54.312525], + [19.66064, 54.426084], + [19.888481, 54.86616], + [21.268449, 55.190482] + ] + ], + [ + [ + [33.435988, 45.971917], + [33.699462, 46.219573], + [34.410402, 46.005162], + [34.732017, 45.965666], + [34.861792, 45.768182], + [35.012659, 45.737725], + [35.020788, 45.651219], + [35.510009, 45.409993], + [36.529998, 45.46999], + [36.334713, 45.113216], + [35.239999, 44.939996], + [33.882511, 44.361479], + [33.326421, 44.564877], + [33.546924, 45.034771], + [32.454174, 45.327466], + [32.630804, 45.519186], + [33.588162, 45.851569], + [33.435988, 45.971917] + ] + ], + [ + [ + [143.648007, 50.7476], + [144.654148, 48.976391], + [143.173928, 49.306551], + [142.558668, 47.861575], + [143.533492, 46.836728], + [143.505277, 46.137908], + [142.747701, 46.740765], + [142.09203, 45.966755], + [141.906925, 46.805929], + [142.018443, 47.780133], + [141.904445, 48.859189], + [142.1358, 49.615163], + [142.179983, 50.952342], + [141.594076, 51.935435], + [141.682546, 53.301966], + [142.606934, 53.762145], + [142.209749, 54.225476], + [142.654786, 54.365881], + [142.914616, 53.704578], + [143.260848, 52.74076], + [143.235268, 51.75666], + [143.648007, 50.7476] + ] + ], + [ + [ + [-175.01425, 66.58435], + [-174.33983, 66.33556], + [-174.57182, 67.06219], + [-171.85731, 66.91308], + [-169.89958, 65.97724], + [-170.89107, 65.54139], + [-172.53025, 65.43791], + [-172.555, 64.46079], + [-172.95533, 64.25269], + [-173.89184, 64.2826], + [-174.65392, 64.63125], + [-175.98353, 64.92288], + [-176.20716, 65.35667], + [-177.22266, 65.52024], + [-178.35993, 65.39052], + [-178.90332, 65.74044], + [-178.68611, 66.11211], + [-179.88377, 65.87456], + [-179.43268, 65.40411], + [-180, 64.979709], + [-180, 68.963636], + [-177.55, 68.2], + [-174.92825, 67.20589], + [-175.01425, 66.58435] + ] + ], + [ + [ + [180, 70.832199], + [178.903425, 70.78114], + [178.7253, 71.0988], + [180, 71.515714], + [180, 70.832199] + ] + ], + [ + [ + [-178.69378, 70.89302], + [-180, 70.832199], + [-180, 71.515714], + [-179.871875, 71.55762], + [-179.02433, 71.55553], + [-177.577945, 71.26948], + [-177.663575, 71.13277], + [-178.69378, 70.89302] + ] + ], + [ + [ + [143.60385, 73.21244], + [142.08763, 73.20544], + [140.038155, 73.31692], + [139.86312, 73.36983], + [140.81171, 73.76506], + [142.06207, 73.85758], + [143.48283, 73.47525], + [143.60385, 73.21244] + ] + ], + [ + [ + [150.73167, 75.08406], + [149.575925, 74.68892], + [147.977465, 74.778355], + [146.11919, 75.17298], + [146.358485, 75.49682], + [148.22223, 75.345845], + [150.73167, 75.08406] + ] + ], + [ + [ + [145.086285, 75.562625], + [144.3, 74.82], + [140.61381, 74.84768], + [138.95544, 74.61148], + [136.97439, 75.26167], + [137.51176, 75.94917], + [138.831075, 76.13676], + [141.471615, 76.09289], + [145.086285, 75.562625] + ] + ], + [ + [ + [57.535693, 70.720464], + [56.944979, 70.632743], + [53.677375, 70.762658], + [53.412017, 71.206662], + [51.601895, 71.474759], + [51.455754, 72.014881], + [52.478275, 72.229442], + [52.444169, 72.774731], + [54.427614, 73.627548], + [53.50829, 73.749814], + [55.902459, 74.627486], + [55.631933, 75.081412], + [57.868644, 75.60939], + [61.170044, 76.251883], + [64.498368, 76.439055], + [66.210977, 76.809782], + [68.15706, 76.939697], + [68.852211, 76.544811], + [68.180573, 76.233642], + [64.637326, 75.737755], + [61.583508, 75.260885], + [58.477082, 74.309056], + [56.986786, 73.333044], + [55.419336, 72.371268], + [55.622838, 71.540595], + [57.535693, 70.720464] + ] + ], + [ + [ + [105.07547, 78.30689], + [99.43814, 77.921], + [101.2649, 79.23399], + [102.08635, 79.34641], + [102.837815, 79.28129], + [105.37243, 78.71334], + [105.07547, 78.30689] + ] + ], + [ + [ + [51.136187, 80.54728], + [49.793685, 80.415428], + [48.894411, 80.339567], + [48.754937, 80.175468], + [47.586119, 80.010181], + [46.502826, 80.247247], + [47.072455, 80.559424], + [44.846958, 80.58981], + [46.799139, 80.771918], + [48.318477, 80.78401], + [48.522806, 80.514569], + [49.09719, 80.753986], + [50.039768, 80.918885], + [51.522933, 80.699726], + [51.136187, 80.54728] + ] + ], + [ + [ + [99.93976, 78.88094], + [97.75794, 78.7562], + [94.97259, 79.044745], + [93.31288, 79.4265], + [92.5454, 80.14379], + [91.18107, 80.34146], + [93.77766, 81.0246], + [95.940895, 81.2504], + [97.88385, 80.746975], + [100.186655, 79.780135], + [99.93976, 78.88094] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Rwanda", + "SOV_A3": "RWA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Rwanda", + "ADM0_A3": "RWA", + "GEOU_DIF": 0, + "GEOUNIT": "Rwanda", + "GU_A3": "RWA", + "SU_DIF": 0, + "SUBUNIT": "Rwanda", + "SU_A3": "RWA", + "BRK_DIFF": 0, + "NAME": "Rwanda", + "NAME_LONG": "Rwanda", + "BRK_A3": "RWA", + "BRK_NAME": "Rwanda", + "BRK_GROUP": null, + "ABBREV": "Rwa.", + "POSTAL": "RW", + "FORMAL_EN": "Republic of Rwanda", + "FORMAL_FR": null, + "NAME_CIAWF": "Rwanda", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Rwanda", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 2, + "MAPCOLOR9": 3, + "MAPCOLOR13": 10, + "POP_EST": 11901484, + "POP_RANK": 14, + "GDP_MD_EST": 21970, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "RW", + "ISO_A2": "RW", + "ISO_A3": "RWA", + "ISO_A3_EH": "RWA", + "ISO_N3": "646", + "UN_A3": "646", + "WB_A2": "RW", + "WB_A3": "RWA", + "WOE_ID": 23424937, + "WOE_ID_EH": 23424937, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "RWA", + "ADM0_A3_US": "RWA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [29.024926, -2.917858, 30.816135, -1.134659], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [30.469674, -2.413855], + [29.938359, -2.348487], + [29.632176, -2.917858], + [29.024926, -2.839258], + [29.117479, -2.292211], + [29.254835, -2.21511], + [29.291887, -1.620056], + [29.579466, -1.341313], + [29.821519, -1.443322], + [30.419105, -1.134659], + [30.816135, -1.698914], + [30.758309, -2.28725], + [30.46967, -2.41383], + [30.469674, -2.413855] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 7, + "SOVEREIGNT": "Western Sahara", + "SOV_A3": "SAH", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Indeterminate", + "ADMIN": "Western Sahara", + "ADM0_A3": "SAH", + "GEOU_DIF": 0, + "GEOUNIT": "Western Sahara", + "GU_A3": "SAH", + "SU_DIF": 0, + "SUBUNIT": "Western Sahara", + "SU_A3": "SAH", + "BRK_DIFF": 1, + "NAME": "W. Sahara", + "NAME_LONG": "Western Sahara", + "BRK_A3": "B28", + "BRK_NAME": "W. Sahara", + "BRK_GROUP": null, + "ABBREV": "W. Sah.", + "POSTAL": "WS", + "FORMAL_EN": "Sahrawi Arab Democratic Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Western Sahara", + "NOTE_ADM0": "Self admin.", + "NOTE_BRK": "Self admin.; Claimed by Morocco", + "NAME_SORT": "Western Sahara", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 7, + "MAPCOLOR9": 4, + "MAPCOLOR13": 4, + "POP_EST": 603253, + "POP_RANK": 11, + "GDP_MD_EST": 906.5, + "POP_YEAR": 2017, + "LASTCENSUS": -99, + "GDP_YEAR": 2007, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "WI", + "ISO_A2": "EH", + "ISO_A3": "ESH", + "ISO_A3_EH": "ESH", + "ISO_N3": "732", + "UN_A3": "732", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": 23424990, + "WOE_ID_EH": 23424990, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "MAR", + "ADM0_A3_US": "SAH", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 9, + "LONG_LEN": 14, + "ABBREV_LEN": 7, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 4.7, + "MIN_LABEL": 6, + "MAX_LABEL": 11 + }, + "bbox": [-17.063423, 20.999752, -8.665124, 27.656426], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-8.66559, 27.656426], + [-8.665124, 27.589479], + [-8.6844, 27.395744], + [-8.687294, 25.881056], + [-11.969419, 25.933353], + [-11.937224, 23.374594], + [-12.874222, 23.284832], + [-13.118754, 22.77122], + [-12.929102, 21.327071], + [-16.845194, 21.333323], + [-17.063423, 20.999752], + [-17.020428, 21.42231], + [-17.002962, 21.420734], + [-14.750955, 21.5006], + [-14.630833, 21.86094], + [-14.221168, 22.310163], + [-13.89111, 23.691009], + [-12.500963, 24.770116], + [-12.030759, 26.030866], + [-11.71822, 26.104092], + [-11.392555, 26.883424], + [-10.551263, 26.990808], + [-10.189424, 26.860945], + [-9.735343, 26.860945], + [-9.413037, 27.088476], + [-8.794884, 27.120696], + [-8.817828, 27.656426], + [-8.66559, 27.656426] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Saudi Arabia", + "SOV_A3": "SAU", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Saudi Arabia", + "ADM0_A3": "SAU", + "GEOU_DIF": 0, + "GEOUNIT": "Saudi Arabia", + "GU_A3": "SAU", + "SU_DIF": 0, + "SUBUNIT": "Saudi Arabia", + "SU_A3": "SAU", + "BRK_DIFF": 0, + "NAME": "Saudi Arabia", + "NAME_LONG": "Saudi Arabia", + "BRK_A3": "SAU", + "BRK_NAME": "Saudi Arabia", + "BRK_GROUP": null, + "ABBREV": "Saud.", + "POSTAL": "SA", + "FORMAL_EN": "Kingdom of Saudi Arabia", + "FORMAL_FR": null, + "NAME_CIAWF": "Saudi Arabia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Saudi Arabia", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 1, + "MAPCOLOR9": 6, + "MAPCOLOR13": 7, + "POP_EST": 28571770, + "POP_RANK": 15, + "GDP_MD_EST": 1731000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "SA", + "ISO_A2": "SA", + "ISO_A3": "SAU", + "ISO_A3_EH": "SAU", + "ISO_N3": "682", + "UN_A3": "682", + "WB_A2": "SA", + "WB_A3": "SAU", + "WOE_ID": 23424938, + "WOE_ID_EH": 23424938, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SAU", + "ADM0_A3_US": "SAU", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 12, + "LONG_LEN": 12, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2.7, + "MAX_LABEL": 7 + }, + "bbox": [34.632336, 16.347891, 55.666659, 32.161009], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.579519, 24.245497], + [51.617708, 24.014219], + [52.000733, 23.001154], + [55.006803, 22.496948], + [55.208341, 22.70833], + [55.666659, 22.000001], + [54.999982, 19.999994], + [52.00001, 19.000003], + [49.116672, 18.616668], + [48.183344, 18.166669], + [47.466695, 17.116682], + [47.000005, 16.949999], + [46.749994, 17.283338], + [46.366659, 17.233315], + [45.399999, 17.333335], + [45.216651, 17.433329], + [44.062613, 17.410359], + [43.791519, 17.319977], + [43.380794, 17.579987], + [43.115798, 17.08844], + [43.218375, 16.66689], + [42.779332, 16.347891], + [42.649573, 16.774635], + [42.347989, 17.075806], + [42.270888, 17.474722], + [41.754382, 17.833046], + [41.221391, 18.6716], + [40.939341, 19.486485], + [40.247652, 20.174635], + [39.801685, 20.338862], + [39.139399, 21.291905], + [39.023696, 21.986875], + [39.066329, 22.579656], + [38.492772, 23.688451], + [38.02386, 24.078686], + [37.483635, 24.285495], + [37.154818, 24.858483], + [37.209491, 25.084542], + [36.931627, 25.602959], + [36.639604, 25.826228], + [36.249137, 26.570136], + [35.640182, 27.37652], + [35.130187, 28.063352], + [34.632336, 28.058546], + [34.787779, 28.607427], + [34.83222, 28.957483], + [34.956037, 29.356555], + [36.068941, 29.197495], + [36.501214, 29.505254], + [36.740528, 29.865283], + [37.503582, 30.003776], + [37.66812, 30.338665], + [37.998849, 30.5085], + [37.002166, 31.508413], + [39.004886, 32.010217], + [39.195468, 32.161009], + [40.399994, 31.889992], + [41.889981, 31.190009], + [44.709499, 29.178891], + [46.568713, 29.099025], + [47.459822, 29.002519], + [47.708851, 28.526063], + [48.416094, 28.552004], + [48.807595, 27.689628], + [49.299554, 27.461218], + [49.470914, 27.109999], + [50.152422, 26.689663], + [50.212935, 26.277027], + [50.113303, 25.943972], + [50.239859, 25.60805], + [50.527387, 25.327808], + [50.660557, 24.999896], + [50.810108, 24.754743], + [51.112415, 24.556331], + [51.389608, 24.627386], + [51.579519, 24.245497] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Sudan", + "SOV_A3": "SDN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Sudan", + "ADM0_A3": "SDN", + "GEOU_DIF": 0, + "GEOUNIT": "Sudan", + "GU_A3": "SDN", + "SU_DIF": 0, + "SUBUNIT": "Sudan", + "SU_A3": "SDN", + "BRK_DIFF": 0, + "NAME": "Sudan", + "NAME_LONG": "Sudan", + "BRK_A3": "SDN", + "BRK_NAME": "Sudan", + "BRK_GROUP": null, + "ABBREV": "Sudan", + "POSTAL": "SD", + "FORMAL_EN": "Republic of the Sudan", + "FORMAL_FR": null, + "NAME_CIAWF": "Sudan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Sudan", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 6, + "MAPCOLOR9": 4, + "MAPCOLOR13": 1, + "POP_EST": 37345935, + "POP_RANK": 15, + "GDP_MD_EST": 176300, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "SU", + "ISO_A2": "SD", + "ISO_A3": "SDN", + "ISO_A3_EH": "SDN", + "ISO_N3": "729", + "UN_A3": "729", + "WB_A2": "SD", + "WB_A3": "SDN", + "WOE_ID": -90, + "WOE_ID_EH": 23424952, + "WOE_NOTE": "Almost all FLickr photos are in the north.", + "ADM0_A3_IS": "SDN", + "ADM0_A3_US": "SDN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [21.93681, 8.229188, 38.41009, 22], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [24.567369, 8.229188], + [23.805813, 8.666319], + [23.459013, 8.954286], + [23.394779, 9.265068], + [23.55725, 9.681218], + [23.554304, 10.089255], + [22.977544, 10.714463], + [22.864165, 11.142395], + [22.87622, 11.38461], + [22.50869, 11.67936], + [22.49762, 12.26024], + [22.28801, 12.64605], + [21.93681, 12.58818], + [22.03759, 12.95546], + [22.29658, 13.37232], + [22.18329, 13.78648], + [22.51202, 14.09318], + [22.30351, 14.32682], + [22.56795, 14.94429], + [23.02459, 15.68072], + [23.88689, 15.61084], + [23.83766, 19.58047], + [23.85, 20], + [25, 20.00304], + [25, 22], + [29.02, 22], + [32.9, 22], + [36.86623, 22], + [37.18872, 21.01885], + [36.96941, 20.83744], + [37.1147, 19.80796], + [37.48179, 18.61409], + [37.86276, 18.36786], + [38.41009, 17.998307], + [37.904, 17.42754], + [37.16747, 17.26314], + [36.85253, 16.95655], + [36.75389, 16.29186], + [36.32322, 14.82249], + [36.42951, 14.42211], + [36.27022, 13.56333], + [35.86363, 12.57828], + [35.26049, 12.08286], + [34.83163, 11.31896], + [34.73115, 10.91017], + [34.25745, 10.63009], + [33.96162, 9.58358], + [33.97498, 8.68456], + [33.963393, 9.464285], + [33.824963, 9.484061], + [33.842131, 9.981915], + [33.721959, 10.325262], + [33.206938, 10.720112], + [33.086766, 11.441141], + [33.206938, 12.179338], + [32.743419, 12.248008], + [32.67475, 12.024832], + [32.073892, 11.97333], + [32.314235, 11.681484], + [32.400072, 11.080626], + [31.850716, 10.531271], + [31.352862, 9.810241], + [30.837841, 9.707237], + [29.996639, 10.290927], + [29.618957, 10.084919], + [29.515953, 9.793074], + [29.000932, 9.604232], + [28.966597, 9.398224], + [27.97089, 9.398224], + [27.833551, 9.604232], + [27.112521, 9.638567], + [26.752006, 9.466893], + [26.477328, 9.55273], + [25.962307, 10.136421], + [25.790633, 10.411099], + [25.069604, 10.27376], + [24.794926, 9.810241], + [24.537415, 8.917538], + [24.194068, 8.728696], + [23.88698, 8.61973], + [24.567369, 8.229188] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "South Sudan", + "SOV_A3": "SDS", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "South Sudan", + "ADM0_A3": "SDS", + "GEOU_DIF": 0, + "GEOUNIT": "South Sudan", + "GU_A3": "SDS", + "SU_DIF": 0, + "SUBUNIT": "South Sudan", + "SU_A3": "SDS", + "BRK_DIFF": 0, + "NAME": "S. Sudan", + "NAME_LONG": "South Sudan", + "BRK_A3": "SDS", + "BRK_NAME": "S. Sudan", + "BRK_GROUP": null, + "ABBREV": "S. Sud.", + "POSTAL": "SS", + "FORMAL_EN": "Republic of South Sudan", + "FORMAL_FR": null, + "NAME_CIAWF": "South Sudan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "South Sudan", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 3, + "MAPCOLOR9": 3, + "MAPCOLOR13": 5, + "POP_EST": 13026129, + "POP_RANK": 14, + "GDP_MD_EST": 20880, + "POP_YEAR": 2017, + "LASTCENSUS": 2008, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "-99", + "ISO_A2": "SS", + "ISO_A3": "SSD", + "ISO_A3_EH": "SSD", + "ISO_N3": "728", + "UN_A3": "728", + "WB_A2": "SS", + "WB_A3": "SSD", + "WOE_ID": -99, + "WOE_ID_EH": -99, + "WOE_NOTE": "Includes states of 20069899, 20069897, 20069898, 20069901, 20069909, and 20069908 but maybe more?", + "ADM0_A3_IS": "SSD", + "ADM0_A3_US": "SDS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 8, + "LONG_LEN": 11, + "ABBREV_LEN": 7, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [23.88698, 3.509172, 35.298007, 12.248008], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [27.374226, 5.233944], + [27.213409, 5.550953], + [26.465909, 5.946717], + [26.213418, 6.546603], + [25.796648, 6.979316], + [25.124131, 7.500085], + [25.114932, 7.825104], + [24.567369, 8.229188], + [23.88698, 8.61973], + [24.194068, 8.728696], + [24.537415, 8.917538], + [24.794926, 9.810241], + [25.069604, 10.27376], + [25.790633, 10.411099], + [25.962307, 10.136421], + [26.477328, 9.55273], + [26.752006, 9.466893], + [27.112521, 9.638567], + [27.833551, 9.604232], + [27.97089, 9.398224], + [28.966597, 9.398224], + [29.000932, 9.604232], + [29.515953, 9.793074], + [29.618957, 10.084919], + [29.996639, 10.290927], + [30.837841, 9.707237], + [31.352862, 9.810241], + [31.850716, 10.531271], + [32.400072, 11.080626], + [32.314235, 11.681484], + [32.073892, 11.97333], + [32.67475, 12.024832], + [32.743419, 12.248008], + [33.206938, 12.179338], + [33.086766, 11.441141], + [33.206938, 10.720112], + [33.721959, 10.325262], + [33.842131, 9.981915], + [33.824963, 9.484061], + [33.963393, 9.464285], + [33.97498, 8.68456], + [33.8255, 8.37916], + [33.2948, 8.35458], + [32.95418, 7.78497], + [33.56829, 7.71334], + [34.0751, 7.22595], + [34.25032, 6.82607], + [34.70702, 6.59422], + [35.298007, 5.506], + [34.620196, 4.847123], + [34.005, 4.249885], + [33.39, 3.79], + [32.68642, 3.79232], + [31.88145, 3.55827], + [31.24556, 3.7819], + [30.833852, 3.509172], + [29.9535, 4.173699], + [29.715995, 4.600805], + [29.159078, 4.389267], + [28.696678, 4.455077], + [28.428994, 4.287155], + [27.979977, 4.408413], + [27.374226, 5.233944] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Senegal", + "SOV_A3": "SEN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Senegal", + "ADM0_A3": "SEN", + "GEOU_DIF": 0, + "GEOUNIT": "Senegal", + "GU_A3": "SEN", + "SU_DIF": 0, + "SUBUNIT": "Senegal", + "SU_A3": "SEN", + "BRK_DIFF": 0, + "NAME": "Senegal", + "NAME_LONG": "Senegal", + "BRK_A3": "SEN", + "BRK_NAME": "Senegal", + "BRK_GROUP": null, + "ABBREV": "Sen.", + "POSTAL": "SN", + "FORMAL_EN": "Republic of Senegal", + "FORMAL_FR": null, + "NAME_CIAWF": "Senegal", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Senegal", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 6, + "MAPCOLOR9": 5, + "MAPCOLOR13": 5, + "POP_EST": 14668522, + "POP_RANK": 14, + "GDP_MD_EST": 39720, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "SG", + "ISO_A2": "SN", + "ISO_A3": "SEN", + "ISO_A3_EH": "SEN", + "ISO_N3": "686", + "UN_A3": "686", + "WB_A2": "SN", + "WB_A3": "SEN", + "WOE_ID": 23424943, + "WOE_ID_EH": 23424943, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SEN", + "ADM0_A3_US": "SEN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-17.625043, 12.33209, -11.467899, 16.598264], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-11.513943, 12.442988], + [-11.658301, 12.386583], + [-12.203565, 12.465648], + [-12.278599, 12.35444], + [-12.499051, 12.33209], + [-13.217818, 12.575874], + [-13.700476, 12.586183], + [-15.548477, 12.62817], + [-15.816574, 12.515567], + [-16.147717, 12.547762], + [-16.677452, 12.384852], + [-16.841525, 13.151394], + [-15.931296, 13.130284], + [-15.691001, 13.270353], + [-15.511813, 13.27857], + [-15.141163, 13.509512], + [-14.712197, 13.298207], + [-14.277702, 13.280585], + [-13.844963, 13.505042], + [-14.046992, 13.794068], + [-14.376714, 13.62568], + [-14.687031, 13.630357], + [-15.081735, 13.876492], + [-15.39877, 13.860369], + [-15.624596, 13.623587], + [-16.713729, 13.594959], + [-17.126107, 14.373516], + [-17.625043, 14.729541], + [-17.185173, 14.919477], + [-16.700706, 15.621527], + [-16.463098, 16.135036], + [-16.12069, 16.455663], + [-15.623666, 16.369337], + [-15.135737, 16.587282], + [-14.577348, 16.598264], + [-14.099521, 16.304302], + [-13.435738, 16.039383], + [-12.830658, 15.303692], + [-12.17075, 14.616834], + [-12.124887, 13.994727], + [-11.927716, 13.422075], + [-11.553398, 13.141214], + [-11.467899, 12.754519], + [-11.513943, 12.442988] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Solomon Islands", + "SOV_A3": "SLB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Solomon Islands", + "ADM0_A3": "SLB", + "GEOU_DIF": 0, + "GEOUNIT": "Solomon Islands", + "GU_A3": "SLB", + "SU_DIF": 0, + "SUBUNIT": "Solomon Islands", + "SU_A3": "SLB", + "BRK_DIFF": 0, + "NAME": "Solomon Is.", + "NAME_LONG": "Solomon Islands", + "BRK_A3": "SLB", + "BRK_NAME": "Solomon Is.", + "BRK_GROUP": null, + "ABBREV": "S. Is.", + "POSTAL": "SB", + "FORMAL_EN": null, + "FORMAL_FR": null, + "NAME_CIAWF": "Solomon Islands", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Solomon Islands", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 6, + "POP_EST": 647581, + "POP_RANK": 11, + "GDP_MD_EST": 1198, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "BP", + "ISO_A2": "SB", + "ISO_A3": "SLB", + "ISO_A3_EH": "SLB", + "ISO_N3": "090", + "UN_A3": "090", + "WB_A2": "SB", + "WB_A3": "SLB", + "WOE_ID": 23424766, + "WOE_ID_EH": 23424766, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SLB", + "ADM0_A3_US": "SLB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Melanesia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 11, + "LONG_LEN": 15, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [156.491358, -10.826367, 162.398646, -6.599338], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [162.119025, -10.482719], + [162.398646, -10.826367], + [161.700032, -10.820011], + [161.319797, -10.204751], + [161.917383, -10.446701], + [162.119025, -10.482719] + ] + ], + [ + [ + [160.852229, -9.872937], + [160.462588, -9.89521], + [159.849447, -9.794027], + [159.640003, -9.63998], + [159.702945, -9.24295], + [160.362956, -9.400304], + [160.688518, -9.610162], + [160.852229, -9.872937] + ] + ], + [ + [ + [161.679982, -9.599982], + [161.529397, -9.784312], + [160.788253, -8.917543], + [160.579997, -8.320009], + [160.920028, -8.320009], + [161.280006, -9.120011], + [161.679982, -9.599982] + ] + ], + [ + [ + [159.875027, -8.33732], + [159.917402, -8.53829], + [159.133677, -8.114181], + [158.586114, -7.754824], + [158.21115, -7.421872], + [158.359978, -7.320018], + [158.820001, -7.560003], + [159.640003, -8.020027], + [159.875027, -8.33732] + ] + ], + [ + [ + [157.538426, -7.34782], + [157.33942, -7.404767], + [156.90203, -7.176874], + [156.491358, -6.765943], + [156.542828, -6.599338], + [157.14, -7.021638], + [157.538426, -7.34782] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Sierra Leone", + "SOV_A3": "SLE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Sierra Leone", + "ADM0_A3": "SLE", + "GEOU_DIF": 0, + "GEOUNIT": "Sierra Leone", + "GU_A3": "SLE", + "SU_DIF": 0, + "SUBUNIT": "Sierra Leone", + "SU_A3": "SLE", + "BRK_DIFF": 0, + "NAME": "Sierra Leone", + "NAME_LONG": "Sierra Leone", + "BRK_A3": "SLE", + "BRK_NAME": "Sierra Leone", + "BRK_GROUP": null, + "ABBREV": "S.L.", + "POSTAL": "SL", + "FORMAL_EN": "Republic of Sierra Leone", + "FORMAL_FR": null, + "NAME_CIAWF": "Sierra Leone", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Sierra Leone", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 1, + "MAPCOLOR13": 7, + "POP_EST": 6163195, + "POP_RANK": 13, + "GDP_MD_EST": 10640, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "SL", + "ISO_A2": "SL", + "ISO_A3": "SLE", + "ISO_A3_EH": "SLE", + "ISO_N3": "694", + "UN_A3": "694", + "WB_A2": "SL", + "WB_A3": "SLE", + "WOE_ID": 23424946, + "WOE_ID_EH": 23424946, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SLE", + "ADM0_A3_US": "SLE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 12, + "LONG_LEN": 12, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-13.24655, 6.785917, -10.230094, 10.046984], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-13.24655, 8.903049], + [-12.711958, 9.342712], + [-12.596719, 9.620188], + [-12.425929, 9.835834], + [-12.150338, 9.858572], + [-11.917277, 10.046984], + [-11.117481, 10.045873], + [-10.839152, 9.688246], + [-10.622395, 9.26791], + [-10.65477, 8.977178], + [-10.494315, 8.715541], + [-10.505477, 8.348896], + [-10.230094, 8.406206], + [-10.695595, 7.939464], + [-11.146704, 7.396706], + [-11.199802, 7.105846], + [-11.438779, 6.785917], + [-11.708195, 6.860098], + [-12.428099, 7.262942], + [-12.949049, 7.798646], + [-13.124025, 8.163946], + [-13.24655, 8.903049] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "El Salvador", + "SOV_A3": "SLV", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "El Salvador", + "ADM0_A3": "SLV", + "GEOU_DIF": 0, + "GEOUNIT": "El Salvador", + "GU_A3": "SLV", + "SU_DIF": 0, + "SUBUNIT": "El Salvador", + "SU_A3": "SLV", + "BRK_DIFF": 0, + "NAME": "El Salvador", + "NAME_LONG": "El Salvador", + "BRK_A3": "SLV", + "BRK_NAME": "El Salvador", + "BRK_GROUP": null, + "ABBREV": "El. S.", + "POSTAL": "SV", + "FORMAL_EN": "Republic of El Salvador", + "FORMAL_FR": null, + "NAME_CIAWF": "El Salvador", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "El Salvador", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 6, + "MAPCOLOR13": 8, + "POP_EST": 6172011, + "POP_RANK": 13, + "GDP_MD_EST": 54790, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "ES", + "ISO_A2": "SV", + "ISO_A3": "SLV", + "ISO_A3_EH": "SLV", + "ISO_N3": "222", + "UN_A3": "222", + "WB_A2": "SV", + "WB_A3": "SLV", + "WOE_ID": 23424807, + "WOE_ID_EH": 23424807, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SLV", + "ADM0_A3_US": "SLV", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Central America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [-90.095555, 13.149017, -87.723503, 14.424133], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-90.095555, 13.735338], + [-90.064678, 13.88197], + [-89.721934, 14.134228], + [-89.534219, 14.244816], + [-89.587343, 14.362586], + [-89.353326, 14.424133], + [-89.058512, 14.340029], + [-88.843073, 14.140507], + [-88.541231, 13.980155], + [-88.503998, 13.845486], + [-88.065343, 13.964626], + [-87.859515, 13.893312], + [-87.723503, 13.78505], + [-87.793111, 13.38448], + [-87.904112, 13.149017], + [-88.483302, 13.163951], + [-88.843228, 13.259734], + [-89.256743, 13.458533], + [-89.812394, 13.520622], + [-90.095555, 13.735338] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Somaliland", + "SOV_A3": "SOL", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Indeterminate", + "ADMIN": "Somaliland", + "ADM0_A3": "SOL", + "GEOU_DIF": 0, + "GEOUNIT": "Somaliland", + "GU_A3": "SOL", + "SU_DIF": 0, + "SUBUNIT": "Somaliland", + "SU_A3": "SOL", + "BRK_DIFF": 1, + "NAME": "Somaliland", + "NAME_LONG": "Somaliland", + "BRK_A3": "B30", + "BRK_NAME": "Somaliland", + "BRK_GROUP": null, + "ABBREV": "Solnd.", + "POSTAL": "SL", + "FORMAL_EN": "Republic of Somaliland", + "FORMAL_FR": null, + "NAME_CIAWF": null, + "NOTE_ADM0": "Self admin.", + "NOTE_BRK": "Self admin.; Claimed by Somalia", + "NAME_SORT": "Somaliland", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 6, + "MAPCOLOR9": 5, + "MAPCOLOR13": 2, + "POP_EST": 3500000, + "POP_RANK": 12, + "GDP_MD_EST": 12250, + "POP_YEAR": 2013, + "LASTCENSUS": -99, + "GDP_YEAR": 2013, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "-99", + "ISO_A2": "-99", + "ISO_A3": "-99", + "ISO_A3_EH": "-99", + "ISO_N3": "-99", + "UN_A3": "-099", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": -99, + "WOE_ID_EH": -99, + "WOE_NOTE": "Includes old states of 2347021, 2347020, 2347017 and portion of 2347016.", + "ADM0_A3_IS": "SOM", + "ADM0_A3_US": "SOM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 4, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9 + }, + "bbox": [42.55876, 7.99688, 48.948206, 11.46204], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [42.776852, 10.926879], + [43.145305, 11.46204], + [43.47066, 11.27771], + [43.666668, 10.864169], + [44.117804, 10.445538], + [44.614259, 10.442205], + [45.556941, 10.698029], + [46.645401, 10.816549], + [47.525658, 11.127228], + [48.021596, 11.193064], + [48.378784, 11.375482], + [48.948206, 11.410622], + [48.948205, 11.410617], + [48.948205, 11.410617], + [48.942005, 11.394266], + [48.938491, 10.982327], + [48.938233, 9.9735], + [48.93813, 9.451749], + [48.486736, 8.837626], + [47.78942, 8.003], + [46.94834, 7.99688], + [43.67875, 9.18358], + [43.29699, 9.54048], + [42.92812, 10.02194], + [42.55876, 10.57258], + [42.776852, 10.926879] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Somalia", + "SOV_A3": "SOM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Somalia", + "ADM0_A3": "SOM", + "GEOU_DIF": 0, + "GEOUNIT": "Somalia", + "GU_A3": "SOM", + "SU_DIF": 0, + "SUBUNIT": "Somalia", + "SU_A3": "SOM", + "BRK_DIFF": 0, + "NAME": "Somalia", + "NAME_LONG": "Somalia", + "BRK_A3": "SOM", + "BRK_NAME": "Somalia", + "BRK_GROUP": null, + "ABBREV": "Som.", + "POSTAL": "SO", + "FORMAL_EN": "Federal Republic of Somalia", + "FORMAL_FR": null, + "NAME_CIAWF": "Somalia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Somalia", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 8, + "MAPCOLOR9": 6, + "MAPCOLOR13": 7, + "POP_EST": 7531386, + "POP_RANK": 13, + "GDP_MD_EST": 4719, + "POP_YEAR": 2017, + "LASTCENSUS": 1987, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "SO", + "ISO_A2": "SO", + "ISO_A3": "SOM", + "ISO_A3_EH": "SOM", + "ISO_N3": "706", + "UN_A3": "706", + "WB_A2": "SO", + "WB_A3": "SOM", + "WOE_ID": -90, + "WOE_ID_EH": 23424949, + "WOE_NOTE": "Includes Somaliland (2347021, 2347020, 2347017 and portion of 2347016)", + "ADM0_A3_IS": "SOM", + "ADM0_A3_US": "SOM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [40.98105, -1.68325, 51.13387, 12.02464], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [41.855083, 3.918912], + [42.12861, 4.23413], + [42.76967, 4.25259], + [43.66087, 4.95755], + [44.9636, 5.00162], + [47.78942, 8.003], + [48.486736, 8.837626], + [48.93813, 9.451749], + [48.938233, 9.9735], + [48.938491, 10.982327], + [48.942005, 11.394266], + [48.948205, 11.410617], + [48.948205, 11.410617], + [49.26776, 11.43033], + [49.72862, 11.5789], + [50.25878, 11.67957], + [50.73202, 12.0219], + [51.1112, 12.02464], + [51.13387, 11.74815], + [51.04153, 11.16651], + [51.04531, 10.6409], + [50.83418, 10.27972], + [50.55239, 9.19874], + [50.07092, 8.08173], + [49.4527, 6.80466], + [48.59455, 5.33911], + [47.74079, 4.2194], + [46.56476, 2.85529], + [45.56399, 2.04576], + [44.06815, 1.05283], + [43.13597, 0.2922], + [42.04157, -0.91916], + [41.81095, -1.44647], + [41.58513, -1.68325], + [40.993, -0.85829], + [40.98105, 2.78452], + [41.855083, 3.918912] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Republic of Serbia", + "SOV_A3": "SRB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Republic of Serbia", + "ADM0_A3": "SRB", + "GEOU_DIF": 0, + "GEOUNIT": "Republic of Serbia", + "GU_A3": "SRB", + "SU_DIF": 0, + "SUBUNIT": "Republic of Serbia", + "SU_A3": "SRB", + "BRK_DIFF": 0, + "NAME": "Serbia", + "NAME_LONG": "Serbia", + "BRK_A3": "SRB", + "BRK_NAME": "Serbia", + "BRK_GROUP": null, + "ABBREV": "Serb.", + "POSTAL": "RS", + "FORMAL_EN": "Republic of Serbia", + "FORMAL_FR": null, + "NAME_CIAWF": "Serbia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Serbia", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 3, + "MAPCOLOR9": 2, + "MAPCOLOR13": 10, + "POP_EST": 7111024, + "POP_RANK": 13, + "GDP_MD_EST": 101800, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "RI", + "ISO_A2": "RS", + "ISO_A3": "SRB", + "ISO_A3_EH": "SRB", + "ISO_N3": "688", + "UN_A3": "688", + "WB_A2": "YF", + "WB_A3": "SRB", + "WOE_ID": -90, + "WOE_ID_EH": 20069818, + "WOE_NOTE": "Expired WOE also contains Kosovo.", + "ADM0_A3_IS": "SRB", + "ADM0_A3_US": "SRB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 7 + }, + "bbox": [18.829825, 42.245224, 22.986019, 46.17173], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [22.65715, 44.234923], + [22.410446, 44.008063], + [22.500157, 43.642814], + [22.986019, 43.211161], + [22.604801, 42.898519], + [22.436595, 42.580321], + [22.545012, 42.461362], + [22.380526, 42.32026], + [21.91708, 42.30364], + [21.576636, 42.245224], + [21.54332, 42.32025], + [21.66292, 42.43922], + [21.77505, 42.6827], + [21.63302, 42.67717], + [21.43866, 42.86255], + [21.27421, 42.90959], + [21.143395, 43.068685], + [20.95651, 43.13094], + [20.81448, 43.27205], + [20.63508, 43.21671], + [20.49679, 42.88469], + [20.25758, 42.81275], + [20.3398, 42.89852], + [19.95857, 43.10604], + [19.63, 43.21378], + [19.48389, 43.35229], + [19.21852, 43.52384], + [19.454, 43.5681], + [19.59976, 44.03847], + [19.11761, 44.42307], + [19.36803, 44.863], + [19.00548, 44.86023], + [19.005485, 44.860234], + [19.390476, 45.236516], + [19.072769, 45.521511], + [18.829825, 45.908872], + [18.829838, 45.908878], + [19.596045, 46.17173], + [20.220192, 46.127469], + [20.762175, 45.734573], + [20.874313, 45.416375], + [21.483526, 45.18117], + [21.562023, 44.768947], + [22.145088, 44.478422], + [22.459022, 44.702517], + [22.705726, 44.578003], + [22.474008, 44.409228], + [22.65715, 44.234923] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Suriname", + "SOV_A3": "SUR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Suriname", + "ADM0_A3": "SUR", + "GEOU_DIF": 0, + "GEOUNIT": "Suriname", + "GU_A3": "SUR", + "SU_DIF": 0, + "SUBUNIT": "Suriname", + "SU_A3": "SUR", + "BRK_DIFF": 0, + "NAME": "Suriname", + "NAME_LONG": "Suriname", + "BRK_A3": "SUR", + "BRK_NAME": "Suriname", + "BRK_GROUP": null, + "ABBREV": "Sur.", + "POSTAL": "SR", + "FORMAL_EN": "Republic of Suriname", + "FORMAL_FR": null, + "NAME_CIAWF": "Suriname", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Suriname", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 7, + "MAPCOLOR13": 6, + "POP_EST": 591919, + "POP_RANK": 11, + "GDP_MD_EST": 8547, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "NS", + "ISO_A2": "SR", + "ISO_A3": "SUR", + "ISO_A3_EH": "SUR", + "ISO_N3": "740", + "UN_A3": "740", + "WB_A2": "SR", + "WB_A3": "SUR", + "WOE_ID": 23424913, + "WOE_ID_EH": 23424913, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SUR", + "ADM0_A3_US": "SUR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [-58.044694, 1.817667, -53.958045, 6.025291], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-54.524754, 2.311849], + [-55.097587, 2.523748], + [-55.569755, 2.421506], + [-55.973322, 2.510364], + [-56.073342, 2.220795], + [-55.9056, 2.021996], + [-55.995698, 1.817667], + [-56.539386, 1.899523], + [-57.150098, 2.768927], + [-57.281433, 3.333492], + [-57.601569, 3.334655], + [-58.044694, 4.060864], + [-57.86021, 4.576801], + [-57.914289, 4.812626], + [-57.307246, 5.073567], + [-57.147436, 5.97315], + [-55.949318, 5.772878], + [-55.84178, 5.953125], + [-55.03325, 6.025291], + [-53.958045, 5.756548], + [-54.478633, 4.896756], + [-54.399542, 4.212611], + [-54.011504, 3.62257], + [-54.184284, 3.194172], + [-54.27123, 2.738748], + [-54.524754, 2.311849] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Slovakia", + "SOV_A3": "SVK", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Slovakia", + "ADM0_A3": "SVK", + "GEOU_DIF": 0, + "GEOUNIT": "Slovakia", + "GU_A3": "SVK", + "SU_DIF": 0, + "SUBUNIT": "Slovakia", + "SU_A3": "SVK", + "BRK_DIFF": 0, + "NAME": "Slovakia", + "NAME_LONG": "Slovakia", + "BRK_A3": "SVK", + "BRK_NAME": "Slovakia", + "BRK_GROUP": null, + "ABBREV": "Svk.", + "POSTAL": "SK", + "FORMAL_EN": "Slovak Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Slovakia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Slovak Republic", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 4, + "MAPCOLOR9": 4, + "MAPCOLOR13": 9, + "POP_EST": 5445829, + "POP_RANK": 13, + "GDP_MD_EST": 168800, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "LO", + "ISO_A2": "SK", + "ISO_A3": "SVK", + "ISO_A3_EH": "SVK", + "ISO_N3": "703", + "UN_A3": "703", + "WB_A2": "SK", + "WB_A3": "SVK", + "WOE_ID": 23424877, + "WOE_ID_EH": 23424877, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SVK", + "ADM0_A3_US": "SVK", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [16.879983, 47.758429, 22.558138, 49.571574], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [16.979667, 48.123497], + [16.879983, 48.470013], + [16.960288, 48.596982], + [17.101985, 48.816969], + [17.545007, 48.800019], + [17.886485, 48.903475], + [17.913512, 48.996493], + [18.104973, 49.043983], + [18.170498, 49.271515], + [18.399994, 49.315001], + [18.554971, 49.495015], + [18.853144, 49.49623], + [18.909575, 49.435846], + [19.320713, 49.571574], + [19.825023, 49.217125], + [20.415839, 49.431453], + [20.887955, 49.328772], + [21.607808, 49.470107], + [22.558138, 49.085738], + [22.280842, 48.825392], + [22.085608, 48.422264], + [21.872236, 48.319971], + [20.801294, 48.623854], + [20.473562, 48.56285], + [20.239054, 48.327567], + [19.769471, 48.202691], + [19.661364, 48.266615], + [19.174365, 48.111379], + [18.777025, 48.081768], + [18.696513, 47.880954], + [17.857133, 47.758429], + [17.488473, 47.867466], + [16.979667, 48.123497] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Slovenia", + "SOV_A3": "SVN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Slovenia", + "ADM0_A3": "SVN", + "GEOU_DIF": 0, + "GEOUNIT": "Slovenia", + "GU_A3": "SVN", + "SU_DIF": 0, + "SUBUNIT": "Slovenia", + "SU_A3": "SVN", + "BRK_DIFF": 0, + "NAME": "Slovenia", + "NAME_LONG": "Slovenia", + "BRK_A3": "SVN", + "BRK_NAME": "Slovenia", + "BRK_GROUP": null, + "ABBREV": "Slo.", + "POSTAL": "SLO", + "FORMAL_EN": "Republic of Slovenia", + "FORMAL_FR": null, + "NAME_CIAWF": "Slovenia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Slovenia", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 3, + "MAPCOLOR9": 2, + "MAPCOLOR13": 12, + "POP_EST": 1972126, + "POP_RANK": 12, + "GDP_MD_EST": 68350, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "SI", + "ISO_A2": "SI", + "ISO_A3": "SVN", + "ISO_A3_EH": "SVN", + "ISO_N3": "705", + "UN_A3": "705", + "WB_A2": "SI", + "WB_A3": "SVN", + "WOE_ID": 23424945, + "WOE_ID_EH": 23424945, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SVN", + "ADM0_A3_US": "SVN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Southern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [13.69811, 45.452316, 16.564808, 46.852386], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [13.806475, 46.509306], + [14.632472, 46.431817], + [15.137092, 46.658703], + [16.011664, 46.683611], + [16.202298, 46.852386], + [16.370505, 46.841327], + [16.564808, 46.503751], + [15.768733, 46.238108], + [15.67153, 45.834154], + [15.323954, 45.731783], + [15.327675, 45.452316], + [14.935244, 45.471695], + [14.595109, 45.634941], + [14.411968, 45.466166], + [13.71506, 45.500324], + [13.93763, 45.591016], + [13.69811, 46.016778], + [13.806475, 46.509306] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Sweden", + "SOV_A3": "SWE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Sweden", + "ADM0_A3": "SWE", + "GEOU_DIF": 0, + "GEOUNIT": "Sweden", + "GU_A3": "SWE", + "SU_DIF": 0, + "SUBUNIT": "Sweden", + "SU_A3": "SWE", + "BRK_DIFF": 0, + "NAME": "Sweden", + "NAME_LONG": "Sweden", + "BRK_A3": "SWE", + "BRK_NAME": "Sweden", + "BRK_GROUP": null, + "ABBREV": "Swe.", + "POSTAL": "S", + "FORMAL_EN": "Kingdom of Sweden", + "FORMAL_FR": null, + "NAME_CIAWF": "Sweden", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Sweden", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 4, + "MAPCOLOR9": 2, + "MAPCOLOR13": 4, + "POP_EST": 9960487, + "POP_RANK": 13, + "GDP_MD_EST": 498100, + "POP_YEAR": 2017, + "LASTCENSUS": -99, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": -99, + "FIPS_10_": "SW", + "ISO_A2": "SE", + "ISO_A3": "SWE", + "ISO_A3_EH": "SWE", + "ISO_N3": "752", + "UN_A3": "752", + "WB_A2": "SE", + "WB_A3": "SWE", + "WOE_ID": 23424954, + "WOE_ID_EH": 23424954, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SWE", + "ADM0_A3_US": "SWE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Northern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [11.027369, 55.361737, 23.903379, 69.106247], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [20.645593, 69.106247], + [21.978535, 68.616846], + [23.539473, 67.936009], + [23.56588, 66.396051], + [23.903379, 66.006927], + [22.183173, 65.723741], + [21.213517, 65.026005], + [21.369631, 64.413588], + [19.778876, 63.609554], + [17.847779, 62.7494], + [17.119555, 61.341166], + [17.831346, 60.636583], + [18.787722, 60.081914], + [17.869225, 58.953766], + [16.829185, 58.719827], + [16.44771, 57.041118], + [15.879786, 56.104302], + [14.666681, 56.200885], + [14.100721, 55.407781], + [12.942911, 55.361737], + [12.625101, 56.30708], + [11.787942, 57.441817], + [11.027369, 58.856149], + [11.468272, 59.432393], + [12.300366, 60.117933], + [12.631147, 61.293572], + [11.992064, 61.800362], + [11.930569, 63.128318], + [12.579935, 64.066219], + [13.571916, 64.049114], + [13.919905, 64.445421], + [13.55569, 64.787028], + [15.108411, 66.193867], + [16.108712, 67.302456], + [16.768879, 68.013937], + [17.729182, 68.010552], + [17.993868, 68.567391], + [19.87856, 68.407194], + [20.025269, 69.065139], + [20.645593, 69.106247] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Swaziland", + "SOV_A3": "SWZ", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Swaziland", + "ADM0_A3": "SWZ", + "GEOU_DIF": 0, + "GEOUNIT": "Swaziland", + "GU_A3": "SWZ", + "SU_DIF": 0, + "SUBUNIT": "Swaziland", + "SU_A3": "SWZ", + "BRK_DIFF": 0, + "NAME": "Swaziland", + "NAME_LONG": "Swaziland", + "BRK_A3": "SWZ", + "BRK_NAME": "Swaziland", + "BRK_GROUP": null, + "ABBREV": "Swz.", + "POSTAL": "SW", + "FORMAL_EN": "Kingdom of Swaziland", + "FORMAL_FR": null, + "NAME_CIAWF": "Swaziland", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Swaziland", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 5, + "POP_EST": 1467152, + "POP_RANK": 12, + "GDP_MD_EST": 11060, + "POP_YEAR": 2017, + "LASTCENSUS": 2007, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "WZ", + "ISO_A2": "SZ", + "ISO_A3": "SWZ", + "ISO_A3_EH": "SWZ", + "ISO_N3": "748", + "UN_A3": "748", + "WB_A2": "SZ", + "WB_A3": "SWZ", + "WOE_ID": 23424993, + "WOE_ID_EH": 23424993, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SWZ", + "ADM0_A3_US": "SWZ", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Southern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [30.676609, -27.285879, 32.071665, -25.660191], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [31.837778, -25.843332], + [31.985779, -26.29178], + [32.071665, -26.73382], + [31.86806, -27.177927], + [31.282773, -27.285879], + [30.685962, -26.743845], + [30.676609, -26.398078], + [30.949667, -26.022649], + [31.04408, -25.731452], + [31.333158, -25.660191], + [31.837778, -25.843332] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Syria", + "SOV_A3": "SYR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Syria", + "ADM0_A3": "SYR", + "GEOU_DIF": 0, + "GEOUNIT": "Syria", + "GU_A3": "SYR", + "SU_DIF": 0, + "SUBUNIT": "Syria", + "SU_A3": "SYR", + "BRK_DIFF": 0, + "NAME": "Syria", + "NAME_LONG": "Syria", + "BRK_A3": "SYR", + "BRK_NAME": "Syria", + "BRK_GROUP": null, + "ABBREV": "Syria", + "POSTAL": "SYR", + "FORMAL_EN": "Syrian Arab Republic", + "FORMAL_FR": null, + "NAME_CIAWF": "Syria", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Syrian Arab Republic", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 6, + "POP_EST": 18028549, + "POP_RANK": 14, + "GDP_MD_EST": 50280, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2015, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "SY", + "ISO_A2": "SY", + "ISO_A3": "SYR", + "ISO_A3_EH": "SYR", + "ISO_N3": "760", + "UN_A3": "760", + "WB_A2": "SY", + "WB_A3": "SYR", + "WOE_ID": 23424956, + "WOE_ID_EH": 23424956, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "SYR", + "ADM0_A3_US": "SYR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [35.700798, 32.312938, 42.349591, 37.229873], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [42.349591, 37.229873], + [41.837064, 36.605854], + [41.289707, 36.358815], + [41.383965, 35.628317], + [41.006159, 34.419372], + [38.792341, 33.378686], + [36.834062, 32.312938], + [35.719918, 32.709192], + [35.700798, 32.716014], + [35.836397, 32.868123], + [35.821101, 33.277426], + [36.06646, 33.824912], + [36.61175, 34.201789], + [36.448194, 34.593935], + [35.998403, 34.644914], + [35.905023, 35.410009], + [36.149763, 35.821535], + [36.41755, 36.040617], + [36.685389, 36.259699], + [36.739494, 36.81752], + [37.066761, 36.623036], + [38.167727, 36.90121], + [38.699891, 36.712927], + [39.52258, 36.716054], + [40.673259, 37.091276], + [41.212089, 37.074352], + [42.349591, 37.229873] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Chad", + "SOV_A3": "TCD", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Chad", + "ADM0_A3": "TCD", + "GEOU_DIF": 0, + "GEOUNIT": "Chad", + "GU_A3": "TCD", + "SU_DIF": 0, + "SUBUNIT": "Chad", + "SU_A3": "TCD", + "BRK_DIFF": 0, + "NAME": "Chad", + "NAME_LONG": "Chad", + "BRK_A3": "TCD", + "BRK_NAME": "Chad", + "BRK_GROUP": null, + "ABBREV": "Chad", + "POSTAL": "TD", + "FORMAL_EN": "Republic of Chad", + "FORMAL_FR": null, + "NAME_CIAWF": "Chad", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Chad", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 1, + "MAPCOLOR9": 8, + "MAPCOLOR13": 6, + "POP_EST": 12075985, + "POP_RANK": 14, + "GDP_MD_EST": 30590, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "CD", + "ISO_A2": "TD", + "ISO_A3": "TCD", + "ISO_A3_EH": "TCD", + "ISO_N3": "148", + "UN_A3": "148", + "WB_A2": "TD", + "WB_A3": "TCD", + "WOE_ID": 23424777, + "WOE_ID_EH": 23424777, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TCD", + "ADM0_A3_US": "TCD", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Middle Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [13.540394, 7.421925, 23.88689, 23.40972], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [22.864165, 11.142395], + [22.231129, 10.971889], + [21.723822, 10.567056], + [21.000868, 9.475985], + [20.059685, 9.012706], + [19.094008, 9.074847], + [18.81201, 8.982915], + [18.911022, 8.630895], + [18.389555, 8.281304], + [17.96493, 7.890914], + [16.705988, 7.508328], + [16.456185, 7.734774], + [16.290562, 7.754307], + [16.106232, 7.497088], + [15.27946, 7.421925], + [15.436092, 7.692812], + [15.120866, 8.38215], + [14.979996, 8.796104], + [14.544467, 8.965861], + [13.954218, 9.549495], + [14.171466, 10.021378], + [14.627201, 9.920919], + [14.909354, 9.992129], + [15.467873, 9.982337], + [14.923565, 10.891325], + [14.960152, 11.555574], + [14.89336, 12.21905], + [14.495787, 12.859396], + [14.595781, 13.330427], + [13.954477, 13.353449], + [13.956699, 13.996691], + [13.540394, 14.367134], + [13.97217, 15.68437], + [15.247731, 16.627306], + [15.300441, 17.92795], + [15.685741, 19.95718], + [15.903247, 20.387619], + [15.487148, 20.730415], + [15.47106, 21.04845], + [15.096888, 21.308519], + [14.8513, 22.86295], + [15.86085, 23.40972], + [19.84926, 21.49509], + [23.83766, 19.58047], + [23.88689, 15.61084], + [23.02459, 15.68072], + [22.56795, 14.94429], + [22.30351, 14.32682], + [22.51202, 14.09318], + [22.18329, 13.78648], + [22.29658, 13.37232], + [22.03759, 12.95546], + [21.93681, 12.58818], + [22.28801, 12.64605], + [22.49762, 12.26024], + [22.50869, 11.67936], + [22.87622, 11.38461], + [22.864165, 11.142395] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 6, + "SOVEREIGNT": "Togo", + "SOV_A3": "TGO", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Togo", + "ADM0_A3": "TGO", + "GEOU_DIF": 0, + "GEOUNIT": "Togo", + "GU_A3": "TGO", + "SU_DIF": 0, + "SUBUNIT": "Togo", + "SU_A3": "TGO", + "BRK_DIFF": 0, + "NAME": "Togo", + "NAME_LONG": "Togo", + "BRK_A3": "TGO", + "BRK_NAME": "Togo", + "BRK_GROUP": null, + "ABBREV": "Togo", + "POSTAL": "TG", + "FORMAL_EN": "Togolese Republic", + "FORMAL_FR": "République Togolaise", + "NAME_CIAWF": "Togo", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Togo", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 1, + "MAPCOLOR9": 3, + "MAPCOLOR13": 5, + "POP_EST": 7965055, + "POP_RANK": 13, + "GDP_MD_EST": 11610, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "TO", + "ISO_A2": "TG", + "ISO_A3": "TGO", + "ISO_A3_EH": "TGO", + "ISO_N3": "768", + "UN_A3": "768", + "WB_A2": "TG", + "WB_A3": "TGO", + "WOE_ID": 23424965, + "WOE_ID_EH": 23424965, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TGO", + "ADM0_A3_US": "TGO", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Western Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 4, + "LONG_LEN": 4, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 5, + "MAX_LABEL": 10 + }, + "bbox": [-0.049785, 5.928837, 1.865241, 11.018682], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [0.899563, 10.997339], + [0.772336, 10.470808], + [1.077795, 10.175607], + [1.425061, 9.825395], + [1.463043, 9.334624], + [1.664478, 9.12859], + [1.618951, 6.832038], + [1.865241, 6.142158], + [1.060122, 5.928837], + [0.836931, 6.279979], + [0.570384, 6.914359], + [0.490957, 7.411744], + [0.712029, 8.312465], + [0.461192, 8.677223], + [0.365901, 9.465004], + [0.36758, 10.191213], + [-0.049785, 10.706918], + [0.023803, 11.018682], + [0.899563, 10.997339] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Thailand", + "SOV_A3": "THA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Thailand", + "ADM0_A3": "THA", + "GEOU_DIF": 0, + "GEOUNIT": "Thailand", + "GU_A3": "THA", + "SU_DIF": 0, + "SUBUNIT": "Thailand", + "SU_A3": "THA", + "BRK_DIFF": 0, + "NAME": "Thailand", + "NAME_LONG": "Thailand", + "BRK_A3": "THA", + "BRK_NAME": "Thailand", + "BRK_GROUP": null, + "ABBREV": "Thai.", + "POSTAL": "TH", + "FORMAL_EN": "Kingdom of Thailand", + "FORMAL_FR": null, + "NAME_CIAWF": "Thailand", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Thailand", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 6, + "MAPCOLOR9": 8, + "MAPCOLOR13": 1, + "POP_EST": 68414135, + "POP_RANK": 16, + "GDP_MD_EST": 1161000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "TH", + "ISO_A2": "TH", + "ISO_A3": "THA", + "ISO_A3_EH": "THA", + "ISO_N3": "764", + "UN_A3": "764", + "WB_A2": "TH", + "WB_A3": "THA", + "WOE_ID": 23424960, + "WOE_ID_EH": 23424960, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "THA", + "ADM0_A3_US": "THA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [97.375896, 5.691384, 105.589039, 20.41785], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [105.218777, 14.273212], + [104.281418, 14.416743], + [102.988422, 14.225721], + [102.348099, 13.394247], + [102.584932, 12.186595], + [101.687158, 12.64574], + [100.83181, 12.627085], + [100.978467, 13.412722], + [100.097797, 13.406856], + [100.018733, 12.307001], + [99.478921, 10.846367], + [99.153772, 9.963061], + [99.222399, 9.239255], + [99.873832, 9.207862], + [100.279647, 8.295153], + [100.459274, 7.429573], + [101.017328, 6.856869], + [101.623079, 6.740622], + [102.141187, 6.221636], + [101.814282, 5.810808], + [101.154219, 5.691384], + [101.075516, 6.204867], + [100.259596, 6.642825], + [100.085757, 6.464489], + [99.690691, 6.848213], + [99.519642, 7.343454], + [98.988253, 7.907993], + [98.503786, 8.382305], + [98.339662, 7.794512], + [98.150009, 8.350007], + [98.25915, 8.973923], + [98.553551, 9.93296], + [99.038121, 10.960546], + [99.587286, 11.892763], + [99.196354, 12.804748], + [99.212012, 13.269294], + [99.097755, 13.827503], + [98.430819, 14.622028], + [98.192074, 15.123703], + [98.537376, 15.308497], + [98.903348, 16.177824], + [98.493761, 16.837836], + [97.859123, 17.567946], + [97.375896, 18.445438], + [97.797783, 18.62708], + [98.253724, 19.708203], + [98.959676, 19.752981], + [99.543309, 20.186598], + [100.115988, 20.41785], + [100.548881, 20.109238], + [100.606294, 19.508344], + [101.282015, 19.462585], + [101.035931, 18.408928], + [101.059548, 17.512497], + [102.113592, 18.109102], + [102.413005, 17.932782], + [102.998706, 17.961695], + [103.200192, 18.309632], + [103.956477, 18.240954], + [104.716947, 17.428859], + [104.779321, 16.441865], + [105.589039, 15.570316], + [105.544338, 14.723934], + [105.218777, 14.273212] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Tajikistan", + "SOV_A3": "TJK", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Tajikistan", + "ADM0_A3": "TJK", + "GEOU_DIF": 0, + "GEOUNIT": "Tajikistan", + "GU_A3": "TJK", + "SU_DIF": 0, + "SUBUNIT": "Tajikistan", + "SU_A3": "TJK", + "BRK_DIFF": 0, + "NAME": "Tajikistan", + "NAME_LONG": "Tajikistan", + "BRK_A3": "TJK", + "BRK_NAME": "Tajikistan", + "BRK_GROUP": null, + "ABBREV": "Tjk.", + "POSTAL": "TJ", + "FORMAL_EN": "Republic of Tajikistan", + "FORMAL_FR": null, + "NAME_CIAWF": "Tajikistan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Tajikistan", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 5, + "POP_EST": 8468555, + "POP_RANK": 13, + "GDP_MD_EST": 25810, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "TI", + "ISO_A2": "TJ", + "ISO_A3": "TJK", + "ISO_A3_EH": "TJK", + "ISO_N3": "762", + "UN_A3": "762", + "WB_A2": "TJ", + "WB_A3": "TJK", + "WOE_ID": 23424961, + "WOE_ID_EH": 23424961, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TJK", + "ADM0_A3_US": "TJK", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Central Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [67.44222, 36.738171, 74.980002, 40.960213], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [74.980002, 37.41999], + [73.948696, 37.421566], + [73.260056, 37.495257], + [72.63689, 37.047558], + [72.193041, 36.948288], + [71.844638, 36.738171], + [71.448693, 37.065645], + [71.541918, 37.905774], + [71.239404, 37.953265], + [71.348131, 38.258905], + [70.806821, 38.486282], + [70.376304, 38.138396], + [70.270574, 37.735165], + [70.116578, 37.588223], + [69.518785, 37.608997], + [69.196273, 37.151144], + [68.859446, 37.344336], + [68.135562, 37.023115], + [67.83, 37.144994], + [68.392033, 38.157025], + [68.176025, 38.901553], + [67.44222, 39.140144], + [67.701429, 39.580478], + [68.536416, 39.533453], + [69.011633, 40.086158], + [69.329495, 40.727824], + [70.666622, 40.960213], + [70.45816, 40.496495], + [70.601407, 40.218527], + [71.014198, 40.244366], + [70.648019, 39.935754], + [69.55961, 40.103211], + [69.464887, 39.526683], + [70.549162, 39.604198], + [71.784694, 39.279463], + [73.675379, 39.431237], + [73.928852, 38.505815], + [74.257514, 38.606507], + [74.864816, 38.378846], + [74.829986, 37.990007], + [74.980002, 37.41999] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Turkmenistan", + "SOV_A3": "TKM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Turkmenistan", + "ADM0_A3": "TKM", + "GEOU_DIF": 0, + "GEOUNIT": "Turkmenistan", + "GU_A3": "TKM", + "SU_DIF": 0, + "SUBUNIT": "Turkmenistan", + "SU_A3": "TKM", + "BRK_DIFF": 0, + "NAME": "Turkmenistan", + "NAME_LONG": "Turkmenistan", + "BRK_A3": "TKM", + "BRK_NAME": "Turkmenistan", + "BRK_GROUP": null, + "ABBREV": "Turkm.", + "POSTAL": "TM", + "FORMAL_EN": "Turkmenistan", + "FORMAL_FR": null, + "NAME_CIAWF": "Turkmenistan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Turkmenistan", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 2, + "MAPCOLOR9": 1, + "MAPCOLOR13": 9, + "POP_EST": 5351277, + "POP_RANK": 13, + "GDP_MD_EST": 94720, + "POP_YEAR": 2017, + "LASTCENSUS": 1995, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "TX", + "ISO_A2": "TM", + "ISO_A3": "TKM", + "ISO_A3_EH": "TKM", + "ISO_N3": "795", + "UN_A3": "795", + "WB_A2": "TM", + "WB_A3": "TKM", + "WOE_ID": 23424972, + "WOE_ID_EH": 23424972, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TKM", + "ADM0_A3_US": "TKM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Central Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 12, + "LONG_LEN": 12, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [52.50246, 35.270664, 66.54615, 42.751551], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [66.518607, 37.362784], + [66.217385, 37.39379], + [65.745631, 37.661164], + [65.588948, 37.305217], + [64.746105, 37.111818], + [64.546479, 36.312073], + [63.982896, 36.007957], + [63.193538, 35.857166], + [62.984662, 35.404041], + [62.230651, 35.270664], + [61.210817, 35.650072], + [61.123071, 36.491597], + [60.377638, 36.527383], + [59.234762, 37.412988], + [58.436154, 37.522309], + [57.330434, 38.029229], + [56.619366, 38.121394], + [56.180375, 37.935127], + [55.511578, 37.964117], + [54.800304, 37.392421], + [53.921598, 37.198918], + [53.735511, 37.906136], + [53.880929, 38.952093], + [53.101028, 39.290574], + [53.357808, 39.975286], + [52.693973, 40.033629], + [52.915251, 40.876523], + [53.858139, 40.631034], + [54.736845, 40.951015], + [54.008311, 41.551211], + [53.721713, 42.123191], + [52.91675, 41.868117], + [52.814689, 41.135371], + [52.50246, 41.783316], + [52.944293, 42.116034], + [54.079418, 42.324109], + [54.755345, 42.043971], + [55.455251, 41.259859], + [55.968191, 41.308642], + [57.096391, 41.32231], + [56.932215, 41.826026], + [57.78653, 42.170553], + [58.629011, 42.751551], + [59.976422, 42.223082], + [60.083341, 41.425146], + [60.465953, 41.220327], + [61.547179, 41.26637], + [61.882714, 41.084857], + [62.37426, 40.053886], + [63.518015, 39.363257], + [64.170223, 38.892407], + [65.215999, 38.402695], + [66.54615, 37.974685], + [66.518607, 37.362784] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "East Timor", + "SOV_A3": "TLS", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "East Timor", + "ADM0_A3": "TLS", + "GEOU_DIF": 0, + "GEOUNIT": "East Timor", + "GU_A3": "TLS", + "SU_DIF": 0, + "SUBUNIT": "East Timor", + "SU_A3": "TLS", + "BRK_DIFF": 0, + "NAME": "Timor-Leste", + "NAME_LONG": "Timor-Leste", + "BRK_A3": "TLS", + "BRK_NAME": "Timor-Leste", + "BRK_GROUP": null, + "ABBREV": "T.L.", + "POSTAL": "TL", + "FORMAL_EN": "Democratic Republic of Timor-Leste", + "FORMAL_FR": null, + "NAME_CIAWF": "Timor-Leste", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Timor-Leste", + "NAME_ALT": "East Timor", + "MAPCOLOR7": 2, + "MAPCOLOR8": 2, + "MAPCOLOR9": 4, + "MAPCOLOR13": 3, + "POP_EST": 1291358, + "POP_RANK": 12, + "GDP_MD_EST": 4975, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "TT", + "ISO_A2": "TL", + "ISO_A3": "TLS", + "ISO_A3_EH": "TLS", + "ISO_N3": "626", + "UN_A3": "626", + "WB_A2": "TP", + "WB_A3": "TMP", + "WOE_ID": 23424968, + "WOE_ID_EH": 23424968, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TLS", + "ADM0_A3_US": "TLS", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 11, + "LONG_LEN": 11, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [124.968682, -9.393173, 127.335928, -8.273345], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.08852, -9.393173], + [125.07002, -9.089987], + [124.968682, -8.89279], + [125.086246, -8.656887], + [125.947072, -8.432095], + [126.644704, -8.398247], + [126.957243, -8.273345], + [127.335928, -8.397317], + [126.967992, -8.668256], + [125.925885, -9.106007], + [125.08852, -9.393173] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 5, + "SOVEREIGNT": "Trinidad and Tobago", + "SOV_A3": "TTO", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Trinidad and Tobago", + "ADM0_A3": "TTO", + "GEOU_DIF": 0, + "GEOUNIT": "Trinidad and Tobago", + "GU_A3": "TTO", + "SU_DIF": 0, + "SUBUNIT": "Trinidad and Tobago", + "SU_A3": "TTO", + "BRK_DIFF": 0, + "NAME": "Trinidad and Tobago", + "NAME_LONG": "Trinidad and Tobago", + "BRK_A3": "TTO", + "BRK_NAME": "Trinidad and Tobago", + "BRK_GROUP": null, + "ABBREV": "Tr.T.", + "POSTAL": "TT", + "FORMAL_EN": "Republic of Trinidad and Tobago", + "FORMAL_FR": null, + "NAME_CIAWF": "Trinidad and Tobago", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Trinidad and Tobago", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 5, + "POP_EST": 1218208, + "POP_RANK": 12, + "GDP_MD_EST": 43570, + "POP_YEAR": 2017, + "LASTCENSUS": 2011, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "TD", + "ISO_A2": "TT", + "ISO_A3": "TTO", + "ISO_A3_EH": "TTO", + "ISO_N3": "780", + "UN_A3": "780", + "WB_A2": "TT", + "WB_A3": "TTO", + "WOE_ID": 23424958, + "WOE_ID_EH": 23424958, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TTO", + "ADM0_A3_US": "TTO", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Caribbean", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 19, + "LONG_LEN": 19, + "ABBREV_LEN": 5, + "TINY": 2, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4.5, + "MAX_LABEL": 9.5 + }, + "bbox": [-61.95, 10, -60.895, 10.89], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-61.68, 10.76], + [-61.105, 10.89], + [-60.895, 10.855], + [-60.935, 10.11], + [-61.77, 10], + [-61.95, 10.09], + [-61.66, 10.365], + [-61.68, 10.76] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Tunisia", + "SOV_A3": "TUN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Tunisia", + "ADM0_A3": "TUN", + "GEOU_DIF": 0, + "GEOUNIT": "Tunisia", + "GU_A3": "TUN", + "SU_DIF": 0, + "SUBUNIT": "Tunisia", + "SU_A3": "TUN", + "BRK_DIFF": 0, + "NAME": "Tunisia", + "NAME_LONG": "Tunisia", + "BRK_A3": "TUN", + "BRK_NAME": "Tunisia", + "BRK_GROUP": null, + "ABBREV": "Tun.", + "POSTAL": "TN", + "FORMAL_EN": "Republic of Tunisia", + "FORMAL_FR": null, + "NAME_CIAWF": "Tunisia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Tunisia", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 3, + "MAPCOLOR9": 3, + "MAPCOLOR13": 2, + "POP_EST": 11403800, + "POP_RANK": 14, + "GDP_MD_EST": 130800, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "TS", + "ISO_A2": "TN", + "ISO_A3": "TUN", + "ISO_A3_EH": "TUN", + "ISO_N3": "788", + "UN_A3": "788", + "WB_A2": "TN", + "WB_A3": "TUN", + "WOE_ID": 23424967, + "WOE_ID_EH": 23424967, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TUN", + "ADM0_A3_US": "TUN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Northern Africa", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [7.524482, 30.307556, 11.488787, 37.349994], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [9.48214, 30.307556], + [9.055603, 32.102692], + [8.439103, 32.506285], + [8.430473, 32.748337], + [7.612642, 33.344115], + [7.524482, 34.097376], + [8.140981, 34.655146], + [8.376368, 35.479876], + [8.217824, 36.433177], + [8.420964, 36.946427], + [9.509994, 37.349994], + [10.210002, 37.230002], + [10.18065, 36.724038], + [11.028867, 37.092103], + [11.100026, 36.899996], + [10.600005, 36.41], + [10.593287, 35.947444], + [10.939519, 35.698984], + [10.807847, 34.833507], + [10.149593, 34.330773], + [10.339659, 33.785742], + [10.856836, 33.76874], + [11.108501, 33.293343], + [11.488787, 33.136996], + [11.432253, 32.368903], + [10.94479, 32.081815], + [10.636901, 31.761421], + [9.950225, 31.37607], + [10.056575, 30.961831], + [9.970017, 30.539325], + [9.48214, 30.307556] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Turkey", + "SOV_A3": "TUR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Turkey", + "ADM0_A3": "TUR", + "GEOU_DIF": 0, + "GEOUNIT": "Turkey", + "GU_A3": "TUR", + "SU_DIF": 0, + "SUBUNIT": "Turkey", + "SU_A3": "TUR", + "BRK_DIFF": 0, + "NAME": "Turkey", + "NAME_LONG": "Turkey", + "BRK_A3": "TUR", + "BRK_NAME": "Turkey", + "BRK_GROUP": null, + "ABBREV": "Tur.", + "POSTAL": "TR", + "FORMAL_EN": "Republic of Turkey", + "FORMAL_FR": null, + "NAME_CIAWF": "Turkey", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Turkey", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 8, + "MAPCOLOR13": 4, + "POP_EST": 80845215, + "POP_RANK": 16, + "GDP_MD_EST": 1670000, + "POP_YEAR": 2017, + "LASTCENSUS": 2000, + "GDP_YEAR": 2016, + "ECONOMY": "4. Emerging region: MIKT", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "TU", + "ISO_A2": "TR", + "ISO_A3": "TUR", + "ISO_A3_EH": "TUR", + "ISO_N3": "792", + "UN_A3": "792", + "WB_A2": "TR", + "WB_A3": "TUR", + "WOE_ID": 23424969, + "WOE_ID_EH": 23424969, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TUR", + "ADM0_A3_US": "TUR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [26.043351, 35.821535, 44.79399, 42.141485], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [43.582746, 41.092143], + [43.752658, 40.740201], + [43.656436, 40.253564], + [44.400009, 40.005], + [44.79399, 39.713003], + [44.109225, 39.428136], + [44.421403, 38.281281], + [44.225756, 37.971584], + [44.77267, 37.17045], + [44.772677, 37.170437], + [44.293452, 37.001514], + [43.942259, 37.256228], + [42.779126, 37.385264], + [42.349591, 37.229873], + [41.212089, 37.074352], + [40.673259, 37.091276], + [39.52258, 36.716054], + [38.699891, 36.712927], + [38.167727, 36.90121], + [37.066761, 36.623036], + [36.739494, 36.81752], + [36.685389, 36.259699], + [36.41755, 36.040617], + [36.149763, 35.821535], + [35.782085, 36.274995], + [36.160822, 36.650606], + [35.550936, 36.565443], + [34.714553, 36.795532], + [34.026895, 36.21996], + [32.509158, 36.107564], + [31.699595, 36.644275], + [30.621625, 36.677865], + [30.391096, 36.262981], + [29.699976, 36.144357], + [28.732903, 36.676831], + [27.641187, 36.658822], + [27.048768, 37.653361], + [26.318218, 38.208133], + [26.8047, 38.98576], + [26.170785, 39.463612], + [27.28002, 40.420014], + [28.819978, 40.460011], + [29.240004, 41.219991], + [31.145934, 41.087622], + [32.347979, 41.736264], + [33.513283, 42.01896], + [35.167704, 42.040225], + [36.913127, 41.335358], + [38.347665, 40.948586], + [39.512607, 41.102763], + [40.373433, 41.013673], + [41.554084, 41.535656], + [42.619549, 41.583173], + [43.582746, 41.092143] + ] + ], + [ + [ + [26.117042, 41.826905], + [27.135739, 42.141485], + [27.99672, 42.007359], + [28.115525, 41.622886], + [28.988443, 41.299934], + [28.806438, 41.054962], + [27.619017, 40.999823], + [27.192377, 40.690566], + [26.358009, 40.151994], + [26.043351, 40.617754], + [26.056942, 40.824123], + [26.294602, 40.936261], + [26.604196, 41.562115], + [26.117042, 41.826905] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Taiwan", + "SOV_A3": "TWN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Taiwan", + "ADM0_A3": "TWN", + "GEOU_DIF": 0, + "GEOUNIT": "Taiwan", + "GU_A3": "TWN", + "SU_DIF": 0, + "SUBUNIT": "Taiwan", + "SU_A3": "TWN", + "BRK_DIFF": 1, + "NAME": "Taiwan", + "NAME_LONG": "Taiwan", + "BRK_A3": "B77", + "BRK_NAME": "Taiwan", + "BRK_GROUP": null, + "ABBREV": "Taiwan", + "POSTAL": "TW", + "FORMAL_EN": null, + "FORMAL_FR": null, + "NAME_CIAWF": "Taiwan", + "NOTE_ADM0": null, + "NOTE_BRK": "Self admin.; Claimed by China", + "NAME_SORT": "Taiwan", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 5, + "MAPCOLOR9": 7, + "MAPCOLOR13": 2, + "POP_EST": 23508428, + "POP_RANK": 15, + "GDP_MD_EST": 1127000, + "POP_YEAR": 2017, + "LASTCENSUS": -99, + "GDP_YEAR": 2016, + "ECONOMY": "2. Developed region: nonG7", + "INCOME_GRP": "2. High income: nonOECD", + "WIKIPEDIA": -99, + "FIPS_10_": "TW", + "ISO_A2": "TW", + "ISO_A3": "TWN", + "ISO_A3_EH": "TWN", + "ISO_N3": "158", + "UN_A3": "-099", + "WB_A2": "-99", + "WB_A3": "-99", + "WOE_ID": 23424971, + "WOE_ID_EH": 23424971, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TWN", + "ADM0_A3_US": "TWN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [120.106189, 21.970571, 121.951244, 25.295459], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [121.777818, 24.394274], + [121.175632, 22.790857], + [120.74708, 21.970571], + [120.220083, 22.814861], + [120.106189, 23.556263], + [120.69468, 24.538451], + [121.495044, 25.295459], + [121.951244, 24.997596], + [121.777818, 24.394274] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "United Republic of Tanzania", + "SOV_A3": "TZA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "United Republic of Tanzania", + "ADM0_A3": "TZA", + "GEOU_DIF": 0, + "GEOUNIT": "Tanzania", + "GU_A3": "TZA", + "SU_DIF": 0, + "SUBUNIT": "Tanzania", + "SU_A3": "TZA", + "BRK_DIFF": 0, + "NAME": "Tanzania", + "NAME_LONG": "Tanzania", + "BRK_A3": "TZA", + "BRK_NAME": "Tanzania", + "BRK_GROUP": null, + "ABBREV": "Tanz.", + "POSTAL": "TZ", + "FORMAL_EN": "United Republic of Tanzania", + "FORMAL_FR": null, + "NAME_CIAWF": "Tanzania", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Tanzania", + "NAME_ALT": null, + "MAPCOLOR7": 3, + "MAPCOLOR8": 6, + "MAPCOLOR9": 2, + "MAPCOLOR13": 2, + "POP_EST": 53950935, + "POP_RANK": 16, + "GDP_MD_EST": 150600, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "TZ", + "ISO_A2": "TZ", + "ISO_A3": "TZA", + "ISO_A3_EH": "TZA", + "ISO_N3": "834", + "UN_A3": "834", + "WB_A2": "TZ", + "WB_A3": "TZA", + "WOE_ID": 23424973, + "WOE_ID_EH": 23424973, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "TZA", + "ADM0_A3_US": "TZA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [29.339998, -11.720938, 40.31659, -0.95], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [29.339998, -4.499983], + [29.753512, -4.452389], + [30.11632, -4.09012], + [30.50554, -3.56858], + [30.75224, -3.35931], + [30.74301, -3.03431], + [30.52766, -2.80762], + [30.469674, -2.413855], + [30.46967, -2.41383], + [30.758309, -2.28725], + [30.816135, -1.698914], + [30.419105, -1.134659], + [30.76986, -1.01455], + [31.86617, -1.02736], + [33.903711, -0.95], + [34.07262, -1.05982], + [37.69869, -3.09699], + [37.7669, -3.67712], + [39.20222, -4.67677], + [38.74054, -5.90895], + [38.79977, -6.47566], + [39.44, -6.84], + [39.47, -7.1], + [39.19469, -7.7039], + [39.25203, -8.00781], + [39.18652, -8.48551], + [39.53574, -9.11237], + [39.9496, -10.0984], + [40.316586, -10.317098], + [40.31659, -10.3171], + [39.521, -10.89688], + [38.427557, -11.285202], + [37.82764, -11.26879], + [37.47129, -11.56876], + [36.775151, -11.594537], + [36.514082, -11.720938], + [35.312398, -11.439146], + [34.559989, -11.52002], + [34.28, -10.16], + [33.940838, -9.693674], + [33.73972, -9.41715], + [32.759375, -9.230599], + [32.191865, -8.930359], + [31.556348, -8.762049], + [31.157751, -8.594579], + [30.74001, -8.340006], + [30.740015, -8.340007], + [30.199997, -7.079981], + [29.620032, -6.520015], + [29.419993, -5.939999], + [29.519987, -5.419979], + [29.339998, -4.499983] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Uganda", + "SOV_A3": "UGA", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Uganda", + "ADM0_A3": "UGA", + "GEOU_DIF": 0, + "GEOUNIT": "Uganda", + "GU_A3": "UGA", + "SU_DIF": 0, + "SUBUNIT": "Uganda", + "SU_A3": "UGA", + "BRK_DIFF": 0, + "NAME": "Uganda", + "NAME_LONG": "Uganda", + "BRK_A3": "UGA", + "BRK_NAME": "Uganda", + "BRK_GROUP": null, + "ABBREV": "Uga.", + "POSTAL": "UG", + "FORMAL_EN": "Republic of Uganda", + "FORMAL_FR": null, + "NAME_CIAWF": "Uganda", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Uganda", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 6, + "MAPCOLOR13": 4, + "POP_EST": 39570125, + "POP_RANK": 15, + "GDP_MD_EST": 84930, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "UG", + "ISO_A2": "UG", + "ISO_A3": "UGA", + "ISO_A3_EH": "UGA", + "ISO_N3": "800", + "UN_A3": "800", + "WB_A2": "UG", + "WB_A3": "UGA", + "WOE_ID": 23424974, + "WOE_ID_EH": 23424974, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "UGA", + "ADM0_A3_US": "UGA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [29.579466, -1.443322, 35.03599, 4.249885], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [29.579466, -1.341313], + [29.587838, -0.587406], + [29.819503, -0.20531], + [29.875779, 0.59738], + [30.086154, 1.062313], + [30.468508, 1.583805], + [30.85267, 1.849396], + [31.174149, 2.204465], + [30.773347, 2.339883], + [30.83386, 3.509166], + [30.833852, 3.509172], + [31.24556, 3.7819], + [31.88145, 3.55827], + [32.68642, 3.79232], + [33.39, 3.79], + [34.005, 4.249885], + [34.47913, 3.5556], + [34.59607, 3.05374], + [35.03599, 1.90584], + [34.6721, 1.17694], + [34.18, 0.515], + [33.893569, 0.109814], + [33.903711, -0.95], + [31.86617, -1.02736], + [30.76986, -1.01455], + [30.419105, -1.134659], + [29.821519, -1.443322], + [29.579466, -1.341313] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Ukraine", + "SOV_A3": "UKR", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Ukraine", + "ADM0_A3": "UKR", + "GEOU_DIF": 0, + "GEOUNIT": "Ukraine", + "GU_A3": "UKR", + "SU_DIF": 0, + "SUBUNIT": "Ukraine", + "SU_A3": "UKR", + "BRK_DIFF": 0, + "NAME": "Ukraine", + "NAME_LONG": "Ukraine", + "BRK_A3": "UKR", + "BRK_NAME": "Ukraine", + "BRK_GROUP": null, + "ABBREV": "Ukr.", + "POSTAL": "UA", + "FORMAL_EN": "Ukraine", + "FORMAL_FR": null, + "NAME_CIAWF": "Ukraine", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Ukraine", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 1, + "MAPCOLOR9": 6, + "MAPCOLOR13": 3, + "POP_EST": 44033874, + "POP_RANK": 15, + "GDP_MD_EST": 352600, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "UP", + "ISO_A2": "UA", + "ISO_A3": "UKR", + "ISO_A3_EH": "UKR", + "ISO_N3": "804", + "UN_A3": "804", + "WB_A2": "UA", + "WB_A3": "UKR", + "WOE_ID": 23424976, + "WOE_ID_EH": 23424976, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "UKR", + "ADM0_A3_US": "UKR", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Europe", + "REGION_UN": "Europe", + "SUBREGION": "Eastern Europe", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7 + }, + "bbox": [22.085608, 45.293308, 40.080789, 52.335075], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.527071, 51.578454], + [24.005078, 51.617444], + [24.553106, 51.888461], + [25.327788, 51.910656], + [26.337959, 51.832289], + [27.454066, 51.592303], + [28.241615, 51.572227], + [28.617613, 51.427714], + [28.992835, 51.602044], + [29.254938, 51.368234], + [30.157364, 51.416138], + [30.555117, 51.319503], + [30.619454, 51.822806], + [30.927549, 52.042353], + [31.785992, 52.101678], + [32.15944, 52.06125], + [32.412058, 52.288695], + [32.715761, 52.238465], + [33.7527, 52.335075], + [34.391731, 51.768882], + [34.141978, 51.566413], + [34.224816, 51.255993], + [35.022183, 51.207572], + [35.37791, 50.77394], + [35.356116, 50.577197], + [36.626168, 50.225591], + [37.39346, 50.383953], + [38.010631, 49.915662], + [38.594988, 49.926462], + [40.06904, 49.60105], + [40.080789, 49.30743], + [39.67465, 48.78382], + [39.89562, 48.23241], + [39.738278, 47.898937], + [38.77057, 47.82562], + [38.255112, 47.5464], + [38.223538, 47.10219], + [37.425137, 47.022221], + [36.759855, 46.6987], + [35.823685, 46.645964], + [34.962342, 46.273197], + [35.012659, 45.737725], + [34.861792, 45.768182], + [34.732017, 45.965666], + [34.410402, 46.005162], + [33.699462, 46.219573], + [33.435988, 45.971917], + [33.298567, 46.080598], + [31.74414, 46.333348], + [31.675307, 46.706245], + [30.748749, 46.5831], + [30.377609, 46.03241], + [29.603289, 45.293308], + [29.149725, 45.464925], + [28.679779, 45.304031], + [28.233554, 45.488283], + [28.485269, 45.596907], + [28.659987, 45.939987], + [28.933717, 46.25883], + [28.862972, 46.437889], + [29.072107, 46.517678], + [29.170654, 46.379262], + [29.759972, 46.349988], + [30.024659, 46.423937], + [29.83821, 46.525326], + [29.908852, 46.674361], + [29.559674, 46.928583], + [29.415135, 47.346645], + [29.050868, 47.510227], + [29.122698, 47.849095], + [28.670891, 48.118149], + [28.259547, 48.155562], + [27.522537, 48.467119], + [26.857824, 48.368211], + [26.619337, 48.220726], + [26.19745, 48.220881], + [25.945941, 47.987149], + [25.207743, 47.891056], + [24.866317, 47.737526], + [24.402056, 47.981878], + [23.760958, 47.985598], + [23.142236, 48.096341], + [22.710531, 47.882194], + [22.64082, 48.15024], + [22.085608, 48.422264], + [22.280842, 48.825392], + [22.558138, 49.085738], + [22.776419, 49.027395], + [22.51845, 49.476774], + [23.426508, 50.308506], + [23.922757, 50.424881], + [24.029986, 50.705407], + [23.527071, 51.578454] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Uruguay", + "SOV_A3": "URY", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Uruguay", + "ADM0_A3": "URY", + "GEOU_DIF": 0, + "GEOUNIT": "Uruguay", + "GU_A3": "URY", + "SU_DIF": 0, + "SUBUNIT": "Uruguay", + "SU_A3": "URY", + "BRK_DIFF": 0, + "NAME": "Uruguay", + "NAME_LONG": "Uruguay", + "BRK_A3": "URY", + "BRK_NAME": "Uruguay", + "BRK_GROUP": null, + "ABBREV": "Ury.", + "POSTAL": "UY", + "FORMAL_EN": "Oriental Republic of Uruguay", + "FORMAL_FR": null, + "NAME_CIAWF": "Uruguay", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Uruguay", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 2, + "MAPCOLOR9": 2, + "MAPCOLOR13": 10, + "POP_EST": 3360148, + "POP_RANK": 12, + "GDP_MD_EST": 73250, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "UY", + "ISO_A2": "UY", + "ISO_A3": "URY", + "ISO_A3_EH": "URY", + "ISO_N3": "858", + "UN_A3": "858", + "WB_A2": "UY", + "WB_A3": "URY", + "WOE_ID": 23424979, + "WOE_ID_EH": 23424979, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "URY", + "ADM0_A3_US": "URY", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [-58.427074, -34.952647, -53.209589, -30.109686], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-58.427074, -33.909454], + [-58.349611, -33.263189], + [-58.132648, -33.040567], + [-58.14244, -32.044504], + [-57.874937, -31.016556], + [-57.625133, -30.216295], + [-56.976026, -30.109686], + [-55.973245, -30.883076], + [-55.60151, -30.853879], + [-54.572452, -31.494511], + [-53.787952, -32.047243], + [-53.209589, -32.727666], + [-53.650544, -33.202004], + [-53.373662, -33.768378], + [-53.806426, -34.396815], + [-54.935866, -34.952647], + [-55.67409, -34.752659], + [-56.215297, -34.859836], + [-57.139685, -34.430456], + [-57.817861, -34.462547], + [-58.427074, -33.909454] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "United States of America", + "SOV_A3": "US1", + "ADM0_DIF": 1, + "LEVEL": 2, + "TYPE": "Country", + "ADMIN": "United States of America", + "ADM0_A3": "USA", + "GEOU_DIF": 0, + "GEOUNIT": "United States of America", + "GU_A3": "USA", + "SU_DIF": 0, + "SUBUNIT": "United States", + "SU_A3": "USA", + "BRK_DIFF": 0, + "NAME": "United States of America", + "NAME_LONG": "United States", + "BRK_A3": "USA", + "BRK_NAME": "United States", + "BRK_GROUP": null, + "ABBREV": "U.S.A.", + "POSTAL": "US", + "FORMAL_EN": "United States of America", + "FORMAL_FR": null, + "NAME_CIAWF": "United States", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "United States of America", + "NAME_ALT": null, + "MAPCOLOR7": 4, + "MAPCOLOR8": 5, + "MAPCOLOR9": 1, + "MAPCOLOR13": 1, + "POP_EST": 326625791, + "POP_RANK": 17, + "GDP_MD_EST": 18560000, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "1. Developed region: G7", + "INCOME_GRP": "1. High income: OECD", + "WIKIPEDIA": 0, + "FIPS_10_": "US", + "ISO_A2": "US", + "ISO_A3": "USA", + "ISO_A3_EH": "USA", + "ISO_N3": "840", + "UN_A3": "840", + "WB_A2": "US", + "WB_A3": "USA", + "WOE_ID": 23424977, + "WOE_ID_EH": 23424977, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "USA", + "ADM0_A3_US": "USA", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "North America", + "REGION_UN": "Americas", + "SUBREGION": "Northern America", + "REGION_WB": "North America", + "NAME_LEN": 24, + "LONG_LEN": 13, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 5.7 + }, + "bbox": [-171.791111, 18.91619, -66.96466, 71.357764], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-122.84, 49], + [-120, 49], + [-117.03121, 49], + [-116.04818, 49], + [-113, 49], + [-110.05, 49], + [-107.05, 49], + [-104.04826, 48.99986], + [-100.65, 49], + [-97.22872, 49.0007], + [-95.15907, 49], + [-95.15609, 49.38425], + [-94.81758, 49.38905], + [-94.64, 48.84], + [-94.32914, 48.67074], + [-93.63087, 48.60926], + [-92.61, 48.45], + [-91.64, 48.14], + [-90.83, 48.27], + [-89.6, 48.01], + [-89.272917, 48.019808], + [-88.378114, 48.302918], + [-87.439793, 47.94], + [-86.461991, 47.553338], + [-85.652363, 47.220219], + [-84.87608, 46.900083], + [-84.779238, 46.637102], + [-84.543749, 46.538684], + [-84.6049, 46.4396], + [-84.3367, 46.40877], + [-84.14212, 46.512226], + [-84.091851, 46.275419], + [-83.890765, 46.116927], + [-83.616131, 46.116927], + [-83.469551, 45.994686], + [-83.592851, 45.816894], + [-82.550925, 45.347517], + [-82.337763, 44.44], + [-82.137642, 43.571088], + [-82.43, 42.98], + [-82.9, 42.43], + [-83.12, 42.08], + [-83.142, 41.975681], + [-83.02981, 41.832796], + [-82.690089, 41.675105], + [-82.439278, 41.675105], + [-81.277747, 42.209026], + [-80.247448, 42.3662], + [-78.939362, 42.863611], + [-78.92, 42.965], + [-79.01, 43.27], + [-79.171674, 43.466339], + [-78.72028, 43.625089], + [-77.737885, 43.629056], + [-76.820034, 43.628784], + [-76.5, 44.018459], + [-76.375, 44.09631], + [-75.31821, 44.81645], + [-74.867, 45.00048], + [-73.34783, 45.00738], + [-71.50506, 45.0082], + [-71.405, 45.255], + [-71.08482, 45.30524], + [-70.66, 45.46], + [-70.305, 45.915], + [-69.99997, 46.69307], + [-69.237216, 47.447781], + [-68.905, 47.185], + [-68.23444, 47.35486], + [-67.79046, 47.06636], + [-67.79134, 45.70281], + [-67.13741, 45.13753], + [-66.96466, 44.8097], + [-68.03252, 44.3252], + [-69.06, 43.98], + [-70.11617, 43.68405], + [-70.645476, 43.090238], + [-70.81489, 42.8653], + [-70.825, 42.335], + [-70.495, 41.805], + [-70.08, 41.78], + [-70.185, 42.145], + [-69.88497, 41.92283], + [-69.96503, 41.63717], + [-70.64, 41.475], + [-71.12039, 41.49445], + [-71.86, 41.32], + [-72.295, 41.27], + [-72.87643, 41.22065], + [-73.71, 40.931102], + [-72.24126, 41.11948], + [-71.945, 40.93], + [-73.345, 40.63], + [-73.982, 40.628], + [-73.952325, 40.75075], + [-74.25671, 40.47351], + [-73.96244, 40.42763], + [-74.17838, 39.70926], + [-74.90604, 38.93954], + [-74.98041, 39.1964], + [-75.20002, 39.24845], + [-75.52805, 39.4985], + [-75.32, 38.96], + [-75.071835, 38.782032], + [-75.05673, 38.40412], + [-75.37747, 38.01551], + [-75.94023, 37.21689], + [-76.03127, 37.2566], + [-75.72205, 37.93705], + [-76.23287, 38.319215], + [-76.35, 39.15], + [-76.542725, 38.717615], + [-76.32933, 38.08326], + [-76.989998, 38.239992], + [-76.30162, 37.917945], + [-76.25874, 36.9664], + [-75.9718, 36.89726], + [-75.86804, 36.55125], + [-75.72749, 35.55074], + [-76.36318, 34.80854], + [-77.397635, 34.51201], + [-78.05496, 33.92547], + [-78.55435, 33.86133], + [-79.06067, 33.49395], + [-79.20357, 33.15839], + [-80.301325, 32.509355], + [-80.86498, 32.0333], + [-81.33629, 31.44049], + [-81.49042, 30.72999], + [-81.31371, 30.03552], + [-80.98, 29.18], + [-80.535585, 28.47213], + [-80.53, 28.04], + [-80.056539, 26.88], + [-80.088015, 26.205765], + [-80.13156, 25.816775], + [-80.38103, 25.20616], + [-80.68, 25.08], + [-81.17213, 25.20126], + [-81.33, 25.64], + [-81.71, 25.87], + [-82.24, 26.73], + [-82.70515, 27.49504], + [-82.85526, 27.88624], + [-82.65, 28.55], + [-82.93, 29.1], + [-83.70959, 29.93656], + [-84.1, 30.09], + [-85.10882, 29.63615], + [-85.28784, 29.68612], + [-85.7731, 30.15261], + [-86.4, 30.4], + [-87.53036, 30.27433], + [-88.41782, 30.3849], + [-89.18049, 30.31598], + [-89.593831, 30.159994], + [-89.413735, 29.89419], + [-89.43, 29.48864], + [-89.21767, 29.29108], + [-89.40823, 29.15961], + [-89.77928, 29.30714], + [-90.15463, 29.11743], + [-90.880225, 29.148535], + [-91.626785, 29.677], + [-92.49906, 29.5523], + [-93.22637, 29.78375], + [-93.84842, 29.71363], + [-94.69, 29.48], + [-95.60026, 28.73863], + [-96.59404, 28.30748], + [-97.14, 27.83], + [-97.37, 27.38], + [-97.38, 26.69], + [-97.33, 26.21], + [-97.14, 25.87], + [-97.53, 25.84], + [-98.24, 26.06], + [-99.02, 26.37], + [-99.3, 26.84], + [-99.52, 27.54], + [-100.11, 28.11], + [-100.45584, 28.69612], + [-100.9576, 29.38071], + [-101.6624, 29.7793], + [-102.48, 29.76], + [-103.11, 28.97], + [-103.94, 29.27], + [-104.45697, 29.57196], + [-104.70575, 30.12173], + [-105.03737, 30.64402], + [-105.63159, 31.08383], + [-106.1429, 31.39995], + [-106.50759, 31.75452], + [-108.24, 31.754854], + [-108.24194, 31.34222], + [-109.035, 31.34194], + [-111.02361, 31.33472], + [-113.30498, 32.03914], + [-114.815, 32.52528], + [-114.72139, 32.72083], + [-115.99135, 32.61239], + [-117.12776, 32.53534], + [-117.295938, 33.046225], + [-117.944, 33.621236], + [-118.410602, 33.740909], + [-118.519895, 34.027782], + [-119.081, 34.078], + [-119.438841, 34.348477], + [-120.36778, 34.44711], + [-120.62286, 34.60855], + [-120.74433, 35.15686], + [-121.71457, 36.16153], + [-122.54747, 37.55176], + [-122.51201, 37.78339], + [-122.95319, 38.11371], + [-123.7272, 38.95166], + [-123.86517, 39.76699], + [-124.39807, 40.3132], + [-124.17886, 41.14202], + [-124.2137, 41.99964], + [-124.53284, 42.76599], + [-124.14214, 43.70838], + [-124.020535, 44.615895], + [-123.89893, 45.52341], + [-124.079635, 46.86475], + [-124.39567, 47.72017], + [-124.68721, 48.184433], + [-124.566101, 48.379715], + [-123.12, 48.04], + [-122.58736, 47.096], + [-122.34, 47.36], + [-122.5, 48.18], + [-122.84, 49] + ] + ], + [ + [ + [-140.985988, 69.711998], + [-140.986, 69.712], + [-140.9925, 66.00003], + [-140.99778, 60.30639], + [-140.013, 60.27682], + [-139.039, 60], + [-138.34089, 59.56211], + [-137.4525, 58.905], + [-136.47972, 59.46389], + [-135.47583, 59.78778], + [-134.945, 59.27056], + [-134.27111, 58.86111], + [-133.35556, 58.41028], + [-132.73042, 57.69289], + [-131.70781, 56.55212], + [-130.00778, 55.91583], + [-129.98, 55.285], + [-130.53611, 54.80278], + [-130.536109, 54.802754], + [-130.53611, 54.802753], + [-131.085818, 55.178906], + [-131.967211, 55.497776], + [-132.250011, 56.369996], + [-133.539181, 57.178887], + [-134.078063, 58.123068], + [-135.038211, 58.187715], + [-136.628062, 58.212209], + [-137.800006, 58.499995], + [-139.867787, 59.537762], + [-140.825274, 59.727517], + [-142.574444, 60.084447], + [-143.958881, 59.99918], + [-145.925557, 60.45861], + [-147.114374, 60.884656], + [-148.224306, 60.672989], + [-148.018066, 59.978329], + [-148.570823, 59.914173], + [-149.727858, 59.705658], + [-150.608243, 59.368211], + [-151.716393, 59.155821], + [-151.859433, 59.744984], + [-151.409719, 60.725803], + [-150.346941, 61.033588], + [-150.621111, 61.284425], + [-151.895839, 60.727198], + [-152.57833, 60.061657], + [-154.019172, 59.350279], + [-153.287511, 58.864728], + [-154.232492, 58.146374], + [-155.307491, 57.727795], + [-156.308335, 57.422774], + [-156.556097, 56.979985], + [-158.117217, 56.463608], + [-158.433321, 55.994154], + [-159.603327, 55.566686], + [-160.28972, 55.643581], + [-161.223048, 55.364735], + [-162.237766, 55.024187], + [-163.069447, 54.689737], + [-164.785569, 54.404173], + [-164.942226, 54.572225], + [-163.84834, 55.039431], + [-162.870001, 55.348043], + [-161.804175, 55.894986], + [-160.563605, 56.008055], + [-160.07056, 56.418055], + [-158.684443, 57.016675], + [-158.461097, 57.216921], + [-157.72277, 57.570001], + [-157.550274, 58.328326], + [-157.041675, 58.918885], + [-158.194731, 58.615802], + [-158.517218, 58.787781], + [-159.058606, 58.424186], + [-159.711667, 58.93139], + [-159.981289, 58.572549], + [-160.355271, 59.071123], + [-161.355003, 58.670838], + [-161.968894, 58.671665], + [-162.054987, 59.266925], + [-161.874171, 59.633621], + [-162.518059, 59.989724], + [-163.818341, 59.798056], + [-164.662218, 60.267484], + [-165.346388, 60.507496], + [-165.350832, 61.073895], + [-166.121379, 61.500019], + [-165.734452, 62.074997], + [-164.919179, 62.633076], + [-164.562508, 63.146378], + [-163.753332, 63.219449], + [-163.067224, 63.059459], + [-162.260555, 63.541936], + [-161.53445, 63.455817], + [-160.772507, 63.766108], + [-160.958335, 64.222799], + [-161.518068, 64.402788], + [-160.777778, 64.788604], + [-161.391926, 64.777235], + [-162.45305, 64.559445], + [-162.757786, 64.338605], + [-163.546394, 64.55916], + [-164.96083, 64.446945], + [-166.425288, 64.686672], + [-166.845004, 65.088896], + [-168.11056, 65.669997], + [-166.705271, 66.088318], + [-164.47471, 66.57666], + [-163.652512, 66.57666], + [-163.788602, 66.077207], + [-161.677774, 66.11612], + [-162.489715, 66.735565], + [-163.719717, 67.116395], + [-164.430991, 67.616338], + [-165.390287, 68.042772], + [-166.764441, 68.358877], + [-166.204707, 68.883031], + [-164.430811, 68.915535], + [-163.168614, 69.371115], + [-162.930566, 69.858062], + [-161.908897, 70.33333], + [-160.934797, 70.44769], + [-159.039176, 70.891642], + [-158.119723, 70.824721], + [-156.580825, 71.357764], + [-155.06779, 71.147776], + [-154.344165, 70.696409], + [-153.900006, 70.889989], + [-152.210006, 70.829992], + [-152.270002, 70.600006], + [-150.739992, 70.430017], + [-149.720003, 70.53001], + [-147.613362, 70.214035], + [-145.68999, 70.12001], + [-144.920011, 69.989992], + [-143.589446, 70.152514], + [-142.07251, 69.851938], + [-140.985988, 69.711998], + [-140.985988, 69.711998] + ] + ], + [ + [ + [-155.54211, 19.08348], + [-155.68817, 18.91619], + [-155.93665, 19.05939], + [-155.90806, 19.33888], + [-156.07347, 19.70294], + [-156.02368, 19.81422], + [-155.85008, 19.97729], + [-155.91907, 20.17395], + [-155.86108, 20.26721], + [-155.78505, 20.2487], + [-155.40214, 20.07975], + [-155.22452, 19.99302], + [-155.06226, 19.8591], + [-154.80741, 19.50871], + [-154.83147, 19.45328], + [-155.22217, 19.23972], + [-155.54211, 19.08348] + ] + ], + [ + [ + [-156.07926, 20.64397], + [-156.41445, 20.57241], + [-156.58673, 20.783], + [-156.70167, 20.8643], + [-156.71055, 20.92676], + [-156.61258, 21.01249], + [-156.25711, 20.91745], + [-155.99566, 20.76404], + [-156.07926, 20.64397] + ] + ], + [ + [ + [-156.75824, 21.17684], + [-156.78933, 21.06873], + [-157.32521, 21.09777], + [-157.25027, 21.21958], + [-156.75824, 21.17684] + ] + ], + [ + [ + [-157.65283, 21.32217], + [-157.70703, 21.26442], + [-157.7786, 21.27729], + [-158.12667, 21.31244], + [-158.2538, 21.53919], + [-158.29265, 21.57912], + [-158.0252, 21.71696], + [-157.94161, 21.65272], + [-157.65283, 21.32217] + ] + ], + [ + [ + [-159.34512, 21.982], + [-159.46372, 21.88299], + [-159.80051, 22.06533], + [-159.74877, 22.1382], + [-159.5962, 22.23618], + [-159.36569, 22.21494], + [-159.34512, 21.982] + ] + ], + [ + [ + [-153.006314, 57.115842], + [-154.00509, 56.734677], + [-154.516403, 56.992749], + [-154.670993, 57.461196], + [-153.76278, 57.816575], + [-153.228729, 57.968968], + [-152.564791, 57.901427], + [-152.141147, 57.591059], + [-153.006314, 57.115842] + ] + ], + [ + [ + [-165.579164, 59.909987], + [-166.19277, 59.754441], + [-166.848337, 59.941406], + [-167.455277, 60.213069], + [-166.467792, 60.38417], + [-165.67443, 60.293607], + [-165.579164, 59.909987] + ] + ], + [ + [ + [-171.731657, 63.782515], + [-171.114434, 63.592191], + [-170.491112, 63.694975], + [-169.682505, 63.431116], + [-168.689439, 63.297506], + [-168.771941, 63.188598], + [-169.52944, 62.976931], + [-170.290556, 63.194438], + [-170.671386, 63.375822], + [-171.553063, 63.317789], + [-171.791111, 63.405846], + [-171.731657, 63.782515] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Uzbekistan", + "SOV_A3": "UZB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Uzbekistan", + "ADM0_A3": "UZB", + "GEOU_DIF": 0, + "GEOUNIT": "Uzbekistan", + "GU_A3": "UZB", + "SU_DIF": 0, + "SUBUNIT": "Uzbekistan", + "SU_A3": "UZB", + "BRK_DIFF": 0, + "NAME": "Uzbekistan", + "NAME_LONG": "Uzbekistan", + "BRK_A3": "UZB", + "BRK_NAME": "Uzbekistan", + "BRK_GROUP": null, + "ABBREV": "Uzb.", + "POSTAL": "UZ", + "FORMAL_EN": "Republic of Uzbekistan", + "FORMAL_FR": null, + "NAME_CIAWF": "Uzbekistan", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Uzbekistan", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 3, + "MAPCOLOR9": 5, + "MAPCOLOR13": 4, + "POP_EST": 29748859, + "POP_RANK": 15, + "GDP_MD_EST": 202300, + "POP_YEAR": 2017, + "LASTCENSUS": 1989, + "GDP_YEAR": 2016, + "ECONOMY": "6. Developing region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "UZ", + "ISO_A2": "UZ", + "ISO_A3": "UZB", + "ISO_A3_EH": "UZB", + "ISO_N3": "860", + "UN_A3": "860", + "WB_A2": "UZ", + "WB_A3": "UZB", + "WOE_ID": 23424980, + "WOE_ID_EH": 23424980, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "UZB", + "ADM0_A3_US": "UZB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Central Asia", + "REGION_WB": "Europe & Central Asia", + "NAME_LEN": 10, + "LONG_LEN": 10, + "ABBREV_LEN": 4, + "TINY": 5, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [55.928917, 37.144994, 73.055417, 45.586804], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [67.83, 37.144994], + [67.075782, 37.356144], + [66.518607, 37.362784], + [66.54615, 37.974685], + [65.215999, 38.402695], + [64.170223, 38.892407], + [63.518015, 39.363257], + [62.37426, 40.053886], + [61.882714, 41.084857], + [61.547179, 41.26637], + [60.465953, 41.220327], + [60.083341, 41.425146], + [59.976422, 42.223082], + [58.629011, 42.751551], + [57.78653, 42.170553], + [56.932215, 41.826026], + [57.096391, 41.32231], + [55.968191, 41.308642], + [55.928917, 44.995858], + [58.503127, 45.586804], + [58.689989, 45.500014], + [60.239972, 44.784037], + [61.05832, 44.405817], + [62.0133, 43.504477], + [63.185787, 43.650075], + [64.900824, 43.728081], + [66.098012, 42.99766], + [66.023392, 41.994646], + [66.510649, 41.987644], + [66.714047, 41.168444], + [67.985856, 41.135991], + [68.259896, 40.662325], + [68.632483, 40.668681], + [69.070027, 41.384244], + [70.388965, 42.081308], + [70.962315, 42.266154], + [71.259248, 42.167711], + [70.420022, 41.519998], + [71.157859, 41.143587], + [71.870115, 41.3929], + [73.055417, 40.866033], + [71.774875, 40.145844], + [71.014198, 40.244366], + [70.601407, 40.218527], + [70.45816, 40.496495], + [70.666622, 40.960213], + [69.329495, 40.727824], + [69.011633, 40.086158], + [68.536416, 39.533453], + [67.701429, 39.580478], + [67.44222, 39.140144], + [68.176025, 38.901553], + [68.392033, 38.157025], + [67.83, 37.144994] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Venezuela", + "SOV_A3": "VEN", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Venezuela", + "ADM0_A3": "VEN", + "GEOU_DIF": 0, + "GEOUNIT": "Venezuela", + "GU_A3": "VEN", + "SU_DIF": 0, + "SUBUNIT": "Venezuela", + "SU_A3": "VEN", + "BRK_DIFF": 0, + "NAME": "Venezuela", + "NAME_LONG": "Venezuela", + "BRK_A3": "VEN", + "BRK_NAME": "Venezuela", + "BRK_GROUP": null, + "ABBREV": "Ven.", + "POSTAL": "VE", + "FORMAL_EN": "Bolivarian Republic of Venezuela", + "FORMAL_FR": "República Bolivariana de Venezuela", + "NAME_CIAWF": "Venezuela", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Venezuela, RB", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 3, + "MAPCOLOR9": 1, + "MAPCOLOR13": 4, + "POP_EST": 31304016, + "POP_RANK": 15, + "GDP_MD_EST": 468600, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "VE", + "ISO_A2": "VE", + "ISO_A3": "VEN", + "ISO_A3_EH": "VEN", + "ISO_N3": "862", + "UN_A3": "862", + "WB_A2": "VE", + "WB_A3": "VEN", + "WOE_ID": 23424982, + "WOE_ID_EH": 23424982, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "VEN", + "ADM0_A3_US": "VEN", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "South America", + "REGION_UN": "Americas", + "SUBREGION": "South America", + "REGION_WB": "Latin America & Caribbean", + "NAME_LEN": 9, + "LONG_LEN": 9, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 7.5 + }, + "bbox": [-73.304952, 0.724452, -59.758285, 12.162307], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-60.733574, 5.200277], + [-60.601179, 4.918098], + [-60.966893, 4.536468], + [-62.08543, 4.162124], + [-62.804533, 4.006965], + [-63.093198, 3.770571], + [-63.888343, 4.02053], + [-64.628659, 4.148481], + [-64.816064, 4.056445], + [-64.368494, 3.79721], + [-64.408828, 3.126786], + [-64.269999, 2.497006], + [-63.422867, 2.411068], + [-63.368788, 2.2009], + [-64.083085, 1.916369], + [-64.199306, 1.492855], + [-64.611012, 1.328731], + [-65.354713, 1.095282], + [-65.548267, 0.789254], + [-66.325765, 0.724452], + [-66.876326, 1.253361], + [-67.181294, 2.250638], + [-67.447092, 2.600281], + [-67.809938, 2.820655], + [-67.303173, 3.318454], + [-67.337564, 3.542342], + [-67.621836, 3.839482], + [-67.823012, 4.503937], + [-67.744697, 5.221129], + [-67.521532, 5.55687], + [-67.34144, 6.095468], + [-67.695087, 6.267318], + [-68.265052, 6.153268], + [-68.985319, 6.206805], + [-69.38948, 6.099861], + [-70.093313, 6.960376], + [-70.674234, 7.087785], + [-71.960176, 6.991615], + [-72.198352, 7.340431], + [-72.444487, 7.423785], + [-72.479679, 7.632506], + [-72.360901, 8.002638], + [-72.439862, 8.405275], + [-72.660495, 8.625288], + [-72.78873, 9.085027], + [-73.304952, 9.152], + [-73.027604, 9.73677], + [-72.905286, 10.450344], + [-72.614658, 10.821975], + [-72.227575, 11.108702], + [-71.973922, 11.608672], + [-71.331584, 11.776284], + [-71.360006, 11.539994], + [-71.94705, 11.423282], + [-71.620868, 10.96946], + [-71.633064, 10.446494], + [-72.074174, 9.865651], + [-71.695644, 9.072263], + [-71.264559, 9.137195], + [-71.039999, 9.859993], + [-71.350084, 10.211935], + [-71.400623, 10.968969], + [-70.155299, 11.375482], + [-70.293843, 11.846822], + [-69.943245, 12.162307], + [-69.5843, 11.459611], + [-68.882999, 11.443385], + [-68.233271, 10.885744], + [-68.194127, 10.554653], + [-67.296249, 10.545868], + [-66.227864, 10.648627], + [-65.655238, 10.200799], + [-64.890452, 10.077215], + [-64.329479, 10.389599], + [-64.318007, 10.641418], + [-63.079322, 10.701724], + [-61.880946, 10.715625], + [-62.730119, 10.420269], + [-62.388512, 9.948204], + [-61.588767, 9.873067], + [-60.830597, 9.38134], + [-60.671252, 8.580174], + [-60.150096, 8.602757], + [-59.758285, 8.367035], + [-60.550588, 7.779603], + [-60.637973, 7.415], + [-60.295668, 7.043911], + [-60.543999, 6.856584], + [-61.159336, 6.696077], + [-61.139415, 6.234297], + [-61.410303, 5.959068], + [-60.733574, 5.200277] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "Vietnam", + "SOV_A3": "VNM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Vietnam", + "ADM0_A3": "VNM", + "GEOU_DIF": 0, + "GEOUNIT": "Vietnam", + "GU_A3": "VNM", + "SU_DIF": 0, + "SUBUNIT": "Vietnam", + "SU_A3": "VNM", + "BRK_DIFF": 0, + "NAME": "Vietnam", + "NAME_LONG": "Vietnam", + "BRK_A3": "VNM", + "BRK_NAME": "Vietnam", + "BRK_GROUP": null, + "ABBREV": "Viet.", + "POSTAL": "VN", + "FORMAL_EN": "Socialist Republic of Vietnam", + "FORMAL_FR": null, + "NAME_CIAWF": "Vietnam", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Vietnam", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 6, + "MAPCOLOR9": 5, + "MAPCOLOR13": 4, + "POP_EST": 96160163, + "POP_RANK": 16, + "GDP_MD_EST": 594900, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "VM", + "ISO_A2": "VN", + "ISO_A3": "VNM", + "ISO_A3_EH": "VNM", + "ISO_N3": "704", + "UN_A3": "704", + "WB_A2": "VN", + "WB_A3": "VNM", + "WOE_ID": 23424984, + "WOE_ID_EH": 23424984, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "VNM", + "ADM0_A3_US": "VNM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "South-Eastern Asia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 5, + "TINY": 2, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 2, + "MAX_LABEL": 7 + }, + "bbox": [102.170436, 8.59976, 109.33527, 23.352063], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [102.170436, 22.464753], + [102.706992, 22.708795], + [103.504515, 22.703757], + [104.476858, 22.81915], + [105.329209, 23.352063], + [105.811247, 22.976892], + [106.725403, 22.794268], + [106.567273, 22.218205], + [107.04342, 21.811899], + [108.05018, 21.55238], + [106.715068, 20.696851], + [105.881682, 19.75205], + [105.662006, 19.058165], + [106.426817, 18.004121], + [107.361954, 16.697457], + [108.269495, 16.079742], + [108.877107, 15.276691], + [109.33527, 13.426028], + [109.200136, 11.666859], + [108.36613, 11.008321], + [107.220929, 10.364484], + [106.405113, 9.53084], + [105.158264, 8.59976], + [104.795185, 9.241038], + [105.076202, 9.918491], + [104.334335, 10.486544], + [105.199915, 10.88931], + [106.24967, 10.961812], + [105.810524, 11.567615], + [107.491403, 12.337206], + [107.614548, 13.535531], + [107.382727, 14.202441], + [107.564525, 15.202173], + [107.312706, 15.908538], + [106.556008, 16.604284], + [105.925762, 17.485315], + [105.094598, 18.666975], + [103.896532, 19.265181], + [104.183388, 19.624668], + [104.822574, 19.886642], + [104.435, 20.758733], + [103.203861, 20.766562], + [102.754896, 21.675137], + [102.170436, 22.464753] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 4, + "SOVEREIGNT": "Vanuatu", + "SOV_A3": "VUT", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Vanuatu", + "ADM0_A3": "VUT", + "GEOU_DIF": 0, + "GEOUNIT": "Vanuatu", + "GU_A3": "VUT", + "SU_DIF": 0, + "SUBUNIT": "Vanuatu", + "SU_A3": "VUT", + "BRK_DIFF": 0, + "NAME": "Vanuatu", + "NAME_LONG": "Vanuatu", + "BRK_A3": "VUT", + "BRK_NAME": "Vanuatu", + "BRK_GROUP": null, + "ABBREV": "Van.", + "POSTAL": "VU", + "FORMAL_EN": "Republic of Vanuatu", + "FORMAL_FR": null, + "NAME_CIAWF": "Vanuatu", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Vanuatu", + "NAME_ALT": null, + "MAPCOLOR7": 6, + "MAPCOLOR8": 3, + "MAPCOLOR9": 7, + "MAPCOLOR13": 3, + "POP_EST": 282814, + "POP_RANK": 10, + "GDP_MD_EST": 723, + "POP_YEAR": 2017, + "LASTCENSUS": 2009, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "NH", + "ISO_A2": "VU", + "ISO_A3": "VUT", + "ISO_A3_EH": "VUT", + "ISO_N3": "548", + "UN_A3": "548", + "WB_A2": "VU", + "WB_A3": "VUT", + "WOE_ID": 23424907, + "WOE_ID_EH": 23424907, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "VUT", + "ADM0_A3_US": "VUT", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Oceania", + "REGION_UN": "Oceania", + "SUBREGION": "Melanesia", + "REGION_WB": "East Asia & Pacific", + "NAME_LEN": 7, + "LONG_LEN": 7, + "ABBREV_LEN": 4, + "TINY": 2, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 4, + "MAX_LABEL": 9 + }, + "bbox": [166.629137, -16.59785, 167.844877, -14.626497], + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [167.844877, -16.466333], + [167.515181, -16.59785], + [167.180008, -16.159995], + [167.216801, -15.891846], + [167.844877, -16.466333] + ] + ], + [ + [ + [167.107712, -14.93392], + [167.270028, -15.740021], + [167.001207, -15.614602], + [166.793158, -15.668811], + [166.649859, -15.392704], + [166.629137, -14.626497], + [167.107712, -14.93392] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Yemen", + "SOV_A3": "YEM", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Yemen", + "ADM0_A3": "YEM", + "GEOU_DIF": 0, + "GEOUNIT": "Yemen", + "GU_A3": "YEM", + "SU_DIF": 0, + "SUBUNIT": "Yemen", + "SU_A3": "YEM", + "BRK_DIFF": 0, + "NAME": "Yemen", + "NAME_LONG": "Yemen", + "BRK_A3": "YEM", + "BRK_NAME": "Yemen", + "BRK_GROUP": null, + "ABBREV": "Yem.", + "POSTAL": "YE", + "FORMAL_EN": "Republic of Yemen", + "FORMAL_FR": null, + "NAME_CIAWF": "Yemen", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Yemen, Rep.", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 3, + "MAPCOLOR9": 3, + "MAPCOLOR13": 11, + "POP_EST": 28036829, + "POP_RANK": 15, + "GDP_MD_EST": 73450, + "POP_YEAR": 2017, + "LASTCENSUS": 2004, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "YM", + "ISO_A2": "YE", + "ISO_A3": "YEM", + "ISO_A3_EH": "YEM", + "ISO_N3": "887", + "UN_A3": "887", + "WB_A2": "RY", + "WB_A3": "YEM", + "WOE_ID": 23425002, + "WOE_ID_EH": 23425002, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "YEM", + "ADM0_A3_US": "YEM", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Asia", + "REGION_UN": "Asia", + "SUBREGION": "Western Asia", + "REGION_WB": "Middle East & North Africa", + "NAME_LEN": 5, + "LONG_LEN": 5, + "ABBREV_LEN": 4, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [42.604873, 12.58595, 53.108573, 19.000003], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [52.00001, 19.000003], + [52.782184, 17.349742], + [53.108573, 16.651051], + [52.385206, 16.382411], + [52.191729, 15.938433], + [52.168165, 15.59742], + [51.172515, 15.17525], + [49.574576, 14.708767], + [48.679231, 14.003202], + [48.238947, 13.94809], + [47.938914, 14.007233], + [47.354454, 13.59222], + [46.717076, 13.399699], + [45.877593, 13.347764], + [45.62505, 13.290946], + [45.406459, 13.026905], + [45.144356, 12.953938], + [44.989533, 12.699587], + [44.494576, 12.721653], + [44.175113, 12.58595], + [43.482959, 12.6368], + [43.222871, 13.22095], + [43.251448, 13.767584], + [43.087944, 14.06263], + [42.892245, 14.802249], + [42.604873, 15.213335], + [42.805015, 15.261963], + [42.702438, 15.718886], + [42.823671, 15.911742], + [42.779332, 16.347891], + [43.218375, 16.66689], + [43.115798, 17.08844], + [43.380794, 17.579987], + [43.791519, 17.319977], + [44.062613, 17.410359], + [45.216651, 17.433329], + [45.399999, 17.333335], + [46.366659, 17.233315], + [46.749994, 17.283338], + [47.000005, 16.949999], + [47.466695, 17.116682], + [48.183344, 18.166669], + [49.116672, 18.616668], + [52.00001, 19.000003] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 2, + "SOVEREIGNT": "South Africa", + "SOV_A3": "ZAF", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "South Africa", + "ADM0_A3": "ZAF", + "GEOU_DIF": 0, + "GEOUNIT": "South Africa", + "GU_A3": "ZAF", + "SU_DIF": 0, + "SUBUNIT": "South Africa", + "SU_A3": "ZAF", + "BRK_DIFF": 0, + "NAME": "South Africa", + "NAME_LONG": "South Africa", + "BRK_A3": "ZAF", + "BRK_NAME": "South Africa", + "BRK_GROUP": null, + "ABBREV": "S.Af.", + "POSTAL": "ZA", + "FORMAL_EN": "Republic of South Africa", + "FORMAL_FR": null, + "NAME_CIAWF": "South Africa", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "South Africa", + "NAME_ALT": null, + "MAPCOLOR7": 2, + "MAPCOLOR8": 3, + "MAPCOLOR9": 4, + "MAPCOLOR13": 2, + "POP_EST": 54841552, + "POP_RANK": 16, + "GDP_MD_EST": 739100, + "POP_YEAR": 2017, + "LASTCENSUS": 2001, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "3. Upper middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "SF", + "ISO_A2": "ZA", + "ISO_A3": "ZAF", + "ISO_A3_EH": "ZAF", + "ISO_N3": "710", + "UN_A3": "710", + "WB_A2": "ZA", + "WB_A3": "ZAF", + "WOE_ID": 23424942, + "WOE_ID_EH": 23424942, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ZAF", + "ADM0_A3_US": "ZAF", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Southern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 12, + "LONG_LEN": 12, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 1.7, + "MAX_LABEL": 6.7 + }, + "bbox": [16.344977, -34.819166, 32.83012, -22.091313], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [19.895768, -24.76779], + [20.165726, -24.917962], + [20.758609, -25.868136], + [20.66647, -26.477453], + [20.889609, -26.828543], + [21.605896, -26.726534], + [22.105969, -26.280256], + [22.579532, -25.979448], + [22.824271, -25.500459], + [23.312097, -25.26869], + [23.73357, -25.390129], + [24.211267, -25.670216], + [25.025171, -25.71967], + [25.664666, -25.486816], + [25.765849, -25.174845], + [25.941652, -24.696373], + [26.485753, -24.616327], + [26.786407, -24.240691], + [27.11941, -23.574323], + [28.017236, -22.827754], + [29.432188, -22.091313], + [29.839037, -22.102216], + [30.322883, -22.271612], + [30.659865, -22.151567], + [31.191409, -22.25151], + [31.670398, -23.658969], + [31.930589, -24.369417], + [31.752408, -25.484284], + [31.837778, -25.843332], + [31.333158, -25.660191], + [31.04408, -25.731452], + [30.949667, -26.022649], + [30.676609, -26.398078], + [30.685962, -26.743845], + [31.282773, -27.285879], + [31.86806, -27.177927], + [32.071665, -26.73382], + [32.83012, -26.742192], + [32.580265, -27.470158], + [32.462133, -28.301011], + [32.203389, -28.752405], + [31.521001, -29.257387], + [31.325561, -29.401978], + [30.901763, -29.909957], + [30.622813, -30.423776], + [30.055716, -31.140269], + [28.925553, -32.172041], + [28.219756, -32.771953], + [27.464608, -33.226964], + [26.419452, -33.61495], + [25.909664, -33.66704], + [25.780628, -33.944646], + [25.172862, -33.796851], + [24.677853, -33.987176], + [23.594043, -33.794474], + [22.988189, -33.916431], + [22.574157, -33.864083], + [21.542799, -34.258839], + [20.689053, -34.417175], + [20.071261, -34.795137], + [19.616405, -34.819166], + [19.193278, -34.462599], + [18.855315, -34.444306], + [18.424643, -33.997873], + [18.377411, -34.136521], + [18.244499, -33.867752], + [18.25008, -33.281431], + [17.92519, -32.611291], + [18.24791, -32.429131], + [18.221762, -31.661633], + [17.566918, -30.725721], + [17.064416, -29.878641], + [17.062918, -29.875954], + [16.344977, -28.576705], + [16.824017, -28.082162], + [17.218929, -28.355943], + [17.387497, -28.783514], + [17.836152, -28.856378], + [18.464899, -29.045462], + [19.002127, -28.972443], + [19.894734, -28.461105], + [19.895768, -24.76779] + ], + [ + [28.978263, -28.955597], + [28.5417, -28.647502], + [28.074338, -28.851469], + [27.532511, -29.242711], + [26.999262, -29.875954], + [27.749397, -30.645106], + [28.107205, -30.545732], + [28.291069, -30.226217], + [28.8484, -30.070051], + [29.018415, -29.743766], + [29.325166, -29.257387], + [28.978263, -28.955597] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Zambia", + "SOV_A3": "ZMB", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Zambia", + "ADM0_A3": "ZMB", + "GEOU_DIF": 0, + "GEOUNIT": "Zambia", + "GU_A3": "ZMB", + "SU_DIF": 0, + "SUBUNIT": "Zambia", + "SU_A3": "ZMB", + "BRK_DIFF": 0, + "NAME": "Zambia", + "NAME_LONG": "Zambia", + "BRK_A3": "ZMB", + "BRK_NAME": "Zambia", + "BRK_GROUP": null, + "ABBREV": "Zambia", + "POSTAL": "ZM", + "FORMAL_EN": "Republic of Zambia", + "FORMAL_FR": null, + "NAME_CIAWF": "Zambia", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Zambia", + "NAME_ALT": null, + "MAPCOLOR7": 5, + "MAPCOLOR8": 8, + "MAPCOLOR9": 5, + "MAPCOLOR13": 13, + "POP_EST": 15972000, + "POP_RANK": 14, + "GDP_MD_EST": 65170, + "POP_YEAR": 2017, + "LASTCENSUS": 2010, + "GDP_YEAR": 2016, + "ECONOMY": "7. Least developed region", + "INCOME_GRP": "4. Lower middle income", + "WIKIPEDIA": -99, + "FIPS_10_": "ZA", + "ISO_A2": "ZM", + "ISO_A3": "ZMB", + "ISO_A3_EH": "ZMB", + "ISO_N3": "894", + "UN_A3": "894", + "WB_A2": "ZM", + "WB_A3": "ZMB", + "WOE_ID": 23425003, + "WOE_ID_EH": 23425003, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ZMB", + "ADM0_A3_US": "ZMB", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 6, + "LONG_LEN": 6, + "ABBREV_LEN": 6, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [21.887843, -17.961229, 33.485688, -8.238257], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.215048, -17.523116], + [22.562478, -16.898451], + [21.887843, -16.08031], + [21.933886, -12.898437], + [24.016137, -12.911046], + [23.930922, -12.565848], + [24.079905, -12.191297], + [23.904154, -11.722282], + [24.017894, -11.237298], + [23.912215, -10.926826], + [24.257155, -10.951993], + [24.314516, -11.262826], + [24.78317, -11.238694], + [25.418118, -11.330936], + [25.75231, -11.784965], + [26.553088, -11.92444], + [27.16442, -11.608748], + [27.388799, -12.132747], + [28.155109, -12.272481], + [28.523562, -12.698604], + [28.934286, -13.248958], + [29.699614, -13.257227], + [29.616001, -12.178895], + [29.341548, -12.360744], + [28.642417, -11.971569], + [28.372253, -11.793647], + [28.49607, -10.789884], + [28.673682, -9.605925], + [28.449871, -9.164918], + [28.734867, -8.526559], + [29.002912, -8.407032], + [30.346086, -8.238257], + [30.74001, -8.340006], + [31.157751, -8.594579], + [31.556348, -8.762049], + [32.191865, -8.930359], + [32.759375, -9.230599], + [33.231388, -9.676722], + [33.485688, -10.525559], + [33.31531, -10.79655], + [33.114289, -11.607198], + [33.306422, -12.435778], + [32.991764, -12.783871], + [32.688165, -13.712858], + [33.214025, -13.97186], + [30.179481, -14.796099], + [30.274256, -15.507787], + [29.516834, -15.644678], + [28.947463, -16.043051], + [28.825869, -16.389749], + [28.467906, -16.4684], + [27.598243, -17.290831], + [27.044427, -17.938026], + [26.706773, -17.961229], + [26.381935, -17.846042], + [25.264226, -17.73654], + [25.084443, -17.661816], + [25.07695, -17.578823], + [24.682349, -17.353411], + [24.033862, -17.295843], + [23.215048, -17.523116] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 1, + "featurecla": "Admin-0 country", + "LABELRANK": 3, + "SOVEREIGNT": "Zimbabwe", + "SOV_A3": "ZWE", + "ADM0_DIF": 0, + "LEVEL": 2, + "TYPE": "Sovereign country", + "ADMIN": "Zimbabwe", + "ADM0_A3": "ZWE", + "GEOU_DIF": 0, + "GEOUNIT": "Zimbabwe", + "GU_A3": "ZWE", + "SU_DIF": 0, + "SUBUNIT": "Zimbabwe", + "SU_A3": "ZWE", + "BRK_DIFF": 0, + "NAME": "Zimbabwe", + "NAME_LONG": "Zimbabwe", + "BRK_A3": "ZWE", + "BRK_NAME": "Zimbabwe", + "BRK_GROUP": null, + "ABBREV": "Zimb.", + "POSTAL": "ZW", + "FORMAL_EN": "Republic of Zimbabwe", + "FORMAL_FR": null, + "NAME_CIAWF": "Zimbabwe", + "NOTE_ADM0": null, + "NOTE_BRK": null, + "NAME_SORT": "Zimbabwe", + "NAME_ALT": null, + "MAPCOLOR7": 1, + "MAPCOLOR8": 5, + "MAPCOLOR9": 3, + "MAPCOLOR13": 9, + "POP_EST": 13805084, + "POP_RANK": 14, + "GDP_MD_EST": 28330, + "POP_YEAR": 2017, + "LASTCENSUS": 2002, + "GDP_YEAR": 2016, + "ECONOMY": "5. Emerging region: G20", + "INCOME_GRP": "5. Low income", + "WIKIPEDIA": -99, + "FIPS_10_": "ZI", + "ISO_A2": "ZW", + "ISO_A3": "ZWE", + "ISO_A3_EH": "ZWE", + "ISO_N3": "716", + "UN_A3": "716", + "WB_A2": "ZW", + "WB_A3": "ZWE", + "WOE_ID": 23425004, + "WOE_ID_EH": 23425004, + "WOE_NOTE": "Exact WOE match as country", + "ADM0_A3_IS": "ZWE", + "ADM0_A3_US": "ZWE", + "ADM0_A3_UN": -99, + "ADM0_A3_WB": -99, + "CONTINENT": "Africa", + "REGION_UN": "Africa", + "SUBREGION": "Eastern Africa", + "REGION_WB": "Sub-Saharan Africa", + "NAME_LEN": 8, + "LONG_LEN": 8, + "ABBREV_LEN": 5, + "TINY": -99, + "HOMEPART": 1, + "MIN_ZOOM": 0, + "MIN_LABEL": 3, + "MAX_LABEL": 8 + }, + "bbox": [25.264226, -22.271612, 32.849861, -15.507787], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [29.432188, -22.091313], + [28.794656, -21.639454], + [28.02137, -21.485975], + [27.727228, -20.851802], + [27.724747, -20.499059], + [27.296505, -20.39152], + [26.164791, -19.293086], + [25.850391, -18.714413], + [25.649163, -18.536026], + [25.264226, -17.73654], + [26.381935, -17.846042], + [26.706773, -17.961229], + [27.044427, -17.938026], + [27.598243, -17.290831], + [28.467906, -16.4684], + [28.825869, -16.389749], + [28.947463, -16.043051], + [29.516834, -15.644678], + [30.274256, -15.507787], + [30.338955, -15.880839], + [31.173064, -15.860944], + [31.636498, -16.07199], + [31.852041, -16.319417], + [32.328239, -16.392074], + [32.847639, -16.713398], + [32.849861, -17.979057], + [32.654886, -18.67209], + [32.611994, -19.419383], + [32.772708, -19.715592], + [32.659743, -20.30429], + [32.508693, -20.395292], + [32.244988, -21.116489], + [31.191409, -22.25151], + [30.659865, -22.151567], + [30.322883, -22.271612], + [29.839037, -22.102216], + [29.432188, -22.091313] + ] + ] + } + } + ], + "bbox": [-180, -90, 180, 83.64513] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx new file mode 100644 index 00000000..9006702e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx @@ -0,0 +1,34 @@ +// ©AngelaMos | 2026 +// App.tsx + +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'sonner' + +import { queryClient } from '@/core/api' +import { router } from '@/core/app/routers' + +export default function App(): React.ReactElement { + return ( + + + + + + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..920d21f6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts @@ -0,0 +1,19 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './useAdmin' +export * from './useAlerts' +export * from './useAuth' +export * from './useBgpHijackData' +export * from './useCoinbasePrices' +export * from './useCveData' +export * from './useDShieldData' +export * from './useEarthquakeData' +export * from './useIssPosition' +export * from './useKevData' +export * from './useOutageData' +export * from './useRansomwareData' +export * from './useSpaceWeather' +export * from './useUsers' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts new file mode 100644 index 00000000..753787e2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts @@ -0,0 +1,219 @@ +// =================== +// © AngelaMos | 2025 +// useAdmin.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + type AdminUserCreateRequest, + type AdminUserUpdateRequest, + isValidUserListResponse, + isValidUserResponse, + USER_ERROR_MESSAGES, + USER_SUCCESS_MESSAGES, + type UserListResponse, + type UserResponse, + UserResponseError, +} from '@/api/types' +import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const adminQueries = { + all: () => QUERY_KEYS.ADMIN.ALL, + users: { + all: () => QUERY_KEYS.ADMIN.USERS.ALL(), + list: (page: number, size: number) => QUERY_KEYS.ADMIN.USERS.LIST(page, size), + byId: (id: string) => QUERY_KEYS.ADMIN.USERS.BY_ID(id), + }, +} as const + +interface UseAdminUsersParams { + page?: number + size?: number +} + +const fetchAdminUsers = async ( + page: number, + size: number +): Promise => { + const response = await apiClient.get(API_ENDPOINTS.ADMIN.USERS.LIST, { + params: { page, page_size: size }, + }) + const data: unknown = response.data + + if (!isValidUserListResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_LIST_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.LIST + ) + } + + return data +} + +export const useAdminUsers = ( + params: UseAdminUsersParams = {} +): UseQueryResult => { + const page = params.page ?? PAGINATION.DEFAULT_PAGE + const size = params.size ?? PAGINATION.DEFAULT_SIZE + + return useQuery({ + queryKey: adminQueries.users.list(page, size), + queryFn: () => fetchAdminUsers(page, size), + ...QUERY_STRATEGIES.standard, + }) +} + +const fetchAdminUserById = async (id: string): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.ADMIN.USERS.BY_ID(id) + ) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.BY_ID(id) + ) + } + + return data +} + +export const useAdminUser = (id: string): UseQueryResult => { + return useQuery({ + queryKey: adminQueries.users.byId(id), + queryFn: () => fetchAdminUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performAdminCreateUser = async ( + data: AdminUserCreateRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.ADMIN.USERS.CREATE, + data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.CREATE + ) + } + + return responseData +} + +export const useAdminCreateUser = (): UseMutationResult< + UserResponse, + Error, + AdminUserCreateRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminCreateUser, + onSuccess: async (): Promise => { + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + + toast.success(USER_SUCCESS_MESSAGES.CREATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_CREATE + toast.error(message) + }, + }) +} + +interface AdminUpdateUserParams { + id: string + data: AdminUserUpdateRequest +} + +const performAdminUpdateUser = async ( + params: AdminUpdateUserParams +): Promise => { + const response = await apiClient.put( + API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id), + params.data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id) + ) + } + + return responseData +} + +export const useAdminUpdateUser = (): UseMutationResult< + UserResponse, + Error, + AdminUpdateUserParams +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminUpdateUser, + onSuccess: async ( + data: UserResponse, + variables: AdminUpdateUserParams + ): Promise => { + queryClient.setQueryData(adminQueries.users.byId(variables.id), data) + + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + + toast.success(USER_SUCCESS_MESSAGES.UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_UPDATE + toast.error(message) + }, + }) +} + +const performAdminDeleteUser = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.ADMIN.USERS.DELETE(id)) +} + +export const useAdminDeleteUser = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminDeleteUser, + onSuccess: async (_, id: string): Promise => { + queryClient.removeQueries({ queryKey: adminQueries.users.byId(id) }) + + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + + toast.success(USER_SUCCESS_MESSAGES.DELETED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_DELETE + toast.error(message) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAlerts.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAlerts.ts new file mode 100644 index 00000000..7b1e3820 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAlerts.ts @@ -0,0 +1,111 @@ +// =================== +// © AngelaMos | 2026 +// useAlerts.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { apiClient } from '@/core/api' + +export interface AlertRule { + id: string + name: string + topic: string + predicate: string + cooldown_sec: number + enabled: boolean + created_at: string + updated_at: string +} + +const ALERT_RULES_KEY = ['me', 'alerts'] as const +const ALERT_HISTORY_KEY = ['me', 'alerts', 'history'] as const + +const fetchRules = async (): Promise => { + const res = await apiClient.get('/v1/me/alerts/') + return Array.isArray(res.data) ? res.data : [] +} + +export const useAlertRules = (): UseQueryResult => + useQuery({ + queryKey: ALERT_RULES_KEY, + queryFn: fetchRules, + staleTime: 30_000, + }) + +interface UpdateRuleVars { + id: string + patch: Partial< + Pick + > +} + +const performUpdateRule = async ({ + id, + patch, +}: UpdateRuleVars): Promise => { + const res = await apiClient.patch(`/v1/me/alerts/${id}`, patch) + return res.data +} + +export const useUpdateAlertRule = (): UseMutationResult< + AlertRule, + Error, + UpdateRuleVars +> => { + const qc = useQueryClient() + return useMutation({ + mutationFn: performUpdateRule, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ALERT_RULES_KEY }) + }, + onError: () => { + toast.error('Failed to update alert rule') + }, + }) +} + +const performDeleteRule = async (id: string): Promise => { + await apiClient.delete(`/v1/me/alerts/${id}`) +} + +export const useDeleteAlertRule = (): UseMutationResult => { + const qc = useQueryClient() + return useMutation({ + mutationFn: performDeleteRule, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ALERT_RULES_KEY }) + toast.success('Rule removed') + }, + onError: () => { + toast.error('Failed to remove rule') + }, + }) +} + +export interface AlertHistoryEntry { + id: number + rule_id: string + fired_at: string + delivered_to: string[] | null +} + +const fetchHistory = async (): Promise => { + const res = await apiClient.get( + '/v1/me/alerts/history?limit=20' + ) + return Array.isArray(res.data) ? res.data : [] +} + +export const useAlertHistory = (): UseQueryResult => + useQuery({ + queryKey: ALERT_HISTORY_KEY, + queryFn: fetchHistory, + staleTime: 15_000, + }) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts new file mode 100644 index 00000000..66f79b17 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts @@ -0,0 +1,213 @@ +// =================== +// © AngelaMos | 2025 +// useAuth.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + AUTH_ERROR_MESSAGES, + AUTH_SUCCESS_MESSAGES, + AuthResponseError, + isValidTokenWithUserResponse, + isValidUserResponse, + type LoginRequest, + type PasswordChangeRequest, + type TokenWithUserResponse, + type UserResponse, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS, ROUTES } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' +import { useAuthStore } from '@/core/lib' + +export const authQueries = { + all: () => QUERY_KEYS.AUTH.ALL, + me: () => QUERY_KEYS.AUTH.ME(), +} as const + +const fetchCurrentUser = async (): Promise => { + const response = await apiClient.get(API_ENDPOINTS.AUTH.ME) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.AUTH.ME + ) + } + + return data +} + +export const useCurrentUser = (): UseQueryResult => { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + + return useQuery({ + queryKey: authQueries.me(), + queryFn: fetchCurrentUser, + enabled: isAuthenticated, + ...QUERY_STRATEGIES.auth, + }) +} + +const performLogin = async ( + credentials: LoginRequest +): Promise => { + const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, { + email: credentials.email, + password: credentials.password, + }) + + const data: unknown = response.data + + if (!isValidTokenWithUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_LOGIN_RESPONSE, + API_ENDPOINTS.AUTH.LOGIN + ) + } + + return data +} + +export const useLogin = (): UseMutationResult< + TokenWithUserResponse, + Error, + LoginRequest +> => { + const queryClient = useQueryClient() + const login = useAuthStore((s) => s.login) + + return useMutation({ + mutationFn: performLogin, + onSuccess: (data: TokenWithUserResponse): void => { + login(data.user, data.tokens.access_token) + + queryClient.setQueryData(authQueries.me(), data.user) + + toast.success(AUTH_SUCCESS_MESSAGES.WELCOME_BACK(data.user.name)) + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError ? error.message : 'Login failed' + toast.error(message) + }, + }) +} + +const performLogout = async (): Promise => { + // Refresh-token cookie is sent automatically by axios via withCredentials. + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT, {}) +} + +export const useLogout = (): UseMutationResult => { + const queryClient = useQueryClient() + const logout = useAuthStore((s) => s.logout) + + return useMutation({ + mutationFn: performLogout, + onSuccess: (): void => { + logout() + + queryClient.removeQueries({ queryKey: authQueries.all() }) + + toast.success(AUTH_SUCCESS_MESSAGES.LOGOUT_SUCCESS) + + window.location.href = ROUTES.LOGIN + }, + onError: (): void => { + logout() + queryClient.removeQueries({ queryKey: authQueries.all() }) + window.location.href = ROUTES.LOGIN + }, + }) +} + +const performLogoutAll = async (): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT_ALL) +} + +export const useLogoutAll = (): UseMutationResult => { + const queryClient = useQueryClient() + const logout = useAuthStore((s) => s.logout) + + return useMutation({ + mutationFn: performLogoutAll, + onSuccess: (): void => { + logout() + + queryClient.removeQueries({ queryKey: authQueries.all() }) + + toast.success('Logged out from all sessions') + + window.location.href = ROUTES.LOGIN + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError + ? error.message + : 'Failed to logout all sessions' + toast.error(message) + }, + }) +} + +const performPasswordChange = async ( + data: PasswordChangeRequest +): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, data) +} + +export const useChangePassword = (): UseMutationResult< + void, + Error, + PasswordChangeRequest +> => { + return useMutation({ + mutationFn: performPasswordChange, + onSuccess: (): void => { + toast.success(AUTH_SUCCESS_MESSAGES.PASSWORD_CHANGED) + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError + ? error.message + : 'Failed to change password' + toast.error(message) + }, + }) +} + +export const useRefreshAuth = (): (() => Promise) => { + const queryClient = useQueryClient() + const { login, logout } = useAuthStore() + + return async (): Promise => { + try { + const response = await apiClient.post( + API_ENDPOINTS.AUTH.REFRESH, + {} + ) + const data: unknown = response.data + + if (!isValidTokenWithUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_TOKEN_RESPONSE, + API_ENDPOINTS.AUTH.REFRESH + ) + } + + login(data.user, data.tokens.access_token) + queryClient.setQueryData(authQueries.me(), data.user) + } catch { + logout() + queryClient.removeQueries({ queryKey: authQueries.all() }) + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts new file mode 100644 index 00000000..64ded5b6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useBgpHijackData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { type BgpHijack, isValidBgpHijack } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useBgpHijackStore } from '@/stores/bgpHijack' + +interface BgpHijackData { + items: BgpHijack[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentHijacks = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.HIJACKS, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidBgpHijack) +} + +export function useBgpHijackData(): BgpHijackData { + const items = useBgpHijackStore((s) => s.items) + const push = useBgpHijackStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.HIJACKS(), + queryFn: fetchRecentHijacks, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCoinbasePrices.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCoinbasePrices.ts new file mode 100644 index 00000000..9062a2af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCoinbasePrices.ts @@ -0,0 +1,36 @@ +// ©AngelaMos | 2026 +// useCoinbasePrices.ts + +import { useEffect } from 'react' +import { useSnapshot } from '@/api/snapshot' +import { isValidCoinbasePriceMap } from '@/api/types' +import { type MinuteBar, type PriceTick, usePrices } from '@/stores/prices' + +const EMPTY_BARS: MinuteBar[] = [] + +interface CoinbasePricesData { + latest: PriceTick | undefined + history: MinuteBar[] +} + +export function useCoinbasePrices(symbol: string): CoinbasePricesData { + const { data } = useSnapshot() + const latest = usePrices((s) => s.latest[symbol]) + const historyRaw = usePrices((s) => s.history[symbol]) + const history = historyRaw ?? EMPTY_BARS + const pushTick = usePrices((s) => s.pushTick) + + const priceMap = data?.coinbase_price + const seed = isValidCoinbasePriceMap(priceMap) ? priceMap[symbol] : undefined + useEffect(() => { + if (!seed) return + pushTick({ + symbol: seed.symbol, + ts: new Date(seed.ts).getTime(), + price: seed.price, + volume24h: seed.volume_24h, + }) + }, [seed, pushTick]) + + return { latest, history } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCveData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCveData.ts new file mode 100644 index 00000000..12c20faa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCveData.ts @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// useCveData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { type CveEvent, isValidCveEvent } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useCveStore } from '@/stores/cve' + +interface CveData { + items: CveEvent[] +} + +const INTEL_LIMIT = 200 +const STALE_MS = 5 * 60_000 + +const fetchRecentCVEs = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.CVES, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidCveEvent) +} + +export function useCveData(): CveData { + const items = useCveStore((s) => s.items) + const push = useCveStore((s) => s.push) + + // /intel/cves backfills the panel on cold load. WS deltas (cve_new) layer + // on top via the dashboard lifecycle. Snapshot's single-event slot is no + // longer the source of truth here. + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.CVES(), + queryFn: fetchRecentCVEs, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useDShieldData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useDShieldData.ts new file mode 100644 index 00000000..6952b469 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useDShieldData.ts @@ -0,0 +1,11 @@ +// ©AngelaMos | 2026 +// useDShieldData.ts + +import { useSnapshot } from '@/api/snapshot' +import { type DShieldData, isValidDShieldData } from '@/api/types' + +export function useDShieldData(): DShieldData { + const { data } = useSnapshot() + const raw = data?.scan_firehose + return isValidDShieldData(raw) ? raw : {} +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts new file mode 100644 index 00000000..0dcf90f3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useEarthquakeData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { type EarthquakePayload, isValidEarthquakePayload } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useEarthquakeStore } from '@/stores/earthquake' + +interface EarthquakeData { + items: EarthquakePayload[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentQuakes = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.QUAKES, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidEarthquakePayload) +} + +export function useEarthquakeData(): EarthquakeData { + const items = useEarthquakeStore((s) => s.items) + const push = useEarthquakeStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.QUAKES(), + queryFn: fetchRecentQuakes, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useIssPosition.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useIssPosition.ts new file mode 100644 index 00000000..73bdefd1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useIssPosition.ts @@ -0,0 +1,11 @@ +// ©AngelaMos | 2026 +// useIssPosition.ts + +import { useSnapshot } from '@/api/snapshot' +import { type IssPosition, isValidIssPosition } from '@/api/types' + +export function useIssPosition(): IssPosition | undefined { + const { data } = useSnapshot() + const raw = data?.iss_position + return isValidIssPosition(raw) ? raw : undefined +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useKevData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useKevData.ts new file mode 100644 index 00000000..58a9443c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useKevData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useKevData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { isValidKevEntry, type KevEntry } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useKevStore } from '@/stores/kev' + +interface KevData { + items: KevEntry[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 5 * 60_000 + +const fetchRecentKEV = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.KEV, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidKevEntry) +} + +export function useKevData(): KevData { + const items = useKevStore((s) => s.items) + const push = useKevStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.KEV(), + queryFn: fetchRecentKEV, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useNotifications.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useNotifications.ts new file mode 100644 index 00000000..f0e75ab1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useNotifications.ts @@ -0,0 +1,222 @@ +// =================== +// © AngelaMos | 2026 +// useNotifications.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + type ChannelListResponse, + type ChannelResponse, + type CreateChannelRequest, + isValidChannelListResponse, + isValidTelegramStatus, + NOTIFICATION_ERROR_MESSAGES, + NOTIFICATION_SUCCESS_MESSAGES, + type RegisterTelegramRequest, + type RegisterTelegramResponse, + type TelegramStatusResponse, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' + +export const notificationQueries = { + channels: () => QUERY_KEYS.NOTIFICATIONS.CHANNELS(), + telegramStatus: () => QUERY_KEYS.NOTIFICATIONS.TELEGRAM_STATUS(), +} as const + +const fetchChannels = async (): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.NOTIFICATIONS.CHANNELS + ) + const data: unknown = response.data + + if (!isValidChannelListResponse(data)) { + throw new Error(NOTIFICATION_ERROR_MESSAGES.INVALID_RESPONSE) + } + + return data +} + +export const useNotificationChannels = (): UseQueryResult< + ChannelListResponse, + Error +> => { + return useQuery({ + queryKey: notificationQueries.channels(), + queryFn: fetchChannels, + staleTime: 1000 * 30, + }) +} + +const fetchTelegramStatus = async (): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.NOTIFICATIONS.TELEGRAM_STATUS + ) + const data: unknown = response.data + + if (!isValidTelegramStatus(data)) { + throw new Error(NOTIFICATION_ERROR_MESSAGES.INVALID_RESPONSE) + } + + return data +} + +export const useTelegramStatus = (): UseQueryResult< + TelegramStatusResponse, + Error +> => { + return useQuery({ + queryKey: notificationQueries.telegramStatus(), + queryFn: fetchTelegramStatus, + staleTime: 1000 * 15, + }) +} + +const performCreateChannel = async ( + data: CreateChannelRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.NOTIFICATIONS.CHANNELS, + data + ) + return response.data +} + +export const useCreateChannel = (): UseMutationResult< + ChannelResponse, + Error, + CreateChannelRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performCreateChannel, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.CHANNEL_ADDED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.CREATE_FAILED) + }, + }) +} + +const performDeleteChannel = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.NOTIFICATIONS.CHANNEL_BY_ID(id)) +} + +export const useDeleteChannel = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performDeleteChannel, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.CHANNEL_DELETED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.DELETE_FAILED) + }, + }) +} + +const performTestChannel = async (id: string): Promise => { + await apiClient.post(API_ENDPOINTS.NOTIFICATIONS.CHANNEL_TEST(id)) +} + +export const useTestChannel = (): UseMutationResult => { + return useMutation({ + mutationFn: performTestChannel, + onSuccess: (): void => { + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TEST_SENT) + }, + onError: (error: Error): void => { + toast.error(`${NOTIFICATION_ERROR_MESSAGES.TEST_FAILED}: ${error.message}`) + }, + }) +} + +const performRegisterTelegram = async ( + data: RegisterTelegramRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.NOTIFICATIONS.TELEGRAM, + data + ) + return response.data +} + +export const useRegisterTelegram = (): UseMutationResult< + RegisterTelegramResponse, + Error, + RegisterTelegramRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performRegisterTelegram, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + void queryClient.invalidateQueries({ + queryKey: notificationQueries.telegramStatus(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TELEGRAM_REGISTERED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.REGISTER_TELEGRAM_FAILED) + }, + }) +} + +const performUnlinkTelegram = async (): Promise => { + await apiClient.delete(API_ENDPOINTS.NOTIFICATIONS.TELEGRAM) +} + +export const useUnlinkTelegram = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performUnlinkTelegram, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + void queryClient.invalidateQueries({ + queryKey: notificationQueries.telegramStatus(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TELEGRAM_UNLINKED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.UNLINK_TELEGRAM_FAILED) + }, + }) +} + +const performTestTelegram = async (): Promise => { + await apiClient.post(API_ENDPOINTS.NOTIFICATIONS.TELEGRAM_TEST) +} + +export const useTestTelegram = (): UseMutationResult => { + return useMutation({ + mutationFn: performTestTelegram, + onSuccess: (): void => { + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TEST_SENT) + }, + onError: (error: Error): void => { + toast.error(`${NOTIFICATION_ERROR_MESSAGES.TEST_FAILED}: ${error.message}`) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts new file mode 100644 index 00000000..56a448fa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useOutageData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { type InternetOutage, isValidInternetOutage } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useOutageStore } from '@/stores/outage' + +interface OutageData { + items: InternetOutage[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentOutages = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.OUTAGES, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidInternetOutage) +} + +export function useOutageData(): OutageData { + const items = useOutageStore((s) => s.items) + const push = useOutageStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.OUTAGES(), + queryFn: fetchRecentOutages, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useRansomwareData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useRansomwareData.ts new file mode 100644 index 00000000..2136aa3b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useRansomwareData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useRansomwareData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { isValidRansomwareVictim, type RansomwareVictim } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useRansomwareStore } from '@/stores/ransomware' + +interface RansomwareData { + items: RansomwareVictim[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentRansomware = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.RANSOMWARE, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidRansomwareVictim) +} + +export function useRansomwareData(): RansomwareData { + const items = useRansomwareStore((s) => s.items) + const push = useRansomwareStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.RANSOMWARE(), + queryFn: fetchRecentRansomware, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useSpaceWeather.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useSpaceWeather.ts new file mode 100644 index 00000000..9b669195 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useSpaceWeather.ts @@ -0,0 +1,11 @@ +// ©AngelaMos | 2026 +// useSpaceWeather.ts + +import { useSnapshot } from '@/api/snapshot' +import { isValidSpaceWeatherData, type SpaceWeatherData } from '@/api/types' + +export function useSpaceWeather(): SpaceWeatherData { + const { data } = useSnapshot() + const raw = data?.space_weather + return isValidSpaceWeatherData(raw) ? raw : {} +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts new file mode 100644 index 00000000..36e35be9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts @@ -0,0 +1,142 @@ +// =================== +// © AngelaMos | 2025 +// useUsers.ts +// =================== + +import { + type UseMutationResult, + useMutation, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + isValidUserResponse, + USER_ERROR_MESSAGES, + USER_SUCCESS_MESSAGES, + type UserCreateRequest, + type UserResponse, + UserResponseError, + type UserUpdateRequest, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useAuthStore } from '@/core/lib' +import { authQueries } from './useAuth' + +export const userQueries = { + all: () => QUERY_KEYS.USERS.ALL, + me: () => QUERY_KEYS.USERS.ME(), +} as const + +const performRegister = async (data: UserCreateRequest): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.REGISTER, data) +} + +export const useRegister = (): UseMutationResult< + void, + Error, + UserCreateRequest +> => { + return useMutation({ + mutationFn: performRegister, + onSuccess: (): void => { + toast.success(USER_SUCCESS_MESSAGES.REGISTERED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_CREATE + toast.error(message) + }, + }) +} + +const performUpdateProfile = async ( + data: UserUpdateRequest +): Promise => { + const response = await apiClient.put(API_ENDPOINTS.USERS.ME, data) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.ME + ) + } + + return responseData +} + +export const useUpdateProfile = (): UseMutationResult< + UserResponse, + Error, + UserUpdateRequest +> => { + const queryClient = useQueryClient() + const updateUser = useAuthStore((s) => s.updateUser) + + return useMutation({ + mutationFn: performUpdateProfile, + onSuccess: (data: UserResponse): void => { + updateUser(data) + + queryClient.setQueryData(authQueries.me(), data) + queryClient.setQueryData(userQueries.me(), data) + + toast.success(USER_SUCCESS_MESSAGES.PROFILE_UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_UPDATE + toast.error(message) + }, + }) +} + +interface UpdateEmailVars { + current_password: string + new_email: string +} + +const performUpdateEmail = async ( + data: UpdateEmailVars +): Promise => { + const response = await apiClient.put(API_ENDPOINTS.USERS.EMAIL, data) + const responseData: unknown = response.data + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.EMAIL + ) + } + return responseData +} + +export const useUpdateEmail = (): UseMutationResult< + UserResponse, + Error, + UpdateEmailVars +> => { + const queryClient = useQueryClient() + const updateUser = useAuthStore((s) => s.updateUser) + + return useMutation({ + mutationFn: performUpdateEmail, + onSuccess: (data: UserResponse): void => { + updateUser(data) + queryClient.setQueryData(authQueries.me(), data) + queryClient.setQueryData(userQueries.me(), data) + toast.success('Email updated') + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : 'Failed to update email' + toast.error(message) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts new file mode 100644 index 00000000..0840a7c1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './hooks' +export * from './types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts new file mode 100644 index 00000000..3c4365da --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// snapshot.ts + +import { useQuery } from '@tanstack/react-query' +import { apiClient } from '@/core/api' + +export type Snapshot = Record + +export const SNAPSHOT_KEY = ['snapshot'] as const + +export function useSnapshot() { + return useQuery({ + queryKey: SNAPSHOT_KEY, + queryFn: async (): Promise => { + const res = await apiClient.get('/v1/snapshot') + return res.data + }, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts new file mode 100644 index 00000000..a4265fd3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts @@ -0,0 +1,135 @@ +// =================== +// © AngelaMos | 2025 +// auth.types.ts +// =================== + +import { z } from 'zod' +import { PASSWORD_CONSTRAINTS } from '@/config' + +export const UserRole = { + UNKNOWN: 'unknown', + USER: 'user', + ADMIN: 'admin', +} as const + +export type UserRole = (typeof UserRole)[keyof typeof UserRole] + +export const userResponseSchema = z.object({ + id: z.string().uuid(), + email: z.string().email(), + name: z.string(), + role: z.nativeEnum(UserRole), + tier: z.string(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().optional(), +}) + +export const tokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + token_type: z.string(), + expires_in: z.number(), + expires_at: z.string().datetime(), +}) + +export const tokenWithUserResponseSchema = z.object({ + user: userResponseSchema, + tokens: tokenResponseSchema, +}) + +export const loginRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), +}) + +export const registerRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).optional(), +}) + +export const passwordChangeRequestSchema = z.object({ + current_password: z.string(), + new_password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), +}) + +export const logoutAllResponseSchema = z.object({ + revoked_sessions: z.number(), +}) + +export type UserResponse = z.infer +export type TokenResponse = z.infer +export type TokenWithUserResponse = z.infer +export type LoginRequest = z.infer +export type RegisterRequest = z.infer +export type PasswordChangeRequest = z.infer +export type LogoutAllResponse = z.infer + +export const isValidUserResponse = (data: unknown): data is UserResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + return userResponseSchema.safeParse(data).success +} + +export const isValidTokenResponse = (data: unknown): data is TokenResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + return tokenResponseSchema.safeParse(data).success +} + +export const isValidTokenWithUserResponse = ( + data: unknown +): data is TokenWithUserResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + return tokenWithUserResponseSchema.safeParse(data).success +} + +export const isValidLogoutAllResponse = ( + data: unknown +): data is LogoutAllResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + return logoutAllResponseSchema.safeParse(data).success +} + +export class AuthResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'AuthResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, AuthResponseError.prototype) + } +} + +export const AUTH_ERROR_MESSAGES = { + INVALID_USER_RESPONSE: 'Invalid user data from server', + INVALID_LOGIN_RESPONSE: 'Invalid login response from server', + INVALID_TOKEN_RESPONSE: 'Invalid token response from server', + INVALID_LOGOUT_RESPONSE: 'Invalid logout response from server', + NO_REFRESH_TOKEN: 'No refresh token available', + SESSION_EXPIRED: 'Session expired', +} as const + +export const AUTH_SUCCESS_MESSAGES = { + WELCOME_BACK: (name: string | null) => + `Welcome back${name !== null ? `, ${name}` : ''}!`, + LOGOUT_SUCCESS: 'Logged out successfully', + PASSWORD_CHANGED: 'Password changed successfully', + REGISTERED: 'Account created successfully!', +} as const + +export type AuthErrorMessage = + (typeof AUTH_ERROR_MESSAGES)[keyof typeof AUTH_ERROR_MESSAGES] +export type AuthSuccessMessage = typeof AUTH_SUCCESS_MESSAGES diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/coinbase.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/coinbase.types.ts new file mode 100644 index 00000000..48d1fcdb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/coinbase.types.ts @@ -0,0 +1,32 @@ +// ©AngelaMos | 2026 +// coinbase.types.ts + +import { z } from 'zod' + +export const coinbaseTickSchema = z.object({ + symbol: z.string(), + ts: z.string(), + price: z.string(), + volume_24h: z.string().optional(), +}) + +export type CoinbaseTick = z.infer + +export const coinbasePriceMapSchema = z.record(z.string(), coinbaseTickSchema) + +export type CoinbasePriceMap = z.infer + +export const isValidCoinbaseTick = (data: unknown): data is CoinbaseTick => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return coinbaseTickSchema.safeParse(data).success +} + +export const isValidCoinbasePriceMap = ( + data: unknown +): data is CoinbasePriceMap => { + if (data === null || data === undefined || typeof data !== 'object') + return false + if (Array.isArray(data)) return false + return coinbasePriceMapSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/cve.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/cve.types.ts new file mode 100644 index 00000000..0df53d31 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/cve.types.ts @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// cve.types.ts + +import { z } from 'zod' + +export const cveEventSchema = z.object({ + CveID: z.string(), + Published: z.string(), + LastModified: z.string(), + Severity: z.string(), + CVSS: z.number().nullable(), + EPSSScore: z.number().nullable(), + EPSSPercentile: z.number().nullable(), + InKEV: z.boolean().optional(), +}) + +export type CveEvent = z.infer + +export const kevEntrySchema = z.object({ + cveID: z.string(), + vendorProject: z.string(), + product: z.string(), + vulnerabilityName: z.string(), + dateAdded: z.string(), + dueDate: z.string().optional(), + knownRansomwareCampaignUse: z.string().optional(), + shortDescription: z.string().optional(), + requiredAction: z.string().optional(), +}) + +export type KevEntry = z.infer + +export const isValidCveEvent = (data: unknown): data is CveEvent => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return cveEventSchema.safeParse(data).success +} + +export const isValidKevEntry = (data: unknown): data is KevEntry => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return kevEntrySchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts new file mode 100644 index 00000000..86982db4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts @@ -0,0 +1,68 @@ +// ©AngelaMos | 2026 +// geo.types.ts + +import { z } from 'zod' + +export const issPositionSchema = z.object({ + latitude: z.number(), + longitude: z.number(), + altitude: z.number(), + velocity: z.number(), + timestamp: z.number(), + fetched_at: z.string().optional(), +}) + +export type IssPosition = z.infer + +export const earthquakePropertiesSchema = z.object({ + mag: z.number().nullable().optional(), + place: z.string().nullable().optional(), + time: z.number().optional(), + updated: z.number().optional(), + alert: z.string().nullable().optional(), + tsunami: z.number().optional(), + url: z.string().optional(), + type: z.string().optional(), +}) + +export type EarthquakeProperties = z.infer + +export const earthquakePayloadSchema = z.object({ + id: z.string(), + geometry: z.object({ coordinates: z.array(z.number()).optional() }).optional(), + properties: earthquakePropertiesSchema.optional(), +}) + +export type EarthquakePayload = z.infer + +export const internetOutageSchema = z.object({ + id: z.string(), + startDate: z.string().optional(), + endDate: z.string().nullable().optional(), + locations: z.array(z.string()).optional(), + asns: z.array(z.number()).optional(), + reason: z.string().optional(), + outageType: z.string().optional(), +}) + +export type InternetOutage = z.infer + +export const isValidIssPosition = (data: unknown): data is IssPosition => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return issPositionSchema.safeParse(data).success +} + +export const isValidEarthquakePayload = ( + data: unknown +): data is EarthquakePayload => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return earthquakePayloadSchema.safeParse(data).success +} + +export const isValidInternetOutage = (data: unknown): data is InternetOutage => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return internetOutageSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts new file mode 100644 index 00000000..a0522bae --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts @@ -0,0 +1,13 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './auth.types' +export * from './coinbase.types' +export * from './cve.types' +export * from './geo.types' +export * from './intel.types' +export * from './notification.types' +export * from './threat.types' +export * from './user.types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/intel.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/intel.types.ts new file mode 100644 index 00000000..d44e8d61 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/intel.types.ts @@ -0,0 +1,53 @@ +// ©AngelaMos | 2026 +// intel.types.ts + +import { z } from 'zod' + +export const spaceWeatherDataSchema = z.object({ + kp: z.number().optional(), + bz_gsm: z.number().optional(), + speed_kms: z.number().optional(), + density: z.number().optional(), + xray_class: z.string().optional(), + xray_flux: z.number().optional(), + ts: z.string().optional(), + pushed: z.number().optional(), +}) + +export type SpaceWeatherData = z.infer + +export const wikiItnSchema = z.object({ + slug: z.string(), + text: z.string(), +}) + +export type WikiItn = z.infer + +export const gdeltSpikeSchema = z.object({ + theme: z.string(), + time: z.string(), + count: z.number(), + zscore: z.number(), +}) + +export type GdeltSpike = z.infer + +export const isValidSpaceWeatherData = ( + data: unknown +): data is SpaceWeatherData => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return spaceWeatherDataSchema.safeParse(data).success +} + +export const isValidWikiItn = (data: unknown): data is WikiItn => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return wikiItnSchema.safeParse(data).success +} + +export const isValidGdeltSpike = (data: unknown): data is GdeltSpike => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return gdeltSpikeSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/notification.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/notification.types.ts new file mode 100644 index 00000000..8f3d0ec3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/notification.types.ts @@ -0,0 +1,89 @@ +// =================== +// © AngelaMos | 2026 +// notification.types.ts +// =================== + +import { z } from 'zod' + +export const channelTypeSchema = z.enum(['slack', 'discord', 'telegram']) +export type ChannelType = z.infer + +export const channelResponseSchema = z.object({ + id: z.string(), + type: channelTypeSchema, + label: z.string(), + invalid: z.boolean(), + created_at: z.string(), +}) + +export const telegramStatusResponseSchema = z.object({ + configured: z.boolean(), + linked: z.boolean(), + pending_link: z.boolean(), + webhook_url: z.string().optional(), + webhook_registered: z.boolean().optional(), + created_at: z.string().optional(), +}) + +export const channelListResponseSchema = z.object({ + channels: z.array(channelResponseSchema), + telegram: telegramStatusResponseSchema, +}) + +export const registerTelegramResponseSchema = z.object({ + webhook_url: z.string(), + webhook_registered: z.boolean(), +}) + +export const createChannelRequestSchema = z.object({ + type: z.enum(['slack', 'discord']), + label: z.string().min(1).max(100), + webhook_url: z.string().url().max(2048), +}) + +export const registerTelegramRequestSchema = z.object({ + bot_token: z.string().min(10).max(200), +}) + +export type ChannelResponse = z.infer +export type TelegramStatusResponse = z.infer +export type ChannelListResponse = z.infer +export type RegisterTelegramResponse = z.infer< + typeof registerTelegramResponseSchema +> +export type CreateChannelRequest = z.infer +export type RegisterTelegramRequest = z.infer< + typeof registerTelegramRequestSchema +> + +export const isValidChannelListResponse = ( + data: unknown +): data is ChannelListResponse => { + if (data === null || data === undefined) return false + return channelListResponseSchema.safeParse(data).success +} + +export const isValidTelegramStatus = ( + data: unknown +): data is TelegramStatusResponse => { + if (data === null || data === undefined) return false + return telegramStatusResponseSchema.safeParse(data).success +} + +export const NOTIFICATION_ERROR_MESSAGES = { + LIST_FAILED: 'Failed to load notification channels', + CREATE_FAILED: 'Failed to add notification channel', + DELETE_FAILED: 'Failed to remove notification channel', + TEST_FAILED: 'Test notification failed', + REGISTER_TELEGRAM_FAILED: 'Failed to register Telegram bot', + UNLINK_TELEGRAM_FAILED: 'Failed to unlink Telegram', + INVALID_RESPONSE: 'Invalid response from server', +} as const + +export const NOTIFICATION_SUCCESS_MESSAGES = { + CHANNEL_ADDED: 'Notification channel added', + CHANNEL_DELETED: 'Notification channel removed', + TEST_SENT: 'Test notification sent', + TELEGRAM_REGISTERED: 'Telegram bot registered', + TELEGRAM_UNLINKED: 'Telegram unlinked', +} as const diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts new file mode 100644 index 00000000..a07ecb21 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts @@ -0,0 +1,99 @@ +// ©AngelaMos | 2026 +// threat.types.ts + +import { z } from 'zod' + +export const ransomwareVictimSchema = z.object({ + post_title: z.string(), + group_name: z.string(), + discovered: z.string(), + country: z.string().optional(), + activity: z.string().optional(), + website: z.string().optional(), + description: z.string().optional(), +}) + +export type RansomwareVictim = z.infer + +export const dshieldPortSchema = z.object({ + rank: z.number(), + targetport: z.number(), + records: z.number(), + targets: z.number(), + sources: z.number(), +}) + +export type DShieldPort = z.infer + +export const dshieldSourceSchema = z.object({ + rank: z.number(), + source: z.string(), + reports: z.number(), + targets: z.number(), + country: z.string().optional(), + classification: z.string().optional(), + actor: z.string().optional(), +}) + +export type DShieldSource = z.infer + +export const dshieldDailySummarySchema = z.object({ + date: z.string(), + records: z.number(), + sources: z.number(), + targets: z.number(), +}) + +export type DShieldDailySummary = z.infer + +export const dshieldDataSchema = z.object({ + topports: z + .union([z.record(z.string(), z.unknown()), z.array(z.unknown())]) + .optional(), + topips: z.array(dshieldSourceSchema).optional(), + dailysummary: z.array(dshieldDailySummarySchema).optional(), + ts: z.string().optional(), +}) + +export type DShieldData = z.infer + +export const isValidRansomwareVictim = ( + data: unknown +): data is RansomwareVictim => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return ransomwareVictimSchema.safeParse(data).success +} + +export const isValidDShieldData = (data: unknown): data is DShieldData => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return dshieldDataSchema.safeParse(data).success +} + +const bgpHijackEnrichmentSchema = z.object({ + country: z.string().optional(), + abuse_confidence: z.number().optional(), + isp: z.string().optional(), + checked_ip: z.string().optional(), +}) + +export const bgpHijackSchema = z.object({ + id: z.number(), + detectedAt: z.string().optional(), + startedAt: z.string().optional(), + duration: z.number().optional(), + confidenceScore: z.number().optional(), + hijackerAsn: z.number().optional(), + victimAsns: z.array(z.number()).nullable().optional(), + prefixes: z.array(z.string()).optional(), + enrichment: bgpHijackEnrichmentSchema.optional(), +}) + +export type BgpHijack = z.infer + +export const isValidBgpHijack = (data: unknown): data is BgpHijack => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return bgpHijackSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts new file mode 100644 index 00000000..4031596f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts @@ -0,0 +1,103 @@ +// =================== +// © AngelaMos | 2025 +// user.types.ts +// =================== + +import { z } from 'zod' +import { PASSWORD_CONSTRAINTS } from '@/config' +import { UserRole, userResponseSchema } from './auth.types' + +export { type UserResponse, UserRole, userResponseSchema } from './auth.types' + +export const userListResponseSchema = z.object({ + items: z.array(userResponseSchema), + total: z.number(), + page: z.number(), + page_size: z.number(), + total_pages: z.number(), +}) + +export const userCreateRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + name: z.string().min(1).max(100), +}) + +export const userUpdateRequestSchema = z.object({ + name: z.string().min(1).max(100).optional(), +}) + +export const adminUserCreateRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + name: z.string().min(1).max(100), + role: z.nativeEnum(UserRole).optional(), + tier: z.string().optional(), +}) + +export const adminUserUpdateRequestSchema = z.object({ + name: z.string().min(1).max(100).optional(), + role: z.nativeEnum(UserRole).optional(), + tier: z.string().optional(), +}) + +export const paginationParamsSchema = z.object({ + page: z.number().min(1), + size: z.number().min(1).max(100), +}) + +export type UserListResponse = z.infer +export type UserCreateRequest = z.infer +export type UserUpdateRequest = z.infer +export type AdminUserCreateRequest = z.infer +export type AdminUserUpdateRequest = z.infer +export type PaginationParams = z.infer + +export const isValidUserListResponse = ( + data: unknown +): data is UserListResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + return userListResponseSchema.safeParse(data).success +} + +export class UserResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'UserResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, UserResponseError.prototype) + } +} + +export const USER_ERROR_MESSAGES = { + INVALID_USER_RESPONSE: 'Invalid user data from server', + INVALID_USER_LIST_RESPONSE: 'Invalid user list from server', + USER_NOT_FOUND: 'User not found', + EMAIL_ALREADY_EXISTS: 'Email already exists', + FAILED_TO_CREATE: 'Failed to create user', + FAILED_TO_UPDATE: 'Failed to update user', + FAILED_TO_DELETE: 'Failed to delete user', +} as const + +export const USER_SUCCESS_MESSAGES = { + CREATED: 'User created successfully', + UPDATED: 'User updated successfully', + DELETED: 'User deleted successfully', + PROFILE_UPDATED: 'Profile updated successfully', + REGISTERED: + 'Registration successful! Please check your email to verify your account.', +} as const + +export type UserErrorMessage = + (typeof USER_ERROR_MESSAGES)[keyof typeof USER_ERROR_MESSAGES] +export type UserSuccessMessage = + (typeof USER_SUCCESS_MESSAGES)[keyof typeof USER_SUCCESS_MESSAGES] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts new file mode 100644 index 00000000..0b01fcd7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts @@ -0,0 +1,141 @@ +// ©AngelaMos | 2026 +// ws.test.ts + +import { describe, expect, it, vi } from 'vitest' +import { createDashboardWS, type WSDriver } from './ws' + +class FakeDriver implements WSDriver { + onOpen: (() => void) | null = null + onMessage: ((data: string) => void) | null = null + onClose: (() => void) | null = null + sent: string[] = [] + closed = false + + send(s: string) { + this.sent.push(s) + } + + close() { + if (this.closed) return + this.closed = true + this.onClose?.() + } + + fire(data: string) { + this.onMessage?.(data) + } + + open() { + this.onOpen?.() + } +} + +describe('dashboard WS', () => { + it('does not send init until setReady() is called', () => { + const drv = new FakeDriver() + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + }) + ws.connect() + drv.open() + expect(drv.sent).toEqual([]) + ws.setReady() + expect(drv.sent).toContain('{"op":"init"}') + ws.disconnect() + }) + + it('buffers events received before ready, replays on setReady', () => { + const drv = new FakeDriver() + const events: unknown[] = [] + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + onEvent: (e) => events.push(e), + }) + ws.connect() + drv.open() + drv.fire(JSON.stringify({ topic: 'heartbeat', payload: {} })) + expect(events).toHaveLength(0) + ws.setReady() + expect(events).toHaveLength(1) + ws.disconnect() + }) + + it('forwards events received after ready immediately', () => { + const drv = new FakeDriver() + const events: unknown[] = [] + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + onEvent: (e) => events.push(e), + }) + ws.connect() + drv.open() + ws.setReady() + drv.fire(JSON.stringify({ topic: 'heartbeat', payload: { v: 1 } })) + expect(events).toHaveLength(1) + ws.disconnect() + }) + + it('reconnects with backoff after drop and resends init when ready', async () => { + const drivers: FakeDriver[] = [] + const ws = createDashboardWS({ + driver: () => { + const d = new FakeDriver() + drivers.push(d) + return d + }, + topics: ['heartbeat'], + backoff: { initialMs: 5, maxMs: 20 }, + }) + ws.connect() + drivers[0].open() + ws.setReady() + expect(drivers[0].sent).toContain('{"op":"init"}') + + drivers[0].close() + await new Promise((r) => setTimeout(r, 50)) + + expect(drivers.length).toBeGreaterThanOrEqual(2) + drivers[1].open() + expect(drivers[1].sent).toContain('{"op":"init"}') + ws.disconnect() + }) + + it('does not reconnect after explicit disconnect', async () => { + const drivers: FakeDriver[] = [] + const ws = createDashboardWS({ + driver: () => { + const d = new FakeDriver() + drivers.push(d) + return d + }, + topics: ['heartbeat'], + backoff: { initialMs: 5, maxMs: 20 }, + }) + ws.connect() + drivers[0].open() + ws.disconnect() + await new Promise((r) => setTimeout(r, 30)) + expect(drivers).toHaveLength(1) + }) + + it('ignores malformed JSON without crashing', () => { + const drv = new FakeDriver() + const events: unknown[] = [] + const errors = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + onEvent: (e) => events.push(e), + }) + ws.connect() + drv.open() + ws.setReady() + drv.fire('{not json') + expect(events).toHaveLength(0) + errors.mockRestore() + ws.disconnect() + }) +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts new file mode 100644 index 00000000..4fcc8d3b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts @@ -0,0 +1,140 @@ +// ©AngelaMos | 2026 +// ws.ts + +export interface WSDriver { + onOpen: (() => void) | null + onMessage: ((data: string) => void) | null + onClose: (() => void) | null + send(s: string): void + close(): void +} + +export interface WSEvent { + ch: string + data?: unknown + ts?: string +} + +export interface BackoffConfig { + initialMs: number + maxMs: number +} + +export interface CreateDashboardWSOpts { + driver: () => WSDriver + topics: string[] + onEvent?: (ev: WSEvent) => void + backoff?: BackoffConfig +} + +const DEFAULT_BACKOFF: BackoffConfig = { + initialMs: 1_000, + maxMs: 30_000, +} + +interface DashboardWS { + connect: () => void + setReady: () => void + disconnect: () => void +} + +const INIT_OP = '{"op":"init"}' + +export function createDashboardWS(opts: CreateDashboardWSOpts): DashboardWS { + const backoff = opts.backoff ?? DEFAULT_BACKOFF + const onEvent = opts.onEvent ?? (() => undefined) + + let driver: WSDriver | null = null + let ready = false + let opened = false + let closed = false + let buffer: WSEvent[] = [] + let nextDelay = backoff.initialMs + let reconnectTimer: ReturnType | null = null + + function sendInitIfReady() { + if (ready && opened && driver) { + driver.send(INIT_OP) + } + } + + function attach() { + if (closed) return + const d = opts.driver() + driver = d + opened = false + d.onOpen = () => { + nextDelay = backoff.initialMs + opened = true + sendInitIfReady() + } + d.onMessage = (data) => { + let parsed: WSEvent + try { + parsed = JSON.parse(data) as WSEvent + } catch { + return + } + if (ready) { + onEvent(parsed) + } else { + buffer.push(parsed) + } + } + d.onClose = () => { + driver = null + opened = false + if (closed) return + const delay = nextDelay + nextDelay = Math.min(nextDelay * 2, backoff.maxMs) + reconnectTimer = setTimeout(attach, delay) + } + } + + return { + connect() { + attach() + }, + setReady() { + ready = true + sendInitIfReady() + const flush = buffer + buffer = [] + for (const ev of flush) onEvent(ev) + }, + disconnect() { + closed = true + ready = false + opened = false + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + driver?.close() + driver = null + buffer = [] + }, + } +} + +export function browserDriver(url: string, topics: string[]): WSDriver { + const u = new URL(url, window.location.origin.replace('http', 'ws')) + if (topics.length > 0) u.searchParams.set('topics', topics.join(',')) + const sock = new WebSocket(u.toString()) + + const driver: WSDriver = { + onOpen: null, + onMessage: null, + onClose: null, + send(s) { + if (sock.readyState === WebSocket.OPEN) sock.send(s) + }, + close() { + sock.close() + }, + } + sock.addEventListener('open', () => driver.onOpen?.()) + sock.addEventListener('message', (e) => driver.onMessage?.(String(e.data))) + sock.addEventListener('close', () => driver.onClose?.()) + return driver +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts new file mode 100644 index 00000000..fc86b087 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts @@ -0,0 +1,148 @@ +// =================== +// © AngelaMos | 2026 +// config.ts +// =================== +const API_VERSION = 'v1' + +export const API_ENDPOINTS = { + AUTH: { + LOGIN: `/${API_VERSION}/auth/login`, + REGISTER: `/${API_VERSION}/auth/register`, + REFRESH: `/${API_VERSION}/auth/refresh`, + LOGOUT: `/${API_VERSION}/auth/logout`, + LOGOUT_ALL: `/${API_VERSION}/auth/logout-all`, + ME: `/${API_VERSION}/auth/me`, + CHANGE_PASSWORD: `/${API_VERSION}/auth/change-password`, + }, + USERS: { + BASE: `/${API_VERSION}/users`, + ME: `/${API_VERSION}/users/me`, + EMAIL: `/${API_VERSION}/users/me/email`, + }, + ADMIN: { + USERS: { + LIST: `/${API_VERSION}/admin/users`, + CREATE: `/${API_VERSION}/admin/users`, + BY_ID: (id: string) => `/${API_VERSION}/admin/users/${id}`, + UPDATE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + DELETE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + }, + }, + NOTIFICATIONS: { + CHANNELS: `/${API_VERSION}/notifications/channels`, + CHANNEL_BY_ID: (id: string) => `/${API_VERSION}/notifications/channels/${id}`, + CHANNEL_TEST: (id: string) => + `/${API_VERSION}/notifications/channels/${id}/test`, + TELEGRAM: `/${API_VERSION}/notifications/telegram`, + TELEGRAM_STATUS: `/${API_VERSION}/notifications/telegram/status`, + TELEGRAM_TEST: `/${API_VERSION}/notifications/telegram/test`, + }, + INTEL: { + CVES: `/${API_VERSION}/intel/cves`, + KEV: `/${API_VERSION}/intel/kev`, + HIJACKS: `/${API_VERSION}/intel/hijacks`, + OUTAGES: `/${API_VERSION}/intel/outages`, + RANSOMWARE: `/${API_VERSION}/intel/ransomware`, + QUAKES: `/${API_VERSION}/intel/quakes`, + }, +} as const + +export const QUERY_KEYS = { + AUTH: { + ALL: ['auth'] as const, + ME: () => [...QUERY_KEYS.AUTH.ALL, 'me'] as const, + }, + USERS: { + ALL: ['users'] as const, + ME: () => [...QUERY_KEYS.USERS.ALL, 'me'] as const, + }, + ADMIN: { + ALL: ['admin'] as const, + USERS: { + ALL: () => [...QUERY_KEYS.ADMIN.ALL, 'users'] as const, + LIST: (page: number, size: number) => + [...QUERY_KEYS.ADMIN.USERS.ALL(), 'list', { page, size }] as const, + BY_ID: (id: string) => + [...QUERY_KEYS.ADMIN.USERS.ALL(), 'detail', id] as const, + }, + }, + NOTIFICATIONS: { + ALL: ['notifications'] as const, + CHANNELS: () => [...QUERY_KEYS.NOTIFICATIONS.ALL, 'channels'] as const, + TELEGRAM_STATUS: () => + [...QUERY_KEYS.NOTIFICATIONS.ALL, 'telegram-status'] as const, + }, + INTEL: { + ALL: ['intel'] as const, + CVES: () => [...QUERY_KEYS.INTEL.ALL, 'cves'] as const, + KEV: () => [...QUERY_KEYS.INTEL.ALL, 'kev'] as const, + HIJACKS: () => [...QUERY_KEYS.INTEL.ALL, 'hijacks'] as const, + OUTAGES: () => [...QUERY_KEYS.INTEL.ALL, 'outages'] as const, + RANSOMWARE: () => [...QUERY_KEYS.INTEL.ALL, 'ransomware'] as const, + QUAKES: () => [...QUERY_KEYS.INTEL.ALL, 'quakes'] as const, + }, +} as const + +export const ROUTES = { + HOME: '/', + LOGIN: '/login', + REGISTER: '/register', + DASHBOARD: '/dashboard', + SETTINGS: '/settings', + UNAUTHORIZED: '/unauthorized', + ADMIN: { + DASHBOARD: '/admin', + USERS: '/admin/users', + USER_DETAIL: (id: string) => `/admin/users/${id}`, + }, +} as const + +export const STORAGE_KEYS = { + AUTH: 'auth-storage', + UI: 'ui-storage', + PRESENTATION_MODE: 'monitor:presentation-mode', +} as const + +export const QUERY_CONFIG = { + STALE_TIME: { + USER: 1000 * 60 * 5, + STATIC: Infinity, + FREQUENT: 1000 * 30, + }, + GC_TIME: { + DEFAULT: 1000 * 60 * 30, + LONG: 1000 * 60 * 60, + }, + RETRY: { + DEFAULT: 3, + NONE: 0, + }, +} as const + +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER: 500, +} as const + +export const PASSWORD_CONSTRAINTS = { + MIN_LENGTH: 8, + MAX_LENGTH: 128, +} as const + +export const PAGINATION = { + DEFAULT_PAGE: 1, + DEFAULT_SIZE: 20, + MAX_SIZE: 100, +} as const + +export type ApiEndpoint = typeof API_ENDPOINTS +export type QueryKey = typeof QUERY_KEYS +export type Route = typeof ROUTES diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..f2722898 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts @@ -0,0 +1,215 @@ +// =================== +// © AngelaMos | 2025 +// api.config.ts +// =================== + +import axios, { + type AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios' +import { API_ENDPOINTS, HTTP_STATUS } from '@/config' +import { useAuthStore } from '@/core/lib' +import { ApiError, ApiErrorCode, transformAxiosError } from './errors' + +interface RequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean +} + +interface RefreshSubscriber { + resolve: (token: string) => void + reject: (error: Error) => void +} + +interface GoEnvelope { + success: boolean + data?: unknown + meta?: { + page: number + page_size: number + total: number + total_pages: number + } +} + +const getBaseURL = (): string => { + return import.meta.env.VITE_API_URL ?? '/api' +} + +export const apiClient: AxiosInstance = axios.create({ + baseURL: getBaseURL(), + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, +}) + +let isRefreshing = false +let refreshSubscribers: RefreshSubscriber[] = [] + +const processRefreshQueue = (error: Error | null, token: string | null): void => { + refreshSubscribers.forEach((subscriber) => { + if (error !== null) { + subscriber.reject(error) + } else if (token !== null) { + subscriber.resolve(token) + } + }) + refreshSubscribers = [] +} + +const addRefreshSubscriber = ( + resolve: (token: string) => void, + reject: (error: Error) => void +): void => { + refreshSubscribers.push({ resolve, reject }) +} + +const handleTokenRefresh = async (): Promise => { + // Refresh token rides in the HttpOnly cookie set by the backend on login. + // axios sends it automatically because the client is configured with + // withCredentials: true. We don't read or send it from JS at all. + const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH, {}) + + if ( + response.data === null || + response.data === undefined || + typeof response.data !== 'object' + ) { + throw new ApiError( + 'Invalid refresh response', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + } + + const payload = response.data as { + tokens?: { access_token?: string } + } + const accessToken = payload.tokens?.access_token + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new ApiError( + 'Invalid access token', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + } + + return accessToken +} + +const handleAuthFailure = (): void => { + useAuthStore.getState().logout() + window.location.href = '/login' +} + +apiClient.interceptors.response.use( + (response) => { + if ( + response.status !== 204 && + response.data !== null && + response.data !== undefined && + typeof response.data === 'object' && + 'success' in (response.data as object) + ) { + const envelope = response.data as GoEnvelope + if (envelope.meta !== undefined && envelope.meta !== null) { + response.data = { + items: envelope.data, + page: envelope.meta.page, + page_size: envelope.meta.page_size, + total: envelope.meta.total, + total_pages: envelope.meta.total_pages, + } + } else { + response.data = envelope.data + } + } + return response + }, + (error: unknown) => Promise.reject(error) +) + +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + const token = useAuthStore.getState().accessToken + if (token !== null && token.length > 0) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error: unknown): Promise => { + return Promise.reject(error) + } +) + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError): Promise => { + const originalRequest = error.config as RequestConfig | undefined + + if (originalRequest === undefined) { + return Promise.reject(transformAxiosError(error)) + } + + const isUnauthorized = error.response?.status === HTTP_STATUS.UNAUTHORIZED + const isNotRetried = originalRequest._retry !== true + const isNotRefreshEndpoint = + originalRequest.url?.includes(API_ENDPOINTS.AUTH.REFRESH) !== true + const hadBearerToken = + typeof originalRequest.headers.Authorization === 'string' && + originalRequest.headers.Authorization.startsWith('Bearer ') + // Cold-load hydration: persisted isAuthenticated=true but accessToken + // is missing (it's not persisted across reloads). The first protected + // call has no Bearer header, gets 401, and we should still try a + // refresh via the HttpOnly cookie. + const persistedAuth = useAuthStore.getState().isAuthenticated + + if ( + isUnauthorized && + isNotRetried && + isNotRefreshEndpoint && + (hadBearerToken || persistedAuth) + ) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + addRefreshSubscriber( + (newToken: string): void => { + originalRequest.headers.Authorization = `Bearer ${newToken}` + resolve(apiClient(originalRequest)) + }, + (refreshError: Error): void => { + reject(refreshError) + } + ) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + const newToken = await handleTokenRefresh() + useAuthStore.getState().setAccessToken(newToken) + processRefreshQueue(null, newToken) + originalRequest.headers.Authorization = `Bearer ${newToken}` + return await apiClient(originalRequest) + } catch (refreshError: unknown) { + const apiError = + refreshError instanceof ApiError + ? refreshError + : new ApiError( + 'Session expired', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + processRefreshQueue(apiError, null) + handleAuthFailure() + return Promise.reject(apiError) + } finally { + isRefreshing = false + } + } + + return Promise.reject(transformAxiosError(error)) + } +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..1d507781 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts @@ -0,0 +1,118 @@ +/** + * ©AngelaMos | 2025 + * errors.ts + */ + +import type { AxiosError } from 'axios' + +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] + +export class ApiError extends Error { + readonly code: ApiErrorCode + readonly statusCode: number + readonly details?: Record + + constructor( + message: string, + code: ApiErrorCode, + statusCode: number, + details?: Record + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + getUserMessage(): string { + const messages: Record = { + [ApiErrorCode.NETWORK_ERROR]: + 'Unable to connect. Please check your internet connection.', + [ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.', + [ApiErrorCode.AUTHENTICATION_ERROR]: + 'Your session has expired. Please log in again.', + [ApiErrorCode.AUTHORIZATION_ERROR]: + 'You do not have permission to perform this action.', + [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.', + [ApiErrorCode.CONFLICT]: + 'This operation conflicts with an existing resource.', + [ApiErrorCode.RATE_LIMITED]: + 'Too many requests. Please wait a moment and try again.', + [ApiErrorCode.SERVER_ERROR]: + 'Something went wrong on our end. Please try again later.', + [ApiErrorCode.UNKNOWN_ERROR]: + 'An unexpected error occurred. Please try again.', + } + return messages[this.code] + } +} + +interface ApiErrorResponse { + success?: boolean + error?: { code?: string; message?: string } + detail?: string | { msg: string; type: string }[] + message?: string +} + +export function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined + let message = 'An error occurred' + let details: Record | undefined + + if (data?.error?.message) { + message = data.error.message + } else if (data?.detail) { + if (typeof data.detail === 'string') { + message = data.detail + } else if (Array.isArray(data.detail)) { + details = { validation: [] } + data.detail.forEach((err) => { + details?.validation.push(err.msg) + }) + message = 'Validation error' + } + } else if (data?.message) { + message = data.message + } + + const codeMap: Record = { + 400: ApiErrorCode.VALIDATION_ERROR, + 401: ApiErrorCode.AUTHENTICATION_ERROR, + 403: ApiErrorCode.AUTHORIZATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 409: ApiErrorCode.CONFLICT, + 429: ApiErrorCode.RATE_LIMITED, + 500: ApiErrorCode.SERVER_ERROR, + 502: ApiErrorCode.SERVER_ERROR, + 503: ApiErrorCode.SERVER_ERROR, + 504: ApiErrorCode.SERVER_ERROR, + } + + const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR + + return new ApiError(message, code, status, details) +} + +declare module '@tanstack/react-query' { + interface Register { + defaultError: ApiError + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts new file mode 100644 index 00000000..1818cd7e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './api.config' +export * from './errors' +export * from './query.config' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..8244b22d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2025 +// query.config.ts +// =================== + +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { QUERY_CONFIG } from '@/config' +import { ApiError, ApiErrorCode } from './errors' + +const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [ + ApiErrorCode.AUTHENTICATION_ERROR, + ApiErrorCode.AUTHORIZATION_ERROR, + ApiErrorCode.NOT_FOUND, + ApiErrorCode.VALIDATION_ERROR, +] as const + +const shouldRetryQuery = (failureCount: number, error: Error): boolean => { + if (error instanceof ApiError) { + if (NO_RETRY_ERROR_CODES.includes(error.code)) { + return false + } + } + return failureCount < QUERY_CONFIG.RETRY.DEFAULT +} + +const calculateRetryDelay = (attemptIndex: number): number => { + const baseDelay = 1000 + const maxDelay = 30000 + return Math.min(baseDelay * 2 ** attemptIndex, maxDelay) +} + +const handleQueryCacheError = ( + error: Error, + query: { state: { data: unknown } } +): void => { + if (query.state.data !== undefined) { + const message = + error instanceof ApiError + ? error.getUserMessage() + : 'Background update failed' + toast.error(message) + } +} + +const handleMutationCacheError = ( + error: Error, + _variables: unknown, + _context: unknown, + mutation: { options: { onError?: unknown } } +): void => { + if (mutation.options.onError === undefined) { + const message = + error instanceof ApiError ? error.getUserMessage() : 'Operation failed' + toast.error(message) + } +} + +export const QUERY_STRATEGIES = { + standard: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + }, + frequent: { + staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT, + }, + static: { + staleTime: QUERY_CONFIG.STALE_TIME.STATIC, + gcTime: QUERY_CONFIG.GC_TIME.LONG, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + auth: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: QUERY_CONFIG.RETRY.NONE, + }, +} as const + +export type QueryStrategy = keyof typeof QUERY_STRATEGIES + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: shouldRetryQuery, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }, + mutations: { + retry: QUERY_CONFIG.RETRY.NONE, + }, + }, + queryCache: new QueryCache({ + onError: handleQueryCacheError, + }), + mutationCache: new MutationCache({ + onError: handleMutationCacheError, + }), +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx new file mode 100644 index 00000000..6e8d8496 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx @@ -0,0 +1,55 @@ +// =================== +// © AngelaMos | 2025 +// protected-route.tsx +// =================== + +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useCurrentUser } from '@/api/hooks' +import type { UserRole } from '@/api/types' +import { ROUTES } from '@/config' +import { useAuthStore } from '@/core/lib' + +interface ProtectedRouteProps { + allowedRoles?: UserRole[] + redirectTo?: string +} + +export function ProtectedRoute({ + allowedRoles, + redirectTo = ROUTES.LOGIN, +}: ProtectedRouteProps): React.ReactElement { + const location = useLocation() + const { isAuthenticated, isLoading, user } = useAuthStore() + + // On cold load, isAuthenticated is restored from localStorage but the + // access token is gone (not persisted). useCurrentUser fires /auth/me; + // the axios interceptor catches the inevitable 401 and refreshes via + // the HttpOnly cookie. While that's in flight we hold the route — no + // flash of protected content before bouncing to /login. + const { isLoading: hydrating, isError: hydrationFailed } = useCurrentUser() + + if (isLoading || (isAuthenticated && hydrating && user === null)) { + return
Loading…
+ } + + if (!isAuthenticated || hydrationFailed) { + return ( + + ) + } + + if ( + allowedRoles !== undefined && + allowedRoles.length > 0 && + user !== null && + !allowedRoles.includes(user.role) + ) { + return + } + + return +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx new file mode 100644 index 00000000..117c81ed --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// routers.tsx + +import { createBrowserRouter, type RouteObject } from 'react-router-dom' +import { UserRole } from '@/api/types' +import { ROUTES } from '@/config' +import { ProtectedRoute } from './protected-route' + +const routes: RouteObject[] = [ + { + path: ROUTES.HOME, + lazy: () => import('@/pages/dashboard'), + }, + { + path: ROUTES.LOGIN, + lazy: () => import('@/pages/login'), + }, + { + path: ROUTES.REGISTER, + lazy: () => import('@/pages/register'), + }, + { + element: , + children: [ + { + path: ROUTES.SETTINGS, + lazy: () => import('@/pages/settings'), + }, + ], + }, + { + element: , + children: [ + { + path: ROUTES.ADMIN.USERS, + lazy: () => import('@/pages/admin'), + }, + ], + }, + { + path: '*', + lazy: () => import('@/pages/dashboard'), + }, +] + +export const router = createBrowserRouter(routes) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts new file mode 100644 index 00000000..22c422c5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts @@ -0,0 +1,43 @@ +/** + * ©AngelaMos | 2025 + * auth.form.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface AuthFormState { + loginEmail: string + registerEmail: string + setLoginEmail: (email: string) => void + setRegisterEmail: (email: string) => void + clearLoginForm: () => void + clearRegisterForm: () => void +} + +export const useAuthFormStore = create()( + devtools( + persist( + (set) => ({ + loginEmail: '', + registerEmail: '', + + setLoginEmail: (email) => + set({ loginEmail: email }, false, 'authForm/setLoginEmail'), + + setRegisterEmail: (email) => + set({ registerEmail: email }, false, 'authForm/setRegisterEmail'), + + clearLoginForm: () => + set({ loginEmail: '' }, false, 'authForm/clearLoginForm'), + + clearRegisterForm: () => + set({ registerEmail: '' }, false, 'authForm/clearRegisterForm'), + }), + { + name: 'auth-form-storage', + } + ), + { name: 'AuthFormStore' } + ) +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts new file mode 100644 index 00000000..2d10ceb3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts @@ -0,0 +1,114 @@ +// =================== +// © AngelaMos | 2025 +// auth.store.ts +// =================== + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { type UserResponse, UserRole } from '@/api/types' +import { STORAGE_KEYS } from '@/config' + +// Refresh tokens live in an HttpOnly cookie set by the backend, NOT in JS +// state — so XSS-leakable storage doesn't carry the long-lived credential. +// The access token stays in memory (not persisted) and is short-lived (15m); +// `isAuthenticated` is persisted so the UI knows to attempt a hydration call +// on cold load instead of immediately bouncing to /login. +interface AuthState { + user: UserResponse | null + accessToken: string | null + isAuthenticated: boolean + isLoading: boolean +} + +interface AuthActions { + login: (user: UserResponse, accessToken: string) => void + logout: () => void + setLoading: (loading: boolean) => void + setAccessToken: (token: string | null) => void + setUser: (user: UserResponse | null) => void + updateUser: (updates: Partial) => void +} + +type AuthStore = AuthState & AuthActions + +export const useAuthStore = create()( + devtools( + persist( + (set) => ({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + + login: (user, accessToken) => + set( + { + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }, + false, + 'auth/login' + ), + + logout: () => + set( + { + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }, + false, + 'auth/logout' + ), + + setLoading: (loading) => + set({ isLoading: loading }, false, 'auth/setLoading'), + + setAccessToken: (token) => + set({ accessToken: token }, false, 'auth/setAccessToken'), + + setUser: (user) => + set({ user, isAuthenticated: user !== null }, false, 'auth/setUser'), + + updateUser: (updates) => + set( + (state) => ({ + user: state.user !== null ? { ...state.user, ...updates } : null, + }), + false, + 'auth/updateUser' + ), + }), + { + name: STORAGE_KEYS.AUTH, + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + } + ), + { name: 'AuthStore' } + ) +) + +export const useUser = (): UserResponse | null => useAuthStore((s) => s.user) +export const useIsAuthenticated = (): boolean => + useAuthStore((s) => s.isAuthenticated) +export const useIsAuthLoading = (): boolean => useAuthStore((s) => s.isLoading) +export const useAccessToken = (): string | null => + useAuthStore((s) => s.accessToken) + +export const useHasRole = (role: UserRole): boolean => { + const user = useAuthStore((s) => s.user) + return user !== null && user.role === role +} + +export const useIsAdmin = (): boolean => { + const user = useAuthStore((s) => s.user) + return user !== null && user.role === UserRole.ADMIN +} + +export { UserRole } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..a42c054e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts @@ -0,0 +1,5 @@ +// ©AngelaMos | 2026 +// index.ts + +export * from './auth.form.store' +export * from './auth.store' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/audio.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/audio.ts new file mode 100644 index 00000000..4ee3ed13 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/audio.ts @@ -0,0 +1,47 @@ +// ©AngelaMos | 2026 +// audio.ts + +import { useAudioStore } from '@/stores/audio' + +let listenerAttached = false + +function ensureCtx(): AudioContext { + let ctx = useAudioStore.getState().ctx + if (!ctx) { + ctx = new AudioContext() + useAudioStore.getState().setCtx(ctx) + } + return ctx +} + +export async function loadChime(file: File): Promise { + const ctx = ensureCtx() + const ab = await file.arrayBuffer() + const buffer = await ctx.decodeAudioData(ab) + useAudioStore.getState().setBuffer(buffer) +} + +export function unlockOnFirstGesture(): void { + if (useAudioStore.getState().unlocked) return + if (listenerAttached) return + listenerAttached = true + + const handler = async (): Promise => { + listenerAttached = false + const ctx = ensureCtx() + if (ctx.state === 'suspended') await ctx.resume() + useAudioStore.getState().setUnlocked(true) + } + + document.addEventListener('pointerdown', handler, { once: true }) +} + +export function playChime(): boolean { + const { ctx, buffer, unlocked } = useAudioStore.getState() + if (!ctx || !buffer || !unlocked) return false + const src = ctx.createBufferSource() + src.buffer = buffer + src.connect(ctx.destination) + src.start(0) + return true +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts new file mode 100644 index 00000000..5fa0f535 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts @@ -0,0 +1,226 @@ +// ©AngelaMos | 2026 +// countryCentroids.ts + +export interface Centroid { + lat: number + lng: number +} + +const ISO_ALIASES: Record = { + UK: 'GB', +} + +const SUPPLEMENTARY_CENTROIDS: Record = { + HK: { lat: 22.3193, lng: 114.1694 }, + MO: { lat: 22.1987, lng: 113.5439 }, + SG: { lat: 1.3521, lng: 103.8198 }, + BH: { lat: 26.0667, lng: 50.5577 }, + MT: { lat: 35.9375, lng: 14.3754 }, + LI: { lat: 47.166, lng: 9.555 }, + AD: { lat: 42.5063, lng: 1.5218 }, + MC: { lat: 43.7384, lng: 7.4246 }, + SM: { lat: 43.9424, lng: 12.4578 }, + VA: { lat: 41.9029, lng: 12.4534 }, + MV: { lat: 3.2028, lng: 73.2207 }, + BB: { lat: 13.1939, lng: -59.5432 }, + BM: { lat: 32.3078, lng: -64.7505 }, + KY: { lat: 19.3133, lng: -81.2546 }, + JM: { lat: 18.1096, lng: -77.2975 }, + AW: { lat: 12.5211, lng: -69.9683 }, + CW: { lat: 12.1696, lng: -68.99 }, + AG: { lat: 17.0608, lng: -61.7964 }, + TT: { lat: 10.6918, lng: -61.2225 }, + MU: { lat: -20.3484, lng: 57.5522 }, + SC: { lat: -4.6796, lng: 55.492 }, + CV: { lat: 16.5388, lng: -23.0418 }, + ST: { lat: 0.1864, lng: 6.6131 }, + KM: { lat: -11.6455, lng: 43.3333 }, + GD: { lat: 12.1165, lng: -61.679 }, + LC: { lat: 13.9094, lng: -60.9789 }, + VC: { lat: 13.2528, lng: -61.197 }, + KN: { lat: 17.3578, lng: -62.783 }, + DM: { lat: 15.415, lng: -61.371 }, +} + +export const COUNTRY_CENTROIDS: Record = { + AE: { lat: 24.2762, lng: 53.9882 }, + AF: { lat: 33.9024, lng: 67.8432 }, + AL: { lat: 41.1566, lng: 20.1623 }, + AM: { lat: 39.9947, lng: 45.0442 }, + AO: { lat: -11.8974, lng: 17.86 }, + AQ: { lat: -76.6353, lng: 0.0 }, + AR: { lat: -37.0911, lng: -63.5219 }, + AT: { lat: 47.7354, lng: 13.2298 }, + AU: { lat: -24.852, lng: 133.4542 }, + AZ: { lat: 40.0655, lng: 47.6827 }, + BA: { lat: 43.9419, lng: 17.6749 }, + BD: { lat: 23.5587, lng: 90.3786 }, + BE: { lat: 50.5023, lng: 4.3351 }, + BF: { lat: 12.3635, lng: -1.6467 }, + BG: { lat: 42.7347, lng: 25.4693 }, + BI: { lat: -3.4242, lng: 29.8886 }, + BJ: { lat: 9.1889, lng: 2.2847 }, + BN: { lat: 4.7277, lng: 114.8274 }, + BO: { lat: -16.3175, lng: -63.5444 }, + BR: { lat: -14.2619, lng: -54.3586 }, + BS: { lat: 24.4601, lng: -77.9716 }, + BT: { lat: 27.5079, lng: 90.459 }, + BW: { lat: -22.2452, lng: 24.6638 }, + BY: { lat: 53.7443, lng: 27.9466 }, + BZ: { lat: 17.1935, lng: -88.668 }, + CA: { lat: 56.7978, lng: -98.3406 }, + CD: { lat: -4.0006, lng: 21.6782 }, + CF: { lat: 6.705, lng: 20.9168 }, + CG: { lat: -0.6549, lng: 14.7734 }, + CH: { lat: 46.8039, lng: 8.2327 }, + CI: { lat: 7.4312, lng: -5.5825 }, + CL: { lat: -35.7182, lng: -71.3148 }, + CM: { lat: 7.2935, lng: 12.2508 }, + CN: { lat: 36.8706, lng: 104.3508 }, + CO: { lat: 4.0696, lng: -72.9336 }, + CR: { lat: 9.7211, lng: -84.244 }, + CU: { lat: 21.522, lng: -79.5765 }, + CY: { lat: 34.8725, lng: 33.1308 }, + CZ: { lat: 49.8363, lng: 15.5466 }, + DE: { lat: 51.1428, lng: 10.5028 }, + DJ: { lat: 11.8133, lng: 42.4898 }, + DK: { lat: 56.2804, lng: 9.5011 }, + DO: { lat: 18.7417, lng: -70.1315 }, + DZ: { lat: 28.0879, lng: 1.6576 }, + EC: { lat: -1.7891, lng: -78.1007 }, + EE: { lat: 58.5428, lng: 25.7357 }, + EG: { lat: 26.7928, lng: 30.7831 }, + EH: { lat: 24.3281, lng: -12.8643 }, + ER: { lat: 15.2269, lng: 39.7022 }, + ES: { lat: 39.8476, lng: -3.1767 }, + ET: { lat: 9.1907, lng: 40.3718 }, + FI: { lat: 65.0053, lng: 26.0808 }, + FJ: { lat: -17.814, lng: 178.0016 }, + FK: { lat: -51.7, lng: -59.475 }, + FR: { lat: 46.7459, lng: 1.7535 }, + GA: { lat: -0.826, lng: 11.6117 }, + GB: { lat: 54.2975, lng: -2.2342 }, + GE: { lat: 42.3088, lng: 43.2965 }, + GH: { lat: 7.9044, lng: -1.0921 }, + GL: { lat: 71.8409, lng: -42.7528 }, + GM: { lat: 13.5034, lng: -15.3432 }, + GN: { lat: 9.9476, lng: -11.4812 }, + GQ: { lat: 1.647, lng: 10.2953 }, + GR: { lat: 39.1185, lng: 23.3771 }, + GT: { lat: 15.7773, lng: -90.2271 }, + GW: { lat: 11.8343, lng: -15.189 }, + GY: { lat: 4.8176, lng: -58.9748 }, + HN: { lat: 14.495, lng: -86.2503 }, + HR: { lat: 44.4919, lng: 16.5237 }, + HT: { lat: 18.9733, lng: -73.0415 }, + HU: { lat: 47.1917, lng: 19.4564 }, + ID: { lat: -2.0151, lng: 122.0041 }, + IE: { lat: 53.4005, lng: -8.005 }, + IL: { lat: 31.3894, lng: 35.0509 }, + IN: { lat: 21.7298, lng: 82.7896 }, + IQ: { lat: 33.2421, lng: 43.6802 }, + IR: { lat: 32.3956, lng: 53.7129 }, + IS: { lat: 65.0116, lng: -18.968 }, + IT: { lat: 42.5121, lng: 12.6151 }, + JM: { lat: 18.1127, lng: -77.2687 }, + JO: { lat: 31.2881, lng: 37.059 }, + JP: { lat: 36.2041, lng: 135.6614 }, + KE: { lat: 0.4146, lng: 37.8743 }, + KG: { lat: 41.2889, lng: 74.8624 }, + KH: { lat: 12.5286, lng: 104.9813 }, + KP: { lat: 40.3272, lng: 127.5228 }, + KR: { lat: 36.5011, lng: 127.7929 }, + KW: { lat: 29.2926, lng: 47.4924 }, + KZ: { lat: 48.0238, lng: 66.9132 }, + LA: { lat: 18.1729, lng: 103.8403 }, + LB: { lat: 33.867, lng: 35.8689 }, + LK: { lat: 7.8962, lng: 80.7416 }, + LR: { lat: 6.4484, lng: -9.4892 }, + LS: { lat: -29.6463, lng: 28.1622 }, + LT: { lat: 55.1391, lng: 23.822 }, + LU: { lat: 49.7854, lng: 5.9584 }, + LV: { lat: 56.7926, lng: 24.6163 }, + LY: { lat: 26.3587, lng: 17.2421 }, + MA: { lat: 28.5904, lng: -9.0725 }, + MD: { lat: 46.9777, lng: 28.322 }, + ME: { lat: 42.7007, lng: 19.3949 }, + MG: { lat: -18.821, lng: 46.8654 }, + MK: { lat: 41.5815, lng: 21.7078 }, + ML: { lat: 17.5355, lng: -3.9503 }, + MM: { lat: 19.1345, lng: 96.7416 }, + MN: { lat: 46.8224, lng: 103.762 }, + MR: { lat: 21.0063, lng: -10.9934 }, + MW: { lat: -13.0159, lng: 34.23 }, + MX: { lat: 23.6298, lng: -101.9699 }, + MY: { lat: 3.8506, lng: 114.4226 }, + MZ: { lat: -18.5296, lng: 35.4775 }, + NA: { lat: -22.9934, lng: 18.4093 }, + NC: { lat: -21.2528, lng: 165.5748 }, + NE: { lat: 17.5659, lng: 8.0994 }, + NG: { lat: 9.0533, lng: 8.6344 }, + NI: { lat: 12.8716, lng: -85.4079 }, + NL: { lat: 52.1571, lng: 5.2035 }, + NP: { lat: 28.4103, lng: 84.1316 }, + NZ: { lat: -38.0695, lng: 175.5765 }, + OM: { lat: 20.7879, lng: 55.904 }, + PA: { lat: 8.4161, lng: -80.1042 }, + PE: { lat: -9.2026, lng: -75.038 }, + PG: { lat: -6.6263, lng: 145.9009 }, + PH: { lat: 15.521, lng: 122.0325 }, + PK: { lat: 30.4125, lng: 69.3558 }, + PL: { lat: 51.9395, lng: 19.0523 }, + PR: { lat: 18.2336, lng: -66.4167 }, + PS: { lat: 31.943, lng: 35.2365 }, + PT: { lat: 39.5594, lng: -7.9578 }, + PY: { lat: -23.4456, lng: -58.489 }, + QA: { lat: 25.3355, lng: 51.1753 }, + RO: { lat: 45.9547, lng: 24.9234 }, + RS: { lat: 44.2085, lng: 20.9079 }, + RU: { lat: 59.4247, lng: 103.6441 }, + RW: { lat: -2.0263, lng: 29.9205 }, + SA: { lat: 24.2544, lng: 45.1495 }, + SB: { lat: -7.9292, lng: 159.0643 }, + SD: { lat: 15.1146, lng: 30.1734 }, + SE: { lat: 62.234, lng: 17.4654 }, + SI: { lat: 46.1524, lng: 15.1315 }, + SK: { lat: 48.665, lng: 19.7191 }, + SL: { lat: 8.4165, lng: -11.7383 }, + SN: { lat: 14.4652, lng: -14.5465 }, + SO: { lat: 5.1707, lng: 46.0575 }, + SR: { lat: 3.9215, lng: -56.0014 }, + SS: { lat: 7.8786, lng: 29.5925 }, + SV: { lat: 13.7866, lng: -88.9095 }, + SY: { lat: 34.7714, lng: 39.0252 }, + SZ: { lat: -26.473, lng: 31.3741 }, + TD: { lat: 15.4158, lng: 18.7136 }, + TF: { lat: -49.2, lng: 69.64 }, + TG: { lat: 8.4738, lng: 0.9077 }, + TH: { lat: 13.0546, lng: 101.4825 }, + TJ: { lat: 38.8492, lng: 71.2111 }, + TL: { lat: -8.8333, lng: 126.1523 }, + TM: { lat: 39.0111, lng: 59.5243 }, + TN: { lat: 33.8288, lng: 9.5066 }, + TR: { lat: 38.9309, lng: 35.4824 }, + TT: { lat: 10.445, lng: -61.4225 }, + TW: { lat: 23.633, lng: 121.0287 }, + TZ: { lat: -6.3355, lng: 34.8283 }, + UA: { lat: 48.8142, lng: 31.0832 }, + UG: { lat: 1.4033, lng: 32.3077 }, + US: { lat: 37.2345, lng: -95.8259 }, + UY: { lat: -32.5312, lng: -55.8183 }, + UZ: { lat: 41.3659, lng: 64.4922 }, + VE: { lat: 6.4434, lng: -66.5316 }, + VN: { lat: 15.9759, lng: 105.7529 }, + VU: { lat: -15.1833, lng: 166.9496 }, + XK: { lat: 42.5596, lng: 20.9229 }, + YE: { lat: 15.793, lng: 47.8567 }, + ZA: { lat: -28.4552, lng: 24.5875 }, + ZM: { lat: -13.0997, lng: 27.6868 }, + ZW: { lat: -18.8897, lng: 29.057 }, +} + +export function getCentroid(iso2: string): Centroid | null { + if (!iso2) return null + const code = ISO_ALIASES[iso2] ?? iso2 + return COUNTRY_CENTROIDS[code] ?? SUPPLEMENTARY_CENTROIDS[code] ?? null +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts new file mode 100644 index 00000000..a71b3dbb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts @@ -0,0 +1,28 @@ +// ©AngelaMos | 2026 +// ethos-tokens.ts + +export const colors = { + bg: '#0a0a0a', + bgPanel: '#0f0f0f', + bgRowHover: '#141414', + fg1: '#e5e5e5', + fg2: '#a3a3a3', + fg3: '#6b7280', + fg4: '#404040', + ok: '#4ade80', + amber: '#f59e0b', +} as const + +export const density = { + rowPy: 4, + rowPx: 8, + lineHeightTight: 1.25, + letterSpacingLabel: '0.08em', +} as const + +export const typeSizes = { + body: 10, + label: 10, + numL: 18, + numXL: 25, +} as const diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/usePriceFlash.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/usePriceFlash.ts new file mode 100644 index 00000000..0cd0a1eb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/usePriceFlash.ts @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// usePriceFlash.ts + +import { useEffect, useRef, useState } from 'react' + +export type FlashDir = 'up' | 'down' | null + +interface FlashState { + dir: FlashDir + key: number +} + +const FLASH_MS = 600 + +export function usePriceFlash(price: number | null): FlashState { + const prevRef = useRef(null) + const [state, setState] = useState({ dir: null, key: 0 }) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (price === null) return + + const prev = prevRef.current + prevRef.current = price + + if (prev === null || price === prev) return + + if (timerRef.current !== null) clearTimeout(timerRef.current) + + const dir: FlashDir = price > prev ? 'up' : 'down' + setState((s) => ({ dir, key: s.key + 1 })) + + timerRef.current = setTimeout(() => { + setState((s) => ({ ...s, dir: null })) + }, FLASH_MS) + }, [price]) + + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + } + }, []) + + return state +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/useThrottledValue.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/useThrottledValue.ts new file mode 100644 index 00000000..6dfd5058 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/useThrottledValue.ts @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// useThrottledValue.ts + +import { useEffect, useRef, useState } from 'react' + +const DEFAULT_INTERVAL_MS = 200 + +export function useThrottledValue( + value: T, + intervalMs: number = DEFAULT_INTERVAL_MS +): T { + const [throttled, setThrottled] = useState(value) + const latestRef = useRef(value) + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + latestRef.current = value + const now = performance.now() + const elapsed = now - lastEmitRef.current + + if (elapsed >= intervalMs) { + lastEmitRef.current = now + setThrottled(value) + return + } + + if (timerRef.current !== null) return + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = performance.now() + setThrottled(latestRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + } + }, []) + + return throttled +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx new file mode 100644 index 00000000..d37d2419 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx @@ -0,0 +1,18 @@ +// =========================== +// ©AngelaMos | 2025 +// main.tsx +// =========================== + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import '@fontsource-variable/inter/index.css' +import '@fontsource/geist-mono/400.css' +import '@fontsource/geist-mono/500.css' +import App from './App' +import './styles.scss' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss new file mode 100644 index 00000000..ef515000 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss @@ -0,0 +1,468 @@ +// =================== +// © AngelaMos | 2025 +// admin.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); + background-color: $bg-default; +} + +.header { + @include flex-between; + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.createBtn { + display: flex; + align-items: center; + gap: $space-2; + padding: $space-2 $space-4; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + width: 16px; + height: 16px; + } +} + +.table { + width: 100%; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('md') { + display: none; + } +} + +.tableHeaderCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + border-bottom: 1px solid $border-default; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('md') { + grid-template-columns: 1fr; + gap: $space-2; + } +} + +.tableCell { + display: flex; + align-items: center; + font-size: $font-size-sm; + color: $text-default; + min-width: 0; + + @include breakpoint-down('md') { + &::before { + content: attr(data-label); + font-size: $font-size-xs; + color: $text-lighter; + margin-right: $space-2; + min-width: 80px; + } + } +} + +.email { + @include truncate; +} + +.badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.admin { + background: $bg-selection; + color: $text-default; + } + + &.user { + background: $bg-surface-200; + color: $text-light; + } + + &.active { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.inactive { + background: hsl(0 72% 51% / 20%); + color: $error-light; + } +} + +.actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @include breakpoint-down('md') { + justify-content: flex-start; + } +} + +.actionBtn { + width: 32px; + height: 32px; + @include flex-center; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.delete { + @include hover { + border-color: $error-default; + color: $error-default; + } + } + + svg { + width: 16px; + height: 16px; + } +} + +.pagination { + @include flex-between; + padding: $space-4; + border-top: 1px solid $border-default; +} + +.paginationInfo { + font-size: $font-size-sm; + color: $text-lighter; +} + +.paginationBtns { + display: flex; + gap: $space-2; +} + +.paginationBtn { + padding: $space-2 $space-3; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.empty { + @include flex-column-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.modal { + position: fixed; + inset: 0; + z-index: $z-modal; + @include flex-center; +} + +.modalOverlay { + @include absolute-fill; + background: rgb(0, 0, 0, 70%); +} + +.modalContent { + position: relative; + width: 100%; + max-width: 400px; + margin: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-6; +} + +.modalHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.modalTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.modalClose { + width: 32px; + height: 32px; + @include flex-center; + border: none; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 20px; + height: 20px; + } +} + +.form { + @include flex-column; + gap: $space-4; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; +} + +.input { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } +} + +.select { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.checkbox { + display: flex; + align-items: center; + gap: $space-2; + cursor: pointer; + + input { + width: 18px; + height: 18px; + accent-color: $white; + } + + span { + font-size: $font-size-sm; + color: $text-light; + } +} + +.formActions { + display: flex; + gap: $space-3; + margin-top: $space-2; +} + +.submitBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelBtn { + flex: 1; + height: 44px; + @include flex-center; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} + +.deleteConfirm { + @include flex-column; + gap: $space-4; +} + +.deleteText { + font-size: $font-size-sm; + color: $text-light; + line-height: $line-height-relaxed; +} + +.deleteEmail { + font-weight: $font-weight-medium; + color: $text-default; +} + +.deleteBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $error-default; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx new file mode 100644 index 00000000..6d24d339 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx @@ -0,0 +1,390 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuPencil, LuPlus, LuTrash2, LuX } from 'react-icons/lu' +import { + useAdminCreateUser, + useAdminDeleteUser, + useAdminUpdateUser, + useAdminUsers, +} from '@/api/hooks' +import type { UserResponse } from '@/api/types' +import { UserRole } from '@/api/types' +import { PAGINATION } from '@/config' +import styles from './admin.module.scss' + +type ModalState = + | { type: 'closed' } + | { type: 'create' } + | { type: 'edit'; user: UserResponse } + | { type: 'delete'; user: UserResponse } + +export function Component(): React.ReactElement { + const [page, setPage] = useState(PAGINATION.DEFAULT_PAGE) + const [modal, setModal] = useState({ type: 'closed' }) + + const { data, isLoading } = useAdminUsers({ + page, + size: PAGINATION.DEFAULT_SIZE, + }) + const createUser = useAdminCreateUser() + const updateUser = useAdminUpdateUser() + const deleteUser = useAdminDeleteUser() + + const handleCreate = (formData: FormData): void => { + const email = formData.get('email') as string + const password = formData.get('password') as string + const rawName = (formData.get('fullName') as string) ?? '' + const name = rawName.trim() || email.split('@')[0] || 'user' + const role = formData.get('role') as UserRole + + createUser.mutate( + { email, password, name, role }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleUpdate = (userId: string, formData: FormData): void => { + const name = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + + updateUser.mutate( + { id: userId, data: { name, role } }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleDelete = (userId: string): void => { + deleteUser.mutate(userId, { onSuccess: () => setModal({ type: 'closed' }) }) + } + + const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0 + + return ( +
+
+

Users

+ +
+ +
+
+
Email
+
Name
+
Role
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No users found
+ )} + + {data?.items.map((user) => ( +
+
+ {user.email} +
+
+ {user.name} +
+
+ + {user.role} + +
+
+ + +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} users) + +
+ + +
+
+ )} +
+ + {modal.type === 'create' && ( +
+ +
+
{ + e.preventDefault() + handleCreate(new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + )} + + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.user.id, new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + )} + + {modal.type === 'delete' && ( +
+ +
+
+

+ Are you sure you want to delete{' '} + {modal.user.email}? + This action cannot be undone. +

+
+ + +
+
+ + + )} + + ) +} + +Component.displayName = 'AdminUsers' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/About.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/About.module.scss new file mode 100644 index 00000000..5771c1e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/About.module.scss @@ -0,0 +1,74 @@ +// ©AngelaMos | 2026 +// About.module.scss + +.dialog { + background: var(--bg-panel); + color: var(--fg-1); + border: 1px solid var(--fg-4); + padding: 0; + max-width: 480px; + width: 90%; + font-family: var(--font-sans); + font-size: var(--type-body); + + &::backdrop { + background: rgb(0, 0, 0, 60%); + } +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + border-bottom: 1px solid var(--fg-4); +} + +.title { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-2); +} + +.close { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + background: transparent; + border: 0; + padding: 0; + color: var(--fg-3); + cursor: pointer; + + &:hover { + color: var(--fg-1); + } + + &:focus-visible { + outline: 1px solid var(--fg-2); + outline-offset: 1px; + } +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + color: var(--fg-2); + line-height: 1.4; + + p { + margin: 0; + } +} + +.kbd { + font-family: var(--font-mono); + color: var(--fg-1); + border: 1px solid var(--fg-4); + padding: 0 4px; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/About.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/About.tsx new file mode 100644 index 00000000..fe504185 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/About.tsx @@ -0,0 +1,74 @@ +// ©AngelaMos | 2026 +// About.tsx + +import { useEffect, useRef } from 'react' +import { FiX } from 'react-icons/fi' +import { useUIStore } from '@/stores/ui' +import styles from './About.module.scss' + +export function About(): React.ReactElement { + const isOpen = useUIStore((s) => s.aboutOpen) + const close = useUIStore((s) => s.closeAbout) + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + if (isOpen && !dialog.open) { + dialog.showModal() + } else if (!isOpen && dialog.open) { + dialog.close() + } + }, [isOpen]) + + function handleBackdropClick(e: React.MouseEvent): void { + if (e.target === dialogRef.current) close() + } + + function handleKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Escape') close() + } + + return ( + +
+ Monitoring the Situation + +
+
+

+ "Monitoring the situation" is a Twitter/X meme from June 2025. This is + the version that actually monitors the situation. +

+

+ Operator-grade real-time dashboard pulling live data from ten + high-signal feeds: DShield mass-scan, Cloudflare Radar BGP/outage, NVD + + EPSS CVE velocity, CISA KEV, ransomware.live, Coinbase BTC/ETH ticks, + USGS earthquakes, NOAA SWPC space weather, Wikipedia ITN + GDELT theme + spikes, and the ISS live position. Single Go binary backend, React 19 + frontend, Postgres + Redis, served behind a Cloudflare Tunnel. +

+

+ F enters presentation mode (full + viewport, chrome hidden). Esc exits + or closes this dialog. +

+
+
+ ) +} + +About.displayName = 'About' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/AlertBanner.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/AlertBanner.module.scss new file mode 100644 index 00000000..fb4458ec --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/AlertBanner.module.scss @@ -0,0 +1,44 @@ +// ©AngelaMos | 2026 +// AlertBanner.module.scss + +.banner { + grid-area: alertbanner; + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 16px; + background: var(--amber); + color: var(--amber-fg); + font-size: var(--type-body); + font-weight: 500; +} + +.message { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + background: transparent; + border: 0; + padding: 0; + color: var(--amber-fg); + cursor: pointer; + + &:hover { + color: #000; + } + + &:focus-visible { + outline: 1px solid var(--amber-fg); + outline-offset: 1px; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/AlertBanner.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/AlertBanner.tsx new file mode 100644 index 00000000..329b4a4c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/AlertBanner.tsx @@ -0,0 +1,38 @@ +// ©AngelaMos | 2026 +// AlertBanner.tsx + +import { useEffect } from 'react' +import { FiX } from 'react-icons/fi' +import { useUIStore } from '@/stores/ui' +import styles from './AlertBanner.module.scss' + +const AUTO_DISMISS_MS = 30_000 + +export function AlertBanner(): React.ReactElement | null { + const alert = useUIStore((s) => s.currentAlert) + const dismiss = useUIStore((s) => s.dismissAlert) + + useEffect(() => { + if (!alert) return + const id = setTimeout(dismiss, AUTO_DISMISS_MS) + return () => clearTimeout(id) + }, [alert, dismiss]) + + if (!alert) return null + + return ( + + ) +} + +AlertBanner.displayName = 'AlertBanner' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/BottomTicker.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/BottomTicker.module.scss new file mode 100644 index 00000000..27c17526 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/BottomTicker.module.scss @@ -0,0 +1,59 @@ +// ©AngelaMos | 2026 +// BottomTicker.module.scss + +.ticker { + grid-area: bottomticker; + height: 32px; + border-top: 1px solid var(--fg-4); + background: var(--bg); + overflow: hidden; + position: relative; +} + +.track { + position: absolute; + top: 0; + bottom: 0; + display: inline-flex; + align-items: center; + gap: 48px; + white-space: nowrap; + will-change: transform; + animation: ticker-right 60s linear infinite; +} + +@keyframes ticker-right { + from { transform: translateX(-100%); } + to { transform: translateX(100vw); } +} + +.item { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--type-body); + color: var(--fg-2); + + &::after { + content: '·'; + color: var(--fg-4); + margin-left: 8px; + } +} + +.source { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + color: var(--fg-3); + text-transform: uppercase; +} + +.headline { + color: var(--fg-2); +} + +.ts { + font-family: var(--font-mono); + color: var(--fg-3); + font-variant-numeric: tabular-nums; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/BottomTicker.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/BottomTicker.tsx new file mode 100644 index 00000000..75ebc46f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/BottomTicker.tsx @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// BottomTicker.tsx + +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useTicker } from '@/stores/ticker' +import { useUIStore } from '@/stores/ui' +import styles from './BottomTicker.module.scss' + +const MS_PER_SECOND = 1_000 +const SECONDS_PER_MINUTE = 60 +const SECONDS_PER_HOUR = 3_600 +const COPY_COUNT = 3 +const TICKER_PX_PER_SECOND = 90 +const MIN_DURATION_S = 30 + +function measureDuration(el: HTMLDivElement): number { + const w = el.scrollWidth + const vw = window.innerWidth + return Math.max(MIN_DURATION_S, (w + vw) / TICKER_PX_PER_SECOND) +} + +export function BottomTicker(): React.ReactElement | null { + const items = useTicker((s) => s.items) + const isPresentation = useUIStore((s) => s.presentationMode) + const trackRef = useRef(null) + + useLayoutEffect(() => { + const el = trackRef.current + if (!el) return + el.style.animationDuration = `${measureDuration(el)}s` + }, []) + + useEffect(() => { + const onResize = (): void => { + const el = trackRef.current + if (!el) return + el.style.animationDuration = `${measureDuration(el)}s` + } + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + if (isPresentation) return null + if (items.length === 0) return
+ + return ( +
+
+ {Array.from({ length: COPY_COUNT }, (_, r) => + items.map((item, i) => ( + + {item.source} + {item.headline} + {formatRel(item.ts)} + + )) + )} +
+
+ ) +} + +BottomTicker.displayName = 'BottomTicker' + +function formatRel(ts: number): string { + const diff = (Date.now() - ts) / MS_PER_SECOND + if (diff < SECONDS_PER_MINUTE) return `${Math.floor(diff)}s` + if (diff < SECONDS_PER_HOUR) return `${Math.floor(diff / SECONDS_PER_MINUTE)}m` + return `${Math.floor(diff / SECONDS_PER_HOUR)}h` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.module.scss new file mode 100644 index 00000000..5a541952 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.module.scss @@ -0,0 +1,249 @@ +// ©AngelaMos | 2026 +// Dashboard.module.scss + +.root { + display: grid; + grid-template-rows: auto auto auto 1fr auto auto; + grid-template-areas: + 'topstrip' + 'alertbanner' + 'timezoneticker' + 'main' + 'bottomticker' + 'statusfooter'; + height: 100dvh; + width: 100vw; + background-color: var(--bg); + background-image: + radial-gradient(circle at center, rgb(255, 255, 255, 1.8%) 1px, transparent 1.2px); + background-size: 12px 12px; + color: var(--fg-1); + overflow: hidden; +} + +.grid { + grid-area: main; + display: grid; + grid-template-columns: + calc(var(--col-panel-width) + env(safe-area-inset-left)) + 1fr + calc(var(--col-panel-width) + env(safe-area-inset-right)); + min-height: 0; +} + +.left { + display: flex; + flex-direction: column; + overflow-y: auto; + border-right: 1px solid var(--fg-4); + padding-left: env(safe-area-inset-left); +} + +.center { + position: relative; + overflow: hidden; +} + +.right { + display: flex; + flex-direction: column; + overflow-y: auto; + border-left: 1px solid var(--fg-4); + padding-right: env(safe-area-inset-right); +} + +.group { + display: flex; + flex-direction: column; +} + +.group + .group { + border-top: 1px solid var(--rule-strong); +} + +.root.presentation { + .grid { + grid-template-columns: 280px 1fr 280px; + } + + .left { + border-right: 0; + } + + .right { + border-left: 0; + } +} + +// ── Globe resize hint ───────────────────────────────────────── + +.center::before, +.center::after { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 28px; + background: linear-gradient(to bottom, transparent, var(--fg-4), transparent); + border-radius: 1px; + pointer-events: none; + z-index: 1; + opacity: 0.5; + transition: opacity 200ms; +} + +.center::before { left: 4px; } +.center::after { right: 4px; } + +.center:hover::before, +.center:hover::after { + opacity: 1; +} + +// ── Rotate banner ───────────────────────────────────────────── + +.rotateBanner { + display: none; + flex-shrink: 0; +} + +// ── ≤749px: stack layout ───────────────────────────────────── +// Left → Right → Globe, full-width, page scrolls. + +@media (width <= 749px) { + .root { + display: flex; + flex-direction: column; + height: auto; + min-height: 100dvh; + overflow: hidden auto; + } + + .grid { + display: flex; + flex-direction: column; + flex: 1; + min-height: unset; + } + + .left { + order: 1; + overflow-y: visible; + border-right: none; + border-bottom: 1px solid var(--rule-strong); + } + + .right { + order: 2; + overflow-y: visible; + border-left: none; + border-top: 1px solid var(--rule-strong); + } + + .center { + order: 3; + height: 420px; + flex-shrink: 0; + border-top: 1px solid var(--rule-strong); + overflow: hidden; + } + + .center::before, + .center::after { + display: none; + } +} + +// ── ≤440px portrait: medium phone (16 Pro Max and below) ────── + +@media (width <= 440px) and (orientation: portrait) { + :root { + --type-num-xl: 18px; + --type-num-l: 15px; + } + + .center { + height: 360px; + } +} + +// ── ≤400px portrait: small phone (16 Pro and below) ────────── + +@media (width <= 400px) and (orientation: portrait) { + :root { + --type-num-xl: 16px; + --type-num-l: 13px; + } + + .center { + height: 300px; + } +} + +// ── ≤430px portrait: rotate banner ─────────────────────────── + +@media (width <= 430px) and (orientation: portrait) { + .rotateBanner { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 16px; + background: color-mix(in sRGB, var(--amber) 10%, transparent); + border-bottom: 1px solid color-mix(in sRGB, var(--amber) 25%, transparent); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--amber); + } +} + +// ── Landscape ≤550px: base phone landscape ─────────────────── +// 3-column stays, timezone + status dropped, type scaled down. + +@media (height <= 550px) and (orientation: landscape) and (width >= 500px) { + :root { + --col-panel-width: 180px; + --type-num-xl: 14px; + --type-num-l: 13px; + --type-body: 9px; + --type-label: 9px; + --type-col: 8.5px; + --type-meta: 8.5px; + --letter-spacing-label: 0.07em; + } + + .root { + grid-template-rows: auto auto 1fr auto; + grid-template-areas: + 'topstrip' + 'alertbanner' + 'main' + 'bottomticker'; + } + + .left, + .right { + overflow-x: hidden; + } +} + +// ── Landscape ≤440px: compact phone landscape ──────────────── + +@media (height <= 440px) and (orientation: landscape) and (width >= 500px) { + :root { + --col-panel-width: 160px; + --type-num-xl: 12px; + --type-num-l: 11px; + } +} + +// ── Landscape ≤400px: very compact phone landscape ─────────── + +@media (height <= 400px) and (orientation: landscape) and (width >= 500px) { + :root { + --col-panel-width: 145px; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx new file mode 100644 index 00000000..737ac359 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx @@ -0,0 +1,85 @@ +// ©AngelaMos | 2026 +// Dashboard.tsx + +import { Globe } from '@/pages/globe/Globe' +import { BGPHijackPanel } from '@/pages/panels/BGPHijackPanel' +import { BTCPanel } from '@/pages/panels/BTCPanel' +import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel' +import { DShieldPanel } from '@/pages/panels/DShieldPanel' +import { EarthquakePanel } from '@/pages/panels/EarthquakePanel' +import { ETHPanel } from '@/pages/panels/ETHPanel' +import { ISSPanel } from '@/pages/panels/ISSPanel' +import { KEVPanel } from '@/pages/panels/KEVPanel' +import { OutagePanel } from '@/pages/panels/OutagePanel' +import { RansomwarePanel } from '@/pages/panels/RansomwarePanel' +import { SpaceWeatherPanel } from '@/pages/panels/SpaceWeatherPanel' +import { WikiPanel } from '@/pages/panels/WikiPanel' +import { useUIStore } from '@/stores/ui' +import { About } from './About' +import { AlertBanner } from './AlertBanner' +import { BottomTicker } from './BottomTicker' +import styles from './Dashboard.module.scss' +import { useDashboardLifecycle } from './lifecycle' +import { presentationMode } from './presentationMode' +import { StatusFooter } from './StatusFooter' +import { TimezoneTickerBar } from './TimezoneTickerBar' +import { TopStrip } from './TopStrip' + +export function Dashboard(): React.ReactElement { + useDashboardLifecycle() + presentationMode.useGlobalShortcut() + const isPresentation = useUIStore((s) => s.presentationMode) + + const rootClass = isPresentation + ? `${styles.root} ${styles.presentation}` + : styles.root + + return ( +
+ + + + +
+ +
+ +
+ +
+ + + +
+ ) +} + +Dashboard.displayName = 'Dashboard' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/StatusFooter.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/StatusFooter.module.scss new file mode 100644 index 00000000..cf6fb932 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/StatusFooter.module.scss @@ -0,0 +1,117 @@ +// ©AngelaMos | 2026 +// StatusFooter.module.scss + +.bar { + grid-area: statusfooter; + height: 22px; + border-top: 1px solid var(--rule-strong); + background: var(--bg); + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + font-family: var(--font-sans); + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-3); + overflow: hidden; + white-space: nowrap; +} + +.brand { + color: var(--fg-2); + font-weight: 500; + flex-shrink: 0; +} + +.sep { + color: var(--fg-4); + flex-shrink: 0; +} + +.list { + display: flex; + align-items: center; + gap: 14px; + overflow: hidden; + flex: 1; +} + +.entry { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; +} + +.dot { + width: 6px; + height: 6px; + display: inline-block; + flex-shrink: 0; +} + +.live { background: var(--ok); } +.batch { background: var(--amber); } +.stale { background: var(--fg-3); } + +.label { + color: var(--fg-2); + font-weight: 500; +} + +.age { + color: var(--fg-3); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 9.5px; + letter-spacing: 0; + text-transform: lowercase; + font-weight: 400; + min-width: 22px; +} + +.liveText { + color: var(--ok); + font-family: var(--font-sans); + font-size: 9.5px; + letter-spacing: 0.04em; + text-transform: lowercase; + font-weight: 500; + min-width: 22px; +} + +.legend { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + border-left: 1px solid var(--fg-4); + padding-left: 12px; + margin-left: 4px; +} + +.legendDot { + width: 6px; + height: 6px; + display: inline-block; + flex-shrink: 0; +} + +.legendLabel { + color: var(--fg-3); + letter-spacing: 0.04em; + text-transform: lowercase; +} + +.legendSep { + color: var(--fg-4); + margin: 0 2px; +} + +@media (height <= 550px) and (orientation: landscape) and (width >= 600px) { + .bar { + display: none; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/StatusFooter.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/StatusFooter.tsx new file mode 100644 index 00000000..26603a09 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/StatusFooter.tsx @@ -0,0 +1,192 @@ +// ©AngelaMos | 2026 +// StatusFooter.tsx + +import { useEffect, useState } from 'react' +import { type SourceKey, useFreshness } from '@/stores/freshness' +import { useUIStore } from '@/stores/ui' +import styles from './StatusFooter.module.scss' + +const TICK_MS = 1_000 +const LIVE_THRESHOLD_MS = 5_000 +const AGE_BUCKET_MS = 5_000 + +interface SourceSpec { + key: SourceKey + label: string + staleAfterMs: number + batch: boolean + hint: string +} + +const SOURCES: readonly SourceSpec[] = [ + { + key: 'ws', + label: 'WS', + staleAfterMs: 10_000, + batch: false, + hint: 'WebSocket heartbeat', + }, + { + key: 'btc', + label: 'BTC', + staleAfterMs: 90_000, + batch: false, + hint: 'Coinbase WS · real-time', + }, + { + key: 'eth', + label: 'ETH', + staleAfterMs: 90_000, + batch: false, + hint: 'Coinbase WS · real-time', + }, + { + key: 'iss', + label: 'ISS', + staleAfterMs: 60_000, + batch: false, + hint: 'wheretheiss.at · real-time', + }, + { + key: 'spacewx', + label: 'SWPC', + staleAfterMs: 900_000, + batch: true, + hint: 'NOAA SWPC · polls every 1m (Kp/plasma) · 3h (X-ray)', + }, + { + key: 'quake', + label: 'USGS', + staleAfterMs: 600_000, + batch: true, + hint: 'USGS earthquake feed · polls every 1m', + }, + { + key: 'cve', + label: 'NVD', + staleAfterMs: 900_000, + batch: true, + hint: 'NVD CVE 2.0 · polls every 2h', + }, + { + key: 'kev', + label: 'KEV', + staleAfterMs: 86_400_000, + batch: true, + hint: 'CISA KEV catalog · polls every 1h', + }, + { + key: 'ransomware', + label: 'RANSOM', + staleAfterMs: 3_600_000, + batch: true, + hint: 'ransomware.live · polls every 15m', + }, + { + key: 'bgp', + label: 'BGP', + staleAfterMs: 600_000, + batch: true, + hint: 'Cloudflare Radar · polls every 5m', + }, + { + key: 'outage', + label: 'OUTAGE', + staleAfterMs: 1_800_000, + batch: true, + hint: 'Cloudflare Radar · polls every 5m', + }, + { + key: 'dshield', + label: 'DSHIELD', + staleAfterMs: 300_000, + batch: true, + hint: 'DShield / ISC SANS · polls every 1h', + }, + { + key: 'wiki', + label: 'WIKI', + staleAfterMs: 3_600_000, + batch: true, + hint: 'Wikipedia ITN · polls every 5m', + }, +] as const + +const MS_PER_S = 1_000 +const S_PER_M = 60 +const S_PER_H = 3_600 + +export function StatusFooter(): React.ReactElement | null { + const ts = useFreshness((s) => s.ts) + const isPresentation = useUIStore((s) => s.presentationMode) + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), TICK_MS) + return () => clearInterval(id) + }, []) + + if (isPresentation) return null + + return ( +
+ SOURCE FEED + + · + + + {SOURCES.map((src) => { + const last = ts[src.key] + const age = last ? now - last : null + const stale = + last === undefined + ? !src.batch + : age !== null && age > src.staleAfterMs + const dotCls = stale + ? `${styles.dot} ${styles.stale}` + : src.batch + ? `${styles.dot} ${styles.batch}` + : `${styles.dot} ${styles.live}` + const isLive = + !stale && !src.batch && age !== null && age < LIVE_THRESHOLD_MS + return ( + + + + {src.label} + + + {isLive ? 'live' : fmtAge(age)} + + + ) + })} + +
+ ) +} + +StatusFooter.displayName = 'StatusFooter' + +function fmtAge(ms: number | null): string { + if (ms === null || !Number.isFinite(ms) || ms < 0) return '—' + const bucketed = Math.floor(ms / AGE_BUCKET_MS) * AGE_BUCKET_MS + const s = Math.floor(bucketed / MS_PER_S) + if (s < S_PER_M) return `${s}s` + if (s < S_PER_H) return `${Math.floor(s / S_PER_M)}m` + return `${Math.floor(s / S_PER_H)}h` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TimezoneTickerBar.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TimezoneTickerBar.module.scss new file mode 100644 index 00000000..9b462b2e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TimezoneTickerBar.module.scss @@ -0,0 +1,95 @@ +// ©AngelaMos | 2026 +// TimezoneTickerBar.module.scss + +.bar { + grid-area: timezoneticker; + height: 26px; + border-bottom: 1px solid var(--fg-4); + background: var(--bg); + overflow: hidden; + display: flex; + align-items: center; +} + +.track { + display: flex; + align-items: center; + white-space: nowrap; + animation: scroll 48s linear infinite; + will-change: transform; +} + +.copy { + display: flex; + align-items: center; + gap: 28px; + padding-right: 28px; +} + +.city { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-sans); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + + &::after { + content: '·'; + color: var(--fg-4); + margin-left: 6px; + } +} + +.label { + color: var(--fg-2); + text-transform: uppercase; +} + +.time { + color: var(--fg-1); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-weight: 400; +} + +.dot { + width: 4px; + height: 4px; + display: inline-block; + flex-shrink: 0; +} + +.dotOffice { + background: var(--ok); +} + +.dotAwake { + background: var(--fg-2); +} + +.dotNeutral { + background: var(--fg-3); +} + +.dotAsleep { + background: transparent; + box-shadow: inset 0 0 0 1px var(--fg-3); +} + +@media (height <= 550px) and (orientation: landscape) and (width >= 600px) { + .bar { + display: none; + } +} + +@keyframes scroll { + from { + transform: translateX(0); + } + + to { + transform: translateX(-50%); + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TimezoneTickerBar.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TimezoneTickerBar.tsx new file mode 100644 index 00000000..8cd796db --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TimezoneTickerBar.tsx @@ -0,0 +1,90 @@ +// ©AngelaMos | 2026 +// TimezoneTickerBar.tsx + +import { useEffect, useState } from 'react' +import { useUIStore } from '@/stores/ui' +import styles from './TimezoneTickerBar.module.scss' + +const CITIES = [ + { label: 'UTC', tz: 'UTC' }, + { label: 'LONDON', tz: 'Europe/London' }, + { label: 'NEW YORK', tz: 'America/New_York' }, + { label: 'LOS ANGELES', tz: 'America/Los_Angeles' }, + { label: 'SÃO PAULO', tz: 'America/Sao_Paulo' }, + { label: 'MOSCOW', tz: 'Europe/Moscow' }, + { label: 'DUBAI', tz: 'Asia/Dubai' }, + { label: 'MUMBAI', tz: 'Asia/Kolkata' }, + { label: 'HONG KONG', tz: 'Asia/Hong_Kong' }, + { label: 'TOKYO', tz: 'Asia/Tokyo' }, + { label: 'SYDNEY', tz: 'Australia/Sydney' }, +] as const + +const TICK_MS = 1_000 + +type CityState = 'awake' | 'office' | 'asleep' + +function formatTime(d: Date, tz: string): string { + return d.toLocaleTimeString('en-US', { + timeZone: tz, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function localHour(d: Date, tz: string): number { + const h = d.toLocaleTimeString('en-US', { + timeZone: tz, + hour12: false, + hour: '2-digit', + }) + return Number.parseInt(h, 10) +} + +function cityState(hour: number): CityState { + if (hour >= 9 && hour <= 17) return 'office' + if (hour >= 7 && hour <= 21) return 'awake' + return 'asleep' +} + +export function TimezoneTickerBar(): React.ReactElement | null { + const [now, setNow] = useState(() => new Date()) + const isPresentation = useUIStore((s) => s.presentationMode) + + useEffect(() => { + const id = setInterval(() => setNow(new Date()), TICK_MS) + return () => clearInterval(id) + }, []) + + if (isPresentation) return null + + return ( +
+
+ {[0, 1].map((copy) => ( + + {CITIES.map((city) => { + const state = cityState(localHour(now, city.tz)) + const DOT_CLS: Record = { + office: styles.dotOffice, + awake: styles.dotAwake, + asleep: styles.dotAsleep, + } + const dotCls = `${styles.dot} ${DOT_CLS[state]}` + return ( + + + {city.label} + {formatTime(now, city.tz)} + + ) + })} + + ))} +
+
+ ) +} + +TimezoneTickerBar.displayName = 'TimezoneTickerBar' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TopStrip.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TopStrip.module.scss new file mode 100644 index 00000000..f8f05615 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TopStrip.module.scss @@ -0,0 +1,174 @@ +// ©AngelaMos | 2026 +// TopStrip.module.scss + +.strip { + position: relative; + grid-area: topstrip; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + height: 36px; + padding-left: max(12px, env(safe-area-inset-left)); + padding-right: max(12px, env(safe-area-inset-right)); + border-bottom: 1px solid var(--rule-strong); + background-color: var(--bg); + background-image: + radial-gradient(circle at center, rgb(255, 255, 255, 5%) 1px, transparent 1.2px); + background-size: 6px 6px; + background-position: 0 0; +} + +.strip::before, +.strip::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 96px; + pointer-events: none; +} + +.strip::before { + left: 0; + background: linear-gradient(to right, var(--bg) 35%, transparent 100%); +} + +.strip::after { + right: 0; + background: linear-gradient(to left, var(--bg) 35%, transparent 100%); +} + +.left { + position: relative; + display: flex; + align-items: center; + gap: 8px; + justify-self: start; + z-index: 1; +} + +.title { + font-family: var(--font-sans); + font-size: var(--type-label); + font-weight: 500; + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciaButton { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + justify-self: center; + padding: 3px 10px; + background: transparent; + border: 1px solid color-mix(in sRGB, var(--accent) 40%, transparent); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); + cursor: pointer; + z-index: 1; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; + + &:hover { + background: color-mix(in sRGB, var(--accent) 12%, transparent); + border-color: var(--accent); + color: var(--fg-1); + } + + &:active { + background: color-mix(in sRGB, var(--accent) 22%, transparent); + } + + &:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; + } +} + +.ciaIcon { + flex-shrink: 0; + width: 12px; + height: 12px; +} + +.right { + position: relative; + display: flex; + align-items: center; + gap: 8px; + justify-self: end; + z-index: 1; +} + +@media (width <= 749px), +(height <= 550px) and (orientation: landscape) and (width >= 600px) { + .ciaButton { + display: none; + } + + .strip { + grid-template-columns: 1fr auto; + } +} + +.fullscreenWrap { + position: relative; + display: inline-flex; +} + +.pwaHint { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 200px; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--fg-3); + border-radius: 4px; + font-family: var(--font-sans); + font-size: 10px; + letter-spacing: 0.04em; + line-height: 1.5; + color: var(--fg-1); + text-transform: none; + white-space: normal; + z-index: 100; + pointer-events: none; + + strong { + color: var(--accent); + font-weight: 600; + } +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + background: transparent; + border: 0; + padding: 0; + color: var(--fg-3); + cursor: pointer; + + &:hover { + color: var(--fg-1); + } + + &:focus-visible { + outline: 1px solid var(--fg-2); + outline-offset: 1px; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TopStrip.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TopStrip.tsx new file mode 100644 index 00000000..b6e63050 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/TopStrip.tsx @@ -0,0 +1,164 @@ +// ©AngelaMos | 2026 +// TopStrip.tsx + +import { useEffect, useRef, useState } from 'react' +import { + FiHelpCircle, + FiMaximize2, + FiMinimize2, + FiSettings, + FiUser, + FiVolume2, +} from 'react-icons/fi' +import { useNavigate } from 'react-router-dom' +import { ROUTES } from '@/config' +import { useAuthStore } from '@/core/lib/auth.store' +import { useUIStore } from '@/stores/ui' +import styles from './TopStrip.module.scss' + +const CIA_AUDIO_SRC = '/chip-in-my-brain.mp3' +const CIA_LABEL = 'IM BEING GANGSTALKED BY THE CIA' +const PWA_HINT_DURATION_MS = 4_000 +const ABOUT_HOVER_DELAY_MS = 300 + +function isIOS(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent) +} + +function isInStandaloneMode(): boolean { + return window.matchMedia('(display-mode: standalone)').matches +} + +export function TopStrip(): React.ReactElement | null { + const audioRef = useRef(null) + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const openAbout = useUIStore((s) => s.openAbout) + const isPresentation = useUIStore((s) => s.presentationMode) + const navigate = useNavigate() + const [isFullscreen, setIsFullscreen] = useState(false) + const [showPWAHint, setShowPWAHint] = useState(false) + const hintTimerRef = useRef | null>(null) + const aboutHoverRef = useRef | null>(null) + + useEffect(() => { + const handler = (): void => setIsFullscreen(!!document.fullscreenElement) + document.addEventListener('fullscreenchange', handler) + return () => document.removeEventListener('fullscreenchange', handler) + }, []) + + useEffect(() => { + if (!showPWAHint) return + const dismiss = (): void => { + if (hintTimerRef.current) clearTimeout(hintTimerRef.current) + setShowPWAHint(false) + } + document.addEventListener('pointerdown', dismiss, { capture: true }) + return () => + document.removeEventListener('pointerdown', dismiss, { capture: true }) + }, [showPWAHint]) + + function handleCIA(): void { + if (!audioRef.current) { + audioRef.current = new Audio(CIA_AUDIO_SRC) + } + audioRef.current.currentTime = 0 + audioRef.current.play().catch(() => undefined) + } + + function handleFullscreen(): void { + if (isIOS() && !isInStandaloneMode()) { + if (hintTimerRef.current) clearTimeout(hintTimerRef.current) + setShowPWAHint(true) + hintTimerRef.current = setTimeout( + () => setShowPWAHint(false), + PWA_HINT_DURATION_MS + ) + return + } + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => undefined) + } else { + document.documentElement.requestFullscreen().catch(() => undefined) + } + } + + if (isPresentation) return null + + return ( +
+
+ + MONITORING THE SITUATION + + +
+ + + +
+
+ + {showPWAHint && ( +
+ Tap ShareAdd to Home Screen for + fullscreen +
+ )} +
+ {isAuthenticated ? ( + + ) : ( + + )} +
+
+ ) +} + +TopStrip.displayName = 'TopStrip' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..b183a002 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx @@ -0,0 +1,4 @@ +// ©AngelaMos | 2026 +// index.tsx + +export { Dashboard as Component } from './Dashboard' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts new file mode 100644 index 00000000..cbf68fb7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts @@ -0,0 +1,495 @@ +// ©AngelaMos | 2026 +// lifecycle.ts + +import { type QueryClient, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { SNAPSHOT_KEY, type Snapshot, useSnapshot } from '@/api/snapshot' +import { + type BgpHijack, + type CveEvent, + type DShieldData, + type EarthquakePayload, + type GdeltSpike, + type InternetOutage, + type IssPosition, + isValidBgpHijack, + isValidCoinbaseTick, + isValidCveEvent, + isValidDShieldData, + isValidEarthquakePayload, + isValidGdeltSpike, + isValidInternetOutage, + isValidIssPosition, + isValidKevEntry, + isValidRansomwareVictim, + isValidWikiItn, + type KevEntry, + type RansomwareVictim, + type WikiItn, +} from '@/api/types' +import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws' +import { unlockOnFirstGesture } from '@/lib/audio' +import { getCentroid } from '@/lib/countryCentroids' +import { useBgpHijackStore } from '@/stores/bgpHijack' +import { useCveStore } from '@/stores/cve' +import { useEarthquakeStore } from '@/stores/earthquake' +import { type SourceKey, useFreshness } from '@/stores/freshness' +import { useGlobeEvents } from '@/stores/globeEvents' +import { useHeartbeat } from '@/stores/heartbeat' +import { useKevStore } from '@/stores/kev' +import { useOutageStore } from '@/stores/outage' +import { usePrices } from '@/stores/prices' +import { useRansomwareStore, victimKey } from '@/stores/ransomware' +import { useTicker } from '@/stores/ticker' + +const ALL_TOPICS: readonly string[] = [ + 'heartbeat', + 'scan_firehose', + 'internet_outage', + 'bgp_hijack', + 'cve_new', + 'kev_added', + 'ransomware_victim', + 'coinbase_price', + 'earthquake', + 'space_weather', + 'wiki_itn', + 'gdelt_spike', + 'iss_position', +] + +const WS_URL = '/api/v1/ws' +const GLOBE_RING_TTL_MS = 4_000 +const GLOBE_EVICT_INTERVAL_MS = 5 * 60_000 + +export function useDashboardLifecycle(): void { + const { data: snapshot, isSuccess } = useSnapshot() + const queryClient = useQueryClient() + const wsRef = useRef | null>(null) + const globeSeededRef = useRef(false) + + useEffect(() => { + unlockOnFirstGesture() + }, []) + + useEffect(() => { + if (!snapshot || globeSeededRef.current) return + globeSeededRef.current = true + seedGlobeFromSnapshot(snapshot) + }, [snapshot]) + + useEffect(() => { + if (!isSuccess) return + + const ws = createDashboardWS({ + driver: () => browserDriver(WS_URL, [...ALL_TOPICS]), + topics: [...ALL_TOPICS], + onEvent: (ev) => routeEvent(ev, queryClient), + }) + wsRef.current = ws + ws.connect() + ws.setReady() + + return () => { + ws.disconnect() + wsRef.current = null + } + }, [isSuccess, queryClient]) + + useEffect(() => { + const id = setInterval(() => { + useGlobeEvents.getState().evict(Date.now()) + }, GLOBE_EVICT_INTERVAL_MS) + return () => clearInterval(id) + }, []) + + void snapshot +} + +function routeEvent(ev: WSEvent, queryClient: QueryClient): void { + if (ev.ch === 'heartbeat') { + useHeartbeat.getState().beat() + useFreshness.getState().ping('ws') + return + } + const data = ev.data + if (data === undefined || data === null) return + dispatchEvent(ev.ch, data, queryClient) +} + +function dispatchEvent( + ch: string, + data: unknown, + queryClient: QueryClient +): void { + const ping = useFreshness.getState().ping + switch (ch) { + case 'cve_new': + if (isValidCveEvent(data)) { + handleCve(data) + ping('cve') + } + break + case 'kev_added': + if (isValidKevEntry(data)) { + handleKev(data) + ping('kev') + } + break + case 'ransomware_victim': + if (isValidRansomwareVictim(data)) { + handleRansomware(data) + ping('ransomware') + } + break + case 'coinbase_price': + if (isValidCoinbaseTick(data)) { + handleCoinbase(data) + pingCoinbaseChannel(data.symbol, ping) + } + break + case 'earthquake': + if (isValidEarthquakePayload(data)) { + handleEarthquake(data) + ping('quake') + } + break + case 'iss_position': + if (isValidIssPosition(data)) { + handleIss(data, queryClient) + ping('iss') + } + break + case 'wiki_itn': + if (isValidWikiItn(data)) { + handleWiki(data) + ping('wiki') + } + break + case 'gdelt_spike': + if (isValidGdeltSpike(data)) handleGdelt(data) + break + case 'internet_outage': + if (isValidInternetOutage(data)) { + handleOutage(data, queryClient) + ping('outage') + } + break + case 'bgp_hijack': + if (isValidBgpHijack(data)) { + handleHijack(data, queryClient) + ping('bgp') + } + break + case 'scan_firehose': + if (isValidDShieldData(data)) { + handleScanFirehose(data, queryClient) + ping('dshield') + } + break + case 'space_weather': + mergeIntoSnapshot(queryClient, ch, data) + ping('spacewx') + break + default: + break + } +} + +function pingCoinbaseChannel(symbol: string, ping: (k: SourceKey) => void): void { + if (symbol === 'BTC-USD') ping('btc') + else if (symbol === 'ETH-USD') ping('eth') +} + +function handleCve(p: CveEvent): void { + useCveStore.getState().push(p) + useTicker.getState().push({ + id: `cve-tick-${p.CveID}`, + source: 'CVE', + headline: `${p.CveID} · ${p.Severity}`, + ts: Date.now(), + }) +} + +function handleKev(p: KevEntry): void { + useKevStore.getState().push(p) +} + +function handleRansomware(p: RansomwareVictim): void { + useRansomwareStore.getState().push(p) + pushRansomwarePoint(p) + useTicker.getState().push({ + id: `rw-tick-${victimKey(p)}`, + source: 'RANSOM', + headline: `${p.group_name} · ${p.post_title}`, + ts: Date.now(), + }) +} + +function pushRansomwarePoint(p: RansomwareVictim): void { + if (!p.country) return + const c = getCentroid(p.country) + if (!c) return + useGlobeEvents.getState().pushPoint({ + id: `rw-${victimKey(p)}`, + type: 'ransomware', + lat: c.lat, + lng: c.lng, + emittedAt: Date.now(), + meta: { group_name: p.group_name, post_title: p.post_title }, + }) +} + +function handleOutage(p: InternetOutage, queryClient: QueryClient): void { + useOutageStore.getState().push(p) + pushOutagePoints(p) + mergeIntoSnapshot(queryClient, 'internet_outage', p) +} + +function pushOutagePoints(p: InternetOutage): void { + if (!Array.isArray(p.locations)) return + const now = Date.now() + for (const loc of p.locations) { + const c = getCentroid(loc) + if (!c) continue + useGlobeEvents.getState().pushPoint({ + id: `outage-${p.id}-${loc}`, + type: 'outage', + lat: c.lat, + lng: c.lng, + emittedAt: now, + meta: { reason: p.reason, type: p.outageType }, + }) + } +} + +function handleHijack(p: BgpHijack, queryClient: QueryClient): void { + useBgpHijackStore.getState().push(p) + pushHijackPoint(p) + mergeIntoSnapshot(queryClient, 'bgp_hijack', p) + const n = p.prefixes?.length ?? 0 + useTicker.getState().push({ + id: `bgp-tick-${p.id}`, + source: 'BGP', + headline: `ASN ${p.hijackerAsn || '?'} · ${n} prefix${n !== 1 ? 'es' : ''} hijacked`, + ts: Date.now(), + }) +} + +function pushHijackPoint(p: BgpHijack): void { + const country = p.enrichment?.country + if (!country) return + const c = getCentroid(country) + if (!c) return + useGlobeEvents.getState().pushPoint({ + id: `hijack-${p.id}`, + type: 'hijack', + lat: c.lat, + lng: c.lng, + emittedAt: Date.now(), + meta: { + asn: p.hijackerAsn, + isp: p.enrichment?.isp, + prefixes: p.prefixes?.length, + }, + }) +} + +function handleScanFirehose(p: DShieldData, queryClient: QueryClient): void { + pushScanPoints(p) + mergeIntoSnapshot(queryClient, 'scan_firehose', p) +} + +function pushScanPoints(p: DShieldData): void { + const now = Date.now() + for (const src of p.topips ?? []) { + if (!src.country) continue + const c = getCentroid(src.country) + if (!c) continue + useGlobeEvents.getState().pushPoint({ + id: `scan-${src.source}`, + type: 'scan', + lat: c.lat, + lng: c.lng, + emittedAt: now, + meta: { source: src.source, reports: src.reports }, + }) + } +} + +function handleCoinbase(p: { + symbol: string + ts: string + price: string + volume_24h?: string +}): void { + const tsMs = new Date(p.ts).getTime() + const minute = Math.floor(tsMs / 60_000) * 60_000 + const prices = usePrices.getState() + + prices.pushTick({ + symbol: p.symbol, + ts: tsMs, + price: p.price, + volume24h: p.volume_24h, + }) + + const bars = prices.history[p.symbol] ?? [] + const existing = bars.find((b) => b.minute === minute) + const num = Number(p.price) + + prices.pushMinute({ + symbol: p.symbol, + minute, + open: existing?.open ?? p.price, + high: existing ? String(Math.max(Number(existing.high), num)) : p.price, + low: existing ? String(Math.min(Number(existing.low), num)) : p.price, + close: p.price, + volume: p.volume_24h, + }) +} + +function handleEarthquake(p: EarthquakePayload): void { + useEarthquakeStore.getState().push(p) + const coords = p.geometry?.coordinates + if (!Array.isArray(coords)) return + const lng = coords[0] + const lat = coords[1] + if (typeof lat !== 'number' || typeof lng !== 'number') return + const now = Date.now() + useGlobeEvents.getState().pushPoint({ + id: `eq-${p.id}`, + type: 'earthquake', + lat, + lng, + emittedAt: now, + meta: p.properties, + }) + useGlobeEvents.getState().pushRing({ + id: `eq-ring-${p.id}-${now}`, + lat, + lng, + emittedAt: now, + ttlMs: GLOBE_RING_TTL_MS, + }) +} + +function handleIss(p: IssPosition, queryClient: QueryClient): void { + useGlobeEvents.getState().pushPoint({ + id: 'iss-current', + type: 'iss', + lat: p.latitude, + lng: p.longitude, + emittedAt: Date.now(), + }) + mergeIntoSnapshot(queryClient, 'iss_position', p) +} + +function handleWiki(p: WikiItn): void { + if (!p.text) return + const id = `wiki-${p.slug || p.text}` + useTicker.getState().push({ + id, + source: 'Wikipedia', + headline: p.text, + ts: Date.now(), + href: p.slug ? `https://en.wikipedia.org/wiki/${p.slug}` : undefined, + }) +} + +function handleGdelt(p: GdeltSpike): void { + useTicker.getState().push({ + id: `gdelt-${p.theme}-${p.time}`, + source: 'GDELT', + headline: `${p.theme} spike (z=${p.zscore.toFixed(2)}, n=${p.count})`, + ts: Date.now(), + }) +} + +function mergeIntoSnapshot( + queryClient: QueryClient, + topic: string, + data: unknown +): void { + queryClient.setQueryData>(SNAPSHOT_KEY, (prev) => ({ + ...(prev ?? {}), + [topic]: data, + })) +} + +function seedGlobeFromSnapshot(snap: Snapshot): void { + const eq = snap.earthquake + if (isValidEarthquakePayload(eq)) { + useEarthquakeStore.getState().push(eq) + const coords = eq.geometry?.coordinates + if (Array.isArray(coords)) { + const lng = coords[0] + const lat = coords[1] + if (typeof lat === 'number' && typeof lng === 'number') { + useGlobeEvents.getState().pushPoint({ + id: `eq-${eq.id}`, + type: 'earthquake', + lat, + lng, + emittedAt: Date.now(), + meta: eq.properties, + }) + } + } + } + + const iss = snap.iss_position + if (isValidIssPosition(iss)) { + useGlobeEvents.getState().pushPoint({ + id: 'iss-current', + type: 'iss', + lat: iss.latitude, + lng: iss.longitude, + emittedAt: Date.now(), + }) + } + + const rw = snap.ransomware_victim + if (isValidRansomwareVictim(rw)) { + pushRansomwarePoint(rw) + useTicker.getState().push({ + id: `rw-tick-${victimKey(rw)}`, + source: 'RANSOM', + headline: `${rw.group_name} · ${rw.post_title}`, + ts: Date.now(), + }) + } + + const outage = snap.internet_outage + if (isValidInternetOutage(outage)) { + useOutageStore.getState().push(outage) + pushOutagePoints(outage) + } + + const hijack = snap.bgp_hijack + if (isValidBgpHijack(hijack)) { + useBgpHijackStore.getState().push(hijack) + pushHijackPoint(hijack) + const n = hijack.prefixes?.length ?? 0 + useTicker.getState().push({ + id: `bgp-tick-${hijack.id}`, + source: 'BGP', + headline: `ASN ${hijack.hijackerAsn || '?'} · ${n} prefix${n !== 1 ? 'es' : ''} hijacked`, + ts: Date.now(), + }) + } + + const scan = snap.scan_firehose + if (isValidDShieldData(scan)) pushScanPoints(scan) + + const wiki = snap.wiki_itn + if (isValidWikiItn(wiki) && wiki.text) { + useTicker.getState().push({ + id: `wiki-${wiki.slug || wiki.text}`, + source: 'Wikipedia', + headline: wiki.text, + ts: Date.now(), + href: wiki.slug ? `https://en.wikipedia.org/wiki/${wiki.slug}` : undefined, + }) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/presentationMode.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/presentationMode.ts new file mode 100644 index 00000000..a2687360 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/presentationMode.ts @@ -0,0 +1,52 @@ +// ©AngelaMos | 2026 +// presentationMode.ts + +import { useEffect } from 'react' +import { STORAGE_KEYS } from '@/config' +import { useUIStore } from '@/stores/ui' + +function readInitial(): boolean { + return localStorage.getItem(STORAGE_KEYS.PRESENTATION_MODE) === '1' +} + +function persist(on: boolean): void { + if (on) { + localStorage.setItem(STORAGE_KEYS.PRESENTATION_MODE, '1') + } else { + localStorage.removeItem(STORAGE_KEYS.PRESENTATION_MODE) + } +} + +function isTypingInForm(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + if (target.isContentEditable) return true + return ( + target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement + ) +} + +export const presentationMode = { + readInitial, + useGlobalShortcut(): void { + const setMode = useUIStore((s) => s.setPresentationMode) + + useEffect(() => { + setMode(readInitial()) + + function onKey(e: KeyboardEvent): void { + if (e.key === 'f' || e.key === 'F') { + if (isTypingInForm(e.target)) return + const next = !useUIStore.getState().presentationMode + setMode(next) + persist(next) + } else if (e.key === 'Escape') { + setMode(false) + persist(false) + } + } + + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [setMode]) + }, +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.module.scss new file mode 100644 index 00000000..afa50d02 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.module.scss @@ -0,0 +1,124 @@ +// ©AngelaMos | 2026 +// Globe.module.scss + +.globeWrap { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.legend { + position: absolute; + bottom: 14px; + left: 14px; + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: none; +} + +.legendRow { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-3); + line-height: 1; +} + +.legendDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.hbWrap { + position: absolute; + top: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 7px; + pointer-events: none; +} + +.hbConnecting, +.hbLive, +.hbDead { + position: relative; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.hbConnecting { + background: var(--fg-3); +} + +.hbLive { + background: var(--ok); +} + +.hbDead { + background: #f87171; +} + +.hbRing { + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--ok); + animation: hb-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes hb-ping { + 0% { + transform: scale(1); + opacity: 0.7; + } + + 100% { + transform: scale(2.8); + opacity: 0; + } +} + +.hbLabel { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-3); + line-height: 1; +} + +@keyframes drag-hint-fade { + 0% { opacity: 0; } + 15% { opacity: 1; } + 75% { opacity: 1; } + 100% { opacity: 0; } +} + +.dragHint { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-3); + pointer-events: none; + white-space: nowrap; + opacity: 0; + animation: drag-hint-fade 3.5s ease-in-out 1.2s 1 forwards; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx new file mode 100644 index 00000000..4b372d81 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx @@ -0,0 +1,228 @@ +// ©AngelaMos | 2026 +// Globe.tsx + +import { memo, useEffect, useRef, useState } from 'react' +import GlobeGL, { type GlobeMethods } from 'react-globe.gl' +import { useGlobeEvents } from '@/stores/globeEvents' +import { useHeartbeat } from '@/stores/heartbeat' +import styles from './Globe.module.scss' +import { panTo } from './globeCamera' +import { useGlobePoints, useGlobeRings } from './globeLayers' + +const GLOBE_AUTO_ROTATE_SPEED = 1.0 +const GLOBE_RESUME_DELAY_MS = 3_000 +const ATMOSPHERE_COLOR = '#1f2937' +const ATMOSPHERE_ALTITUDE = 0.12 +const RING_COLOR = '#e5e5e5' +const RING_MAX_RADIUS = 4 +const RING_PROPAGATION_SPEED = 4 +const RING_REPEAT_PERIOD = 0 +const COUNTRIES_URL = '/world-countries-110m.geo.json' +const COUNTRY_OUTLINE_COLOR = '#404040' +const COUNTRY_FILL_TRANSPARENT = 'rgba(0,0,0,0)' +const COUNTRY_ALTITUDE = 0.005 + +const LEGEND_ITEMS = [ + { color: '#4ade80', label: 'ISS' }, + { color: '#facc15', label: 'Ransomware' }, + { color: '#f59e0b', label: 'Outage' }, + { color: '#ef4444', label: 'Hijack' }, + { color: '#60a5fa', label: 'Scan' }, + { color: '#a855f7', label: 'Earthquake' }, +] as const + +interface CountryFeature { + type: 'Feature' + properties: Record + geometry: { + type: string + coordinates: unknown + } +} + +interface CountriesData { + type: 'FeatureCollection' + features: CountryFeature[] +} + +const GLOBE_DEFAULT_LAT = 15 +const GLOBE_DEFAULT_LNG = 10 +const GLOBE_DEFAULT_ALTITUDE = 2.5 + +export const Globe = memo(function Globe(): React.ReactElement { + const wrapRef = useRef(null) + const globeRef = useRef(undefined) + const resumeTimerRef = useRef | null>(null) + const viewInitRef = useRef(false) + const [size, setSize] = useState({ w: 0, h: 0 }) + const [countries, setCountries] = useState([]) + + const points = useGlobePoints() + const rings = useGlobeRings() + const focusEvent = useGlobeEvents((s) => s.focusEvent) + + useEffect(() => { + let cancelled = false + fetch(COUNTRIES_URL) + .then((res) => res.json() as Promise) + .then((fc) => { + if (cancelled) return + setCountries(fc.features) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return + const ro = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + setSize({ + w: entry.contentRect.width, + h: entry.contentRect.height, + }) + }) + ro.observe(wrap) + return () => ro.disconnect() + }, []) + + useEffect(() => { + if (size.w === 0) return + const globe = globeRef.current + if (!globe) return + + if (!viewInitRef.current) { + viewInitRef.current = true + globe.pointOfView( + { + lat: GLOBE_DEFAULT_LAT, + lng: GLOBE_DEFAULT_LNG, + altitude: GLOBE_DEFAULT_ALTITUDE, + }, + 0 + ) + } + + const controls = globe.controls() + if (!controls) return + + controls.autoRotate = true + controls.autoRotateSpeed = GLOBE_AUTO_ROTATE_SPEED + + const onStart = () => { + if (resumeTimerRef.current !== null) clearTimeout(resumeTimerRef.current) + controls.autoRotate = false + } + const onEnd = () => { + resumeTimerRef.current = setTimeout(() => { + controls.autoRotate = true + }, GLOBE_RESUME_DELAY_MS) + } + + controls.addEventListener('start', onStart) + controls.addEventListener('end', onEnd) + + return () => { + controls.removeEventListener('start', onStart) + controls.removeEventListener('end', onEnd) + if (resumeTimerRef.current !== null) clearTimeout(resumeTimerRef.current) + } + }, [size.w]) + + useEffect(() => { + if (!focusEvent) return + const controls = globeRef.current?.controls() + if (controls) { + if (resumeTimerRef.current !== null) clearTimeout(resumeTimerRef.current) + controls.autoRotate = false + } + panTo(globeRef, focusEvent.lat, focusEvent.lng) + }, [focusEvent]) + + return ( +
+ {size.w > 0 && size.h > 0 && ( + COUNTRY_FILL_TRANSPARENT} + polygonSideColor={() => COUNTRY_FILL_TRANSPARENT} + polygonStrokeColor={() => COUNTRY_OUTLINE_COLOR} + polygonsTransitionDuration={0} + pointsData={points} + pointsMerge + pointLat="lat" + pointLng="lng" + pointColor="color" + pointAltitude="altitude" + pointRadius="radius" + ringsData={rings} + ringLat="lat" + ringLng="lng" + ringColor={() => RING_COLOR} + ringMaxRadius={RING_MAX_RADIUS} + ringPropagationSpeed={RING_PROPAGATION_SPEED} + ringRepeatPeriod={RING_REPEAT_PERIOD} + atmosphereColor={ATMOSPHERE_COLOR} + atmosphereAltitude={ATMOSPHERE_ALTITUDE} + backgroundColor="rgba(0,0,0,0)" + /> + )} +
drag to rotate
+
+ {LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ ))} +
+ +
+ ) +}) + +const HB_STALE_MS = 12_000 + +function HeartbeatDot(): React.ReactElement { + const lastTs = useHeartbeat((s) => s.lastTs) + const [tick, setTick] = useState(0) + + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), 1_000) + return () => clearInterval(id) + }, []) + + void tick + const isLive = lastTs !== null && Date.now() - lastTs < HB_STALE_MS + const isConnecting = lastTs === null + + const dotClass = isConnecting + ? styles.hbConnecting + : isLive + ? styles.hbLive + : styles.hbDead + + const label = isConnecting ? 'CONNECTING' : isLive ? 'LIVE' : 'NO SIGNAL' + + return ( +
+
+ {isLive &&
} +
+ {label} +
+ ) +} + +Globe.displayName = 'Globe' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/globeCamera.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/globeCamera.ts new file mode 100644 index 00000000..1c5bb43f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/globeCamera.ts @@ -0,0 +1,20 @@ +// ©AngelaMos | 2026 +// globeCamera.ts + +import type { MutableRefObject } from 'react' +import type { GlobeMethods } from 'react-globe.gl' + +const DEFAULT_PAN_DURATION_MS = 1200 +const DEFAULT_FOCUS_ALTITUDE = 1.8 + +export type GlobeRef = MutableRefObject + +export function panTo( + ref: GlobeRef, + lat: number, + lng: number, + durationMs: number = DEFAULT_PAN_DURATION_MS, + altitude: number = DEFAULT_FOCUS_ALTITUDE +): void { + ref.current?.pointOfView({ lat, lng, altitude }, durationMs) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/globeLayers.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/globeLayers.ts new file mode 100644 index 00000000..7feb42a8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/globeLayers.ts @@ -0,0 +1,78 @@ +// ©AngelaMos | 2026 +// globeLayers.ts + +import { useMemo } from 'react' +import { type GlobePointType, useGlobeEvents } from '@/stores/globeEvents' + +const COLOR_BY_TYPE: Record = { + iss: '#4ade80', + ransomware: '#facc15', + outage: '#f59e0b', + hijack: '#ef4444', + scan: '#60a5fa', + earthquake: '#a855f7', +} + +const ALTITUDE_BY_TYPE: Record = { + iss: 0.08, + earthquake: 0.06, + hijack: 0.06, + ransomware: 0.05, + outage: 0.05, + scan: 0.04, +} + +const RADIUS_BY_TYPE: Record = { + iss: 0.6, + earthquake: 0.55, + hijack: 0.55, + ransomware: 0.5, + outage: 0.5, + scan: 0.45, +} + +export interface GlobePointDatum { + id: string + type: GlobePointType + lat: number + lng: number + color: string + altitude: number + radius: number +} + +export interface GlobeRingDatum { + id: string + lat: number + lng: number +} + +export function useGlobePoints(): GlobePointDatum[] { + const points = useGlobeEvents((s) => s.points) + return useMemo( + () => + points.map((p) => ({ + id: p.id, + type: p.type, + lat: p.lat, + lng: p.lng, + color: COLOR_BY_TYPE[p.type], + altitude: ALTITUDE_BY_TYPE[p.type], + radius: RADIUS_BY_TYPE[p.type], + })), + [points] + ) +} + +export function useGlobeRings(): GlobeRingDatum[] { + const rings = useGlobeEvents((s) => s.rings) + return useMemo( + () => + rings.map((r) => ({ + id: r.id, + lat: r.lat, + lng: r.lng, + })), + [rings] + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx new file mode 100644 index 00000000..07c1efd8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx @@ -0,0 +1,124 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { FiArrowLeft } from 'react-icons/fi' +import { LuEye, LuEyeOff } from 'react-icons/lu' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { useLogin } from '@/api/hooks' +import { loginRequestSchema } from '@/api/types' +import { ROUTES } from '@/config' +import { useAuthFormStore } from '@/core/lib' +import styles from './login.module.scss' + +export function Component(): React.ReactElement { + const navigate = useNavigate() + const login = useLogin() + + const { loginEmail, setLoginEmail, clearLoginForm } = useAuthFormStore() + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + + const result = loginRequestSchema.safeParse({ + email: loginEmail, + password, + }) + + if (!result.success) { + const firstError = result.error.issues[0] + toast.error(firstError.message) + return + } + + login.mutate(result.data, { + onSuccess: () => { + clearLoginForm() + navigate(ROUTES.DASHBOARD) + }, + }) + } + + return ( +
+ +
+
+

Login

+

Welcome back

+
+ +
+
+ + setLoginEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="current-password" + /> + +
+
+ + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ) +} + +Component.displayName = 'Login' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss new file mode 100644 index 00000000..4134dcf8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss @@ -0,0 +1,200 @@ +// =================== +// © AngelaMos | 2025 +// login.module.scss +// =================== + +@use '@/styles' as *; + +.page { + position: relative; + min-height: 100vh; + min-height: 100dvh; + font-size: 16px; + @include flex-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-page 1px, + transparent 1px + ); + background-size: 22px 22px; + padding: $space-4; +} + +.card { + width: 100%; + max-width: 400px; + background-color: $black; + background-image: radial-gradient( + circle, + $bg-card 1px, + transparent 1px + ); + background-size: 20px 20px; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-8; +} + +.header { + margin-bottom: $space-7; +} + +.title { + font-size: 2.6rem; + font-weight: $font-weight-semibold; + color: $white; + margin-bottom: $space-3; +} + +.subtitle { + font-size: 1.25rem; + color: $text-light; +} + + +.form { + @include flex-column; + gap: $space-5; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: 1.25rem; + font-weight: $font-weight-medium; + color: $white; +} + +.input { + width: 100%; + height: 48px; + padding: 0 $space-4; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: 1.1rem; + color: $white; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } + + &[aria-invalid='true'] { + border-color: $error-default; + } +} + +.inputWrapper { + position: relative; + width: 100%; +} + +.eyeButton { + position: absolute; + right: $space-3; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: $text-muted; + cursor: pointer; + padding: $space-1; + @include transition-fast; + + &:hover { + color: $white; + } + + svg { + width: 17px; + height: 17px; + } +} + +.error { + font-size: $font-size-xs; + color: $error-default; +} + +.submit { + width: 100%; + height: 48px; + margin-top: $space-2; + display: flex; + align-items: center; + justify-content: center; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: 1.35rem; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.footer { + margin-top: $space-7; + text-align: center; + font-size: $font-size-lg; + color: $text-light; +} + +.link { + color: $text-default; + text-decoration: underline; + text-underline-offset: 4px; + @include transition-fast; + + @include hover { + color: $text-light; + } +} + +.backButton { + position: absolute; + top: $space-5; + left: $space-5; + display: inline-flex; + align-items: center; + gap: $space-2; + background: transparent; + border: none; + color: $text-muted; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + cursor: pointer; + padding: $space-1 0; + @include transition-fast; + + @include hover { + color: $text-light; + } + + svg { + width: 16px; + height: 16px; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss new file mode 100644 index 00000000..3bef8861 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.module.scss @@ -0,0 +1,77 @@ +// ©AngelaMos | 2026 +// BGPHijackPanel.module.scss + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.table tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.asn { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + width: 18%; + color: var(--fg-1); +} + +.cc { + color: var(--fg-3); + width: 14%; +} + +.isp { + color: var(--fg-2); + width: 34%; +} + +.pfx { + font-family: var(--font-mono); + color: var(--fg-2); + width: 18%; +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 16%; + text-align: right; +} + +.flash { + animation: row-flash 600ms ease-out; +} + +@keyframes row-flash { + 0% { + background: var(--fg-4); + } + + 100% { + background: transparent; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx new file mode 100644 index 00000000..49a93e3f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx @@ -0,0 +1,121 @@ +// ©AngelaMos | 2026 +// BGPHijackPanel.tsx + +import { useEffect, useRef, useState } from 'react' +import { useBgpHijackData } from '@/api/hooks' +import { useFreshness } from '@/stores/freshness' +import styles from './BGPHijackPanel.module.scss' +import { Panel } from './Panel' + +const HIJACK_ROW_LIMIT = 6 +const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 600_000 +const MS_PER_HOUR = 3_600_000 +const MS_PER_MINUTE = 60_000 +const HOURS_PER_DAY = 24 + +export function BGPHijackPanel(): React.ReactElement { + const { items } = useBgpHijackData() + const seenIds = useRef>(new Set()) + const [flashIds, setFlashIds] = useState>(new Set()) + + useEffect(() => { + const isFirstSeed = seenIds.current.size === 0 + const newIds: number[] = [] + for (const h of items) { + if (!seenIds.current.has(h.id)) { + seenIds.current.add(h.id) + if (!isFirstSeed) newIds.push(h.id) + } + } + if (newIds.length > 0) setFlashIds(new Set(newIds)) + }, [items]) + + useEffect(() => { + if (flashIds.size === 0) return + const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS) + return () => clearTimeout(t) + }, [flashIds]) + + const recent = items.slice(0, HIJACK_ROW_LIMIT) + const now = Date.now() + + const lastTickAt = useFreshness((s) => s.ts.bgp) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + + + + + + + + + + + + + {recent.map((h) => { + const isFlashing = flashIds.has(h.id) + return ( + + + + + + + + ) + })} + +
ASNCCISPPfxAgo
{fmtAsn(h.hijackerAsn)}{h.enrichment?.country ?? '—'} + {h.enrichment?.isp ?? '—'} + + {fmtPfx(h.prefixes)} + {fmtAgo(h.detectedAt, now)}
+
+ ) +} + +BGPHijackPanel.displayName = 'BGPHijackPanel' + +function fmtAsn(asn: number | undefined): string { + if (!asn) return '—' + return `AS${asn}` +} + +function fmtPfx(prefixes: string[] | undefined): string { + if (!prefixes || prefixes.length === 0) return '—' + const first = prefixes[0] ?? '—' + if (prefixes.length === 1) return first + return `${first} +${prefixes.length - 1}` +} + +function fmtAgo(iso: string | undefined, now: number): string { + if (!iso || iso.startsWith('0001')) return '—' + const t = new Date(iso).getTime() + if (!Number.isFinite(t)) return '—' + const diff = now - t + if (diff < 0) return '—' + if (diff < MS_PER_HOUR) { + return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m` + } + if (diff < HOURS_PER_DAY * MS_PER_HOUR) { + return `${Math.floor(diff / MS_PER_HOUR)}h` + } + return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss new file mode 100644 index 00000000..0640fdc1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// BTCPanel.module.scss + +.hero { + display: flex; + align-items: baseline; + gap: 6px; + padding: 8px; +} + +.price { + font-family: var(--font-mono); + font-size: var(--type-num-xl); + font-variant-numeric: tabular-nums; + color: var(--fg-1); + line-height: 1; +} + +.unit { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + color: var(--fg-3); + text-transform: uppercase; +} + +.changes { + padding: 0 8px; +} + +.change { + display: grid; + grid-template-columns: 36px auto; + gap: 8px; + align-items: center; + font-family: var(--font-mono); + font-size: var(--type-body); + padding: var(--row-py) 0; +} + +.changeLabel { + font-family: var(--font-sans); + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); +} + +.changeValue { + color: var(--fg-1); + font-variant-numeric: tabular-nums; +} + +.up { color: var(--ok); } +.down { color: #f87171; } + +@keyframes flash-up { + 0% { color: var(--ok); } + 100% { color: var(--fg-1); } +} + +@keyframes flash-down { + 0% { color: #f87171; } + 100% { color: var(--fg-1); } +} + +.flashUp { + animation: flash-up 600ms ease-out forwards; +} + +.flashDown { + animation: flash-down 600ms ease-out forwards; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx new file mode 100644 index 00000000..92d8ce29 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx @@ -0,0 +1,115 @@ +// ©AngelaMos | 2026 +// BTCPanel.tsx + +import { useCoinbasePrices } from '@/api/hooks' +import { usePriceFlash } from '@/lib/usePriceFlash' +import { useThrottledValue } from '@/lib/useThrottledValue' +import styles from './BTCPanel.module.scss' +import { Panel } from './Panel' + +const SYMBOL = 'BTC-USD' +const PERCENT_DECIMALS = 2 +const PRICE_DECIMALS = 2 +const STALE_AFTER_MS = 60_000 +const PRICE_THROTTLE_MS = 1_500 + +export function BTCPanel(): React.ReactElement { + const { latest, history } = useCoinbasePrices(SYMBOL) + + const rawPriceNum = latest ? Number(latest.price) : null + const priceNum = useThrottledValue(rawPriceNum, PRICE_THROTTLE_MS) + const closes = history.map((b) => Number(b.close)) + const pct1h = computeChangePct(closes) + const { dir: flashDir, key: flashKey } = usePriceFlash(priceNum) + + const lastTickAt = latest?.ts + const isStale = + latest === undefined ? undefined : Date.now() - latest.ts > STALE_AFTER_MS + + const priceClass = [ + styles.price, + flashDir === 'up' + ? styles.flashUp + : flashDir === 'down' + ? styles.flashDown + : '', + ] + .filter(Boolean) + .join(' ') + + return ( + +
+ + {fmtPrice(priceNum)} + + USD +
+
+ +
+
+ ) +} + +BTCPanel.displayName = 'BTCPanel' + +function ChangeRow({ + label, + pct, +}: { + label: string + pct: number | null +}): React.ReactElement { + const isUp = pct !== null && pct >= 0 + return ( +
+ {label} + + {pct === null ? ( + '—' + ) : ( + <> + + {isUp ? '△' : '▽'} + + {` ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %`} + + )} + +
+ ) +} + +function computeChangePct(closes: number[]): number | null { + if (closes.length < 2) return null + const first = closes[0] + const last = closes[closes.length - 1] + if ( + first === undefined || + last === undefined || + !Number.isFinite(first) || + !Number.isFinite(last) || + first === 0 + ) { + return null + } + return ((last - first) / first) * 100 +} + +function fmtPrice(n: number | null): string { + if (n === null || !Number.isFinite(n)) return '—' + return n.toLocaleString(undefined, { + minimumFractionDigits: PRICE_DECIMALS, + maximumFractionDigits: PRICE_DECIMALS, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss new file mode 100644 index 00000000..f3a2cc26 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.module.scss @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// CVEVelocityPanel.module.scss + +.kpis { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid var(--fg-4); +} + +.spark { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + border-bottom: 1px solid var(--fg-4); + color: var(--fg-2); +} + +.recent { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.recent thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.recent tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recent tbody tr:hover { + background: var(--bg-row-hover); +} + +.cveId { + font-family: var(--font-mono); + width: 38%; +} + +.severity { + width: 22%; + color: var(--fg-2); +} + +.epss { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + width: 18%; + text-align: right; +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + width: 22%; + color: var(--fg-3); + text-align: right; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx new file mode 100644 index 00000000..84cd9f6e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx @@ -0,0 +1,145 @@ +// ©AngelaMos | 2026 +// CVEVelocityPanel.tsx + +import { useMemo } from 'react' +import { useCveData } from '@/api/hooks' +import type { CveEvent } from '@/api/types' +import { useFreshness } from '@/stores/freshness' +import styles from './CVEVelocityPanel.module.scss' +import { Panel } from './Panel' +import { KPI } from './shared/KPI' +import { Sparkline } from './shared/Sparkline' + +const RECENT_ROW_LIMIT = 5 +const SPARKLINE_HOURS = 24 +const STALE_AFTER_MS = 900_000 +const SPARKLINE_WIDTH = 280 +const SPARKLINE_HEIGHT = 28 +const MS_PER_HOUR = 3_600_000 +const MS_PER_MINUTE = 60_000 +const HOURS_PER_DAY = 24 + +export function CVEVelocityPanel(): React.ReactElement { + const { items } = useCveData() + const nowMinute = Math.floor(Date.now() / MS_PER_MINUTE) * MS_PER_MINUTE + + const counts = useMemo(() => { + return { + h1: countWithin(items, 1, nowMinute), + h6: countWithin(items, 6, nowMinute), + h24: countWithin(items, SPARKLINE_HOURS, nowMinute), + } + }, [items, nowMinute]) + + const hourly = useMemo( + () => hourlyBuckets(items, SPARKLINE_HOURS, nowMinute), + [items, nowMinute] + ) + + const recent = items.slice(0, RECENT_ROW_LIMIT) + const now = Date.now() + + const lastTickAt = useFreshness((s) => s.ts.cve) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + +
+ + + +
+
+ +
+ + + + + + + + + + + {recent.map((c) => ( + + + + + + + ))} + +
CVESevEPSSAgo
{c.CveID}{c.Severity}{fmtEpss(c.EPSSPercentile)}{fmtAgo(c.Published, now)}
+
+ ) +} + +CVEVelocityPanel.displayName = 'CVEVelocityPanel' + +function countWithin(items: CveEvent[], hours: number, now: number): number { + const cutoff = now - hours * MS_PER_HOUR + return items.reduce((acc, item) => { + const ts = parseTs(item.Published) + return ts !== null && ts >= cutoff ? acc + 1 : acc + }, 0) +} + +function hourlyBuckets(items: CveEvent[], hours: number, now: number): number[] { + const buckets = new Array(hours).fill(0) + for (const item of items) { + const ts = parseTs(item.Published) + if (ts === null) continue + const hoursAgo = Math.floor((now - ts) / MS_PER_HOUR) + if (hoursAgo >= 0 && hoursAgo < hours) { + const idx = hours - 1 - hoursAgo + buckets[idx] = (buckets[idx] ?? 0) + 1 + } + } + return buckets +} + +function parseTs(iso: string): number | null { + const t = new Date(iso).getTime() + return Number.isFinite(t) ? t : null +} + +function fmtEpss(percentile: number | null): string { + if (percentile === null) return '—' + return `${(percentile * 100).toFixed(1)}%` +} + +function fmtAgo(iso: string, now: number): string { + const t = parseTs(iso) + if (t === null) return '—' + const diffMs = now - t + if (diffMs < MS_PER_HOUR) { + const m = Math.floor(diffMs / MS_PER_MINUTE) + return `${Math.max(m, 0)}m` + } + if (diffMs < HOURS_PER_DAY * MS_PER_HOUR) { + const h = Math.floor(diffMs / MS_PER_HOUR) + const mins = Math.floor((diffMs % MS_PER_HOUR) / MS_PER_MINUTE) + return mins > 0 ? `${h}h${mins}m` : `${h}h` + } + const d = Math.floor(diffMs / (HOURS_PER_DAY * MS_PER_HOUR)) + return `${d}d` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss new file mode 100644 index 00000000..7c694774 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// DShieldPanel.module.scss + +.row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 8px; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.table tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.summary { + margin: 8px; + font-size: var(--type-body); + color: var(--fg-3); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.tag { + padding: var(--row-py) var(--row-px); + font-family: var(--font-mono); + font-size: var(--type-col); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + white-space: nowrap; +} + +.tagBenign { + color: var(--fg-2); +} + +.tagMalicious { + color: var(--amber); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx new file mode 100644 index 00000000..53179a59 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx @@ -0,0 +1,148 @@ +// ©AngelaMos | 2026 +// DShieldPanel.tsx + +import { useDShieldData } from '@/api/hooks' +import type { DShieldDailySummary, DShieldPort, DShieldSource } from '@/api/types' +import { useFreshness } from '@/stores/freshness' +import styles from './DShieldPanel.module.scss' +import { Panel } from './Panel' + +const PORT_ROW_LIMIT = 8 +const SOURCE_ROW_LIMIT = 8 +const STALE_AFTER_MS = 300_000 +const THOUSAND = 1_000 +const MILLION = 1_000_000 + +export function DShieldPanel(): React.ReactElement { + const ds = useDShieldData() + const lastTickAt = useFreshness((s) => s.ts.dshield) + + const ports = toArray(ds.topports) + .filter(isPort) + .slice() + .sort((a, b) => a.rank - b.rank) + .slice(0, PORT_ROW_LIMIT) + + const sources = (ds.topips ?? []) + .slice() + .sort((a, b) => a.rank - b.rank) + .slice(0, SOURCE_ROW_LIMIT) + + const summary = pickLatestSummary(ds.dailysummary) + const hasData = ports.length > 0 || sources.length > 0 + const isStale = !hasData + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + +
+ + + + + + + + + + {ports.map((p) => ( + + + + + + ))} + +
PortHitsSrc
{p.targetport}{fmtN(p.records)}{fmtN(p.sources)}
+ + + + + + + + + + + + {sources.map((s) => ( + + + + + + + ))} + +
Source IPReportsTgtTag
{s.source}{fmtN(s.reports)}{fmtN(s.targets)}{renderTag(s)}
+
+ + {summary && ( +

+ {fmtN(summary.records)} records · {fmtN(summary.sources)} sources ·{' '} + {fmtN(summary.targets)} targets — {summary.date} +

+ )} +
+ ) +} + +DShieldPanel.displayName = 'DShieldPanel' + +function toArray(v: Record | T[] | undefined): T[] { + if (!v) return [] + if (Array.isArray(v)) return v + return Object.values(v) +} + +function isPort(v: unknown): v is DShieldPort { + if (!v || typeof v !== 'object') return false + const p = v as Record + return ( + typeof p.targetport === 'number' && + typeof p.records === 'number' && + typeof p.sources === 'number' && + typeof p.rank === 'number' + ) +} + +function pickLatestSummary( + list: DShieldDailySummary[] | undefined +): DShieldDailySummary | undefined { + if (!list || list.length === 0) return undefined + return list.reduce((latest, entry) => + entry.date > latest.date ? entry : latest + ) +} + +function fmtN(n: number): string { + if (n >= MILLION) return `${(n / MILLION).toFixed(1)}M` + if (n >= THOUSAND) return `${(n / THOUSAND).toFixed(1)}k` + return String(n) +} + +function renderTag(s: DShieldSource): string { + if (s.actor && s.actor.length > 0) return s.actor.toUpperCase() + if (s.classification && s.classification.length > 0) { + return s.classification.toUpperCase() + } + return '' +} + +function tagClass(s: DShieldSource): string { + const c = (s.classification ?? '').toLowerCase() + if (c === 'malicious') return `${styles.tag} ${styles.tagMalicious}` + if (c === 'benign') return `${styles.tag} ${styles.tagBenign}` + return styles.tag +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss new file mode 100644 index 00000000..70d205bd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// ETHPanel.module.scss + +.hero { + display: flex; + align-items: baseline; + gap: 6px; + padding: 8px; +} + +.price { + font-family: var(--font-mono); + font-size: var(--type-num-xl); + font-variant-numeric: tabular-nums; + color: var(--fg-1); + line-height: 1; +} + +.unit { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + color: var(--fg-3); + text-transform: uppercase; +} + +.changes { + padding: 0 8px; +} + +.change { + display: grid; + grid-template-columns: 36px auto; + gap: 8px; + align-items: center; + font-family: var(--font-mono); + font-size: var(--type-body); + padding: var(--row-py) 0; +} + +.changeLabel { + font-family: var(--font-sans); + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); +} + +.changeValue { + color: var(--fg-1); + font-variant-numeric: tabular-nums; +} + +.up { color: var(--ok); } +.down { color: #f87171; } + +@keyframes flash-up { + 0% { color: var(--ok); } + 100% { color: var(--fg-1); } +} + +@keyframes flash-down { + 0% { color: #f87171; } + 100% { color: var(--fg-1); } +} + +.flashUp { + animation: flash-up 600ms ease-out forwards; +} + +.flashDown { + animation: flash-down 600ms ease-out forwards; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx new file mode 100644 index 00000000..9eaeab50 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx @@ -0,0 +1,115 @@ +// ©AngelaMos | 2026 +// ETHPanel.tsx + +import { useCoinbasePrices } from '@/api/hooks' +import { usePriceFlash } from '@/lib/usePriceFlash' +import { useThrottledValue } from '@/lib/useThrottledValue' +import styles from './ETHPanel.module.scss' +import { Panel } from './Panel' + +const SYMBOL = 'ETH-USD' +const PERCENT_DECIMALS = 2 +const PRICE_DECIMALS = 2 +const STALE_AFTER_MS = 60_000 +const PRICE_THROTTLE_MS = 1_500 + +export function ETHPanel(): React.ReactElement { + const { latest, history } = useCoinbasePrices(SYMBOL) + + const rawPriceNum = latest ? Number(latest.price) : null + const priceNum = useThrottledValue(rawPriceNum, PRICE_THROTTLE_MS) + const closes = history.map((b) => Number(b.close)) + const pct1h = computeChangePct(closes) + const { dir: flashDir, key: flashKey } = usePriceFlash(priceNum) + + const lastTickAt = latest?.ts + const isStale = + latest === undefined ? undefined : Date.now() - latest.ts > STALE_AFTER_MS + + const priceClass = [ + styles.price, + flashDir === 'up' + ? styles.flashUp + : flashDir === 'down' + ? styles.flashDown + : '', + ] + .filter(Boolean) + .join(' ') + + return ( + +
+ + {fmtPrice(priceNum)} + + USD +
+
+ +
+
+ ) +} + +ETHPanel.displayName = 'ETHPanel' + +function ChangeRow({ + label, + pct, +}: { + label: string + pct: number | null +}): React.ReactElement { + const isUp = pct !== null && pct >= 0 + return ( +
+ {label} + + {pct === null ? ( + '—' + ) : ( + <> + + {isUp ? '△' : '▽'} + + {` ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %`} + + )} + +
+ ) +} + +function computeChangePct(closes: number[]): number | null { + if (closes.length < 2) return null + const first = closes[0] + const last = closes[closes.length - 1] + if ( + first === undefined || + last === undefined || + !Number.isFinite(first) || + !Number.isFinite(last) || + first === 0 + ) { + return null + } + return ((last - first) / first) * 100 +} + +function fmtPrice(n: number | null): string { + if (n === null || !Number.isFinite(n)) return '—' + return n.toLocaleString(undefined, { + minimumFractionDigits: PRICE_DECIMALS, + maximumFractionDigits: PRICE_DECIMALS, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.module.scss new file mode 100644 index 00000000..12351f19 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.module.scss @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// EarthquakePanel.module.scss + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.table tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.mag { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + width: 16%; + text-align: right; +} + +.place { + width: 64%; + color: var(--fg-1); +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 20%; + text-align: right; +} + +.flash { + animation: row-flash 600ms ease-out; +} + +@keyframes row-flash { + 0% { + background: var(--fg-4); + } + + 100% { + background: transparent; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx new file mode 100644 index 00000000..d8d505c2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx @@ -0,0 +1,106 @@ +// ©AngelaMos | 2026 +// EarthquakePanel.tsx + +import { useEffect, useRef, useState } from 'react' +import { useEarthquakeData } from '@/api/hooks' +import { useFreshness } from '@/stores/freshness' +import styles from './EarthquakePanel.module.scss' +import { Panel } from './Panel' + +const QUAKE_ROW_LIMIT = 8 +const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 600_000 +const MS_PER_HOUR = 3_600_000 +const MS_PER_MINUTE = 60_000 +const HOURS_PER_DAY = 24 + +export function EarthquakePanel(): React.ReactElement { + const { items } = useEarthquakeData() + const seenIds = useRef>(new Set()) + const [flashIds, setFlashIds] = useState>(new Set()) + + useEffect(() => { + const isFirstSeed = seenIds.current.size === 0 + const newIds: string[] = [] + for (const q of items) { + if (!seenIds.current.has(q.id)) { + seenIds.current.add(q.id) + if (!isFirstSeed) newIds.push(q.id) + } + } + if (newIds.length > 0) setFlashIds(new Set(newIds)) + }, [items]) + + useEffect(() => { + if (flashIds.size === 0) return + const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS) + return () => clearTimeout(t) + }, [flashIds]) + + const recent = items.slice(0, QUAKE_ROW_LIMIT) + const now = Date.now() + + const lastTickAt = useFreshness((s) => s.ts.quake) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + + + + + + + + + + + {recent.map((q) => { + const isFlashing = flashIds.has(q.id) + return ( + + + + + + ) + })} + +
MagPlaceAgo
{fmtMag(q.properties?.mag)} + {q.properties?.place ?? '—'} + {fmtAgo(q.properties?.time, now)}
+
+ ) +} + +EarthquakePanel.displayName = 'EarthquakePanel' + +function fmtMag(mag: number | null | undefined): string { + if (mag === null || mag === undefined) return '—' + return mag.toFixed(1) +} + +function fmtAgo(ts: number | undefined, now: number): string { + if (!ts) return '—' + const diff = now - ts + if (diff < 0) return '—' + if (diff < MS_PER_HOUR) { + return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m` + } + if (diff < HOURS_PER_DAY * MS_PER_HOUR) { + return `${Math.floor(diff / MS_PER_HOUR)}h` + } + return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.module.scss new file mode 100644 index 00000000..afede6b4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.module.scss @@ -0,0 +1,29 @@ +// ©AngelaMos | 2026 +// ISSPanel.module.scss + +.row { + display: grid; + grid-template-columns: 90px 1fr; + gap: 8px; + padding: 4px 8px; + align-items: center; + font-family: var(--font-mono); + font-size: var(--type-body); + font-variant-numeric: tabular-nums; +} + +.label { + font-family: var(--font-sans); + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); +} + +.value { + color: var(--fg-1); +} + +.muted { + color: var(--fg-3); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx new file mode 100644 index 00000000..bf5b63db --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx @@ -0,0 +1,62 @@ +// ©AngelaMos | 2026 +// ISSPanel.tsx + +import { useIssPosition } from '@/api/hooks' +import styles from './ISSPanel.module.scss' +import { Panel } from './Panel' + +const STALE_AFTER_MS = 30_000 +const COORDS_DECIMALS = 2 +const ALT_DECIMALS = 0 + +export function ISSPanel(): React.ReactElement { + const iss = useIssPosition() + + const lastTickAt = iss?.fetched_at + ? new Date(iss.fetched_at).getTime() + : undefined + const isStale = + lastTickAt !== undefined + ? Date.now() - lastTickAt > STALE_AFTER_MS + : undefined + + return ( + +
+ Lat, Lon + + {iss + ? `${iss.latitude.toFixed(COORDS_DECIMALS)}°, ${iss.longitude.toFixed(COORDS_DECIMALS)}°` + : '—'} + +
+
+ Alt + + {iss ? `${iss.altitude.toFixed(ALT_DECIMALS)} km` : '—'} + +
+
+ Vel + + {iss ? `${Math.round(iss.velocity).toLocaleString()} km/h` : '—'} + +
+
+ Next Pass + +
+
+ ) +} + +ISSPanel.displayName = 'ISSPanel' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/KEVPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/KEVPanel.module.scss new file mode 100644 index 00000000..0eecb582 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/KEVPanel.module.scss @@ -0,0 +1,50 @@ +// ©AngelaMos | 2026 +// KEVPanel.module.scss + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.table tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.cveId { + font-family: var(--font-mono); + width: 38%; +} + +.vp { + color: var(--fg-2); + width: 40%; +} + +.date { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 22%; + text-align: right; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/KEVPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/KEVPanel.tsx new file mode 100644 index 00000000..7945a87b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/KEVPanel.tsx @@ -0,0 +1,58 @@ +// ©AngelaMos | 2026 +// KEVPanel.tsx + +import { useKevData } from '@/api/hooks' +import { useFreshness } from '@/stores/freshness' +import styles from './KEVPanel.module.scss' +import { Panel } from './Panel' + +const KEV_ROW_LIMIT = 6 +const STALE_AFTER_MS = 86_400_000 + +export function KEVPanel(): React.ReactElement { + const { items } = useKevData() + const recent = items.slice(0, KEV_ROW_LIMIT) + + const lastTickAt = useFreshness((s) => s.ts.kev) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + + + + + + + + + + + {recent.map((k) => ( + + + + + + ))} + +
CVEVendor · ProductAdded
{k.cveID} + {k.vendorProject} · {k.product} + {k.dateAdded}
+
+ ) +} + +KEVPanel.displayName = 'KEVPanel' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.module.scss new file mode 100644 index 00000000..b92a4011 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.module.scss @@ -0,0 +1,69 @@ +// ©AngelaMos | 2026 +// OutagePanel.module.scss + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.table tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.cc { + width: 16%; + color: var(--fg-3); +} + +.info { + width: 38%; + color: var(--fg-2); +} + +.state { + width: 18%; + color: var(--fg-2); +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 28%; + text-align: right; +} + +.flash { + animation: row-flash 600ms ease-out; +} + +@keyframes row-flash { + 0% { + background: var(--fg-4); + } + + 100% { + background: transparent; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx new file mode 100644 index 00000000..b4320d4e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx @@ -0,0 +1,138 @@ +// ©AngelaMos | 2026 +// OutagePanel.tsx + +import { useEffect, useRef, useState } from 'react' +import { useOutageData } from '@/api/hooks' +import { useFreshness } from '@/stores/freshness' +import styles from './OutagePanel.module.scss' +import { Panel } from './Panel' + +const OUTAGE_ROW_LIMIT = 6 +const REGIME_CC = 'IR' +const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 1_800_000 +const MS_PER_HOUR = 3_600_000 +const MS_PER_MINUTE = 60_000 +const HOURS_PER_DAY = 24 + +export function OutagePanel(): React.ReactElement { + const { items } = useOutageData() + const seenIds = useRef>(new Set()) + const [flashIds, setFlashIds] = useState>(new Set()) + + useEffect(() => { + const isFirstSeed = seenIds.current.size === 0 + const newIds: string[] = [] + for (const o of items) { + if (!seenIds.current.has(o.id)) { + seenIds.current.add(o.id) + if (!isFirstSeed) newIds.push(o.id) + } + } + if (newIds.length > 0) setFlashIds(new Set(newIds)) + }, [items]) + + useEffect(() => { + if (flashIds.size === 0) return + const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS) + return () => clearTimeout(t) + }, [flashIds]) + + const recent = items.slice(0, OUTAGE_ROW_LIMIT) + const now = Date.now() + + const lastTickAt = useFreshness((s) => s.ts.outage) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + + + + + + + + + + + + {recent.map((o) => { + const isFlashing = flashIds.has(o.id) + return ( + + + + + + + ) + })} + +
CCCauseStateStarted
{fmtCC(o.locations)} + {isRegime(o.locations) + ? 'Regime 👎' + : fmtCause(o.reason, o.outageType)} + {fmtState(o.endDate)}{fmtAgo(o.startDate, now)}
+
+ ) +} + +OutagePanel.displayName = 'OutagePanel' + +function isRegime(locations: string[] | undefined): boolean { + return locations?.[0] === REGIME_CC +} + +function fmtCC(locations: string[] | undefined): string { + if (!locations || locations.length === 0) return '—' + const first = locations[0] ?? '—' + if (locations.length === 1) return first + return `${first} +${locations.length - 1}` +} + +function fmtCause( + reason: string | undefined, + outageType: string | undefined +): string { + if (reason?.trim()) return reason + if (outageType?.trim()) return outageType + return '—' +} + +function fmtState(endDate: string | null | undefined): string { + if (endDate === null || endDate === undefined) return 'active' + const t = new Date(endDate).getTime() + if (!Number.isFinite(t)) return 'ended' + return 'ended' +} + +function fmtAgo(iso: string | undefined, now: number): string { + if (!iso) return '—' + const t = new Date(iso).getTime() + if (!Number.isFinite(t)) return '—' + const diff = now - t + if (diff < 0) return '—' + if (diff < MS_PER_HOUR) { + return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m` + } + if (diff < HOURS_PER_DAY * MS_PER_HOUR) { + return `${Math.floor(diff / MS_PER_HOUR)}h` + } + return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss new file mode 100644 index 00000000..a1c0f7e4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss @@ -0,0 +1,109 @@ +// ©AngelaMos | 2026 +// Panel.module.scss + +.panel { + position: relative; + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--fg-4); + min-height: 0; + padding-left: 2px; +} + +.panel::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--rule-strong); +} + +.head { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; + padding: 5px 10px 5px 12px; + background: var(--bg); + border-bottom: 1px solid var(--rule-strong); +} + +.titleBlock { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.title { + font-family: var(--font-sans); + font-size: var(--type-label); + font-weight: 500; + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.subtitle { + color: var(--fg-3); + font-weight: 400; +} + +.source { + font-family: var(--font-sans); + font-size: var(--type-meta); + letter-spacing: 0.04em; + color: var(--fg-3); + text-transform: lowercase; + font-weight: 400; +} + +.meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fg-3); + flex-shrink: 0; +} + +.rawLink { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--fg-3); + + &:hover { + color: var(--fg-1); + } + + &:focus-visible { + outline: 1px solid var(--fg-2); + outline-offset: 1px; + } +} + +.body { + flex: 1; + overflow: auto; + padding: 4px 0; + min-height: 0; +} + +.accentBgp { &::before { background: var(--accent-bgp); } } +.accentOutage { &::before { background: var(--accent-outage); } } +.accentKev { &::before { background: var(--accent-kev); } } +.accentRansomware { &::before { background: var(--accent-ransomware); } } +.accentDshield { &::before { background: var(--accent-dshield); } } +.accentQuake { &::before { background: var(--accent-quake); } } +.accentIss { &::before { background: var(--accent-iss); } } +.accentSpacewx { &::before { background: var(--accent-spacewx); } } +.accentCve { &::before { background: var(--accent-cve); } } +.accentWiki { &::before { background: var(--accent-wiki); } } +.accentBtc { &::before { background: var(--accent-btc); } } +.accentEth { &::before { background: var(--accent-eth); } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx new file mode 100644 index 00000000..5d3a7cb4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx @@ -0,0 +1,95 @@ +// ©AngelaMos | 2026 +// Panel.tsx + +import type { ReactNode } from 'react' +import { FiExternalLink } from 'react-icons/fi' +import styles from './Panel.module.scss' +import { StaleIndicator } from './shared/StaleIndicator' + +export type PanelAccent = + | 'bgp' + | 'outage' + | 'kev' + | 'ransomware' + | 'dshield' + | 'quake' + | 'iss' + | 'spacewx' + | 'cve' + | 'wiki' + | 'btc' + | 'eth' + +export interface PanelProps { + title: string + subtitle?: string + source?: string + accent?: PanelAccent + rawHref?: string + rawLabel?: string + isStale?: boolean + lastTickAt?: number + batch?: boolean + children: ReactNode +} + +export function Panel({ + title, + subtitle, + source, + accent, + rawHref, + rawLabel, + isStale, + lastTickAt, + batch, + children, +}: PanelProps): React.ReactElement { + const ACCENT_CLASS: Record = { + bgp: styles.accentBgp, + outage: styles.accentOutage, + kev: styles.accentKev, + ransomware: styles.accentRansomware, + dshield: styles.accentDshield, + quake: styles.accentQuake, + iss: styles.accentIss, + spacewx: styles.accentSpacewx, + cve: styles.accentCve, + wiki: styles.accentWiki, + btc: styles.accentBtc, + eth: styles.accentEth, + } + const accentClass = accent ? ACCENT_CLASS[accent] : '' + const panelClass = [styles.panel, accentClass].filter(Boolean).join(' ') + + return ( +
+
+ + + {title} + {subtitle && · {subtitle}} + + {source && {source}} + + + + {rawHref && ( + + + + )} + +
+
{children}
+
+ ) +} + +Panel.displayName = 'Panel' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss new file mode 100644 index 00000000..55f81358 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.module.scss @@ -0,0 +1,69 @@ +// ©AngelaMos | 2026 +// RansomwarePanel.module.scss + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table thead th { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + text-align: left; + padding: var(--row-py) var(--row-px); + border-bottom: 1px solid var(--fg-4); + font-weight: 400; +} + +.table tbody td { + padding: var(--row-py) var(--row-px); + font-size: var(--type-body); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.table tbody tr:hover { + background: var(--bg-row-hover); +} + +.victim { + font-family: var(--font-mono); + width: 50%; +} + +.group { + color: var(--fg-2); + width: 25%; +} + +.cc { + color: var(--fg-3); + width: 12%; +} + +.ago { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--fg-3); + width: 13%; + text-align: right; +} + +.flash { + animation: row-flash 600ms ease-out; +} + +@keyframes row-flash { + 0% { + background: var(--fg-4); + } + + 100% { + background: transparent; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx new file mode 100644 index 00000000..6dadc234 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/RansomwarePanel.tsx @@ -0,0 +1,106 @@ +// ©AngelaMos | 2026 +// RansomwarePanel.tsx + +import { useEffect, useRef, useState } from 'react' +import { useRansomwareData } from '@/api/hooks' +import { useFreshness } from '@/stores/freshness' +import { victimKey } from '@/stores/ransomware' +import { Panel } from './Panel' +import styles from './RansomwarePanel.module.scss' + +const VICTIM_ROW_LIMIT = 6 +const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 3_600_000 +const MS_PER_HOUR = 3_600_000 +const MS_PER_MINUTE = 60_000 +const HOURS_PER_DAY = 24 + +export function RansomwarePanel(): React.ReactElement { + const { items } = useRansomwareData() + const seenKeys = useRef>(new Set()) + const [flashKeys, setFlashKeys] = useState>(new Set()) + + useEffect(() => { + const isFirstSeed = seenKeys.current.size === 0 + const newKeys: string[] = [] + for (const v of items) { + const k = victimKey(v) + if (!seenKeys.current.has(k)) { + seenKeys.current.add(k) + if (!isFirstSeed) newKeys.push(k) + } + } + if (newKeys.length > 0) setFlashKeys(new Set(newKeys)) + }, [items]) + + useEffect(() => { + if (flashKeys.size === 0) return + const t = setTimeout(() => setFlashKeys(new Set()), FLASH_DURATION_MS) + return () => clearTimeout(t) + }, [flashKeys]) + + const recent = items.slice(0, VICTIM_ROW_LIMIT) + const now = Date.now() + + const lastTickAt = useFreshness((s) => s.ts.ransomware) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + + + + + + + + + + + + {recent.map((v) => { + const key = victimKey(v) + const isFlashing = flashKeys.has(key) + return ( + + + + + + + ) + })} + +
VictimGroupCCAgo
+ {v.post_title} + {v.group_name}{v.country ?? '—'}{fmtAgo(v.discovered, now)}
+
+ ) +} + +RansomwarePanel.displayName = 'RansomwarePanel' + +function fmtAgo(iso: string, now: number): string { + const t = new Date(iso).getTime() + if (!Number.isFinite(t)) return '—' + const diff = now - t + if (diff < MS_PER_HOUR) { + return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m` + } + if (diff < HOURS_PER_DAY * MS_PER_HOUR) { + return `${Math.floor(diff / MS_PER_HOUR)}h` + } + return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.module.scss new file mode 100644 index 00000000..4b793a10 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.module.scss @@ -0,0 +1,58 @@ +// ©AngelaMos | 2026 +// SpaceWeatherPanel.module.scss + +.row { + display: grid; + grid-template-columns: 92px 1fr 1fr; + gap: 8px; + padding: 4px 8px; + align-items: center; + font-family: var(--font-mono); + font-size: var(--type-body); + font-variant-numeric: tabular-nums; + color: var(--fg-1); +} + +.label { + font-family: var(--font-sans); + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); +} + +.value { + color: var(--fg-1); +} + +.muted { + color: var(--fg-3); +} + +.elevated { + color: var(--amber); +} + +.classLetter { + font-family: var(--font-sans); + color: var(--fg-1); +} + +.kpBar { + display: grid; + grid-template-columns: repeat(9, 1fr); + gap: 2px; + height: 10px; +} + +.kpSegEmpty { + border: 1px solid var(--fg-4); +} + +.kpSegFilled { + background: var(--fg-2); +} + +.kpSegElevated { + background: var(--amber); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx new file mode 100644 index 00000000..9f5afec1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx @@ -0,0 +1,104 @@ +// ©AngelaMos | 2026 +// SpaceWeatherPanel.tsx + +import { useSpaceWeather } from '@/api/hooks' +import { Panel } from './Panel' +import styles from './SpaceWeatherPanel.module.scss' + +const KP_SEGMENT_KEYS = [ + 'kp-0', + 'kp-1', + 'kp-2', + 'kp-3', + 'kp-4', + 'kp-5', + 'kp-6', + 'kp-7', + 'kp-8', +] as const +const KP_ELEVATED_THRESHOLD = 7 +const STALE_AFTER_MS = 600_000 +const KP_DECIMALS = 1 +const DENSITY_DECIMALS = 2 +const XRAY_FLUX_DECIMALS = 1 +const XRAY_ELEVATED_PATTERN = /^[MX]/ + +export function SpaceWeatherPanel(): React.ReactElement { + const sw = useSpaceWeather() + + const lastTickAt = sw.ts ? new Date(sw.ts).getTime() : undefined + const isStaleByTime = + lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + const kpElevated = sw.kp !== undefined && sw.kp >= KP_ELEVATED_THRESHOLD + const xrayElevated = + sw.xray_class !== undefined && XRAY_ELEVATED_PATTERN.test(sw.xray_class) + return ( + +
+ Kp Index + + {sw.kp !== undefined ? sw.kp.toFixed(KP_DECIMALS) : '—'} + + +
+
+ Solar Wind + + {sw.speed_kms !== undefined ? `${Math.round(sw.speed_kms)} km/s` : '—'} + + + {sw.density !== undefined + ? `${sw.density.toFixed(DENSITY_DECIMALS)} p/cc` + : '—'} + +
+
+ X-Ray Flux + + {sw.xray_flux !== undefined + ? sw.xray_flux.toExponential(XRAY_FLUX_DECIMALS) + : '—'} + + + {sw.xray_class ?? '—'} + +
+
+ ) +} + +SpaceWeatherPanel.displayName = 'SpaceWeatherPanel' + +interface KpBarProps { + kp: number | undefined + elevated: boolean +} + +function KpBar({ kp, elevated }: KpBarProps): React.ReactElement { + return ( +
+ {KP_SEGMENT_KEYS.map((key, i) => { + const filled = kp !== undefined && i < Math.floor(kp) + let cls: string + if (!filled) { + cls = styles.kpSegEmpty + } else if (elevated) { + cls = styles.kpSegElevated + } else { + cls = styles.kpSegFilled + } + return
+ })} +
+ ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.module.scss new file mode 100644 index 00000000..2cd0b22e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.module.scss @@ -0,0 +1,61 @@ +// ©AngelaMos | 2026 +// WikiPanel.module.scss + +.list { + padding: 0 8px 4px; +} + +.empty { + padding: var(--row-py) 0; + font-family: var(--font-mono); + font-size: var(--type-body); + color: var(--fg-3); +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: var(--row-py) 0; + border-bottom: 1px solid var(--fg-4); + + &:last-child { + border-bottom: none; + } +} + +.headline { + font-size: var(--type-body); + color: var(--fg-1); + line-height: 1.35; + flex: 1; + min-width: 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + &[href] { + text-decoration: none; + + &:hover { + color: var(--fg-1); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 1px solid var(--fg-2); + outline-offset: 1px; + } + } +} + +.ts { + font-family: var(--font-mono); + font-size: var(--type-label); + color: var(--fg-3); + flex-shrink: 0; + letter-spacing: var(--letter-spacing-label); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.tsx new file mode 100644 index 00000000..da1cdebe --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.tsx @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// WikiPanel.tsx + +import { useFreshness } from '@/stores/freshness' +import { useTicker } from '@/stores/ticker' +import { Panel } from './Panel' +import styles from './WikiPanel.module.scss' + +const MAX_ITEMS = 6 +const STALE_AFTER_MS = 3_600_000 +const MS_PER_SECOND = 1_000 +const SECONDS_PER_MINUTE = 60 +const SECONDS_PER_HOUR = 3_600 + +export function WikiPanel(): React.ReactElement { + const all = useTicker((s) => s.items) + const items = all + .filter((i) => i.source === 'Wikipedia') + .slice(-MAX_ITEMS) + .reverse() + + const lastTickAt = useFreshness((s) => s.ts.wiki) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + +
+ {items.length === 0 ? ( +
+ ) : ( + items.map((item) => ( +
+ {item.href ? ( + + {item.headline} + + ) : ( + {item.headline} + )} + {fmtRel(item.ts)} +
+ )) + )} +
+
+ ) +} + +WikiPanel.displayName = 'WikiPanel' + +function fmtRel(ts: number): string { + const diff = (Date.now() - ts) / MS_PER_SECOND + if (diff < SECONDS_PER_MINUTE) return `${Math.floor(diff)}s` + if (diff < SECONDS_PER_HOUR) return `${Math.floor(diff / SECONDS_PER_MINUTE)}m` + return `${Math.floor(diff / SECONDS_PER_HOUR)}h` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.module.scss new file mode 100644 index 00000000..f97a0951 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.module.scss @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// KPI.module.scss + +.kpi { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 8px; +} + +.value { + font-family: var(--font-mono); + font-size: var(--type-num-l); + font-variant-numeric: tabular-nums; + color: var(--fg-1); + line-height: 1; +} + +.label { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.tsx new file mode 100644 index 00000000..34ed09e0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.tsx @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// KPI.tsx + +import type { ReactNode } from 'react' +import styles from './KPI.module.scss' + +export interface KPIProps { + label: string + value: ReactNode +} + +export function KPI({ label, value }: KPIProps): React.ReactElement { + return ( +
+
{value}
+
{label}
+
+ ) +} + +KPI.displayName = 'KPI' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/Sparkline.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/Sparkline.tsx new file mode 100644 index 00000000..df64c136 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/Sparkline.tsx @@ -0,0 +1,49 @@ +// ©AngelaMos | 2026 +// Sparkline.tsx + +import { extent } from 'd3-array' +import { scaleLinear } from 'd3-scale' + +const DEFAULT_WIDTH = 80 +const DEFAULT_HEIGHT = 16 +const STROKE_WIDTH = 1 +const MIN_DATA_POINTS = 2 + +export interface SparklineProps { + data: number[] + width?: number + height?: number +} + +export function Sparkline({ + data, + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, +}: SparklineProps): React.ReactElement | null { + if (data.length < MIN_DATA_POINTS) return null + const [min, max] = extent(data) + if (min === undefined || max === undefined) return null + + const x = scaleLinear() + .domain([0, data.length - 1]) + .range([0, width]) + const y = scaleLinear().domain([min, max]).range([height, 0]) + + const path = data + .map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(v)}`) + .join('') + + return ( + + Trend sparkline + + + ) +} + +Sparkline.displayName = 'Sparkline' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss new file mode 100644 index 00000000..e5fbeb3d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// StaleIndicator.module.scss + +.dot { + display: inline-block; + width: 6px; + height: 6px; + flex-shrink: 0; +} + +.live { + background: var(--ok); +} + +.batch { + background: var(--amber); +} + +.stale { + background: var(--fg-3); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx new file mode 100644 index 00000000..164866ba --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx @@ -0,0 +1,34 @@ +// ©AngelaMos | 2026 +// StaleIndicator.tsx + +import styles from './StaleIndicator.module.scss' + +export interface StaleIndicatorProps { + stale?: boolean + lastTickAt?: number + batch?: boolean +} + +export function StaleIndicator({ + stale, + lastTickAt, + batch, +}: StaleIndicatorProps): React.ReactElement { + let state: 'live' | 'batch' | 'stale' + if (stale === undefined || stale === true) { + state = 'stale' + } else if (batch) { + state = 'batch' + } else { + state = 'live' + } + const className = `${styles.dot} ${styles[state]}` + + const title = lastTickAt + ? `Last update ${new Date(lastTickAt).toISOString().slice(11, 19)} UTC` + : undefined + + return +} + +StaleIndicator.displayName = 'StaleIndicator' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx new file mode 100644 index 00000000..ec7fd310 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx @@ -0,0 +1,193 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { FiArrowLeft } from 'react-icons/fi' +import { LuEye, LuEyeOff } from 'react-icons/lu' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { z } from 'zod' +import { useRegister } from '@/api/hooks' +import { userCreateRequestSchema } from '@/api/types' +import { PASSWORD_CONSTRAINTS, ROUTES } from '@/config' +import { useAuthFormStore } from '@/core/lib' +import styles from './register.module.scss' + +const registerFormSchema = userCreateRequestSchema + .extend({ + confirmPassword: z + .string() + .min( + PASSWORD_CONSTRAINTS.MIN_LENGTH, + `Password must be at least ${PASSWORD_CONSTRAINTS.MIN_LENGTH} characters` + ) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }) + +export function Component(): React.ReactElement { + const navigate = useNavigate() + const register = useRegister() + + const { registerEmail, setRegisterEmail, clearRegisterForm } = + useAuthFormStore() + const [name, setName] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + + const result = registerFormSchema.safeParse({ + email: registerEmail, + name, + password, + confirmPassword, + }) + + if (!result.success) { + const firstError = result.error.issues[0] + toast.error(firstError.message) + return + } + + register.mutate( + { + email: result.data.email, + name: result.data.name, + password: result.data.password, + }, + { + onSuccess: () => { + clearRegisterForm() + navigate(ROUTES.LOGIN) + }, + } + ) + } + + return ( +
+ +
+
+

Sign up

+

Create a new account

+
+ +
+
+ + setName(e.target.value)} + autoComplete="name" + /> +
+ +
+ + setRegisterEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ + +
+ +

+ Already have an account?{' '} + + Login + +

+
+
+ ) +} + +Component.displayName = 'Register' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss new file mode 100644 index 00000000..0e694975 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss @@ -0,0 +1,199 @@ +// =================== +// © AngelaMos | 2025 +// register.module.scss +// =================== + +@use '@/styles' as *; + +.page { + position: relative; + min-height: 100vh; + min-height: 100dvh; + font-size: 16px; + @include flex-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-page 1px, + transparent 1px + ); + background-size: 22px 22px; + padding: $space-4; +} + +.card { + width: 100%; + max-width: 400px; + background-color: $black; + background-image: radial-gradient( + circle, + $bg-card 1px, + transparent 1px + ); + background-size: 20px 20px; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-8; +} + +.header { + margin-bottom: $space-7; +} + +.title { + font-size: 2.6rem; + font-weight: $font-weight-semibold; + color: $white; + margin-bottom: $space-3; +} + +.subtitle { + font-size: 1.25rem; + color: $text-light; +} + +.form { + @include flex-column; + gap: $space-5; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: 1.2rem; + font-weight: $font-weight-medium; + color: $white; +} + +.input { + width: 100%; + height: 48px; + padding: 0 $space-4; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-lg; + color: $white; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } + + &[aria-invalid='true'] { + border-color: $error-default; + } +} + +.inputWrapper { + position: relative; + width: 100%; +} + +.eyeButton { + position: absolute; + right: $space-3; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: $text-muted; + cursor: pointer; + padding: $space-1; + @include transition-fast; + + &:hover { + color: $white; + } + + svg { + width: 17px; + height: 17px; + } +} + +.error { + font-size: $font-size-xs; + color: $error-default; +} + +.submit { + width: 100%; + height: 48px; + margin-top: $space-2; + display: flex; + align-items: center; + justify-content: center; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: 1.35rem; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.footer { + margin-top: $space-7; + text-align: center; + font-size: $font-size-lg; + color: $text-light; +} + +.link { + color: $text-default; + text-decoration: underline; + text-underline-offset: 4px; + @include transition-fast; + + @include hover { + color: $text-light; + } +} + +.backButton { + position: absolute; + top: $space-5; + left: $space-5; + display: inline-flex; + align-items: center; + gap: $space-2; + background: transparent; + border: none; + color: $text-muted; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + cursor: pointer; + padding: $space-1 0; + @include transition-fast; + + @include hover { + color: $text-light; + } + + svg { + width: 16px; + height: 16px; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx new file mode 100644 index 00000000..63b67c22 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx @@ -0,0 +1,799 @@ +// =================== +// © AngelaMos | 2026 +// index.tsx +// =================== + +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + type AlertRule, + useAlertRules, + useChangePassword, + useLogout, + useDeleteAlertRule, + useUpdateAlertRule, + useUpdateEmail, + useUpdateProfile, +} from '@/api/hooks' +import { + useCreateChannel, + useDeleteChannel, + useNotificationChannels, + useRegisterTelegram, + useTestChannel, + useTestTelegram, + useUnlinkTelegram, +} from '@/api/hooks/useNotifications' +import { + type ChannelType, + createChannelRequestSchema, + registerTelegramRequestSchema, +} from '@/api/types' +import { useUser } from '@/core/lib' +import styles from './settings.module.scss' + +type AddingType = 'slack' | 'discord' | null + +function ArrowLeft() { + return ( + + ) +} + +function TelegramLogo() { + return ( + + ) +} + +function SlackLogo() { + return ( + + ) +} + +function DiscordLogo() { + return ( + + ) +} + +const CHANNEL_ICON_CLASS: Record = { + slack: styles.channelIconSlack, + discord: styles.channelIconDiscord, + telegram: styles.channelIconTelegram, +} + +function ChannelIcon({ type }: { type: ChannelType }) { + return ( + + {type === 'slack' && } + {type === 'discord' && } + {type === 'telegram' && } + + ) +} + +const CHANNEL_CARD_COLOR: Record<'slack' | 'discord', string> = { + slack: styles.channelCardSlack, + discord: styles.channelCardDiscord, +} + +function WebhookChannelForm({ + type, + onCancel, +}: { + type: 'slack' | 'discord' + onCancel: () => void +}) { + const createChannel = useCreateChannel() + const [label, setLabel] = useState('') + const [webhookURL, setWebhookURL] = useState('') + const [error, setError] = useState(null) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const result = createChannelRequestSchema.safeParse({ + type, + label, + webhook_url: webhookURL, + }) + if (!result.success) { + setError(result.error.issues[0]?.message ?? 'Invalid input') + return + } + setError(null) + createChannel.mutate(result.data, { onSuccess: onCancel }) + } + + const typeName = type === 'slack' ? 'Slack' : 'Discord' + const placeholder = + type === 'slack' + ? 'https://hooks.slack.com/services/...' + : 'https://discord.com/api/webhooks/...' + + return ( +
+
+
+ + setLabel(e.target.value)} + /> +
+
+ + setWebhookURL(e.target.value)} + /> +
+
+ {error != null && {error}} +
+ + +
+
+ ) +} + +function TelegramRegisterForm({ onCancel }: { onCancel: () => void }) { + const registerTelegram = useRegisterTelegram() + const [botToken, setBotToken] = useState('') + const [error, setError] = useState(null) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const result = registerTelegramRequestSchema.safeParse({ + bot_token: botToken, + }) + if (!result.success) { + setError(result.error.issues[0]?.message ?? 'Invalid bot token') + return + } + setError(null) + registerTelegram.mutate(result.data, { onSuccess: onCancel }) + } + + return ( +
+
+ + setBotToken(e.target.value)} + /> + {error != null && {error}} +
+
+ + +
+
+ ) +} + +function TelegramSection() { + const { data } = useNotificationChannels() + const [showForm, setShowForm] = useState(false) + const unlinkTelegram = useUnlinkTelegram() + const testTelegram = useTestTelegram() + + const tg = data?.telegram + + const copyToClipboard = (text: string) => { + void navigator.clipboard.writeText(text) + } + + return ( +
+
+ Telegram +
+ +
+
+
+ +
+
+
+ Telegram bot + {tg?.configured === true && + (tg.linked ? ( + + linked + + ) : ( + + pending link + + ))} +
+ + {tg?.configured !== true && !showForm && ( +

+ Create a bot via @BotFather and paste the token below. We'll + register the webhook automatically. +

+ )} + + {tg?.configured === true && ( +
+ {tg.linked && ( + + )} + + +
+ )} + + {tg?.configured !== true && !showForm && ( + + )} +
+
+ + {showForm && setShowForm(false)} />} + + {!showForm && tg?.pending_link === true && ( +
+ + Action required — link your account + +

+ Open your bot in Telegram and send any message (e.g.{' '} + /start). It will capture your chat ID and activate + automatically. +

+ {tg.webhook_url != null && ( +
+

+ If the bot isn't responding, register the webhook manually + using your bot token: +

+
+ {tg.webhook_url} + +
+
+ )} +
+ )} +
+
+ ) +} + +function AccountSection() { + const user = useUser() + const updateProfile = useUpdateProfile() + const updateEmail = useUpdateEmail() + + const [name, setName] = useState(user?.name ?? '') + const [email, setEmail] = useState('') + const [emailPassword, setEmailPassword] = useState('') + + useEffect(() => { + if (user?.name) setName(user.name) + }, [user?.name]) + + const handleSaveName = (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + updateProfile.mutate({ name }) + } + + const handleChangeEmail = (e: React.FormEvent) => { + e.preventDefault() + if (!email.trim() || !emailPassword) return + updateEmail.mutate( + { current_password: emailPassword, new_email: email }, + { + onSuccess: () => { + setEmail('') + setEmailPassword('') + }, + } + ) + } + + return ( +
+
+ Account +
+ +
+
+ + setName(e.target.value)} + placeholder="Your display name" + autoComplete="name" + /> +
+
+ +
+
+ +
+
+ + +
+
+
+ + setEmail(e.target.value)} + placeholder="new@example.com" + autoComplete="email" + /> +
+
+ + setEmailPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + /> +
+
+
+ +
+
+ + +
+ ) +} + +function SignOutRow() { + const logout = useLogout() + return ( +
+ +
+ ) +} + +function PasswordSection() { + const changePassword = useChangePassword() + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (next.length < 8) { + setError('New password must be at least 8 characters') + return + } + if (next !== confirm) { + setError('Passwords do not match') + return + } + setError(null) + changePassword.mutate( + { current_password: current, new_password: next }, + { + onSuccess: () => { + setCurrent('') + setNext('') + setConfirm('') + }, + } + ) + } + + return ( +
+
+ Password +
+ +
+
+ + setCurrent(e.target.value)} + autoComplete="current-password" + /> +
+
+
+ + setNext(e.target.value)} + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + autoComplete="new-password" + /> +
+
+ {error != null && {error}} +
+ +
+
+
+ ) +} + +function AlertRulesSection() { + const { data: rules, isLoading } = useAlertRules() + const updateRule = useUpdateAlertRule() + const deleteRule = useDeleteAlertRule() + + return ( +
+
+ Alert rules +
+ {isLoading &&

Loading rules…

} + {!isLoading && (rules?.length ?? 0) === 0 && ( +
+

+ No rules yet. Defaults are seeded on registration. +

+
+ )} +
+ {rules?.map((r: AlertRule) => ( +
+
+
+ {r.name} +
+ {r.topic} + {r.cooldown_sec > 0 && ( + + cooldown {r.cooldown_sec}s + + )} + {!r.enabled && ( + + disabled + + )} +
+
+
+
+ + +
+
+ ))} +
+
+ ) +} + +function WebhookChannelsSection() { + const { data, isLoading } = useNotificationChannels() + const deleteChannel = useDeleteChannel() + const testChannel = useTestChannel() + const [addingType, setAddingType] = useState(null) + + const channels = (data?.channels ?? []).filter( + (ch) => ch.type === 'slack' || ch.type === 'discord' + ) + + return ( +
+
+ Slack & Discord + {addingType == null && ( +
+ + +
+ )} +
+ + {addingType != null && ( + setAddingType(null)} + /> + )} + +
+ {isLoading &&

Loading...

} + {!isLoading && channels.length === 0 && addingType == null && ( +
+

+ No channels configured. Add Slack or Discord to get started. +

+
+ )} + {channels.map((ch) => { + const colorClass = + ch.type === 'slack' || ch.type === 'discord' + ? CHANNEL_CARD_COLOR[ch.type] + : '' + return ( +
+
+ +
+ {ch.label} +
+ {ch.type} + {ch.invalid && ( + + invalid + + )} +
+
+
+
+ + +
+
+ ) + })} +
+
+ ) +} + +export function Component(): React.ReactElement { + const navigate = useNavigate() + + return ( +
+
+ + +
+

Settings

+

+ Manage your account, password, and where alerts get delivered. +

+
+ + + + +
+

Notifications

+

+ Configure where alert notifications are delivered. Each channel can be + tested after setup. +

+
+ + + + +
+
+ ) +} + +Component.displayName = 'Settings' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss new file mode 100644 index 00000000..747be7a9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss @@ -0,0 +1,549 @@ +// =================== +// © AngelaMos | 2026 +// settings.module.scss +// =================== + +@use '@/styles' as *; + +.page { + font-size: 16px; + min-height: calc(100vh - 56px); + padding: $space-10 $space-6; + background-color: $bg-default; + background-image: radial-gradient(circle, $bg-page 1px, transparent 1px); + background-size: 22px 22px; + + @include breakpoint-down('sm') { + padding: $space-6 $space-4; + } +} + +.container { + max-width: 880px; + margin: 0 auto; +} + +.backBtn { + display: inline-flex; + align-items: center; + gap: $space-2; + margin-bottom: $space-6; + padding: 0; + background: transparent; + border: none; + color: $text-lighter; + font-size: $font-size-sm; + cursor: pointer; + @include transition-fast; + + svg { + width: 16px; + height: 16px; + } + + @include hover { + color: $text-default; + } +} + +.header { + margin-bottom: $space-10; +} + +.title { + font-size: $font-size-3xl; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-3; +} + +.subtitle { + font-size: $font-size-base; + color: $text-lighter; + line-height: $line-height-relaxed; + max-width: 520px; +} + +.section { + margin-bottom: $space-10; +} + +.sectionHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $text-light; + text-transform: uppercase; + letter-spacing: $tracking-wider; +} + +.channelList { + @include flex-column; + gap: $space-3; +} + +.channelCard { + @include flex-between; + padding: $space-5; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + border-left-width: 3px; + gap: $space-4; + @include transition-normal; + + @include hover { + background: $bg-surface-200; + border-color: $border-strong; + } + + @include breakpoint-down('sm') { + flex-direction: column; + align-items: flex-start; + } +} + +.channelCardSlack { + border-left-color: #e01e5a; +} + +.channelCardDiscord { + border-left-color: #5865f2; +} + +.channelCardInvalid { + border-left-color: $error-default; +} + +.channelLeft { + display: flex; + align-items: center; + gap: $space-4; + min-width: 0; +} + +.channelIcon { + width: 44px; + height: 44px; + border-radius: $radius-lg; + @include flex-center; + flex-shrink: 0; + + svg { + width: 22px; + height: 22px; + } +} + +.channelIconSlack { + background: rgb(224, 30, 90, 15%); + color: #e01e5a; +} + +.channelIconDiscord { + background: rgb(88, 101, 242, 15%); + color: #5865f2; +} + +.channelIconTelegram { + background: rgb(44, 165, 224, 15%); + color: #2ca5e0; +} + +.channelInfo { + @include flex-column; + gap: $space-1; + min-width: 0; +} + +.channelLabel { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + @include truncate; +} + +.channelMeta { + display: flex; + align-items: center; + gap: $space-2; + flex-wrap: wrap; +} + +.channelType { + font-size: $font-size-xs; + color: $text-muted; + font-family: $font-mono; +} + +.badge { + font-size: $font-size-xs; + padding: 2px $space-2; + border-radius: $radius-full; + font-weight: $font-weight-medium; +} + +.badgeLinked { + background: rgb(74, 222, 128, 12%); + color: hsl(140, 60%, 55%); +} + +.badgePending { + background: rgb(245, 158, 11, 12%); + color: hsl(40, 80%, 60%); +} + +.badgeInvalid { + background: rgb(220, 38, 38, 12%); + color: hsl(0, 65%, 60%); +} + +.channelActions { + display: flex; + align-items: center; + gap: $space-2; + flex-shrink: 0; + + @include breakpoint-down('sm') { + width: 100%; + justify-content: flex-end; + } +} + +.actionBtn { + height: 34px; + padding: 0 $space-4; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + border-radius: $radius-md; + border: 1px solid $border-default; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + border-color: $border-strong; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.actionBtnDanger { + @include hover { + border-color: $error-default; + color: $error-light; + background: rgb(220, 38, 38, 8%); + } +} + +.empty { + padding: $space-10; + text-align: center; + border: 1px dashed $border-default; + border-radius: $radius-lg; +} + +.emptyText { + font-size: $font-size-sm; + color: $text-muted; +} + +.addBtn { + height: 36px; + padding: 0 $space-5; + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + border-radius: $radius-md; + border: 1px solid $border-strong; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + svg { + width: 15px; + height: 15px; + flex-shrink: 0; + } + + @include hover { + background: $bg-surface-100; + color: $text-default; + border-color: $border-stronger; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.addBtnSlack { + border-color: rgb(224, 30, 90, 35%); + color: #e01e5a; + + @include hover { + background: rgb(224, 30, 90, 8%); + border-color: rgb(224, 30, 90, 70%); + color: #e01e5a; + } +} + +.addBtnDiscord { + border-color: rgb(88, 101, 242, 35%); + color: #5865f2; + + @include hover { + background: rgb(88, 101, 242, 8%); + border-color: rgb(88, 101, 242, 70%); + color: #5865f2; + } +} + +.form { + @include flex-column; + gap: $space-5; + padding: $space-6; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + margin-top: $space-4; +} + +.formRow { + @include flex-column; + gap: $space-2; +} + +.formRowHalf { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-5; + + @include breakpoint-down('sm') { + grid-template-columns: 1fr; + } +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; +} + +.input { + width: 100%; + height: 42px; + padding: 0 $space-3; + background: $bg-control; + border: 1px solid $border-control; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-stronger; + } +} + +.fieldError { + font-size: $font-size-xs; + color: $error-default; +} + +.formActions { + display: flex; + gap: $space-3; + justify-content: flex-end; + padding-top: $space-2; + + @include breakpoint-down('sm') { + flex-direction: column-reverse; + } +} + +.cancelBtn { + height: 38px; + padding: 0 $space-5; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + border-radius: $radius-md; + border: 1px solid $border-default; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + color: $text-default; + border-color: $border-strong; + } +} + +.submitBtn { + height: 38px; + padding: 0 $space-6; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + border-radius: $radius-md; + border: none; + background: $white; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.88); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.telegramCard { + padding: $space-6; + background: $bg-surface-100; + border: 1px solid $border-default; + border-top: 1.5px solid #2ca5e0; + border-radius: $radius-lg; +} + +.telegramCardHeader { + display: flex; + align-items: flex-start; + gap: $space-4; + margin-bottom: $space-5; +} + +.telegramIconWrap { + width: 52px; + height: 52px; + border-radius: $radius-lg; + background: rgb(44, 165, 224, 15%); + color: #2ca5e0; + @include flex-center; + flex-shrink: 0; + + svg { + width: 26px; + height: 26px; + } +} + +.telegramCardBody { + flex: 1; + min-width: 0; +} + +.telegramTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-1; + display: flex; + align-items: center; + gap: $space-2; +} + +.telegramDesc { + font-size: $font-size-sm; + color: $text-lighter; + line-height: $line-height-relaxed; + margin-bottom: $space-4; +} + +.telegramActions { + display: flex; + gap: $space-2; + flex-wrap: wrap; +} + +.linkInstructions { + @include flex-column; + gap: $space-4; + padding: $space-5; + background: $bg-surface-200; + border-radius: $radius-md; + border: 1px solid $border-muted; + margin-top: $space-4; +} + +.linkInstructionsTitle { + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + color: hsl(40, 80%, 60%); + text-transform: uppercase; + letter-spacing: $tracking-wider; +} + +.linkStep { + font-size: $font-size-sm; + color: $text-light; + line-height: $line-height-relaxed; + + code { + font-family: $font-mono; + font-size: $font-size-xs; + background: $bg-alternative; + padding: 1px $space-1-5; + border-radius: $radius-xs; + color: $text-default; + } +} + +.webhookURLRow { + display: flex; + align-items: center; + gap: $space-2; + background: $bg-alternative; + border: 1px solid $border-muted; + border-radius: $radius-md; + padding: $space-2 $space-3; +} + +.webhookURL { + font-size: $font-size-xs; + font-family: $font-mono; + color: $text-lighter; + flex: 1; + @include truncate; +} + +.copyBtn { + height: 26px; + padding: 0 $space-3; + font-size: $font-size-xs; + border-radius: $radius-sm; + border: 1px solid $border-default; + background: transparent; + color: $text-lighter; + cursor: pointer; + flex-shrink: 0; + @include transition-fast; + + @include hover { + color: $text-default; + border-color: $border-strong; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts new file mode 100644 index 00000000..eb302b7d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts @@ -0,0 +1,22 @@ +// ©AngelaMos | 2026 +// audio.ts + +import { create } from 'zustand' + +interface AudioStore { + ctx: AudioContext | null + buffer: AudioBuffer | null + unlocked: boolean + setCtx: (ctx: AudioContext | null) => void + setBuffer: (b: AudioBuffer | null) => void + setUnlocked: (u: boolean) => void +} + +export const useAudioStore = create((set) => ({ + ctx: null, + buffer: null, + unlocked: false, + setCtx: (ctx) => set({ ctx }), + setBuffer: (buffer) => set({ buffer }), + setUnlocked: (unlocked) => set({ unlocked }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts new file mode 100644 index 00000000..45942777 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/bgpHijack.ts @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// bgpHijack.ts + +import { create } from 'zustand' +import type { BgpHijack } from '@/api/types' + +interface BgpHijackStore { + items: BgpHijack[] + push: (item: BgpHijack) => void + clear: () => void +} + +const BGP_HIJACK_CAP = 100 + +export const useBgpHijackStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const filtered = s.items.filter((i) => i.id !== item.id) + return { items: [item, ...filtered].slice(0, BGP_HIJACK_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts new file mode 100644 index 00000000..28a3341c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/cve.ts @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// cve.ts + +import { create } from 'zustand' +import type { CveEvent } from '@/api/types' + +interface CveStore { + items: CveEvent[] + push: (item: CveEvent) => void + clear: () => void +} + +const CVE_CAP = 500 + +export const useCveStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const filtered = s.items.filter((i) => i.CveID !== item.CveID) + return { items: [item, ...filtered].slice(0, CVE_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/earthquake.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/earthquake.ts new file mode 100644 index 00000000..1eb08e43 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/earthquake.ts @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// earthquake.ts + +import { create } from 'zustand' +import type { EarthquakePayload } from '@/api/types' + +interface EarthquakeStore { + items: EarthquakePayload[] + push: (item: EarthquakePayload) => void + clear: () => void +} + +const EARTHQUAKE_CAP = 100 + +export const useEarthquakeStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const filtered = s.items.filter((i) => i.id !== item.id) + return { items: [item, ...filtered].slice(0, EARTHQUAKE_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/freshness.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/freshness.ts new file mode 100644 index 00000000..337ce95b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/freshness.ts @@ -0,0 +1,29 @@ +// ©AngelaMos | 2026 +// freshness.ts + +import { create } from 'zustand' + +export type SourceKey = + | 'bgp' + | 'outage' + | 'kev' + | 'ransomware' + | 'dshield' + | 'cve' + | 'spacewx' + | 'iss' + | 'quake' + | 'btc' + | 'eth' + | 'wiki' + | 'ws' + +interface FreshnessStore { + ts: Partial> + ping: (k: SourceKey) => void +} + +export const useFreshness = create((set) => ({ + ts: {}, + ping: (k) => set((s) => ({ ts: { ...s.ts, [k]: Date.now() } })), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts new file mode 100644 index 00000000..9e6f4785 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts @@ -0,0 +1,64 @@ +// ©AngelaMos | 2026 +// globeEvents.ts + +import { create } from 'zustand' + +export type GlobePointType = + | 'earthquake' + | 'ransomware' + | 'scan' + | 'iss' + | 'outage' + | 'hijack' + +export interface GlobePoint { + id: string + type: GlobePointType + lat: number + lng: number + emittedAt: number + meta?: Record +} + +export interface GlobeRing { + id: string + lat: number + lng: number + emittedAt: number + ttlMs: number +} + +interface GlobeStore { + points: GlobePoint[] + rings: GlobeRing[] + focusEvent: GlobePoint | null + pushPoint: (p: GlobePoint) => void + pushRing: (r: GlobeRing) => void + evict: (now: number) => void + focus: (p: GlobePoint | null) => void +} + +const POINT_TTL_MS = 15 * 60 * 1000 +const POINT_CAP = 500 +const RING_CAP = 200 + +export const useGlobeEvents = create((set) => ({ + points: [], + rings: [], + focusEvent: null, + pushPoint: (p) => + set((s) => { + const filtered = s.points.filter((existing) => existing.id !== p.id) + return { points: [p, ...filtered].slice(0, POINT_CAP) } + }), + pushRing: (r) => + set((s) => ({ + rings: [...s.rings, r].slice(-RING_CAP), + })), + evict: (now) => + set((s) => ({ + points: s.points.filter((p) => now - p.emittedAt < POINT_TTL_MS), + rings: s.rings.filter((r) => now - r.emittedAt < r.ttlMs), + })), + focus: (p) => set({ focusEvent: p }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/heartbeat.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/heartbeat.ts new file mode 100644 index 00000000..8879f674 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/heartbeat.ts @@ -0,0 +1,14 @@ +// ©AngelaMos | 2026 +// heartbeat.ts + +import { create } from 'zustand' + +interface HeartbeatStore { + lastTs: number | null + beat: () => void +} + +export const useHeartbeat = create((set) => ({ + lastTs: null, + beat: () => set({ lastTs: Date.now() }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/kev.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/kev.ts new file mode 100644 index 00000000..57416858 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/kev.ts @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// kev.ts + +import { create } from 'zustand' +import type { KevEntry } from '@/api/types' + +interface KevStore { + items: KevEntry[] + push: (item: KevEntry) => void + clear: () => void +} + +const KEV_CAP = 200 + +export const useKevStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const filtered = s.items.filter((i) => i.cveID !== item.cveID) + return { items: [item, ...filtered].slice(0, KEV_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/outage.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/outage.ts new file mode 100644 index 00000000..4a24c32a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/outage.ts @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// outage.ts + +import { create } from 'zustand' +import type { InternetOutage } from '@/api/types' + +interface OutageStore { + items: InternetOutage[] + push: (item: InternetOutage) => void + clear: () => void +} + +const OUTAGE_CAP = 50 + +export const useOutageStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const filtered = s.items.filter((i) => i.id !== item.id) + return { items: [item, ...filtered].slice(0, OUTAGE_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts new file mode 100644 index 00000000..bfd95848 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts @@ -0,0 +1,51 @@ +// ©AngelaMos | 2026 +// prices.ts + +import { create } from 'zustand' + +export interface PriceTick { + symbol: string + price: string + ts: number + volume24h?: string +} + +export interface MinuteBar { + symbol: string + minute: number + open: string + high: string + low: string + close: string + volume?: string +} + +interface PricesStore { + latest: Record + history: Record + pushTick: (t: PriceTick) => void + pushMinute: (b: MinuteBar) => void +} + +const HISTORY_CAP = 60 + +export const usePrices = create((set) => ({ + latest: {}, + history: {}, + pushTick: (t) => + set((s) => ({ + latest: { ...s.latest, [t.symbol]: t }, + })), + pushMinute: (b) => + set((s) => { + const cur = s.history[b.symbol] ?? [] + const idx = cur.findIndex((c) => c.minute === b.minute) + let next: MinuteBar[] + if (idx >= 0) { + next = [...cur.slice(0, idx), b, ...cur.slice(idx + 1)] + } else { + next = [...cur, b].slice(-HISTORY_CAP) + } + return { history: { ...s.history, [b.symbol]: next } } + }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.ts new file mode 100644 index 00000000..d44e93bf --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ransomware.ts @@ -0,0 +1,28 @@ +// ©AngelaMos | 2026 +// ransomware.ts + +import { create } from 'zustand' +import type { RansomwareVictim } from '@/api/types' + +interface RansomwareStore { + items: RansomwareVictim[] + push: (item: RansomwareVictim) => void + clear: () => void +} + +const RANSOMWARE_CAP = 200 + +export function victimKey(v: RansomwareVictim): string { + return `${v.post_title}|${v.group_name}|${v.discovered}` +} + +export const useRansomwareStore = create((set) => ({ + items: [], + push: (item) => + set((s) => { + const key = victimKey(item) + const filtered = s.items.filter((i) => victimKey(i) !== key) + return { items: [item, ...filtered].slice(0, RANSOMWARE_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts new file mode 100644 index 00000000..9f70dea3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts @@ -0,0 +1,33 @@ +// ©AngelaMos | 2026 +// ticker.test.ts + +import { beforeEach, describe, expect, it } from 'vitest' +import { useTicker } from './ticker' + +describe('useTicker', () => { + beforeEach(() => { + useTicker.getState().clear() + }) + + it('caps at 50 entries oldest-out', () => { + for (let i = 0; i < 100; i++) { + useTicker.getState().push({ + id: `e${i}`, + source: 'wiki', + headline: `headline ${i}`, + ts: Date.now() + i, + }) + } + expect(useTicker.getState().items).toHaveLength(50) + // oldest survivors are e50..e99 + expect(useTicker.getState().items[0].id).toBe('e50') + expect(useTicker.getState().items.at(-1)?.id).toBe('e99') + }) + + it('deduplicates by id', () => { + const item = { id: 'dup', source: 'gdelt', headline: 'h', ts: 0 } + useTicker.getState().push(item) + useTicker.getState().push(item) + expect(useTicker.getState().items).toHaveLength(1) + }) +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts new file mode 100644 index 00000000..5506c709 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts @@ -0,0 +1,31 @@ +// ©AngelaMos | 2026 +// ticker.ts + +import { create } from 'zustand' + +export interface TickerItem { + id: string + source: string + headline: string + ts: number + href?: string +} + +interface TickerStore { + items: TickerItem[] + push: (item: TickerItem) => void + clear: () => void +} + +const TICKER_CAP = 50 + +export const useTicker = create((set) => ({ + items: [], + push: (item) => + set((s) => { + if (s.items.some((i) => i.id === item.id)) return s + const next = [...s.items, item] + return { items: next.slice(-TICKER_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts new file mode 100644 index 00000000..4c72084c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts @@ -0,0 +1,38 @@ +// ©AngelaMos | 2026 +// ui.ts + +import { create } from 'zustand' + +export interface AlertState { + message: string + severity: 'info' | 'warn' +} + +interface UIStore { + presentationMode: boolean + setPresentationMode: (on: boolean) => void + togglePresentationMode: () => void + + currentAlert: AlertState | null + showAlert: (a: AlertState) => void + dismissAlert: () => void + + aboutOpen: boolean + openAbout: () => void + closeAbout: () => void +} + +export const useUIStore = create((set) => ({ + presentationMode: false, + setPresentationMode: (on) => set({ presentationMode: on }), + togglePresentationMode: () => + set((s) => ({ presentationMode: !s.presentationMode })), + + currentAlert: null, + showAlert: (a) => set({ currentAlert: a }), + dismissAlert: () => set({ currentAlert: null }), + + aboutOpen: false, + openAbout: () => set({ aboutOpen: true }), + closeAbout: () => set({ aboutOpen: false }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss new file mode 100644 index 00000000..a27bac72 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss @@ -0,0 +1,60 @@ +// =================== +// © AngelaMos | 2025 +// styles.scss +// =================== + +@forward 'styles/tokens'; +@forward 'styles/fonts'; +@forward 'styles/mixins'; + +@use 'styles/reset'; +@use 'styles/tokens' as *; +@use 'styles/fonts' as *; + +// Ethos-aligned base for the dashboard surface. Auth pages override +// with their own per-module styles (template residue — left alone). +html, body { + background: var(--bg); + color: var(--fg-1); + font-family: var(--font-sans); + font-size: var(--type-body); + line-height: var(--line-height-tight); + font-feature-settings: "tnum" 1, "cv11" 1, "ss03" 1; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background: var(--fg-4); +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +#root { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--bg); +} + +.app { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--fg-1); + font-family: var(--font-sans); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss new file mode 100644 index 00000000..6f4daa9a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// _ethos.scss + +// HARD DESIGN RULES — referenced from docs/design/ethos-reference.md. +// Mirrored here so anyone editing styles trips over them. If you find +// yourself wanting to break one of these, the answer is no. Fix the +// data, not the design. + +// 1. Tables beat charts. Charts only when shape IS the insight. +// 2. One alarm color: --amber. Never for decoration. +// 3. Monochrome base. 3-4 gray tiers. No branded color per metric. +// 4. No rounded corners, no shadows, no gradients. Straight edges. Flat. +// 5. Motion = meaning. Only motion allowed is "data changed." +// 6. Density > breathing room. Line-height 1.25. Row padding 4px 8px. +// 7. Raw source one click away. +// 8. No onboarding, no empty-state illustrations, no tooltips for +// common terms. +// 9. Zoomed-out feel. AI defaults to 120%-zoom cramped. Push back. + +// Reference: k9s / lazygit / htop / btop / bottom / tig / NOC-SOC. +// NOT: Bloomberg amber-on-black, kali matrix rain, Vercel rounded-cards. diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss new file mode 100644 index 00000000..2a0232a8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss @@ -0,0 +1,12 @@ +// =================== +// © AngelaMos | 2025 +// _fonts.scss +// =================== + +@use 'tokens' as *; + +$font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +$font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, + Consolas, 'Liberation Mono', monospace; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss new file mode 100644 index 00000000..ac5a8266 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// _index.scss +// =================== + +@forward 'tokens'; +@forward 'fonts'; +@forward 'mixins'; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss new file mode 100644 index 00000000..6b5b1ba9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss @@ -0,0 +1,120 @@ +// =================== +// © AngelaMos | 2025 +// _mixins.scss +// =================== + +@use 'sass:map'; +@use 'sass:list'; +@use 'tokens' as *; + +$breakpoints: ( + 'xs': $breakpoint-xs, + 'sm': $breakpoint-sm, + 'md': $breakpoint-md, + 'lg': $breakpoint-lg, + 'xl': $breakpoint-xl, + '2xl': $breakpoint-2xl, +); + +@mixin breakpoint-up($size) { + @media (min-width: map.get($breakpoints, $size)) { + @content; + } +} + +@mixin breakpoint-down($size) { + @media (width < map.get($breakpoints, $size)) { + @content; + } +} + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +@mixin flex-column-center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +@mixin sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +@mixin truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin line-clamp($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: #{$lines}; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@mixin transition-fast { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-fast; + transition-timing-function: $ease-out; +} + +@mixin transition-normal { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-normal; + transition-timing-function: $ease-out; +} + +@mixin absolute-fill { + position: absolute; + inset: 0; +} + +@mixin absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +@mixin hover { + @media (hover: hover) and (pointer: fine) { + &:hover { + @content; + } + } +} + +@mixin hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss new file mode 100644 index 00000000..2ee253d8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss @@ -0,0 +1,198 @@ +// =================== +// © AngelaMos | 2025 +// _reset.scss +// =================== + +@use 'tokens' as *; +@use 'fonts' as *; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html { + font-size: 16px; + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; + overflow-x: hidden; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + scroll-behavior: smooth; + } +} + +body { + min-height: 100vh; + min-height: 100dvh; + line-height: $line-height-normal; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + background-color: $bg-default; + color: $text-default; + font-family: $font-sans; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: $line-height-tight; + text-wrap: balance; + overflow-wrap: break-word; + font-weight: $font-weight-semibold; +} + +p { + text-wrap: pretty; + overflow-wrap: break-word; +} + +ul, +ol { + list-style: none; +} + +a { + color: inherit; + text-decoration: none; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; + height: auto; +} + +input, +button, +textarea, +select { + font: inherit; + color: inherit; +} + +input[type='text'], +input[type='email'], +input[type='password'], +input[type='search'], +input[type='number'], +input[type='tel'], +input[type='url'], +textarea, +select { + font-size: 1.15rem; + appearance: none; +} + +button { + background: none; + border: none; + cursor: pointer; + text-align: inherit; + font-family: inherit; +} + +fieldset { + border: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +textarea:not([rows]) { + min-height: 10em; +} + +:target { + scroll-margin-block: 5ex; +} + +:focus-visible { + outline: 2px solid $border-strong; + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +[hidden] { + display: none !important; +} + +[disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +dialog { + padding: 0; + border: none; + background: transparent; +} + +summary { + cursor: pointer; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@supports (padding: max(0px)) { + body { + padding-left: max(0px, env(safe-area-inset-left)); + padding-right: max(0px, env(safe-area-inset-right)); + } +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: $border-default; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: $border-strong; +} + +::selection { + background-color: $bg-selection; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss new file mode 100644 index 00000000..14aa3845 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss @@ -0,0 +1,236 @@ +// =================== +// © AngelaMos | 2025 +// _tokens.scss +// =================== + +// ============================================================================ +// SPACING (8px base system) +// ============================================================================ +$space-0: 0; +$space-px: 1px; +$space-0-5: 0.125rem; +$space-1: 0.25rem; +$space-1-5: 0.375rem; +$space-2: 0.5rem; +$space-2-5: 0.625rem; +$space-3: 0.75rem; +$space-3-5: 0.875rem; +$space-4: 1rem; +$space-5: 1.25rem; +$space-6: 1.5rem; +$space-7: 1.75rem; +$space-8: 2rem; +$space-9: 2.25rem; +$space-10: 2.5rem; +$space-11: 2.75rem; +$space-12: 3rem; +$space-14: 3.5rem; +$space-16: 4rem; +$space-20: 5rem; +$space-24: 6rem; +$space-28: 7rem; +$space-32: 8rem; + +// ============================================================================ +// TYPOGRAPHY SCALE +// ============================================================================ +$font-size-3xs: 0.625rem; +$font-size-2xs: 0.6875rem; +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-base: 1rem; +$font-size-lg: 1.125rem; +$font-size-xl: 1.25rem; +$font-size-2xl: 1.5rem; +$font-size-3xl: 1.875rem; +$font-size-4xl: 2.25rem; +$font-size-5xl: 3rem; + +// ============================================================================ +// FONT WEIGHTS +// ============================================================================ +$font-weight-regular: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; + +// ============================================================================ +// LINE HEIGHTS +// ============================================================================ +$line-height-none: 1; +$line-height-tight: 1.2; +$line-height-snug: 1.375; +$line-height-normal: 1.5; +$line-height-relaxed: 1.625; + +// ============================================================================ +// LETTER SPACING +// ============================================================================ +$tracking-tighter: -0.05em; +$tracking-tight: -0.025em; +$tracking-normal: 0; +$tracking-wide: 0.025em; +$tracking-wider: 0.05em; + +// ============================================================================ +// COLORS +// ============================================================================ +$white: hsl(0, 0%, 100%); +$black: hsl(0, 0%, 0%); + +// Auth +$bg-page: hsl(0, 0%, 10.5%); +$bg-card: hsl(0, 0%, 6.2%); + +// Home/landing +$bg-landing: hsl(0, 0%, 10.8%); + +$bg-default: hsl(0, 0%, 7.1%); +$bg-alternative: hsl(0, 0%, 5.9%); +$bg-surface-75: hsl(0, 0%, 9%); +$bg-surface-100: hsl(0, 0%, 12.2%); +$bg-surface-200: hsl(0, 0%, 14.1%); +$bg-surface-300: hsl(0, 0%, 16.1%); +$bg-control: hsl(0, 0%, 10%); +$bg-selection: hsl(0, 0%, 19.2%); +$bg-overlay: hsl(0, 0%, 14.1%); +$bg-overlay-hover: hsl(0, 0%, 18%); + +$border-muted: hsl(0, 0%, 11.1%); +$border-default: hsl(0, 0%, 18%); +$border-strong: hsl(0, 0%, 22.4%); +$border-stronger: hsl(0, 0%, 27.1%); +$border-control: hsl(0, 0%, 22.4%); + +$text-default: hsl(0, 0%, 98%); +$text-light: hsl(0, 0%, 70.6%); +$text-lighter: hsl(0, 0%, 53.7%); +$text-muted: hsl(0, 0%, 30.2%); + +$error-default: hsl(0, 72%, 51%); +$error-light: hsl(0, 72%, 65%); + +// ============================================================================ +// BORDER RADIUS +// ============================================================================ +$radius-none: 0; +$radius-xs: 2px; +$radius-sm: 4px; +$radius-md: 6px; +$radius-lg: 8px; +$radius-xl: 12px; +$radius-full: 9999px; + +// ============================================================================ +// Z-INDEX SCALE +// ============================================================================ +$z-hide: -1; +$z-base: 0; +$z-dropdown: 100; +$z-sticky: 200; +$z-fixed: 300; +$z-overlay: 400; +$z-modal: 500; +$z-popover: 600; +$z-tooltip: 700; +$z-toast: 800; +$z-max: 9999; + +// ============================================================================ +// TRANSITIONS +// ============================================================================ +$duration-instant: 0ms; +$duration-fast: 100ms; +$duration-normal: 150ms; +$duration-slow: 200ms; + +$ease-out: cubic-bezier(0, 0, 0.2, 1); +$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + +// ============================================================================ +// BREAKPOINTS +// ============================================================================ +$breakpoint-xs: 360px; +$breakpoint-sm: 480px; +$breakpoint-md: 768px; +$breakpoint-lg: 1024px; +$breakpoint-xl: 1280px; +$breakpoint-2xl: 1536px; + +// ============================================================================ +// CONTAINER WIDTHS +// ============================================================================ +$container-xs: 20rem; +$container-sm: 24rem; +$container-md: 28rem; +$container-lg: 32rem; +$container-xl: 36rem; +$container-2xl: 42rem; +$container-full: 100%; + +// ============================================================================ +// ETHOS TOKENS — operator-grade dashboard surface. +// Per docs/design/ethos-reference.md (also mirrored in _ethos.scss). +// Used by /pages/dashboard/, /pages/globe/, /pages/panels/. NOT used by +// auth pages — those keep the template's SCSS variables above. +// ============================================================================ + +@import 'ethos'; + +:root { + // Background tiers + --bg: #0a0a0a; // very dark neutral, NOT pure black + --bg-panel: #0f0f0f; // panel surface, one tier lighter + --bg-row-hover: #141414; // table-row hover, subtle (no interactive theatre) + + // Text tiers (3 + 1 muted/border) + --fg-1: #e8e4dc; // primary (warm near-white) + --fg-2: #b3aea4; // secondary (warm gray, labels/axes/headers) + --fg-3: #7a7368; // muted (warm gray, timestamps/footnotes) + --fg-4: #2e2e2e; // borders / lines (panel chrome) + + // Rule weights — used for anchoring panel headers above panel borders. + // --rule-strong is intentionally brighter than --fg-4 so the hairline under + // a panel title reads as the start of a new section. + --rule-strong: #4a4a4a; + + // Accents — ONE green, ONE amber. That is all. + --ok: #4ade80; // live / healthy / busy + --amber: #f59e0b; // stale / breached / anomaly. NEVER for decoration. + --amber-fg: #1a1a1a; // near-black text-on-amber pairing (alert banner only) + + // Type — Inter for everything; Geist Mono for IDs / IPs / ports / coords only + --font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "Geist Mono", ui-monospace, "SF Mono", "Cascadia Code", monospace; + + // Density — asymmetric padding (more horizontal, less vertical) + --row-py: 3px; + --row-px: 12px; + --line-height-tight: 1.35; + --letter-spacing-label: 0.09em; + + // Sizes — intentionally smaller than SaaS defaults for operator density + --type-body: 10px; + --type-label: 10px; // panel titles (UPPERCASE) + --type-col: 9.5px; // column headers (UPPERCASE, dim) + --type-meta: 10px; // source attribution / footnotes + --type-num-l: 18px; // KPI numbers + --type-num-xl: 22px; // hero KPI numbers (rare; only top-of-panel) + + // Categorical accent colors — used ONLY on panel-header left edges + // Carries taxonomy (which event type), never decoration of data. + --accent-bgp: #ef4444; // red — hijacks + --accent-outage: #fb923c; // orange — outages + --accent-kev: #d946ef; // magenta — exploited CVEs + --accent-ransomware: #facc15; // yellow — victims + --accent-dshield: #38bdf8; // blue — mass scan + --accent-quake: #a78bfa; // purple — earthquakes + --accent-iss: #4ade80; // green — ISS / live + --accent-spacewx: #4ade80; // green — space weather + --accent-cve: #d946ef; // magenta — CVE velocity + --accent-wiki: #2dd4bf; // teal — news + --accent-btc: #f7931a; // bitcoin orange + --accent-eth: #8b9eff; // ethereum periwinkle + + // Layout — fixed panel column width (spec §11.1) + --col-panel-width: 320px; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js new file mode 100644 index 00000000..56ebd7bc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js @@ -0,0 +1,107 @@ +// ©AngelaMos | 2025 +// stylelint.config.js + +/** @type {import('stylelint').Config} */ +export default { + extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'], + rules: { + 'block-no-empty': true, + 'declaration-no-important': true, + 'color-no-invalid-hex': true, + 'property-no-unknown': true, + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global'], + }, + ], + + 'selector-class-pattern': [ + '^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$', + { + message: + 'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)', + }, + ], + + 'value-keyword-case': [ + 'lower', + { + camelCaseSvgKeywords: true, + ignoreKeywords: [ + 'BlinkMacSystemFont', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + 'Roboto', + 'Arial', + 'Helvetica', + 'Times', + 'Georgia', + 'Verdana', + 'Tahoma', + 'Trebuchet', + 'Impact', + 'Comic', + ], + }, + ], + + 'property-no-vendor-prefix': [ + true, + { + ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'], + }, + ], + 'value-no-vendor-prefix': true, + 'selector-no-vendor-prefix': true, + + 'property-no-deprecated': [ + true, + { + ignoreProperties: ['clip'], + }, + ], + + 'container-name-pattern': null, + 'layer-name-pattern': null, + + 'scss/at-rule-no-unknown': true, + 'scss/declaration-nested-properties-no-divided-groups': true, + 'scss/dollar-variable-no-missing-interpolation': true, + 'scss/dollar-variable-empty-line-before': null, + + 'declaration-empty-line-before': null, + 'custom-property-empty-line-before': null, + + 'no-descending-specificity': null, + + 'media-feature-name-no-unknown': [ + true, + { + ignoreMediaFeatureNames: ['map'], + }, + ], + + 'color-function-notation': null, + 'hue-degree-notation': null, + }, + ignoreFiles: [ + 'node_modules/**', + 'dist/**', + 'build/**', + '**/*.js', + '**/*.ts', + '**/*.tsx', + ], + overrides: [ + { + files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'], + rules: { + 'declaration-no-important': null, + 'scss/comment-no-empty': null, + }, + }, + ], +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json new file mode 100644 index 00000000..afe17f60 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json new file mode 100644 index 00000000..a96b3e59 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts new file mode 100644 index 00000000..a8d5d478 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts @@ -0,0 +1,71 @@ +/** + * ©AngelaMos | 2025 + * vite.config.ts + */ + +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, path.resolve(__dirname, '..'), '') + const isDev = mode === 'development' + + return { + plugins: [react(), tsconfigPaths()], + + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + + css: { + preprocessorOptions: { + scss: {}, + }, + }, + + server: { + port: 5173, + host: '0.0.0.0', + proxy: { + '/api': { + target: env.VITE_API_TARGET || 'http://localhost:8000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ''), + }, + }, + }, + + build: { + target: 'esnext', + cssTarget: 'chrome100', + sourcemap: isDev ? true : 'hidden', + minify: 'oxc', + rollupOptions: { + output: { + manualChunks(id: string): string | undefined { + if (id.includes('node_modules')) { + if (id.includes('react-dom') || id.includes('react-router')) { + return 'vendor-react' + } + if (id.includes('@tanstack/react-query')) { + return 'vendor-query' + } + if (id.includes('zustand')) { + return 'vendor-state' + } + } + return undefined + }, + }, + }, + }, + + preview: { + port: 4173, + }, + } +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/learn/.gitkeep b/PROJECTS/advanced/monitor-the-situation-dashboard/learn/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index 61f49925..be220a14 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@

View Complete Projects:

@@ -126,7 +126,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[Network Covert Channel](./SYNOPSES/advanced/Network.Covert.Channel.md)**
Data exfiltration techniques | ![3-4w](https://img.shields.io/badge/⏱️_3--4w-blue) ![Rust](https://img.shields.io/badge/Rust-000000?logo=rust&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Covert channels • Data exfiltration • Steganography
[Learn More](./SYNOPSES/advanced/Network.Covert.Channel.md) | | **[Automated Penetration Testing](./SYNOPSES/advanced/Automated.Penetration.Testing.md)**
Full pentest automation | ![3-4w](https://img.shields.io/badge/⏱️_3--4w-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Pentest automation • Recon to exploitation • Report generation
[Learn More](./SYNOPSES/advanced/Automated.Penetration.Testing.md) | | **[Haskell Reverse Proxy](./PROJECTS/advanced/haskell-reverse-proxy)**
Functional reverse proxy with security middleware | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Haskell](https://img.shields.io/badge/Haskell-5D4F85?logo=haskell&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Functional programming • Reverse proxy design • Security middleware • Haskell
[Source Code](./PROJECTS/advanced/haskell-reverse-proxy) | -| **["Monitor the Situation" Dashboard](./SYNOPSES/advanced/Monitor.The.Situation.Dashboard.md)**
Real-time cyber threat situational awareness | ![3-4w](https://img.shields.io/badge/⏱️_3--4w-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Threat intelligence • CVE tracking • MITRE ATT&CK • OSINT • Real-time dashboards
[Learn More](./SYNOPSES/advanced/Monitor.The.Situation.Dashboard.md) | +| **["Monitor the Situation" Dashboard](./SYNOPSES/advanced/Monitor.The.Situation.Dashboard.md)**
Real-time cyber threat situational awareness | ![3-4w](https://img.shields.io/badge/⏱️_3--4w-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Threat intelligence • CVE tracking • MITRE ATT&CK • OSINT • Real-time dashboards
[Source Code](./PROJECTS/advanced/monitor-the-situation-dashboard) | | **[Honeypot Network](./PROJECTS/advanced/honeypot-network)**
Multi-service honeypot deployment & analysis | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Honeypot deployment • Attacker behavior analysis • IOC extraction • MITRE mapping
[Source Code](./PROJECTS/advanced/honeypot-network) \| [Docs](./PROJECTS/advanced/honeypot-network/learn) | | **[Supply Chain Security Analyzer](./SYNOPSES/advanced/Supply.Chain.Security.Analyzer.md)**
Dependency vulnerability analysis | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Supply chain security • Dependency analysis • Malicious packages
[Learn More](./SYNOPSES/advanced/Supply.Chain.Security.Analyzer.md) |