feat(nabard): India choropleth on the national view
The first question an apex supervisor asks is geographic — which part of the country is deteriorating — and thirty-six table rows answer that far more slowly than a shape does. Clicking a state drills into its districts, so the map is a way into the hierarchy rather than a separate view of it. Three rules a default chart library would get wrong: No data is not zero. A state with no refinanced portfolio is drawn in a neutral grey that sits nowhere on the stress ramp, so it cannot be misread as healthy. Today that is most of the country, and saying so plainly is more useful than an empty-looking map that implies national coverage. Suppressed is its own state, hatched rather than shaded — those cells have borrowers whose numbers are deliberately not shown, which is a different fact from a low rate. Colour is not the only channel: every state is reachable by hover, focus and keyboard, carries a title element, and its figures appear as text beside the map. A choropleth alone fails colour-blind readers and prints badly. The ramp is single-hue in the brand green and anchored on the worst observed rate rather than on 100 — with a national spread of 15-30% a fixed scale renders every state identically, and a red-to-green ramp would encode a value judgement the number does not carry. Geometry is a static asset generated once from an open district dataset, dissolved to state level, projected to a 520x592 viewBox and decimated to ~220 KB. Not a hosted basemap: the portal has to render inside a bank network with no outbound tile requests. Boundaries are simplified for display and labelled as not authoritative. Adds ~73 KB gzipped to the bundle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import { ChevronRight, EyeOff } from 'lucide-react'
|
||||
import { get } from '../../lib/api'
|
||||
import type { GeoCell } from '../../lib/api'
|
||||
import { Card, Empty } from '../../components/ui/bits'
|
||||
import { IndiaMap } from './IndiaMap'
|
||||
import type { StateDatum } from './IndiaMap'
|
||||
|
||||
type Level = 'ZONE' | 'STATE' | 'DISTRICT'
|
||||
|
||||
@@ -36,6 +38,21 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
||||
})
|
||||
const rows = data ?? []
|
||||
|
||||
// The map is always state-level, whatever the table is showing, so clicking a
|
||||
// state on the map is a way into the hierarchy rather than a separate view of
|
||||
// it. Fetched once and shared by both.
|
||||
const mapQ = useQuery({
|
||||
queryKey: ['apex-geo-map'],
|
||||
queryFn: () => get<GeoCell[]>('/nabard/geography', { level: 'STATE' }),
|
||||
})
|
||||
const mapData: StateDatum[] = (mapQ.data ?? []).map((r) => ({
|
||||
state: r.name,
|
||||
enterprises: r.enterprises,
|
||||
red: r.red,
|
||||
stressRatePct: r.stressRatePct,
|
||||
suppressed: r.suppressed,
|
||||
}))
|
||||
|
||||
const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0))
|
||||
|
||||
return (
|
||||
@@ -64,6 +81,24 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)] gap-4 items-start">
|
||||
<div className="border border-slate-200 rounded-[10px] p-2 bg-white">
|
||||
<IndiaMap
|
||||
data={mapData}
|
||||
minCellSize={minCellSize}
|
||||
selected={state}
|
||||
onSelect={(picked) => {
|
||||
// Selecting on the map jumps straight to that state's districts,
|
||||
// and sets the zone crumb so the trail still makes sense.
|
||||
const row = (mapQ.data ?? []).find((r) => r.name === picked)
|
||||
if (!row) return
|
||||
if (row.zone) setZone(row.zone)
|
||||
setState(picked)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{isError ? (
|
||||
<Empty text="Geography is unavailable — the console could not reach the platform." />
|
||||
) : rows.length === 0 ? (
|
||||
@@ -128,9 +163,14 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
||||
</table>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10.5px] text-slate-400 mt-2.5">
|
||||
The pilot covers four districts of Madhya Pradesh, so only the Central zone carries data
|
||||
today. Every other zone would appear here as partner banks onboard.
|
||||
today — every other state is drawn as <b>no portfolio</b> rather than as zero stress, and
|
||||
would fill in as partner banks onboard. Boundaries are simplified for display and are not
|
||||
an authoritative depiction.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { INDIA_STATE_PATHS, INDIA_VIEWBOX } from '../../lib/indiaMap'
|
||||
|
||||
/** What the map needs to know about one state. */
|
||||
export interface StateDatum {
|
||||
state: string
|
||||
enterprises: number
|
||||
red?: number
|
||||
stressRatePct?: number
|
||||
suppressed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The refinanced portfolio on a map of India.
|
||||
*
|
||||
* <p>A choropleth earns its place here because the question NABARD asks first is
|
||||
* geographic — which part of the country is deteriorating — and a table of
|
||||
* thirty-six rows answers that far more slowly than a shape does.
|
||||
*
|
||||
* <p>Three rules it follows, all of which a default chart library would get
|
||||
* wrong:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>No data is not zero.</b> A state with no refinanced portfolio is
|
||||
* drawn in a neutral grey that is nowhere on the stress ramp, so it can
|
||||
* never be misread as "healthy". Today that is most of the country.</li>
|
||||
* <li><b>Suppressed is its own state.</b> A cell below the minimum size is
|
||||
* hatched rather than shaded — it has borrowers, and their number is
|
||||
* deliberately not being shown.</li>
|
||||
* <li><b>Colour is not the only channel.</b> Every state is also reachable by
|
||||
* hover and keyboard with its figures in text, because a choropleth alone
|
||||
* fails colour-blind readers and prints badly.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export function IndiaMap({
|
||||
data,
|
||||
minCellSize,
|
||||
onSelect,
|
||||
selected,
|
||||
}: {
|
||||
data: StateDatum[]
|
||||
minCellSize: number
|
||||
onSelect?: (state: string) => void
|
||||
selected?: string | null
|
||||
}) {
|
||||
const [hover, setHover] = useState<string | null>(null)
|
||||
|
||||
const byState = useMemo(() => {
|
||||
const map = new Map<string, StateDatum>()
|
||||
data.forEach((d) => map.set(normalise(d.state), d))
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
// Ramp anchored on the worst observed rate, not on 100 — with a national
|
||||
// spread of 15–30% a fixed 0–100 scale would render every state identically.
|
||||
const worst = Math.max(
|
||||
5,
|
||||
...data.filter((d) => !d.suppressed).map((d) => d.stressRatePct ?? 0),
|
||||
)
|
||||
|
||||
const active = hover ?? selected ?? null
|
||||
const activeDatum = active ? byState.get(normalise(active)) : null
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox={INDIA_VIEWBOX}
|
||||
className="w-full h-auto max-h-[520px]"
|
||||
role="img"
|
||||
aria-label="Refinanced portfolio stress by state"
|
||||
>
|
||||
<defs>
|
||||
{/* Hatch marks "withheld", which no colour on the ramp should mean. */}
|
||||
<pattern id="ul-suppressed" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<rect width="6" height="6" fill="#eceae4" />
|
||||
<line x1="0" y1="0" x2="0" y2="6" stroke="#b9b6ac" strokeWidth="2" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{Object.entries(INDIA_STATE_PATHS).map(([state, d]) => {
|
||||
const datum = byState.get(normalise(state))
|
||||
const isActive = active !== null && normalise(active) === normalise(state)
|
||||
return (
|
||||
<path
|
||||
key={state}
|
||||
d={d}
|
||||
fill={fillFor(datum, worst)}
|
||||
stroke={isActive ? '#0a5c40' : '#ffffff'}
|
||||
strokeWidth={isActive ? 1.4 : 0.5}
|
||||
className={datum && onSelect ? 'cursor-pointer' : ''}
|
||||
tabIndex={datum ? 0 : -1}
|
||||
onMouseEnter={() => setHover(state)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(state)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => datum && onSelect?.(state)}
|
||||
onKeyDown={(e) => {
|
||||
if (datum && onSelect && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onSelect(state)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{titleFor(state, datum, minCellSize)}</title>
|
||||
</path>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* The figures in text, so the map is never the only way to read them. */}
|
||||
<div className="absolute top-1 left-1 bg-white/95 border border-slate-200 rounded-[9px] px-3 py-2 min-w-[168px] pointer-events-none">
|
||||
{activeDatum ? (
|
||||
<>
|
||||
<b className="text-[12.5px] text-slate-800 block">{active}</b>
|
||||
{activeDatum.suppressed ? (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
{activeDatum.enterprises} enterprises · figures withheld below {minCellSize}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-600">
|
||||
{activeDatum.enterprises} enterprises ·{' '}
|
||||
<b className="text-rose-700">{activeDatum.red} stressed</b> ·{' '}
|
||||
{activeDatum.stressRatePct}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
Hover a state for its figures
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Legend worst={worst} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend({ worst }: { worst: number }) {
|
||||
return (
|
||||
<div className="absolute bottom-1 left-1 bg-white/95 border border-slate-200 rounded-[9px] px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-400 font-semibold mb-1">
|
||||
Stress rate
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-500">0%</span>
|
||||
<span className="flex h-2.5 w-24 rounded-full overflow-hidden">
|
||||
{[0.2, 0.4, 0.6, 0.8, 1].map((t) => (
|
||||
<span key={t} className="flex-1" style={{ background: ramp(t) }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">{worst.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-slate-500">
|
||||
<span className="w-3 h-2.5 rounded-sm border border-slate-300" style={{ background: '#f2f1ec' }} />
|
||||
no portfolio
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-slate-500">
|
||||
<span
|
||||
className="w-3 h-2.5 rounded-sm border border-slate-300"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(45deg,#b9b6ac 0 2px,#eceae4 2px 4px)',
|
||||
}}
|
||||
/>
|
||||
withheld
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-hue ramp in the brand green family. One hue, varying lightness — a
|
||||
* red-to-green ramp would encode a value judgement the number does not carry,
|
||||
* and reads as three categories rather than a continuum.
|
||||
*/
|
||||
function ramp(t: number): string {
|
||||
const stops = ['#dcebe4', '#a8d3c0', '#6bb79a', '#358f6f', '#0a5c40']
|
||||
const i = Math.min(stops.length - 1, Math.max(0, Math.round(t * (stops.length - 1))))
|
||||
return stops[i]
|
||||
}
|
||||
|
||||
function fillFor(datum: StateDatum | undefined, worst: number): string {
|
||||
if (!datum) return '#f2f1ec'
|
||||
if (datum.suppressed) return 'url(#ul-suppressed)'
|
||||
return ramp((datum.stressRatePct ?? 0) / worst)
|
||||
}
|
||||
|
||||
function titleFor(state: string, datum: StateDatum | undefined, minCellSize: number): string {
|
||||
if (!datum) return `${state} — no refinanced portfolio`
|
||||
if (datum.suppressed) {
|
||||
return `${state} — ${datum.enterprises} enterprises, figures withheld below ${minCellSize}`
|
||||
}
|
||||
return `${state} — ${datum.red} of ${datum.enterprises} stressed (${datum.stressRatePct}%)`
|
||||
}
|
||||
|
||||
/** The dataset and the platform spell a few states differently. */
|
||||
function normalise(name: string): string {
|
||||
const key = name.trim().toLowerCase()
|
||||
const aliases: Record<string, string> = {
|
||||
'andaman and nicobar': 'andaman and nicobar islands',
|
||||
'nct of delhi': 'delhi',
|
||||
orissa: 'odisha',
|
||||
pondicherry: 'puducherry',
|
||||
uttaranchal: 'uttarakhand',
|
||||
'jammu & kashmir': 'jammu and kashmir',
|
||||
'dadra and nagar haveli': 'dadra and nagar haveli and daman and diu',
|
||||
'daman and diu': 'dadra and nagar haveli and daman and diu',
|
||||
}
|
||||
return aliases[key] ?? key
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user