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 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
- **Real-time supply shock intelligence for India's critical import dependencies**
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
- ## Gemini Usage Strategy
193
 
194
- ShockMap uses Gemini Flash in a cost-aware, operator-safe way.
195
 
196
- ### Design principles
197
- - **Flash-only routing** for MVP speed and cost control
198
- - **Schema-constrained generation** for stable JSON outputs
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
- ### Where Gemini is used
205
- - Engine 1 extraction and normalization
206
- - Engine 3 query answering
207
- - Engine 3 action-plan generation
208
 
209
- This keeps Gemini central to the product while preserving a reliable operational UX.
 
 
210
 
211
  ---
212
 
213
- ## Research-Informed Product Direction
214
 
215
- ShockMap is built as a practical MVP now, with a clear path toward more advanced graph learning and causal response intelligence.
216
-
217
- The current production path is intentionally strong:
218
- - interpretable
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
- ## Phase Roadmap
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
- ### Phase 2: Regional Operations Expansion
238
- - state-level and corridor-level overlays
239
- - inventory and days-of-cover modeling
240
- - regional reallocation workflows
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
- ## GNN Roadmap
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
- ### 6. Multi-tenant evolution
347
- - tenant-scoped sectors, alerts, and workspaces
348
- - org-specific configuration and alerting
349
- - audit trails for action workflows
350
 
351
- ### 7. Observability and reliability
352
- - structured logs
353
- - engine health status
354
- - shock feed mode visibility
355
- - deterministic degradation under model or network pressure
 
 
356
 
357
- ---
358
-
359
- ## Repository Structure
360
-
361
- | Path | Purpose |
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
- ## Key API Surfaces
374
 
375
- | Endpoint | Purpose |
376
- |---|---|
377
- | `/healthz` | system status and engine health |
378
- | `/api/v1/sectors` | sector overview |
379
- | `/api/v1/shocks` | live shock feed |
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
- ### Frontend
406
- ```bash
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
- ## Environment Variables
423
-
424
- | Variable | Purpose |
425
- |---|---|
426
- | `GEMINI_API_KEY` | Gemini Flash access for extraction and action intelligence |
427
- | `QDRANT_URL` | hosted vector store endpoint |
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
- ## Tech Stack
436
-
437
- | Layer | Stack |
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
- from fastapi import FastAPI, Request
 
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", "China Minmetals", "CMOC Group"],
52
- "monthly_import_value_usd_millions": 55.0,
53
- "end_use_sectors": ["petroleum refining catalysts", "hybrid batteries", "optics"],
54
- "india_stockpile_days": 30,
55
- "criticality": 0.72,
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
- async with AsyncClient(app=app, base_url=BASE_URL) as ac:
 
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
- <title>frontend</title>
 
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
- "recharts": "^2.15.3"
 
 
 
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
- "dev": true,
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
- "recharts": "^2.15.3"
 
 
 
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 className="p-8 space-y-6 max-w-4xl">
43
- <button onClick={() => navigate('/drugs')} className="flex items-center gap-1 text-sm text-muted hover:text-white transition-colors">
44
- <ChevronLeft className="w-4 h-4" /> Back to drugs
45
  </button>
46
 
47
- <div className="bg-surface border border-white/[0.06] rounded-xl p-6">
48
- <div className="flex items-start justify-between gap-4">
49
  <div>
50
- <h1 className="text-2xl font-bold">{drug.name}</h1>
51
- <p className="text-muted text-sm mt-1">{drug.generic_name} · {drug.therapeutic_class}</p>
52
  </div>
53
- <div className="text-right">
54
  <SeverityBadge severity={riskLabel(current_risk || 0)} />
55
- <p className="text-xs text-muted mt-1">Risk {Math.round(current_risk || 0)}/100</p>
56
  </div>
57
  </div>
58
- <div className="mt-4">
59
  <RiskBar score={current_risk || 0} />
60
  </div>
61
  </div>
62
 
63
- <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
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="bg-surface border border-white/[0.06] rounded-xl p-4">
73
- <p className="text-xs text-muted uppercase tracking-widest">{label}</p>
74
- <p className="text-lg font-semibold mt-1">{value}</p>
75
  </div>
76
  ))}
77
  </div>
78
 
79
  {criticality_breakdown && (
80
- <div className="bg-surface border border-white/[0.06] rounded-xl p-5">
81
- <h2 className="text-sm font-medium mb-4">Criticality Breakdown</h2>
82
- <div className="space-y-2">
83
  {Object.entries(criticality_breakdown)
84
  .filter(([k]) => k !== 'final_score')
85
  .map(([key, val]) => (
86
- <div key={key} className="flex items-center gap-3">
87
- <span className="text-xs text-muted w-40 capitalize">{key.replace(/_/g, ' ')}</span>
88
- <div className="flex-1">
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="bg-surface border border-white/[0.06] rounded-xl p-5">
99
- <h2 className="text-sm font-medium mb-4">Supply Chain Path</h2>
100
- <div className="flex flex-wrap gap-2 items-center">
101
  {dependency_chain.map((node, i) => (
102
- <div key={i} className="flex items-center gap-2">
103
- <div className="px-3 py-1.5 bg-background rounded-lg border border-white/[0.06] text-xs">
104
- <span className="text-muted capitalize">{node.type}: </span>
105
- <span>{node.name || node.id}</span>
106
  </div>
107
- {i < dependency_chain.length - 1 && <span className="text-muted text-xs">→</span>}
108
  </div>
109
  ))}
110
  </div>
@@ -112,16 +112,16 @@ function DrugDetail({ id }) {
112
  )}
113
 
114
  {recent_alerts?.length > 0 && (
115
- <div className="bg-surface border border-white/[0.06] rounded-xl p-5">
116
- <h2 className="text-sm font-medium mb-4">Recent Alerts</h2>
117
- <div className="space-y-3">
118
  {recent_alerts.map(alert => (
119
- <div key={alert.id} className="flex items-start gap-3 p-3 rounded-lg bg-background">
120
  <SeverityBadge severity={alert.severity} />
121
  <div>
122
- <p className="text-sm">{alert.summary}</p>
123
  {alert.gemini_explainer && (
124
- <p className="text-xs text-muted mt-1">{alert.gemini_explainer}</p>
125
  )}
126
  </div>
127
  </div>
@@ -167,29 +167,31 @@ export default function Drugs() {
167
  );
168
 
169
  return (
170
- <div className="p-8">
171
  <PageHeader
172
  title="Drug Catalog"
173
  description={`${total} NLEM essential medicines monitored`}
174
  />
175
 
176
  {/* Filters */}
177
- <div className="flex flex-wrap gap-3 mb-5">
178
- <div className="relative">
179
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
180
  <input
181
  type="text"
182
  placeholder="Search drugs…"
183
  value={search}
184
  onChange={e => setSearch(e.target.value)}
185
- className="pl-8 pr-3 py-2 text-sm bg-surface border border-white/[0.06] rounded-lg text-white placeholder-muted focus:outline-none focus:border-primary/50 w-52"
 
186
  />
187
  </div>
188
 
189
  <select
190
  value={tier}
191
  onChange={e => { setTier(e.target.value); setPage(1); }}
192
- className="px-3 py-2 text-sm bg-surface border border-white/[0.06] rounded-lg text-white focus:outline-none focus:border-primary/50"
 
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="px-3 py-2 text-sm bg-surface border border-white/[0.06] rounded-lg text-white focus:outline-none focus:border-primary/50"
 
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 className="text-sm text-accent mb-4">{error}</p>}
214
 
215
  {/* Table */}
216
- <div className="bg-surface border border-white/[0.06] rounded-xl overflow-hidden">
217
- <table className="w-full text-sm">
218
  <thead>
219
- <tr className="border-b border-white/[0.06]">
220
  {['Drug', 'Class', 'Tier', 'Risk Score', 'Substitute', ''].map(h => (
221
- <th key={h} className="px-4 py-3 text-left text-xs text-muted font-medium uppercase tracking-wider">{h}</th>
222
  ))}
223
  </tr>
224
  </thead>
225
  <tbody>
226
  {loading ? (
227
- <tr><td colSpan={6} className="text-center py-12"><Spinner /></td></tr>
228
  ) : filtered.length === 0 ? (
229
- <tr><td colSpan={6} className="text-center py-12 text-muted text-sm">No drugs match filters</td></tr>
230
  ) : filtered.map(drug => {
231
  const risk = drug.current_risk || drug.criticality_score || 0;
232
  return (
233
- <tr key={drug.id} className="border-b border-white/[0.04] hover:bg-white/[0.02] transition-colors">
234
- <td className="px-4 py-3">
235
- <Link to={`/drugs/${drug.id}`} className="font-medium hover:text-primary transition-colors">
236
  {drug.name}
237
  </Link>
238
  </td>
239
- <td className="px-4 py-3 text-muted">{drug.therapeutic_class || '—'}</td>
240
- <td className="px-4 py-3">
241
- <span className="text-xs bg-white/5 border border-white/10 rounded px-2 py-0.5">
242
  {tierLabel(drug.nlem_tier)}
243
  </span>
244
  </td>
245
- <td className="px-4 py-3 w-40">
246
  <RiskBar score={risk} />
247
  </td>
248
- <td className="px-4 py-3">
249
- <span className={drug.has_substitute ? 'text-secondary' : 'text-muted'}>
250
  {drug.has_substitute ? 'Yes' : 'No'}
251
  </span>
252
  </td>
253
- <td className="px-4 py-3">
254
- <Link to={`/drugs/${drug.id}`} className="text-muted hover:text-primary transition-colors">
255
- <ExternalLink className="w-3.5 h-3.5" />
256
  </Link>
257
  </td>
258
  </tr>
@@ -264,18 +267,20 @@ export default function Drugs() {
264
 
265
  {/* Pagination */}
266
  {total > PAGE_SIZE && (
267
- <div className="flex items-center justify-between mt-4 text-sm text-muted">
268
  <span>Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total}</span>
269
- <div className="flex gap-2">
270
  <button
271
  disabled={page === 1}
272
  onClick={() => setPage(p => p - 1)}
273
- className="px-3 py-1.5 rounded-lg bg-surface border border-white/[0.06] disabled:opacity-40 hover:border-primary/50 transition-colors"
 
274
  >← Prev</button>
275
  <button
276
  disabled={page * PAGE_SIZE >= total}
277
  onClick={() => setPage(p => p + 1)}
278
- className="px-3 py-1.5 rounded-lg bg-surface border border-white/[0.06] disabled:opacity-40 hover:border-primary/50 transition-colors"
 
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((e) => setError(e.message || 'Failed to load graph'))
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
- <path
327
- d={path}
328
- className="graph-flow-line graph-flow-trail"
329
- style={{ strokeWidth: weight + 0.4 }}
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
- <circle
355
- r={selected ? radius + 14 : radius + 10}
356
- fill={fill}
357
- opacity={selected ? 0.18 : 0.11}
358
- className="graph-node-halo"
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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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:8000', changeOrigin: true },
10
- '/healthz': { target: 'http://localhost:8000', changeOrigin: true },
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*