Skip to content

Repository files navigation

StateStats

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.


Part 1 — Using the app

The short version

  1. Pick a category from the top nav (Demography, Economy, Health, Environment, Education, Energy), or start from a tile on the home page.
  2. Tick the states you want in the left sidebar.
  3. 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.

Choosing states

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.

Choosing what to measure

  • 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.

The three views

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.

The stat strip

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.

Exporting

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.


Part 2 — How we use the GoPie API

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.

The contract

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-Agent in gopie.ts. That rule does not reproduce as of 2026-09-03 (a default curl agent and a python-requests/2.31.0 agent 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"}'

One query per indicator

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.

Caching

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.

Failures

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.

Normalization

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 via exclude_states and 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.

Adding an indicator

Append an entry to src/lib/server/catalog.data.json — no query code to write:

{
  "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" }
      ]
    }
  ]
}

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.


Part 3 — Running it

Configuration

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

Develop

npm install
cp .env.example .env      # set GOPIE_API_TOKEN
npm run dev               # http://localhost:8010
npm run check             # svelte-check (types)

Production

npm run build             # → ./build (adapter-node)
node build                # serves UI + /api on $PORT

Docker

docker 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.


Reference

Routes

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)

Layout

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)

SEO

/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.

What changed from V2

  • One service instead of two — no FastAPI, no nginx, no /api reverse 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 uses d3-geo.
  • Per-category routes replace the single-page SPA.

About

State comparisons, made visual. Put any of India's states and union territories side by side across official indicators, spanning demography, the economy, health, environment, education and energy.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages