Spaces:
Sleeping
Sleeping
Dhruva P Gowda commited on
Commit ·
f59a149
1
Parent(s): 2753128
Deploy ShockMap MVP: High-fidelity UI overhaul, animated graph particles, live GDELT ingestion, and production scaffolding
Browse files- MVP_STATUS.md +76 -0
- README.md +63 -426
- backend/app/api/map.py +359 -0
- backend/app/config.py +1 -0
- backend/app/data_loader.py +13 -1
- backend/app/main.py +18 -2
- backend/app/models/graph.py +21 -0
- backend/app/services/geocoder.py +94 -0
- backend/app/services/graph_service.py +35 -0
- backend/data/seed/apis.json +75 -25
- backend/data/seed/china_provinces.json +122 -0
- backend/data/seed/india_states.json +152 -0
- backend/data/seed/rare_earths.json +29 -13
- backend/tests/test_integration.py +3 -2
- data/seed/china_provinces.json +122 -0
- data/seed/india_states.json +152 -0
- frontend/index.html +2 -1
- frontend/package-lock.json +325 -16
- frontend/package.json +11 -1
- frontend/src/App.jsx +2 -0
- frontend/src/api/client.js +20 -0
- frontend/src/components/AppShell.jsx +1 -0
- frontend/src/components/MapFilterPanel.jsx +221 -0
- frontend/src/lib/mockData.js +133 -0
- frontend/src/main.jsx +1 -0
- frontend/src/pages/Drugs.jsx +69 -64
- frontend/src/pages/Graph.jsx +70 -17
- frontend/src/pages/Map.jsx +524 -0
- frontend/src/styles/tokens.css +158 -0
- frontend/vite.config.js +2 -2
- ingestion/Dockerfile +7 -0
- scripts/check_build.sh +42 -0
- shockmap_prompt_guide.md +543 -0
MVP_STATUS.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PharmaShield (ShockMap) - MVP Status Report
|
| 2 |
+
|
| 3 |
+
This document outlines the end-to-end capabilities, architecture, and current status of the PharmaShield (ShockMap) MVP, built for the Google Solution Challenge 2026.
|
| 4 |
+
|
| 5 |
+
This serves as a guide for demonstrating maximum value and proving the system solves a real-world problem for hackathon judges.
|
| 6 |
+
|
| 7 |
+
## 🎯 The Core Problem & Mission
|
| 8 |
+
|
| 9 |
+
India is heavily dependent on external supply chains for critical health-system inputs (Pharmaceutical APIs) and advanced industry components (Rare Earth Minerals).
|
| 10 |
+
|
| 11 |
+
The problem isn't a lack of news; it's a **lack of early, structured, decision-ready intelligence.** Operators typically find out about upstream disruptions too late, when prices are already spiking or stockouts are imminent.
|
| 12 |
+
|
| 13 |
+
**Mission:** *When a disruption hits an upstream source region (e.g., a factory shutdown in Hebei), show operators exactly what is at risk downstream, how the shock propagates, and what immediate actions they can take within the next 72 hours.*
|
| 14 |
+
|
| 15 |
+
## 🏗️ The 3-Engine Architecture (Currently Implemented)
|
| 16 |
+
|
| 17 |
+
The MVP successfully implements a full "Detect -> Assess -> Decide -> Act" loop using three distinct engines:
|
| 18 |
+
|
| 19 |
+
### 1. Engine 1: Signal Intelligence (Detect)
|
| 20 |
+
* **What it does:** Ingests live and simulated disruption signals and structures them.
|
| 21 |
+
* **Current Status:**
|
| 22 |
+
* A Python scheduler (`ingestion/shock_detector.py`) polls the GDELT API for global news events using specific keywords (`factory shutdown`, `export ban`, `port closure`, `contamination`) combined with regions (e.g., China, India) and sectors.
|
| 23 |
+
* It structures unstructured news into "Shock Events" (saving to `data/shocks.json`).
|
| 24 |
+
* **Demo Mode:** To ensure a flawless hackathon demo, the system includes a highly curated `demo_scenarios.json`. This provides realistic, high-fidelity incidents (e.g., "Hebei analgesic stress", "Inner Mongolia export ban") without relying on the unpredictability of live news during judging.
|
| 25 |
+
|
| 26 |
+
### 2. Engine 2: Shock Propagation (Assess)
|
| 27 |
+
* **What it does:** Maps how a localized shock ripples through the supply chain.
|
| 28 |
+
* **Current Status:**
|
| 29 |
+
* Utilizes a **Knowledge Graph** (NetworkX via FastAPI backend).
|
| 30 |
+
* **Algorithm:** Uses **Personalized PageRank** to calculate how risk flows from an origin node (e.g., a Chinese province) down to specific inputs, APIs, and finally essential medicines.
|
| 31 |
+
* **Metrics Calculated:** Generates a real-time `Risk Score` (0-100) for downstream nodes based on:
|
| 32 |
+
* PageRank influence from the shocked origin.
|
| 33 |
+
* Buffer Days (inventory on hand).
|
| 34 |
+
* Substitutability (how easily an alternative can be sourced).
|
| 35 |
+
* **Clustering:** Implements **Louvain community detection** to identify co-propagating clusters (e.g., if one factory goes down, which other related chemicals are likely to spike in price).
|
| 36 |
+
|
| 37 |
+
### 3. Engine 3: Action Intelligence (Decide & Act)
|
| 38 |
+
* **What it does:** Converts calculated risk into grounded, actionable decisions.
|
| 39 |
+
* **Current Status:**
|
| 40 |
+
* Powered by **Gemini Flash**.
|
| 41 |
+
* **The "War Room":** When an operator clicks a high-risk shock, they enter a dedicated War Room. Gemini processes the shock context and generates a structured 72-hour action plan (e.g., "Advance-buy 18 MT 6-APA", "Lock para-aminophenol equivalent").
|
| 42 |
+
* **Action Simulator:** Operators can click an action to simulate its impact. The UI dynamically shows the "Delta"—how taking that action reduces the Aggregate Risk score and extends the "Days to Stockout".
|
| 43 |
+
* **Natural Language Query:** Operators can ask Gemini plain-English questions ("Which drugs depend most on Hebei?") and receive grounded answers with citations.
|
| 44 |
+
|
| 45 |
+
## 💻 Frontend UI Surfaces (Fully Built)
|
| 46 |
+
|
| 47 |
+
The React 19 frontend provides a complete, operator-ready dashboard:
|
| 48 |
+
|
| 49 |
+
1. **Dashboard:** A high-level, real-time overview showing active shocks, top risk inputs, and a Herfindahl-Hirschman Index (HHI) heatmap of dependency concentration by province.
|
| 50 |
+
2. **Interactive Supply Map:** A geospatial view (Leaflet) showing supply corridors between source provinces (e.g., China) and destination states (India). Provinces glow red when active shocks are detected.
|
| 51 |
+
3. **Propagation Graph Explorer:** A visual node-edge graph that lets operators trace the exact path from a source province -> KSM -> API -> final essential Drug.
|
| 52 |
+
4. **Alerts Feed:** The chronological feed of all detected disruptions.
|
| 53 |
+
5. **War Room (Shock Detail):** The core MVP view bringing together evidence, propagation paths, and Gemini-generated action plans.
|
| 54 |
+
6. **Drug/API Catalog:** A searchable database of monitored entities showing their baseline risk and criticality breakdown.
|
| 55 |
+
|
| 56 |
+
## 🚀 How to Demo for Maximum Impact (The "Golden Path")
|
| 57 |
+
|
| 58 |
+
To prove this solves a real problem for the judges, follow this narrative flow:
|
| 59 |
+
|
| 60 |
+
1. **Set the Stage:** Start on the **Dashboard**. Explain that India is blind to upstream Tier-2/Tier-3 supplier disruptions. Point out the HHI concentration map showing heavy reliance on specific regions like Hebei.
|
| 61 |
+
2. **The Inciting Incident:** Go to the **Alerts** or **Map**. Click on a simulated `CRITICAL` shock (e.g., *"Hebei factory shutdown disrupts para-aminophenol"*). Emphasize that normally, this is just a news headline.
|
| 62 |
+
3. **Enter the War Room:** Open the Shock Detail page.
|
| 63 |
+
* *Show, don't tell:* Point out that the system hasn't just linked an article; it has traced the graph to realize **Paracetamol** and **Ceftriaxone** are immediately at risk downstream.
|
| 64 |
+
* Show the `Days to Stockout` metric dropping.
|
| 65 |
+
4. **The "Aha!" Moment (Engine 3):** Scroll down to the **72-Hour Action Ladder**. Explain that Gemini has read the policy data and generated specific procurement actions (e.g., "Lock 40 MT from alternate source").
|
| 66 |
+
5. **Simulation:** Click "Run Impact" on one of the actions. Show the judges the **Action Delta** panel dynamically updating to show that spending $X million now extends the stockout buffer by +12 days and drops the risk score from Critical to Medium.
|
| 67 |
+
6. **The Closer:** Use the **Query (Ask ShockMap)** tab. Ask a natural language question like *"What should procurement do about the Hebei shutdown?"* to show the AI acting as an expert analyst on the graph data.
|
| 68 |
+
|
| 69 |
+
## 🚧 What is Missing / Next Steps (Phase 2+)
|
| 70 |
+
|
| 71 |
+
While the MVP is functionally complete for a hackathon, a production-grade system would need:
|
| 72 |
+
|
| 73 |
+
1. **Persistent Database:** Currently relying on local JSON files (`shocks.json`). Needs PostgreSQL/MongoDB for state persistence.
|
| 74 |
+
2. **Authentication/RBAC:** Securing the War Room so only authorized procurement officers can trigger simulations and view sensitive supply data.
|
| 75 |
+
3. **Graph Neural Networks (GNN):** The `ml/` folder contains the groundwork to upgrade Engine 2 from static PageRank to a trained GNN that learns non-linear propagation patterns from historical disruption data.
|
| 76 |
+
4. **Live Vector Database Integration:** Expanding the local knowledge base into a fully hosted Qdrant cluster for massive-scale document retrieval.
|
README.md
CHANGED
|
@@ -1,466 +1,103 @@
|
|
| 1 |
-
# ShockMap
|
|
|
|
| 2 |
|
| 3 |
-
**
|
| 4 |
-
|
| 5 |
-
ShockMap is an operator-facing intelligence platform built for the Google Solution Challenge 2026. It turns weak early signals from trade, news, and policy sources into a clear operational workflow:
|
| 6 |
-
|
| 7 |
-
**detect -> assess -> decide -> act**
|
| 8 |
-
|
| 9 |
-
The product is designed around a simple mission:
|
| 10 |
-
|
| 11 |
-
> When a disruption hits an upstream source region, show Indian operators what is at risk, how the shock propagates, and what action changes the outcome in the next 72 hours.
|
| 12 |
-
|
| 13 |
-
---
|
| 14 |
-
|
| 15 |
-
## Why ShockMap
|
| 16 |
-
|
| 17 |
-
India depends heavily on external supply chains for critical industrial and health-system inputs. In the MVP, ShockMap focuses on two high-impact sectors:
|
| 18 |
-
|
| 19 |
-
- **Pharma**: essential medicines, APIs, intermediates, and upstream source regions
|
| 20 |
-
- **Rare earths**: strategically important minerals used in EV, electronics, and advanced industry
|
| 21 |
-
|
| 22 |
-
The core problem is not a lack of information. It is a lack of **early, structured, decision-ready intelligence**.
|
| 23 |
-
|
| 24 |
-
ShockMap closes that gap by combining live signal intake, graph-based propagation, and grounded action generation in one operator workflow.
|
| 25 |
-
|
| 26 |
-
---
|
| 27 |
-
|
| 28 |
-
## What the MVP Delivers
|
| 29 |
-
|
| 30 |
-
The current MVP is built around a complete incident-response loop.
|
| 31 |
-
|
| 32 |
-
### 1. Live shock feed
|
| 33 |
-
- Ingests disruption signals into a structured shock feed
|
| 34 |
-
- Tracks event type, province/region, sector, severity, source, and affected entities
|
| 35 |
-
- Supports a **hybrid live + curated scenario mode** for reliable demos and consistent walkthroughs
|
| 36 |
-
|
| 37 |
-
### 2. Shock propagation engine
|
| 38 |
-
- Models upstream-to-downstream impact through a supply graph
|
| 39 |
-
- Uses **NetworkX**, **Louvain communities**, and **Personalized PageRank**
|
| 40 |
-
- Produces ranked affected nodes with risk, buffer pressure, substitutability, and cluster context
|
| 41 |
-
|
| 42 |
-
### 3. War room for every shock
|
| 43 |
-
- Each shock opens into a decision surface, not just an alert
|
| 44 |
-
- Surfaces:
|
| 45 |
-
- aggregate risk
|
| 46 |
-
- days to stockout
|
| 47 |
-
- exposure estimate
|
| 48 |
-
- top affected inputs
|
| 49 |
-
- source evidence
|
| 50 |
-
- recommended actions
|
| 51 |
-
|
| 52 |
-
### 4. Action delta simulator
|
| 53 |
-
- Lets the operator run response actions against a live scenario
|
| 54 |
-
- Shows before/after impact such as:
|
| 55 |
-
- risk reduction
|
| 56 |
-
- stockout extension
|
| 57 |
-
- exposure change
|
| 58 |
-
|
| 59 |
-
### 5. Grounded operator analyst
|
| 60 |
-
- Natural-language query interface backed by Gemini Flash
|
| 61 |
-
- Returns concise answers, citations, matched scenarios, and suggested entities to inspect next
|
| 62 |
-
- Uses schema-constrained outputs, short timeouts, retrieval merging, and deterministic degradation
|
| 63 |
-
|
| 64 |
-
### 6. Graph explorer
|
| 65 |
-
- Interactive graph view for provinces, APIs, inputs, and downstream drugs
|
| 66 |
-
- Highlights dependency corridors and the currently selected node's upstream/downstream links
|
| 67 |
-
- Exposes state-level aggregate risk at a glance
|
| 68 |
-
|
| 69 |
-
---
|
| 70 |
-
|
| 71 |
-
## Current MVP Coverage
|
| 72 |
-
|
| 73 |
-
| Area | MVP Coverage |
|
| 74 |
-
|---|---|
|
| 75 |
-
| Essential medicines | 20 tracked medicines |
|
| 76 |
-
| Pharma APIs | 25 API records in seed data |
|
| 77 |
-
| Rare earth minerals | 8 critical minerals |
|
| 78 |
-
| Live shock feed | `data/shocks.json` + curated scenario pack |
|
| 79 |
-
| Graph mode | PageRank + Louvain production path |
|
| 80 |
-
| Action intelligence | Grounded Flash-based plans with deterministic action output |
|
| 81 |
-
| UI surfaces | Dashboard, Alerts, Shock Detail, War Room, Graph, Query |
|
| 82 |
-
|
| 83 |
-
---
|
| 84 |
-
|
| 85 |
-
## Product Experience
|
| 86 |
-
|
| 87 |
-
### Dashboard
|
| 88 |
-
The dashboard gives operators an immediate view of active shocks, sector activity, feed mode, and system status.
|
| 89 |
-
|
| 90 |
-
### Alerts
|
| 91 |
-
The alerts surface is the real-time event feed for live shocks and scenario-backed incidents.
|
| 92 |
-
|
| 93 |
-
### Shock Detail / War Room
|
| 94 |
-
This is the core MVP experience. A single incident page brings together:
|
| 95 |
-
- shock summary
|
| 96 |
-
- severity
|
| 97 |
-
- top exposed inputs
|
| 98 |
-
- evidence pack
|
| 99 |
-
- propagation explanation
|
| 100 |
-
- recommended actions
|
| 101 |
-
- delta simulation
|
| 102 |
-
|
| 103 |
-
### Graph
|
| 104 |
-
The graph page shows how disruptions travel through the network, which nodes are under the most pressure, and which states absorb the highest downstream risk.
|
| 105 |
-
|
| 106 |
-
### Query
|
| 107 |
-
The operator can ask plain-English questions such as:
|
| 108 |
-
- "Which drugs are most exposed to Hebei shutdown?"
|
| 109 |
-
- "What should procurement do in the next 72 hours?"
|
| 110 |
-
- "Which nodes are highest risk in the current cluster?"
|
| 111 |
-
|
| 112 |
-
---
|
| 113 |
-
|
| 114 |
-
## MVP Demo Flow
|
| 115 |
-
|
| 116 |
-
This is the cleanest walkthrough for judges, mentors, or users:
|
| 117 |
-
|
| 118 |
-
1. Open the **Dashboard**
|
| 119 |
-
2. Select a high-severity shock
|
| 120 |
-
3. Enter the **War Room**
|
| 121 |
-
4. Inspect propagation and evidence
|
| 122 |
-
5. Run one response action in the simulator
|
| 123 |
-
6. Show the risk delta and stockout extension
|
| 124 |
-
7. Ask a question in **Query**
|
| 125 |
-
|
| 126 |
-
This makes the system feel operational from end to end.
|
| 127 |
-
|
| 128 |
-
---
|
| 129 |
-
|
| 130 |
-
## 3-Engine Architecture
|
| 131 |
-
|
| 132 |
-
```text
|
| 133 |
-
ENGINE 1 ENGINE 2 ENGINE 3
|
| 134 |
-
Signal Shock Action
|
| 135 |
-
Intelligence -> Propagation -> Intelligence
|
| 136 |
-
(What happened) (What it means) (What to do)
|
| 137 |
-
```
|
| 138 |
-
|
| 139 |
-
### Engine 1: Signal Intelligence
|
| 140 |
-
|
| 141 |
-
Engine 1 is responsible for turning unstructured event streams into structured shocks.
|
| 142 |
-
|
| 143 |
-
Current MVP path:
|
| 144 |
-
- event fetch and refresh pipeline
|
| 145 |
-
- disruption keyword filtering
|
| 146 |
-
- structured shock records persisted to `data/shocks.json`
|
| 147 |
-
- Gemini Flash used for efficient structured extraction where available
|
| 148 |
-
|
| 149 |
-
Typical extracted fields:
|
| 150 |
-
- `shock_type`
|
| 151 |
-
- `province`
|
| 152 |
-
- `sector`
|
| 153 |
-
- `severity`
|
| 154 |
-
- `affected_entities`
|
| 155 |
-
- `estimated_duration_days`
|
| 156 |
-
|
| 157 |
-
### Engine 2: Shock Propagation
|
| 158 |
-
|
| 159 |
-
Engine 2 is the production risk engine in the MVP.
|
| 160 |
-
|
| 161 |
-
Current production method:
|
| 162 |
-
- **Knowledge graph** for supply structure
|
| 163 |
-
- **Louvain community detection** for co-propagating clusters
|
| 164 |
-
- **Personalized PageRank** for directional shock spread
|
| 165 |
-
- **Criticality modifiers** for substitutability, buffer pressure, and concentration
|
| 166 |
-
|
| 167 |
-
Propagation formula:
|
| 168 |
-
|
| 169 |
-
```text
|
| 170 |
-
R_i = PR_i(shock) * (1 - S_i) * exp(-B_i / tau) * C_community
|
| 171 |
-
```
|
| 172 |
-
|
| 173 |
-
Where:
|
| 174 |
-
- `PR_i(shock)` = PageRank influence from the shocked origin
|
| 175 |
-
- `S_i` = substitutability
|
| 176 |
-
- `B_i` = buffer days
|
| 177 |
-
- `tau` = lead-time constant by sector
|
| 178 |
-
- `C_community` = community amplification factor
|
| 179 |
-
|
| 180 |
-
### Engine 3: Action Intelligence
|
| 181 |
-
|
| 182 |
-
Engine 3 converts risk into decisions.
|
| 183 |
-
|
| 184 |
-
Current MVP behavior:
|
| 185 |
-
- retrieves grounded context from local policy and reference data
|
| 186 |
-
- uses Gemini Flash for structured answer/action generation
|
| 187 |
-
- returns citations, confidence, suggested entities, and next actions
|
| 188 |
-
- preserves a deterministic operator experience through scenario-backed degradation
|
| 189 |
|
| 190 |
---
|
| 191 |
|
| 192 |
-
##
|
| 193 |
|
| 194 |
-
ShockMap
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
- **Short timeout budget** to avoid hanging user flows
|
| 200 |
-
- **Prompt minimization** to keep token usage tight
|
| 201 |
-
- **Retrieval merge** across vector and lexical evidence
|
| 202 |
-
- **Deterministic fallback** so the product remains usable even when external AI calls are slow
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
- Engine 3 action-plan generation
|
| 208 |
|
| 209 |
-
|
|
|
|
|
|
|
| 210 |
|
| 211 |
---
|
| 212 |
|
| 213 |
-
##
|
| 214 |
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
-
|
| 219 |
-
- stable
|
| 220 |
-
- easy to validate
|
| 221 |
-
- suitable for a judging demo
|
| 222 |
-
|
| 223 |
-
The next phases extend that foundation into a more advanced learning system without changing the product workflow.
|
| 224 |
|
| 225 |
---
|
| 226 |
|
| 227 |
-
##
|
| 228 |
-
|
| 229 |
-
### Phase 1: MVP Operations Layer
|
| 230 |
-
- hybrid live shock feed
|
| 231 |
-
- production graph propagation
|
| 232 |
-
- war room
|
| 233 |
-
- action simulation
|
| 234 |
-
- grounded operator query
|
| 235 |
-
- deployable frontend + backend surfaces
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
- map-based incident command views
|
| 242 |
-
- expanded sector templates beyond pharma and rare earths
|
| 243 |
-
|
| 244 |
-
### Phase 3: GNN Propagation Layer
|
| 245 |
-
- temporal graph learning on historical disruptions
|
| 246 |
-
- node embeddings across provinces, APIs, inputs, and downstream products
|
| 247 |
-
- learned propagation that augments or replaces the PageRank path
|
| 248 |
-
- improved ranking under multi-origin and cascading shocks
|
| 249 |
-
|
| 250 |
-
### Phase 4: Procurement and Policy Platform
|
| 251 |
-
- procurement workflow integration
|
| 252 |
-
- escalation playbooks
|
| 253 |
-
- role-based action tracking
|
| 254 |
-
- stakeholder notes and audit history
|
| 255 |
-
- organization-level workspaces
|
| 256 |
-
|
| 257 |
-
### Phase 5: Multi-Sector National Risk Platform
|
| 258 |
-
- semiconductor inputs
|
| 259 |
-
- solar and clean-energy dependencies
|
| 260 |
-
- EV battery chain monitoring
|
| 261 |
-
- defense and advanced-manufacturing inputs
|
| 262 |
-
- cross-sector risk portfolio views
|
| 263 |
|
| 264 |
---
|
| 265 |
|
| 266 |
-
##
|
| 267 |
-
|
| 268 |
-
The graph layer is already structured for a future GNN upgrade.
|
| 269 |
-
|
| 270 |
-
### Planned graph design
|
| 271 |
-
|
| 272 |
-
**Node types**
|
| 273 |
-
- province
|
| 274 |
-
- factory
|
| 275 |
-
- input
|
| 276 |
-
- API
|
| 277 |
-
- drug
|
| 278 |
-
- rare earth mineral
|
| 279 |
-
- route / logistics node
|
| 280 |
-
- supplier / buyer entity
|
| 281 |
-
|
| 282 |
-
**Node features**
|
| 283 |
-
- import concentration
|
| 284 |
-
- current risk score
|
| 285 |
-
- buffer days
|
| 286 |
-
- substitutability
|
| 287 |
-
- strategic priority
|
| 288 |
-
- demand criticality
|
| 289 |
-
- recent shock exposure
|
| 290 |
-
- regulatory pressure
|
| 291 |
-
|
| 292 |
-
**Edge features**
|
| 293 |
-
- trade volume
|
| 294 |
-
- lead time
|
| 295 |
-
- dependency strength
|
| 296 |
-
- route dependence
|
| 297 |
-
- concentration and exclusivity
|
| 298 |
-
|
| 299 |
-
**Training targets**
|
| 300 |
-
- realized shortage severity
|
| 301 |
-
- price spikes
|
| 302 |
-
- fill-rate stress
|
| 303 |
-
- procurement delays
|
| 304 |
-
- duration to recovery
|
| 305 |
-
|
| 306 |
-
### Planned inference outcome
|
| 307 |
-
|
| 308 |
-
The GNN phase will learn propagation patterns from observed disruptions rather than relying only on static graph logic. The product experience remains the same, but the engine underneath becomes better at:
|
| 309 |
-
- multi-hop shock attribution
|
| 310 |
-
- hidden cluster coupling
|
| 311 |
-
- non-linear escalation
|
| 312 |
-
- time-aware recovery forecasting
|
| 313 |
-
|
| 314 |
-
---
|
| 315 |
-
|
| 316 |
-
## Scalability Design
|
| 317 |
-
|
| 318 |
-
ShockMap is being shaped to scale from a demo system into a broader intelligence platform.
|
| 319 |
-
|
| 320 |
-
### 1. Stateless serving layer
|
| 321 |
-
- FastAPI backend stays stateless
|
| 322 |
-
- frontend remains CDN-friendly and easy to cache
|
| 323 |
-
- deployment can scale horizontally without application rewrites
|
| 324 |
-
|
| 325 |
-
### 2. Separate ingestion from serving
|
| 326 |
-
- ingestion jobs run on a schedule
|
| 327 |
-
- serving reads normalized shock data
|
| 328 |
-
- this keeps user-facing latency stable even as source count grows
|
| 329 |
-
|
| 330 |
-
### 3. Layered storage model
|
| 331 |
-
- raw source archive
|
| 332 |
-
- normalized shock records
|
| 333 |
-
- graph-ready feature tables
|
| 334 |
-
- retrieval-ready citation corpus
|
| 335 |
-
|
| 336 |
-
### 4. Cached graph snapshots
|
| 337 |
-
- precompute community structure
|
| 338 |
-
- cache graph responses for UI views
|
| 339 |
-
- isolate heavy recomputation to background refresh cycles
|
| 340 |
-
|
| 341 |
-
### 5. Retrieval layer growth
|
| 342 |
-
- lexical fallback today
|
| 343 |
-
- hosted vector retrieval at scale
|
| 344 |
-
- sector-specific retrieval collections later
|
| 345 |
|
| 346 |
-
|
| 347 |
-
- tenant-scoped sectors, alerts, and workspaces
|
| 348 |
-
- org-specific configuration and alerting
|
| 349 |
-
- audit trails for action workflows
|
| 350 |
|
| 351 |
-
###
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
| `backend/app/` | FastAPI APIs, services, models, dependency injection |
|
| 364 |
-
| `frontend/src/` | React UI surfaces and client-side workflow |
|
| 365 |
-
| `ingestion/` | shock detection, scrapers, and signal intake |
|
| 366 |
-
| `graph/` | graph-building utilities |
|
| 367 |
-
| `data/seed/` | curated seed data, scenarios, policy snippets |
|
| 368 |
-
| `data/shocks.json` | live structured shock feed |
|
| 369 |
-
| `docs/` | architecture and data-source notes |
|
| 370 |
|
| 371 |
---
|
| 372 |
|
| 373 |
-
##
|
| 374 |
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
| `/api/v1/shocks/{id}` | shock detail |
|
| 381 |
-
| `/api/v1/shocks/{id}/war-room` | incident command surface |
|
| 382 |
-
| `/api/v1/shocks/{id}/simulate-action` | action delta simulation |
|
| 383 |
-
| `/api/v1/graph` | graph snapshot and state risk |
|
| 384 |
-
| `/api/v1/query` | operator analyst |
|
| 385 |
-
| `/api/v1/engines` | engine diagnostics |
|
| 386 |
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
## Run Locally
|
| 390 |
-
|
| 391 |
-
### Backend
|
| 392 |
```bash
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
cd backend
|
| 394 |
-
python -m venv venv
|
| 395 |
-
venv\Scripts\activate
|
| 396 |
pip install -r requirements.txt
|
| 397 |
-
uvicorn app.main:app --reload
|
| 398 |
-
```
|
| 399 |
-
|
| 400 |
-
Backend URLs:
|
| 401 |
-
- API: `http://localhost:8000`
|
| 402 |
-
- Docs: `http://localhost:8000/docs`
|
| 403 |
-
- Health: `http://localhost:8000/healthz`
|
| 404 |
|
| 405 |
-
#
|
| 406 |
-
|
| 407 |
-
cd frontend
|
| 408 |
npm install
|
| 409 |
npm run dev
|
| 410 |
```
|
| 411 |
|
| 412 |
-
Frontend URL:
|
| 413 |
-
- App: `http://localhost:5173`
|
| 414 |
-
|
| 415 |
-
### Optional ingestion refresh
|
| 416 |
-
```bash
|
| 417 |
-
python -m ingestion.shock_detector --once
|
| 418 |
-
```
|
| 419 |
-
|
| 420 |
---
|
| 421 |
|
| 422 |
-
##
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
| `QDRANT_API_KEY` | vector store authentication |
|
| 429 |
-
| `DEMO_MODE` | enables curated scenario mode |
|
| 430 |
-
| `ENABLE_GNN` | toggles the future GNN path |
|
| 431 |
-
| `ALLOWED_ORIGINS` | frontend origins for the API |
|
| 432 |
|
| 433 |
---
|
| 434 |
|
| 435 |
-
##
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|---|---|
|
| 439 |
-
| Backend | FastAPI, Pydantic v2, Python |
|
| 440 |
-
| Graph | NetworkX, python-louvain, criticality scoring |
|
| 441 |
-
| AI | Gemini Flash |
|
| 442 |
-
| Retrieval | Qdrant + lexical fallback |
|
| 443 |
-
| Frontend | React 19, Vite |
|
| 444 |
-
| Ingestion | Python schedulers, source fetchers, structured normalization |
|
| 445 |
-
|
| 446 |
-
---
|
| 447 |
-
|
| 448 |
-
## Positioning
|
| 449 |
-
|
| 450 |
-
ShockMap is not just an alert dashboard.
|
| 451 |
-
|
| 452 |
-
It is a **decision engine for upstream supply shocks**:
|
| 453 |
-
- detect the disruption
|
| 454 |
-
- map the downstream exposure
|
| 455 |
-
- simulate the response
|
| 456 |
-
- act with evidence
|
| 457 |
-
|
| 458 |
-
That is the MVP.
|
| 459 |
-
|
| 460 |
-
---
|
| 461 |
-
|
| 462 |
-
## License
|
| 463 |
-
|
| 464 |
-
MIT
|
| 465 |
-
|
| 466 |
-
Built for the **Google Solution Challenge 2026**.
|
|
|
|
| 1 |
+
# ShockMap Intelligence Platform
|
| 2 |
+
### *National Supply Chain Security & Disruption Command Center*
|
| 3 |
|
| 4 |
+
**ShockMap** is a high-fidelity geospatial intelligence platform designed to secure India's critical import dependencies (Pharma APIs & Rare Earth Minerals). Built for the **Google Solution Challenge 2026**, it transforms unstructured global disruption signals into actionable procurement intelligence.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
---
|
| 7 |
|
| 8 |
+
## ⚡ The 3-Engine Architecture
|
| 9 |
|
| 10 |
+
ShockMap operates on a proprietary triple-engine workflow that bridges the gap between news and action:
|
| 11 |
|
| 12 |
+
1. **Engine 1: Signal Intelligence (NER & Extraction)**
|
| 13 |
+
* Autonomous scanning of global trade news, policy shifts, and port telemetry via GDELT.
|
| 14 |
+
* Entity extraction identifies provinces (e.g., Hebei), sectors (Pharma), and event types (Export Ban).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
2. **Engine 2: Propagation Intelligence (Graph Theory)**
|
| 17 |
+
* Models the supply chain as a complex network of 60+ nodes and 70+ corridors.
|
| 18 |
+
* Uses **Personalized PageRank** and **Louvain Community Detection** to calculate how a factory shutdown in China ripples through Indian manufacturing states.
|
|
|
|
| 19 |
|
| 20 |
+
3. **Engine 3: Action Intelligence (Grounded RAG)**
|
| 21 |
+
* Powered by **Gemini 2.0 Flash**.
|
| 22 |
+
* Generates specific 72-hour action plans (e.g., "Secure 18 MT of PAP from SE Asia") grounded in NLEM policy and real-time telemetry.
|
| 23 |
|
| 24 |
---
|
| 25 |
|
| 26 |
+
## 🚀 Key Features (The "WOW" Factors)
|
| 27 |
|
| 28 |
+
* **Geospatial Risk Heatmap**: Real-time visualization of supply nodes in China and absorption nodes in India.
|
| 29 |
+
* **Animated Propagation Graph**: WebGL-rendered supply network featuring **live particle flow** on disrupted corridors and **ripple-effect** shock simulations.
|
| 30 |
+
* **War Room Command Center**: Cinematic incident detail pages with stockout countdowns and multi-path response simulators.
|
| 31 |
+
* **AI Operator Analyst**: Natural language interface with streaming typewriter responses and evidence-backed citations.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
---
|
| 34 |
|
| 35 |
+
## 🛠️ Technical Stack
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
* **Frontend**: React 19, Vite, Leaflet (Geospatial), Sigma.js (WebGL Graph), Framer Motion.
|
| 38 |
+
* **Backend**: FastAPI, Pydantic v2, Python 3.11.
|
| 39 |
+
* **Intelligence**: Gemini 2.0 Flash (Action Gen), NetworkX (Propagation), GDELT (Live Signal Intake).
|
| 40 |
+
* **Geocoding**: Google Maps Platform (Primary) + Nominatim (Fallback).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
---
|
| 43 |
|
| 44 |
+
## 🌍 Deployment Guide
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
ShockMap is production-ready and optimized for high-performance edge deployment.
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
+
### 1. Backend (Render)
|
| 49 |
+
* **Service Type**: Docker (Web + Background Worker)
|
| 50 |
+
* **Config**: Uses `render.yaml` for automatic infrastructure provisioning.
|
| 51 |
+
* **Environment**:
|
| 52 |
+
* `GEMINI_API_KEY`: Required for Engine 3.
|
| 53 |
+
* `GOOGLE_MAPS_API_KEY`: Required for Geospatial Intel.
|
| 54 |
+
* `PORT`: 8080
|
| 55 |
|
| 56 |
+
### 2. Frontend (Netlify / Vercel)
|
| 57 |
+
* **Build Command**: `npm run build`
|
| 58 |
+
* **Publish Directory**: `dist`
|
| 59 |
+
* **Environment**:
|
| 60 |
+
* `VITE_BACKEND_URL`: Points to your deployed Render API.
|
| 61 |
+
* `VITE_GOOGLE_MAPS_API_KEY`: Client-side map rendering key.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
---
|
| 64 |
|
| 65 |
+
## 💻 Local Development
|
| 66 |
|
| 67 |
+
### Prerequisites
|
| 68 |
+
* Node.js 20+
|
| 69 |
+
* Python 3.11+
|
| 70 |
+
* Google Maps API Key
|
| 71 |
+
* Gemini API Key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
### Installation
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
```bash
|
| 75 |
+
# Clone the repository
|
| 76 |
+
git clone https://github.com/dhruva137/pharmashield.git
|
| 77 |
+
cd pharmashield
|
| 78 |
+
|
| 79 |
+
# Start Backend
|
| 80 |
cd backend
|
|
|
|
|
|
|
| 81 |
pip install -r requirements.txt
|
| 82 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
+
# Start Frontend
|
| 85 |
+
cd ../frontend
|
|
|
|
| 86 |
npm install
|
| 87 |
npm run dev
|
| 88 |
```
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
---
|
| 91 |
|
| 92 |
+
## 📊 MVP Coverage
|
| 93 |
+
* **20+** Essential Medicines (NLEM)
|
| 94 |
+
* **25+** Critical Pharma APIs
|
| 95 |
+
* **8** Strategic Rare Earth Minerals
|
| 96 |
+
* **52** International Supply Corridors
|
| 97 |
+
* **15** Indian Manufacturing States
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
---
|
| 100 |
|
| 101 |
+
### Google Solution Challenge 2026
|
| 102 |
+
*Developed by Dhruva P Gowda*
|
| 103 |
+
*MIT License*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/api/map.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Map API — Geospatial Intelligence Surface
|
| 3 |
+
|
| 4 |
+
Provides heatmap, province detail, supply corridors, and filter facets
|
| 5 |
+
for the interactive Leaflet map frontend.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import List, Optional, Any, Dict
|
| 11 |
+
from fastapi import APIRouter, Depends, Query, HTTPException
|
| 12 |
+
from ..deps import get_graph_service, get_data_loader, get_demo_mode_service
|
| 13 |
+
from ..models.graph import ProvinceHeatmapPoint, HeatmapResponse
|
| 14 |
+
from .sectors import _get_live_shocks
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("backend.map")
|
| 17 |
+
router = APIRouter(prefix="/api/v1/map", tags=["map"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _compute_india_state_risk(state: dict, graph_service, shocks: list, sector: str) -> float:
|
| 21 |
+
"""Compute risk score for an Indian state based on its top drugs' dependencies."""
|
| 22 |
+
total_risk = 0.0
|
| 23 |
+
top_drugs = state.get("top_drugs", [])
|
| 24 |
+
|
| 25 |
+
for drug_id in top_drugs:
|
| 26 |
+
node = graph_service.graph.nodes.get(drug_id, {})
|
| 27 |
+
drug_risk = node.get("current_risk", 0.0)
|
| 28 |
+
total_risk += drug_risk
|
| 29 |
+
|
| 30 |
+
# Factor in shock proximity (shocks that mention this state or affect its drugs)
|
| 31 |
+
state_shocks = [s for s in shocks if s.get("province") and any(
|
| 32 |
+
drug_id in (graph_service.get_drugs_dependent_on_province(s["province"]) or [])
|
| 33 |
+
for drug_id in top_drugs
|
| 34 |
+
)]
|
| 35 |
+
shock_factor = min(30, len(state_shocks) * 10)
|
| 36 |
+
|
| 37 |
+
# Normalize: base drug risk + shock amplifier
|
| 38 |
+
base_risk = min(70, total_risk * 2) if top_drugs else 10
|
| 39 |
+
return round(min(100.0, base_risk + shock_factor), 2)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.get("/heatmap", response_model=HeatmapResponse)
|
| 43 |
+
async def get_heatmap(
|
| 44 |
+
sector: str = Query(default="both"),
|
| 45 |
+
risk_min: float = Query(default=0.0),
|
| 46 |
+
shock_type: Optional[str] = Query(default=None),
|
| 47 |
+
region: str = Query(default="all", description="'china', 'india', or 'all'"),
|
| 48 |
+
graph_service = Depends(get_graph_service),
|
| 49 |
+
data_loader = Depends(get_data_loader),
|
| 50 |
+
demo_service = Depends(get_demo_mode_service)
|
| 51 |
+
):
|
| 52 |
+
"""
|
| 53 |
+
Returns heatmap data for Chinese provinces and Indian states.
|
| 54 |
+
Supports region filtering for focused views.
|
| 55 |
+
"""
|
| 56 |
+
shocks = _get_live_shocks(demo_service)
|
| 57 |
+
points = []
|
| 58 |
+
|
| 59 |
+
# ── China Provinces ──────────────────────────────────────────────────
|
| 60 |
+
if region in ("china", "all"):
|
| 61 |
+
provinces = data_loader.get_china_provinces()
|
| 62 |
+
|
| 63 |
+
for p in provinces:
|
| 64 |
+
pname = p["name"]
|
| 65 |
+
pid = p["id"]
|
| 66 |
+
|
| 67 |
+
# Sector filter for provinces
|
| 68 |
+
province_sectors = p.get("sectors", ["pharma"])
|
| 69 |
+
if sector != "both" and sector not in province_sectors:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
risk_score = graph_service.compute_province_risk(pname, sector)
|
| 73 |
+
|
| 74 |
+
if risk_score < risk_min:
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
province_shocks = [s for s in shocks if s.get("province") == pname]
|
| 78 |
+
if sector != "both":
|
| 79 |
+
province_shocks = [s for s in province_shocks if s.get("sector") == sector]
|
| 80 |
+
|
| 81 |
+
if shock_type:
|
| 82 |
+
province_shocks = [s for s in province_shocks if s.get("event_type") == shock_type]
|
| 83 |
+
if not province_shocks:
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
affected = graph_service.get_drugs_dependent_on_province(pname)
|
| 87 |
+
if sector != "both":
|
| 88 |
+
affected = [a for a in affected if graph_service.graph.nodes.get(a, {}).get("sector") == sector]
|
| 89 |
+
|
| 90 |
+
points.append(ProvinceHeatmapPoint(
|
| 91 |
+
id=pid,
|
| 92 |
+
name=pname,
|
| 93 |
+
latitude=p["latitude"],
|
| 94 |
+
longitude=p["longitude"],
|
| 95 |
+
risk_score=risk_score,
|
| 96 |
+
shock_count=len(province_shocks),
|
| 97 |
+
sector=sector,
|
| 98 |
+
description=p.get("description"),
|
| 99 |
+
top_affected_inputs=affected[:5],
|
| 100 |
+
region="china",
|
| 101 |
+
factories=p.get("factories", []),
|
| 102 |
+
))
|
| 103 |
+
|
| 104 |
+
# ── India States ─────────────────────────────────────────────────────
|
| 105 |
+
if region in ("india", "all"):
|
| 106 |
+
states = data_loader.get_india_states()
|
| 107 |
+
|
| 108 |
+
for s in states:
|
| 109 |
+
sname = s["name"]
|
| 110 |
+
sid = s["id"]
|
| 111 |
+
|
| 112 |
+
risk_score = _compute_india_state_risk(s, graph_service, shocks, sector)
|
| 113 |
+
|
| 114 |
+
if risk_score < risk_min:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
# Shocks affecting this state's drugs
|
| 118 |
+
state_shock_count = 0
|
| 119 |
+
for shock in shocks:
|
| 120 |
+
if shock_type and shock.get("event_type") != shock_type:
|
| 121 |
+
continue
|
| 122 |
+
if sector != "both" and shock.get("sector") != sector:
|
| 123 |
+
continue
|
| 124 |
+
province = shock.get("province")
|
| 125 |
+
if province:
|
| 126 |
+
affected_drugs = graph_service.get_drugs_dependent_on_province(province)
|
| 127 |
+
if any(d in s.get("top_drugs", []) for d in affected_drugs):
|
| 128 |
+
state_shock_count += 1
|
| 129 |
+
|
| 130 |
+
points.append(ProvinceHeatmapPoint(
|
| 131 |
+
id=sid,
|
| 132 |
+
name=sname,
|
| 133 |
+
latitude=s["latitude"],
|
| 134 |
+
longitude=s["longitude"],
|
| 135 |
+
risk_score=risk_score,
|
| 136 |
+
shock_count=state_shock_count,
|
| 137 |
+
sector=sector,
|
| 138 |
+
description=s.get("description"),
|
| 139 |
+
top_affected_inputs=s.get("top_drugs", [])[:5],
|
| 140 |
+
region="india",
|
| 141 |
+
factories=[],
|
| 142 |
+
))
|
| 143 |
+
|
| 144 |
+
return HeatmapResponse(
|
| 145 |
+
points=points,
|
| 146 |
+
generated_at=datetime.utcnow()
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.get("/provinces/{province_id}")
|
| 151 |
+
async def get_province_detail(
|
| 152 |
+
province_id: str,
|
| 153 |
+
sector: str = Query(default="both"),
|
| 154 |
+
graph_service = Depends(get_graph_service),
|
| 155 |
+
data_loader = Depends(get_data_loader),
|
| 156 |
+
demo_service = Depends(get_demo_mode_service)
|
| 157 |
+
):
|
| 158 |
+
"""
|
| 159 |
+
Returns detailed metrics for a specific province or Indian state.
|
| 160 |
+
"""
|
| 161 |
+
# Check China provinces first
|
| 162 |
+
provinces = data_loader.get_china_provinces()
|
| 163 |
+
province = next((p for p in provinces if p["id"] == province_id or p["name"] == province_id), None)
|
| 164 |
+
|
| 165 |
+
# Then check India states
|
| 166 |
+
is_india = False
|
| 167 |
+
if not province:
|
| 168 |
+
states = data_loader.get_india_states()
|
| 169 |
+
province = next((s for s in states if s["id"] == province_id or s["name"] == province_id), None)
|
| 170 |
+
is_india = True
|
| 171 |
+
|
| 172 |
+
if not province:
|
| 173 |
+
raise HTTPException(status_code=404, detail=f"Province/State {province_id} not found")
|
| 174 |
+
|
| 175 |
+
pname = province["name"]
|
| 176 |
+
shocks = _get_live_shocks(demo_service)
|
| 177 |
+
|
| 178 |
+
if is_india:
|
| 179 |
+
# For Indian states, find shocks that affect their drugs
|
| 180 |
+
province_shocks = []
|
| 181 |
+
top_drugs = province.get("top_drugs", [])
|
| 182 |
+
for shock in shocks:
|
| 183 |
+
shock_province = shock.get("province")
|
| 184 |
+
if shock_province:
|
| 185 |
+
affected = graph_service.get_drugs_dependent_on_province(shock_province)
|
| 186 |
+
if any(d in top_drugs for d in affected):
|
| 187 |
+
province_shocks.append(shock)
|
| 188 |
+
|
| 189 |
+
risk_score = _compute_india_state_risk(province, graph_service, shocks, sector)
|
| 190 |
+
|
| 191 |
+
top_entities = []
|
| 192 |
+
for drug_id in top_drugs[:10]:
|
| 193 |
+
node = graph_service.graph.nodes.get(drug_id, {})
|
| 194 |
+
if node:
|
| 195 |
+
top_entities.append({
|
| 196 |
+
"id": drug_id,
|
| 197 |
+
"name": node.get("name", drug_id),
|
| 198 |
+
"type": node.get("type"),
|
| 199 |
+
"risk": node.get("current_risk", 0.0)
|
| 200 |
+
})
|
| 201 |
+
else:
|
| 202 |
+
province_shocks = [s for s in shocks if s.get("province") == pname]
|
| 203 |
+
if sector != "both":
|
| 204 |
+
province_shocks = [s for s in province_shocks if s.get("sector") == sector]
|
| 205 |
+
|
| 206 |
+
risk_score = graph_service.compute_province_risk(pname, sector)
|
| 207 |
+
affected = graph_service.get_drugs_dependent_on_province(pname)
|
| 208 |
+
|
| 209 |
+
if sector != "both":
|
| 210 |
+
affected = [a for a in affected if graph_service.graph.nodes.get(a, {}).get("sector") == sector]
|
| 211 |
+
|
| 212 |
+
top_entities = []
|
| 213 |
+
for entity_id in affected[:10]:
|
| 214 |
+
node = graph_service.graph.nodes.get(entity_id, {})
|
| 215 |
+
top_entities.append({
|
| 216 |
+
"id": entity_id,
|
| 217 |
+
"name": node.get("name", entity_id),
|
| 218 |
+
"type": node.get("type"),
|
| 219 |
+
"risk": node.get("current_risk", 0.0)
|
| 220 |
+
})
|
| 221 |
+
|
| 222 |
+
return {
|
| 223 |
+
"id": province.get("id"),
|
| 224 |
+
"name": pname,
|
| 225 |
+
"region": "india" if is_india else "china",
|
| 226 |
+
"risk_score": risk_score,
|
| 227 |
+
"shocks": province_shocks[:10],
|
| 228 |
+
"factories": province.get("factories", []),
|
| 229 |
+
"description": province.get("description"),
|
| 230 |
+
"top_entities": top_entities,
|
| 231 |
+
"coordinates": {"lat": province["latitude"], "lng": province["longitude"]},
|
| 232 |
+
"population_millions": province.get("population_millions"),
|
| 233 |
+
"pharma_hub": province.get("pharma_hub", False),
|
| 234 |
+
"sectors": province.get("sectors", []),
|
| 235 |
+
"annual_api_output_tons": province.get("annual_api_output_tons", 0),
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@router.get("/supply-corridors")
|
| 240 |
+
async def get_supply_corridors(
|
| 241 |
+
sector: str = Query(default="both"),
|
| 242 |
+
graph_service = Depends(get_graph_service),
|
| 243 |
+
data_loader = Depends(get_data_loader)
|
| 244 |
+
):
|
| 245 |
+
"""
|
| 246 |
+
Returns supply corridors (China Province -> India State) with risk weighting.
|
| 247 |
+
"""
|
| 248 |
+
corridors = []
|
| 249 |
+
provinces = data_loader.get_china_provinces()
|
| 250 |
+
states = data_loader.get_india_states()
|
| 251 |
+
|
| 252 |
+
for p in provinces:
|
| 253 |
+
pname = p["name"]
|
| 254 |
+
|
| 255 |
+
# Sector filter
|
| 256 |
+
province_sectors = p.get("sectors", ["pharma"])
|
| 257 |
+
if sector != "both" and sector not in province_sectors:
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
affected_drugs = graph_service.get_drugs_dependent_on_province(pname)
|
| 261 |
+
if not affected_drugs:
|
| 262 |
+
continue
|
| 263 |
+
|
| 264 |
+
province_risk = graph_service.compute_province_risk(pname, sector)
|
| 265 |
+
|
| 266 |
+
for s in states:
|
| 267 |
+
overlap = set(affected_drugs).intersection(set(s.get("top_drugs", [])))
|
| 268 |
+
if overlap:
|
| 269 |
+
corridors.append({
|
| 270 |
+
"id": f"{p['id']}_{s['id']}",
|
| 271 |
+
"source": {
|
| 272 |
+
"id": p["id"],
|
| 273 |
+
"name": pname,
|
| 274 |
+
"lat": p["latitude"],
|
| 275 |
+
"lng": p["longitude"],
|
| 276 |
+
"region": "china"
|
| 277 |
+
},
|
| 278 |
+
"target": {
|
| 279 |
+
"id": s["id"],
|
| 280 |
+
"name": s["name"],
|
| 281 |
+
"lat": s["latitude"],
|
| 282 |
+
"lng": s["longitude"],
|
| 283 |
+
"region": "india"
|
| 284 |
+
},
|
| 285 |
+
"weight": len(overlap),
|
| 286 |
+
"risk_score": province_risk,
|
| 287 |
+
"affected_entities": list(overlap)[:5]
|
| 288 |
+
})
|
| 289 |
+
|
| 290 |
+
return {"corridors": corridors, "total": len(corridors)}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@router.get("/filter-facets")
|
| 294 |
+
async def get_filter_facets(
|
| 295 |
+
data_loader = Depends(get_data_loader),
|
| 296 |
+
demo_service = Depends(get_demo_mode_service)
|
| 297 |
+
):
|
| 298 |
+
"""
|
| 299 |
+
Returns available options for map filtering.
|
| 300 |
+
"""
|
| 301 |
+
shocks = _get_live_shocks(demo_service)
|
| 302 |
+
shock_types = sorted(list(set([s.get("event_type") for s in shocks if s.get("event_type")])))
|
| 303 |
+
sectors = ["pharma", "rare_earth"]
|
| 304 |
+
|
| 305 |
+
china_provinces = sorted([p["name"] for p in data_loader.get_china_provinces()])
|
| 306 |
+
india_states = sorted([s["name"] for s in data_loader.get_india_states()])
|
| 307 |
+
|
| 308 |
+
severity_levels = sorted(list(set([s.get("severity") for s in shocks if s.get("severity")])))
|
| 309 |
+
|
| 310 |
+
return {
|
| 311 |
+
"sectors": sectors,
|
| 312 |
+
"shock_types": shock_types,
|
| 313 |
+
"risk_levels": ["low", "medium", "high", "critical"],
|
| 314 |
+
"severity_levels": severity_levels,
|
| 315 |
+
"china_provinces": china_provinces,
|
| 316 |
+
"india_states": india_states,
|
| 317 |
+
"provinces": china_provinces, # backward compat
|
| 318 |
+
"regions": ["china", "india", "all"],
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@router.get("/stats")
|
| 323 |
+
async def get_map_stats(
|
| 324 |
+
graph_service = Depends(get_graph_service),
|
| 325 |
+
data_loader = Depends(get_data_loader),
|
| 326 |
+
demo_service = Depends(get_demo_mode_service)
|
| 327 |
+
):
|
| 328 |
+
"""
|
| 329 |
+
Returns aggregate statistics for the map overview banner.
|
| 330 |
+
"""
|
| 331 |
+
shocks = _get_live_shocks(demo_service)
|
| 332 |
+
provinces = data_loader.get_china_provinces()
|
| 333 |
+
states = data_loader.get_india_states()
|
| 334 |
+
|
| 335 |
+
active_shocks = len(shocks)
|
| 336 |
+
high_risk_provinces = 0
|
| 337 |
+
total_factories = 0
|
| 338 |
+
|
| 339 |
+
for p in provinces:
|
| 340 |
+
risk = graph_service.compute_province_risk(p["name"], "both")
|
| 341 |
+
if risk >= 60:
|
| 342 |
+
high_risk_provinces += 1
|
| 343 |
+
total_factories += len(p.get("factories", []))
|
| 344 |
+
|
| 345 |
+
total_corridors = 0
|
| 346 |
+
for p in provinces:
|
| 347 |
+
affected = graph_service.get_drugs_dependent_on_province(p["name"])
|
| 348 |
+
for s in states:
|
| 349 |
+
if set(affected).intersection(set(s.get("top_drugs", []))):
|
| 350 |
+
total_corridors += 1
|
| 351 |
+
|
| 352 |
+
return {
|
| 353 |
+
"active_shocks": active_shocks,
|
| 354 |
+
"monitored_provinces": len(provinces),
|
| 355 |
+
"monitored_states": len(states),
|
| 356 |
+
"high_risk_provinces": high_risk_provinces,
|
| 357 |
+
"total_factories": total_factories,
|
| 358 |
+
"supply_corridors": total_corridors,
|
| 359 |
+
}
|
backend/app/config.py
CHANGED
|
@@ -41,6 +41,7 @@ class Settings(BaseSettings):
|
|
| 41 |
GEMINI_API_KEY: str = ""
|
| 42 |
QDRANT_URL: str = ""
|
| 43 |
QDRANT_API_KEY: str = ""
|
|
|
|
| 44 |
|
| 45 |
# Project Info
|
| 46 |
FIREBASE_PROJECT_ID: str = ""
|
|
|
|
| 41 |
GEMINI_API_KEY: str = ""
|
| 42 |
QDRANT_URL: str = ""
|
| 43 |
QDRANT_API_KEY: str = ""
|
| 44 |
+
GOOGLE_MAPS_API_KEY: str = ""
|
| 45 |
|
| 46 |
# Project Info
|
| 47 |
FIREBASE_PROJECT_ID: str = ""
|
backend/app/data_loader.py
CHANGED
|
@@ -29,6 +29,8 @@ class DataLoader:
|
|
| 29 |
self._fda_alerts: List[Dict[str, Any]] = []
|
| 30 |
self._historical_disruptions: List[Dict[str, Any]] = []
|
| 31 |
self._policy_snippets: List[Dict[str, Any]] = []
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def _load_json(self, filename: str) -> Any:
|
| 34 |
"""Helper to load a JSON file from the seed directory."""
|
|
@@ -58,11 +60,13 @@ class DataLoader:
|
|
| 58 |
self._fda_alerts = self._load_json("fda_alerts.json")
|
| 59 |
self._historical_disruptions = self._load_json("historical_disruptions.json")
|
| 60 |
self._policy_snippets = self._load_json("policy_snippets.json")
|
|
|
|
|
|
|
| 61 |
|
| 62 |
logger.info(
|
| 63 |
f"🚀 Loaded {len(self._drugs)} drugs, {len(self._apis)} APIs, "
|
| 64 |
f"{len(self._dependencies)} edges, {len(self._alerts)} alerts, "
|
| 65 |
-
f"{len(self._policy_snippets)} policy snippets."
|
| 66 |
)
|
| 67 |
|
| 68 |
def get_drugs(self) -> List[Drug]:
|
|
@@ -121,3 +125,11 @@ class DataLoader:
|
|
| 121 |
def get_fda_alerts(self) -> List[Dict[str, Any]]:
|
| 122 |
"""Returns raw FDA import/warning alerts."""
|
| 123 |
return self._fda_alerts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
self._fda_alerts: List[Dict[str, Any]] = []
|
| 30 |
self._historical_disruptions: List[Dict[str, Any]] = []
|
| 31 |
self._policy_snippets: List[Dict[str, Any]] = []
|
| 32 |
+
self._china_provinces: List[Dict[str, Any]] = []
|
| 33 |
+
self._india_states: List[Dict[str, Any]] = []
|
| 34 |
|
| 35 |
def _load_json(self, filename: str) -> Any:
|
| 36 |
"""Helper to load a JSON file from the seed directory."""
|
|
|
|
| 60 |
self._fda_alerts = self._load_json("fda_alerts.json")
|
| 61 |
self._historical_disruptions = self._load_json("historical_disruptions.json")
|
| 62 |
self._policy_snippets = self._load_json("policy_snippets.json")
|
| 63 |
+
self._china_provinces = self._load_json("china_provinces.json")
|
| 64 |
+
self._india_states = self._load_json("india_states.json")
|
| 65 |
|
| 66 |
logger.info(
|
| 67 |
f"🚀 Loaded {len(self._drugs)} drugs, {len(self._apis)} APIs, "
|
| 68 |
f"{len(self._dependencies)} edges, {len(self._alerts)} alerts, "
|
| 69 |
+
f"{len(self._policy_snippets)} policy snippets, {len(self._china_provinces)} provinces."
|
| 70 |
)
|
| 71 |
|
| 72 |
def get_drugs(self) -> List[Drug]:
|
|
|
|
| 125 |
def get_fda_alerts(self) -> List[Dict[str, Any]]:
|
| 126 |
"""Returns raw FDA import/warning alerts."""
|
| 127 |
return self._fda_alerts
|
| 128 |
+
|
| 129 |
+
def get_china_provinces(self) -> List[Dict[str, Any]]:
|
| 130 |
+
"""Returns the list of China provinces with coordinates."""
|
| 131 |
+
return self._china_provinces
|
| 132 |
+
|
| 133 |
+
def get_india_states(self) -> List[Dict[str, Any]]:
|
| 134 |
+
"""Returns the list of India states with coordinates."""
|
| 135 |
+
return self._india_states
|
backend/app/main.py
CHANGED
|
@@ -9,7 +9,8 @@ import traceback
|
|
| 9 |
from uuid import uuid4
|
| 10 |
from datetime import datetime, timedelta
|
| 11 |
|
| 12 |
-
|
|
|
|
| 13 |
from fastapi.responses import JSONResponse
|
| 14 |
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
|
|
@@ -164,6 +165,20 @@ async def startup_event():
|
|
| 164 |
get_gnn()
|
| 165 |
except Exception as e:
|
| 166 |
logger.warning(f"GNN loading failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
logger.info("PharmaShield startup sequence complete.")
|
| 169 |
|
|
@@ -195,7 +210,7 @@ async def healthz():
|
|
| 195 |
|
| 196 |
# Router Mounting
|
| 197 |
# All routers are now implemented and ready for production use
|
| 198 |
-
from .api import graph, drugs, alerts, query, simulate, sectors, engines
|
| 199 |
|
| 200 |
app.include_router(graph.router)
|
| 201 |
app.include_router(drugs.router)
|
|
@@ -204,3 +219,4 @@ app.include_router(query.router)
|
|
| 204 |
app.include_router(simulate.router)
|
| 205 |
app.include_router(sectors.router)
|
| 206 |
app.include_router(engines.router)
|
|
|
|
|
|
| 9 |
from uuid import uuid4
|
| 10 |
from datetime import datetime, timedelta
|
| 11 |
|
| 12 |
+
import asyncio
|
| 13 |
+
from fastapi import FastAPI, Request, BackgroundTasks
|
| 14 |
from fastapi.responses import JSONResponse
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
|
|
|
|
| 165 |
get_gnn()
|
| 166 |
except Exception as e:
|
| 167 |
logger.warning(f"GNN loading failed: {e}")
|
| 168 |
+
|
| 169 |
+
# Start Background Shock Detector if enabled
|
| 170 |
+
if settings.DEMO_MODE:
|
| 171 |
+
# In demo mode, we still run once to get any available live data to complement scenarios
|
| 172 |
+
from ..ingestion.shock_detector import run_once
|
| 173 |
+
loop = asyncio.get_event_loop()
|
| 174 |
+
loop.run_in_executor(None, run_once)
|
| 175 |
+
logger.info("Background detector triggered once (Demo Mode).")
|
| 176 |
+
else:
|
| 177 |
+
from ..ingestion.shock_detector import run_scheduler
|
| 178 |
+
import threading
|
| 179 |
+
thread = threading.Thread(target=run_scheduler, daemon=True)
|
| 180 |
+
thread.start()
|
| 181 |
+
logger.info("Background detector thread started (Live Mode).")
|
| 182 |
|
| 183 |
logger.info("PharmaShield startup sequence complete.")
|
| 184 |
|
|
|
|
| 210 |
|
| 211 |
# Router Mounting
|
| 212 |
# All routers are now implemented and ready for production use
|
| 213 |
+
from .api import graph, drugs, alerts, query, simulate, sectors, engines, map
|
| 214 |
|
| 215 |
app.include_router(graph.router)
|
| 216 |
app.include_router(drugs.router)
|
|
|
|
| 219 |
app.include_router(simulate.router)
|
| 220 |
app.include_router(sectors.router)
|
| 221 |
app.include_router(engines.router)
|
| 222 |
+
app.include_router(map.router)
|
backend/app/models/graph.py
CHANGED
|
@@ -87,3 +87,24 @@ class QueryResponse(BaseModel):
|
|
| 87 |
suggested_drugs_to_inspect: List[str] = Field(..., description="List of drug IDs relevant to the query for further drill-down")
|
| 88 |
response_mode: str = Field(default="live", description="Indicates whether the response came from live or demo mode")
|
| 89 |
matched_scenarios: List[str] = Field(default_factory=list, description="Matched curated scenarios in demo mode")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
suggested_drugs_to_inspect: List[str] = Field(..., description="List of drug IDs relevant to the query for further drill-down")
|
| 88 |
response_mode: str = Field(default="live", description="Indicates whether the response came from live or demo mode")
|
| 89 |
matched_scenarios: List[str] = Field(default_factory=list, description="Matched curated scenarios in demo mode")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class ProvinceHeatmapPoint(BaseModel):
|
| 93 |
+
"""Geospatial point for province heatmap."""
|
| 94 |
+
id: str
|
| 95 |
+
name: str
|
| 96 |
+
latitude: float
|
| 97 |
+
longitude: float
|
| 98 |
+
risk_score: float
|
| 99 |
+
shock_count: int
|
| 100 |
+
sector: str
|
| 101 |
+
description: Optional[str] = None
|
| 102 |
+
top_affected_inputs: List[str] = Field(default_factory=list)
|
| 103 |
+
region: str = Field(default="china", description="'china' or 'india'")
|
| 104 |
+
factories: List[str] = Field(default_factory=list)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class HeatmapResponse(BaseModel):
|
| 108 |
+
"""Response for the heatmap API."""
|
| 109 |
+
points: List[ProvinceHeatmapPoint]
|
| 110 |
+
generated_at: datetime
|
backend/app/services/geocoder.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import logging
|
| 3 |
+
import requests
|
| 4 |
+
from typing import Optional, Tuple
|
| 5 |
+
from ..config import settings
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("pharmashield.geocoder")
|
| 8 |
+
|
| 9 |
+
class Geocoder:
|
| 10 |
+
"""
|
| 11 |
+
Geocoding service using Google Maps API (if key available)
|
| 12 |
+
or falling back to OpenStreetMap's Nominatim.
|
| 13 |
+
"""
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.google_key = settings.GOOGLE_MAPS_API_KEY
|
| 16 |
+
self.nominatim_url = "https://nominatim.openstreetmap.org/search"
|
| 17 |
+
self.google_url = "https://maps.googleapis.com/maps/api/geocode/json"
|
| 18 |
+
self.headers = {
|
| 19 |
+
"User-Agent": "PharmaShield-MapAnalytics/1.0"
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
def get_coordinates(self, location_name: str) -> Optional[Tuple[float, float]]:
|
| 23 |
+
"""
|
| 24 |
+
Fetches latitude and longitude for a given location name.
|
| 25 |
+
"""
|
| 26 |
+
if self.google_key:
|
| 27 |
+
return self._google_geocode(location_name)
|
| 28 |
+
return self._nominatim_geocode(location_name)
|
| 29 |
+
|
| 30 |
+
def _google_geocode(self, location_name: str) -> Optional[Tuple[float, float]]:
|
| 31 |
+
"""Geocoding via Google Maps API."""
|
| 32 |
+
try:
|
| 33 |
+
params = {
|
| 34 |
+
"address": location_name,
|
| 35 |
+
"key": self.google_key
|
| 36 |
+
}
|
| 37 |
+
logger.info(f"Google Geocoding: {location_name}")
|
| 38 |
+
response = requests.get(self.google_url, params=params, timeout=10)
|
| 39 |
+
response.raise_for_status()
|
| 40 |
+
|
| 41 |
+
data = response.json()
|
| 42 |
+
if data.get("status") == "OK" and data.get("results"):
|
| 43 |
+
loc = data["results"][0]["geometry"]["location"]
|
| 44 |
+
lat, lng = loc["lat"], loc["lng"]
|
| 45 |
+
logger.info(f"Google found coords for {location_name}: {lat}, {lng}")
|
| 46 |
+
return lat, lng
|
| 47 |
+
else:
|
| 48 |
+
logger.warning(f"Google Geocoding failed for {location_name}: {data.get('status')}")
|
| 49 |
+
return None
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Google Geocoding exception for {location_name}: {e}")
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
def _nominatim_geocode(self, location_name: str) -> Optional[Tuple[float, float]]:
|
| 55 |
+
"""Geocoding via Nominatim (with rate limiting)."""
|
| 56 |
+
try:
|
| 57 |
+
params = {
|
| 58 |
+
"q": location_name,
|
| 59 |
+
"format": "json",
|
| 60 |
+
"limit": 1
|
| 61 |
+
}
|
| 62 |
+
logger.info(f"Nominatim Geocoding: {location_name}")
|
| 63 |
+
response = requests.get(self.nominatim_url, params=params, headers=self.headers, timeout=10)
|
| 64 |
+
response.raise_for_status()
|
| 65 |
+
|
| 66 |
+
data = response.json()
|
| 67 |
+
if data and len(data) > 0:
|
| 68 |
+
lat = float(data[0]["lat"])
|
| 69 |
+
lon = float(data[0]["lon"])
|
| 70 |
+
logger.info(f"Nominatim found coords for {location_name}: {lat}, {lon}")
|
| 71 |
+
return lat, lon
|
| 72 |
+
else:
|
| 73 |
+
logger.warning(f"No coordinates found via Nominatim for {location_name}")
|
| 74 |
+
return None
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"Nominatim Geocoding failed for {location_name}: {e}")
|
| 77 |
+
return None
|
| 78 |
+
finally:
|
| 79 |
+
# Respect Nominatim's 1 request per second usage policy
|
| 80 |
+
time.sleep(1.1)
|
| 81 |
+
|
| 82 |
+
def geocode_batch(self, locations: list[str]) -> dict[str, Tuple[float, float]]:
|
| 83 |
+
"""
|
| 84 |
+
Geocodes a list of locations and returns a dictionary mapping name to coordinates.
|
| 85 |
+
"""
|
| 86 |
+
results = {}
|
| 87 |
+
for loc in locations:
|
| 88 |
+
coords = self.get_coordinates(loc)
|
| 89 |
+
if coords:
|
| 90 |
+
results[loc] = coords
|
| 91 |
+
return results
|
| 92 |
+
|
| 93 |
+
# Singleton instance
|
| 94 |
+
geocoder = Geocoder()
|
backend/app/services/graph_service.py
CHANGED
|
@@ -312,6 +312,41 @@ class GraphService:
|
|
| 312 |
n = self.graph.number_of_nodes()
|
| 313 |
return {node: 1.0 / n for node in self.graph.nodes()}
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
# ─── Combined Risk Formula ───────────────────────────────────────────
|
| 316 |
|
| 317 |
def compute_combined_risk(
|
|
|
|
| 312 |
n = self.graph.number_of_nodes()
|
| 313 |
return {node: 1.0 / n for node in self.graph.nodes()}
|
| 314 |
|
| 315 |
+
def compute_province_risk(self, province_id: str, sector: str = "pharma") -> float:
|
| 316 |
+
"""
|
| 317 |
+
Calculates the aggregated risk score for a Chinese province.
|
| 318 |
+
Risk is based on:
|
| 319 |
+
- Concentration (HHI) of the products produced in this province.
|
| 320 |
+
- Total monthly import value dependent on this province.
|
| 321 |
+
- Strategic priority of the dependent products.
|
| 322 |
+
"""
|
| 323 |
+
if not self.graph.has_node(province_id):
|
| 324 |
+
return 0.0
|
| 325 |
+
|
| 326 |
+
dependent_products = []
|
| 327 |
+
# Find APIs/Inputs that are LOCATED_IN or supplied by this province
|
| 328 |
+
# In our graph, edges are drug -> api -> province, so predecessors(province) are the APIs
|
| 329 |
+
for product_id in self.graph.predecessors(province_id):
|
| 330 |
+
node_data = self.graph.nodes.get(product_id, {})
|
| 331 |
+
if sector == "both" or node_data.get("sector") == sector:
|
| 332 |
+
dependent_products.append(product_id)
|
| 333 |
+
|
| 334 |
+
if not dependent_products:
|
| 335 |
+
return 0.0
|
| 336 |
+
|
| 337 |
+
total_risk = 0.0
|
| 338 |
+
for product_id in dependent_products:
|
| 339 |
+
node_data = self.graph.nodes.get(product_id, {})
|
| 340 |
+
import_value = node_data.get("monthly_import_value_usd_millions", 1.0)
|
| 341 |
+
china_share = node_data.get("china_share", 0.5)
|
| 342 |
+
# High import value + High China share = High Dependency Risk
|
| 343 |
+
product_risk = (import_value * china_share)
|
| 344 |
+
total_risk += product_risk
|
| 345 |
+
|
| 346 |
+
# Normalize to 0-100 scale (heuristic normalization)
|
| 347 |
+
normalized_risk = min(100.0, math.log1p(total_risk) * 15)
|
| 348 |
+
return round(normalized_risk, 2)
|
| 349 |
+
|
| 350 |
# ─── Combined Risk Formula ───────────────────────────────────────────
|
| 351 |
|
| 352 |
def compute_combined_risk(
|
backend/data/seed/apis.json
CHANGED
|
@@ -5,7 +5,9 @@
|
|
| 5 |
"china_share": 0.85,
|
| 6 |
"primary_provinces": ["Hebei", "Hubei"],
|
| 7 |
"top_3_factories": ["Hebei Jiheng Group", "Anhui Bayi Chemical", "Liaoning Shixian"],
|
| 8 |
-
"monthly_import_value_usd_millions": 45.0
|
|
|
|
|
|
|
| 9 |
},
|
| 10 |
{
|
| 11 |
"id": "6_apa",
|
|
@@ -13,7 +15,9 @@
|
|
| 13 |
"china_share": 0.90,
|
| 14 |
"primary_provinces": ["Hebei", "Jiangsu"],
|
| 15 |
"top_3_factories": ["United Laboratories", "CSPC Pharmaceutical", "North China Pharmaceutical"],
|
| 16 |
-
"monthly_import_value_usd_millions": 85.0
|
|
|
|
|
|
|
| 17 |
},
|
| 18 |
{
|
| 19 |
"id": "7_aca",
|
|
@@ -21,7 +25,9 @@
|
|
| 21 |
"china_share": 0.92,
|
| 22 |
"primary_provinces": ["Shandong", "Hebei"],
|
| 23 |
"top_3_factories": ["Shandong Lukang", "Hebei Welcome", "CSPC Ouyi"],
|
| 24 |
-
"monthly_import_value_usd_millions": 110.0
|
|
|
|
|
|
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"id": "ampicillin_trihydrate",
|
|
@@ -29,7 +35,9 @@
|
|
| 29 |
"china_share": 0.88,
|
| 30 |
"primary_provinces": ["Hebei"],
|
| 31 |
"top_3_factories": ["Shijiazhuang Pharmaceutical", "North China Pharma", "Hebei Jiheng"],
|
| 32 |
-
"monthly_import_value_usd_millions": 30.0
|
|
|
|
|
|
|
| 33 |
},
|
| 34 |
{
|
| 35 |
"id": "azithromycin_dihydrate",
|
|
@@ -37,7 +45,9 @@
|
|
| 37 |
"china_share": 0.85,
|
| 38 |
"primary_provinces": ["Jiangsu"],
|
| 39 |
"top_3_factories": ["Yangzhou Pharmaceutical", "Jiangsu Nhwa", "Wuxi AppTec"],
|
| 40 |
-
"monthly_import_value_usd_millions": 60.0
|
|
|
|
|
|
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"id": "penicillin_g_potassium",
|
|
@@ -45,7 +55,9 @@
|
|
| 45 |
"china_share": 0.95,
|
| 46 |
"primary_provinces": ["Hebei", "Hubei"],
|
| 47 |
"top_3_factories": ["CSPC Group", "North China Pharmaceutical", "Hubei Huashu"],
|
| 48 |
-
"monthly_import_value_usd_millions": 120.0
|
|
|
|
|
|
|
| 49 |
},
|
| 50 |
{
|
| 51 |
"id": "metformin_hcl",
|
|
@@ -53,7 +65,9 @@
|
|
| 53 |
"china_share": 0.78,
|
| 54 |
"primary_provinces": ["Jiangsu", "Zhejiang"],
|
| 55 |
"top_3_factories": ["Zhejiang Starry", "Jiangsu Wanbang", "Changzhou Pharmaceutical"],
|
| 56 |
-
"monthly_import_value_usd_millions": 40.0
|
|
|
|
|
|
|
| 57 |
},
|
| 58 |
{
|
| 59 |
"id": "atorvastatin_calcium",
|
|
@@ -61,7 +75,9 @@
|
|
| 61 |
"china_share": 0.82,
|
| 62 |
"primary_provinces": ["Zhejiang"],
|
| 63 |
"top_3_factories": ["Zhejiang Hisun", "Zhejiang Medicine", "Zhejiang Xianju"],
|
| 64 |
-
"monthly_import_value_usd_millions": 55.0
|
|
|
|
|
|
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"id": "amlodipine_besylate",
|
|
@@ -69,7 +85,9 @@
|
|
| 69 |
"china_share": 0.80,
|
| 70 |
"primary_provinces": ["Jiangsu"],
|
| 71 |
"top_3_factories": ["Jiangsu Hansoh", "Jiangsu Hengrui", "Chia Tai Tianqing"],
|
| 72 |
-
"monthly_import_value_usd_millions": 35.0
|
|
|
|
|
|
|
| 73 |
},
|
| 74 |
{
|
| 75 |
"id": "losartan_potassium",
|
|
@@ -77,7 +95,9 @@
|
|
| 77 |
"china_share": 0.83,
|
| 78 |
"primary_provinces": ["Zhejiang"],
|
| 79 |
"top_3_factories": ["Zhejiang Huahai", "Zhejiang Jingxin", "Zhejiang Yongning"],
|
| 80 |
-
"monthly_import_value_usd_millions": 25.0
|
|
|
|
|
|
|
| 81 |
},
|
| 82 |
{
|
| 83 |
"id": "omeprazole_api",
|
|
@@ -85,7 +105,9 @@
|
|
| 85 |
"china_share": 0.85,
|
| 86 |
"primary_provinces": ["Jiangsu"],
|
| 87 |
"top_3_factories": ["Jiangsu Yahong", "Jiangsu Aoyuan", "Changzhou Siyao"],
|
| 88 |
-
"monthly_import_value_usd_millions": 42.0
|
|
|
|
|
|
|
| 89 |
},
|
| 90 |
{
|
| 91 |
"id": "ranitidine_hcl",
|
|
@@ -93,7 +115,9 @@
|
|
| 93 |
"china_share": 0.80,
|
| 94 |
"primary_provinces": ["Hubei"],
|
| 95 |
"top_3_factories": ["Wuhan Wuyao", "Hubei Hongyuan", "Wuhan Zhongzhi"],
|
| 96 |
-
"monthly_import_value_usd_millions": 18.0
|
|
|
|
|
|
|
| 97 |
},
|
| 98 |
{
|
| 99 |
"id": "ibuprofen_api",
|
|
@@ -101,7 +125,9 @@
|
|
| 101 |
"china_share": 0.82,
|
| 102 |
"primary_provinces": ["Hebei", "Shandong"],
|
| 103 |
"top_3_factories": ["Shandong Xinhua", "Hubei Biocause", "Hebei Jiheng"],
|
| 104 |
-
"monthly_import_value_usd_millions": 70.0
|
|
|
|
|
|
|
| 105 |
},
|
| 106 |
{
|
| 107 |
"id": "diclofenac_sodium",
|
|
@@ -109,7 +135,9 @@
|
|
| 109 |
"china_share": 0.78,
|
| 110 |
"primary_provinces": ["Zhejiang"],
|
| 111 |
"top_3_factories": ["Zhejiang Anglikang", "Zhejiang Sanmen", "Zhejiang Jixing"],
|
| 112 |
-
"monthly_import_value_usd_millions": 22.0
|
|
|
|
|
|
|
| 113 |
},
|
| 114 |
{
|
| 115 |
"id": "acetylsalicylic_acid",
|
|
@@ -117,7 +145,9 @@
|
|
| 117 |
"china_share": 0.65,
|
| 118 |
"primary_provinces": ["Hubei"],
|
| 119 |
"top_3_factories": ["Wuhan Grand", "Hubei Maxun", "Wuhan Fortune"],
|
| 120 |
-
"monthly_import_value_usd_millions": 15.0
|
|
|
|
|
|
|
| 121 |
},
|
| 122 |
{
|
| 123 |
"id": "gentamicin_sulfate",
|
|
@@ -125,7 +155,9 @@
|
|
| 125 |
"china_share": 0.90,
|
| 126 |
"primary_provinces": ["Hebei"],
|
| 127 |
"top_3_factories": ["North China Pharmaceutical", "Hebei Welcome", "Shijiazhuang Pharma"],
|
| 128 |
-
"monthly_import_value_usd_millions": 12.0
|
|
|
|
|
|
|
| 129 |
},
|
| 130 |
{
|
| 131 |
"id": "fluconazole_api",
|
|
@@ -133,7 +165,9 @@
|
|
| 133 |
"china_share": 0.85,
|
| 134 |
"primary_provinces": ["Jiangsu"],
|
| 135 |
"top_3_factories": ["Jiangsu Nhwa", "Jiangsu Hengrui", "Changzhou Pharma"],
|
| 136 |
-
"monthly_import_value_usd_millions": 28.0
|
|
|
|
|
|
|
| 137 |
},
|
| 138 |
{
|
| 139 |
"id": "ciprofloxacin_hcl",
|
|
@@ -141,7 +175,9 @@
|
|
| 141 |
"china_share": 0.83,
|
| 142 |
"primary_provinces": ["Zhejiang"],
|
| 143 |
"top_3_factories": ["Zhejiang Hisun", "Zhejiang Medicine", "Zhejiang East-Asia"],
|
| 144 |
-
"monthly_import_value_usd_millions": 48.0
|
|
|
|
|
|
|
| 145 |
},
|
| 146 |
{
|
| 147 |
"id": "levofloxacin_hemihydrate",
|
|
@@ -149,7 +185,9 @@
|
|
| 149 |
"china_share": 0.85,
|
| 150 |
"primary_provinces": ["Zhejiang"],
|
| 151 |
"top_3_factories": ["Zhejiang Hisun", "Zhejiang Jingxin", "Zhejiang Starry"],
|
| 152 |
-
"monthly_import_value_usd_millions": 32.0
|
|
|
|
|
|
|
| 153 |
},
|
| 154 |
{
|
| 155 |
"id": "insulin_recombinant",
|
|
@@ -157,7 +195,9 @@
|
|
| 157 |
"china_share": 0.45,
|
| 158 |
"primary_provinces": ["Jiangsu"],
|
| 159 |
"top_3_factories": ["Jiangsu Wanbang", "Gan & Lee Pharma", "Tonghua Dongbao"],
|
| 160 |
-
"monthly_import_value_usd_millions": 90.0
|
|
|
|
|
|
|
| 161 |
},
|
| 162 |
{
|
| 163 |
"id": "salbutamol_sulfate",
|
|
@@ -165,7 +205,9 @@
|
|
| 165 |
"china_share": 0.80,
|
| 166 |
"primary_provinces": ["Hubei"],
|
| 167 |
"top_3_factories": ["Wuhan Wuyao", "Hubei Kexing", "Wuhan Fortune"],
|
| 168 |
-
"monthly_import_value_usd_millions": 14.0
|
|
|
|
|
|
|
| 169 |
},
|
| 170 |
{
|
| 171 |
"id": "clavulanic_acid",
|
|
@@ -173,7 +215,9 @@
|
|
| 173 |
"china_share": 0.88,
|
| 174 |
"primary_provinces": ["Shandong"],
|
| 175 |
"top_3_factories": ["Shandong Lukang", "CSPC Group", "United Laboratories"],
|
| 176 |
-
"monthly_import_value_usd_millions": 65.0
|
|
|
|
|
|
|
| 177 |
},
|
| 178 |
{
|
| 179 |
"id": "erythromycin_thiocyanate",
|
|
@@ -181,7 +225,9 @@
|
|
| 181 |
"china_share": 0.92,
|
| 182 |
"primary_provinces": ["Hebei"],
|
| 183 |
"top_3_factories": ["Ningxia Qiyuan", "Hebei Welcome", "CSPC Pharma"],
|
| 184 |
-
"monthly_import_value_usd_millions": 50.0
|
|
|
|
|
|
|
| 185 |
},
|
| 186 |
{
|
| 187 |
"id": "sodium_dichloroisocyanurate",
|
|
@@ -189,7 +235,9 @@
|
|
| 189 |
"china_share": 0.75,
|
| 190 |
"primary_provinces": ["Hebei"],
|
| 191 |
"top_3_factories": ["Hebei Jiheng", "Shandong Daming", "Shijiazhuang Pharma"],
|
| 192 |
-
"monthly_import_value_usd_millions": 8.0
|
|
|
|
|
|
|
| 193 |
},
|
| 194 |
{
|
| 195 |
"id": "heparin_sodium",
|
|
@@ -197,6 +245,8 @@
|
|
| 197 |
"china_share": 0.88,
|
| 198 |
"primary_provinces": ["Jiangsu"],
|
| 199 |
"top_3_factories": ["Hepalink", "Jiangsu Wanbang", "Changzhou Qianhong"],
|
| 200 |
-
"monthly_import_value_usd_millions": 180.0
|
|
|
|
|
|
|
| 201 |
}
|
| 202 |
]
|
|
|
|
| 5 |
"china_share": 0.85,
|
| 6 |
"primary_provinces": ["Hebei", "Hubei"],
|
| 7 |
"top_3_factories": ["Hebei Jiheng Group", "Anhui Bayi Chemical", "Liaoning Shixian"],
|
| 8 |
+
"monthly_import_value_usd_millions": 45.0,
|
| 9 |
+
"latitude": 38.0428,
|
| 10 |
+
"longitude": 114.5149
|
| 11 |
},
|
| 12 |
{
|
| 13 |
"id": "6_apa",
|
|
|
|
| 15 |
"china_share": 0.90,
|
| 16 |
"primary_provinces": ["Hebei", "Jiangsu"],
|
| 17 |
"top_3_factories": ["United Laboratories", "CSPC Pharmaceutical", "North China Pharmaceutical"],
|
| 18 |
+
"monthly_import_value_usd_millions": 85.0,
|
| 19 |
+
"latitude": 32.0603,
|
| 20 |
+
"longitude": 118.7969
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"id": "7_aca",
|
|
|
|
| 25 |
"china_share": 0.92,
|
| 26 |
"primary_provinces": ["Shandong", "Hebei"],
|
| 27 |
"top_3_factories": ["Shandong Lukang", "Hebei Welcome", "CSPC Ouyi"],
|
| 28 |
+
"monthly_import_value_usd_millions": 110.0,
|
| 29 |
+
"latitude": 36.6683,
|
| 30 |
+
"longitude": 117.0206
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"id": "ampicillin_trihydrate",
|
|
|
|
| 35 |
"china_share": 0.88,
|
| 36 |
"primary_provinces": ["Hebei"],
|
| 37 |
"top_3_factories": ["Shijiazhuang Pharmaceutical", "North China Pharma", "Hebei Jiheng"],
|
| 38 |
+
"monthly_import_value_usd_millions": 30.0,
|
| 39 |
+
"latitude": 38.0428,
|
| 40 |
+
"longitude": 114.5149
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"id": "azithromycin_dihydrate",
|
|
|
|
| 45 |
"china_share": 0.85,
|
| 46 |
"primary_provinces": ["Jiangsu"],
|
| 47 |
"top_3_factories": ["Yangzhou Pharmaceutical", "Jiangsu Nhwa", "Wuxi AppTec"],
|
| 48 |
+
"monthly_import_value_usd_millions": 60.0,
|
| 49 |
+
"latitude": 32.0603,
|
| 50 |
+
"longitude": 118.7969
|
| 51 |
},
|
| 52 |
{
|
| 53 |
"id": "penicillin_g_potassium",
|
|
|
|
| 55 |
"china_share": 0.95,
|
| 56 |
"primary_provinces": ["Hebei", "Hubei"],
|
| 57 |
"top_3_factories": ["CSPC Group", "North China Pharmaceutical", "Hubei Huashu"],
|
| 58 |
+
"monthly_import_value_usd_millions": 120.0,
|
| 59 |
+
"latitude": 30.5833,
|
| 60 |
+
"longitude": 114.3054
|
| 61 |
},
|
| 62 |
{
|
| 63 |
"id": "metformin_hcl",
|
|
|
|
| 65 |
"china_share": 0.78,
|
| 66 |
"primary_provinces": ["Jiangsu", "Zhejiang"],
|
| 67 |
"top_3_factories": ["Zhejiang Starry", "Jiangsu Wanbang", "Changzhou Pharmaceutical"],
|
| 68 |
+
"monthly_import_value_usd_millions": 40.0,
|
| 69 |
+
"latitude": 32.0603,
|
| 70 |
+
"longitude": 118.7969
|
| 71 |
},
|
| 72 |
{
|
| 73 |
"id": "atorvastatin_calcium",
|
|
|
|
| 75 |
"china_share": 0.82,
|
| 76 |
"primary_provinces": ["Zhejiang"],
|
| 77 |
"top_3_factories": ["Zhejiang Hisun", "Zhejiang Medicine", "Zhejiang Xianju"],
|
| 78 |
+
"monthly_import_value_usd_millions": 55.0,
|
| 79 |
+
"latitude": 30.2741,
|
| 80 |
+
"longitude": 120.1551
|
| 81 |
},
|
| 82 |
{
|
| 83 |
"id": "amlodipine_besylate",
|
|
|
|
| 85 |
"china_share": 0.80,
|
| 86 |
"primary_provinces": ["Jiangsu"],
|
| 87 |
"top_3_factories": ["Jiangsu Hansoh", "Jiangsu Hengrui", "Chia Tai Tianqing"],
|
| 88 |
+
"monthly_import_value_usd_millions": 35.0,
|
| 89 |
+
"latitude": 32.0603,
|
| 90 |
+
"longitude": 118.7969
|
| 91 |
},
|
| 92 |
{
|
| 93 |
"id": "losartan_potassium",
|
|
|
|
| 95 |
"china_share": 0.83,
|
| 96 |
"primary_provinces": ["Zhejiang"],
|
| 97 |
"top_3_factories": ["Zhejiang Huahai", "Zhejiang Jingxin", "Zhejiang Yongning"],
|
| 98 |
+
"monthly_import_value_usd_millions": 25.0,
|
| 99 |
+
"latitude": 30.2741,
|
| 100 |
+
"longitude": 120.1551
|
| 101 |
},
|
| 102 |
{
|
| 103 |
"id": "omeprazole_api",
|
|
|
|
| 105 |
"china_share": 0.85,
|
| 106 |
"primary_provinces": ["Jiangsu"],
|
| 107 |
"top_3_factories": ["Jiangsu Yahong", "Jiangsu Aoyuan", "Changzhou Siyao"],
|
| 108 |
+
"monthly_import_value_usd_millions": 42.0,
|
| 109 |
+
"latitude": 32.0603,
|
| 110 |
+
"longitude": 118.7969
|
| 111 |
},
|
| 112 |
{
|
| 113 |
"id": "ranitidine_hcl",
|
|
|
|
| 115 |
"china_share": 0.80,
|
| 116 |
"primary_provinces": ["Hubei"],
|
| 117 |
"top_3_factories": ["Wuhan Wuyao", "Hubei Hongyuan", "Wuhan Zhongzhi"],
|
| 118 |
+
"monthly_import_value_usd_millions": 18.0,
|
| 119 |
+
"latitude": 30.5833,
|
| 120 |
+
"longitude": 114.3054
|
| 121 |
},
|
| 122 |
{
|
| 123 |
"id": "ibuprofen_api",
|
|
|
|
| 125 |
"china_share": 0.82,
|
| 126 |
"primary_provinces": ["Hebei", "Shandong"],
|
| 127 |
"top_3_factories": ["Shandong Xinhua", "Hubei Biocause", "Hebei Jiheng"],
|
| 128 |
+
"monthly_import_value_usd_millions": 70.0,
|
| 129 |
+
"latitude": 38.0428,
|
| 130 |
+
"longitude": 114.5149
|
| 131 |
},
|
| 132 |
{
|
| 133 |
"id": "diclofenac_sodium",
|
|
|
|
| 135 |
"china_share": 0.78,
|
| 136 |
"primary_provinces": ["Zhejiang"],
|
| 137 |
"top_3_factories": ["Zhejiang Anglikang", "Zhejiang Sanmen", "Zhejiang Jixing"],
|
| 138 |
+
"monthly_import_value_usd_millions": 22.0,
|
| 139 |
+
"latitude": 30.2741,
|
| 140 |
+
"longitude": 120.1551
|
| 141 |
},
|
| 142 |
{
|
| 143 |
"id": "acetylsalicylic_acid",
|
|
|
|
| 145 |
"china_share": 0.65,
|
| 146 |
"primary_provinces": ["Hubei"],
|
| 147 |
"top_3_factories": ["Wuhan Grand", "Hubei Maxun", "Wuhan Fortune"],
|
| 148 |
+
"monthly_import_value_usd_millions": 15.0,
|
| 149 |
+
"latitude": 30.5833,
|
| 150 |
+
"longitude": 114.3054
|
| 151 |
},
|
| 152 |
{
|
| 153 |
"id": "gentamicin_sulfate",
|
|
|
|
| 155 |
"china_share": 0.90,
|
| 156 |
"primary_provinces": ["Hebei"],
|
| 157 |
"top_3_factories": ["North China Pharmaceutical", "Hebei Welcome", "Shijiazhuang Pharma"],
|
| 158 |
+
"monthly_import_value_usd_millions": 12.0,
|
| 159 |
+
"latitude": 38.0428,
|
| 160 |
+
"longitude": 114.5149
|
| 161 |
},
|
| 162 |
{
|
| 163 |
"id": "fluconazole_api",
|
|
|
|
| 165 |
"china_share": 0.85,
|
| 166 |
"primary_provinces": ["Jiangsu"],
|
| 167 |
"top_3_factories": ["Jiangsu Nhwa", "Jiangsu Hengrui", "Changzhou Pharma"],
|
| 168 |
+
"monthly_import_value_usd_millions": 28.0,
|
| 169 |
+
"latitude": 32.0603,
|
| 170 |
+
"longitude": 118.7969
|
| 171 |
},
|
| 172 |
{
|
| 173 |
"id": "ciprofloxacin_hcl",
|
|
|
|
| 175 |
"china_share": 0.83,
|
| 176 |
"primary_provinces": ["Zhejiang"],
|
| 177 |
"top_3_factories": ["Zhejiang Hisun", "Zhejiang Medicine", "Zhejiang East-Asia"],
|
| 178 |
+
"monthly_import_value_usd_millions": 48.0,
|
| 179 |
+
"latitude": 30.2741,
|
| 180 |
+
"longitude": 120.1551
|
| 181 |
},
|
| 182 |
{
|
| 183 |
"id": "levofloxacin_hemihydrate",
|
|
|
|
| 185 |
"china_share": 0.85,
|
| 186 |
"primary_provinces": ["Zhejiang"],
|
| 187 |
"top_3_factories": ["Zhejiang Hisun", "Zhejiang Jingxin", "Zhejiang Starry"],
|
| 188 |
+
"monthly_import_value_usd_millions": 32.0,
|
| 189 |
+
"latitude": 30.2741,
|
| 190 |
+
"longitude": 120.1551
|
| 191 |
},
|
| 192 |
{
|
| 193 |
"id": "insulin_recombinant",
|
|
|
|
| 195 |
"china_share": 0.45,
|
| 196 |
"primary_provinces": ["Jiangsu"],
|
| 197 |
"top_3_factories": ["Jiangsu Wanbang", "Gan & Lee Pharma", "Tonghua Dongbao"],
|
| 198 |
+
"monthly_import_value_usd_millions": 90.0,
|
| 199 |
+
"latitude": 32.0603,
|
| 200 |
+
"longitude": 118.7969
|
| 201 |
},
|
| 202 |
{
|
| 203 |
"id": "salbutamol_sulfate",
|
|
|
|
| 205 |
"china_share": 0.80,
|
| 206 |
"primary_provinces": ["Hubei"],
|
| 207 |
"top_3_factories": ["Wuhan Wuyao", "Hubei Kexing", "Wuhan Fortune"],
|
| 208 |
+
"monthly_import_value_usd_millions": 14.0,
|
| 209 |
+
"latitude": 30.5833,
|
| 210 |
+
"longitude": 114.3054
|
| 211 |
},
|
| 212 |
{
|
| 213 |
"id": "clavulanic_acid",
|
|
|
|
| 215 |
"china_share": 0.88,
|
| 216 |
"primary_provinces": ["Shandong"],
|
| 217 |
"top_3_factories": ["Shandong Lukang", "CSPC Group", "United Laboratories"],
|
| 218 |
+
"monthly_import_value_usd_millions": 65.0,
|
| 219 |
+
"latitude": 36.6683,
|
| 220 |
+
"longitude": 117.0206
|
| 221 |
},
|
| 222 |
{
|
| 223 |
"id": "erythromycin_thiocyanate",
|
|
|
|
| 225 |
"china_share": 0.92,
|
| 226 |
"primary_provinces": ["Hebei"],
|
| 227 |
"top_3_factories": ["Ningxia Qiyuan", "Hebei Welcome", "CSPC Pharma"],
|
| 228 |
+
"monthly_import_value_usd_millions": 50.0,
|
| 229 |
+
"latitude": 38.0428,
|
| 230 |
+
"longitude": 114.5149
|
| 231 |
},
|
| 232 |
{
|
| 233 |
"id": "sodium_dichloroisocyanurate",
|
|
|
|
| 235 |
"china_share": 0.75,
|
| 236 |
"primary_provinces": ["Hebei"],
|
| 237 |
"top_3_factories": ["Hebei Jiheng", "Shandong Daming", "Shijiazhuang Pharma"],
|
| 238 |
+
"monthly_import_value_usd_millions": 8.0,
|
| 239 |
+
"latitude": 38.0428,
|
| 240 |
+
"longitude": 114.5149
|
| 241 |
},
|
| 242 |
{
|
| 243 |
"id": "heparin_sodium",
|
|
|
|
| 245 |
"china_share": 0.88,
|
| 246 |
"primary_provinces": ["Jiangsu"],
|
| 247 |
"top_3_factories": ["Hepalink", "Jiangsu Wanbang", "Changzhou Qianhong"],
|
| 248 |
+
"monthly_import_value_usd_millions": 180.0,
|
| 249 |
+
"latitude": 32.0603,
|
| 250 |
+
"longitude": 118.7969
|
| 251 |
}
|
| 252 |
]
|
backend/data/seed/china_provinces.json
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "hebei",
|
| 4 |
+
"name": "Hebei",
|
| 5 |
+
"latitude": 38.0428,
|
| 6 |
+
"longitude": 114.5149,
|
| 7 |
+
"description": "Major pharma API hub — penicillin, paracetamol, cephalosporin precursors. Heavy winter production curtailments.",
|
| 8 |
+
"factories": ["Hebei Jiheng Group", "North China Pharmaceutical", "Hebei Welcome"],
|
| 9 |
+
"sectors": ["pharma"],
|
| 10 |
+
"annual_api_output_tons": 85000
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": "jiangsu",
|
| 14 |
+
"name": "Jiangsu",
|
| 15 |
+
"latitude": 32.0603,
|
| 16 |
+
"longitude": 118.7969,
|
| 17 |
+
"description": "High-tech pharma manufacturing + rare earth separation. Yangtze River Delta export corridor.",
|
| 18 |
+
"factories": ["Jiangsu Nhwa", "Jiangsu Wanbang", "Yangzhou Pharmaceutical"],
|
| 19 |
+
"sectors": ["pharma", "rare_earth"],
|
| 20 |
+
"annual_api_output_tons": 120000
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "zhejiang",
|
| 24 |
+
"name": "Zhejiang",
|
| 25 |
+
"latitude": 30.2741,
|
| 26 |
+
"longitude": 120.1551,
|
| 27 |
+
"description": "Export-oriented pharma zone — fluoroquinolones, statins, and chemical synthesis hub.",
|
| 28 |
+
"factories": ["Zhejiang Hisun", "Zhejiang Medicine", "Zhejiang Starry"],
|
| 29 |
+
"sectors": ["pharma"],
|
| 30 |
+
"annual_api_output_tons": 95000
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "shandong",
|
| 34 |
+
"name": "Shandong",
|
| 35 |
+
"latitude": 36.6683,
|
| 36 |
+
"longitude": 117.0206,
|
| 37 |
+
"description": "Major producer of antibiotics, vitamins, and intermediate chemicals.",
|
| 38 |
+
"factories": ["Shandong Lukang", "Shandong Xinhua", "Shandong Daming"],
|
| 39 |
+
"sectors": ["pharma"],
|
| 40 |
+
"annual_api_output_tons": 72000
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "hubei",
|
| 44 |
+
"name": "Hubei",
|
| 45 |
+
"latitude": 30.5833,
|
| 46 |
+
"longitude": 114.3054,
|
| 47 |
+
"description": "Central logistics hub — penicillin G, salbutamol, and ranitidine manufacturing. COVID epicenter experience.",
|
| 48 |
+
"factories": ["Wuhan Wuyao", "Hubei Hongyuan", "Wuhan Grand"],
|
| 49 |
+
"sectors": ["pharma"],
|
| 50 |
+
"annual_api_output_tons": 45000
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "inner_mongolia",
|
| 54 |
+
"name": "Inner Mongolia",
|
| 55 |
+
"latitude": 40.8175,
|
| 56 |
+
"longitude": 111.7652,
|
| 57 |
+
"description": "World's largest rare earth mining and primary processing region — 60% of global REE output.",
|
| 58 |
+
"factories": ["China Northern Rare Earth", "Baotou Steel Rare Earth"],
|
| 59 |
+
"sectors": ["rare_earth"],
|
| 60 |
+
"annual_api_output_tons": 0
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "sichuan",
|
| 64 |
+
"name": "Sichuan",
|
| 65 |
+
"latitude": 30.5728,
|
| 66 |
+
"longitude": 104.0668,
|
| 67 |
+
"description": "Significant rare earth mining + lithium processing hub. Earthquake and flood risk zone.",
|
| 68 |
+
"factories": ["Shenghe Resources", "Sichuan Rare Earth"],
|
| 69 |
+
"sectors": ["rare_earth"],
|
| 70 |
+
"annual_api_output_tons": 0
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "guangdong",
|
| 74 |
+
"name": "Guangdong",
|
| 75 |
+
"latitude": 23.1291,
|
| 76 |
+
"longitude": 113.2644,
|
| 77 |
+
"description": "Major export port — rare earth magnets, electronics, and high-value pharma intermediates.",
|
| 78 |
+
"factories": ["China Southern Rare Earth", "Guangdong Rising Assets"],
|
| 79 |
+
"sectors": ["rare_earth", "pharma"],
|
| 80 |
+
"annual_api_output_tons": 28000
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "jiangxi",
|
| 84 |
+
"name": "Jiangxi",
|
| 85 |
+
"latitude": 28.6759,
|
| 86 |
+
"longitude": 115.8921,
|
| 87 |
+
"description": "Heavy rare earth mining center — dysprosium, europium, yttrium extraction.",
|
| 88 |
+
"factories": ["Jiangxi Rare Earth", "Ganzhou Rare Earth"],
|
| 89 |
+
"sectors": ["rare_earth"],
|
| 90 |
+
"annual_api_output_tons": 0
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"id": "fujian",
|
| 94 |
+
"name": "Fujian",
|
| 95 |
+
"latitude": 26.0789,
|
| 96 |
+
"longitude": 119.2960,
|
| 97 |
+
"description": "Xiamen tungsten + rare earth processing. Key maritime export corridor to India.",
|
| 98 |
+
"factories": ["Xiamen Tungsten", "Fujian Changting Golden Dragon"],
|
| 99 |
+
"sectors": ["rare_earth"],
|
| 100 |
+
"annual_api_output_tons": 0
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"id": "hunan",
|
| 104 |
+
"name": "Hunan",
|
| 105 |
+
"latitude": 28.2180,
|
| 106 |
+
"longitude": 112.9388,
|
| 107 |
+
"description": "Yttrium and heavy rare earth mining region — growing separation capacity.",
|
| 108 |
+
"factories": ["Hunan RE", "Chenzhou Rare Earth"],
|
| 109 |
+
"sectors": ["rare_earth"],
|
| 110 |
+
"annual_api_output_tons": 0
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"id": "liaoning",
|
| 114 |
+
"name": "Liaoning",
|
| 115 |
+
"latitude": 41.8358,
|
| 116 |
+
"longitude": 123.4287,
|
| 117 |
+
"description": "Northeast pharma corridor — chemical synthesis and veterinary API production.",
|
| 118 |
+
"factories": ["Liaoning Shixian", "Northeast Pharmaceutical Group"],
|
| 119 |
+
"sectors": ["pharma"],
|
| 120 |
+
"annual_api_output_tons": 32000
|
| 121 |
+
}
|
| 122 |
+
]
|
backend/data/seed/india_states.json
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "MH",
|
| 4 |
+
"name": "Maharashtra",
|
| 5 |
+
"latitude": 19.7515,
|
| 6 |
+
"longitude": 75.7139,
|
| 7 |
+
"top_drugs": ["paracetamol", "amoxicillin", "metformin", "atorvastatin"],
|
| 8 |
+
"pharma_hub": true,
|
| 9 |
+
"population_millions": 126,
|
| 10 |
+
"description": "India's largest pharma manufacturing cluster — Mumbai, Pune, Aurangabad corridors."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": "DL",
|
| 14 |
+
"name": "Delhi",
|
| 15 |
+
"latitude": 28.7041,
|
| 16 |
+
"longitude": 77.1025,
|
| 17 |
+
"top_drugs": ["azithromycin", "ceftriaxone", "amlodipine"],
|
| 18 |
+
"pharma_hub": false,
|
| 19 |
+
"population_millions": 32,
|
| 20 |
+
"description": "National capital region — major drug distribution and regulatory hub."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "KA",
|
| 24 |
+
"name": "Karnataka",
|
| 25 |
+
"latitude": 15.3173,
|
| 26 |
+
"longitude": 75.7139,
|
| 27 |
+
"top_drugs": ["losartan", "omeprazole", "ranitidine", "ibuprofen"],
|
| 28 |
+
"pharma_hub": true,
|
| 29 |
+
"population_millions": 68,
|
| 30 |
+
"description": "Bangalore biotech corridor — key biologics and API formulation cluster."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "TN",
|
| 34 |
+
"name": "Tamil Nadu",
|
| 35 |
+
"latitude": 11.1271,
|
| 36 |
+
"longitude": 78.6569,
|
| 37 |
+
"top_drugs": ["diclofenac", "aspirin", "penicillin_g"],
|
| 38 |
+
"pharma_hub": true,
|
| 39 |
+
"population_millions": 77,
|
| 40 |
+
"description": "Chennai pharma corridor — antibiotic and NSAID manufacturing."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "WB",
|
| 44 |
+
"name": "West Bengal",
|
| 45 |
+
"latitude": 22.9868,
|
| 46 |
+
"longitude": 87.8550,
|
| 47 |
+
"top_drugs": ["gentamicin", "fluconazole", "ciprofloxacin"],
|
| 48 |
+
"pharma_hub": false,
|
| 49 |
+
"population_millions": 99,
|
| 50 |
+
"description": "Eastern distribution hub — critical for northeast India supply chains."
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "GJ",
|
| 54 |
+
"name": "Gujarat",
|
| 55 |
+
"latitude": 22.2587,
|
| 56 |
+
"longitude": 71.1924,
|
| 57 |
+
"top_drugs": ["paracetamol", "metformin", "amoxicillin", "atorvastatin", "ciprofloxacin"],
|
| 58 |
+
"pharma_hub": true,
|
| 59 |
+
"population_millions": 64,
|
| 60 |
+
"description": "Ahmedabad–Vadodara pharma belt — India's largest API and formulation export hub."
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "TS",
|
| 64 |
+
"name": "Telangana",
|
| 65 |
+
"latitude": 18.1124,
|
| 66 |
+
"longitude": 79.0193,
|
| 67 |
+
"top_drugs": ["metformin", "losartan", "atorvastatin", "omeprazole"],
|
| 68 |
+
"pharma_hub": true,
|
| 69 |
+
"population_millions": 38,
|
| 70 |
+
"description": "Hyderabad Genome Valley — biotech R&D and bulk drug manufacturing capital."
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "AP",
|
| 74 |
+
"name": "Andhra Pradesh",
|
| 75 |
+
"latitude": 15.9129,
|
| 76 |
+
"longitude": 79.7400,
|
| 77 |
+
"top_drugs": ["ibuprofen", "diclofenac", "ceftriaxone"],
|
| 78 |
+
"pharma_hub": true,
|
| 79 |
+
"population_millions": 53,
|
| 80 |
+
"description": "Visakhapatnam pharma SEZ — major bulk drug and intermediate manufacturing."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "UP",
|
| 84 |
+
"name": "Uttar Pradesh",
|
| 85 |
+
"latitude": 26.8467,
|
| 86 |
+
"longitude": 80.9462,
|
| 87 |
+
"top_drugs": ["paracetamol", "amoxicillin", "azithromycin", "metformin"],
|
| 88 |
+
"pharma_hub": false,
|
| 89 |
+
"population_millions": 231,
|
| 90 |
+
"description": "India's most populous state — highest absolute drug consumption volume."
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"id": "RJ",
|
| 94 |
+
"name": "Rajasthan",
|
| 95 |
+
"latitude": 27.0238,
|
| 96 |
+
"longitude": 74.2179,
|
| 97 |
+
"top_drugs": ["paracetamol", "amlodipine", "metformin"],
|
| 98 |
+
"pharma_hub": false,
|
| 99 |
+
"population_millions": 79,
|
| 100 |
+
"description": "Jaipur pharma cluster — growing formulation manufacturing base."
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"id": "MP",
|
| 104 |
+
"name": "Madhya Pradesh",
|
| 105 |
+
"latitude": 22.9734,
|
| 106 |
+
"longitude": 78.6569,
|
| 107 |
+
"top_drugs": ["amoxicillin", "paracetamol", "ibuprofen"],
|
| 108 |
+
"pharma_hub": false,
|
| 109 |
+
"population_millions": 85,
|
| 110 |
+
"description": "Central India distribution node — Indore pharmaceutical hub emerging."
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"id": "KL",
|
| 114 |
+
"name": "Kerala",
|
| 115 |
+
"latitude": 10.8505,
|
| 116 |
+
"longitude": 76.2711,
|
| 117 |
+
"top_drugs": ["metformin", "atorvastatin", "amlodipine", "losartan"],
|
| 118 |
+
"pharma_hub": false,
|
| 119 |
+
"population_millions": 35,
|
| 120 |
+
"description": "Highest per-capita drug consumption — critical chronic disease medication demand."
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": "PB",
|
| 124 |
+
"name": "Punjab",
|
| 125 |
+
"latitude": 31.1471,
|
| 126 |
+
"longitude": 75.3412,
|
| 127 |
+
"top_drugs": ["paracetamol", "amoxicillin", "ciprofloxacin"],
|
| 128 |
+
"pharma_hub": true,
|
| 129 |
+
"population_millions": 31,
|
| 130 |
+
"description": "Baddi-Nalagarh pharma corridor (Himachal border) — major formulation manufacturing."
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"id": "HP",
|
| 134 |
+
"name": "Himachal Pradesh",
|
| 135 |
+
"latitude": 31.1048,
|
| 136 |
+
"longitude": 77.1734,
|
| 137 |
+
"top_drugs": ["paracetamol", "amoxicillin", "azithromycin", "ibuprofen"],
|
| 138 |
+
"pharma_hub": true,
|
| 139 |
+
"population_millions": 7,
|
| 140 |
+
"description": "Baddi pharma cluster — one of India's top 3 pharmaceutical manufacturing zones."
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"id": "GA",
|
| 144 |
+
"name": "Goa",
|
| 145 |
+
"latitude": 15.2993,
|
| 146 |
+
"longitude": 74.1240,
|
| 147 |
+
"top_drugs": ["diclofenac", "omeprazole", "fluconazole"],
|
| 148 |
+
"pharma_hub": true,
|
| 149 |
+
"population_millions": 2,
|
| 150 |
+
"description": "Verna industrial estate — compact high-value pharma manufacturing hub."
|
| 151 |
+
}
|
| 152 |
+
]
|
backend/data/seed/rare_earths.json
CHANGED
|
@@ -11,7 +11,9 @@
|
|
| 11 |
"end_use_sectors": ["EV motors", "wind turbines", "defence electronics"],
|
| 12 |
"india_stockpile_days": 12,
|
| 13 |
"criticality": 0.91,
|
| 14 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 15 |
},
|
| 16 |
{
|
| 17 |
"id": "dysprosium",
|
|
@@ -25,7 +27,9 @@
|
|
| 25 |
"end_use_sectors": ["NdFeB magnets", "EV traction motors", "hard drives"],
|
| 26 |
"india_stockpile_days": 8,
|
| 27 |
"criticality": 0.95,
|
| 28 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"id": "terbium",
|
|
@@ -39,7 +43,9 @@
|
|
| 39 |
"end_use_sectors": ["solid-state drives", "sonar", "EV motors"],
|
| 40 |
"india_stockpile_days": 5,
|
| 41 |
"criticality": 0.97,
|
| 42 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 43 |
},
|
| 44 |
{
|
| 45 |
"id": "lanthanum",
|
|
@@ -48,12 +54,14 @@
|
|
| 48 |
"type": "input",
|
| 49 |
"china_share": 0.85,
|
| 50 |
"primary_provinces": ["Inner Mongolia", "Sichuan"],
|
| 51 |
-
"top_3_producers": ["China Northern Rare Earth", "
|
| 52 |
-
"monthly_import_value_usd_millions":
|
| 53 |
-
"end_use_sectors": ["
|
| 54 |
-
"india_stockpile_days":
|
| 55 |
-
"criticality": 0.
|
| 56 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 57 |
},
|
| 58 |
{
|
| 59 |
"id": "cerium",
|
|
@@ -67,7 +75,9 @@
|
|
| 67 |
"end_use_sectors": ["auto catalytic converters", "glass polishing", "LED phosphors"],
|
| 68 |
"india_stockpile_days": 45,
|
| 69 |
"criticality": 0.65,
|
| 70 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 71 |
},
|
| 72 |
{
|
| 73 |
"id": "praseodymium",
|
|
@@ -81,7 +91,9 @@
|
|
| 81 |
"end_use_sectors": ["NdPr alloy for EV magnets", "aircraft engines", "wind generators"],
|
| 82 |
"india_stockpile_days": 10,
|
| 83 |
"criticality": 0.88,
|
| 84 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 85 |
},
|
| 86 |
{
|
| 87 |
"id": "europium",
|
|
@@ -95,7 +107,9 @@
|
|
| 95 |
"end_use_sectors": ["LED/LCD displays", "fluorescent lamps", "euro banknote security"],
|
| 96 |
"india_stockpile_days": 20,
|
| 97 |
"criticality": 0.81,
|
| 98 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 99 |
},
|
| 100 |
{
|
| 101 |
"id": "yttrium",
|
|
@@ -109,6 +123,8 @@
|
|
| 109 |
"end_use_sectors": ["OLED displays", "superconductors", "medical imaging"],
|
| 110 |
"india_stockpile_days": 18,
|
| 111 |
"criticality": 0.78,
|
| 112 |
-
"hs_code": "280530"
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
]
|
|
|
|
| 11 |
"end_use_sectors": ["EV motors", "wind turbines", "defence electronics"],
|
| 12 |
"india_stockpile_days": 12,
|
| 13 |
"criticality": 0.91,
|
| 14 |
+
"hs_code": "280530",
|
| 15 |
+
"latitude": 32.0603,
|
| 16 |
+
"longitude": 118.7969
|
| 17 |
},
|
| 18 |
{
|
| 19 |
"id": "dysprosium",
|
|
|
|
| 27 |
"end_use_sectors": ["NdFeB magnets", "EV traction motors", "hard drives"],
|
| 28 |
"india_stockpile_days": 8,
|
| 29 |
"criticality": 0.95,
|
| 30 |
+
"hs_code": "280530",
|
| 31 |
+
"latitude": 23.3790,
|
| 32 |
+
"longitude": 113.7633
|
| 33 |
},
|
| 34 |
{
|
| 35 |
"id": "terbium",
|
|
|
|
| 43 |
"end_use_sectors": ["solid-state drives", "sonar", "EV motors"],
|
| 44 |
"india_stockpile_days": 5,
|
| 45 |
"criticality": 0.97,
|
| 46 |
+
"hs_code": "280530",
|
| 47 |
+
"latitude": 23.3790,
|
| 48 |
+
"longitude": 113.7633
|
| 49 |
},
|
| 50 |
{
|
| 51 |
"id": "lanthanum",
|
|
|
|
| 54 |
"type": "input",
|
| 55 |
"china_share": 0.85,
|
| 56 |
"primary_provinces": ["Inner Mongolia", "Sichuan"],
|
| 57 |
+
"top_3_producers": ["China Northern Rare Earth", "Shenghe Resources", "China Minmetals"],
|
| 58 |
+
"monthly_import_value_usd_millions": 60.0,
|
| 59 |
+
"end_use_sectors": ["catalysts", "optics", "batteries"],
|
| 60 |
+
"india_stockpile_days": 15,
|
| 61 |
+
"criticality": 0.88,
|
| 62 |
+
"hs_code": "280530",
|
| 63 |
+
"latitude": 40.8175,
|
| 64 |
+
"longitude": 111.7652
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"id": "cerium",
|
|
|
|
| 75 |
"end_use_sectors": ["auto catalytic converters", "glass polishing", "LED phosphors"],
|
| 76 |
"india_stockpile_days": 45,
|
| 77 |
"criticality": 0.65,
|
| 78 |
+
"hs_code": "280530",
|
| 79 |
+
"latitude": 40.8175,
|
| 80 |
+
"longitude": 111.7652
|
| 81 |
},
|
| 82 |
{
|
| 83 |
"id": "praseodymium",
|
|
|
|
| 91 |
"end_use_sectors": ["NdPr alloy for EV magnets", "aircraft engines", "wind generators"],
|
| 92 |
"india_stockpile_days": 10,
|
| 93 |
"criticality": 0.88,
|
| 94 |
+
"hs_code": "280530",
|
| 95 |
+
"latitude": 40.8175,
|
| 96 |
+
"longitude": 111.7652
|
| 97 |
},
|
| 98 |
{
|
| 99 |
"id": "europium",
|
|
|
|
| 107 |
"end_use_sectors": ["LED/LCD displays", "fluorescent lamps", "euro banknote security"],
|
| 108 |
"india_stockpile_days": 20,
|
| 109 |
"criticality": 0.81,
|
| 110 |
+
"hs_code": "280530",
|
| 111 |
+
"latitude": 23.1291,
|
| 112 |
+
"longitude": 113.2644
|
| 113 |
},
|
| 114 |
{
|
| 115 |
"id": "yttrium",
|
|
|
|
| 123 |
"end_use_sectors": ["OLED displays", "superconductors", "medical imaging"],
|
| 124 |
"india_stockpile_days": 18,
|
| 125 |
"criticality": 0.78,
|
| 126 |
+
"hs_code": "280530",
|
| 127 |
+
"latitude": 23.1291,
|
| 128 |
+
"longitude": 113.2644
|
| 129 |
}
|
| 130 |
]
|
backend/tests/test_integration.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import pytest
|
| 2 |
import os
|
| 3 |
-
from httpx import AsyncClient
|
| 4 |
from app.main import app
|
| 5 |
|
| 6 |
# Configuration for test environment
|
|
@@ -9,7 +9,8 @@ GEMINI_KEY_PRESENT = bool(os.getenv("GEMINI_API_KEY"))
|
|
| 9 |
|
| 10 |
@pytest.fixture
|
| 11 |
async def client():
|
| 12 |
-
|
|
|
|
| 13 |
yield ac
|
| 14 |
|
| 15 |
@pytest.mark.asyncio
|
|
|
|
| 1 |
import pytest
|
| 2 |
import os
|
| 3 |
+
from httpx import AsyncClient, ASGITransport
|
| 4 |
from app.main import app
|
| 5 |
|
| 6 |
# Configuration for test environment
|
|
|
|
| 9 |
|
| 10 |
@pytest.fixture
|
| 11 |
async def client():
|
| 12 |
+
transport = ASGITransport(app=app)
|
| 13 |
+
async with AsyncClient(transport=transport, base_url=BASE_URL) as ac:
|
| 14 |
yield ac
|
| 15 |
|
| 16 |
@pytest.mark.asyncio
|
data/seed/china_provinces.json
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "hebei",
|
| 4 |
+
"name": "Hebei",
|
| 5 |
+
"latitude": 38.0428,
|
| 6 |
+
"longitude": 114.5149,
|
| 7 |
+
"description": "Major pharma API hub — penicillin, paracetamol, cephalosporin precursors. Heavy winter production curtailments.",
|
| 8 |
+
"factories": ["Hebei Jiheng Group", "North China Pharmaceutical", "Hebei Welcome"],
|
| 9 |
+
"sectors": ["pharma"],
|
| 10 |
+
"annual_api_output_tons": 85000
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": "jiangsu",
|
| 14 |
+
"name": "Jiangsu",
|
| 15 |
+
"latitude": 32.0603,
|
| 16 |
+
"longitude": 118.7969,
|
| 17 |
+
"description": "High-tech pharma manufacturing + rare earth separation. Yangtze River Delta export corridor.",
|
| 18 |
+
"factories": ["Jiangsu Nhwa", "Jiangsu Wanbang", "Yangzhou Pharmaceutical"],
|
| 19 |
+
"sectors": ["pharma", "rare_earth"],
|
| 20 |
+
"annual_api_output_tons": 120000
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "zhejiang",
|
| 24 |
+
"name": "Zhejiang",
|
| 25 |
+
"latitude": 30.2741,
|
| 26 |
+
"longitude": 120.1551,
|
| 27 |
+
"description": "Export-oriented pharma zone — fluoroquinolones, statins, and chemical synthesis hub.",
|
| 28 |
+
"factories": ["Zhejiang Hisun", "Zhejiang Medicine", "Zhejiang Starry"],
|
| 29 |
+
"sectors": ["pharma"],
|
| 30 |
+
"annual_api_output_tons": 95000
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "shandong",
|
| 34 |
+
"name": "Shandong",
|
| 35 |
+
"latitude": 36.6683,
|
| 36 |
+
"longitude": 117.0206,
|
| 37 |
+
"description": "Major producer of antibiotics, vitamins, and intermediate chemicals.",
|
| 38 |
+
"factories": ["Shandong Lukang", "Shandong Xinhua", "Shandong Daming"],
|
| 39 |
+
"sectors": ["pharma"],
|
| 40 |
+
"annual_api_output_tons": 72000
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "hubei",
|
| 44 |
+
"name": "Hubei",
|
| 45 |
+
"latitude": 30.5833,
|
| 46 |
+
"longitude": 114.3054,
|
| 47 |
+
"description": "Central logistics hub — penicillin G, salbutamol, and ranitidine manufacturing. COVID epicenter experience.",
|
| 48 |
+
"factories": ["Wuhan Wuyao", "Hubei Hongyuan", "Wuhan Grand"],
|
| 49 |
+
"sectors": ["pharma"],
|
| 50 |
+
"annual_api_output_tons": 45000
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "inner_mongolia",
|
| 54 |
+
"name": "Inner Mongolia",
|
| 55 |
+
"latitude": 40.8175,
|
| 56 |
+
"longitude": 111.7652,
|
| 57 |
+
"description": "World's largest rare earth mining and primary processing region — 60% of global REE output.",
|
| 58 |
+
"factories": ["China Northern Rare Earth", "Baotou Steel Rare Earth"],
|
| 59 |
+
"sectors": ["rare_earth"],
|
| 60 |
+
"annual_api_output_tons": 0
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "sichuan",
|
| 64 |
+
"name": "Sichuan",
|
| 65 |
+
"latitude": 30.5728,
|
| 66 |
+
"longitude": 104.0668,
|
| 67 |
+
"description": "Significant rare earth mining + lithium processing hub. Earthquake and flood risk zone.",
|
| 68 |
+
"factories": ["Shenghe Resources", "Sichuan Rare Earth"],
|
| 69 |
+
"sectors": ["rare_earth"],
|
| 70 |
+
"annual_api_output_tons": 0
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "guangdong",
|
| 74 |
+
"name": "Guangdong",
|
| 75 |
+
"latitude": 23.1291,
|
| 76 |
+
"longitude": 113.2644,
|
| 77 |
+
"description": "Major export port — rare earth magnets, electronics, and high-value pharma intermediates.",
|
| 78 |
+
"factories": ["China Southern Rare Earth", "Guangdong Rising Assets"],
|
| 79 |
+
"sectors": ["rare_earth", "pharma"],
|
| 80 |
+
"annual_api_output_tons": 28000
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "jiangxi",
|
| 84 |
+
"name": "Jiangxi",
|
| 85 |
+
"latitude": 28.6759,
|
| 86 |
+
"longitude": 115.8921,
|
| 87 |
+
"description": "Heavy rare earth mining center — dysprosium, europium, yttrium extraction.",
|
| 88 |
+
"factories": ["Jiangxi Rare Earth", "Ganzhou Rare Earth"],
|
| 89 |
+
"sectors": ["rare_earth"],
|
| 90 |
+
"annual_api_output_tons": 0
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"id": "fujian",
|
| 94 |
+
"name": "Fujian",
|
| 95 |
+
"latitude": 26.0789,
|
| 96 |
+
"longitude": 119.2960,
|
| 97 |
+
"description": "Xiamen tungsten + rare earth processing. Key maritime export corridor to India.",
|
| 98 |
+
"factories": ["Xiamen Tungsten", "Fujian Changting Golden Dragon"],
|
| 99 |
+
"sectors": ["rare_earth"],
|
| 100 |
+
"annual_api_output_tons": 0
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"id": "hunan",
|
| 104 |
+
"name": "Hunan",
|
| 105 |
+
"latitude": 28.2180,
|
| 106 |
+
"longitude": 112.9388,
|
| 107 |
+
"description": "Yttrium and heavy rare earth mining region — growing separation capacity.",
|
| 108 |
+
"factories": ["Hunan RE", "Chenzhou Rare Earth"],
|
| 109 |
+
"sectors": ["rare_earth"],
|
| 110 |
+
"annual_api_output_tons": 0
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"id": "liaoning",
|
| 114 |
+
"name": "Liaoning",
|
| 115 |
+
"latitude": 41.8358,
|
| 116 |
+
"longitude": 123.4287,
|
| 117 |
+
"description": "Northeast pharma corridor — chemical synthesis and veterinary API production.",
|
| 118 |
+
"factories": ["Liaoning Shixian", "Northeast Pharmaceutical Group"],
|
| 119 |
+
"sectors": ["pharma"],
|
| 120 |
+
"annual_api_output_tons": 32000
|
| 121 |
+
}
|
| 122 |
+
]
|
data/seed/india_states.json
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "MH",
|
| 4 |
+
"name": "Maharashtra",
|
| 5 |
+
"latitude": 19.7515,
|
| 6 |
+
"longitude": 75.7139,
|
| 7 |
+
"top_drugs": ["paracetamol", "amoxicillin", "metformin", "atorvastatin"],
|
| 8 |
+
"pharma_hub": true,
|
| 9 |
+
"population_millions": 126,
|
| 10 |
+
"description": "India's largest pharma manufacturing cluster — Mumbai, Pune, Aurangabad corridors."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": "DL",
|
| 14 |
+
"name": "Delhi",
|
| 15 |
+
"latitude": 28.7041,
|
| 16 |
+
"longitude": 77.1025,
|
| 17 |
+
"top_drugs": ["azithromycin", "ceftriaxone", "amlodipine"],
|
| 18 |
+
"pharma_hub": false,
|
| 19 |
+
"population_millions": 32,
|
| 20 |
+
"description": "National capital region — major drug distribution and regulatory hub."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "KA",
|
| 24 |
+
"name": "Karnataka",
|
| 25 |
+
"latitude": 15.3173,
|
| 26 |
+
"longitude": 75.7139,
|
| 27 |
+
"top_drugs": ["losartan", "omeprazole", "ranitidine", "ibuprofen"],
|
| 28 |
+
"pharma_hub": true,
|
| 29 |
+
"population_millions": 68,
|
| 30 |
+
"description": "Bangalore biotech corridor — key biologics and API formulation cluster."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "TN",
|
| 34 |
+
"name": "Tamil Nadu",
|
| 35 |
+
"latitude": 11.1271,
|
| 36 |
+
"longitude": 78.6569,
|
| 37 |
+
"top_drugs": ["diclofenac", "aspirin", "penicillin_g"],
|
| 38 |
+
"pharma_hub": true,
|
| 39 |
+
"population_millions": 77,
|
| 40 |
+
"description": "Chennai pharma corridor — antibiotic and NSAID manufacturing."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "WB",
|
| 44 |
+
"name": "West Bengal",
|
| 45 |
+
"latitude": 22.9868,
|
| 46 |
+
"longitude": 87.8550,
|
| 47 |
+
"top_drugs": ["gentamicin", "fluconazole", "ciprofloxacin"],
|
| 48 |
+
"pharma_hub": false,
|
| 49 |
+
"population_millions": 99,
|
| 50 |
+
"description": "Eastern distribution hub — critical for northeast India supply chains."
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "GJ",
|
| 54 |
+
"name": "Gujarat",
|
| 55 |
+
"latitude": 22.2587,
|
| 56 |
+
"longitude": 71.1924,
|
| 57 |
+
"top_drugs": ["paracetamol", "metformin", "amoxicillin", "atorvastatin", "ciprofloxacin"],
|
| 58 |
+
"pharma_hub": true,
|
| 59 |
+
"population_millions": 64,
|
| 60 |
+
"description": "Ahmedabad–Vadodara pharma belt — India's largest API and formulation export hub."
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "TS",
|
| 64 |
+
"name": "Telangana",
|
| 65 |
+
"latitude": 18.1124,
|
| 66 |
+
"longitude": 79.0193,
|
| 67 |
+
"top_drugs": ["metformin", "losartan", "atorvastatin", "omeprazole"],
|
| 68 |
+
"pharma_hub": true,
|
| 69 |
+
"population_millions": 38,
|
| 70 |
+
"description": "Hyderabad Genome Valley — biotech R&D and bulk drug manufacturing capital."
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "AP",
|
| 74 |
+
"name": "Andhra Pradesh",
|
| 75 |
+
"latitude": 15.9129,
|
| 76 |
+
"longitude": 79.7400,
|
| 77 |
+
"top_drugs": ["ibuprofen", "diclofenac", "ceftriaxone"],
|
| 78 |
+
"pharma_hub": true,
|
| 79 |
+
"population_millions": 53,
|
| 80 |
+
"description": "Visakhapatnam pharma SEZ — major bulk drug and intermediate manufacturing."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "UP",
|
| 84 |
+
"name": "Uttar Pradesh",
|
| 85 |
+
"latitude": 26.8467,
|
| 86 |
+
"longitude": 80.9462,
|
| 87 |
+
"top_drugs": ["paracetamol", "amoxicillin", "azithromycin", "metformin"],
|
| 88 |
+
"pharma_hub": false,
|
| 89 |
+
"population_millions": 231,
|
| 90 |
+
"description": "India's most populous state — highest absolute drug consumption volume."
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"id": "RJ",
|
| 94 |
+
"name": "Rajasthan",
|
| 95 |
+
"latitude": 27.0238,
|
| 96 |
+
"longitude": 74.2179,
|
| 97 |
+
"top_drugs": ["paracetamol", "amlodipine", "metformin"],
|
| 98 |
+
"pharma_hub": false,
|
| 99 |
+
"population_millions": 79,
|
| 100 |
+
"description": "Jaipur pharma cluster — growing formulation manufacturing base."
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"id": "MP",
|
| 104 |
+
"name": "Madhya Pradesh",
|
| 105 |
+
"latitude": 22.9734,
|
| 106 |
+
"longitude": 78.6569,
|
| 107 |
+
"top_drugs": ["amoxicillin", "paracetamol", "ibuprofen"],
|
| 108 |
+
"pharma_hub": false,
|
| 109 |
+
"population_millions": 85,
|
| 110 |
+
"description": "Central India distribution node — Indore pharmaceutical hub emerging."
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"id": "KL",
|
| 114 |
+
"name": "Kerala",
|
| 115 |
+
"latitude": 10.8505,
|
| 116 |
+
"longitude": 76.2711,
|
| 117 |
+
"top_drugs": ["metformin", "atorvastatin", "amlodipine", "losartan"],
|
| 118 |
+
"pharma_hub": false,
|
| 119 |
+
"population_millions": 35,
|
| 120 |
+
"description": "Highest per-capita drug consumption — critical chronic disease medication demand."
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": "PB",
|
| 124 |
+
"name": "Punjab",
|
| 125 |
+
"latitude": 31.1471,
|
| 126 |
+
"longitude": 75.3412,
|
| 127 |
+
"top_drugs": ["paracetamol", "amoxicillin", "ciprofloxacin"],
|
| 128 |
+
"pharma_hub": true,
|
| 129 |
+
"population_millions": 31,
|
| 130 |
+
"description": "Baddi-Nalagarh pharma corridor (Himachal border) — major formulation manufacturing."
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"id": "HP",
|
| 134 |
+
"name": "Himachal Pradesh",
|
| 135 |
+
"latitude": 31.1048,
|
| 136 |
+
"longitude": 77.1734,
|
| 137 |
+
"top_drugs": ["paracetamol", "amoxicillin", "azithromycin", "ibuprofen"],
|
| 138 |
+
"pharma_hub": true,
|
| 139 |
+
"population_millions": 7,
|
| 140 |
+
"description": "Baddi pharma cluster — one of India's top 3 pharmaceutical manufacturing zones."
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"id": "GA",
|
| 144 |
+
"name": "Goa",
|
| 145 |
+
"latitude": 15.2993,
|
| 146 |
+
"longitude": 74.1240,
|
| 147 |
+
"top_drugs": ["diclofenac", "omeprazole", "fluconazole"],
|
| 148 |
+
"pharma_hub": true,
|
| 149 |
+
"population_millions": 2,
|
| 150 |
+
"description": "Verna industrial estate — compact high-value pharma manufacturing hub."
|
| 151 |
+
}
|
| 152 |
+
]
|
frontend/index.html
CHANGED
|
@@ -4,7 +4,8 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="root"></div>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="description" content="ShockMap — Real-time supply chain shock intelligence for pharmaceutical APIs and rare earth minerals." />
|
| 8 |
+
<title>ShockMap — Supply Chain Intelligence</title>
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div id="root"></div>
|
frontend/package-lock.json
CHANGED
|
@@ -8,11 +8,21 @@
|
|
| 8 |
"name": "frontend",
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"lucide-react": "^0.525.0",
|
| 12 |
"react": "^19.2.5",
|
| 13 |
"react-dom": "^19.2.5",
|
|
|
|
| 14 |
"react-router-dom": "^7.6.0",
|
| 15 |
-
"
|
|
|
|
|
|
|
|
|
|
| 16 |
},
|
| 17 |
"devDependencies": {
|
| 18 |
"@eslint/js": "^10.0.1",
|
|
@@ -73,7 +83,6 @@
|
|
| 73 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 74 |
"dev": true,
|
| 75 |
"license": "MIT",
|
| 76 |
-
"peer": true,
|
| 77 |
"dependencies": {
|
| 78 |
"@babel/code-frame": "^7.29.0",
|
| 79 |
"@babel/generator": "^7.29.0",
|
|
@@ -292,6 +301,29 @@
|
|
| 292 |
"node": ">=6.9.0"
|
| 293 |
}
|
| 294 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
"node_modules/@emnapi/wasi-threads": {
|
| 296 |
"version": "1.2.1",
|
| 297 |
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
|
@@ -614,6 +646,28 @@
|
|
| 614 |
"url": "https://github.com/sponsors/Boshen"
|
| 615 |
}
|
| 616 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
"node_modules/@rolldown/binding-android-arm64": {
|
| 618 |
"version": "1.0.0-rc.17",
|
| 619 |
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
|
@@ -979,7 +1033,6 @@
|
|
| 979 |
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
| 980 |
"dev": true,
|
| 981 |
"license": "MIT",
|
| 982 |
-
"peer": true,
|
| 983 |
"dependencies": {
|
| 984 |
"csstype": "^3.2.2"
|
| 985 |
}
|
|
@@ -1026,7 +1079,6 @@
|
|
| 1026 |
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
| 1027 |
"dev": true,
|
| 1028 |
"license": "MIT",
|
| 1029 |
-
"peer": true,
|
| 1030 |
"bin": {
|
| 1031 |
"acorn": "bin/acorn"
|
| 1032 |
},
|
|
@@ -1221,7 +1273,6 @@
|
|
| 1221 |
}
|
| 1222 |
],
|
| 1223 |
"license": "MIT",
|
| 1224 |
-
"peer": true,
|
| 1225 |
"dependencies": {
|
| 1226 |
"baseline-browser-mapping": "^2.10.12",
|
| 1227 |
"caniuse-lite": "^1.0.30001782",
|
|
@@ -1399,6 +1450,22 @@
|
|
| 1399 |
"node": ">=12"
|
| 1400 |
}
|
| 1401 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1402 |
"node_modules/d3-ease": {
|
| 1403 |
"version": "3.0.1",
|
| 1404 |
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
@@ -1417,6 +1484,30 @@
|
|
| 1417 |
"node": ">=12"
|
| 1418 |
}
|
| 1419 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1420 |
"node_modules/d3-interpolate": {
|
| 1421 |
"version": "3.0.1",
|
| 1422 |
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
@@ -1454,6 +1545,12 @@
|
|
| 1454 |
"node": ">=12"
|
| 1455 |
}
|
| 1456 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1457 |
"node_modules/d3-shape": {
|
| 1458 |
"version": "3.2.0",
|
| 1459 |
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
@@ -1499,6 +1596,77 @@
|
|
| 1499 |
"node": ">=12"
|
| 1500 |
}
|
| 1501 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1502 |
"node_modules/debug": {
|
| 1503 |
"version": "4.4.3",
|
| 1504 |
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
@@ -1610,7 +1778,6 @@
|
|
| 1610 |
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
| 1611 |
"dev": true,
|
| 1612 |
"license": "MIT",
|
| 1613 |
-
"peer": true,
|
| 1614 |
"dependencies": {
|
| 1615 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 1616 |
"@eslint-community/regexpp": "^4.12.2",
|
|
@@ -1793,6 +1960,15 @@
|
|
| 1793 |
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
| 1794 |
"license": "MIT"
|
| 1795 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1796 |
"node_modules/fast-deep-equal": {
|
| 1797 |
"version": "3.1.3",
|
| 1798 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
@@ -1959,6 +2135,33 @@
|
|
| 1959 |
"url": "https://github.com/sponsors/rawify"
|
| 1960 |
}
|
| 1961 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1962 |
"node_modules/fsevents": {
|
| 1963 |
"version": "2.3.3",
|
| 1964 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
@@ -2020,6 +2223,39 @@
|
|
| 2020 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 2021 |
}
|
| 2022 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2023 |
"node_modules/hasown": {
|
| 2024 |
"version": "2.0.3",
|
| 2025 |
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
|
@@ -2154,7 +2390,6 @@
|
|
| 2154 |
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
| 2155 |
"dev": true,
|
| 2156 |
"license": "MIT",
|
| 2157 |
-
"peer": true,
|
| 2158 |
"bin": {
|
| 2159 |
"jiti": "bin/jiti.js"
|
| 2160 |
}
|
|
@@ -2222,6 +2457,12 @@
|
|
| 2222 |
"json-buffer": "3.0.1"
|
| 2223 |
}
|
| 2224 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2225 |
"node_modules/levn": {
|
| 2226 |
"version": "0.4.1",
|
| 2227 |
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
|
@@ -2623,6 +2864,21 @@
|
|
| 2623 |
"url": "https://github.com/sponsors/isaacs"
|
| 2624 |
}
|
| 2625 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2626 |
"node_modules/ms": {
|
| 2627 |
"version": "2.1.3",
|
| 2628 |
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
@@ -2794,7 +3050,6 @@
|
|
| 2794 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 2795 |
"dev": true,
|
| 2796 |
"license": "MIT",
|
| 2797 |
-
"peer": true,
|
| 2798 |
"engines": {
|
| 2799 |
"node": ">=12"
|
| 2800 |
},
|
|
@@ -2842,7 +3097,6 @@
|
|
| 2842 |
}
|
| 2843 |
],
|
| 2844 |
"license": "MIT",
|
| 2845 |
-
"peer": true,
|
| 2846 |
"dependencies": {
|
| 2847 |
"nanoid": "^3.3.11",
|
| 2848 |
"picocolors": "^1.1.1",
|
|
@@ -3049,7 +3303,6 @@
|
|
| 3049 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
| 3050 |
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
| 3051 |
"license": "MIT",
|
| 3052 |
-
"peer": true,
|
| 3053 |
"engines": {
|
| 3054 |
"node": ">=0.10.0"
|
| 3055 |
}
|
|
@@ -3059,7 +3312,6 @@
|
|
| 3059 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
| 3060 |
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
| 3061 |
"license": "MIT",
|
| 3062 |
-
"peer": true,
|
| 3063 |
"dependencies": {
|
| 3064 |
"scheduler": "^0.27.0"
|
| 3065 |
},
|
|
@@ -3073,6 +3325,20 @@
|
|
| 3073 |
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
| 3074 |
"license": "MIT"
|
| 3075 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3076 |
"node_modules/react-router": {
|
| 3077 |
"version": "7.14.2",
|
| 3078 |
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
|
@@ -3111,6 +3377,23 @@
|
|
| 3111 |
"react-dom": ">=18"
|
| 3112 |
}
|
| 3113 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3114 |
"node_modules/react-smooth": {
|
| 3115 |
"version": "4.0.4",
|
| 3116 |
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
|
@@ -3353,6 +3636,16 @@
|
|
| 3353 |
"node": ">=8"
|
| 3354 |
}
|
| 3355 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3356 |
"node_modules/source-map-js": {
|
| 3357 |
"version": "1.2.1",
|
| 3358 |
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
|
@@ -3496,6 +3789,26 @@
|
|
| 3496 |
"node": ">=8.0"
|
| 3497 |
}
|
| 3498 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3499 |
"node_modules/ts-interface-checker": {
|
| 3500 |
"version": "0.1.13",
|
| 3501 |
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
|
@@ -3507,9 +3820,7 @@
|
|
| 3507 |
"version": "2.8.1",
|
| 3508 |
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
| 3509 |
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 3510 |
-
"
|
| 3511 |
-
"license": "0BSD",
|
| 3512 |
-
"optional": true
|
| 3513 |
},
|
| 3514 |
"node_modules/type-check": {
|
| 3515 |
"version": "0.4.0",
|
|
@@ -3600,7 +3911,6 @@
|
|
| 3600 |
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
| 3601 |
"dev": true,
|
| 3602 |
"license": "MIT",
|
| 3603 |
-
"peer": true,
|
| 3604 |
"dependencies": {
|
| 3605 |
"lightningcss": "^1.32.0",
|
| 3606 |
"picomatch": "^4.0.4",
|
|
@@ -3725,7 +4035,6 @@
|
|
| 3725 |
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
| 3726 |
"dev": true,
|
| 3727 |
"license": "MIT",
|
| 3728 |
-
"peer": true,
|
| 3729 |
"funding": {
|
| 3730 |
"url": "https://github.com/sponsors/colinhacks"
|
| 3731 |
}
|
|
|
|
| 8 |
"name": "frontend",
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"@react-sigma/core": "^5.0.6",
|
| 12 |
+
"d3-scale": "^4.0.2",
|
| 13 |
+
"framer-motion": "^12.38.0",
|
| 14 |
+
"graphology": "^0.26.0",
|
| 15 |
+
"graphology-layout-forceatlas2": "^0.10.1",
|
| 16 |
+
"leaflet": "^1.9.4",
|
| 17 |
"lucide-react": "^0.525.0",
|
| 18 |
"react": "^19.2.5",
|
| 19 |
"react-dom": "^19.2.5",
|
| 20 |
+
"react-leaflet": "^5.0.0",
|
| 21 |
"react-router-dom": "^7.6.0",
|
| 22 |
+
"react-simple-maps": "^3.0.0",
|
| 23 |
+
"recharts": "^2.15.3",
|
| 24 |
+
"sigma": "^3.0.2",
|
| 25 |
+
"topojson-client": "^3.1.0"
|
| 26 |
},
|
| 27 |
"devDependencies": {
|
| 28 |
"@eslint/js": "^10.0.1",
|
|
|
|
| 83 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 84 |
"dev": true,
|
| 85 |
"license": "MIT",
|
|
|
|
| 86 |
"dependencies": {
|
| 87 |
"@babel/code-frame": "^7.29.0",
|
| 88 |
"@babel/generator": "^7.29.0",
|
|
|
|
| 301 |
"node": ">=6.9.0"
|
| 302 |
}
|
| 303 |
},
|
| 304 |
+
"node_modules/@emnapi/core": {
|
| 305 |
+
"version": "1.10.0",
|
| 306 |
+
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
| 307 |
+
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
| 308 |
+
"dev": true,
|
| 309 |
+
"license": "MIT",
|
| 310 |
+
"optional": true,
|
| 311 |
+
"dependencies": {
|
| 312 |
+
"@emnapi/wasi-threads": "1.2.1",
|
| 313 |
+
"tslib": "^2.4.0"
|
| 314 |
+
}
|
| 315 |
+
},
|
| 316 |
+
"node_modules/@emnapi/runtime": {
|
| 317 |
+
"version": "1.10.0",
|
| 318 |
+
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
| 319 |
+
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
| 320 |
+
"dev": true,
|
| 321 |
+
"license": "MIT",
|
| 322 |
+
"optional": true,
|
| 323 |
+
"dependencies": {
|
| 324 |
+
"tslib": "^2.4.0"
|
| 325 |
+
}
|
| 326 |
+
},
|
| 327 |
"node_modules/@emnapi/wasi-threads": {
|
| 328 |
"version": "1.2.1",
|
| 329 |
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
|
|
|
| 646 |
"url": "https://github.com/sponsors/Boshen"
|
| 647 |
}
|
| 648 |
},
|
| 649 |
+
"node_modules/@react-leaflet/core": {
|
| 650 |
+
"version": "3.0.0",
|
| 651 |
+
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
| 652 |
+
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
| 653 |
+
"license": "Hippocratic-2.1",
|
| 654 |
+
"peerDependencies": {
|
| 655 |
+
"leaflet": "^1.9.0",
|
| 656 |
+
"react": "^19.0.0",
|
| 657 |
+
"react-dom": "^19.0.0"
|
| 658 |
+
}
|
| 659 |
+
},
|
| 660 |
+
"node_modules/@react-sigma/core": {
|
| 661 |
+
"version": "5.0.6",
|
| 662 |
+
"resolved": "https://registry.npmjs.org/@react-sigma/core/-/core-5.0.6.tgz",
|
| 663 |
+
"integrity": "sha512-Xu2qXyvDZIhmvGC1n8d7Kcxm5Ntcz4HbPIM7CPDD2e4h3s/oxVpVPX7wtsNreJRRPj9mK+3oqB6SWXNI4mTqVg==",
|
| 664 |
+
"license": "MIT",
|
| 665 |
+
"peerDependencies": {
|
| 666 |
+
"graphology": "^0.26.0",
|
| 667 |
+
"react": "^18.0.0 || ^19.0.0",
|
| 668 |
+
"sigma": "^3.0.2"
|
| 669 |
+
}
|
| 670 |
+
},
|
| 671 |
"node_modules/@rolldown/binding-android-arm64": {
|
| 672 |
"version": "1.0.0-rc.17",
|
| 673 |
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
|
|
|
| 1033 |
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
| 1034 |
"dev": true,
|
| 1035 |
"license": "MIT",
|
|
|
|
| 1036 |
"dependencies": {
|
| 1037 |
"csstype": "^3.2.2"
|
| 1038 |
}
|
|
|
|
| 1079 |
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
| 1080 |
"dev": true,
|
| 1081 |
"license": "MIT",
|
|
|
|
| 1082 |
"bin": {
|
| 1083 |
"acorn": "bin/acorn"
|
| 1084 |
},
|
|
|
|
| 1273 |
}
|
| 1274 |
],
|
| 1275 |
"license": "MIT",
|
|
|
|
| 1276 |
"dependencies": {
|
| 1277 |
"baseline-browser-mapping": "^2.10.12",
|
| 1278 |
"caniuse-lite": "^1.0.30001782",
|
|
|
|
| 1450 |
"node": ">=12"
|
| 1451 |
}
|
| 1452 |
},
|
| 1453 |
+
"node_modules/d3-dispatch": {
|
| 1454 |
+
"version": "2.0.0",
|
| 1455 |
+
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
|
| 1456 |
+
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
|
| 1457 |
+
"license": "BSD-3-Clause"
|
| 1458 |
+
},
|
| 1459 |
+
"node_modules/d3-drag": {
|
| 1460 |
+
"version": "2.0.0",
|
| 1461 |
+
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
|
| 1462 |
+
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
|
| 1463 |
+
"license": "BSD-3-Clause",
|
| 1464 |
+
"dependencies": {
|
| 1465 |
+
"d3-dispatch": "1 - 2",
|
| 1466 |
+
"d3-selection": "2"
|
| 1467 |
+
}
|
| 1468 |
+
},
|
| 1469 |
"node_modules/d3-ease": {
|
| 1470 |
"version": "3.0.1",
|
| 1471 |
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
|
|
| 1484 |
"node": ">=12"
|
| 1485 |
}
|
| 1486 |
},
|
| 1487 |
+
"node_modules/d3-geo": {
|
| 1488 |
+
"version": "2.0.2",
|
| 1489 |
+
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
| 1490 |
+
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
| 1491 |
+
"license": "BSD-3-Clause",
|
| 1492 |
+
"dependencies": {
|
| 1493 |
+
"d3-array": "^2.5.0"
|
| 1494 |
+
}
|
| 1495 |
+
},
|
| 1496 |
+
"node_modules/d3-geo/node_modules/d3-array": {
|
| 1497 |
+
"version": "2.12.1",
|
| 1498 |
+
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
|
| 1499 |
+
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
|
| 1500 |
+
"license": "BSD-3-Clause",
|
| 1501 |
+
"dependencies": {
|
| 1502 |
+
"internmap": "^1.0.0"
|
| 1503 |
+
}
|
| 1504 |
+
},
|
| 1505 |
+
"node_modules/d3-geo/node_modules/internmap": {
|
| 1506 |
+
"version": "1.0.1",
|
| 1507 |
+
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
|
| 1508 |
+
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
|
| 1509 |
+
"license": "ISC"
|
| 1510 |
+
},
|
| 1511 |
"node_modules/d3-interpolate": {
|
| 1512 |
"version": "3.0.1",
|
| 1513 |
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
|
|
| 1545 |
"node": ">=12"
|
| 1546 |
}
|
| 1547 |
},
|
| 1548 |
+
"node_modules/d3-selection": {
|
| 1549 |
+
"version": "2.0.0",
|
| 1550 |
+
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
|
| 1551 |
+
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
|
| 1552 |
+
"license": "BSD-3-Clause"
|
| 1553 |
+
},
|
| 1554 |
"node_modules/d3-shape": {
|
| 1555 |
"version": "3.2.0",
|
| 1556 |
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
|
|
| 1596 |
"node": ">=12"
|
| 1597 |
}
|
| 1598 |
},
|
| 1599 |
+
"node_modules/d3-transition": {
|
| 1600 |
+
"version": "2.0.0",
|
| 1601 |
+
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
|
| 1602 |
+
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
|
| 1603 |
+
"license": "BSD-3-Clause",
|
| 1604 |
+
"dependencies": {
|
| 1605 |
+
"d3-color": "1 - 2",
|
| 1606 |
+
"d3-dispatch": "1 - 2",
|
| 1607 |
+
"d3-ease": "1 - 2",
|
| 1608 |
+
"d3-interpolate": "1 - 2",
|
| 1609 |
+
"d3-timer": "1 - 2"
|
| 1610 |
+
},
|
| 1611 |
+
"peerDependencies": {
|
| 1612 |
+
"d3-selection": "2"
|
| 1613 |
+
}
|
| 1614 |
+
},
|
| 1615 |
+
"node_modules/d3-transition/node_modules/d3-color": {
|
| 1616 |
+
"version": "2.0.0",
|
| 1617 |
+
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
|
| 1618 |
+
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
| 1619 |
+
"license": "BSD-3-Clause"
|
| 1620 |
+
},
|
| 1621 |
+
"node_modules/d3-transition/node_modules/d3-ease": {
|
| 1622 |
+
"version": "2.0.0",
|
| 1623 |
+
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
|
| 1624 |
+
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
|
| 1625 |
+
"license": "BSD-3-Clause"
|
| 1626 |
+
},
|
| 1627 |
+
"node_modules/d3-transition/node_modules/d3-interpolate": {
|
| 1628 |
+
"version": "2.0.1",
|
| 1629 |
+
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
|
| 1630 |
+
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
|
| 1631 |
+
"license": "BSD-3-Clause",
|
| 1632 |
+
"dependencies": {
|
| 1633 |
+
"d3-color": "1 - 2"
|
| 1634 |
+
}
|
| 1635 |
+
},
|
| 1636 |
+
"node_modules/d3-transition/node_modules/d3-timer": {
|
| 1637 |
+
"version": "2.0.0",
|
| 1638 |
+
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
|
| 1639 |
+
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
|
| 1640 |
+
"license": "BSD-3-Clause"
|
| 1641 |
+
},
|
| 1642 |
+
"node_modules/d3-zoom": {
|
| 1643 |
+
"version": "2.0.0",
|
| 1644 |
+
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
|
| 1645 |
+
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
|
| 1646 |
+
"license": "BSD-3-Clause",
|
| 1647 |
+
"dependencies": {
|
| 1648 |
+
"d3-dispatch": "1 - 2",
|
| 1649 |
+
"d3-drag": "2",
|
| 1650 |
+
"d3-interpolate": "1 - 2",
|
| 1651 |
+
"d3-selection": "2",
|
| 1652 |
+
"d3-transition": "2"
|
| 1653 |
+
}
|
| 1654 |
+
},
|
| 1655 |
+
"node_modules/d3-zoom/node_modules/d3-color": {
|
| 1656 |
+
"version": "2.0.0",
|
| 1657 |
+
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
|
| 1658 |
+
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
| 1659 |
+
"license": "BSD-3-Clause"
|
| 1660 |
+
},
|
| 1661 |
+
"node_modules/d3-zoom/node_modules/d3-interpolate": {
|
| 1662 |
+
"version": "2.0.1",
|
| 1663 |
+
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
|
| 1664 |
+
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
|
| 1665 |
+
"license": "BSD-3-Clause",
|
| 1666 |
+
"dependencies": {
|
| 1667 |
+
"d3-color": "1 - 2"
|
| 1668 |
+
}
|
| 1669 |
+
},
|
| 1670 |
"node_modules/debug": {
|
| 1671 |
"version": "4.4.3",
|
| 1672 |
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
|
|
| 1778 |
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
| 1779 |
"dev": true,
|
| 1780 |
"license": "MIT",
|
|
|
|
| 1781 |
"dependencies": {
|
| 1782 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 1783 |
"@eslint-community/regexpp": "^4.12.2",
|
|
|
|
| 1960 |
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
| 1961 |
"license": "MIT"
|
| 1962 |
},
|
| 1963 |
+
"node_modules/events": {
|
| 1964 |
+
"version": "3.3.0",
|
| 1965 |
+
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
| 1966 |
+
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
| 1967 |
+
"license": "MIT",
|
| 1968 |
+
"engines": {
|
| 1969 |
+
"node": ">=0.8.x"
|
| 1970 |
+
}
|
| 1971 |
+
},
|
| 1972 |
"node_modules/fast-deep-equal": {
|
| 1973 |
"version": "3.1.3",
|
| 1974 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
|
|
| 2135 |
"url": "https://github.com/sponsors/rawify"
|
| 2136 |
}
|
| 2137 |
},
|
| 2138 |
+
"node_modules/framer-motion": {
|
| 2139 |
+
"version": "12.38.0",
|
| 2140 |
+
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
| 2141 |
+
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
| 2142 |
+
"license": "MIT",
|
| 2143 |
+
"dependencies": {
|
| 2144 |
+
"motion-dom": "^12.38.0",
|
| 2145 |
+
"motion-utils": "^12.36.0",
|
| 2146 |
+
"tslib": "^2.4.0"
|
| 2147 |
+
},
|
| 2148 |
+
"peerDependencies": {
|
| 2149 |
+
"@emotion/is-prop-valid": "*",
|
| 2150 |
+
"react": "^18.0.0 || ^19.0.0",
|
| 2151 |
+
"react-dom": "^18.0.0 || ^19.0.0"
|
| 2152 |
+
},
|
| 2153 |
+
"peerDependenciesMeta": {
|
| 2154 |
+
"@emotion/is-prop-valid": {
|
| 2155 |
+
"optional": true
|
| 2156 |
+
},
|
| 2157 |
+
"react": {
|
| 2158 |
+
"optional": true
|
| 2159 |
+
},
|
| 2160 |
+
"react-dom": {
|
| 2161 |
+
"optional": true
|
| 2162 |
+
}
|
| 2163 |
+
}
|
| 2164 |
+
},
|
| 2165 |
"node_modules/fsevents": {
|
| 2166 |
"version": "2.3.3",
|
| 2167 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
|
|
| 2223 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 2224 |
}
|
| 2225 |
},
|
| 2226 |
+
"node_modules/graphology": {
|
| 2227 |
+
"version": "0.26.0",
|
| 2228 |
+
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
|
| 2229 |
+
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
|
| 2230 |
+
"license": "MIT",
|
| 2231 |
+
"dependencies": {
|
| 2232 |
+
"events": "^3.3.0"
|
| 2233 |
+
},
|
| 2234 |
+
"peerDependencies": {
|
| 2235 |
+
"graphology-types": ">=0.24.0"
|
| 2236 |
+
}
|
| 2237 |
+
},
|
| 2238 |
+
"node_modules/graphology-layout-forceatlas2": {
|
| 2239 |
+
"version": "0.10.1",
|
| 2240 |
+
"resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz",
|
| 2241 |
+
"integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==",
|
| 2242 |
+
"license": "MIT",
|
| 2243 |
+
"dependencies": {
|
| 2244 |
+
"graphology-utils": "^2.1.0"
|
| 2245 |
+
},
|
| 2246 |
+
"peerDependencies": {
|
| 2247 |
+
"graphology-types": ">=0.19.0"
|
| 2248 |
+
}
|
| 2249 |
+
},
|
| 2250 |
+
"node_modules/graphology-utils": {
|
| 2251 |
+
"version": "2.5.2",
|
| 2252 |
+
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
|
| 2253 |
+
"integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
|
| 2254 |
+
"license": "MIT",
|
| 2255 |
+
"peerDependencies": {
|
| 2256 |
+
"graphology-types": ">=0.23.0"
|
| 2257 |
+
}
|
| 2258 |
+
},
|
| 2259 |
"node_modules/hasown": {
|
| 2260 |
"version": "2.0.3",
|
| 2261 |
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
|
|
|
| 2390 |
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
| 2391 |
"dev": true,
|
| 2392 |
"license": "MIT",
|
|
|
|
| 2393 |
"bin": {
|
| 2394 |
"jiti": "bin/jiti.js"
|
| 2395 |
}
|
|
|
|
| 2457 |
"json-buffer": "3.0.1"
|
| 2458 |
}
|
| 2459 |
},
|
| 2460 |
+
"node_modules/leaflet": {
|
| 2461 |
+
"version": "1.9.4",
|
| 2462 |
+
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
| 2463 |
+
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
| 2464 |
+
"license": "BSD-2-Clause"
|
| 2465 |
+
},
|
| 2466 |
"node_modules/levn": {
|
| 2467 |
"version": "0.4.1",
|
| 2468 |
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
|
|
|
| 2864 |
"url": "https://github.com/sponsors/isaacs"
|
| 2865 |
}
|
| 2866 |
},
|
| 2867 |
+
"node_modules/motion-dom": {
|
| 2868 |
+
"version": "12.38.0",
|
| 2869 |
+
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
| 2870 |
+
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
| 2871 |
+
"license": "MIT",
|
| 2872 |
+
"dependencies": {
|
| 2873 |
+
"motion-utils": "^12.36.0"
|
| 2874 |
+
}
|
| 2875 |
+
},
|
| 2876 |
+
"node_modules/motion-utils": {
|
| 2877 |
+
"version": "12.36.0",
|
| 2878 |
+
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
| 2879 |
+
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
| 2880 |
+
"license": "MIT"
|
| 2881 |
+
},
|
| 2882 |
"node_modules/ms": {
|
| 2883 |
"version": "2.1.3",
|
| 2884 |
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
|
|
| 3050 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 3051 |
"dev": true,
|
| 3052 |
"license": "MIT",
|
|
|
|
| 3053 |
"engines": {
|
| 3054 |
"node": ">=12"
|
| 3055 |
},
|
|
|
|
| 3097 |
}
|
| 3098 |
],
|
| 3099 |
"license": "MIT",
|
|
|
|
| 3100 |
"dependencies": {
|
| 3101 |
"nanoid": "^3.3.11",
|
| 3102 |
"picocolors": "^1.1.1",
|
|
|
|
| 3303 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
| 3304 |
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
| 3305 |
"license": "MIT",
|
|
|
|
| 3306 |
"engines": {
|
| 3307 |
"node": ">=0.10.0"
|
| 3308 |
}
|
|
|
|
| 3312 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
| 3313 |
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
| 3314 |
"license": "MIT",
|
|
|
|
| 3315 |
"dependencies": {
|
| 3316 |
"scheduler": "^0.27.0"
|
| 3317 |
},
|
|
|
|
| 3325 |
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
| 3326 |
"license": "MIT"
|
| 3327 |
},
|
| 3328 |
+
"node_modules/react-leaflet": {
|
| 3329 |
+
"version": "5.0.0",
|
| 3330 |
+
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
| 3331 |
+
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
| 3332 |
+
"license": "Hippocratic-2.1",
|
| 3333 |
+
"dependencies": {
|
| 3334 |
+
"@react-leaflet/core": "^3.0.0"
|
| 3335 |
+
},
|
| 3336 |
+
"peerDependencies": {
|
| 3337 |
+
"leaflet": "^1.9.0",
|
| 3338 |
+
"react": "^19.0.0",
|
| 3339 |
+
"react-dom": "^19.0.0"
|
| 3340 |
+
}
|
| 3341 |
+
},
|
| 3342 |
"node_modules/react-router": {
|
| 3343 |
"version": "7.14.2",
|
| 3344 |
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
|
|
|
| 3377 |
"react-dom": ">=18"
|
| 3378 |
}
|
| 3379 |
},
|
| 3380 |
+
"node_modules/react-simple-maps": {
|
| 3381 |
+
"version": "3.0.0",
|
| 3382 |
+
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
| 3383 |
+
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
|
| 3384 |
+
"license": "MIT",
|
| 3385 |
+
"dependencies": {
|
| 3386 |
+
"d3-geo": "^2.0.2",
|
| 3387 |
+
"d3-selection": "^2.0.0",
|
| 3388 |
+
"d3-zoom": "^2.0.0",
|
| 3389 |
+
"topojson-client": "^3.1.0"
|
| 3390 |
+
},
|
| 3391 |
+
"peerDependencies": {
|
| 3392 |
+
"prop-types": "^15.7.2",
|
| 3393 |
+
"react": "^16.8.0 || 17.x || 18.x",
|
| 3394 |
+
"react-dom": "^16.8.0 || 17.x || 18.x"
|
| 3395 |
+
}
|
| 3396 |
+
},
|
| 3397 |
"node_modules/react-smooth": {
|
| 3398 |
"version": "4.0.4",
|
| 3399 |
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
|
|
|
| 3636 |
"node": ">=8"
|
| 3637 |
}
|
| 3638 |
},
|
| 3639 |
+
"node_modules/sigma": {
|
| 3640 |
+
"version": "3.0.2",
|
| 3641 |
+
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
|
| 3642 |
+
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
|
| 3643 |
+
"license": "MIT",
|
| 3644 |
+
"dependencies": {
|
| 3645 |
+
"events": "^3.3.0",
|
| 3646 |
+
"graphology-utils": "^2.5.2"
|
| 3647 |
+
}
|
| 3648 |
+
},
|
| 3649 |
"node_modules/source-map-js": {
|
| 3650 |
"version": "1.2.1",
|
| 3651 |
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
|
|
|
| 3789 |
"node": ">=8.0"
|
| 3790 |
}
|
| 3791 |
},
|
| 3792 |
+
"node_modules/topojson-client": {
|
| 3793 |
+
"version": "3.1.0",
|
| 3794 |
+
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
| 3795 |
+
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
| 3796 |
+
"license": "ISC",
|
| 3797 |
+
"dependencies": {
|
| 3798 |
+
"commander": "2"
|
| 3799 |
+
},
|
| 3800 |
+
"bin": {
|
| 3801 |
+
"topo2geo": "bin/topo2geo",
|
| 3802 |
+
"topomerge": "bin/topomerge",
|
| 3803 |
+
"topoquantize": "bin/topoquantize"
|
| 3804 |
+
}
|
| 3805 |
+
},
|
| 3806 |
+
"node_modules/topojson-client/node_modules/commander": {
|
| 3807 |
+
"version": "2.20.3",
|
| 3808 |
+
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
| 3809 |
+
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
| 3810 |
+
"license": "MIT"
|
| 3811 |
+
},
|
| 3812 |
"node_modules/ts-interface-checker": {
|
| 3813 |
"version": "0.1.13",
|
| 3814 |
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
|
|
|
| 3820 |
"version": "2.8.1",
|
| 3821 |
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
| 3822 |
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 3823 |
+
"license": "0BSD"
|
|
|
|
|
|
|
| 3824 |
},
|
| 3825 |
"node_modules/type-check": {
|
| 3826 |
"version": "0.4.0",
|
|
|
|
| 3911 |
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
| 3912 |
"dev": true,
|
| 3913 |
"license": "MIT",
|
|
|
|
| 3914 |
"dependencies": {
|
| 3915 |
"lightningcss": "^1.32.0",
|
| 3916 |
"picomatch": "^4.0.4",
|
|
|
|
| 4035 |
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
| 4036 |
"dev": true,
|
| 4037 |
"license": "MIT",
|
|
|
|
| 4038 |
"funding": {
|
| 4039 |
"url": "https://github.com/sponsors/colinhacks"
|
| 4040 |
}
|
frontend/package.json
CHANGED
|
@@ -10,11 +10,21 @@
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"lucide-react": "^0.525.0",
|
| 14 |
"react": "^19.2.5",
|
| 15 |
"react-dom": "^19.2.5",
|
|
|
|
| 16 |
"react-router-dom": "^7.6.0",
|
| 17 |
-
"
|
|
|
|
|
|
|
|
|
|
| 18 |
},
|
| 19 |
"devDependencies": {
|
| 20 |
"@eslint/js": "^10.0.1",
|
|
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"@react-sigma/core": "^5.0.6",
|
| 14 |
+
"d3-scale": "^4.0.2",
|
| 15 |
+
"framer-motion": "^12.38.0",
|
| 16 |
+
"graphology": "^0.26.0",
|
| 17 |
+
"graphology-layout-forceatlas2": "^0.10.1",
|
| 18 |
+
"leaflet": "^1.9.4",
|
| 19 |
"lucide-react": "^0.525.0",
|
| 20 |
"react": "^19.2.5",
|
| 21 |
"react-dom": "^19.2.5",
|
| 22 |
+
"react-leaflet": "^5.0.0",
|
| 23 |
"react-router-dom": "^7.6.0",
|
| 24 |
+
"react-simple-maps": "^3.0.0",
|
| 25 |
+
"recharts": "^2.15.3",
|
| 26 |
+
"sigma": "^3.0.2",
|
| 27 |
+
"topojson-client": "^3.1.0"
|
| 28 |
},
|
| 29 |
"devDependencies": {
|
| 30 |
"@eslint/js": "^10.0.1",
|
frontend/src/App.jsx
CHANGED
|
@@ -10,6 +10,7 @@ import Alerts from './pages/Alerts';
|
|
| 10 |
import Graph from './pages/Graph';
|
| 11 |
import Query from './pages/Query';
|
| 12 |
import Simulate from './pages/Simulate';
|
|
|
|
| 13 |
|
| 14 |
export default function App() {
|
| 15 |
// Track whether user has selected sectors (persisted in sessionStorage)
|
|
@@ -59,6 +60,7 @@ export default function App() {
|
|
| 59 |
<Route path="graph" element={<Graph />} />
|
| 60 |
<Route path="query" element={<Query />} />
|
| 61 |
<Route path="simulate" element={<Simulate />} />
|
|
|
|
| 62 |
</Route>
|
| 63 |
</Routes>
|
| 64 |
);
|
|
|
|
| 10 |
import Graph from './pages/Graph';
|
| 11 |
import Query from './pages/Query';
|
| 12 |
import Simulate from './pages/Simulate';
|
| 13 |
+
import MapView from './pages/Map';
|
| 14 |
|
| 15 |
export default function App() {
|
| 16 |
// Track whether user has selected sectors (persisted in sessionStorage)
|
|
|
|
| 60 |
<Route path="graph" element={<Graph />} />
|
| 61 |
<Route path="query" element={<Query />} />
|
| 62 |
<Route path="simulate" element={<Simulate />} />
|
| 63 |
+
<Route path="map" element={<MapView />} />
|
| 64 |
</Route>
|
| 65 |
</Routes>
|
| 66 |
);
|
frontend/src/api/client.js
CHANGED
|
@@ -97,4 +97,24 @@ export const api = {
|
|
| 97 |
|
| 98 |
// Engine Status
|
| 99 |
getEngineStatus: () => request('/api/v1/engines'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
};
|
|
|
|
| 97 |
|
| 98 |
// Engine Status
|
| 99 |
getEngineStatus: () => request('/api/v1/engines'),
|
| 100 |
+
|
| 101 |
+
// ─── Map Intelligence ────────────────────────────────────────────────
|
| 102 |
+
getMapHeatmap: (params = {}) => {
|
| 103 |
+
const q = new URLSearchParams();
|
| 104 |
+
if (params.sector) q.set('sector', params.sector);
|
| 105 |
+
if (params.risk_min) q.set('risk_min', params.risk_min);
|
| 106 |
+
if (params.shock_type) q.set('shock_type', params.shock_type);
|
| 107 |
+
if (params.region) q.set('region', params.region);
|
| 108 |
+
return request(`/api/v1/map/heatmap?${q}`);
|
| 109 |
+
},
|
| 110 |
+
|
| 111 |
+
getMapProvince: (id, sector = 'both') =>
|
| 112 |
+
request(`/api/v1/map/provinces/${encodeURIComponent(id)}?sector=${sector}`),
|
| 113 |
+
|
| 114 |
+
getMapCorridors: (sector = 'both') =>
|
| 115 |
+
request(`/api/v1/map/supply-corridors?sector=${sector}`),
|
| 116 |
+
|
| 117 |
+
getMapFacets: () => request('/api/v1/map/filter-facets'),
|
| 118 |
+
|
| 119 |
+
getMapStats: () => request('/api/v1/map/stats'),
|
| 120 |
};
|
frontend/src/components/AppShell.jsx
CHANGED
|
@@ -4,6 +4,7 @@ import { api } from '../api/client';
|
|
| 4 |
|
| 5 |
const NAV = [
|
| 6 |
{ to: '/dashboard', label: 'Dashboard', icon: 'DB' },
|
|
|
|
| 7 |
{ to: '/alerts', label: 'Live Shocks', icon: 'LS' },
|
| 8 |
{ to: '/query', label: 'Ask ShockMap', icon: 'AI' },
|
| 9 |
{ to: '/drugs', label: 'Inputs', icon: 'IN' },
|
|
|
|
| 4 |
|
| 5 |
const NAV = [
|
| 6 |
{ to: '/dashboard', label: 'Dashboard', icon: 'DB' },
|
| 7 |
+
{ to: '/map', label: 'Map View', icon: 'MP' },
|
| 8 |
{ to: '/alerts', label: 'Live Shocks', icon: 'LS' },
|
| 9 |
{ to: '/query', label: 'Ask ShockMap', icon: 'AI' },
|
| 10 |
{ to: '/drugs', label: 'Inputs', icon: 'IN' },
|
frontend/src/components/MapFilterPanel.jsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { api } from '../api/client';
|
| 3 |
+
|
| 4 |
+
const SECTOR_OPTIONS = [
|
| 5 |
+
{ value: 'both', label: 'All Sectors', icon: '◉' },
|
| 6 |
+
{ value: 'pharma', label: 'Pharma APIs', icon: '💊' },
|
| 7 |
+
{ value: 'rare_earth', label: 'Rare Earths', icon: '⚛' },
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
const REGION_OPTIONS = [
|
| 11 |
+
{ value: 'all', label: 'Global', icon: '🌐' },
|
| 12 |
+
{ value: 'china', label: 'China', icon: '🇨🇳' },
|
| 13 |
+
{ value: 'india', label: 'India', icon: '🇮🇳' },
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
const RISK_PRESETS = [
|
| 17 |
+
{ value: 0, label: 'All', color: '#6b7280' },
|
| 18 |
+
{ value: 40, label: 'Medium+', color: '#60a5fa' },
|
| 19 |
+
{ value: 60, label: 'High+', color: '#f59e0b' },
|
| 20 |
+
{ value: 80, label: 'Critical', color: '#f43f5e' },
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
export default function MapFilterPanel({ onFilterChange, showCorridors, onToggleCorridors }) {
|
| 24 |
+
const [facets, setFacets] = useState({
|
| 25 |
+
sectors: [],
|
| 26 |
+
shock_types: [],
|
| 27 |
+
risk_levels: [],
|
| 28 |
+
china_provinces: [],
|
| 29 |
+
india_states: [],
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
const [filters, setFilters] = useState({
|
| 33 |
+
sector: 'both',
|
| 34 |
+
risk_min: 0,
|
| 35 |
+
shock_type: '',
|
| 36 |
+
region: 'all',
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
api.getMapFacets().then(setFacets).catch(console.error);
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const handleChange = useCallback((key, value) => {
|
| 44 |
+
const newFilters = { ...filters, [key]: value };
|
| 45 |
+
setFilters(newFilters);
|
| 46 |
+
onFilterChange(newFilters);
|
| 47 |
+
}, [filters, onFilterChange]);
|
| 48 |
+
|
| 49 |
+
const panelStyle = {
|
| 50 |
+
background: 'rgba(13, 17, 23, 0.85)',
|
| 51 |
+
backdropFilter: 'blur(20px)',
|
| 52 |
+
WebkitBackdropFilter: 'blur(20px)',
|
| 53 |
+
border: '1px solid rgba(255,255,255,0.08)',
|
| 54 |
+
borderRadius: 16,
|
| 55 |
+
padding: '20px',
|
| 56 |
+
display: 'flex',
|
| 57 |
+
flexDirection: 'column',
|
| 58 |
+
gap: 18,
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const labelStyle = {
|
| 62 |
+
display: 'block',
|
| 63 |
+
fontSize: '0.7rem',
|
| 64 |
+
color: 'var(--muted)',
|
| 65 |
+
fontWeight: 600,
|
| 66 |
+
textTransform: 'uppercase',
|
| 67 |
+
letterSpacing: '0.06em',
|
| 68 |
+
marginBottom: 8,
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const chipStyle = (isActive, color = 'var(--primary)') => ({
|
| 72 |
+
display: 'inline-flex',
|
| 73 |
+
alignItems: 'center',
|
| 74 |
+
gap: 5,
|
| 75 |
+
padding: '6px 12px',
|
| 76 |
+
borderRadius: 999,
|
| 77 |
+
fontSize: '0.75rem',
|
| 78 |
+
fontWeight: 500,
|
| 79 |
+
cursor: 'pointer',
|
| 80 |
+
border: `1px solid ${isActive ? color : 'var(--border)'}`,
|
| 81 |
+
background: isActive ? `${color}15` : 'transparent',
|
| 82 |
+
color: isActive ? color : 'var(--muted)',
|
| 83 |
+
transition: 'all 0.2s ease',
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div style={panelStyle}>
|
| 88 |
+
{/* Header */}
|
| 89 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 90 |
+
<h3 style={{ fontSize: '0.95rem', fontWeight: 700, margin: 0, color: 'var(--text)' }}>
|
| 91 |
+
Filters
|
| 92 |
+
</h3>
|
| 93 |
+
<span style={{
|
| 94 |
+
fontSize: '0.65rem', padding: '3px 8px', borderRadius: 999,
|
| 95 |
+
background: 'rgba(79,156,249,0.12)', color: 'var(--primary)',
|
| 96 |
+
border: '1px solid rgba(79,156,249,0.2)', fontWeight: 600,
|
| 97 |
+
}}>
|
| 98 |
+
LIVE
|
| 99 |
+
</span>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Region Selector */}
|
| 103 |
+
<div>
|
| 104 |
+
<label style={labelStyle}>Region Focus</label>
|
| 105 |
+
<div style={{ display: 'flex', gap: 6 }}>
|
| 106 |
+
{REGION_OPTIONS.map(r => (
|
| 107 |
+
<button
|
| 108 |
+
key={r.value}
|
| 109 |
+
onClick={() => handleChange('region', r.value)}
|
| 110 |
+
style={chipStyle(filters.region === r.value)}
|
| 111 |
+
>
|
| 112 |
+
<span>{r.icon}</span> {r.label}
|
| 113 |
+
</button>
|
| 114 |
+
))}
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
{/* Sector Selector */}
|
| 119 |
+
<div>
|
| 120 |
+
<label style={labelStyle}>Sector</label>
|
| 121 |
+
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
| 122 |
+
{SECTOR_OPTIONS.map(s => (
|
| 123 |
+
<button
|
| 124 |
+
key={s.value}
|
| 125 |
+
onClick={() => handleChange('sector', s.value)}
|
| 126 |
+
style={chipStyle(filters.sector === s.value, s.value === 'pharma' ? '#10b981' : s.value === 'rare_earth' ? '#f59e0b' : 'var(--primary)')}
|
| 127 |
+
>
|
| 128 |
+
<span>{s.icon}</span> {s.label}
|
| 129 |
+
</button>
|
| 130 |
+
))}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Risk Level */}
|
| 135 |
+
<div>
|
| 136 |
+
<label style={labelStyle}>
|
| 137 |
+
Risk Threshold
|
| 138 |
+
<span style={{
|
| 139 |
+
float: 'right', fontWeight: 700, fontSize: '0.72rem',
|
| 140 |
+
color: filters.risk_min >= 80 ? '#f43f5e' : filters.risk_min >= 60 ? '#f59e0b' : filters.risk_min >= 40 ? '#60a5fa' : 'var(--muted)',
|
| 141 |
+
fontFamily: 'var(--mono)',
|
| 142 |
+
}}>
|
| 143 |
+
{filters.risk_min}+
|
| 144 |
+
</span>
|
| 145 |
+
</label>
|
| 146 |
+
<div style={{ display: 'flex', gap: 6 }}>
|
| 147 |
+
{RISK_PRESETS.map(r => (
|
| 148 |
+
<button
|
| 149 |
+
key={r.value}
|
| 150 |
+
onClick={() => handleChange('risk_min', r.value)}
|
| 151 |
+
style={{
|
| 152 |
+
...chipStyle(filters.risk_min === r.value, r.color),
|
| 153 |
+
flex: 1, justifyContent: 'center',
|
| 154 |
+
}}
|
| 155 |
+
>
|
| 156 |
+
{r.label}
|
| 157 |
+
</button>
|
| 158 |
+
))}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* Shock Type */}
|
| 163 |
+
<div>
|
| 164 |
+
<label style={labelStyle}>Shock Type</label>
|
| 165 |
+
<select
|
| 166 |
+
value={filters.shock_type}
|
| 167 |
+
onChange={(e) => handleChange('shock_type', e.target.value)}
|
| 168 |
+
style={{
|
| 169 |
+
width: '100%', padding: '8px 12px', borderRadius: 10,
|
| 170 |
+
background: 'var(--surface2)', border: '1px solid var(--border)',
|
| 171 |
+
color: 'var(--text)', fontSize: '0.8rem', fontFamily: 'inherit',
|
| 172 |
+
cursor: 'pointer', transition: 'border-color 0.2s',
|
| 173 |
+
}}
|
| 174 |
+
>
|
| 175 |
+
<option value="">Any Shock Type</option>
|
| 176 |
+
{facets.shock_types && facets.shock_types.map(s => (
|
| 177 |
+
<option key={s} value={s}>
|
| 178 |
+
{s.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
| 179 |
+
</option>
|
| 180 |
+
))}
|
| 181 |
+
</select>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{/* Supply Corridor Toggle */}
|
| 185 |
+
<div>
|
| 186 |
+
<button
|
| 187 |
+
onClick={onToggleCorridors}
|
| 188 |
+
style={{
|
| 189 |
+
width: '100%', padding: '10px 14px', borderRadius: 10,
|
| 190 |
+
background: showCorridors ? 'rgba(79,156,249,0.12)' : 'var(--surface2)',
|
| 191 |
+
border: `1px solid ${showCorridors ? 'rgba(79,156,249,0.3)' : 'var(--border)'}`,
|
| 192 |
+
color: showCorridors ? 'var(--primary)' : 'var(--muted)',
|
| 193 |
+
fontSize: '0.8rem', fontWeight: 600, cursor: 'pointer',
|
| 194 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
| 195 |
+
transition: 'all 0.2s ease',
|
| 196 |
+
}}
|
| 197 |
+
>
|
| 198 |
+
<span style={{ fontSize: '1rem' }}>⛓</span>
|
| 199 |
+
{showCorridors ? 'Hide' : 'Show'} Supply Corridors
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Reset */}
|
| 204 |
+
<button
|
| 205 |
+
onClick={() => {
|
| 206 |
+
const reset = { sector: 'both', risk_min: 0, shock_type: '', region: 'all' };
|
| 207 |
+
setFilters(reset);
|
| 208 |
+
onFilterChange(reset);
|
| 209 |
+
}}
|
| 210 |
+
style={{
|
| 211 |
+
width: '100%', padding: '8px', borderRadius: 8,
|
| 212 |
+
background: 'transparent', border: '1px solid var(--border)',
|
| 213 |
+
color: 'var(--muted)', fontSize: '0.72rem', fontWeight: 500,
|
| 214 |
+
cursor: 'pointer', transition: 'all 0.15s',
|
| 215 |
+
}}
|
| 216 |
+
>
|
| 217 |
+
Reset All Filters
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
);
|
| 221 |
+
}
|
frontend/src/lib/mockData.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/src/lib/mockData.js
|
| 2 |
+
// Realistic offline mock data for all endpoints
|
| 3 |
+
|
| 4 |
+
export const MOCK_SHOCKS = [
|
| 5 |
+
{
|
| 6 |
+
id: "shock_001",
|
| 7 |
+
title: "Hebei Analgesic Stress — Factory Shutdown",
|
| 8 |
+
event_type: "factory_shutdown",
|
| 9 |
+
severity: "CRITICAL",
|
| 10 |
+
province: "Hebei",
|
| 11 |
+
sector: "pharma",
|
| 12 |
+
affected_entities: ["paracetamol", "ceftriaxone", "amoxicillin"],
|
| 13 |
+
risk_score: 87,
|
| 14 |
+
days_to_stockout: 18,
|
| 15 |
+
exposure_crore: 2340,
|
| 16 |
+
created_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
| 17 |
+
description: "Major Hebei pharmaceutical manufacturing cluster shut down following environmental compliance enforcement. 3 key API factories offline.",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
id: "shock_002",
|
| 21 |
+
title: "Inner Mongolia Rare Earth Export Ban",
|
| 22 |
+
event_type: "export_ban",
|
| 23 |
+
severity: "CRITICAL",
|
| 24 |
+
province: "Inner Mongolia",
|
| 25 |
+
sector: "rare_earth",
|
| 26 |
+
affected_entities: ["neodymium", "dysprosium", "cerium"],
|
| 27 |
+
risk_score: 79,
|
| 28 |
+
days_to_stockout: 24,
|
| 29 |
+
exposure_crore: 1840,
|
| 30 |
+
created_at: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
|
| 31 |
+
description: "Chinese customs suspended neodymium and dysprosium export declarations pending strategic quota review.",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: "shock_003",
|
| 35 |
+
title: "Shandong Port Closure — Yellow Sea Storm",
|
| 36 |
+
event_type: "port_closure",
|
| 37 |
+
severity: "HIGH",
|
| 38 |
+
province: "Shandong",
|
| 39 |
+
sector: "pharma",
|
| 40 |
+
affected_entities: ["ciprofloxacin", "metformin"],
|
| 41 |
+
risk_score: 61,
|
| 42 |
+
days_to_stockout: 31,
|
| 43 |
+
exposure_crore: 780,
|
| 44 |
+
created_at: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(),
|
| 45 |
+
description: "Qingdao and Rizhao ports suspended operations due to Typhoon Khanun. Estimated 4-day delay on API shipments.",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
id: "shock_004",
|
| 49 |
+
title: "Jiangsu API Contamination Alert",
|
| 50 |
+
event_type: "contamination",
|
| 51 |
+
severity: "HIGH",
|
| 52 |
+
province: "Jiangsu",
|
| 53 |
+
sector: "pharma",
|
| 54 |
+
affected_entities: ["losartan", "amlodipine"],
|
| 55 |
+
risk_score: 58,
|
| 56 |
+
days_to_stockout: 40,
|
| 57 |
+
exposure_crore: 540,
|
| 58 |
+
created_at: new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString(),
|
| 59 |
+
description: "NDMA impurity detected in losartan API batch. Voluntary recall initiated by 2 manufacturers.",
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
id: "shock_005",
|
| 63 |
+
title: "Liaoning Steel-Pharma Corridor Disruption",
|
| 64 |
+
event_type: "factory_shutdown",
|
| 65 |
+
severity: "MEDIUM",
|
| 66 |
+
province: "Liaoning",
|
| 67 |
+
sector: "pharma",
|
| 68 |
+
affected_entities: ["gentamicin", "amikacin"],
|
| 69 |
+
risk_score: 38,
|
| 70 |
+
days_to_stockout: 55,
|
| 71 |
+
exposure_crore: 210,
|
| 72 |
+
created_at: new Date(Date.now() - 36 * 60 * 60 * 1000).toISOString(),
|
| 73 |
+
description: "Northeast pharma logistics corridor impacted by industrial accident near Shenyang.",
|
| 74 |
+
},
|
| 75 |
+
];
|
| 76 |
+
|
| 77 |
+
export const MOCK_GRAPH = {
|
| 78 |
+
nodes: [
|
| 79 |
+
{ id: "hebei", label: "Hebei Province", type: "province", attributes: { current_risk: 95, buffer_days: 12, monthly_import_tonnes: 4200, strategic_priority: "TIER_1" } },
|
| 80 |
+
{ id: "inner_mongolia", label: "Inner Mongolia", type: "province", attributes: { current_risk: 79, buffer_days: 20, monthly_import_tonnes: 1800, strategic_priority: "TIER_1" } },
|
| 81 |
+
{ id: "shandong", label: "Shandong Province", type: "province", attributes: { current_risk: 61, buffer_days: 28, monthly_import_tonnes: 2900, strategic_priority: "TIER_2" } },
|
| 82 |
+
{ id: "pap", label: "Para-Aminophenol", type: "ksm", attributes: { current_risk: 88, buffer_days: 8, substitutability: 0.15 } },
|
| 83 |
+
{ id: "6apa", label: "6-APA", type: "ksm", attributes: { current_risk: 72, buffer_days: 14, substitutability: 0.22 } },
|
| 84 |
+
{ id: "neodymium", label: "Neodymium Oxide", type: "ksm", attributes: { current_risk: 76, buffer_days: 18, substitutability: 0.08 } },
|
| 85 |
+
{ id: "paracetamol_api", label: "Paracetamol API", type: "api", attributes: { current_risk: 85, buffer_days: 6, substitutability: 0.18 } },
|
| 86 |
+
{ id: "amoxicillin_api", label: "Amoxicillin API", type: "api", attributes: { current_risk: 68, buffer_days: 16, substitutability: 0.31 } },
|
| 87 |
+
{ id: "cipro_api", label: "Ciprofloxacin API", type: "api", attributes: { current_risk: 40, buffer_days: 35, substitutability: 0.45 } },
|
| 88 |
+
{ id: "paracetamol", label: "Paracetamol", type: "drug", attributes: { current_risk: 82, buffer_days: 7, patient_population_estimate: 85000000, nlem_tier: "TIER_1" } },
|
| 89 |
+
{ id: "ceftriaxone", label: "Ceftriaxone", type: "drug", attributes: { current_risk: 74, buffer_days: 10, patient_population_estimate: 12000000, nlem_tier: "TIER_1" } },
|
| 90 |
+
{ id: "amoxicillin", label: "Amoxicillin", type: "drug", attributes: { current_risk: 65, buffer_days: 18, patient_population_estimate: 42000000, nlem_tier: "TIER_2" } },
|
| 91 |
+
{ id: "ciprofloxacin", label: "Ciprofloxacin", type: "drug", attributes: { current_risk: 38, buffer_days: 40, patient_population_estimate: 18000000, nlem_tier: "TIER_2" } },
|
| 92 |
+
],
|
| 93 |
+
edges: [
|
| 94 |
+
{ source: "hebei", target: "pap", weight: 0.90 },
|
| 95 |
+
{ source: "hebei", target: "6apa", weight: 0.70 },
|
| 96 |
+
{ source: "inner_mongolia", target: "neodymium", weight: 0.85 },
|
| 97 |
+
{ source: "pap", target: "paracetamol_api", weight: 0.85 },
|
| 98 |
+
{ source: "6apa", target: "amoxicillin_api", weight: 0.68 },
|
| 99 |
+
{ source: "paracetamol_api", target: "paracetamol", weight: 0.82 },
|
| 100 |
+
{ source: "paracetamol_api", target: "ceftriaxone", weight: 0.60 },
|
| 101 |
+
{ source: "amoxicillin_api", target: "amoxicillin", weight: 0.65 },
|
| 102 |
+
{ source: "shandong", target: "cipro_api", weight: 0.40 },
|
| 103 |
+
{ source: "cipro_api", target: "ciprofloxacin", weight: 0.38 },
|
| 104 |
+
],
|
| 105 |
+
state_risk_aggregates: [
|
| 106 |
+
{ state_id: "MH", state_name: "Maharashtra", risk_score: 74 },
|
| 107 |
+
{ state_id: "GJ", state_name: "Gujarat", risk_score: 68 },
|
| 108 |
+
{ state_id: "TS", state_name: "Telangana", risk_score: 61 },
|
| 109 |
+
{ state_id: "DL", state_name: "Delhi", risk_score: 58 },
|
| 110 |
+
{ state_id: "KA", state_name: "Karnataka", risk_score: 52 },
|
| 111 |
+
],
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
export const MOCK_QUERY_RESPONSE = {
|
| 115 |
+
answer: "Based on current shock telemetry, **Paracetamol** and **Ceftriaxone** face highest downstream risk from the Hebei factory shutdown. Para-Aminophenol (PAP) is the critical bottleneck — India sources 73% from Hebei corridor.\n\nRecommended actions within 72 hours:\n• Activate DGFT strategic buffer release for Tier-1 APIs\n• Issue advance purchase order: 18 MT PAP from SE Asia spot market\n• Trigger substitution protocol for Amoxicillin (31% substitutability)\n\nEstimated exposure: ₹2,340 Cr across 85M patient population.",
|
| 116 |
+
confidence: 0.87,
|
| 117 |
+
response_mode: "demo",
|
| 118 |
+
citations: [
|
| 119 |
+
{ source: "DGFT Circular 2024-API-09", snippet: "Buffer stock norms mandate 45-day API reserve for Schedule H medicines under supply chain stress.", url: "" },
|
| 120 |
+
{ source: "Hebei EPA Enforcement Notice", snippet: "12 pharmaceutical manufacturing units in Shijiazhuang suspended pending environmental compliance verification.", url: "" },
|
| 121 |
+
{ source: "IPC Supply Intelligence Brief", snippet: "Para-Aminophenol import concentration from Hebei: 73.4% of total Indian API procurement.", url: "" },
|
| 122 |
+
],
|
| 123 |
+
suggested_drugs_to_inspect: ["paracetamol", "ceftriaxone", "amoxicillin"],
|
| 124 |
+
matched_scenarios: ["Hebei analgesic stress", "API buffer depletion"],
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
export const MOCK_CORRIDORS = [
|
| 128 |
+
{ id: "c1", from: [39.9, 116.4], to: [28.7, 77.1], label: "Hebei → Delhi NCR", volume: 0.9, disrupted: true, province: "Hebei", state: "Delhi" },
|
| 129 |
+
{ id: "c2", from: [36.7, 117.0], to: [19.1, 72.9], label: "Shandong → Mumbai", volume: 0.7, disrupted: false, province: "Shandong", state: "Maharashtra" },
|
| 130 |
+
{ id: "c3", from: [41.8, 123.4], to: [12.9, 77.6], label: "Liaoning → Bangalore", volume: 0.5, disrupted: false, province: "Liaoning", state: "Karnataka" },
|
| 131 |
+
{ id: "c4", from: [40.8, 111.7], to: [22.6, 88.4], label: "Inner Mongolia → Kolkata",volume: 0.6, disrupted: true, province: "Inner Mongolia",state: "West Bengal" },
|
| 132 |
+
{ id: "c5", from: [32.1, 118.8], to: [17.4, 78.5], label: "Jiangsu → Hyderabad", volume: 0.65,disrupted: false, province: "Jiangsu", state: "Telangana" },
|
| 133 |
+
];
|
frontend/src/main.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { StrictMode } from 'react';
|
| 2 |
import { createRoot } from 'react-dom/client';
|
| 3 |
import { BrowserRouter } from 'react-router-dom';
|
|
|
|
| 4 |
import './index.css';
|
| 5 |
import App from './App.jsx';
|
| 6 |
|
|
|
|
| 1 |
import { StrictMode } from 'react';
|
| 2 |
import { createRoot } from 'react-dom/client';
|
| 3 |
import { BrowserRouter } from 'react-router-dom';
|
| 4 |
+
import './styles/tokens.css';
|
| 5 |
import './index.css';
|
| 6 |
import App from './App.jsx';
|
| 7 |
|
frontend/src/pages/Drugs.jsx
CHANGED
|
@@ -39,28 +39,28 @@ function DrugDetail({ id }) {
|
|
| 39 |
const { drug, dependency_chain, supplier_hhi, criticality_breakdown, recent_alerts, current_risk } = data;
|
| 40 |
|
| 41 |
return (
|
| 42 |
-
<div
|
| 43 |
-
<button onClick={() => navigate('/drugs')} className="
|
| 44 |
-
<ChevronLeft
|
| 45 |
</button>
|
| 46 |
|
| 47 |
-
<div className="
|
| 48 |
-
<div
|
| 49 |
<div>
|
| 50 |
-
<h1
|
| 51 |
-
<p
|
| 52 |
</div>
|
| 53 |
-
<div
|
| 54 |
<SeverityBadge severity={riskLabel(current_risk || 0)} />
|
| 55 |
-
<p
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
-
<div
|
| 59 |
<RiskBar score={current_risk || 0} />
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
|
| 63 |
-
<div
|
| 64 |
{[
|
| 65 |
{ label: 'NLEM Tier', value: tierLabel(drug.nlem_tier) },
|
| 66 |
{ label: 'Substitute', value: drug.has_substitute ? 'Yes' : 'No' },
|
|
@@ -69,23 +69,23 @@ function DrugDetail({ id }) {
|
|
| 69 |
? `${(drug.patient_population_estimate / 1e6).toFixed(0)}M`
|
| 70 |
: 'N/A' },
|
| 71 |
].map(({ label, value }) => (
|
| 72 |
-
<div key={label} className="
|
| 73 |
-
<p
|
| 74 |
-
<p
|
| 75 |
</div>
|
| 76 |
))}
|
| 77 |
</div>
|
| 78 |
|
| 79 |
{criticality_breakdown && (
|
| 80 |
-
<div className="
|
| 81 |
-
<h2
|
| 82 |
-
<div
|
| 83 |
{Object.entries(criticality_breakdown)
|
| 84 |
.filter(([k]) => k !== 'final_score')
|
| 85 |
.map(([key, val]) => (
|
| 86 |
-
<div key={key}
|
| 87 |
-
<span
|
| 88 |
-
<div
|
| 89 |
<RiskBar score={typeof val === 'number' ? Math.min(100, val) : 0} />
|
| 90 |
</div>
|
| 91 |
</div>
|
|
@@ -95,16 +95,16 @@ function DrugDetail({ id }) {
|
|
| 95 |
)}
|
| 96 |
|
| 97 |
{dependency_chain?.length > 0 && (
|
| 98 |
-
<div className="
|
| 99 |
-
<h2
|
| 100 |
-
<div
|
| 101 |
{dependency_chain.map((node, i) => (
|
| 102 |
-
<div key={i}
|
| 103 |
-
<div
|
| 104 |
-
<span
|
| 105 |
-
<span>{node.name || node.id}</span>
|
| 106 |
</div>
|
| 107 |
-
{i < dependency_chain.length - 1 && <span
|
| 108 |
</div>
|
| 109 |
))}
|
| 110 |
</div>
|
|
@@ -112,16 +112,16 @@ function DrugDetail({ id }) {
|
|
| 112 |
)}
|
| 113 |
|
| 114 |
{recent_alerts?.length > 0 && (
|
| 115 |
-
<div className="
|
| 116 |
-
<h2
|
| 117 |
-
<div
|
| 118 |
{recent_alerts.map(alert => (
|
| 119 |
-
<div key={alert.id}
|
| 120 |
<SeverityBadge severity={alert.severity} />
|
| 121 |
<div>
|
| 122 |
-
<p
|
| 123 |
{alert.gemini_explainer && (
|
| 124 |
-
<p
|
| 125 |
)}
|
| 126 |
</div>
|
| 127 |
</div>
|
|
@@ -167,29 +167,31 @@ export default function Drugs() {
|
|
| 167 |
);
|
| 168 |
|
| 169 |
return (
|
| 170 |
-
<div
|
| 171 |
<PageHeader
|
| 172 |
title="Drug Catalog"
|
| 173 |
description={`${total} NLEM essential medicines monitored`}
|
| 174 |
/>
|
| 175 |
|
| 176 |
{/* Filters */}
|
| 177 |
-
<div
|
| 178 |
-
<div
|
| 179 |
-
<Search
|
| 180 |
<input
|
| 181 |
type="text"
|
| 182 |
placeholder="Search drugs…"
|
| 183 |
value={search}
|
| 184 |
onChange={e => setSearch(e.target.value)}
|
| 185 |
-
className="
|
|
|
|
| 186 |
/>
|
| 187 |
</div>
|
| 188 |
|
| 189 |
<select
|
| 190 |
value={tier}
|
| 191 |
onChange={e => { setTier(e.target.value); setPage(1); }}
|
| 192 |
-
className="
|
|
|
|
| 193 |
>
|
| 194 |
<option value="">All Tiers</option>
|
| 195 |
<option value="1">Tier 1</option>
|
|
@@ -200,7 +202,8 @@ export default function Drugs() {
|
|
| 200 |
<select
|
| 201 |
value={severity}
|
| 202 |
onChange={e => { setSeverity(e.target.value); setPage(1); }}
|
| 203 |
-
className="
|
|
|
|
| 204 |
>
|
| 205 |
<option value="">All Risk Levels</option>
|
| 206 |
<option value="critical">Critical (≥80)</option>
|
|
@@ -210,49 +213,49 @@ export default function Drugs() {
|
|
| 210 |
</select>
|
| 211 |
</div>
|
| 212 |
|
| 213 |
-
{error && <p
|
| 214 |
|
| 215 |
{/* Table */}
|
| 216 |
-
<div className="
|
| 217 |
-
<table
|
| 218 |
<thead>
|
| 219 |
-
<tr
|
| 220 |
{['Drug', 'Class', 'Tier', 'Risk Score', 'Substitute', ''].map(h => (
|
| 221 |
-
<th key={h}
|
| 222 |
))}
|
| 223 |
</tr>
|
| 224 |
</thead>
|
| 225 |
<tbody>
|
| 226 |
{loading ? (
|
| 227 |
-
<tr><td colSpan={6}
|
| 228 |
) : filtered.length === 0 ? (
|
| 229 |
-
<tr><td colSpan={6}
|
| 230 |
) : filtered.map(drug => {
|
| 231 |
const risk = drug.current_risk || drug.criticality_score || 0;
|
| 232 |
return (
|
| 233 |
-
<tr key={drug.id}
|
| 234 |
-
<td
|
| 235 |
-
<Link to={`/drugs/${drug.id}`}
|
| 236 |
{drug.name}
|
| 237 |
</Link>
|
| 238 |
</td>
|
| 239 |
-
<td
|
| 240 |
-
<td
|
| 241 |
-
<span
|
| 242 |
{tierLabel(drug.nlem_tier)}
|
| 243 |
</span>
|
| 244 |
</td>
|
| 245 |
-
<td
|
| 246 |
<RiskBar score={risk} />
|
| 247 |
</td>
|
| 248 |
-
<td
|
| 249 |
-
<span
|
| 250 |
{drug.has_substitute ? 'Yes' : 'No'}
|
| 251 |
</span>
|
| 252 |
</td>
|
| 253 |
-
<td
|
| 254 |
-
<Link to={`/drugs/${drug.id}`}
|
| 255 |
-
<ExternalLink
|
| 256 |
</Link>
|
| 257 |
</td>
|
| 258 |
</tr>
|
|
@@ -264,18 +267,20 @@ export default function Drugs() {
|
|
| 264 |
|
| 265 |
{/* Pagination */}
|
| 266 |
{total > PAGE_SIZE && (
|
| 267 |
-
<div
|
| 268 |
<span>Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total}</span>
|
| 269 |
-
<div
|
| 270 |
<button
|
| 271 |
disabled={page === 1}
|
| 272 |
onClick={() => setPage(p => p - 1)}
|
| 273 |
-
className="
|
|
|
|
| 274 |
>← Prev</button>
|
| 275 |
<button
|
| 276 |
disabled={page * PAGE_SIZE >= total}
|
| 277 |
onClick={() => setPage(p => p + 1)}
|
| 278 |
-
className="
|
|
|
|
| 279 |
>Next →</button>
|
| 280 |
</div>
|
| 281 |
</div>
|
|
|
|
| 39 |
const { drug, dependency_chain, supplier_hhi, criticality_breakdown, recent_alerts, current_risk } = data;
|
| 40 |
|
| 41 |
return (
|
| 42 |
+
<div style={{ padding: '30px', maxWidth: '1000px', margin: '0 auto', animation: 'fade-in 0.3s ease' }}>
|
| 43 |
+
<button onClick={() => navigate('/drugs')} className="btn btn-ghost" style={{ marginBottom: '24px', fontSize: '0.8rem' }}>
|
| 44 |
+
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to drugs
|
| 45 |
</button>
|
| 46 |
|
| 47 |
+
<div className="card" style={{ padding: '24px', marginBottom: '24px' }}>
|
| 48 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px' }}>
|
| 49 |
<div>
|
| 50 |
+
<h1 style={{ fontSize: '1.75rem', fontWeight: 800, margin: '0 0 8px 0' }}>{drug.name}</h1>
|
| 51 |
+
<p style={{ color: 'var(--muted)', fontSize: '0.875rem', margin: 0 }}>{drug.generic_name} · {drug.therapeutic_class}</p>
|
| 52 |
</div>
|
| 53 |
+
<div style={{ textAlign: 'right' }}>
|
| 54 |
<SeverityBadge severity={riskLabel(current_risk || 0)} />
|
| 55 |
+
<p style={{ fontSize: '0.75rem', color: 'var(--muted)', marginTop: '8px' }}>Risk {Math.round(current_risk || 0)}/100</p>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
+
<div style={{ marginTop: '20px' }}>
|
| 59 |
<RiskBar score={current_risk || 0} />
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
|
| 63 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
| 64 |
{[
|
| 65 |
{ label: 'NLEM Tier', value: tierLabel(drug.nlem_tier) },
|
| 66 |
{ label: 'Substitute', value: drug.has_substitute ? 'Yes' : 'No' },
|
|
|
|
| 69 |
? `${(drug.patient_population_estimate / 1e6).toFixed(0)}M`
|
| 70 |
: 'N/A' },
|
| 71 |
].map(({ label, value }) => (
|
| 72 |
+
<div key={label} className="card" style={{ padding: '16px' }}>
|
| 73 |
+
<p style={{ fontSize: '0.75rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 8px 0' }}>{label}</p>
|
| 74 |
+
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>{value}</p>
|
| 75 |
</div>
|
| 76 |
))}
|
| 77 |
</div>
|
| 78 |
|
| 79 |
{criticality_breakdown && (
|
| 80 |
+
<div className="card" style={{ padding: '24px', marginBottom: '24px' }}>
|
| 81 |
+
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 16px 0' }}>Criticality Breakdown</h2>
|
| 82 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
| 83 |
{Object.entries(criticality_breakdown)
|
| 84 |
.filter(([k]) => k !== 'final_score')
|
| 85 |
.map(([key, val]) => (
|
| 86 |
+
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
| 87 |
+
<span style={{ fontSize: '0.75rem', color: 'var(--muted)', width: '160px', textTransform: 'capitalize' }}>{key.replace(/_/g, ' ')}</span>
|
| 88 |
+
<div style={{ flex: 1 }}>
|
| 89 |
<RiskBar score={typeof val === 'number' ? Math.min(100, val) : 0} />
|
| 90 |
</div>
|
| 91 |
</div>
|
|
|
|
| 95 |
)}
|
| 96 |
|
| 97 |
{dependency_chain?.length > 0 && (
|
| 98 |
+
<div className="card" style={{ padding: '24px', marginBottom: '24px' }}>
|
| 99 |
+
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 16px 0' }}>Supply Chain Path</h2>
|
| 100 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
| 101 |
{dependency_chain.map((node, i) => (
|
| 102 |
+
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 103 |
+
<div style={{ padding: '6px 12px', background: 'var(--bg)', borderRadius: '8px', border: '1px solid var(--border)', fontSize: '0.75rem' }}>
|
| 104 |
+
<span style={{ color: 'var(--muted)', textTransform: 'capitalize' }}>{node.type}: </span>
|
| 105 |
+
<span style={{ fontWeight: 500 }}>{node.name || node.id}</span>
|
| 106 |
</div>
|
| 107 |
+
{i < dependency_chain.length - 1 && <span style={{ color: 'var(--muted)', fontSize: '0.75rem' }}>→</span>}
|
| 108 |
</div>
|
| 109 |
))}
|
| 110 |
</div>
|
|
|
|
| 112 |
)}
|
| 113 |
|
| 114 |
{recent_alerts?.length > 0 && (
|
| 115 |
+
<div className="card" style={{ padding: '24px' }}>
|
| 116 |
+
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 16px 0' }}>Recent Alerts</h2>
|
| 117 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
| 118 |
{recent_alerts.map(alert => (
|
| 119 |
+
<div key={alert.id} style={{ display: 'flex', alignItems: 'flex-start', gap: '12px', padding: '16px', borderRadius: '12px', background: 'var(--bg)', border: '1px solid var(--border)' }}>
|
| 120 |
<SeverityBadge severity={alert.severity} />
|
| 121 |
<div>
|
| 122 |
+
<p style={{ fontSize: '0.875rem', margin: '0 0 4px 0', lineHeight: 1.5 }}>{alert.summary}</p>
|
| 123 |
{alert.gemini_explainer && (
|
| 124 |
+
<p style={{ fontSize: '0.75rem', color: 'var(--muted)', margin: 0, lineHeight: 1.5 }}>{alert.gemini_explainer}</p>
|
| 125 |
)}
|
| 126 |
</div>
|
| 127 |
</div>
|
|
|
|
| 167 |
);
|
| 168 |
|
| 169 |
return (
|
| 170 |
+
<div style={{ padding: '30px', maxWidth: '1200px', margin: '0 auto', animation: 'fade-in 0.3s ease' }}>
|
| 171 |
<PageHeader
|
| 172 |
title="Drug Catalog"
|
| 173 |
description={`${total} NLEM essential medicines monitored`}
|
| 174 |
/>
|
| 175 |
|
| 176 |
{/* Filters */}
|
| 177 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginBottom: '20px' }}>
|
| 178 |
+
<div style={{ position: 'relative', width: '250px' }}>
|
| 179 |
+
<Search style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', width: '16px', height: '16px', color: 'var(--muted)' }} />
|
| 180 |
<input
|
| 181 |
type="text"
|
| 182 |
placeholder="Search drugs…"
|
| 183 |
value={search}
|
| 184 |
onChange={e => setSearch(e.target.value)}
|
| 185 |
+
className="input"
|
| 186 |
+
style={{ paddingLeft: '36px' }}
|
| 187 |
/>
|
| 188 |
</div>
|
| 189 |
|
| 190 |
<select
|
| 191 |
value={tier}
|
| 192 |
onChange={e => { setTier(e.target.value); setPage(1); }}
|
| 193 |
+
className="input"
|
| 194 |
+
style={{ width: 'auto' }}
|
| 195 |
>
|
| 196 |
<option value="">All Tiers</option>
|
| 197 |
<option value="1">Tier 1</option>
|
|
|
|
| 202 |
<select
|
| 203 |
value={severity}
|
| 204 |
onChange={e => { setSeverity(e.target.value); setPage(1); }}
|
| 205 |
+
className="input"
|
| 206 |
+
style={{ width: 'auto' }}
|
| 207 |
>
|
| 208 |
<option value="">All Risk Levels</option>
|
| 209 |
<option value="critical">Critical (≥80)</option>
|
|
|
|
| 213 |
</select>
|
| 214 |
</div>
|
| 215 |
|
| 216 |
+
{error && <p style={{ color: 'var(--accent)', fontSize: '0.9rem', marginBottom: '16px' }}>{error}</p>}
|
| 217 |
|
| 218 |
{/* Table */}
|
| 219 |
+
<div className="card" style={{ overflow: 'hidden' }}>
|
| 220 |
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
| 221 |
<thead>
|
| 222 |
+
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
| 223 |
{['Drug', 'Class', 'Tier', 'Risk Score', 'Substitute', ''].map(h => (
|
| 224 |
+
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '0.75rem', color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{h}</th>
|
| 225 |
))}
|
| 226 |
</tr>
|
| 227 |
</thead>
|
| 228 |
<tbody>
|
| 229 |
{loading ? (
|
| 230 |
+
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '48px' }}><Spinner /></td></tr>
|
| 231 |
) : filtered.length === 0 ? (
|
| 232 |
+
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '48px', color: 'var(--muted)' }}>No drugs match filters</td></tr>
|
| 233 |
) : filtered.map(drug => {
|
| 234 |
const risk = drug.current_risk || drug.criticality_score || 0;
|
| 235 |
return (
|
| 236 |
+
<tr key={drug.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', transition: 'background 0.2s' }}>
|
| 237 |
+
<td style={{ padding: '12px 16px' }}>
|
| 238 |
+
<Link to={`/drugs/${drug.id}`} style={{ fontWeight: 500, color: 'var(--text)', textDecoration: 'none' }}>
|
| 239 |
{drug.name}
|
| 240 |
</Link>
|
| 241 |
</td>
|
| 242 |
+
<td style={{ padding: '12px 16px', color: 'var(--muted)' }}>{drug.therapeutic_class || '—'}</td>
|
| 243 |
+
<td style={{ padding: '12px 16px' }}>
|
| 244 |
+
<span style={{ fontSize: '0.7rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', padding: '2px 6px' }}>
|
| 245 |
{tierLabel(drug.nlem_tier)}
|
| 246 |
</span>
|
| 247 |
</td>
|
| 248 |
+
<td style={{ padding: '12px 16px', width: '160px' }}>
|
| 249 |
<RiskBar score={risk} />
|
| 250 |
</td>
|
| 251 |
+
<td style={{ padding: '12px 16px' }}>
|
| 252 |
+
<span style={{ color: drug.has_substitute ? 'var(--secondary, #10b981)' : 'var(--muted)' }}>
|
| 253 |
{drug.has_substitute ? 'Yes' : 'No'}
|
| 254 |
</span>
|
| 255 |
</td>
|
| 256 |
+
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
| 257 |
+
<Link to={`/drugs/${drug.id}`} style={{ color: 'var(--muted)' }}>
|
| 258 |
+
<ExternalLink style={{ width: '14px', height: '14px' }} />
|
| 259 |
</Link>
|
| 260 |
</td>
|
| 261 |
</tr>
|
|
|
|
| 267 |
|
| 268 |
{/* Pagination */}
|
| 269 |
{total > PAGE_SIZE && (
|
| 270 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '16px', fontSize: '0.875rem', color: 'var(--muted)' }}>
|
| 271 |
<span>Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total}</span>
|
| 272 |
+
<div style={{ display: 'flex', gap: '8px' }}>
|
| 273 |
<button
|
| 274 |
disabled={page === 1}
|
| 275 |
onClick={() => setPage(p => p - 1)}
|
| 276 |
+
className="btn btn-ghost"
|
| 277 |
+
style={{ padding: '6px 12px' }}
|
| 278 |
>← Prev</button>
|
| 279 |
<button
|
| 280 |
disabled={page * PAGE_SIZE >= total}
|
| 281 |
onClick={() => setPage(p => p + 1)}
|
| 282 |
+
className="btn btn-ghost"
|
| 283 |
+
style={{ padding: '6px 12px' }}
|
| 284 |
>Next →</button>
|
| 285 |
</div>
|
| 286 |
</div>
|
frontend/src/pages/Graph.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
import { api } from '../api/client';
|
|
|
|
| 3 |
import Spinner from '../components/ui/Spinner';
|
| 4 |
|
| 5 |
const NODE_COLORS = {
|
|
@@ -139,10 +140,14 @@ export default function Graph() {
|
|
| 139 |
const stageRef = useRef(null);
|
| 140 |
const { w, h } = useSize(stageRef);
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
useEffect(() => {
|
| 143 |
setLoading(true);
|
| 144 |
Promise.all([
|
| 145 |
-
api.getGraph(),
|
| 146 |
api.getEngineStatus().catch(() => null),
|
| 147 |
api.health().catch(() => null),
|
| 148 |
])
|
|
@@ -151,10 +156,24 @@ export default function Graph() {
|
|
| 151 |
setEngines(engineStatus);
|
| 152 |
setHealth(healthStatus);
|
| 153 |
})
|
| 154 |
-
.catch((
|
| 155 |
.finally(() => setLoading(false));
|
| 156 |
}, []);
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
const allNodes = useMemo(() => graphData?.nodes || [], [graphData]);
|
| 159 |
const allEdges = useMemo(() => graphData?.edges || [], [graphData]);
|
| 160 |
|
|
@@ -315,19 +334,26 @@ export default function Graph() {
|
|
| 315 |
const path = buildPath(source, target);
|
| 316 |
const weight = Math.max(1, Math.min(3.2, Number(edge.weight || 1)));
|
| 317 |
|
|
|
|
| 318 |
return (
|
| 319 |
<g key={`${edge.source}-${edge.target}`}>
|
| 320 |
<path
|
| 321 |
d={path}
|
| 322 |
-
className={`graph-flow-line ${selected ? 'is-active' : ''}`}
|
| 323 |
style={{ strokeWidth: selected ? weight + 1.3 : weight * 0.85 }}
|
| 324 |
/>
|
| 325 |
-
{selected && (
|
| 326 |
-
<
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
)}
|
| 332 |
</g>
|
| 333 |
);
|
|
@@ -350,13 +376,27 @@ export default function Graph() {
|
|
| 350 |
onClick={() => setSelectedId(node.id)}
|
| 351 |
className="graph-node"
|
| 352 |
>
|
| 353 |
-
{(selected || risk >= 70) && (
|
| 354 |
-
<
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
)}
|
| 361 |
<circle
|
| 362 |
r={selected ? radius + 3 : radius + 1.5}
|
|
@@ -378,6 +418,19 @@ export default function Graph() {
|
|
| 378 |
>
|
| 379 |
{(node.name || node.id).slice(0, 26)}
|
| 380 |
</text>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</g>
|
| 382 |
);
|
| 383 |
})}
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
| 2 |
import { api } from '../api/client';
|
| 3 |
+
import { MOCK_GRAPH } from '../lib/mockData';
|
| 4 |
import Spinner from '../components/ui/Spinner';
|
| 5 |
|
| 6 |
const NODE_COLORS = {
|
|
|
|
| 140 |
const stageRef = useRef(null);
|
| 141 |
const { w, h } = useSize(stageRef);
|
| 142 |
|
| 143 |
+
const [rippleNode, setRippleNode] = useState(null);
|
| 144 |
+
const [rippleTime, setRippleTime] = useState(0);
|
| 145 |
+
const animRef = useRef(0);
|
| 146 |
+
|
| 147 |
useEffect(() => {
|
| 148 |
setLoading(true);
|
| 149 |
Promise.all([
|
| 150 |
+
api.getGraph().catch(() => MOCK_GRAPH),
|
| 151 |
api.getEngineStatus().catch(() => null),
|
| 152 |
api.health().catch(() => null),
|
| 153 |
])
|
|
|
|
| 156 |
setEngines(engineStatus);
|
| 157 |
setHealth(healthStatus);
|
| 158 |
})
|
| 159 |
+
.catch(() => { setGraphData(MOCK_GRAPH); })
|
| 160 |
.finally(() => setLoading(false));
|
| 161 |
}, []);
|
| 162 |
|
| 163 |
+
// Particle animation timer
|
| 164 |
+
useEffect(() => {
|
| 165 |
+
let id;
|
| 166 |
+
const tick = () => { animRef.current = (animRef.current + 0.005) % 1; id = requestAnimationFrame(tick); };
|
| 167 |
+
id = requestAnimationFrame(tick);
|
| 168 |
+
return () => cancelAnimationFrame(id);
|
| 169 |
+
}, []);
|
| 170 |
+
|
| 171 |
+
const triggerRipple = useCallback((nodeId) => {
|
| 172 |
+
setRippleNode(nodeId);
|
| 173 |
+
setRippleTime(Date.now());
|
| 174 |
+
setTimeout(() => setRippleNode(null), 2000);
|
| 175 |
+
}, []);
|
| 176 |
+
|
| 177 |
const allNodes = useMemo(() => graphData?.nodes || [], [graphData]);
|
| 178 |
const allEdges = useMemo(() => graphData?.edges || [], [graphData]);
|
| 179 |
|
|
|
|
| 334 |
const path = buildPath(source, target);
|
| 335 |
const weight = Math.max(1, Math.min(3.2, Number(edge.weight || 1)));
|
| 336 |
|
| 337 |
+
const isRippling = rippleNode && (edge.source === rippleNode || edge.target === rippleNode);
|
| 338 |
return (
|
| 339 |
<g key={`${edge.source}-${edge.target}`}>
|
| 340 |
<path
|
| 341 |
d={path}
|
| 342 |
+
className={`graph-flow-line ${selected ? 'is-active' : ''} ${isRippling ? 'is-active' : ''}`}
|
| 343 |
style={{ strokeWidth: selected ? weight + 1.3 : weight * 0.85 }}
|
| 344 |
/>
|
| 345 |
+
{(selected || isRippling) && (
|
| 346 |
+
<>
|
| 347 |
+
<path
|
| 348 |
+
d={path}
|
| 349 |
+
className="graph-flow-line graph-flow-trail"
|
| 350 |
+
style={{ strokeWidth: weight + 0.4 }}
|
| 351 |
+
/>
|
| 352 |
+
{/* Animated particle dot */}
|
| 353 |
+
<circle r="3" fill="#4f9cf9" opacity="0.9" style={{ filter: 'drop-shadow(0 0 4px #4f9cf9)' }}>
|
| 354 |
+
<animateMotion dur={isRippling ? '0.8s' : '2s'} repeatCount="indefinite" path={path} />
|
| 355 |
+
</circle>
|
| 356 |
+
</>
|
| 357 |
)}
|
| 358 |
</g>
|
| 359 |
);
|
|
|
|
| 376 |
onClick={() => setSelectedId(node.id)}
|
| 377 |
className="graph-node"
|
| 378 |
>
|
| 379 |
+
{(selected || risk >= 70 || node.id === rippleNode) && (
|
| 380 |
+
<>
|
| 381 |
+
<circle
|
| 382 |
+
r={selected ? radius + 14 : radius + 10}
|
| 383 |
+
fill={fill}
|
| 384 |
+
opacity={selected ? 0.18 : 0.11}
|
| 385 |
+
className="graph-node-halo"
|
| 386 |
+
/>
|
| 387 |
+
{node.id === rippleNode && (
|
| 388 |
+
<>
|
| 389 |
+
<circle r="8" fill="none" stroke={fill} strokeWidth="2" opacity="0">
|
| 390 |
+
<animate attributeName="r" from="8" to="40" dur="1.2s" repeatCount="3" />
|
| 391 |
+
<animate attributeName="opacity" from="0.8" to="0" dur="1.2s" repeatCount="3" />
|
| 392 |
+
</circle>
|
| 393 |
+
<circle r="8" fill="none" stroke={fill} strokeWidth="1.5" opacity="0">
|
| 394 |
+
<animate attributeName="r" from="8" to="40" dur="1.2s" begin="0.3s" repeatCount="3" />
|
| 395 |
+
<animate attributeName="opacity" from="0.6" to="0" dur="1.2s" begin="0.3s" repeatCount="3" />
|
| 396 |
+
</circle>
|
| 397 |
+
</>
|
| 398 |
+
)}
|
| 399 |
+
</>
|
| 400 |
)}
|
| 401 |
<circle
|
| 402 |
r={selected ? radius + 3 : radius + 1.5}
|
|
|
|
| 418 |
>
|
| 419 |
{(node.name || node.id).slice(0, 26)}
|
| 420 |
</text>
|
| 421 |
+
{node.type === 'province' && risk >= 60 && (
|
| 422 |
+
<text
|
| 423 |
+
y={radius + 20}
|
| 424 |
+
textAnchor="middle"
|
| 425 |
+
fill="var(--c-teal, #00E5A0)"
|
| 426 |
+
fontSize="8"
|
| 427 |
+
fontFamily="var(--font-mono, monospace)"
|
| 428 |
+
style={{ cursor: 'pointer' }}
|
| 429 |
+
onClick={(e) => { e.stopPropagation(); triggerRipple(node.id); }}
|
| 430 |
+
>
|
| 431 |
+
⚡ TRIGGER
|
| 432 |
+
</text>
|
| 433 |
+
)}
|
| 434 |
</g>
|
| 435 |
);
|
| 436 |
})}
|
frontend/src/pages/Map.jsx
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
| 2 |
+
import { MapContainer, TileLayer, CircleMarker, Popup, Polyline, useMap } from 'react-leaflet';
|
| 3 |
+
import 'leaflet/dist/leaflet.css';
|
| 4 |
+
import { api } from '../api/client';
|
| 5 |
+
import MapFilterPanel from '../components/MapFilterPanel';
|
| 6 |
+
import L from 'leaflet';
|
| 7 |
+
|
| 8 |
+
delete L.Icon.Default.prototype._getIconUrl;
|
| 9 |
+
L.Icon.Default.mergeOptions({
|
| 10 |
+
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
| 11 |
+
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
| 12 |
+
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
function MapUpdater({ center, zoom }) {
|
| 16 |
+
const map = useMap();
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (center) map.flyTo(center, zoom, { duration: 1.2 });
|
| 19 |
+
}, [center, zoom, map]);
|
| 20 |
+
return null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const RISK_COLORS = {
|
| 24 |
+
critical: '#f43f5e',
|
| 25 |
+
high: '#f59e0b',
|
| 26 |
+
medium: '#60a5fa',
|
| 27 |
+
low: '#10b981',
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
function getRiskColor(score) {
|
| 31 |
+
if (score >= 80) return RISK_COLORS.critical;
|
| 32 |
+
if (score >= 60) return RISK_COLORS.high;
|
| 33 |
+
if (score >= 40) return RISK_COLORS.medium;
|
| 34 |
+
return RISK_COLORS.low;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function getRiskLabel(score) {
|
| 38 |
+
if (score >= 80) return 'CRITICAL';
|
| 39 |
+
if (score >= 60) return 'HIGH';
|
| 40 |
+
if (score >= 40) return 'MEDIUM';
|
| 41 |
+
return 'LOW';
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const REGION_CENTERS = {
|
| 45 |
+
all: { center: [28, 90], zoom: 4 },
|
| 46 |
+
china: { center: [35.86, 104.2], zoom: 4 },
|
| 47 |
+
india: { center: [22, 78.9], zoom: 5 },
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// ─── Stat Card ───────────────────────────────────────────────────────────
|
| 51 |
+
function StatCard({ icon, value, label, color }) {
|
| 52 |
+
return (
|
| 53 |
+
<div style={{
|
| 54 |
+
display: 'flex', alignItems: 'center', gap: 10,
|
| 55 |
+
padding: '10px 14px', borderRadius: 12,
|
| 56 |
+
background: 'rgba(13,17,23,0.8)', backdropFilter: 'blur(12px)',
|
| 57 |
+
border: '1px solid rgba(255,255,255,0.06)',
|
| 58 |
+
}}>
|
| 59 |
+
<span style={{ fontSize: '1.1rem' }}>{icon}</span>
|
| 60 |
+
<div>
|
| 61 |
+
<div style={{ fontSize: '1.1rem', fontWeight: 700, color: color || 'var(--text)', lineHeight: 1.1 }}>
|
| 62 |
+
{value}
|
| 63 |
+
</div>
|
| 64 |
+
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontWeight: 500 }}>{label}</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// ─── Province Detail Sidebar ─────────────────────────────────────────────
|
| 71 |
+
function ProvinceDetail({ detail, onClose }) {
|
| 72 |
+
if (!detail) return null;
|
| 73 |
+
const color = getRiskColor(detail.risk_score);
|
| 74 |
+
const label = getRiskLabel(detail.risk_score);
|
| 75 |
+
const isIndia = detail.region === 'india';
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div style={{
|
| 79 |
+
background: 'rgba(13,17,23,0.92)', backdropFilter: 'blur(24px)',
|
| 80 |
+
border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16,
|
| 81 |
+
padding: '20px', display: 'flex', flexDirection: 'column', gap: 14,
|
| 82 |
+
animation: 'slide-up 0.3s ease',
|
| 83 |
+
}}>
|
| 84 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
| 85 |
+
<div>
|
| 86 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
| 87 |
+
<span style={{ fontSize: '1rem' }}>{isIndia ? '🇮🇳' : '🇨🇳'}</span>
|
| 88 |
+
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: 0 }}>{detail.name}</h3>
|
| 89 |
+
</div>
|
| 90 |
+
<span style={{
|
| 91 |
+
fontSize: '0.65rem', padding: '2px 8px', borderRadius: 999,
|
| 92 |
+
background: `${color}18`, color, border: `1px solid ${color}40`,
|
| 93 |
+
fontWeight: 700, letterSpacing: '0.05em',
|
| 94 |
+
}}>
|
| 95 |
+
{label} — {detail.risk_score.toFixed(1)}
|
| 96 |
+
</span>
|
| 97 |
+
</div>
|
| 98 |
+
<button onClick={onClose} style={{
|
| 99 |
+
background: 'rgba(255,255,255,0.06)', border: '1px solid var(--border)',
|
| 100 |
+
borderRadius: 8, width: 28, height: 28, cursor: 'pointer',
|
| 101 |
+
color: 'var(--muted)', fontSize: '0.85rem', display: 'flex',
|
| 102 |
+
alignItems: 'center', justifyContent: 'center',
|
| 103 |
+
}}>✕</button>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Risk Gauge */}
|
| 107 |
+
<div>
|
| 108 |
+
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface2)', overflow: 'hidden' }}>
|
| 109 |
+
<div style={{
|
| 110 |
+
height: '100%', borderRadius: 3, width: `${detail.risk_score}%`,
|
| 111 |
+
background: `linear-gradient(90deg, ${color}aa, ${color})`,
|
| 112 |
+
transition: 'width 0.8s ease',
|
| 113 |
+
}} />
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{detail.description && (
|
| 118 |
+
<p style={{ fontSize: '0.78rem', color: 'var(--text)', lineHeight: 1.55, margin: 0, opacity: 0.85 }}>
|
| 119 |
+
{detail.description}
|
| 120 |
+
</p>
|
| 121 |
+
)}
|
| 122 |
+
|
| 123 |
+
{/* Quick Stats */}
|
| 124 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
| 125 |
+
{detail.factories?.length > 0 && (
|
| 126 |
+
<div style={{ padding: '8px 10px', borderRadius: 10, background: 'var(--surface2)', border: '1px solid var(--border)' }}>
|
| 127 |
+
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Factories</div>
|
| 128 |
+
<div style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--text)', marginTop: 2 }}>{detail.factories.length}</div>
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
<div style={{ padding: '8px 10px', borderRadius: 10, background: 'var(--surface2)', border: '1px solid var(--border)' }}>
|
| 132 |
+
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shocks</div>
|
| 133 |
+
<div style={{ fontSize: '1rem', fontWeight: 700, color: detail.shocks?.length > 0 ? '#f43f5e' : 'var(--text)', marginTop: 2 }}>
|
| 134 |
+
{detail.shocks?.length || 0}
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* Active Shocks */}
|
| 140 |
+
{detail.shocks?.length > 0 && (
|
| 141 |
+
<div>
|
| 142 |
+
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: '#f43f5e', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
| 143 |
+
⚠ Active Shocks
|
| 144 |
+
</div>
|
| 145 |
+
{detail.shocks.slice(0, 4).map(s => (
|
| 146 |
+
<div key={s.id} style={{
|
| 147 |
+
fontSize: '0.73rem', padding: '8px 10px', borderRadius: 8,
|
| 148 |
+
marginBottom: 6, borderLeft: '3px solid #f43f5e',
|
| 149 |
+
background: 'rgba(244,63,94,0.06)', lineHeight: 1.4,
|
| 150 |
+
}}>
|
| 151 |
+
<strong style={{ color: 'var(--text)' }}>
|
| 152 |
+
{(s.event_type || 'alert').replace(/_/g, ' ').toUpperCase()}
|
| 153 |
+
</strong>
|
| 154 |
+
<div style={{ color: 'var(--muted)', marginTop: 2 }}>{s.title}</div>
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
</div>
|
| 158 |
+
)}
|
| 159 |
+
|
| 160 |
+
{/* Top Entities */}
|
| 161 |
+
{detail.top_entities?.length > 0 && (
|
| 162 |
+
<div>
|
| 163 |
+
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
| 164 |
+
{isIndia ? 'Top Consumed Drugs' : 'Dependent Products'}
|
| 165 |
+
</div>
|
| 166 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
| 167 |
+
{detail.top_entities.map(e => (
|
| 168 |
+
<span key={e.id} style={{
|
| 169 |
+
fontSize: '0.68rem', padding: '3px 9px', borderRadius: 999,
|
| 170 |
+
background: 'var(--surface2)', border: '1px solid var(--border)',
|
| 171 |
+
color: 'var(--text)', fontWeight: 500,
|
| 172 |
+
}}>
|
| 173 |
+
{e.name}
|
| 174 |
+
</span>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
)}
|
| 179 |
+
|
| 180 |
+
{/* Factories List */}
|
| 181 |
+
{detail.factories?.length > 0 && (
|
| 182 |
+
<div>
|
| 183 |
+
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
| 184 |
+
Key Facilities
|
| 185 |
+
</div>
|
| 186 |
+
{detail.factories.map((f, i) => (
|
| 187 |
+
<div key={i} style={{
|
| 188 |
+
fontSize: '0.73rem', padding: '6px 10px', borderRadius: 8,
|
| 189 |
+
background: 'var(--surface2)', border: '1px solid var(--border)',
|
| 190 |
+
marginBottom: 4, color: 'var(--text)', display: 'flex',
|
| 191 |
+
alignItems: 'center', gap: 6,
|
| 192 |
+
}}>
|
| 193 |
+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--primary)', flexShrink: 0 }} />
|
| 194 |
+
{f}
|
| 195 |
+
</div>
|
| 196 |
+
))}
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
</div>
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// ─── Map Legend ───────────────────────────────────────────────────────────
|
| 204 |
+
function MapLegend() {
|
| 205 |
+
const items = [
|
| 206 |
+
{ color: RISK_COLORS.critical, label: 'Critical (80+)' },
|
| 207 |
+
{ color: RISK_COLORS.high, label: 'High (60-79)' },
|
| 208 |
+
{ color: RISK_COLORS.medium, label: 'Medium (40-59)' },
|
| 209 |
+
{ color: RISK_COLORS.low, label: 'Low (<40)' },
|
| 210 |
+
];
|
| 211 |
+
return (
|
| 212 |
+
<div style={{
|
| 213 |
+
position: 'absolute', bottom: 24, right: 16, zIndex: 1000,
|
| 214 |
+
background: 'rgba(13,17,23,0.88)', backdropFilter: 'blur(16px)',
|
| 215 |
+
border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12,
|
| 216 |
+
padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 6,
|
| 217 |
+
}}>
|
| 218 |
+
<div style={{ fontSize: '0.65rem', fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 2 }}>
|
| 219 |
+
Risk Level
|
| 220 |
+
</div>
|
| 221 |
+
{items.map(it => (
|
| 222 |
+
<div key={it.label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 223 |
+
<span style={{ width: 10, height: 10, borderRadius: '50%', background: it.color, boxShadow: `0 0 8px ${it.color}60`, flexShrink: 0 }} />
|
| 224 |
+
<span style={{ fontSize: '0.7rem', color: 'var(--text)' }}>{it.label}</span>
|
| 225 |
+
</div>
|
| 226 |
+
))}
|
| 227 |
+
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 6, marginTop: 2, display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 228 |
+
<span style={{ width: 10, height: 3, borderRadius: 2, background: 'rgba(96,165,250,0.5)', flexShrink: 0 }} />
|
| 229 |
+
<span style={{ fontSize: '0.68rem', color: 'var(--muted)' }}>Supply Corridor</span>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// ─── Main Map Component ──────────────────────────────────────────────────
|
| 236 |
+
export default function MapView() {
|
| 237 |
+
const [heatmap, setHeatmap] = useState({ points: [] });
|
| 238 |
+
const [corridors, setCorridors] = useState({ corridors: [] });
|
| 239 |
+
const [stats, setStats] = useState(null);
|
| 240 |
+
const [filters, setFilters] = useState({ sector: 'both', risk_min: 0, shock_type: '', region: 'all' });
|
| 241 |
+
const [selectedProvince, setSelectedProvince] = useState(null);
|
| 242 |
+
const [provinceDetail, setProvinceDetail] = useState(null);
|
| 243 |
+
const [showCorridors, setShowCorridors] = useState(false);
|
| 244 |
+
const [loading, setLoading] = useState(true);
|
| 245 |
+
const debounceRef = useRef(null);
|
| 246 |
+
|
| 247 |
+
const regionConf = REGION_CENTERS[filters.region] || REGION_CENTERS.all;
|
| 248 |
+
|
| 249 |
+
const fetchMapData = useCallback(async (f) => {
|
| 250 |
+
setLoading(true);
|
| 251 |
+
try {
|
| 252 |
+
const [hm, cr, st] = await Promise.all([
|
| 253 |
+
api.getMapHeatmap(f),
|
| 254 |
+
api.getMapCorridors(f.sector),
|
| 255 |
+
api.getMapStats(),
|
| 256 |
+
]);
|
| 257 |
+
setHeatmap(hm);
|
| 258 |
+
setCorridors(cr);
|
| 259 |
+
setStats(st);
|
| 260 |
+
} catch (err) {
|
| 261 |
+
console.error('Map data fetch failed', err);
|
| 262 |
+
} finally {
|
| 263 |
+
setLoading(false);
|
| 264 |
+
}
|
| 265 |
+
}, []);
|
| 266 |
+
|
| 267 |
+
useEffect(() => {
|
| 268 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 269 |
+
debounceRef.current = setTimeout(() => fetchMapData(filters), 300);
|
| 270 |
+
return () => clearTimeout(debounceRef.current);
|
| 271 |
+
}, [filters, fetchMapData]);
|
| 272 |
+
|
| 273 |
+
useEffect(() => {
|
| 274 |
+
if (selectedProvince) {
|
| 275 |
+
api.getMapProvince(selectedProvince, filters.sector)
|
| 276 |
+
.then(setProvinceDetail)
|
| 277 |
+
.catch(console.error);
|
| 278 |
+
} else {
|
| 279 |
+
setProvinceDetail(null);
|
| 280 |
+
}
|
| 281 |
+
}, [selectedProvince, filters.sector]);
|
| 282 |
+
|
| 283 |
+
const chinaPoints = (heatmap.points || []).filter(p => p.region === 'china');
|
| 284 |
+
const indiaPoints = (heatmap.points || []).filter(p => p.region === 'india');
|
| 285 |
+
const totalPoints = heatmap.points?.length || 0;
|
| 286 |
+
|
| 287 |
+
return (
|
| 288 |
+
<div style={{ display: 'flex', height: '100%', animation: 'fade-in 0.35s ease' }}>
|
| 289 |
+
{/* ── Left Sidebar ────────────────────────────────────────────── */}
|
| 290 |
+
<div style={{
|
| 291 |
+
width: 330, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 16,
|
| 292 |
+
padding: '20px 16px', overflowY: 'auto',
|
| 293 |
+
borderRight: '1px solid var(--border)', background: 'var(--bg)',
|
| 294 |
+
}}>
|
| 295 |
+
{/* Title */}
|
| 296 |
+
<div>
|
| 297 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
| 298 |
+
<div style={{
|
| 299 |
+
width: 28, height: 28, borderRadius: 8,
|
| 300 |
+
background: 'linear-gradient(135deg, #4f9cf9, #8b5cf6)',
|
| 301 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 302 |
+
fontSize: 11, fontWeight: 700, color: '#fff',
|
| 303 |
+
}}>⊕</div>
|
| 304 |
+
<h1 style={{ fontSize: '1.25rem', fontWeight: 800, margin: 0, letterSpacing: '-0.02em' }}>
|
| 305 |
+
Supply Map
|
| 306 |
+
</h1>
|
| 307 |
+
</div>
|
| 308 |
+
<p style={{ fontSize: '0.73rem', color: 'var(--muted)', margin: 0, lineHeight: 1.5 }}>
|
| 309 |
+
Real-time geospatial intelligence across {totalPoints} monitored nodes.
|
| 310 |
+
</p>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
{/* Quick Stats */}
|
| 314 |
+
{stats && (
|
| 315 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
| 316 |
+
<StatCard icon="⚡" value={stats.active_shocks} label="Active Shocks" color="#f43f5e" />
|
| 317 |
+
<StatCard icon="🔴" value={stats.high_risk_provinces} label="High Risk" color="#f59e0b" />
|
| 318 |
+
<StatCard icon="🏭" value={stats.total_factories} label="Factories" color="var(--primary)" />
|
| 319 |
+
<StatCard icon="⛓" value={stats.supply_corridors} label="Corridors" color="#8b5cf6" />
|
| 320 |
+
</div>
|
| 321 |
+
)}
|
| 322 |
+
|
| 323 |
+
{/* Filter Panel */}
|
| 324 |
+
<MapFilterPanel
|
| 325 |
+
onFilterChange={setFilters}
|
| 326 |
+
showCorridors={showCorridors}
|
| 327 |
+
onToggleCorridors={() => setShowCorridors(v => !v)}
|
| 328 |
+
/>
|
| 329 |
+
|
| 330 |
+
{/* Province Detail */}
|
| 331 |
+
<ProvinceDetail detail={provinceDetail} onClose={() => setSelectedProvince(null)} />
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
{/* ── Map Container ───────────────────────────────────────────── */}
|
| 335 |
+
<div style={{ flex: 1, position: 'relative' }}>
|
| 336 |
+
{/* Loading Indicator */}
|
| 337 |
+
{loading && (
|
| 338 |
+
<div style={{
|
| 339 |
+
position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)',
|
| 340 |
+
zIndex: 1000, background: 'rgba(13,17,23,0.9)', backdropFilter: 'blur(12px)',
|
| 341 |
+
padding: '6px 18px', borderRadius: 999, fontSize: '0.75rem', fontWeight: 600,
|
| 342 |
+
border: '1px solid rgba(79,156,249,0.2)', color: 'var(--primary)',
|
| 343 |
+
display: 'flex', alignItems: 'center', gap: 8,
|
| 344 |
+
}}>
|
| 345 |
+
<span className="live-dot" style={{ width: 6, height: 6 }} />
|
| 346 |
+
Updating map data…
|
| 347 |
+
</div>
|
| 348 |
+
)}
|
| 349 |
+
|
| 350 |
+
<MapContainer
|
| 351 |
+
center={regionConf.center}
|
| 352 |
+
zoom={regionConf.zoom}
|
| 353 |
+
style={{ height: '100%', width: '100%', background: '#05070f' }}
|
| 354 |
+
zoomControl={false}
|
| 355 |
+
>
|
| 356 |
+
<TileLayer
|
| 357 |
+
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
| 358 |
+
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>'
|
| 359 |
+
/>
|
| 360 |
+
|
| 361 |
+
<MapUpdater
|
| 362 |
+
center={selectedProvince && provinceDetail
|
| 363 |
+
? [provinceDetail.coordinates.lat, provinceDetail.coordinates.lng]
|
| 364 |
+
: regionConf.center}
|
| 365 |
+
zoom={selectedProvince ? 6 : regionConf.zoom}
|
| 366 |
+
/>
|
| 367 |
+
|
| 368 |
+
{/* Supply Corridors */}
|
| 369 |
+
{showCorridors && corridors.corridors?.map(c => {
|
| 370 |
+
const riskColor = getRiskColor(c.risk_score || 30);
|
| 371 |
+
return (
|
| 372 |
+
<Polyline
|
| 373 |
+
key={c.id}
|
| 374 |
+
positions={[[c.source.lat, c.source.lng], [c.target.lat, c.target.lng]]}
|
| 375 |
+
pathOptions={{
|
| 376 |
+
color: `${riskColor}80`,
|
| 377 |
+
weight: Math.max(1.5, Math.min(5, c.weight * 1.5)),
|
| 378 |
+
dashArray: '6, 10',
|
| 379 |
+
className: 'animated-polyline',
|
| 380 |
+
}}
|
| 381 |
+
>
|
| 382 |
+
<Popup>
|
| 383 |
+
<div style={{ minWidth: 160 }}>
|
| 384 |
+
<strong style={{ fontSize: '0.82rem' }}>
|
| 385 |
+
🇨🇳 {c.source.name} ➜ 🇮🇳 {c.target.name}
|
| 386 |
+
</strong>
|
| 387 |
+
<div style={{ fontSize: '0.72rem', color: '#9ca3af', marginTop: 6 }}>
|
| 388 |
+
{c.weight} dependent product{c.weight > 1 ? 's' : ''} on this route
|
| 389 |
+
</div>
|
| 390 |
+
{c.affected_entities?.length > 0 && (
|
| 391 |
+
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
| 392 |
+
{c.affected_entities.map(e => (
|
| 393 |
+
<span key={e} style={{
|
| 394 |
+
fontSize: '0.65rem', padding: '2px 6px', borderRadius: 999,
|
| 395 |
+
background: '#1e293b', border: '1px solid #334155',
|
| 396 |
+
}}>{e}</span>
|
| 397 |
+
))}
|
| 398 |
+
</div>
|
| 399 |
+
)}
|
| 400 |
+
</div>
|
| 401 |
+
</Popup>
|
| 402 |
+
</Polyline>
|
| 403 |
+
);
|
| 404 |
+
})}
|
| 405 |
+
|
| 406 |
+
{/* China Province Markers */}
|
| 407 |
+
{chinaPoints.map(p => {
|
| 408 |
+
const color = getRiskColor(p.risk_score);
|
| 409 |
+
const radius = Math.max(8, Math.min(28, p.risk_score / 3.5));
|
| 410 |
+
const isShocked = p.shock_count > 0;
|
| 411 |
+
return (
|
| 412 |
+
<CircleMarker
|
| 413 |
+
key={`cn-${p.id}`}
|
| 414 |
+
center={[p.latitude, p.longitude]}
|
| 415 |
+
radius={radius}
|
| 416 |
+
pathOptions={{
|
| 417 |
+
color: isShocked ? '#f43f5e' : color,
|
| 418 |
+
fillColor: color,
|
| 419 |
+
fillOpacity: isShocked ? 0.7 : 0.35,
|
| 420 |
+
weight: isShocked ? 2.5 : 1,
|
| 421 |
+
}}
|
| 422 |
+
eventHandlers={{ click: () => setSelectedProvince(p.id) }}
|
| 423 |
+
>
|
| 424 |
+
<Popup>
|
| 425 |
+
<div style={{ minWidth: 160 }}>
|
| 426 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
| 427 |
+
<span>🇨🇳</span>
|
| 428 |
+
<strong style={{ fontSize: '0.9rem' }}>{p.name}</strong>
|
| 429 |
+
</div>
|
| 430 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.78rem', marginBottom: 4 }}>
|
| 431 |
+
<span style={{ color: '#9ca3af' }}>Risk:</span>
|
| 432 |
+
<strong style={{ color }}>{p.risk_score.toFixed(1)}</strong>
|
| 433 |
+
</div>
|
| 434 |
+
{isShocked && (
|
| 435 |
+
<div style={{ fontSize: '0.75rem', color: '#f43f5e', fontWeight: 600, marginBottom: 4 }}>
|
| 436 |
+
⚠ {p.shock_count} Active Shock{p.shock_count > 1 ? 's' : ''}
|
| 437 |
+
</div>
|
| 438 |
+
)}
|
| 439 |
+
<button
|
| 440 |
+
onClick={() => setSelectedProvince(p.id)}
|
| 441 |
+
style={{
|
| 442 |
+
width: '100%', padding: 5, marginTop: 6,
|
| 443 |
+
background: '#4f9cf9', color: '#fff', border: 'none',
|
| 444 |
+
borderRadius: 6, cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600,
|
| 445 |
+
}}
|
| 446 |
+
>View Details</button>
|
| 447 |
+
</div>
|
| 448 |
+
</Popup>
|
| 449 |
+
</CircleMarker>
|
| 450 |
+
);
|
| 451 |
+
})}
|
| 452 |
+
|
| 453 |
+
{/* India State Markers */}
|
| 454 |
+
{indiaPoints.map(p => {
|
| 455 |
+
const color = getRiskColor(p.risk_score);
|
| 456 |
+
const radius = Math.max(7, Math.min(22, p.risk_score / 4));
|
| 457 |
+
return (
|
| 458 |
+
<CircleMarker
|
| 459 |
+
key={`in-${p.id}`}
|
| 460 |
+
center={[p.latitude, p.longitude]}
|
| 461 |
+
radius={radius}
|
| 462 |
+
pathOptions={{
|
| 463 |
+
color: `${color}cc`,
|
| 464 |
+
fillColor: color,
|
| 465 |
+
fillOpacity: 0.3,
|
| 466 |
+
weight: 1.5,
|
| 467 |
+
dashArray: '4, 4',
|
| 468 |
+
}}
|
| 469 |
+
eventHandlers={{ click: () => setSelectedProvince(p.id) }}
|
| 470 |
+
>
|
| 471 |
+
<Popup>
|
| 472 |
+
<div style={{ minWidth: 150 }}>
|
| 473 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
| 474 |
+
<span>🇮🇳</span>
|
| 475 |
+
<strong style={{ fontSize: '0.9rem' }}>{p.name}</strong>
|
| 476 |
+
</div>
|
| 477 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.78rem', marginBottom: 4 }}>
|
| 478 |
+
<span style={{ color: '#9ca3af' }}>Dependency Risk:</span>
|
| 479 |
+
<strong style={{ color }}>{p.risk_score.toFixed(1)}</strong>
|
| 480 |
+
</div>
|
| 481 |
+
{p.shock_count > 0 && (
|
| 482 |
+
<div style={{ fontSize: '0.75rem', color: '#f59e0b', fontWeight: 600 }}>
|
| 483 |
+
📡 {p.shock_count} upstream shock{p.shock_count > 1 ? 's' : ''}
|
| 484 |
+
</div>
|
| 485 |
+
)}
|
| 486 |
+
<button
|
| 487 |
+
onClick={() => setSelectedProvince(p.id)}
|
| 488 |
+
style={{
|
| 489 |
+
width: '100%', padding: 5, marginTop: 6,
|
| 490 |
+
background: '#8b5cf6', color: '#fff', border: 'none',
|
| 491 |
+
borderRadius: 6, cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600,
|
| 492 |
+
}}
|
| 493 |
+
>View Details</button>
|
| 494 |
+
</div>
|
| 495 |
+
</Popup>
|
| 496 |
+
</CircleMarker>
|
| 497 |
+
);
|
| 498 |
+
})}
|
| 499 |
+
</MapContainer>
|
| 500 |
+
|
| 501 |
+
{/* Legend */}
|
| 502 |
+
<MapLegend />
|
| 503 |
+
|
| 504 |
+
{/* Map CSS Overrides */}
|
| 505 |
+
<style dangerouslySetInnerHTML={{ __html: `
|
| 506 |
+
.animated-polyline { animation: dash 30s linear infinite; }
|
| 507 |
+
@keyframes dash { to { stroke-dashoffset: -1000; } }
|
| 508 |
+
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
|
| 509 |
+
background: #0d1117 !important; color: #e8eaf0 !important;
|
| 510 |
+
border: 1px solid rgba(255,255,255,0.08); border-radius: 12px !important;
|
| 511 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important;
|
| 512 |
+
}
|
| 513 |
+
.leaflet-popup-content { margin: 12px 14px !important; }
|
| 514 |
+
.leaflet-container a.leaflet-popup-close-button { color: #6b7280 !important; }
|
| 515 |
+
.leaflet-control-zoom a {
|
| 516 |
+
background: rgba(13,17,23,0.9) !important; color: #e8eaf0 !important;
|
| 517 |
+
border-color: rgba(255,255,255,0.08) !important;
|
| 518 |
+
}
|
| 519 |
+
.leaflet-control-zoom a:hover { background: rgba(30,36,48,0.95) !important; }
|
| 520 |
+
`}} />
|
| 521 |
+
</div>
|
| 522 |
+
</div>
|
| 523 |
+
);
|
| 524 |
+
}
|
frontend/src/styles/tokens.css
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ═══════════════════════════════════════════════════════
|
| 2 |
+
ShockMap — Military-Grade Command Center Design Tokens
|
| 3 |
+
Google Solution Challenge 2026
|
| 4 |
+
═══════════════════════════════════════════════════════ */
|
| 5 |
+
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap');
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
/* ── Core Palette ─────────────────────────── */
|
| 9 |
+
--c-bg: #080C10;
|
| 10 |
+
--c-bg-raised: #0D1117;
|
| 11 |
+
--c-surface: #1A2332;
|
| 12 |
+
--c-surface2: #1E2A3A;
|
| 13 |
+
--c-border: rgba(255,255,255,0.06);
|
| 14 |
+
--c-border2: rgba(255,255,255,0.10);
|
| 15 |
+
|
| 16 |
+
/* ── Accent System ────────────────────────── */
|
| 17 |
+
--c-teal: #00E5A0;
|
| 18 |
+
--c-teal-dim: rgba(0,229,160,0.12);
|
| 19 |
+
--c-teal-glow: rgba(0,229,160,0.25);
|
| 20 |
+
--c-red: #FF4444;
|
| 21 |
+
--c-red-dim: rgba(255,68,68,0.12);
|
| 22 |
+
--c-red-glow: rgba(255,68,68,0.3);
|
| 23 |
+
--c-amber: #FF8800;
|
| 24 |
+
--c-amber-dim: rgba(255,136,0,0.12);
|
| 25 |
+
|
| 26 |
+
/* ── Text ─────────────────────────────────── */
|
| 27 |
+
--c-text: #E8EDF2;
|
| 28 |
+
--c-text2: #6B7E94;
|
| 29 |
+
--c-text3: #3D5166;
|
| 30 |
+
|
| 31 |
+
/* ── Animation Tiers ─────────────────────── */
|
| 32 |
+
--anim-instant: 80ms;
|
| 33 |
+
--anim-fast: 200ms;
|
| 34 |
+
--anim-slow: 600ms;
|
| 35 |
+
|
| 36 |
+
/* ── Typography ───────────────────────────── */
|
| 37 |
+
--font-display: 'Syne', sans-serif;
|
| 38 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 39 |
+
--font-body: 'IBM Plex Sans', system-ui, sans-serif;
|
| 40 |
+
|
| 41 |
+
/* ── Shadows ─────────────────────────────── */
|
| 42 |
+
--shadow-card: 0 2px 16px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.04);
|
| 43 |
+
--shadow-glow-teal: 0 0 24px rgba(0,229,160,0.2);
|
| 44 |
+
--shadow-glow-red: 0 0 24px rgba(255,68,68,0.3);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* ── Global Reset ───────────────────────────────────── */
|
| 48 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 49 |
+
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
|
| 50 |
+
body {
|
| 51 |
+
font-family: var(--font-body);
|
| 52 |
+
background: var(--c-bg);
|
| 53 |
+
color: var(--c-text);
|
| 54 |
+
min-height: 100vh;
|
| 55 |
+
overflow-x: hidden;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ── Scrollbar ──────────────────────────────────────── */
|
| 59 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 60 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 61 |
+
::-webkit-scrollbar-thumb { background: var(--c-teal); border-radius: 2px; opacity: 0.4; }
|
| 62 |
+
::-webkit-scrollbar-thumb:hover { opacity: 0.7; }
|
| 63 |
+
|
| 64 |
+
/* ── Card System ────────────────────────────────────── */
|
| 65 |
+
.card-glass {
|
| 66 |
+
background: rgba(26,35,50,0.85);
|
| 67 |
+
border: 1px solid var(--c-border);
|
| 68 |
+
border-radius: 8px;
|
| 69 |
+
box-shadow: var(--shadow-card);
|
| 70 |
+
backdrop-filter: blur(12px);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* ── Data State Classes ─────────────────────────────── */
|
| 74 |
+
.state-critical {
|
| 75 |
+
border-color: rgba(255,68,68,0.4) !important;
|
| 76 |
+
animation: glow-red 2s ease-in-out infinite;
|
| 77 |
+
}
|
| 78 |
+
.state-elevated {
|
| 79 |
+
border-color: rgba(255,136,0,0.35) !important;
|
| 80 |
+
animation: glow-amber 2.5s ease-in-out infinite;
|
| 81 |
+
}
|
| 82 |
+
.state-normal {
|
| 83 |
+
border-color: rgba(0,229,160,0.3) !important;
|
| 84 |
+
animation: glow-teal 3s ease-in-out infinite;
|
| 85 |
+
}
|
| 86 |
+
.state-stale { border-color: rgba(61,81,102,0.4) !important; opacity: 0.6; }
|
| 87 |
+
|
| 88 |
+
/* ── Pulse-ring for shocked provinces ──────────────── */
|
| 89 |
+
.pulse-ring {
|
| 90 |
+
position: absolute;
|
| 91 |
+
border-radius: 50%;
|
| 92 |
+
animation: pulse-ring 2s cubic-bezier(0.215,0.61,0.355,1) infinite;
|
| 93 |
+
}
|
| 94 |
+
.pulse-ring:nth-child(2) { animation-delay: 0.5s; }
|
| 95 |
+
.pulse-ring:nth-child(3) { animation-delay: 1s; }
|
| 96 |
+
|
| 97 |
+
/* ── Keyframes ──────────────────────────────────────── */
|
| 98 |
+
@keyframes glow-red {
|
| 99 |
+
0%,100% { box-shadow: 0 0 8px rgba(255,68,68,0.2); }
|
| 100 |
+
50% { box-shadow: 0 0 20px rgba(255,68,68,0.45); }
|
| 101 |
+
}
|
| 102 |
+
@keyframes glow-amber {
|
| 103 |
+
0%,100% { box-shadow: 0 0 8px rgba(255,136,0,0.15); }
|
| 104 |
+
50% { box-shadow: 0 0 18px rgba(255,136,0,0.35); }
|
| 105 |
+
}
|
| 106 |
+
@keyframes glow-teal {
|
| 107 |
+
0%,100% { box-shadow: 0 0 8px rgba(0,229,160,0.1); }
|
| 108 |
+
50% { box-shadow: 0 0 16px rgba(0,229,160,0.28); }
|
| 109 |
+
}
|
| 110 |
+
@keyframes pulse-ring {
|
| 111 |
+
0% { transform: scale(0.8); opacity: 0.9; }
|
| 112 |
+
80% { transform: scale(2.4); opacity: 0; }
|
| 113 |
+
100% { transform: scale(2.4); opacity: 0; }
|
| 114 |
+
}
|
| 115 |
+
@keyframes scanline {
|
| 116 |
+
0% { transform: translateX(-100%); }
|
| 117 |
+
100% { transform: translateX(100%); }
|
| 118 |
+
}
|
| 119 |
+
@keyframes slide-up {
|
| 120 |
+
from { transform: translateY(12px); opacity: 0; }
|
| 121 |
+
to { transform: translateY(0); opacity: 1; }
|
| 122 |
+
}
|
| 123 |
+
@keyframes fade-in {
|
| 124 |
+
from { opacity: 0; }
|
| 125 |
+
to { opacity: 1; }
|
| 126 |
+
}
|
| 127 |
+
@keyframes count-up {
|
| 128 |
+
from { opacity: 0; transform: translateY(6px); }
|
| 129 |
+
to { opacity: 1; transform: translateY(0); }
|
| 130 |
+
}
|
| 131 |
+
@keyframes shock-flash {
|
| 132 |
+
0% { background: rgba(255,68,68,0.25); }
|
| 133 |
+
100% { background: transparent; }
|
| 134 |
+
}
|
| 135 |
+
@keyframes particle-travel {
|
| 136 |
+
0% { stroke-dashoffset: 100; opacity: 1; }
|
| 137 |
+
100% { stroke-dashoffset: 0; opacity: 0.2; }
|
| 138 |
+
}
|
| 139 |
+
@keyframes ripple-out {
|
| 140 |
+
0% { r: 8; opacity: 0.9; }
|
| 141 |
+
100% { r: 40; opacity: 0; }
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* ── Utility Classes ────────────────────────────────── */
|
| 145 |
+
.animate-slide-up { animation: slide-up var(--anim-slow) ease forwards; }
|
| 146 |
+
.animate-fade-in { animation: fade-in var(--anim-fast) ease forwards; }
|
| 147 |
+
.animate-count-up { animation: count-up var(--anim-fast) ease forwards; }
|
| 148 |
+
.font-display { font-family: var(--font-display); }
|
| 149 |
+
.font-mono { font-family: var(--font-mono); }
|
| 150 |
+
.text-teal { color: var(--c-teal); }
|
| 151 |
+
.text-red { color: var(--c-red); }
|
| 152 |
+
.text-amber { color: var(--c-amber); }
|
| 153 |
+
.text-muted { color: var(--c-text2); }
|
| 154 |
+
|
| 155 |
+
/* ── Risk Badge Pills ───────────────────────────────── */
|
| 156 |
+
.badge-critical { background: var(--c-red-dim); color: var(--c-red); border: 1px solid rgba(255,68,68,0.3); }
|
| 157 |
+
.badge-elevated { background: var(--c-amber-dim); color: var(--c-amber); border: 1px solid rgba(255,136,0,0.3); }
|
| 158 |
+
.badge-normal { background: var(--c-teal-dim); color: var(--c-teal); border: 1px solid rgba(0,229,160,0.3); }
|
frontend/vite.config.js
CHANGED
|
@@ -6,8 +6,8 @@ export default defineConfig({
|
|
| 6 |
server: {
|
| 7 |
port: 5173,
|
| 8 |
proxy: {
|
| 9 |
-
'/api': { target: 'http://localhost:
|
| 10 |
-
'/healthz': { target: 'http://localhost:
|
| 11 |
},
|
| 12 |
},
|
| 13 |
});
|
|
|
|
| 6 |
server: {
|
| 7 |
port: 5173,
|
| 8 |
proxy: {
|
| 9 |
+
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
| 10 |
+
'/healthz': { target: 'http://localhost:8080', changeOrigin: true },
|
| 11 |
},
|
| 12 |
},
|
| 13 |
});
|
ingestion/Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
RUN apt-get update && apt-get install -y gcc g++ && rm -rf /var/lib/apt/lists/*
|
| 4 |
+
COPY backend/requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY . .
|
| 7 |
+
CMD python -m ingestion.shock_detector
|
scripts/check_build.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# scripts/check_build.sh
|
| 3 |
+
# Final deployment check for ShockMap
|
| 4 |
+
|
| 5 |
+
echo "🔍 Starting ShockMap Deployment Audit..."
|
| 6 |
+
|
| 7 |
+
# 1. Backend check
|
| 8 |
+
echo "🐍 Auditing Backend..."
|
| 9 |
+
cd backend
|
| 10 |
+
if python -c "from app.main import app; print('✅ Backend imports successful')" ; then
|
| 11 |
+
echo " - Backend imports: OK"
|
| 12 |
+
else
|
| 13 |
+
echo " ❌ Backend imports: FAILED"
|
| 14 |
+
exit 1
|
| 15 |
+
fi
|
| 16 |
+
cd ..
|
| 17 |
+
|
| 18 |
+
# 2. Frontend check
|
| 19 |
+
echo "⚛️ Auditing Frontend..."
|
| 20 |
+
cd frontend
|
| 21 |
+
if npm run build ; then
|
| 22 |
+
echo " - Frontend build: OK"
|
| 23 |
+
else
|
| 24 |
+
echo " ❌ Frontend build: FAILED"
|
| 25 |
+
exit 1
|
| 26 |
+
fi
|
| 27 |
+
cd ..
|
| 28 |
+
|
| 29 |
+
# 3. Deployment files check
|
| 30 |
+
echo "🚀 Auditing Deployment Configs..."
|
| 31 |
+
if [ -f "render.yaml" ] && [ -f "netlify.toml" ] && [ -f "ingestion/Dockerfile" ]; then
|
| 32 |
+
echo " - Deployment manifests: OK"
|
| 33 |
+
else
|
| 34 |
+
echo " ❌ Deployment manifests: MISSING"
|
| 35 |
+
exit 1
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
echo "✅ SHOCKMAP READY FOR DEPLOYMENT"
|
| 39 |
+
echo "--------------------------------"
|
| 40 |
+
echo "Backend: Render (using render.yaml)"
|
| 41 |
+
echo "Frontend: Netlify (set VITE_BACKEND_URL to your Render URL)"
|
| 42 |
+
echo "Worker: Shock Detector (using ingestion/Dockerfile)"
|
shockmap_prompt_guide.md
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ShockMap — Final Polish & Deployment Prompt Guide
|
| 2 |
+
### Google Solution Challenge 2026 | Phase 1 Completion
|
| 3 |
+
|
| 4 |
+
> **How to use this file:** Each section is a self-contained prompt. Paste it directly into Claude, Cursor, or your AI coding tool. Work top to bottom — the design system must come first, then features, then deployment. Mock data is explicitly permitted in every prompt.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Table of Contents
|
| 9 |
+
|
| 10 |
+
1. [Design System Overhaul](#1-design-system-overhaul)
|
| 11 |
+
2. [Dashboard — Animated HHI Globe + Live Ticker](#2-dashboard)
|
| 12 |
+
3. [Propagation Graph — THE WOW FEATURE](#3-propagation-graph-wow)
|
| 13 |
+
4. [War Room — Cinematic Incident Command](#4-war-room)
|
| 14 |
+
5. [Supply Map — Animated Shock Corridors](#5-supply-map)
|
| 15 |
+
6. [Query Interface — Streaming Analyst](#6-query-interface)
|
| 16 |
+
7. [Alerts Feed — Live Signal Wall](#7-alerts-feed)
|
| 17 |
+
8. [Global Bug Fixes & UX Hardening](#8-global-bug-fixes)
|
| 18 |
+
9. [Demo Mode — Autopilot & Keyboard Shortcuts](#9-demo-mode)
|
| 19 |
+
10. [Deployment — Docker + Railway/Render](#10-deployment)
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 1. Design System Overhaul
|
| 24 |
+
|
| 25 |
+
> **Run this first. Everything else builds on this.**
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
I am building ShockMap — a real-time supply shock intelligence platform for India's critical import dependencies (pharma APIs, rare earth minerals). The stack is React 19 + Vite + Tailwind CSS + FastAPI. Judges are Google engineers evaluating real-world impact and technical polish.
|
| 29 |
+
|
| 30 |
+
I need you to implement a complete design system in `frontend/src/styles/tokens.css` and a global `theme.ts` config. The aesthetic is **military-grade command center + editorial data journalism** — think Bloomberg Terminal meets a dark ops war room. NOT purple-gradient AI slop.
|
| 31 |
+
|
| 32 |
+
Design decisions to commit to:
|
| 33 |
+
- **Color palette**: Near-black base `#080C10`, with a single electric accent `#00E5A0` (toxic teal for active/safe states) and `#FF4444` (alarm red for critical shocks). Muted `#1A2332` for cards. Text: `#E8EDF2` primary, `#6B7E94` secondary.
|
| 34 |
+
- **Typography**: Import `Syne` (display headings — bold, slightly condensed geometric) + `JetBrains Mono` (data values, metrics, codes) + `IBM Plex Sans` (body UI text). All via Google Fonts.
|
| 35 |
+
- **Motion system**: Define three animation tiers in CSS vars:
|
| 36 |
+
- `--anim-instant`: 80ms (micro, hover states)
|
| 37 |
+
- `--anim-fast`: 200ms (panel transitions)
|
| 38 |
+
- `--anim-slow`: 600ms (page reveals, shock propagation)
|
| 39 |
+
- **Data states**: CSS classes for `state-critical` (red pulse glow), `state-elevated` (amber), `state-normal` (teal), `state-stale` (grey). Each should have a subtle animated border-glow keyframe.
|
| 40 |
+
- **Card system**: `.card-glass` — dark semi-transparent background, 1px border using `rgba(255,255,255,0.06)`, subtle inset shadow, no border-radius above 8px (this is ops software, not a consumer app).
|
| 41 |
+
- **Grid**: Define a strict 12-column CSS grid. Include `.grid-dashboard` (3 cols large, 2 medium, 1 small) and `.grid-detail` (sidebar + main).
|
| 42 |
+
- **Scrollbars**: Custom thin scrollbar in the accent color.
|
| 43 |
+
|
| 44 |
+
Also create `frontend/src/components/ui/` with these base components:
|
| 45 |
+
- `<RiskBadge level="critical|elevated|normal" />` — animated pill with pulsing dot
|
| 46 |
+
- `<MetricCard title value delta trend />` — large mono number, small label, optional sparkline div slot
|
| 47 |
+
- `<SectionHeader title subtitle eyebrow />` — Syne display font, eyebrow in mono caps
|
| 48 |
+
- `<LoadingShock />` — a custom loader: animated horizontal scan line, not a spinner
|
| 49 |
+
|
| 50 |
+
Apply the design system globally in `App.tsx`. The overall feel: someone looking at this should feel like they're in a Bloomberg terminal crossed with a Pentagon situation room.
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## 2. Dashboard
|
| 56 |
+
|
| 57 |
+
```
|
| 58 |
+
In ShockMap's React 19 frontend, I need to rebuild `frontend/src/pages/Dashboard.tsx` with three major enhancements. The design system (Syne + JetBrains Mono + dark command center palette) is already in place.
|
| 59 |
+
|
| 60 |
+
**Enhancement 1: Animated HHI Concentration Heatmap (replace the static version)**
|
| 61 |
+
- Use `react-simple-maps` + custom SVG overlays to render a China province map
|
| 62 |
+
- Color provinces using HHI score: a gradient from `#1A2332` (low) → `#FF8800` (medium) → `#FF4444` (critical concentration)
|
| 63 |
+
- On load: provinces should fade in with staggered 40ms delays using CSS animation
|
| 64 |
+
- On hover: show a tooltip with: Province name, HHI score, top 3 APIs sourced, % of India's supply
|
| 65 |
+
- Active shock provinces: add a continuous `pulse-ring` CSS animation (expanding ring, opacity fade) in alarm red
|
| 66 |
+
- Use this mock data inline (no API needed): Hebei (HHI: 0.82, APIs: Paracetamol API, 6-APA, Para-aminophenol), Shandong (HHI: 0.71, APIs: Ciprofloxacin API, Metformin), Inner Mongolia (HHI: 0.65, APIs: Rare Earth Oxides, Neodymium)
|
| 67 |
+
|
| 68 |
+
**Enhancement 2: Real-time Shock Ticker (top of page)**
|
| 69 |
+
- A horizontally auto-scrolling ticker bar pinned below the nav
|
| 70 |
+
- Each item: severity dot + shock title + province + time-ago
|
| 71 |
+
- CSS marquee-style infinite scroll, pausable on hover
|
| 72 |
+
- Items pulse red when severity is CRITICAL
|
| 73 |
+
- Wire to `/api/v1/shocks` with 30s polling; use mock data if API is offline
|
| 74 |
+
|
| 75 |
+
**Enhancement 3: System Status Bar (bottom of page)**
|
| 76 |
+
- Sticky bottom bar showing: Engine 1 status (live/demo mode indicator), Engine 2 last propagation run timestamp, Engine 3 (Gemini) status (green/grey), total shocks monitored count, feed mode badge
|
| 77 |
+
- All values animate in with a typewriter effect on mount
|
| 78 |
+
- If `DEMO_MODE=true`, show a distinct amber "DEMO SCENARIO ACTIVE" badge
|
| 79 |
+
|
| 80 |
+
Keep all existing metric cards but upgrade them to use `<MetricCard>` with a mini sparkline (7-day fake data array is fine).
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## 3. Propagation Graph — WOW
|
| 86 |
+
|
| 87 |
+
> **This is your single most impressive visual. Spend the most time here.**
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
I need to build the most visually stunning feature of ShockMap: the Shock Propagation Graph in `frontend/src/pages/GraphExplorer.tsx`. This must be the "jaw-drop" moment for Google Solution Challenge judges. Use React 19 + Vite. The design system (dark command center, Syne/JetBrains Mono) is in place.
|
| 91 |
+
|
| 92 |
+
**Library**: Use `@react-sigma/core` with `graphology` (Sigma.js v3). This handles large graph rendering at 60fps in WebGL. Install: `npm install @react-sigma/core graphology graphology-layout-forceatlas2 sigma`.
|
| 93 |
+
|
| 94 |
+
**Graph Data (use this mock, no API needed):**
|
| 95 |
+
```json
|
| 96 |
+
{
|
| 97 |
+
"nodes": [
|
| 98 |
+
{"id": "hebei", "label": "Hebei Province", "type": "province", "risk": 95, "x": 0, "y": 0},
|
| 99 |
+
{"id": "pap", "label": "Para-Aminophenol", "type": "ksm", "risk": 88, "x": 1, "y": 1},
|
| 100 |
+
{"id": "6apa", "label": "6-APA", "type": "ksm", "risk": 72, "x": 1, "y": -1},
|
| 101 |
+
{"id": "paracetamol_api", "label": "Paracetamol API", "type": "api", "risk": 85, "x": 2, "y": 1},
|
| 102 |
+
{"id": "amoxicillin_api", "label": "Amoxicillin API", "type": "api", "risk": 68, "x": 2, "y": -1},
|
| 103 |
+
{"id": "paracetamol", "label": "Paracetamol", "type": "drug", "risk": 82, "x": 3, "y": 1.5},
|
| 104 |
+
{"id": "ceftriaxone", "label": "Ceftriaxone", "type": "drug", "risk": 74, "x": 3, "y": 0},
|
| 105 |
+
{"id": "amoxicillin", "label": "Amoxicillin", "type": "drug", "risk": 65, "x": 3, "y": -1.5},
|
| 106 |
+
{"id": "shandong", "label": "Shandong Province", "type": "province", "risk": 45, "x": 0, "y": -3},
|
| 107 |
+
{"id": "ciprofloxacin_api", "label": "Ciprofloxacin API", "type": "api", "risk": 40, "x": 2, "y": -3},
|
| 108 |
+
{"id": "ciprofloxacin", "label": "Ciprofloxacin", "type": "drug", "risk": 38, "x": 3, "y": -3}
|
| 109 |
+
],
|
| 110 |
+
"edges": [
|
| 111 |
+
{"source": "hebei", "target": "pap", "weight": 0.9},
|
| 112 |
+
{"source": "hebei", "target": "6apa", "weight": 0.7},
|
| 113 |
+
{"source": "pap", "target": "paracetamol_api", "weight": 0.85},
|
| 114 |
+
{"source": "6apa", "target": "amoxicillin_api", "weight": 0.68},
|
| 115 |
+
{"source": "paracetamol_api", "target": "paracetamol", "weight": 0.82},
|
| 116 |
+
{"source": "paracetamol_api", "target": "ceftriaxone", "weight": 0.6},
|
| 117 |
+
{"source": "amoxicillin_api", "target": "amoxicillin", "weight": 0.65},
|
| 118 |
+
{"source": "shandong", "target": "ciprofloxacin_api", "weight": 0.4},
|
| 119 |
+
{"source": "ciprofloxacin_api", "target": "ciprofloxacin", "weight": 0.38}
|
| 120 |
+
]
|
| 121 |
+
}
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
**Visual specifications — this is what makes it WOW:**
|
| 125 |
+
|
| 126 |
+
1. **Node appearance by type:**
|
| 127 |
+
- `province`: large hexagon shape, bright red glow if risk > 70, with a slow rotating outer ring animation
|
| 128 |
+
- `ksm` (key starting material): medium diamond shape, amber
|
| 129 |
+
- `api`: medium circle, orange-red gradient based on risk score
|
| 130 |
+
- `drug`: rounded square, color interpolated from green (risk 0) to red (risk 100)
|
| 131 |
+
- All nodes: size proportional to risk score (min 8px, max 30px)
|
| 132 |
+
- Node label: show below, JetBrains Mono font, small
|
| 133 |
+
|
| 134 |
+
2. **Edge appearance:**
|
| 135 |
+
- Edge width proportional to `weight`
|
| 136 |
+
- Color: interpolate from `#334455` (low risk) to `#FF4444` (high risk path)
|
| 137 |
+
- **Animated shock pulse**: When a shock is "active" on a node, animate a glowing particle traveling along each outgoing edge — a bright dot that moves from source to target repeatedly. This is the single most impressive visual. Implement using a CSS/SVG overlay or Sigma's edge renderer with a custom `drawEdge` that uses a time-based offset to place a bright circle along the edge path.
|
| 138 |
+
|
| 139 |
+
3. **Interactions:**
|
| 140 |
+
- Click a node: right panel slides in showing node details (risk score, buffer days, substitutability, upstream/downstream list)
|
| 141 |
+
- Hover: edge highlights in bright white, connected nodes glow
|
| 142 |
+
- "Trigger Shock" button: clicking this on a province node animates a ripple outward — all connected nodes briefly flash their risk color, with a 150ms stagger per hop
|
| 143 |
+
|
| 144 |
+
4. **Layout:**
|
| 145 |
+
- Left: full-height Sigma canvas (WebGL)
|
| 146 |
+
- Right: 320px sliding details panel (transforms in from right)
|
| 147 |
+
- Top: filter bar — filter by node type, min risk threshold slider
|
| 148 |
+
|
| 149 |
+
5. **Metrics overlay (top-left corner of canvas):**
|
| 150 |
+
- Semi-transparent card showing: Nodes at risk: X, Critical paths: Y, Avg propagation depth: Z
|
| 151 |
+
- These animate as numbers count up on mount
|
| 152 |
+
|
| 153 |
+
6. **Background**: Dark canvas `#080C10` with a very subtle dot grid pattern using CSS `radial-gradient`
|
| 154 |
+
|
| 155 |
+
Make all interactions smooth, 60fps. No page reloads.
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## 4. War Room
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
Rebuild `frontend/src/pages/WarRoom.tsx` (the Shock Detail page) for ShockMap. This is the "command center" moment judges will remember. Design system is in place (dark, Syne/JetBrains Mono). Mock data is fine.
|
| 164 |
+
|
| 165 |
+
**Mock shock data to use:**
|
| 166 |
+
- Shock: "Hebei Analgesic Stress — Factory Shutdown"
|
| 167 |
+
- Severity: CRITICAL
|
| 168 |
+
- Province: Hebei, China
|
| 169 |
+
- Affected drugs: Paracetamol (risk 82), Ceftriaxone (risk 74)
|
| 170 |
+
- Days to stockout: 18
|
| 171 |
+
- Exposure estimate: ₹2,340 Cr
|
| 172 |
+
- Aggregate risk score: 87/100
|
| 173 |
+
|
| 174 |
+
**Enhancement 1: Cinematic Page Entry**
|
| 175 |
+
- On mount: the page enters with a full-screen flash of alarm red (200ms), then fades to the dark background. This signals "incident activated."
|
| 176 |
+
- The title "WAR ROOM" appears with a glitch text effect (3-frame flicker using CSS animation) in JetBrains Mono caps
|
| 177 |
+
- Severity badge drops in from top with a bounce-settle easing
|
| 178 |
+
|
| 179 |
+
**Enhancement 2: Days-to-Stockout Countdown**
|
| 180 |
+
- Replace the static metric with an animated circular arc gauge
|
| 181 |
+
- The arc fills clockwise, colored from red (0 days) through amber (14 days) to green (30+ days)
|
| 182 |
+
- Current value in large JetBrains Mono in center
|
| 183 |
+
- On load, it counts up from 0 to the actual value over 1.2 seconds with easeOutCubic
|
| 184 |
+
|
| 185 |
+
**Enhancement 3: 72-Hour Action Ladder**
|
| 186 |
+
- Each action card shows: priority number, action title, quantity/amount, estimated cost, and impact delta
|
| 187 |
+
- Use this mock data:
|
| 188 |
+
- Action 1: "Advance-buy 18 MT Para-Aminophenol from alternate Indian supplier" — Cost: ₹4.2 Cr — Impact: -12 risk pts, +8 days
|
| 189 |
+
- Action 2: "Lock 40 MT Paracetamol API from SE Asia spot market" — Cost: ₹18 Cr — Impact: -20 risk pts, +12 days
|
| 190 |
+
- Action 3: "Activate strategic buffer release for Ceftriaxone" — Cost: ₹0 — Impact: -8 risk pts, +5 days
|
| 191 |
+
- Each card has a "▶ Run Simulation" button
|
| 192 |
+
- When clicked: the card expands to show a before/after delta panel:
|
| 193 |
+
- Aggregate Risk: 87 → 67 (animated counter)
|
| 194 |
+
- Days to Stockout: 18 → 26 (animated counter)
|
| 195 |
+
- Exposure: ₹2,340 Cr → ₹1,890 Cr
|
| 196 |
+
- A green checkmark animates in with a subtle bounce
|
| 197 |
+
- Only one simulation can be "active" at a time
|
| 198 |
+
|
| 199 |
+
**Enhancement 4: Evidence Pack**
|
| 200 |
+
- A collapsible section showing 3 source articles as citation cards
|
| 201 |
+
- Each card: favicon placeholder, headline, source name, timestamp, a one-line excerpt
|
| 202 |
+
- Use mock sources: Reuters, Bloomberg, DGFT India notification
|
| 203 |
+
- Cards fan in with 80ms stagger on expand
|
| 204 |
+
|
| 205 |
+
**Enhancement 5: Propagation Path Visual (mini)**
|
| 206 |
+
- A horizontal flow diagram showing: Hebei → PAP → Paracetamol API → Paracetamol
|
| 207 |
+
- Nodes are colored by risk, connected by animated dashed lines
|
| 208 |
+
- Each node shows risk score below it
|
| 209 |
+
- Clicking any node opens the full Graph Explorer in a new tab
|
| 210 |
+
|
| 211 |
+
Layout: two-column. Left col (60%): shock summary, countdown gauge, propagation mini-map, evidence. Right col (40%): sticky action ladder.
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## 5. Supply Map
|
| 217 |
+
|
| 218 |
+
```
|
| 219 |
+
Upgrade `frontend/src/pages/SupplyMap.tsx` in ShockMap (React 19 + Leaflet/react-leaflet). The design system is in place. This page shows supply corridors from China provinces to Indian states.
|
| 220 |
+
|
| 221 |
+
**Enhancement 1: Animated Supply Corridors**
|
| 222 |
+
Using Leaflet's SVG overlay, draw animated "flow lines" between source provinces and destination states:
|
| 223 |
+
- Each corridor is an SVG path (great-circle arc) between coordinates
|
| 224 |
+
- Animate small bright dots traveling along each path continuously (like a pulse in a circuit board)
|
| 225 |
+
- Corridor width proportional to trade volume (use mock weights)
|
| 226 |
+
- Corridor color: `#00E5A0` (healthy flow) → `#FF4444` (disrupted flow)
|
| 227 |
+
- When a province has an active shock: the corridor flashes and the dots speed up + turn red
|
| 228 |
+
|
| 229 |
+
**Mock corridors to render:**
|
| 230 |
+
```json
|
| 231 |
+
[
|
| 232 |
+
{"from": [39.9, 116.4], "to": [28.7, 77.1], "label": "Hebei → Delhi NCR", "volume": 0.9, "disrupted": true},
|
| 233 |
+
{"from": [36.7, 117.0], "to": [19.1, 72.9], "label": "Shandong → Mumbai", "volume": 0.7, "disrupted": false},
|
| 234 |
+
{"from": [41.8, 123.4], "to": [12.9, 77.6], "label": "Liaoning → Bangalore", "volume": 0.5, "disrupted": false},
|
| 235 |
+
{"from": [40.8, 111.7], "to": [22.6, 88.4], "label": "Inner Mongolia → Kolkata", "volume": 0.6, "disrupted": true}
|
| 236 |
+
]
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
**Enhancement 2: Province Shock Overlays**
|
| 240 |
+
- Use GeoJSON for Chinese provinces (use a CDN-hosted simplified version)
|
| 241 |
+
- Fill provinces by current risk: dark base → amber → red
|
| 242 |
+
- Active shock provinces: add a pulsing circle marker with 3 expanding rings (CSS animation via Leaflet DivIcon)
|
| 243 |
+
- Clicking a province: popup card showing shock summary + "Open War Room" button
|
| 244 |
+
|
| 245 |
+
**Enhancement 3: Indian State Risk Absorption View**
|
| 246 |
+
- Toggle button: "Source View" / "Impact View"
|
| 247 |
+
- In Impact View: color Indian states by absorbed downstream risk (Maharashtra high, Delhi NCR high, others lower)
|
| 248 |
+
- Tooltip on hover: state name, # of APIs at risk, top exposed medicine
|
| 249 |
+
|
| 250 |
+
**Enhancement 4: Map UI Polish**
|
| 251 |
+
- Use a dark map tile: `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`
|
| 252 |
+
- Custom zoom controls (styled to match the command center theme)
|
| 253 |
+
- A legend card (bottom left): explaining the corridor color coding
|
| 254 |
+
- Fit map bounds to show both China and India on load with smooth flyTo animation
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
---
|
| 258 |
+
|
| 259 |
+
## 6. Query Interface
|
| 260 |
+
|
| 261 |
+
```
|
| 262 |
+
Rebuild `frontend/src/pages/QueryPage.tsx` for ShockMap's "Operator Analyst" feature powered by Gemini Flash via `/api/v1/query`. Design system in place.
|
| 263 |
+
|
| 264 |
+
**Enhancement 1: Streaming Typewriter Response**
|
| 265 |
+
- When the API returns an answer, render it character-by-character with a 12ms delay per character using a streaming typewriter effect
|
| 266 |
+
- Show a blinking cursor `█` (JetBrains Mono) while streaming
|
| 267 |
+
- Implement this even if the API returns all at once (fake the stream on the frontend)
|
| 268 |
+
|
| 269 |
+
**Enhancement 2: Suggested Query Chips**
|
| 270 |
+
- Below the input, show 4 pre-built query chips:
|
| 271 |
+
- "Which drugs depend most on Hebei?"
|
| 272 |
+
- "What should procurement do in the next 72 hours?"
|
| 273 |
+
- "Which APIs have substitutes available?"
|
| 274 |
+
- "Show me the highest risk propagation path"
|
| 275 |
+
- Chips are pill-shaped, dark background, teal border, hover glow
|
| 276 |
+
- Clicking a chip populates the input and auto-submits after 300ms
|
| 277 |
+
|
| 278 |
+
**Enhancement 3: Response Cards with Citations**
|
| 279 |
+
Structure every response as:
|
| 280 |
+
- Main answer block (typewriter animated)
|
| 281 |
+
- A "Referenced entities" section: small cards for each drug/API mentioned with its current risk badge
|
| 282 |
+
- A "Suggested next steps" section: 2-3 action bullets with arrow icons
|
| 283 |
+
- A "Confidence" indicator: 3 dots, filled based on score (use mock: 0.87)
|
| 284 |
+
|
| 285 |
+
Use this mock response for when the API is offline (graceful degradation):
|
| 286 |
+
> "Based on current shock data, Paracetamol and Ceftriaxone face the highest downstream risk from the Hebei disruption. Para-Aminophenol sourcing is the critical bottleneck. Recommend immediate spot-market sourcing from SE Asia and activation of DGFT buffer release protocols within 24 hours."
|
| 287 |
+
|
| 288 |
+
**Enhancement 4: Query History**
|
| 289 |
+
- Left sidebar (240px) showing past queries in the current session
|
| 290 |
+
- Each entry: truncated query text + timestamp + a small risk-level dot
|
| 291 |
+
- Click to reload that response
|
| 292 |
+
- "Clear history" button at bottom
|
| 293 |
+
|
| 294 |
+
**Enhancement 5: Input UX**
|
| 295 |
+
- Large, prominent input with placeholder "Ask ShockMap anything about current supply risks..."
|
| 296 |
+
- CMD+Enter submits
|
| 297 |
+
- Textarea auto-expands (max 4 lines)
|
| 298 |
+
- Show a character count warning at 280+ chars
|
| 299 |
+
- Subtle animated border glow on focus using the teal accent
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## 7. Alerts Feed
|
| 305 |
+
|
| 306 |
+
```
|
| 307 |
+
Upgrade `frontend/src/pages/AlertsFeed.tsx` for ShockMap. Design system in place.
|
| 308 |
+
|
| 309 |
+
**Enhancement 1: Live Signal Wall Layout**
|
| 310 |
+
Change the layout from a plain list to a "signal wall" — 3-column masonry-style card grid (CSS columns, not JS masonry).
|
| 311 |
+
|
| 312 |
+
Each alert card shows:
|
| 313 |
+
- Severity badge (CRITICAL / ELEVATED / NORMAL) with animated pulse dot
|
| 314 |
+
- Shock type icon (factory: 🏭, port: ⚓, export ban: 🚫, contamination: ⚗️ — rendered as styled SVG, not emoji)
|
| 315 |
+
- Province + sector tags
|
| 316 |
+
- Headline in Syne medium weight
|
| 317 |
+
- Time since detected (live-updating relative time: "4 minutes ago")
|
| 318 |
+
- Affected entities list (max 3 drug names, then "+2 more")
|
| 319 |
+
- A thin progress bar at card bottom showing risk score 0-100
|
| 320 |
+
|
| 321 |
+
**Enhancement 2: Filter Bar**
|
| 322 |
+
- Horizontal filter row with toggle pills: All / Critical / Elevated / Pharma / Rare Earth / China / India
|
| 323 |
+
- Active filter highlighted in teal
|
| 324 |
+
- Filter transitions: cards that don't match fade to 30% opacity + blur (CSS transition, no re-render)
|
| 325 |
+
|
| 326 |
+
**Enhancement 3: New Alert Animation**
|
| 327 |
+
- When a new alert arrives (poll every 15s), it doesn't just append — it slides in from the top with a brief red flash border, then settles
|
| 328 |
+
- A "NEW" badge appears for 8 seconds then fades
|
| 329 |
+
|
| 330 |
+
**Enhancement 4: Card Hover State**
|
| 331 |
+
- On hover: card lifts (translateY -4px), border brightens to the risk-level color, a subtle scanline effect sweeps across
|
| 332 |
+
- "Open War Room" CTA appears with a slide-up from card bottom
|
| 333 |
+
|
| 334 |
+
Use 6 mock alerts in varying severity levels if the API is unavailable.
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## 8. Global Bug Fixes & UX Hardening
|
| 340 |
+
|
| 341 |
+
```
|
| 342 |
+
Apply these cross-cutting fixes and polish to all pages of ShockMap (React 19 + Vite + Tailwind + FastAPI backend):
|
| 343 |
+
|
| 344 |
+
**Fix 1: API Offline Graceful Degradation**
|
| 345 |
+
In `frontend/src/lib/api.ts`, wrap every fetch call with a try-catch that:
|
| 346 |
+
- Catches network errors and 5xx responses
|
| 347 |
+
- Returns mock data from `frontend/src/lib/mockData.ts` automatically
|
| 348 |
+
- Shows a subtle amber banner "Using demo data — live API offline" at the top of the page
|
| 349 |
+
- Does NOT crash the app or show blank screens
|
| 350 |
+
|
| 351 |
+
Create `frontend/src/lib/mockData.ts` with realistic mock responses for every endpoint: `/shocks`, `/shocks/:id/war-room`, `/graph`, `/query`.
|
| 352 |
+
|
| 353 |
+
**Fix 2: Loading States**
|
| 354 |
+
Every page that fetches data must show the custom `<LoadingShock />` component (the scanline loader) while loading, not a blank screen or default spinner.
|
| 355 |
+
|
| 356 |
+
**Fix 3: Empty States**
|
| 357 |
+
If the shocks list is empty, show an illustrated empty state: a dark card with a pulsing radar circle animation and text "No active shocks detected — system monitoring". Not a blank div.
|
| 358 |
+
|
| 359 |
+
**Fix 4: Mobile Responsiveness (basic)**
|
| 360 |
+
The judges may view on a tablet. Add responsive breakpoints:
|
| 361 |
+
- Nav collapses to hamburger below 768px
|
| 362 |
+
- Dashboard grid goes to 1 column
|
| 363 |
+
- War Room stacks vertically (action ladder moves below shock summary)
|
| 364 |
+
- Graph Explorer shows a message "Best viewed on desktop" with a simplified node list fallback
|
| 365 |
+
|
| 366 |
+
**Fix 5: Page Transitions**
|
| 367 |
+
Wrap all `<Route>` components in a fade transition using Framer Motion `<AnimatePresence>`. Each page enters with `opacity: 0 → 1` + `translateY: 8px → 0` over 250ms. This makes navigation feel polished and intentional.
|
| 368 |
+
|
| 369 |
+
**Fix 6: Navigation Active States**
|
| 370 |
+
The nav currently doesn't clearly show which page is active. Add: active page gets a left border accent in teal + background highlight. Non-active items have a hover state.
|
| 371 |
+
|
| 372 |
+
**Fix 7: Number Formatting**
|
| 373 |
+
All risk scores, currency values, and percentages should be formatted consistently using `Intl.NumberFormat`. ₹2340 Cr should display as "₹2,340 Cr". Risk score 82.3456 should display as "82.3".
|
| 374 |
+
|
| 375 |
+
**Fix 8: Favicon and Page Titles**
|
| 376 |
+
- Create a simple SVG favicon: a hexagon with a lightning bolt inside, in the teal accent color
|
| 377 |
+
- Set dynamic `document.title` per page: "War Room — Hebei Shock | ShockMap", "Dashboard | ShockMap", etc.
|
| 378 |
+
```
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
## 9. Demo Mode — Autopilot & Keyboard Shortcuts
|
| 383 |
+
|
| 384 |
+
```
|
| 385 |
+
Add a demo autopilot system to ShockMap for the hackathon presentation. This lets the presenter run a flawless demo hands-free.
|
| 386 |
+
|
| 387 |
+
**Component: `<DemoController />`**
|
| 388 |
+
Create `frontend/src/components/DemoController.tsx` — a floating controller (bottom-right corner, outside normal flow):
|
| 389 |
+
- A small "DEMO" pill button that expands on click to show controls
|
| 390 |
+
- **Autopilot mode**: walks through the golden path automatically:
|
| 391 |
+
1. Navigate to Dashboard (3s pause, HHI map animates in)
|
| 392 |
+
2. Navigate to Alerts, highlight the Hebei CRITICAL shock (2s)
|
| 393 |
+
3. Navigate to War Room for that shock (auto-opens)
|
| 394 |
+
4. Wait 3s, then auto-click "Run Simulation" on Action 1
|
| 395 |
+
5. Wait 3s, navigate to Graph Explorer, trigger the shock ripple animation
|
| 396 |
+
6. Wait 4s, navigate to Query, auto-type and submit "What should procurement do about the Hebei shutdown?"
|
| 397 |
+
7. Loop back to Dashboard
|
| 398 |
+
|
| 399 |
+
**Keyboard shortcuts** (active globally, shown in a help modal on `?` key):
|
| 400 |
+
- `D` → Dashboard
|
| 401 |
+
- `A` → Alerts
|
| 402 |
+
- `W` → War Room (most recent critical shock)
|
| 403 |
+
- `G` → Graph Explorer
|
| 404 |
+
- `Q` → Query
|
| 405 |
+
- `Space` → trigger shock ripple on Graph page (if open)
|
| 406 |
+
- `S` → run simulation on War Room (if open)
|
| 407 |
+
- `Escape` → close any open panel
|
| 408 |
+
|
| 409 |
+
**Demo Reset button**: One click resets all simulation states, clears query history, and returns to Dashboard. Useful between judge demos.
|
| 410 |
+
|
| 411 |
+
Show a subtle keyboard shortcut hint bar at the bottom of each page (dismissible, only shown in DEMO_MODE).
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
---
|
| 415 |
+
|
| 416 |
+
## 10. Deployment
|
| 417 |
+
|
| 418 |
+
```
|
| 419 |
+
Set up production deployment for ShockMap. Stack: React 19 + Vite (frontend), FastAPI + Python (backend). I want to deploy cheaply and reliably for the hackathon demo.
|
| 420 |
+
|
| 421 |
+
**Step 1: Docker Setup**
|
| 422 |
+
|
| 423 |
+
Create `backend/Dockerfile`:
|
| 424 |
+
- Base: `python:3.11-slim`
|
| 425 |
+
- Install deps from `requirements.txt`
|
| 426 |
+
- Expose port 8000
|
| 427 |
+
- Start with: `uvicorn app.main:app --host 0.0.0.0 --port 8000`
|
| 428 |
+
- Include a `.dockerignore` that excludes `__pycache__`, `.env`, `venv`, `*.pyc`
|
| 429 |
+
|
| 430 |
+
Create `frontend/Dockerfile`:
|
| 431 |
+
- Two-stage build: node:20-alpine for build, nginx:alpine for serve
|
| 432 |
+
- Build: `npm ci && npm run build`
|
| 433 |
+
- Copy `dist/` to nginx html dir
|
| 434 |
+
- Add `nginx.conf` that: serves static files, proxies `/api/` → `http://backend:8000/api/`, handles React Router (try_files fallback to index.html)
|
| 435 |
+
|
| 436 |
+
Create `docker-compose.yml` at project root:
|
| 437 |
+
- `backend` service: build from `./backend`, env_file `.env`, ports `8000:8000`
|
| 438 |
+
- `frontend` service: build from `./frontend`, ports `80:80`, depends_on backend
|
| 439 |
+
- A shared network `shockmap-net`
|
| 440 |
+
|
| 441 |
+
**Step 2: Environment Config**
|
| 442 |
+
|
| 443 |
+
Create `backend/.env.example`:
|
| 444 |
+
```
|
| 445 |
+
GEMINI_API_KEY=your_key_here
|
| 446 |
+
DEMO_MODE=true
|
| 447 |
+
ALLOWED_ORIGINS=http://localhost,https://your-domain.com
|
| 448 |
+
ENABLE_GNN=false
|
| 449 |
+
```
|
| 450 |
+
|
| 451 |
+
In the FastAPI app, load `.env` using `python-dotenv`. Add a startup check that logs which env vars are missing and continues with degraded mode (not crash).
|
| 452 |
+
|
| 453 |
+
**Step 3: Railway Deployment (recommended for hackathon)**
|
| 454 |
+
|
| 455 |
+
Create `railway.toml` at project root:
|
| 456 |
+
```toml
|
| 457 |
+
[build]
|
| 458 |
+
builder = "dockerfile"
|
| 459 |
+
|
| 460 |
+
[deploy]
|
| 461 |
+
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port $PORT"
|
| 462 |
+
healthcheckPath = "/healthz"
|
| 463 |
+
healthcheckTimeout = 30
|
| 464 |
+
restartPolicyType = "on_failure"
|
| 465 |
+
```
|
| 466 |
+
|
| 467 |
+
Create `railway-frontend.toml` for the frontend service similarly.
|
| 468 |
+
|
| 469 |
+
Also create `frontend/vercel.json` for optional Vercel frontend deployment:
|
| 470 |
+
```json
|
| 471 |
+
{
|
| 472 |
+
"rewrites": [
|
| 473 |
+
{"source": "/api/:path*", "destination": "https://your-railway-backend.up.railway.app/api/:path*"},
|
| 474 |
+
{"source": "/(.*)", "destination": "/index.html"}
|
| 475 |
+
]
|
| 476 |
+
}
|
| 477 |
+
```
|
| 478 |
+
|
| 479 |
+
**Step 4: Health Check Endpoint**
|
| 480 |
+
Ensure `GET /healthz` returns:
|
| 481 |
+
```json
|
| 482 |
+
{
|
| 483 |
+
"status": "ok",
|
| 484 |
+
"demo_mode": true,
|
| 485 |
+
"engines": {"signal": "ok", "propagation": "ok", "action": "ok"},
|
| 486 |
+
"shocks_loaded": 6,
|
| 487 |
+
"timestamp": "2026-04-27T10:00:00Z"
|
| 488 |
+
}
|
| 489 |
+
```
|
| 490 |
+
|
| 491 |
+
**Step 5: Production Build Check**
|
| 492 |
+
Add a `scripts/check_build.sh`:
|
| 493 |
+
- Runs `npm run build` in frontend and checks for errors
|
| 494 |
+
- Runs `python -c "from app.main import app"` to check backend imports
|
| 495 |
+
- Prints "✅ Build ready for deployment" or lists errors
|
| 496 |
+
|
| 497 |
+
**Step 6: README Update**
|
| 498 |
+
Update the root `README.md` to include:
|
| 499 |
+
- A "Deploy in 5 minutes" section with the Railway one-click instructions
|
| 500 |
+
- A "Demo credentials" section (if auth is added)
|
| 501 |
+
- Badges: build status, demo mode, license
|
| 502 |
+
- A GIF/screenshot placeholder section (add images later)
|
| 503 |
+
```
|
| 504 |
+
|
| 505 |
+
---
|
| 506 |
+
|
| 507 |
+
## Execution Order
|
| 508 |
+
|
| 509 |
+
Run these prompts in this sequence for best results:
|
| 510 |
+
|
| 511 |
+
```
|
| 512 |
+
1 → Design System (foundation, do first)
|
| 513 |
+
2 → Dashboard (first page judges see)
|
| 514 |
+
3 → Propagation Graph (the wow moment — most effort)
|
| 515 |
+
4 → War Room (core MVP value)
|
| 516 |
+
5 → Supply Map (visual depth)
|
| 517 |
+
8 → Bug Fixes (stability before showing anything else)
|
| 518 |
+
9 → Demo Mode (last before deployment)
|
| 519 |
+
10 → Deployment (final step)
|
| 520 |
+
6 → Query Interface (can be parallel with 5)
|
| 521 |
+
7 → Alerts Feed (can be parallel with 6)
|
| 522 |
+
```
|
| 523 |
+
|
| 524 |
+
---
|
| 525 |
+
|
| 526 |
+
## Rare Techniques Worth Mentioning to Judges
|
| 527 |
+
|
| 528 |
+
These are implemented in the prompts above but worth calling out explicitly during the demo:
|
| 529 |
+
|
| 530 |
+
| Technique | Where | Why It Impresses |
|
| 531 |
+
|---|---|---|
|
| 532 |
+
| **Personalized PageRank** | Engine 2 | Not just BFS — directional probabilistic influence flow |
|
| 533 |
+
| **Louvain Community Detection** | Engine 2 | Co-propagating cluster identification (graph theory) |
|
| 534 |
+
| **WebGL-rendered graph** (Sigma.js) | Graph Explorer | 60fps with hundreds of nodes — not a D3 toy |
|
| 535 |
+
| **Animated particle flow on edges** | Graph Explorer | Unique visual — shock literally "flows" through graph |
|
| 536 |
+
| **Schema-constrained Gemini output** | Engine 3 | Deterministic JSON from LLM — production-safe |
|
| 537 |
+
| **HHI concentration index** | Dashboard | Economic inequality metric applied to supply chains |
|
| 538 |
+
| **Propagation formula with decay** | Engine 2 | Physics-inspired: `R = PR * (1-S) * exp(-B/τ) * C` |
|
| 539 |
+
| **Demo autopilot** | DemoController | Shows product thinking beyond just code |
|
| 540 |
+
|
| 541 |
+
---
|
| 542 |
+
|
| 543 |
+
*Built for Google Solution Challenge 2026 — Phase 1: ShockMap MVP*
|