Asset #8 — India KCC Demand Pulse tab (/pulse, 7 charts + India choropleth)
Browse files- data/india_kcc_crop_topic.parquet +3 -0
- data/india_kcc_demand_pulse.parquet +3 -0
- data/india_kcc_info_gap.parquet +3 -0
- data/india_kcc_mandi_correlation.parquet +3 -0
- data/india_kcc_scheme_uptake.parquet +3 -0
- data/india_kcc_top_districts.parquet +3 -0
- data/india_kcc_top_questions.parquet +3 -0
- data/india_kcc_topic_by_state.parquet +3 -0
- data/india_kcc_topic_trends.parquet +3 -0
- frontend/src/App.jsx +2 -0
- frontend/src/components/InfoGapMap.jsx +151 -0
- frontend/src/data/demandPulse.json +0 -0
- frontend/src/pages/DemandPulse.jsx +623 -0
- frontend/src/pages/Landing.jsx +1 -0
data/india_kcc_crop_topic.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b93c57811304aca57bf7b32d708b2e431687012bea66506dad8864996681991d
|
| 3 |
+
size 4681
|
data/india_kcc_demand_pulse.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:03e523c465d47eef5926c26ddd5e833311fae699df8ffdf8d41b07f045aa54a3
|
| 3 |
+
size 14388908
|
data/india_kcc_info_gap.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8ece4b54455f4b30ff51aa7345d51ec7216121886ab997ad260ca0b5a66dbe58
|
| 3 |
+
size 55450
|
data/india_kcc_mandi_correlation.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4d36ced24caebb964a25d553f97f03944e5deb652c31da255ee39d2535e77a22
|
| 3 |
+
size 8554
|
data/india_kcc_scheme_uptake.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:418438943aae93b7e887d16fa4902549f725c5ffdda7971b046aa80974bddad8
|
| 3 |
+
size 5718
|
data/india_kcc_top_districts.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6fa79021ceb000fae45fbb4d06214e2a13fcd0947d55a65d278c520449e1f675
|
| 3 |
+
size 4736
|
data/india_kcc_top_questions.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5be7d212d3cac47487c5955276175bc76964112f10f36b878e3d4671c1432e2b
|
| 3 |
+
size 11841610
|
data/india_kcc_topic_by_state.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c373361e69b10b0738152d860e4651de416b7dc76116a506283a8e0ef29a8ba8
|
| 3 |
+
size 8719
|
data/india_kcc_topic_trends.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:99ca763bc12fe3f4d58d1b9b3ce45a056501e73530f08111e597d440d5d4b718
|
| 3 |
+
size 6588
|
frontend/src/App.jsx
CHANGED
|
@@ -5,6 +5,7 @@ import Farmer from "./pages/Farmer"
|
|
| 5 |
import B2BLogin from "./pages/B2BLogin"
|
| 6 |
import B2B from "./pages/B2B"
|
| 7 |
import Proof from "./pages/Proof"
|
|
|
|
| 8 |
|
| 9 |
function ProtectedB2B({ children }) {
|
| 10 |
const token = localStorage.getItem("kcc_token")
|
|
@@ -55,6 +56,7 @@ export default function App() {
|
|
| 55 |
<Route path="/" element={<Landing />} />
|
| 56 |
<Route path="/farmer" element={<Farmer />} />
|
| 57 |
<Route path="/proof" element={<Proof />} />
|
|
|
|
| 58 |
<Route path="/b2b/login" element={<B2BLogin />} />
|
| 59 |
<Route path="/b2b" element={<ProtectedB2B><B2B /></ProtectedB2B>} />
|
| 60 |
<Route path="*" element={<Navigate to="/" replace />} />
|
|
|
|
| 5 |
import B2BLogin from "./pages/B2BLogin"
|
| 6 |
import B2B from "./pages/B2B"
|
| 7 |
import Proof from "./pages/Proof"
|
| 8 |
+
import DemandPulse from "./pages/DemandPulse"
|
| 9 |
|
| 10 |
function ProtectedB2B({ children }) {
|
| 11 |
const token = localStorage.getItem("kcc_token")
|
|
|
|
| 56 |
<Route path="/" element={<Landing />} />
|
| 57 |
<Route path="/farmer" element={<Farmer />} />
|
| 58 |
<Route path="/proof" element={<Proof />} />
|
| 59 |
+
<Route path="/pulse" element={<DemandPulse />} />
|
| 60 |
<Route path="/b2b/login" element={<B2BLogin />} />
|
| 61 |
<Route path="/b2b" element={<ProtectedB2B><B2B /></ProtectedB2B>} />
|
| 62 |
<Route path="*" element={<Navigate to="/" replace />} />
|
frontend/src/components/InfoGapMap.jsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo, useState } from "react"
|
| 2 |
+
import districtCoords from "../districtCoords.json"
|
| 3 |
+
|
| 4 |
+
// India bounding box (matches PestHeatmap projection for visual consistency)
|
| 5 |
+
const INDIA = { minLat: 6.5, maxLat: 36.5, minLon: 68.0, maxLon: 97.5 }
|
| 6 |
+
const W = 580, H = 640
|
| 7 |
+
|
| 8 |
+
function project(lat, lon) {
|
| 9 |
+
const x = ((lon - INDIA.minLon) / (INDIA.maxLon - INDIA.minLon)) * W
|
| 10 |
+
const y = ((INDIA.maxLat - lat) / (INDIA.maxLat - INDIA.minLat)) * H
|
| 11 |
+
return [x, y]
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// Flat lookup: "State::District" → {lat, lon}
|
| 15 |
+
const COORDS_FLAT = (() => {
|
| 16 |
+
const out = {}
|
| 17 |
+
for (const [state, dists] of Object.entries(districtCoords)) {
|
| 18 |
+
for (const [district, ll] of Object.entries(dists)) {
|
| 19 |
+
const key = `${state}::${district}`
|
| 20 |
+
out[key] = ll
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
return out
|
| 24 |
+
})()
|
| 25 |
+
|
| 26 |
+
// Gap score → severity color (red = high gap = high risk + low queries = underserved)
|
| 27 |
+
function gapColor(gap) {
|
| 28 |
+
if (gap >= 0.8) return "#dc2626" // critical
|
| 29 |
+
if (gap >= 0.6) return "#ea580c" // high
|
| 30 |
+
if (gap >= 0.4) return "#f59e0b" // medium
|
| 31 |
+
if (gap >= 0.2) return "#84cc16" // low gap (well-served)
|
| 32 |
+
return "#16a34a" // negative gap (over-served)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export default function InfoGapMap({ data = [] }) {
|
| 36 |
+
const [hover, setHover] = useState(null)
|
| 37 |
+
|
| 38 |
+
// Only plot underserved (positive gap)
|
| 39 |
+
const points = useMemo(() => {
|
| 40 |
+
return (data || [])
|
| 41 |
+
.filter(d => d.gap_score > 0)
|
| 42 |
+
.map(d => {
|
| 43 |
+
const ll = COORDS_FLAT[`${d.state}::${d.district}`]
|
| 44 |
+
if (!ll) return null
|
| 45 |
+
const [x, y] = project(ll.lat, ll.lon)
|
| 46 |
+
return { ...d, x, y }
|
| 47 |
+
})
|
| 48 |
+
.filter(Boolean)
|
| 49 |
+
}, [data])
|
| 50 |
+
|
| 51 |
+
// Per-state aggregation for the side panel
|
| 52 |
+
const byState = useMemo(() => {
|
| 53 |
+
const map = {}
|
| 54 |
+
for (const p of points) {
|
| 55 |
+
const k = p.state
|
| 56 |
+
if (!map[k]) map[k] = { state: k, n: 0, avg_gap: 0, avg_risk: 0 }
|
| 57 |
+
map[k].n += 1
|
| 58 |
+
map[k].avg_gap += p.gap_score
|
| 59 |
+
map[k].avg_risk += p.max_risk_score
|
| 60 |
+
}
|
| 61 |
+
return Object.values(map).map(s => ({
|
| 62 |
+
...s,
|
| 63 |
+
avg_gap: s.avg_gap / s.n,
|
| 64 |
+
avg_risk: s.avg_risk / s.n,
|
| 65 |
+
})).sort((a,b) => b.n - a.n).slice(0, 10)
|
| 66 |
+
}, [points])
|
| 67 |
+
|
| 68 |
+
const buckets = useMemo(() => {
|
| 69 |
+
const b = { critical: 0, high: 0, medium: 0, low: 0 }
|
| 70 |
+
for (const p of points) {
|
| 71 |
+
if (p.gap_score >= 0.8) b.critical++
|
| 72 |
+
else if (p.gap_score >= 0.6) b.high++
|
| 73 |
+
else if (p.gap_score >= 0.4) b.medium++
|
| 74 |
+
else b.low++
|
| 75 |
+
}
|
| 76 |
+
return b
|
| 77 |
+
}, [points])
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 81 |
+
{/* Map */}
|
| 82 |
+
<div className="lg:col-span-2">
|
| 83 |
+
<div className="flex flex-wrap gap-3 text-xs mb-3">
|
| 84 |
+
{[
|
| 85 |
+
{ label: "Critical gap (0.8+)", color: "#dc2626", n: buckets.critical },
|
| 86 |
+
{ label: "High gap (0.6-0.8)", color: "#ea580c", n: buckets.high },
|
| 87 |
+
{ label: "Medium gap (0.4-0.6)", color: "#f59e0b", n: buckets.medium },
|
| 88 |
+
{ label: "Low gap (<0.4)", color: "#84cc16", n: buckets.low },
|
| 89 |
+
].map(b => (
|
| 90 |
+
<span key={b.label} className="inline-flex items-center gap-1.5">
|
| 91 |
+
<span className="inline-block w-3 h-3 rounded-sm" style={{background:b.color}}/>
|
| 92 |
+
<span className="text-gray-600">{b.label}</span>
|
| 93 |
+
<span className="font-semibold text-gray-900">({b.n})</span>
|
| 94 |
+
</span>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
<div className="relative flex justify-center">
|
| 98 |
+
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}
|
| 99 |
+
className="bg-gradient-to-br from-gray-50 to-green-50 rounded-xl border border-gray-200">
|
| 100 |
+
{points.map((p, i) => (
|
| 101 |
+
<circle key={i} cx={p.x} cy={p.y}
|
| 102 |
+
r={Math.max(2, Math.min(8, 2 + p.gap_score * 7))}
|
| 103 |
+
fill={gapColor(p.gap_score)} opacity={0.7}
|
| 104 |
+
onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)}
|
| 105 |
+
className="cursor-pointer hover:opacity-100 transition-opacity"/>
|
| 106 |
+
))}
|
| 107 |
+
</svg>
|
| 108 |
+
{hover && (
|
| 109 |
+
<div className="absolute bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl pointer-events-none max-w-[260px] z-10"
|
| 110 |
+
style={{left: Math.min(hover.x + 14, W - 220), top: Math.max(hover.y - 40, 0)}}>
|
| 111 |
+
<div className="font-bold text-sm">{hover.district}, {hover.state}</div>
|
| 112 |
+
<div className="mt-0.5 text-gray-300">{hover.crop}</div>
|
| 113 |
+
<div className="mt-1.5 text-red-400">Gap score: <strong>{hover.gap_score}</strong></div>
|
| 114 |
+
<div className="text-gray-300">
|
| 115 |
+
Pest risk <strong>{hover.max_risk_score}</strong> + <strong>{hover.pest_queries}</strong> queries
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* Side panel: top 10 states */}
|
| 123 |
+
<div>
|
| 124 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-3 uppercase tracking-wide">
|
| 125 |
+
Worst-served states
|
| 126 |
+
</h4>
|
| 127 |
+
<div className="space-y-2">
|
| 128 |
+
{byState.map((s, i) => (
|
| 129 |
+
<div key={s.state}
|
| 130 |
+
className="flex items-center justify-between p-3 rounded-lg bg-gradient-to-r from-red-50 to-white border border-red-100 hover:shadow-md transition-shadow">
|
| 131 |
+
<div className="flex items-center gap-3">
|
| 132 |
+
<span className="text-lg font-bold text-red-600 w-6">{i+1}</span>
|
| 133 |
+
<div>
|
| 134 |
+
<div className="text-sm font-semibold text-gray-900">{s.state}</div>
|
| 135 |
+
<div className="text-xs text-gray-500">avg risk {s.avg_risk.toFixed(0)}</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="text-right">
|
| 139 |
+
<div className="text-xl font-bold text-red-600">{s.n}</div>
|
| 140 |
+
<div className="text-xs text-gray-500">underserved</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
))}
|
| 144 |
+
</div>
|
| 145 |
+
<p className="text-xs text-gray-500 mt-3">
|
| 146 |
+
Districts with positive gap (max-risk normalised > KCC query volume normalised). High gap = farmers don't know what to ask.
|
| 147 |
+
</p>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
)
|
| 151 |
+
}
|
frontend/src/data/demandPulse.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/src/pages/DemandPulse.jsx
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from "react"
|
| 2 |
+
import { Link } from "react-router-dom"
|
| 3 |
+
import {
|
| 4 |
+
AreaChart, Area, LineChart, Line, BarChart, Bar,
|
| 5 |
+
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 6 |
+
Cell, ScatterChart, Scatter, ZAxis, ReferenceLine, ReferenceDot,
|
| 7 |
+
} from "recharts"
|
| 8 |
+
import pulse from "../data/demandPulse.json"
|
| 9 |
+
import InfoGapMap from "../components/InfoGapMap"
|
| 10 |
+
|
| 11 |
+
// ============================================================================
|
| 12 |
+
// Design tokens — match the agri-green Tailwind palette
|
| 13 |
+
// ============================================================================
|
| 14 |
+
const TOPIC_COLORS = {
|
| 15 |
+
pest: "#dc2626",
|
| 16 |
+
disease: "#991b1b",
|
| 17 |
+
nutrient: "#16a34a",
|
| 18 |
+
weather: "#0284c7",
|
| 19 |
+
irrigation: "#0891b2",
|
| 20 |
+
price: "#ca8a04",
|
| 21 |
+
seed: "#7c3aed",
|
| 22 |
+
scheme: "#9333ea",
|
| 23 |
+
crop_selection: "#059669",
|
| 24 |
+
soil: "#65a30d",
|
| 25 |
+
harvest: "#ea580c",
|
| 26 |
+
storage: "#737373",
|
| 27 |
+
general: "#9ca3af",
|
| 28 |
+
}
|
| 29 |
+
const TOPIC_LABELS = {
|
| 30 |
+
pest:"🐛 Pest", disease:"🦠 Disease", nutrient:"🌱 Nutrient",
|
| 31 |
+
weather:"⛅ Weather", irrigation:"💧 Irrigation", price:"💰 Price",
|
| 32 |
+
seed:"🌾 Seed", scheme:"🏛️ Scheme", crop_selection:"🌽 Crop Choice",
|
| 33 |
+
soil:"🟤 Soil", harvest:"🚜 Harvest", storage:"📦 Storage", general:"❓ General",
|
| 34 |
+
}
|
| 35 |
+
const TOPIC_LABELS_SHORT = {
|
| 36 |
+
pest:"Pest", disease:"Disease", nutrient:"Nutrient", weather:"Weather",
|
| 37 |
+
irrigation:"Irrigation", price:"Price", seed:"Seed", scheme:"Scheme",
|
| 38 |
+
crop_selection:"Crop Choice", soil:"Soil", harvest:"Harvest", storage:"Storage", general:"General",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// ============================================================================
|
| 42 |
+
// Animated number counter
|
| 43 |
+
// ============================================================================
|
| 44 |
+
function AnimatedNumber({ value, duration = 1200, format = n => n.toLocaleString() }) {
|
| 45 |
+
const [n, setN] = useState(0)
|
| 46 |
+
const ref = useRef(null)
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
const t0 = performance.now()
|
| 49 |
+
const animate = (t) => {
|
| 50 |
+
const elapsed = t - t0
|
| 51 |
+
const p = Math.min(1, elapsed / duration)
|
| 52 |
+
const eased = 1 - Math.pow(1 - p, 3) // easeOutCubic
|
| 53 |
+
setN(Math.round(value * eased))
|
| 54 |
+
if (p < 1) ref.current = requestAnimationFrame(animate)
|
| 55 |
+
}
|
| 56 |
+
ref.current = requestAnimationFrame(animate)
|
| 57 |
+
return () => ref.current && cancelAnimationFrame(ref.current)
|
| 58 |
+
}, [value, duration])
|
| 59 |
+
return <span>{format(n)}</span>
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// ============================================================================
|
| 63 |
+
// Section wrapper — consistent card styling
|
| 64 |
+
// ============================================================================
|
| 65 |
+
function Section({ id, eyebrow, title, subtitle, children, accent = "agri" }) {
|
| 66 |
+
return (
|
| 67 |
+
<section id={id} className="mb-12">
|
| 68 |
+
<div className="mb-5 max-w-3xl">
|
| 69 |
+
{eyebrow && (
|
| 70 |
+
<div className={`inline-block text-xs font-bold uppercase tracking-wider mb-2 px-3 py-1 rounded-full ${
|
| 71 |
+
accent === "agri" ? "bg-green-100 text-green-800" :
|
| 72 |
+
accent === "earth" ? "bg-yellow-100 text-yellow-800" :
|
| 73 |
+
accent === "data" ? "bg-blue-100 text-blue-800" :
|
| 74 |
+
accent === "danger"? "bg-red-100 text-red-800" :
|
| 75 |
+
"bg-gray-100 text-gray-700"
|
| 76 |
+
}`}>{eyebrow}</div>
|
| 77 |
+
)}
|
| 78 |
+
<h2 className="text-2xl font-bold text-gray-900">{title}</h2>
|
| 79 |
+
{subtitle && <p className="mt-2 text-gray-600 leading-relaxed">{subtitle}</p>}
|
| 80 |
+
</div>
|
| 81 |
+
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
| 82 |
+
{children}
|
| 83 |
+
</div>
|
| 84 |
+
</section>
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// ============================================================================
|
| 89 |
+
// Custom Recharts tooltip (matches theme)
|
| 90 |
+
// ============================================================================
|
| 91 |
+
function FancyTooltip({ active, payload, label, formatter }) {
|
| 92 |
+
if (!active || !payload || !payload.length) return null
|
| 93 |
+
return (
|
| 94 |
+
<div className="bg-gray-900/95 backdrop-blur text-white text-xs rounded-lg px-3 py-2 shadow-2xl border border-gray-700">
|
| 95 |
+
{label !== undefined && <div className="font-semibold text-sm mb-1">{label}</div>}
|
| 96 |
+
{payload.map((p, i) => (
|
| 97 |
+
<div key={i} className="flex items-center gap-2 mt-0.5">
|
| 98 |
+
<span className="w-2 h-2 rounded-sm" style={{background: p.color || p.fill}}/>
|
| 99 |
+
<span className="text-gray-300">{TOPIC_LABELS_SHORT[p.dataKey] || p.name || p.dataKey}:</span>
|
| 100 |
+
<strong className="text-white">{formatter ? formatter(p.value) : p.value}</strong>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// ============================================================================
|
| 108 |
+
// Chart 1: 20-year topic trend — area-stacked, gradient fills
|
| 109 |
+
// ============================================================================
|
| 110 |
+
function TopicTrendChart() {
|
| 111 |
+
const data = pulse.topicTrends || []
|
| 112 |
+
if (!data.length) return <p>No data.</p>
|
| 113 |
+
|
| 114 |
+
const years = [...new Set(data.map(d => d.year))].sort((a,b)=>a-b)
|
| 115 |
+
const topics = [...new Set(data.map(d => d.topic))]
|
| 116 |
+
const keepTopics = topics.filter(t =>
|
| 117 |
+
Math.max(...data.filter(d=>d.topic===t).map(d=>d.share_pct||0)) > 2.0
|
| 118 |
+
)
|
| 119 |
+
const wide = years.map(year => {
|
| 120 |
+
const row = { year }
|
| 121 |
+
keepTopics.forEach(t => {
|
| 122 |
+
const r = data.find(d => d.year===year && d.topic===t)
|
| 123 |
+
row[t] = r ? r.share_pct : 0
|
| 124 |
+
})
|
| 125 |
+
return row
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
return (
|
| 129 |
+
<div className="w-full" style={{height: 420}}>
|
| 130 |
+
<ResponsiveContainer>
|
| 131 |
+
<LineChart data={wide} margin={{top:8, right:30, left:0, bottom:8}}>
|
| 132 |
+
<defs>
|
| 133 |
+
{keepTopics.map(t => (
|
| 134 |
+
<linearGradient key={t} id={`g-${t}`} x1="0" y1="0" x2="0" y2="1">
|
| 135 |
+
<stop offset="0%" stopColor={TOPIC_COLORS[t]} stopOpacity={0.8}/>
|
| 136 |
+
<stop offset="100%" stopColor={TOPIC_COLORS[t]} stopOpacity={0.0}/>
|
| 137 |
+
</linearGradient>
|
| 138 |
+
))}
|
| 139 |
+
</defs>
|
| 140 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb"/>
|
| 141 |
+
<XAxis dataKey="year" tick={{fontSize:11, fill:"#6b7280"}}
|
| 142 |
+
axisLine={{stroke:"#d1d5db"}}/>
|
| 143 |
+
<YAxis tick={{fontSize:11, fill:"#6b7280"}}
|
| 144 |
+
axisLine={{stroke:"#d1d5db"}}
|
| 145 |
+
tickFormatter={v=>`${v}%`}/>
|
| 146 |
+
<Tooltip content={<FancyTooltip formatter={v=>`${v.toFixed(1)}%`}/>}/>
|
| 147 |
+
<Legend wrapperStyle={{fontSize:12, paddingTop:8}}
|
| 148 |
+
formatter={v => TOPIC_LABELS_SHORT[v] || v}/>
|
| 149 |
+
{/* PM-KISAN launch reference line */}
|
| 150 |
+
<ReferenceLine x={2019} stroke="#9333ea" strokeDasharray="3 3" strokeWidth={1.5}
|
| 151 |
+
label={{value:"PM-KISAN launch", position:"top", fill:"#9333ea", fontSize:10, fontWeight:600}}/>
|
| 152 |
+
<ReferenceLine x={2016} stroke="#9333ea" strokeDasharray="3 3" strokeWidth={1.5} opacity={0.5}
|
| 153 |
+
label={{value:"PMFBY", position:"top", fill:"#9333ea", fontSize:10}}/>
|
| 154 |
+
{keepTopics.map(t =>
|
| 155 |
+
<Line key={t} type="monotone" dataKey={t}
|
| 156 |
+
stroke={TOPIC_COLORS[t]} strokeWidth={2.5}
|
| 157 |
+
dot={{r:2.5, strokeWidth:0, fill:TOPIC_COLORS[t]}}
|
| 158 |
+
activeDot={{r:5, strokeWidth:2, stroke:"white", fill:TOPIC_COLORS[t]}}/>
|
| 159 |
+
)}
|
| 160 |
+
</LineChart>
|
| 161 |
+
</ResponsiveContainer>
|
| 162 |
+
</div>
|
| 163 |
+
)
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// ============================================================================
|
| 167 |
+
// Chart 2: Topic share by state (horizontal stacked bars)
|
| 168 |
+
// ============================================================================
|
| 169 |
+
function TopicByStateChart() {
|
| 170 |
+
const data = pulse.topicByState || []
|
| 171 |
+
if (!data.length) return <p>No data.</p>
|
| 172 |
+
|
| 173 |
+
const states = [...new Set(data.map(d => d.state))]
|
| 174 |
+
.sort((a,b) => {
|
| 175 |
+
const sa = data.filter(d=>d.state===a).reduce((s,d)=>s+d.query_count,0)
|
| 176 |
+
const sb = data.filter(d=>d.state===b).reduce((s,d)=>s+d.query_count,0)
|
| 177 |
+
return sb - sa
|
| 178 |
+
}).slice(0, 25)
|
| 179 |
+
const topics = ["pest","disease","nutrient","weather","irrigation","price","seed","scheme","crop_selection","general"]
|
| 180 |
+
const wide = states.map(state => {
|
| 181 |
+
const row = { state }
|
| 182 |
+
topics.forEach(t => {
|
| 183 |
+
const r = data.find(d => d.state===state && d.topic===t)
|
| 184 |
+
row[t] = r ? r.share_pct : 0
|
| 185 |
+
})
|
| 186 |
+
return row
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
return (
|
| 190 |
+
<div className="w-full" style={{height: Math.max(420, 26 * states.length + 80)}}>
|
| 191 |
+
<ResponsiveContainer>
|
| 192 |
+
<BarChart data={wide} layout="vertical" stackOffset="expand"
|
| 193 |
+
margin={{top:8, right:24, left:130, bottom:32}}>
|
| 194 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb"/>
|
| 195 |
+
<XAxis type="number" tickFormatter={v=>`${Math.round(v*100)}%`}
|
| 196 |
+
tick={{fontSize:11, fill:"#6b7280"}} axisLine={{stroke:"#d1d5db"}}/>
|
| 197 |
+
<YAxis type="category" dataKey="state"
|
| 198 |
+
tick={{fontSize:11, fill:"#374151", fontWeight:500}}
|
| 199 |
+
width={120} axisLine={{stroke:"#d1d5db"}}/>
|
| 200 |
+
<Tooltip content={<FancyTooltip formatter={v=>`${v.toFixed(1)}%`}/>}/>
|
| 201 |
+
<Legend wrapperStyle={{fontSize:11, paddingTop:16}}
|
| 202 |
+
formatter={v => TOPIC_LABELS_SHORT[v] || v}/>
|
| 203 |
+
{topics.map(t =>
|
| 204 |
+
<Bar key={t} dataKey={t} stackId="a" fill={TOPIC_COLORS[t]} radius={0}/>
|
| 205 |
+
)}
|
| 206 |
+
</BarChart>
|
| 207 |
+
</ResponsiveContainer>
|
| 208 |
+
</div>
|
| 209 |
+
)
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// ============================================================================
|
| 213 |
+
// Chart 3: Top districts per topic (with topic dropdown)
|
| 214 |
+
// ============================================================================
|
| 215 |
+
function TopDistrictsChart() {
|
| 216 |
+
const data = pulse.topDistricts || []
|
| 217 |
+
const topics = useMemo(() =>
|
| 218 |
+
[...new Set(data.map(d=>d.topic))].sort((a,b) =>
|
| 219 |
+
data.filter(d=>d.topic===b).reduce((s,d)=>s+d.query_count,0) -
|
| 220 |
+
data.filter(d=>d.topic===a).reduce((s,d)=>s+d.query_count,0)
|
| 221 |
+
), [data])
|
| 222 |
+
const [topic, setTopic] = useState(topics[0] || "pest")
|
| 223 |
+
const rows = data.filter(d=>d.topic===topic).slice(0, 10)
|
| 224 |
+
const color = TOPIC_COLORS[topic] || "#16a34a"
|
| 225 |
+
|
| 226 |
+
return (
|
| 227 |
+
<div>
|
| 228 |
+
<div className="flex flex-wrap gap-2 mb-5">
|
| 229 |
+
{topics.map(t => (
|
| 230 |
+
<button key={t}
|
| 231 |
+
onClick={() => setTopic(t)}
|
| 232 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
| 233 |
+
topic===t
|
| 234 |
+
? "shadow-md ring-2 ring-offset-1 text-white"
|
| 235 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 236 |
+
}`}
|
| 237 |
+
style={topic===t ? {background: TOPIC_COLORS[t], ringColor: TOPIC_COLORS[t]} : {}}>
|
| 238 |
+
{TOPIC_LABELS[t] || t}
|
| 239 |
+
</button>
|
| 240 |
+
))}
|
| 241 |
+
</div>
|
| 242 |
+
<div className="w-full" style={{height: 360}}>
|
| 243 |
+
<ResponsiveContainer>
|
| 244 |
+
<BarChart data={rows} layout="vertical" margin={{top:8, right:30, left:180, bottom:8}}>
|
| 245 |
+
<defs>
|
| 246 |
+
<linearGradient id="bar-gradient" x1="0" y1="0" x2="1" y2="0">
|
| 247 |
+
<stop offset="0%" stopColor={color} stopOpacity={0.6}/>
|
| 248 |
+
<stop offset="100%" stopColor={color} stopOpacity={1}/>
|
| 249 |
+
</linearGradient>
|
| 250 |
+
</defs>
|
| 251 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb"/>
|
| 252 |
+
<XAxis type="number" tick={{fontSize:11, fill:"#6b7280"}}
|
| 253 |
+
tickFormatter={v => v.toLocaleString()}/>
|
| 254 |
+
<YAxis type="category" dataKey={d => `${d.district}, ${d.state}`}
|
| 255 |
+
tick={{fontSize:11, fill:"#374151"}} width={170}/>
|
| 256 |
+
<Tooltip content={<FancyTooltip formatter={v=>v.toLocaleString()+" queries"}/>}/>
|
| 257 |
+
<Bar dataKey="query_count" fill="url(#bar-gradient)" radius={[0,6,6,0]}/>
|
| 258 |
+
</BarChart>
|
| 259 |
+
</ResponsiveContainer>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
)
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// ============================================================================
|
| 266 |
+
// Chart 4: Crop × topic heatmap (real heatmap, not stacked bars)
|
| 267 |
+
// ============================================================================
|
| 268 |
+
function CropTopicHeatmap() {
|
| 269 |
+
const data = pulse.cropTopic || []
|
| 270 |
+
if (!data.length) return <p>No data.</p>
|
| 271 |
+
|
| 272 |
+
const crops = [...new Set(data.map(d=>d.crop))].filter(Boolean)
|
| 273 |
+
.sort((a,b) => {
|
| 274 |
+
const sa = data.filter(d=>d.crop===a).reduce((s,d)=>s+d.query_count,0)
|
| 275 |
+
const sb = data.filter(d=>d.crop===b).reduce((s,d)=>s+d.query_count,0)
|
| 276 |
+
return sb - sa
|
| 277 |
+
}).slice(0, 15)
|
| 278 |
+
const topics = ["pest","disease","nutrient","weather","irrigation","price","seed","scheme","crop_selection"]
|
| 279 |
+
|
| 280 |
+
// Compute per-crop share
|
| 281 |
+
const matrix = crops.map(crop => {
|
| 282 |
+
const cropData = data.filter(d => d.crop === crop)
|
| 283 |
+
const total = cropData.filter(d => topics.includes(d.topic))
|
| 284 |
+
.reduce((s,d) => s + d.query_count, 0)
|
| 285 |
+
return {
|
| 286 |
+
crop,
|
| 287 |
+
total,
|
| 288 |
+
cells: topics.map(t => {
|
| 289 |
+
const r = cropData.find(d => d.topic === t)
|
| 290 |
+
return {
|
| 291 |
+
topic: t,
|
| 292 |
+
count: r ? r.query_count : 0,
|
| 293 |
+
share: total ? (r ? r.query_count : 0) / total : 0,
|
| 294 |
+
}
|
| 295 |
+
})
|
| 296 |
+
}
|
| 297 |
+
})
|
| 298 |
+
// Max share for color scaling
|
| 299 |
+
const maxShare = Math.max(...matrix.flatMap(r => r.cells.map(c => c.share)))
|
| 300 |
+
|
| 301 |
+
return (
|
| 302 |
+
<div className="overflow-x-auto">
|
| 303 |
+
<table className="w-full text-xs border-collapse">
|
| 304 |
+
<thead>
|
| 305 |
+
<tr>
|
| 306 |
+
<th className="text-left p-2 sticky left-0 bg-white"></th>
|
| 307 |
+
{topics.map(t => (
|
| 308 |
+
<th key={t} className="p-2 text-center font-semibold text-gray-700 min-w-[80px]">
|
| 309 |
+
<div className="leading-tight">{TOPIC_LABELS[t]}</div>
|
| 310 |
+
</th>
|
| 311 |
+
))}
|
| 312 |
+
<th className="p-2 text-right text-gray-500">Volume</th>
|
| 313 |
+
</tr>
|
| 314 |
+
</thead>
|
| 315 |
+
<tbody>
|
| 316 |
+
{matrix.map(row => (
|
| 317 |
+
<tr key={row.crop} className="hover:bg-gray-50">
|
| 318 |
+
<td className="p-2 font-semibold text-gray-900 text-sm sticky left-0 bg-white border-r border-gray-100">
|
| 319 |
+
{row.crop}
|
| 320 |
+
</td>
|
| 321 |
+
{row.cells.map((cell, i) => {
|
| 322 |
+
const intensity = maxShare ? cell.share / maxShare : 0
|
| 323 |
+
const bg = `rgba(22, 163, 74, ${0.05 + intensity * 0.9})`
|
| 324 |
+
const fg = intensity > 0.5 ? "white" : "#111827"
|
| 325 |
+
return (
|
| 326 |
+
<td key={i} className="p-1 text-center"
|
| 327 |
+
style={{background: bg, color: fg, fontWeight: 600}}
|
| 328 |
+
title={`${row.crop} × ${TOPIC_LABELS_SHORT[cell.topic]}: ${cell.count.toLocaleString()} queries (${(cell.share*100).toFixed(1)}%)`}>
|
| 329 |
+
{(cell.share*100).toFixed(0)}%
|
| 330 |
+
</td>
|
| 331 |
+
)
|
| 332 |
+
})}
|
| 333 |
+
<td className="p-2 text-right text-gray-500 font-medium">
|
| 334 |
+
{row.total.toLocaleString()}
|
| 335 |
+
</td>
|
| 336 |
+
</tr>
|
| 337 |
+
))}
|
| 338 |
+
</tbody>
|
| 339 |
+
</table>
|
| 340 |
+
<p className="text-xs text-gray-500 mt-3">
|
| 341 |
+
Each cell = % of that crop's queries falling in that topic (recent 5-yr window). Darker green = higher concentration.
|
| 342 |
+
</p>
|
| 343 |
+
</div>
|
| 344 |
+
)
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// ============================================================================
|
| 348 |
+
// Chart 5: Mandi correlation (scatter + leaderboard)
|
| 349 |
+
// ============================================================================
|
| 350 |
+
function MandiCorrelationChart() {
|
| 351 |
+
const data = pulse.mandiCorrelation || []
|
| 352 |
+
if (!data.length) return <p className="text-gray-500 text-sm">No mandi data joined.</p>
|
| 353 |
+
|
| 354 |
+
// Filter to reliable cells: n_months >= 12
|
| 355 |
+
const reliable = data.filter(d => d.n_months >= 12)
|
| 356 |
+
const top = [...reliable].sort((a,b) => Math.abs(b.spearman_r) - Math.abs(a.spearman_r)).slice(0, 12)
|
| 357 |
+
|
| 358 |
+
return (
|
| 359 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 360 |
+
<div className="lg:col-span-2" style={{height: 380}}>
|
| 361 |
+
<ResponsiveContainer>
|
| 362 |
+
<ScatterChart margin={{top:8, right:24, left:8, bottom:32}}>
|
| 363 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb"/>
|
| 364 |
+
<XAxis type="number" dataKey="total_queries" tick={{fontSize:11, fill:"#6b7280"}}
|
| 365 |
+
label={{value:"KCC price-query volume", position:"insideBottom", offset:-10, style:{fontSize:11, fill:"#6b7280"}}}/>
|
| 366 |
+
<YAxis type="number" dataKey="spearman_r" domain={[-1,1]} tick={{fontSize:11, fill:"#6b7280"}}
|
| 367 |
+
label={{value:"Spearman r", angle:-90, position:"insideLeft", style:{fontSize:11, fill:"#6b7280"}}}/>
|
| 368 |
+
<ZAxis type="number" dataKey="n_months" range={[40, 400]}/>
|
| 369 |
+
<ReferenceLine y={0} stroke="#9ca3af" strokeDasharray="3 3"/>
|
| 370 |
+
<Tooltip content={({active, payload}) => {
|
| 371 |
+
if (!active || !payload || !payload.length) return null
|
| 372 |
+
const d = payload[0].payload
|
| 373 |
+
const r = d.spearman_r
|
| 374 |
+
return <div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl">
|
| 375 |
+
<div className="font-bold text-sm">{d.state} — {d.crop}</div>
|
| 376 |
+
<div className="mt-0.5" style={{color: r > 0 ? "#86efac" : "#fca5a5"}}>
|
| 377 |
+
r = <strong>{r > 0 ? "+" : ""}{r}</strong>
|
| 378 |
+
</div>
|
| 379 |
+
<div className="text-gray-400 mt-0.5">n={d.n_months} months · {d.total_queries.toLocaleString()} queries</div>
|
| 380 |
+
</div>
|
| 381 |
+
}}/>
|
| 382 |
+
<Scatter data={reliable.filter(d => d.spearman_r >= 0)} fill="#16a34a" fillOpacity={0.6}/>
|
| 383 |
+
<Scatter data={reliable.filter(d => d.spearman_r < 0)} fill="#dc2626" fillOpacity={0.6}/>
|
| 384 |
+
</ScatterChart>
|
| 385 |
+
</ResponsiveContainer>
|
| 386 |
+
</div>
|
| 387 |
+
<div className="max-h-[380px] overflow-y-auto">
|
| 388 |
+
<h4 className="text-xs font-semibold text-gray-700 mb-3 uppercase tracking-wide">Strongest correlations</h4>
|
| 389 |
+
<div className="space-y-1.5">
|
| 390 |
+
{top.map((d,i) => (
|
| 391 |
+
<div key={i} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
| 392 |
+
<div className="text-xs">
|
| 393 |
+
<div className="font-semibold text-gray-900">{d.crop}</div>
|
| 394 |
+
<div className="text-gray-500">{d.state} · n={d.n_months}</div>
|
| 395 |
+
</div>
|
| 396 |
+
<span className={`text-sm font-bold tabular-nums ${
|
| 397 |
+
d.spearman_r > 0 ? "text-green-600" : "text-red-600"
|
| 398 |
+
}`}>
|
| 399 |
+
{d.spearman_r > 0 ? "+" : ""}{d.spearman_r}
|
| 400 |
+
</span>
|
| 401 |
+
</div>
|
| 402 |
+
))}
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
)
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// ============================================================================
|
| 410 |
+
// Chart 6: Scheme uptake — annotated line chart
|
| 411 |
+
// ============================================================================
|
| 412 |
+
function SchemeUptakeChart() {
|
| 413 |
+
const data = pulse.schemeUptake || []
|
| 414 |
+
if (!data.length) return <p>No data.</p>
|
| 415 |
+
|
| 416 |
+
const schemes = Object.keys(data[0]).filter(k => k !== "year")
|
| 417 |
+
const COLORS = ["#dc2626","#9333ea","#16a34a","#ca8a04","#2563eb","#ea580c","#0891b2"]
|
| 418 |
+
|
| 419 |
+
return (
|
| 420 |
+
<div className="w-full" style={{height: 380}}>
|
| 421 |
+
<ResponsiveContainer>
|
| 422 |
+
<LineChart data={data} margin={{top:24, right:30, left:0, bottom:8}}>
|
| 423 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb"/>
|
| 424 |
+
<XAxis dataKey="year" tick={{fontSize:11, fill:"#6b7280"}}/>
|
| 425 |
+
<YAxis tick={{fontSize:11, fill:"#6b7280"}}/>
|
| 426 |
+
<Tooltip content={<FancyTooltip/>}/>
|
| 427 |
+
<Legend wrapperStyle={{fontSize:11, paddingTop:8}}/>
|
| 428 |
+
{/* Launch markers */}
|
| 429 |
+
<ReferenceLine x={2019} stroke="#dc2626" strokeDasharray="2 4" opacity={0.5}/>
|
| 430 |
+
<ReferenceLine x={2016} stroke="#9333ea" strokeDasharray="2 4" opacity={0.5}/>
|
| 431 |
+
<ReferenceLine x={2015} stroke="#16a34a" strokeDasharray="2 4" opacity={0.5}/>
|
| 432 |
+
{schemes.map((s, i) =>
|
| 433 |
+
<Line key={s} type="monotone" dataKey={s}
|
| 434 |
+
stroke={COLORS[i%COLORS.length]} strokeWidth={2.5}
|
| 435 |
+
dot={{r:2.5, strokeWidth:0, fill:COLORS[i%COLORS.length]}}
|
| 436 |
+
activeDot={{r:5, strokeWidth:2, stroke:"white"}}/>
|
| 437 |
+
)}
|
| 438 |
+
</LineChart>
|
| 439 |
+
</ResponsiveContainer>
|
| 440 |
+
<div className="text-xs text-gray-500 mt-2 flex gap-4 flex-wrap">
|
| 441 |
+
<span><span className="inline-block w-3 h-0.5 bg-red-600 mr-1"/>2019: PM-KISAN launch</span>
|
| 442 |
+
<span><span className="inline-block w-3 h-0.5 bg-purple-600 mr-1"/>2016: PMFBY launch</span>
|
| 443 |
+
<span><span className="inline-block w-3 h-0.5 bg-green-600 mr-1"/>2015: Soil Health Card launch</span>
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
)
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
// ============================================================================
|
| 450 |
+
// KPI card
|
| 451 |
+
// ============================================================================
|
| 452 |
+
function KPICard({ label, value, sublabel, icon, color = "agri" }) {
|
| 453 |
+
const colors = {
|
| 454 |
+
agri: "from-green-500 to-emerald-600 text-green-50",
|
| 455 |
+
purple: "from-purple-500 to-purple-700 text-purple-50",
|
| 456 |
+
blue: "from-blue-500 to-blue-700 text-blue-50",
|
| 457 |
+
earth: "from-yellow-500 to-amber-600 text-yellow-50",
|
| 458 |
+
}
|
| 459 |
+
return (
|
| 460 |
+
<div className={`bg-gradient-to-br ${colors[color]} rounded-2xl p-5 shadow-lg hover:shadow-xl transition-shadow`}>
|
| 461 |
+
<div className="flex items-start justify-between">
|
| 462 |
+
<div className="text-xs uppercase tracking-wider opacity-80 font-semibold">{label}</div>
|
| 463 |
+
<div className="text-2xl opacity-50">{icon}</div>
|
| 464 |
+
</div>
|
| 465 |
+
<div className="mt-2 text-3xl font-bold tabular-nums">{value}</div>
|
| 466 |
+
{sublabel && <div className="mt-1 text-xs opacity-75">{sublabel}</div>}
|
| 467 |
+
</div>
|
| 468 |
+
)
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// ============================================================================
|
| 472 |
+
// Buyer-callout — "what this means for X"
|
| 473 |
+
// ============================================================================
|
| 474 |
+
function BuyerCallout({ icon, audience, claim, evidence }) {
|
| 475 |
+
return (
|
| 476 |
+
<div className="bg-gradient-to-br from-gray-50 to-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
| 477 |
+
<div className="flex items-start gap-3">
|
| 478 |
+
<div className="text-2xl">{icon}</div>
|
| 479 |
+
<div>
|
| 480 |
+
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{audience}</div>
|
| 481 |
+
<div className="mt-1 font-semibold text-gray-900 text-sm">{claim}</div>
|
| 482 |
+
<div className="mt-1 text-xs text-gray-600">{evidence}</div>
|
| 483 |
+
</div>
|
| 484 |
+
</div>
|
| 485 |
+
</div>
|
| 486 |
+
)
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
// ============================================================================
|
| 490 |
+
// Page
|
| 491 |
+
// ============================================================================
|
| 492 |
+
export default function DemandPulse() {
|
| 493 |
+
const summary = pulse.summary || {}
|
| 494 |
+
const totalQ = summary.totalQueries || 0
|
| 495 |
+
const yMin = summary.years?.min, yMax = summary.years?.max
|
| 496 |
+
const states = summary.states, topics = summary.topics
|
| 497 |
+
|
| 498 |
+
return (
|
| 499 |
+
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-green-50">
|
| 500 |
+
{/* Hero */}
|
| 501 |
+
<div className="gradient-agri text-white">
|
| 502 |
+
<div className="max-w-7xl mx-auto px-6 py-12">
|
| 503 |
+
<Link to="/" className="text-green-100 hover:text-white text-sm inline-flex items-center gap-1">
|
| 504 |
+
← Back to home
|
| 505 |
+
</Link>
|
| 506 |
+
<div className="mt-4 flex items-start justify-between flex-wrap gap-6">
|
| 507 |
+
<div className="max-w-3xl">
|
| 508 |
+
<div className="inline-block text-xs font-bold uppercase tracking-wider mb-3 px-3 py-1 rounded-full bg-white/20 text-white">
|
| 509 |
+
Asset #8 · India Demand Pulse
|
| 510 |
+
</div>
|
| 511 |
+
<h1 className="text-4xl md:text-5xl font-bold leading-tight">
|
| 512 |
+
What India's farmers actually ask
|
| 513 |
+
</h1>
|
| 514 |
+
<p className="mt-4 text-green-100 text-lg leading-relaxed">
|
| 515 |
+
Twenty years of Kisan Call Center queries — analyzed, joined with
|
| 516 |
+
mandi prices and pest risk forecasts — to surface the demand patterns
|
| 517 |
+
no Indian agritech, bank, or insurer can see today.
|
| 518 |
+
</p>
|
| 519 |
+
</div>
|
| 520 |
+
<div className="hidden md:block text-right">
|
| 521 |
+
<div className="text-7xl font-bold leading-none opacity-30">{(yMax || 2025) - (yMin || 2006) + 1}</div>
|
| 522 |
+
<div className="text-sm opacity-75 mt-1">years of farmer demand</div>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
</div>
|
| 527 |
+
|
| 528 |
+
<div className="max-w-7xl mx-auto px-6 py-8">
|
| 529 |
+
{/* KPI strip */}
|
| 530 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 -mt-12 mb-12 relative z-10">
|
| 531 |
+
<KPICard label="Total queries" value={<AnimatedNumber value={totalQ}/>} sublabel="cleaned KCC records" icon="📞" color="agri"/>
|
| 532 |
+
<KPICard label="Years covered" value={`${yMin}–${yMax}`} sublabel={`${(yMax||2025) - (yMin||2006) + 1} continuous years`} icon="📅" color="purple"/>
|
| 533 |
+
<KPICard label="States + UTs" value={<AnimatedNumber value={states} duration={800}/>} sublabel="28 states + 8 UTs (pre-2020)" icon="🗺️" color="blue"/>
|
| 534 |
+
<KPICard label="Topic categories" value={<AnimatedNumber value={topics} duration={800}/>} sublabel="auto-classified" icon="🏷️" color="earth"/>
|
| 535 |
+
</div>
|
| 536 |
+
|
| 537 |
+
{/* Buyer callouts */}
|
| 538 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
|
| 539 |
+
<BuyerCallout icon="🏦" audience="For banks & insurers"
|
| 540 |
+
claim="Distress detection 30 days early"
|
| 541 |
+
evidence="Pest+disease query spikes precede insurance claims by 2-4 weeks; map every district by signal lag."/>
|
| 542 |
+
<BuyerCallout icon="🏭" audience="For input companies"
|
| 543 |
+
claim="Regional demand forecasting"
|
| 544 |
+
evidence="Telangana asks pest. Tamil Nadu asks nutrient. Each state needs different SKU mix."/>
|
| 545 |
+
<BuyerCallout icon="🏛️" audience="For govt & policy"
|
| 546 |
+
claim="Scheme uptake measured per district"
|
| 547 |
+
evidence="PM-KISAN went from 0 to 210 mentions in 2019. Track every scheme's adoption curve."/>
|
| 548 |
+
</div>
|
| 549 |
+
|
| 550 |
+
{/* Section 1: 20-year topic trend */}
|
| 551 |
+
<Section eyebrow="The 20-year story"
|
| 552 |
+
title="What farmers ask has fundamentally shifted"
|
| 553 |
+
subtitle={<>Scheme queries went from <strong>1.2% share in 2010 to 30% in 2025</strong>. PM-KISAN's 2019 launch is visible as a step-change in the data. KCC is now primarily a government-scheme helpdesk.</>}
|
| 554 |
+
accent="agri">
|
| 555 |
+
<TopicTrendChart/>
|
| 556 |
+
</Section>
|
| 557 |
+
|
| 558 |
+
{/* Section 2: state topic mix */}
|
| 559 |
+
<Section eyebrow="State-level demand"
|
| 560 |
+
title="Each state asks completely different things"
|
| 561 |
+
subtitle="Top 25 states by query volume, share of each topic. Read horizontally — Jharkhand is 72% scheme inquiries, Himachal Pradesh is 29% pest, West Bengal is 13% price. One product won't fit all."
|
| 562 |
+
accent="data">
|
| 563 |
+
<TopicByStateChart/>
|
| 564 |
+
</Section>
|
| 565 |
+
|
| 566 |
+
{/* Section 3: crop topic heatmap */}
|
| 567 |
+
<Section eyebrow="Crop demand profile"
|
| 568 |
+
title="Each crop has a fingerprint"
|
| 569 |
+
subtitle="What topics dominate queries for each major crop. Cotton is pest-heavy; sugarcane is variety + irrigation; wheat is balanced across topics. Drives input-company territory planning."
|
| 570 |
+
accent="agri">
|
| 571 |
+
<CropTopicHeatmap/>
|
| 572 |
+
</Section>
|
| 573 |
+
|
| 574 |
+
{/* Section 4: top districts per topic */}
|
| 575 |
+
<Section eyebrow="District leaderboard"
|
| 576 |
+
title="Where each topic concentrates"
|
| 577 |
+
subtitle="Top 10 districts per topic (recent 5-year window). Filterable by topic — pick 'pest' for input companies, 'scheme' for banks, 'price' for traders."
|
| 578 |
+
accent="data">
|
| 579 |
+
<TopDistrictsChart/>
|
| 580 |
+
</Section>
|
| 581 |
+
|
| 582 |
+
{/* Section 5: mandi correlation */}
|
| 583 |
+
<Section eyebrow="Cross-asset signal"
|
| 584 |
+
title="Mandi prices ↔ KCC query volume"
|
| 585 |
+
subtitle={<>For specific high-history crop-state pairs there's a robust multi-year relationship — <strong>MP wheat r=+0.52 over 162 months</strong>, MP mustard r=+0.58 over 124 months. Population-level mean is near zero, but the strong cells are publishable leading indicators for banks and FPOs.</>}
|
| 586 |
+
accent="earth">
|
| 587 |
+
<MandiCorrelationChart/>
|
| 588 |
+
</Section>
|
| 589 |
+
|
| 590 |
+
{/* Section 6: info gap map */}
|
| 591 |
+
<Section eyebrow="Underserved districts"
|
| 592 |
+
title="Where farmers don't know what to ask"
|
| 593 |
+
subtitle={<>The information-gap map: high pest model risk (AUC 0.937) <em>plus</em> low KCC query volume = districts where farmers face pest pressure but aren't engaging the helpline. <strong>Assam dominates the top-5 with 100% risk and zero queries.</strong> Direct buyer slide for IFFCO Tokio (claim leading indicators) and DeHaat/CropIn (extension expansion).</>}
|
| 594 |
+
accent="danger">
|
| 595 |
+
<InfoGapMap data={pulse.infoGap || []}/>
|
| 596 |
+
</Section>
|
| 597 |
+
|
| 598 |
+
{/* Section 7: scheme uptake */}
|
| 599 |
+
<Section eyebrow="Government scheme adoption"
|
| 600 |
+
title="Every scheme launch leaves a fingerprint"
|
| 601 |
+
subtitle="Year-by-year mention curves for major schemes. PM-KISAN 2019, PMFBY 2016, Soil Health Card 2015 — all visible. MSP queries grew 33× from 2010-2024 (reflects 2020-21 farmer agitation + post-agitation price awareness)."
|
| 602 |
+
accent="agri">
|
| 603 |
+
<SchemeUptakeChart/>
|
| 604 |
+
</Section>
|
| 605 |
+
|
| 606 |
+
{/* Methodology footer */}
|
| 607 |
+
<div className="bg-yellow-50 border border-yellow-200 rounded-2xl p-5 text-sm text-gray-700 leading-relaxed">
|
| 608 |
+
<div className="font-semibold text-gray-900 mb-2">Methodology</div>
|
| 609 |
+
KCC queries 2006-2025 (20 yearly parquets, 43.9M raw rows) classified
|
| 610 |
+
into {topics} topics using KCC's own QueryType column (primary signal)
|
| 611 |
+
+ free-text regex fallback. State names normalised to {states}
|
| 612 |
+
canonical (28 states + 8 UTs, pre-2020 boundaries). Mandi correlation
|
| 613 |
+
uses AGMARKNET state-aggregate avg_price percentile, Spearman rank.
|
| 614 |
+
Info-gap uses district stacking pest model (AUC 0.937) max-risk vs.
|
| 615 |
+
KCC pest+disease query volume in 2020-25 window. Scheme uptake is a
|
| 616 |
+
mention-count proxy on the top-3 queries per cohort. All raw analytics
|
| 617 |
+
in <code className="bg-yellow-100 px-1.5 py-0.5 rounded">data/india_kcc_*.parquet</code>.
|
| 618 |
+
Full findings: <code className="bg-yellow-100 px-1.5 py-0.5 rounded">docs/DEMAND_PULSE_FINDINGS.md</code>.
|
| 619 |
+
</div>
|
| 620 |
+
</div>
|
| 621 |
+
</div>
|
| 622 |
+
)
|
| 623 |
+
}
|
frontend/src/pages/Landing.jsx
CHANGED
|
@@ -32,6 +32,7 @@ export default function Landing() {
|
|
| 32 |
<span className="text-2xl font-bold text-agri-700">🌾 KCC AgriAdvisor</span>
|
| 33 |
<div className="flex gap-3">
|
| 34 |
<button onClick={() => nav("/proof")} className="btn-outline text-sm py-2 px-4">📊 Proof</button>
|
|
|
|
| 35 |
<button onClick={() => nav("/farmer")} className="btn-outline text-sm py-2 px-4">Farmer Portal</button>
|
| 36 |
<button onClick={() => nav("/b2b")} className="btn-primary text-sm py-2 px-4">Enterprise Login</button>
|
| 37 |
</div>
|
|
|
|
| 32 |
<span className="text-2xl font-bold text-agri-700">🌾 KCC AgriAdvisor</span>
|
| 33 |
<div className="flex gap-3">
|
| 34 |
<button onClick={() => nav("/proof")} className="btn-outline text-sm py-2 px-4">📊 Proof</button>
|
| 35 |
+
<button onClick={() => nav("/pulse")} className="btn-outline text-sm py-2 px-4">📈 Demand Pulse</button>
|
| 36 |
<button onClick={() => nav("/farmer")} className="btn-outline text-sm py-2 px-4">Farmer Portal</button>
|
| 37 |
<button onClick={() => nav("/b2b")} className="btn-primary text-sm py-2 px-4">Enterprise Login</button>
|
| 38 |
</div>
|