Compare Indian states and UTs across official indicators — population, economy, health, environment, education and energy — on one page, with a bar chart, a trend line and a choropleth map.
Live at statestats.dataful.in.
One SvelteKit service. Every request is answered by querying the GoPie SQL API
live: there is no local warehouse, no ETL step and no separate backend.
catalog.data.json is the single source of truth — the indicator whitelist plus the
table / where / column rules used to build each query.
- Pick a category from the top nav (Demography, Economy, Health, Environment, Education, Energy), or start from a tile on the home page.
- Tick the states you want in the left sidebar.
- Read the answer in the panel: a stat strip, then your choice of Bar, Trend or Map.
Your state selection follows you across categories, so you can pick five states once and then flip between Health and Education without re-selecting.
The sidebar lists every state and UT. Search narrows the list; Clear all empties the selection.
Bar and Trend are capped at 5 states — past that the bars get thin and the trend
lines become impossible to tell apart. Map view has no cap: a choropleth stays
readable with all 37 shaded, so in Map the counter drops the / 5, the button becomes
Select all, and you can select every state at once.
Switching from Map back to Bar keeps whatever you selected — it will not silently drop states to fit the cap. If you carried 37 states into Bar view you will see 37 bars; to get back under the cap, deselect. States greyed out with NO DATA have no figures for the indicator you are looking at.
- Sub-indicator dropdown (next to the category name) — e.g. Life Expectancy, Projected Population.
- Dimension filters (top right) — the breakdowns an indicator offers, e.g. Gender: Total / Male / Female, or Rural / Urban.
- Year — defaults to the latest year with data. Changing a filter or sub-indicator keeps the year you picked; it only falls back to "latest" when the indicator you switched to has no data for that year at all.
Fiscal years and survey periods are labelled as published (e.g. 2019–23) and resolve
internally to their end year.
| View | Shows | Notes |
|---|---|---|
| Bar | One bar per state for the selected year | Ranked, easiest for a direct read-off |
| Trend | One line per state over time | Toggle the faint All India reference line |
| Map | Choropleth of India | No selection cap |
On the map, each category has its own colour ramp — light is the lowest value among your selected states, dark the highest. Selected states carry a thin black outline and are labelled with their name above their value; where the polygon is too narrow for the full name it falls back to the usual short form (HP, TG, WB, A & N …). A state too small to hold a label at all — Delhi, Goa, Sikkim, Puducherry — gets a callout in the side gutter, joined to the state by a curved dotted leader line, the way a printed choropleth is set. Which gutter it goes to is judged against the land at that state's own latitude, not the midpoint of the whole map: the north-east drags the global midpoint so far east that every peninsular state would count as "left", which put Puducherry's label on the Arabian Sea side. Where two in-place labels would collide, the smaller state is the one moved out to a callout; multi-part states (Puducherry, A & N, Lakshadweep) are measured on their largest piece, so the label lands on land rather than in the sea between the parts.
Dadra & Nagar Haveli and Daman & Diu merged into one UT in 2020, so the map carries a single polygon where the published series still report two states. That polygon stands for both: selecting either lights it up, and selecting both combines them by the indicator's own rule — summed for counts, averaged for rates. The side panel still lists them separately, since that is how the source reports them.
A selected state with no figure at the resolved year is labelled "no data" rather than left blank. The year is the latest one where any selected state has data, so adding a state with a longer series can leave an earlier pick with nothing to show — Goa's forest cover runs to 2023 while DNH and DD end in 2019, when they merged. Painted in the near-neutral no-data grey and left unlabelled, that read as "my click did nothing". Unselected states stay a flat neutral grey. Hovering any state — selected or not — shows its value. The side panel ranks your selection high to low.
Above every chart: a cross-state aggregate, the highest and lowest state, the All India reference, and the unit.
The aggregate respects the nature of the number. Absolute counts (population, generation) are summed; rates, percentages and per-capita figures are averaged — adding two percentages together would be meaningless. Each indicator declares which in the catalog, and the tile label tells you which was applied.
PNG renders the current card — chart, stat strip, citation and all — as an image for decks and reports. The "Powered by Dataful and GoPie AI" strip sits at the foot of the card on screen but leads the exported image, so the credit survives a crop, and a faint Dataful watermark sits over the chart area of the PNG only. Every indicator carries its source citation, and links back to the underlying dataset on dataful.in where one is catalogued.
GoPie is the data source. It exposes a DuckDB-backed SQL endpoint: you POST a SQL string, you get rows back as JSON. Everything the app shows comes from it at request time.
POST https://gopie-api.factly.dev/v1/api/sql
Authorization: Bearer <GOPIE_API_TOKEN>
Content-Type: application/json
User-Agent: states-compare-backend/3.0
Accept: application/json
{ "query": "SELECT 1 AS ok" }
Response:
{
"columns": ["ok"],
"count": 1,
"data": [{ "ok": 1 }],
"executionTime": 0
}The client reads data and ignores the rest. An error key, or a missing data,
means the query failed.
Why we pin an explicit User-Agent. GoPie sits behind Cloudflare, which has previously answered default script agents with error 1010 and no useful message — hence the hard-coded
User-Agentingopie.ts. That rule does not reproduce as of 2026-09-03 (a default curl agent and apython-requests/2.31.0agent both get through), so treat the header as cheap insurance rather than a current hard requirement. If you ever get a 1010 with no explanation, this is the first thing to check.
Try it by hand:
curl -s https://gopie-api.factly.dev/v1/api/sql \
-H "Authorization: Bearer $GOPIE_API_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: states-compare-dev/1.0" \
-d '{"query":"SELECT state, year, value FROM gp_EcLJruU86OszZ LIMIT 5"}'src/lib/server/gopie.ts is the only module that talks to GoPie. Everything else
goes through runSql().
For any indicator the server builds exactly one statement from the catalog and fetches the whole (small) series — then computes points, aggregate, highest/lowest, trend and the All India reference in TypeScript:
SELECT "state" AS state, "year" AS yr, "value" AS val
FROM gp_EcLJruU86OszZ
WHERE "month" = 'October' AND "gender" = 'Total'FROM takes a gp_... table id straight from the catalog. Identifiers and literals in
the WHERE clause go through quoteIdent() / quoteLiteral() in catalog.ts, which
double up embedded quotes.
Nothing user-supplied ever reaches the SQL string. The browser sends an
indicator_id; the server looks that id up in the catalog whitelist and builds the
query from the catalogued table, columns and filters. Requests for an unknown id are
rejected before any query is built. The browser never runs SQL and never sees the
token — config.ts reads it via $env/dynamic/private, which is server-only.
runSql() keeps an in-process Map keyed by the exact SQL string, with a TTL of
CACHE_TTL_SECONDS (default 300s). Because /api/compare, /api/indicator-values and
/api/indicator-info all derive from the same series query, one page load costs a
single GoPie round-trip, and repeat visits within the window cost none.
The cache is per-process and in-memory: it does not survive a restart and is not shared between replicas. That is deliberate — it is a latency cache, not a store of record.
runSql() throws GopieError for every failure mode — missing token, timeout
(GOPIE_TIMEOUT_SECONDS, default 30s, via AbortController), non-2xx, non-JSON,
an error in the payload, or a missing data key. respond.ts maps those to a clean
502, so a data-source problem never surfaces as a stack trace. /api/health reports
GoPie reachability and returns 503 when it is down or the token is missing.
Applied in catalog.ts as rows come back, matching the V2 Python pipeline exactly:
- Fiscal years and survey periods (
2019-23) resolve to their end year, keeping the original string as the display label. - Values are parsed with commas stripped; unparseable values and blank states are dropped.
- State names are slugified into stable
state_codes. - Aggregate rows (
All India,Total State(s), …) are flagged viaexclude_statesand kept out of the selectable list — All India is shown only as a faint reference.
Column names in GoPie tables use underscores. A mismatch here surfaces as an empty series rather than an error, so check the column names first when an indicator renders blank.
Append an entry to src/lib/server/catalog.data.json — no query code to write:
Indicators sharing a subcategory collapse into one dropdown entry, with their
dimensions rendered as the filter row. dimensions are expanded server-side into one
concrete indicator per option combination.
Get aggregation right: only absolute counts may be totalled; rates and percentages
must be averaged.
Copy .env.example → .env and set the token. Real env vars (e.g. injected by
Kubernetes) always win over the file.
| Var | Default | Notes |
|---|---|---|
GOPIE_API_URL |
https://gopie-api.factly.dev/v1/api/sql |
GoPie SQL endpoint |
GOPIE_API_TOKEN |
— | secret, required |
GOPIE_TIMEOUT_SECONDS |
30 |
per-request timeout |
CACHE_TTL_SECONDS |
300 |
read-cache freshness window |
HOST |
0.0.0.0 (prod) |
adapter-node bind host |
PORT |
8010 |
never 8000 |
npm install
cp .env.example .env # set GOPIE_API_TOKEN
npm run dev # http://localhost:8010
npm run check # svelte-check (types)npm run build # → ./build (adapter-node)
node build # serves UI + /api on $PORTdocker compose up --build # http://localhost:8010
# or
docker build -t asia-south1-docker.pkg.dev/factly-prod/tools/states-compare:<tag> \
. --platform linux/amd64 --push
docker run -p 8010:8010 --env-file .env \
asia-south1-docker.pkg.dev/factly-prod/tools/states-compare:<tag>Always build --platform linux/amd64 — the cluster is amd64.
k8s probes use /healthz for both liveness and readiness; /api/health is the
dependency check (503 when GoPie is down or the token is missing) — for monitoring, not
readiness. See DEPLOY.md for the full deployment spec.
Pages — / is the home landing (one tile per category), /indicators is the
reference guide (what every indicator measures, its unit, source, direction and whether
it sums or averages across states) and /contributors credits the team who built it.
All three are static — catalog or a local list — so they render even when GoPie is down. Comparison pages are file-based, one per category: /demography, /economy,
/health, /environment, /education, /energy.
API — same paths and JSON shapes the V2 SPA used, so the data contract is unchanged:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/categories |
6 categories, each with sub-indicators |
| GET | /api/states |
selectable states (aggregates excluded) |
| GET | /api/indicator-values?key=&year= |
every state's value for one indicator (choropleth) |
| GET | /api/indicator-info?key= |
an indicator's years (+ labels & FY/CY kind) and which states have data |
| POST | /api/compare |
per-state values + aggregate + trend |
| GET | /api/health |
service + GoPie connectivity (503 when degraded) |
| GET | /healthz |
liveness (does not call GoPie) |
src/
lib/
server/ server-only (never shipped to the browser)
config.ts env-driven settings ($env/dynamic/private)
gopie.ts GoPie SQL client (fetch) + short-TTL result cache
catalog.ts loads catalog.data.json, expands dimensions, normalizers
queries.ts compare / states / categories / choropleth / indicator-info
catalog.data.json indicator whitelist + GoPie table ids + query rules
respond.ts maps GoPie failures to 502
components/ Header, CategoryNav, StateSidebar, DimensionFilters,
SubcategoryDropdown, ResultPanel
charts/ BarChart + LineChart (hand-rolled SVG, d3-scale/shape),
StateMap (d3-geo)
api.ts browser fetch client for /api/*
selection.svelte.ts cross-category selected-states store (runes)
types.ts, format.ts, groups.ts, colors.ts, ticks.ts, stateName.ts, slug.ts,
export.svelte.ts
routes/
+layout.server.ts loads categories (catalog) — no network
+page.svelte / → home landing
indicators/ /indicators → the indicator reference guide (catalog only)
contributors/ /contributors → the team behind the site (see lib/contributors.ts)
(app)/ route group carrying the compare chrome
+layout.server.ts loads states (GoPie)
+layout.svelte Header + CategoryNav shell
[category]/ one page per category
api/ +server.ts endpoints
healthz/ liveness probe (no GoPie call)
static/ india.topo.json, favicon.svg, robots.txt,
factly-logo-white.png, team/ (contributor photos)
/robots.txt is a route (not a static file) so its Sitemap: line always names the
origin the deployment is actually running on — as a static file it hardcoded one host
and went stale the moment the site moved. Every page is crawlable; the only exclusions
are /api/ (not pages, and every crawl costs a live GoPie query) and /healthz.
The hashed /_app/ bundles are deliberately not blocked: search engines render a
page before ranking it, and hiding its CSS and JS makes it look broken to the renderer.
Set ORIGIN (or PUBLIC_SITE_URL) in production, or both files fall back to
https://statestats.dataful.in. /sitemap.xml lists the
landing, the indicator guide, the contributors page and every category page that has a
live indicator.
The UI renders light only — there is no dark palette and no theme toggle. Charts, maps and the exported PNG are all tuned against a white card, which is where they end up: decks, documents and screenshots.
- One service instead of two — no FastAPI, no nginx, no
/apireverse proxy, no CORS, no service-name DNS wiring. - Query layer ported Python → TypeScript in
src/lib/server/(identical logic and response shapes). - Charts: recharts (React-only) replaced with hand-rolled SVG using
d3-scale/d3-shape; the map still usesd3-geo. - Per-category routes replace the single-page SPA.
{ "indicator_id": "demography.projected-population", "category": "Demography", "subcategory": "Projected Population", "label": "Projected Population", "unit": "thousand persons", "direction": "neutral", // higher_better | lower_better | neutral "aggregation": "sum", // sum for counts, avg for rates/percentages "source": "NCP / Census of India", "description": "…", "ingest": { "table": "gp_EcLJruU86OszZ", // GoPie table id "year_column": "year", "value_column": "value", "where": { "month": "October" } }, "dimensions": [ // optional — becomes the filter row in the UI { "name": "Gender", "column": "gender", "default": "Total", "options": [ { "label": "Total", "match": "Total" }, { "label": "Male", "match": "Male" }, { "label": "Female", "match": "Female" } ] } ] }