hritikm15 commited on
Commit
c483ab8
·
verified ·
1 Parent(s): ef3ce21

Asset #8 — India KCC Demand Pulse tab (/pulse, 7 charts + India choropleth)

Browse files
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 &gt; 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>