Hugdrigo commited on
Commit
3939ca3
·
verified ·
1 Parent(s): 2bfaf20

Upload 19 files

Browse files
Files changed (19) hide show
  1. CHANGELOG.md +12 -0
  2. CITATION.cff +17 -0
  3. DATA_DICTIONARY.md +40 -0
  4. DEPLOYMENT_ES.md +39 -0
  5. LICENSE +24 -0
  6. LIMITATIONS.md +14 -0
  7. METHODOLOGY.md +65 -0
  8. PRIVACY.md +10 -0
  9. QA_REPORT.md +29 -0
  10. README.md +92 -4
  11. app.js +122 -0
  12. browser_test.py +71 -0
  13. core.js +52 -0
  14. demo-data.js +12 -0
  15. index.html +29 -18
  16. package.json +1 -0
  17. style.css +1 -28
  18. tests.mjs +48 -0
  19. worldbank.js +17 -0
CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## 1.0.0 · 2026-10-08
4
+
5
+ - First Education Data Lab Static release, built without external JavaScript frameworks or secrets.
6
+ - Eight World Development Indicators and 16 Latin American economies.
7
+ - Source-aware comparative ranking and temporal line plots with visible gaps.
8
+ - Exact-year two-country difference; same-country/year pairwise Pearson correlation.
9
+ - Coverage matrix, reporting lag and methodology audit.
10
+ - Clearly labeled synthetic demo plus explicit World Bank live-data request.
11
+ - Export source-labeled CSV, research brief Markdown and reproducible JSON session; import a previous session.
12
+ - Documentation and technical tests. No other portfolio projects overwritten.
CITATION.cff ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: 'If you use this software, cite this software release and the underlying World Bank or UNESCO indicator datasets separately.'
3
+ title: 'Education Data Lab: Comparative Education Intelligence'
4
+ type: software
5
+ authors:
6
+ - family-names: Pantoja
7
+ given-names: Rodrigo
8
+ version: 1.0.0
9
+ license: MIT
10
+ date-released: '2026-10-08'
11
+ keywords:
12
+ - comparative education
13
+ - Latin America
14
+ - reproducible research
15
+ - data analysis
16
+ - World Bank indicators
17
+ url: https://huggingface.co/spaces/Hugdrigo/education-data-lab
DATA_DICTIONARY.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data Dictionary
2
+
3
+ ## Data sources
4
+
5
+ World Bank Indicators API v2, WDI source ID `2`. Education indicator content may originate from the UNESCO Institute for Statistics. No values are harvested in advance or distributed as official current data inside this ZIP. The app retrieves values in the visitor's browser when requested.
6
+
7
+ ## Supported concepts
8
+
9
+ | Code | Label | Unit | Interpret carefully |
10
+ |---|---|---|---|
11
+ | `SE.PRM.CMPT.ZS` | Primary completion rate (gross intake to last primary grade) | Percent of relevant age population | Does not equal a cohort graduation rate, can exceed 100. |
12
+ | `SE.SEC.ENRR` | Secondary enrollment, gross | Percent of age-eligible population | Numerator includes students outside official age range; can exceed 100. |
13
+ | `SE.TER.ENRR` | Tertiary enrollment, gross | Percent of age-eligible population | Does not measure degrees awarded; can exceed 100. |
14
+ | `SE.XPD.TOTL.GD.ZS` | Government expenditure on education, total | % of GDP | Spending level relative to GDP, not outcome or per-pupil spending. |
15
+ | `SE.ADT.LITR.ZS` | Adult literacy rate, 15+ | Percent | Survey definitions and reporting intervals may vary. |
16
+ | `IT.NET.USER.ZS` | Individuals using the Internet | Percent | Context, not education success. |
17
+ | `NY.GDP.PCAP.CD` | GDP per capita, current US dollars | USD | Nominal values reflect FX and price effects. |
18
+ | `SP.POP.TOTL` | Population | People | Context series, not school-aged population. |
19
+
20
+ ## Observation schema
21
+
22
+ - `iso3`: one of the supported 16 ISO-3 country codes.
23
+ - `year`: integer from 1990 to 2026.
24
+ - `indicator`: original World Bank code.
25
+ - `value`: a finite reported numeric value. A reported zero is valid; null is missing and is removed.
26
+ - `footnote`: optional WDI footnote, if supplied.
27
+ - `status`: optional World Bank observation status.
28
+
29
+ Data are de-duplicated by `(country, year, indicator)`. Repeated rows retain the first occurrence. Unknown codes, invalid numbers and nulls are not included. All series are filtered to the user-selected time range before analysis.
30
+
31
+ ## Supported economies
32
+
33
+ Argentina, Bolivia, Brazil, Chile, Colombia, Costa Rica, Dominican Republic, Ecuador, Mexico, Panama, Peru, Paraguay, Uruguay, Guatemala, Honduras, El Salvador.
34
+
35
+ ## Provider links
36
+
37
+ - https://datahelpdesk.worldbank.org/knowledgebase/articles/889392
38
+ - https://databank.worldbank.org/metadataglossary/world-development-indicators/
39
+ - https://databank.worldbank.org/metadataglossary/world-development-indicators/series/SE.PRM.CMPT.ZS
40
+ - https://databank.worldbank.org/metadataglossary/world-development-indicators/series/SE.SEC.ENRR
DEPLOYMENT_ES.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Publicar Education Data Lab en Hugging Face (guía muy simple)
2
+
3
+ **No tienes que programar ni borrar ninguno de tus proyectos anteriores.**
4
+
5
+ 1. Descarga el ZIP `Education_Data_Lab_...zip` desde ChatGPT.
6
+ 2. Abre el programa **Finder** en tu Mac y entra a **Descargas**.
7
+ 3. Haz doble clic en el ZIP para descomprimirlo; abre la carpeta que aparece.
8
+ 4. Abre https://huggingface.co e inicia sesión como **Hugdrigo**.
9
+ 5. Haz clic en **+ New → Space** y mantén **Manual setup**.
10
+ 6. En **Space name**, escribe exactamente: `education-data-lab`.
11
+ 7. En **Short description**, copia: `Compare Latin American education using World Bank data.`
12
+ 8. En **License**, selecciona **MIT** directamente del menú desplegable. Si da error en el formulario de creación, déjalo vacío; el archivo README.md trae `license: mit`.
13
+ 9. En **Select the Space SDK** elige **Static**.
14
+ 10. En **Choose a Static template** elige **Blank**.
15
+ 11. En visibilidad elige **Public**, luego pulsa **Create Space**.
16
+ 12. Abre: https://huggingface.co/spaces/Hugdrigo/education-data-lab/tree/main (después de crear el Space).
17
+ 13. Pulsa **+ Contribute → Upload files**.
18
+ 14. Vuelve a Finder, abre la carpeta descomprimida, presiona **⌘ Command + A** y arrastra **TODOS** los archivos de la carpeta al cuadro grande de Hugging Face. **No subas el ZIP cerrado.**
19
+ 15. Comprueba que no haya errores rojos. Deja marcada la opción **Commit directly to main**.
20
+ 16. Pulsa **Commit changes to main** y espera unos segundos.
21
+ 17. Abre la pestaña **App** de https://huggingface.co/spaces/Hugdrigo/education-data-lab.
22
+ 18. Verás el dashboard con una etiqueta **SYNTHETIC DEMO**, porque los valores de ejemplo NO son datos reales.
23
+ 19. Pulsa **Load World Bank data**. Si tienes conexión a Internet y la API responde, la etiqueta cambiará a **WORLD BANK LIVE**.
24
+ 20. Abre las secciones **Trends**, **Country gaps**, **Associations**, **Data quality** y **Research brief**.
25
+ 21. Para probar las descargas, pulsa **Export data CSV** y entra en **Research brief** para descargar Markdown y JSON.
26
+ 22. Si aparece cualquier mensaje rojo, toma una captura y envíamela. **No borres el Space, el README ni los archivos anteriores.**
27
+
28
+ ## Qué hacer si aparece un error
29
+
30
+ - **Missing SDK:** confirma que el archivo `README.md` está en la raíz del repositorio, junto a `index.html`, y que contiene `sdk: static` en las primeras líneas.
31
+ - **Invalid color:** utiliza `colorFrom: green` y `colorTo: blue` (ya configurados en el paquete).
32
+ - **Short description too long:** usa el texto exacto del paso 7, que cumple el límite de 60 caracteres.
33
+ - **License invalid:** selecciona MIT desde la lista desplegable, sin teclearlo manualmente, o deja el campo vacío inicialmente.
34
+ - **Error de la API:** la app no sustituye datos oficiales por datos falsos de forma automática. Usa el modo demo claramente etiquetado y revisa tu conexión.
35
+ - **Archivos no visibles:** comprueba que soltaste los archivos *dentro* del repositorio, no el ZIP comprimido.
36
+
37
+ ### Resultado esperado
38
+
39
+ Habrás publicado una aplicación gratuita en tu perfil de Hugging Face, con gráficos educativos, comparaciones de países, resultados descriptivos, auditoría de cobertura y descarga de informes.
LICENSE ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodrigo Pantoja
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This license does not override the rights or terms applicable to data retrieved
24
+ from the World Bank or upstream data providers.
LIMITATIONS.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Limitations, ethics and responsible interpretation
2
+
3
+ 1. **No validated education score:** Eight indicators are not a complete education system assessment or an official SDG4 scorecard.
4
+ 2. **No causal inference:** Associations between spending, technology, access and outcomes are descriptive and potentially confounded. No panel regression, causal design or statistical uncertainty interval is supplied.
5
+ 3. **Not all observations are comparable:** Gross enrollment and primary completion ratios may exceed 100. Years, definitions, collection quality and measurement methods vary.
6
+ 4. **Inexact latest-year ranking:** The overview ranks latest reported values that can have different reporting years. It is not necessarily a common-year league table.
7
+ 5. **Missing information stays missing:** Low coverage is visible; a missing point is not zero and is not estimated. In some countries some indicators have very sparse coverage.
8
+ 6. **No unseen national/individual inequality inference:** The Country gaps view measures differences between **two national aggregates** on exactly the same year. It cannot reveal gender, income, disability, ethnicity, geographic or individual-level gaps.
9
+ 7. **Descriptive trends only:** Changes in reported levels do not establish intervention effects, growth-adjusted improvements, learning achievement or welfare effects.
10
+ 8. **Reported dates and source revisions:** Data retrieved today may later be revised by the World Bank or source producers. Download sessions and cite retrieval dates for reproducibility.
11
+ 9. **Data provider rights:** Repository MIT licensing covers original code only, not World Bank/UNESCO source datasets.
12
+ 10. **Synthetic demo:** Fictional values are generated for interface testing, never to be used as evidence or publication data.
13
+ 11. **API availability:** Live API availability and browser cross-origin access are not guaranteed. This build has mocked local API integration tests; a production connection check is needed after deployment.
14
+ 12. **Study design:** For a scientific comparative education paper, preregister selection, inspect indicator definitions, assess sample sizes and data vintage, and perform robust contextual and inferential analyses outside this tool.
METHODOLOGY.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Methodology and reproducibility
2
+
3
+ ## Scope
4
+
5
+ This is an exploratory, source-aware **comparative education intelligence** dashboard. The eight indicators are selected examples, not a complete education system assessment or an official SDG4 index. The application avoids causal interpretations.
6
+
7
+ ## Data ingestion
8
+
9
+ Clicking **Load World Bank data** requests all eight series from the WDI source (ID 2):
10
+
11
+ `https://api.worldbank.org/v2/country/ARG;BOL;BRA/indicator/SE.PRM.CMPT.ZS;SE.SEC.ENRR;...?source=2&date=2010:2025&format=json&per_page=20000`
12
+
13
+ The actual URL is assembled from selected economies, indicators and years; users can inspect the exact link in the Data quality view or exported research brief. All retrieval is initiated by the browser. The app never silently substitutes synthetic data after a failed request.
14
+
15
+ ## Overview
16
+
17
+ Each country is represented by its **latest non-missing observation within the selected window**. Latest values can be from different years; year and reporting lag are shown. A ranking here is not a same-year ranking. Source-specific measurement definitions remain relevant.
18
+
19
+ ## Trends
20
+
21
+ Points show unmodified observations; connecting segments are displayed only for **consecutive reported years**, with gaps remaining visually disconnected. A change compares first and last available values, possibly from different years per country.
22
+
23
+ ## Country gaps
24
+
25
+ For two selected countries `A` and `B` in year `t`:
26
+
27
+ `gap(A,B,t) = reported_value(A,t) - reported_value(B,t)`.
28
+
29
+ Both observations must exist for **the exact same year**. This is a *between-country level difference*, not an inequality or socioeconomic equity gap inside either country.
30
+
31
+ ## Associations
32
+
33
+ A pair is included only if X and Y exist for the same country and calendar year. The displayed sample size counts country-year pairs. Pearson's correlation coefficient is the conventional covariance divided by the product of centered sums of squares. It is not causal, and the application does **not** estimate statistical significance, confidence intervals, fixed effects or corrected clustered standard errors. Repeated years from each country violate independent-sample assumptions; do not treat r as a confirmed structural relationship.
34
+
35
+ ## Coverage and reporting lag
36
+
37
+ `coverage = valid_observations / (selected_countries × selected_years × 8_indicators)`.
38
+
39
+ Mean reporting lag averages `(selected_end_year - latest_available_year)` for each observed country-indicator combination. A country-indicator combination without any reported value is excluded from the lag mean but remains missing in the coverage ratio. This is a reporting *timeliness* statistic, not a data quality score.
40
+
41
+ ## Research brief
42
+
43
+ A deterministic descriptive Markdown narrative is assembled directly from currently loaded observations; it does not use an LLM or claim independent verification. Exported CSV and session JSON include a status marking LIVE / SYNTHETIC / IMPORTED and preserve source-derived years and metadata. Source footnotes can be incomplete because the upstream API may not return them for every observation.
44
+
45
+ ## Missing data
46
+
47
+ No imputation, interpolation, invented country values, or silent fill-forward of missing data. Number of points and exact reporting years remain visible. Nulls are never converted to zero. Year and observation count constraints are validated.
48
+
49
+ ## Synthetic demo
50
+
51
+ `demo-data.js` generates illustrative values deterministically from a fixed mathematical formula, with intentionally missing observations. Demo output is always marked **SYNTHETIC DEMO** and must never be presented as official data.
52
+
53
+ ## Reproducibility
54
+
55
+ - Deterministic analysis helpers: `core.js`
56
+ - Live API adapter: `worldbank.js`
57
+ - Clearly synthetic generator: `demo-data.js`
58
+ - Interactive UI: `app.js`, `index.html`, `style.css`
59
+ - Unit tests: `node --test tests.mjs`
60
+ - Browser QA: `browser_test.py` (requires Python Playwright and Chromium)
61
+ - Data dictionary, limitations and privacy: other root Markdown documents.
62
+
63
+ ## Limitations
64
+
65
+ World Bank data might have missing observations, definition changes and revision cycles. Gross enrollment can exceed 100%. GDP per capita in current US dollars is not purchasing-power-adjusted. Country-to-country and time-to-time differences do not identify policy impacts. Review original provider metadata and seek specialist input for high-stakes interpretations.
PRIVACY.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Privacy and security
2
+
3
+ - A **Static** Hugging Face Space runs its data processing in your web browser. No custom server, user account, API key or paid model is required.
4
+ - The app makes outbound requests to `api.worldbank.org` **only** after the user clicks the live-data button. These requests disclose the selected country codes, indicator codes and date range, and the normal HTTP metadata of the visitor to that third-party provider.
5
+ - No analytics trackers, third-party UI script CDNs, authentication cookies, or intentionally submitted personal identifiers are included.
6
+ - Research sessions exported as JSON stay on the visitor's own device unless the user shares them.
7
+ - Imported JSON is parsed locally and filtered through a strict observation schema. It is not submitted to the World Bank. Data from imported JSON are **not independently verified**.
8
+ - A restrictive size limit applies when importing a session; users should still avoid loading private or confidential information into unknown or shared systems.
9
+ - Browser and Hugging Face platform hosting may have their own access logs or privacy policies. This document does not guarantee the absence of all network metadata collection.
10
+ - Do not place tokens, passwords, private educational records or proprietary datasets inside public repository files.
QA_REPORT.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Technical quality assurance report
2
+
3
+ Project: Education Data Lab v1.0.0 | Date: 2026-10-08
4
+
5
+ ## Automated logic tests
6
+
7
+ - Node native runner: `node --test tests.mjs`
8
+ - Result: 40/40 passing after changing a float-equality assertion to a tolerance-based comparison.
9
+ - Coverage includes country/indicator validation, finite/missing values, exact-year joins and gaps, correlation, percentage change, duplicates, coverage and lags, HTML/CSV escape, session import, reproducible demo and mock API parsing.
10
+
11
+ ## Browser test
12
+
13
+ - Playwright with system Chromium, 1440px and 390px viewport.
14
+ - 19/19 checks passed: overview, five additional research tabs, chart rendering, gap calculation, CSV/Markdown/JSON exports, session restoration, API response handling, deliberate network failure, and mobile overflow.
15
+ - No captured JavaScript page errors.
16
+ - Local test uses in-memory HTML bundle because localhost navigation was blocked by this test environment.
17
+ - External World Bank API call is tested with **mocked** JSON and explicit HTTP 500 failure injection. This is not a live third-party connectivity test.
18
+
19
+ ## Validated configuration
20
+
21
+ - `sdk: static`, `app_file: index.html`, `colorFrom: green`, `colorTo: blue`, `license: mit` in README YAML frontmatter.
22
+ - Short description within 60 characters.
23
+ - All files stored at ZIP root so browser upload via Finder works.
24
+ - Every file named by static HTML/script references exists.
25
+ - No external JS dependencies or secrets.
26
+
27
+ ## Publication gate
28
+
29
+ The ZIP is ready to upload, but final production status remains **NOT VERIFIED** until Hugging Face Static Space deployment and a real World Bank request both succeed. After upload, perform the checks in `DEPLOYMENT_ES.md`.
README.md CHANGED
@@ -1,12 +1,100 @@
1
  ---
2
  title: Education Data Lab
3
- emoji: 🐠
4
- colorFrom: yellow
5
  colorTo: blue
6
  sdk: static
 
7
  pinned: false
8
  license: mit
9
- short_description: Compare Latin American education using World Bank data. Cop
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Education Data Lab
3
+ emoji: 🎓
4
+ colorFrom: green
5
  colorTo: blue
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
  license: mit
10
+ short_description: Compare Latin American education using World Bank data.
11
+ tags:
12
+ - education
13
+ - data-analysis
14
+ - latin-america
15
+ - data-visualization
16
+ - world-bank
17
+ - research
18
+ - reproducibility
19
  ---
20
 
21
+ # Education Data Lab · Comparative Education Intelligence
22
+
23
+ **An open-source, browser-based research dashboard for comparative education in Latin America.**
24
+
25
+ Education Data Lab combines eight selected World Development Indicators with transparent comparative analytics, exact-year country gap calculations, longitudinal visualizations, exploratory associations, data-coverage audits, and exportable evidence notes. It is a research aid, **not** a predictive model, causal evaluation, official SDG 4 index, or substitute for education-sector expertise.
26
+
27
+ ## What the application actually does
28
+
29
+ 1. Fetches public World Bank Indicators API v2 data for up to eight selected Latin American economies, using WDI source 2 and a specified year range.
30
+ 2. Keeps missing data missing and displays source years instead of aligning observations by silent imputation.
31
+ 3. Offers six views: Overview, Trends, Country gaps, Associations, Data quality, Research brief.
32
+ 4. Calculates descriptive changes, exact-year between-country differences, observation coverage, source lag, and pairwise Pearson correlations.
33
+ 5. Exports source-marked CSV, research briefs in Markdown, and research sessions in JSON. JSON sessions can be imported to reproduce exploratory views.
34
+ 6. Includes a **clearly labeled synthetic demo** for offline interface testing. Its numbers are fictional and must not be cited.
35
+
36
+ ## Reproducible methods, not invented metrics
37
+
38
+ All charts and reports use deterministic transformations from selected observations. The demo data are generated by `demo-data.js`. The source code has no secret keys, cloud database, cookies or external runtime JavaScript libraries. Browser-side network requests occur only when you click **Load World Bank data**.
39
+
40
+ **Live-data verification remains outstanding until the published Space is tested.** The local browser tests use a mocked API response and cannot confirm current third-party API availability.
41
+
42
+ ## Usage
43
+
44
+ - Select between one and eight countries, a primary indicator, a year window, and an association indicator.
45
+ - Press **Load World Bank data** for live public observations; the current mode is always visible in the top-right header.
46
+ - Explore all six research tabs.
47
+ - Export the CSV data, Markdown brief, or JSON research session in the sixth tab.
48
+ - Read the limitations before using any conclusions in publications or decisions.
49
+
50
+ ## Validated indicator IDs
51
+
52
+ | Indicator | World Bank code | Notes |
53
+ |---|---|---|
54
+ | Primary completion rate | `SE.PRM.CMPT.ZS` | Gross intake to last primary grade; may exceed 100. |
55
+ | Secondary school enrollment | `SE.SEC.ENRR` | Gross enrollment, not net. |
56
+ | Tertiary school enrollment | `SE.TER.ENRR` | Gross enrollment, not tertiary completion. |
57
+ | Education expenditure | `SE.XPD.TOTL.GD.ZS` | Government education spending, % GDP. |
58
+ | Adult literacy rate | `SE.ADT.LITR.ZS` | Reporting may be sparse. |
59
+ | Individuals using the Internet | `IT.NET.USER.ZS` | Context series, not schooling outcome. |
60
+ | GDP per capita (current USD) | `NY.GDP.PCAP.CD` | Context series subject to prices and exchange rates. |
61
+ | Population | `SP.POP.TOTL` | Context series. |
62
+
63
+ See [DATA_DICTIONARY.md](DATA_DICTIONARY.md) and [METHODOLOGY.md](METHODOLOGY.md) for fuller discussion.
64
+
65
+ ## Data provenance
66
+
67
+ - [World Bank Indicators API](https://datahelpdesk.worldbank.org/knowledgebase/articles/889392)
68
+ - [API query parameters](https://datahelpdesk.worldbank.org/knowledgebase/articles/898581-api-basic-call-structures)
69
+ - [World Development Indicators metadata glossary](https://databank.worldbank.org/metadataglossary/world-development-indicators/)
70
+ - Education series are drawn largely from UNESCO Institute for Statistics through the World Bank WDI distribution.
71
+
72
+ Indicator availability, methodology, units and license terms are owned by the respective data providers. **The MIT license in this repository covers the application source code, not third-party data.**
73
+
74
+ ## Run locally
75
+
76
+ For this static site, any local HTTP server that supports JavaScript modules works:
77
+
78
+ ```bash
79
+ python3 -m http.server 8000
80
+ ```
81
+
82
+ Then open `http://localhost:8000`. Or publish the files unchanged to Hugging Face as a **Static** Space.
83
+
84
+ ## Automated tests
85
+
86
+ With Node.js 22 or newer:
87
+
88
+ ```bash
89
+ npm test
90
+ ```
91
+
92
+ The test suite covers normalizing WDI records, missingness, same-year joins, correlations, data exports, simulated values and invalid input. For local browser QA, see `browser_test.py` and `QA_REPORT.md`. The headless browser QA uses an in-memory HTML bundle when local navigation is restricted.
93
+
94
+ ## Known limits and ethics
95
+
96
+ The dashboard does not certify data quality, provide official education rankings, predict outcomes, infer causality, represent unobserved people, or account for sampling error. Correlations may be unstable because the same countries contribute many years and observations are not statistically independent. Comparing latest observations may mix reporting years.
97
+
98
+ Read [LIMITATIONS.md](LIMITATIONS.md), [PRIVACY.md](PRIVACY.md), and the English documentation before interpretation. Spanish publishing steps are in [DEPLOYMENT_ES.md](DEPLOYMENT_ES.md).
99
+
100
+ **Created by Rodrigo Pantoja · Applied AI, data, education and research engineering.**
app.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {COUNTRIES,INDICATORS,IDS,COUNTRY_IDS,finite,escapeHTML,formatValue,latest,subset,coverage,status,timeline,compareCountries,crossCountryGap,paired,pearson,change,toCSV,validateSession,normalizeRows} from './core.js';
2
+ import {makeWorldBankURL,requestWorldBank} from './worldbank.js';
3
+ import {createDemo} from './demo-data.js';
4
+ const $=id=>document.getElementById(id);
5
+ const COLOR=['#007e77','#3562af','#df8c43','#8b6cc2','#d75872','#4a9d9c','#b78d27','#3f8294'];
6
+ const STATE={rows:createDemo(),mode:'demo',url:null,loadedAt:null,loadedCountries:[],activeView:'overview'};
7
+ const saved=['ARG','BOL','BRA','CHL','COL','PRY'];
8
+ function populate(){
9
+ $('countries').innerHTML=COUNTRY_IDS.map(c=>`<option value="${c}" ${saved.includes(c)?'selected':''}>${escapeHTML(COUNTRIES[c])}</option>`).join('');
10
+ $('indicator').innerHTML=IDS.map(id=>`<option value="${id}">${escapeHTML(INDICATORS[id].short)}</option>`).join('');
11
+ $('compare-indicator').innerHTML=IDS.map(id=>`<option value="${id}" ${id==='IT.NET.USER.ZS'?'selected':''}>${escapeHTML(INDICATORS[id].short)}</option>`).join('');
12
+ $('gap-year').value='2020';
13
+ }
14
+ function chosen(){return [...$('countries').selectedOptions].map(o=>o.value);}
15
+ function filters(){const countries=chosen(),start=Number($('start').value),end=Number($('end').value),ind=$('indicator').value,other=$('compare-indicator').value;
16
+ if(!countries.length)throw new Error('Choose at least one country.');if(countries.length>8)throw new Error('Choose at most eight countries.');if(!Number.isInteger(start)||!Number.isInteger(end)||start<1990||end>2026||start>end)throw new Error('Choose a valid year range between 1990 and 2026.');
17
+ return {countries,start,end,ind,other};}
18
+ function setAlert(text,type='demo'){const box=$('alert');box.textContent=text;box.className='alert '+type;}
19
+ function withFilters(fn){try{return fn(filters());}catch(e){setAlert(e.message,'error');return null;}}
20
+ function esc(n){return escapeHTML(n);}
21
+ function num(n,d=1){return n===null||n===undefined?'N/A':Number(n).toLocaleString('en-US',{maximumFractionDigits:d});}
22
+ function smallTitle(t){return esc(t);}
23
+ function empty(msg){return `<div class="empty">${esc(msg)}</div>`;}
24
+ function updateTop(f){
25
+ const visible=STATE.rows.filter(r=>f.countries.includes(r.iso3)&&r.year>=f.start&&r.year<=f.end);
26
+ const st=status(visible,f.countries,IDS,f.start,f.end);
27
+ $('metric-countries').textContent=f.countries.length;
28
+ $('metric-obs').textContent=num(st.count,0);
29
+ $('metric-coverage').textContent=(100*st.ratio).toFixed(1)+'%';
30
+ $('metric-lag').textContent=st.meanLag===null?'N/A':st.meanLag.toFixed(1)+'y';
31
+ $('mode-pill').className='mode-pill '+STATE.mode;$('mode-pill').textContent=STATE.mode==='live'?'WORLD BANK LIVE':STATE.mode==='imported'?'IMPORTED DATA':'SYNTHETIC DEMO';
32
+ $('status-title').textContent=STATE.mode==='live'?'Official data retrieved from World Bank API':STATE.mode==='imported'?'User-imported research session':'Synthetic research preview';
33
+ $('status-sub').textContent=STATE.mode==='live'?`Retrieved ${STATE.loadedAt||'this session'} · ${st.count} valid observations`:(STATE.mode==='imported'?'User-supplied values; original provenance not verified.':'Generated values for demonstrating application functionality.');
34
+ $('footer-source').textContent=STATE.mode==='live'?'World Bank WDI source 2 · last loaded '+STATE.loadedAt:STATE.mode==='imported'?'USER-IMPORTED OBSERVATIONS':'SYNTHETIC DEMO, NOT FOR CITATION';
35
+ if(STATE.mode==='demo')setAlert('SYNTHETIC DEMO: These figures are invented for interface testing. Click “Load World Bank data” to retrieve official observations.','demo');
36
+ else if(STATE.mode==='imported')setAlert('IMPORTED SESSION: User-provided data. Source and accuracy have not been independently verified.','imported');
37
+ else {const missing=f.countries.filter(c=>!STATE.loadedCountries.includes(c));setAlert(missing.length?'WORLD BANK DATA: New selected countries were not in the last request. Click “Load World Bank data” again.':`OFFICIAL WORLD BANK DATA: ${st.count} non-missing records. Each observation retains its original year. No imputation.`,missing.length?'error':'live');}
38
+ }
39
+ function renderOverview(f){
40
+ const rank=compareCountries(STATE.rows,f.countries,f.ind,f.start,f.end);
41
+ $('overview-meta').textContent=INDICATORS[f.ind].short+' · '+f.start+'–'+f.end;
42
+ const max=rank.length?Math.max(...rank.map(x=>Math.max(0,x.point.value)),0):0;
43
+ $('rank-bars').innerHTML=rank.length?rank.map((x,i)=>`<div class="rank-row"><div class="rank-label">${esc(COUNTRIES[x.country])}</div><div class="track"><div class="fill" style="width:${max?Math.max(0,x.point.value/max*100):0}%;background:${COLOR[i%COLOR.length]}"></div></div><div class="rank-value">${esc(formatValue(x.point.value,f.ind))}<small>${x.point.year}</small></div></div>`).join(''):empty('No observations found. Try a different indicator or wider year range.');
44
+ $('rank-table').innerHTML=rank.length?`<div class="scrolltable"><table><thead><tr><th>Rank</th><th>Economy</th><th>Indicator</th><th class="num">Reported value</th><th class="num">Year</th><th class="num">Lag</th></tr></thead><tbody>${rank.map((x,i)=>`<tr><td>${i+1}</td><td>${esc(COUNTRIES[x.country])}</td><td>${esc(INDICATORS[f.ind].short)}</td><td class="num">${esc(formatValue(x.point.value,f.ind))}</td><td class="num">${x.point.year}</td><td class="num">${f.end-x.point.year}</td></tr>`).join('')}</tbody></table></div>`:empty('No comparable country observations in this interval.');
45
+ }
46
+ function bounds(nums){let min=Math.min(...nums),max=Math.max(...nums);if(!Number.isFinite(min)||!Number.isFinite(max))return [0,1];if(max===min){const delta=Math.max(1,Math.abs(max)*.05);return[min-delta,max+delta];}const p=(max-min)*.08;return[min-p,max+p];}
47
+ function renderTrends(f){
48
+ $('trend-title').textContent=INDICATORS[f.ind].label;
49
+ const series=timeline(STATE.rows,f.countries,f.ind,f.start,f.end),vals=series.flatMap(s=>s.points.map(p=>p.value));
50
+ if(!vals.length){$('line-chart').innerHTML=empty('No observations to plot for this indicator.');$('legend').innerHTML='';$('trend-notes').innerHTML='';return;}
51
+ const w=800,h=330,pad={l:74,r:30,t:25,b:45},[ymin,ymax]=bounds(vals),xx=yr=>pad.l+(yr-f.start)/Math.max(1,f.end-f.start)*(w-pad.l-pad.r),yy=v=>h-pad.b-(v-ymin)/(ymax-ymin)*(h-pad.t-pad.b);
52
+ let svg=`<svg class="plot-svg" viewBox="0 0 ${w} ${h}" role="img" aria-label="Time series for selected education indicator">`;
53
+ for(let i=0;i<6;i++){const v=ymin+i*(ymax-ymin)/5,y=yy(v);svg+=`<line class="grid" x1="${pad.l}" y1="${y}" x2="${w-pad.r}" y2="${y}"/><text class="chart-tick" x="${pad.l-10}" y="${y+4}" text-anchor="end">${esc(num(v,1))}</text>`;}
54
+ const step=Math.max(1,Math.ceil((f.end-f.start)/7));for(let y=f.start;y<=f.end;y+=step)svg+=`<text class="chart-tick" x="${xx(y)}" y="${h-17}" text-anchor="middle">${y}</text>`;
55
+ series.forEach((s,j)=>{const color=COLOR[j%COLOR.length];for(let i=1;i<s.points.length;i++){
56
+ const a=s.points[i-1],b=s.points[i];if(b.year-a.year!==1)continue;
57
+ svg+=`<line x1="${xx(a.year)}" y1="${yy(a.value)}" x2="${xx(b.year)}" y2="${yy(b.value)}" stroke="${color}" stroke-width="2.6" stroke-linecap="round"/>`;
58
+ }for(const p of s.points)svg+=`<circle cx="${xx(p.year)}" cy="${yy(p.value)}" r="3.2" fill="${color}"><title>${esc(COUNTRIES[p.iso3])} ${p.year}: ${esc(formatValue(p.value,f.ind))}</title></circle>`;});svg+='</svg>';
59
+ $('line-chart').innerHTML=svg;$('legend').innerHTML=series.map((s,i)=>`<span><i style="background:${COLOR[i%COLOR.length]}"></i>${esc(COUNTRIES[s.iso3])} (${s.points.length})</span>`).join('');
60
+ const lines=series.map(s=>{if(s.points.length<2)return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td colspan="3">Insufficient years</td></tr>`;const ch=change(s.points[0],s.points[s.points.length-1]);return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td>${s.points[0].year} → ${s.points[s.points.length-1].year}</td><td class="num">${ch?esc((ch.amount>0?'+':'')+num(ch.amount,2)): 'N/A'}</td><td class="num">${s.points.length}</td></tr>`;});
61
+ $('trend-notes').innerHTML=`<h3>Observed change, without interpolation</h3><div class="scrolltable"><table><thead><tr><th>Economy</th><th>First → last</th><th class="num">Change (indicator units)</th><th class="num">Reported years</th></tr></thead><tbody>${lines.join('')}</tbody></table></div><p>${esc(INDICATORS[f.ind].tip)} Change uses earliest and latest available observations, not necessarily the requested boundary years.</p>`;
62
+ }
63
+ function renderGaps(f){
64
+ const selected=f.countries;$('gap-a').innerHTML=selected.map(c=>`<option value="${c}">${esc(COUNTRIES[c])}</option>`).join('');$('gap-b').innerHTML=selected.map(c=>`<option value="${c}">${esc(COUNTRIES[c])}</option>`).join('');
65
+ const priorA=$('gap-a').dataset.last,priorB=$('gap-b').dataset.last;$('gap-a').value=selected.includes(priorA)?priorA:selected[0];$('gap-b').value=selected.includes(priorB)?priorB:(selected[1]||selected[0]);
66
+ const yr=Number($('gap-year').value);if(yr<f.start||yr>f.end){$('gap-year').value=String(f.end);}
67
+ renderGapResult(f);
68
+ const firsts=timeline(STATE.rows,selected,f.ind,f.start,f.end);
69
+ $('progress-table').innerHTML=`<div class="chart-title"><h3>Progress diagnostics</h3><small>First and last reported points; no missing years filled.</small></div><div class="scrolltable"><table><thead><tr><th>Economy</th><th class="num">First reported</th><th class="num">Last reported</th><th class="num">Change</th><th class="num">Available years</th></tr></thead><tbody>${firsts.map(s=>{if(!s.points.length)return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td colspan="4">Missing</td></tr>`;const a=s.points[0],b=s.points[s.points.length-1],d=change(a,b);return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td class="num">${esc(formatValue(a.value,f.ind))} (${a.year})</td><td class="num">${esc(formatValue(b.value,f.ind))} (${b.year})</td><td class="num">${d?(d.amount>0?'+':'')+esc(num(d.amount,2)):'N/A'}</td><td class="num">${s.points.length}</td></tr>`;}).join('')}</tbody></table></div>`;
70
+ }
71
+ function renderGapResult(f){const a=$('gap-a').value,b=$('gap-b').value,yr=Number($('gap-year').value);$('gap-a').dataset.last=a;$('gap-b').dataset.last=b;
72
+ if(!a||!b||a===b||!Number.isInteger(yr)||yr<f.start||yr>f.end){$('gap-result').innerHTML=empty('Choose two different economies and a year within the selected range.');return;}
73
+ const z=crossCountryGap(STATE.rows,a,b,f.ind,f.start,f.end,yr);$('gap-result').innerHTML=z?`<div class="numberline"><strong>${z.delta>=0?'+':''}${esc(num(z.delta,2))}</strong> ${esc(INDICATORS[f.ind].unit)} · ${esc(COUNTRIES[a])} minus ${esc(COUNTRIES[b])} in ${yr}</div><p class="muted">${esc(COUNTRIES[a])}: ${esc(formatValue(z.a,f.ind))} · ${esc(COUNTRIES[b])}: ${esc(formatValue(z.b,f.ind))}. This is a descriptive cross-country difference, not an estimate of within-country equity.</p>`:empty('At least one of these countries has no observation for this exact year. Choose another year; values are never filled in.');}
74
+ function renderRelations(f){const a=f.ind,b=f.other;if(a===b){$('scatter-chart').innerHTML=empty('Choose a different association variable.');$('relationship-meta').textContent='Comparing a measure with itself is not useful.';return;}
75
+ const pairs=paired(STATE.rows,a,b,f.countries,f.start,f.end);if(pairs.length<3){$('scatter-chart').innerHTML=empty('Too few matched country-year observations. Try a wider period or a series with better coverage.');$('relationship-meta').textContent=`Only ${pairs.length} valid matched pairs. At least three are required.`;return;}
76
+ const w=800,h=355,p={l:85,r:25,t:25,b:72},[xmin,xmax]=bounds(pairs.map(x=>x[a])),[ymin,ymax]=bounds(pairs.map(x=>x[b]));const xx=x=>p.l+(x-xmin)/(xmax-xmin)*(w-p.l-p.r),yy=y=>h-p.b-(y-ymin)/(ymax-ymin)*(h-p.t-p.b);
77
+ let svg=`<svg class="plot-svg" viewBox="0 0 ${w} ${h}" role="img" aria-label="Scatterplot of paired observations">`;
78
+ for(let i=0;i<5;i++){const v=ymin+i*(ymax-ymin)/4,y=yy(v);svg+=`<line class="grid" x1="${p.l}" y1="${y}" x2="${w-p.r}" y2="${y}"/><text class="chart-tick" x="${p.l-9}" y="${y+4}" text-anchor="end">${esc(num(v,1))}</text>`;}
79
+ for(let i=0;i<5;i++){const v=xmin+i*(xmax-xmin)/4,x=xx(v);svg+=`<text class="chart-tick" x="${x}" y="${h-49}" text-anchor="middle">${esc(num(v,1))}</text>`;}
80
+ svg+=`<text x="${w/2}" y="${h-10}" text-anchor="middle" font-size="11" fill="#355766">${esc(INDICATORS[a].short)} (${esc(INDICATORS[a].unit)})</text>`;
81
+ svg+=`<text x="17" y="${h/2}" transform="rotate(-90 17 ${h/2})" text-anchor="middle" font-size="11" fill="#355766">${esc(INDICATORS[b].short)} (${esc(INDICATORS[b].unit)})</text>`;
82
+ for(const o of pairs){const ci=f.countries.indexOf(o.iso3);svg+=`<circle cx="${xx(o[a])}" cy="${yy(o[b])}" r="4.5" fill="${COLOR[ci%COLOR.length]}" fill-opacity=".68"><title>${esc(COUNTRIES[o.iso3])} ${o.year}: ${esc(num(o[a],2))} / ${esc(num(o[b],2))}</title></circle>`;}svg+='</svg>';
83
+ const r=pearson(pairs,a,b);$('scatter-chart').innerHTML=svg;
84
+ $('relationship-meta').innerHTML=`<strong>Paired observations:</strong> ${pairs.length} (country-year pairs) · <strong>Pearson r:</strong> ${r===null?'N/A':num(r,3)} · <strong>Selected countries:</strong> ${f.countries.length}.<br/>This is an exploratory descriptive correlation. The same country appears in multiple years, observations are not independent, reporting may be uneven, and no statistical significance or causal claim is implied.`;
85
+ }
86
+ function renderQuality(f){const years=f.end-f.start+1;const sample=STATE.rows.filter(r=>f.countries.includes(r.iso3)&&r.year>=f.start&&r.year<=f.end),cols=IDS;
87
+ const matrix=f.countries.map(c=>{const cells=cols.map(ind=>{const points=sample.filter(r=>r.iso3===c&&r.indicator===ind);const ratio=points.length/years,shade=ratio===0?'background:#fae8e5':`background:rgba(9,151,134,${(.09+.65*ratio).toFixed(2)})`;return `<td class="matrix-square ${!ratio?'missing':''}" style="${shade}" title="${esc(COUNTRIES[c])} ${esc(INDICATORS[ind].short)}: ${points.length}/${years} years">${num(ratio*100,0)}%</td>`;}).join('');return `<tr><td>${esc(COUNTRIES[c])}</td>${cells}</tr>`;}).join('');
88
+ $('coverage-matrix').innerHTML=`<div class="scrolltable"><table class="matrix"><thead><tr><th>Economy</th>${cols.map(id=>`<th title="${esc(INDICATORS[id].label)}">${esc(INDICATORS[id].short)}</th>`).join('')}</tr></thead><tbody>${matrix}</tbody></table></div><p class="hint">Color darkness approximates the share of selected years with a reported value. Coverage is not a quality certificate.</p>`;
89
+ const st=status(sample,f.countries,IDS,f.start,f.end);$('source-audit').innerHTML=`<h3>Audit trail and caveats</h3><p><b>Data mode:</b> ${STATE.mode==='live'?'World Bank WDI API, source 2':STATE.mode==='demo'?'SYNTHETIC DEMO (not official)':'User imported, source unverified'}.<br/><b>Period:</b> ${f.start}–${f.end}. <b>Coverage:</b> ${st.count}/${st.eligible} possible country-year-indicator observations. <b>Reporting lag:</b> ${st.meanLag===null?'N/A':st.meanLag.toFixed(1)+' years'} averaged over available series.</p><p>UNESCO UIS supplies many education series reproduced in World Development Indicators. Gross enrollment and primary completion definitions should not be interpreted as graduation rates, and gross ratios can exceed 100%. Missing values stay missing. Cross-country coverage and vintage differ. GDP per capita and Internet use are contextual, not direct school outcomes.</p><p><b>API:</b> ${STATE.url?`<a href="${esc(STATE.url)}" target="_blank" rel="noopener noreferrer">Exact query link</a>`:'Not queried live in this session.'} · <a href="https://datahelpdesk.worldbank.org/knowledgebase/articles/889392" target="_blank" rel="noopener noreferrer">World Bank API documentation</a> · <a href="https://databank.worldbank.org/metadataglossary/world-development-indicators/" target="_blank" rel="noopener noreferrer">Indicator metadata glossary</a></p>`;
90
+ }
91
+ function makeBrief(f){const s=subset(STATE.rows,f.countries,f.ind,f.start,f.end),rank=compareCountries(STATE.rows,f.countries,f.ind,f.start,f.end),st=status(STATE.rows,f.countries,IDS,f.start,f.end),pairs=paired(STATE.rows,f.ind,f.other,f.countries,f.start,f.end);const r=f.ind===f.other?null:pearson(pairs,f.ind,f.other);const mode=STATE.mode==='live'?'OFFICIAL WORLD BANK WDI OBSERVATIONS':STATE.mode==='demo'?'SYNTHETIC DEMO, NOT CITABLE':'USER-IMPORTED DATA, SOURCE UNVERIFIED';
92
+ const lines=[`# Education Data Lab: Comparative Research Brief`,``,`**Data status:** ${mode}`,`**Period:** ${f.start}–${f.end}`,`**Countries:** ${f.countries.map(c=>COUNTRIES[c]).join(', ')}`,`**Primary measure:** ${INDICATORS[f.ind].label} (${INDICATORS[f.ind].unit})`,`**Association measure:** ${INDICATORS[f.other].label}`,`**Generated:** ${new Date().toISOString()}`,``,`## Observations`,`- ${s.length} non-missing observations for the primary measure; ${st.count}/${st.eligible} country-year-indicator cells observed across the 8 tracked series.`,`- Latest reported values may have different reporting years and must not be described as same-year comparisons.`];
93
+ if(rank.length){const top=rank[0];lines.push(`- Highest latest *reported* value within these selected economies: ${COUNTRIES[top.country]} (${formatValue(top.point.value,f.ind)}, ${top.point.year}). This is not a causal ranking or necessarily the latest calendar year.`);}else lines.push('- No latest reported observation is available for this measure in the selected window.');
94
+ for(const item of rank.slice(0,8))lines.push(`- ${COUNTRIES[item.country]}: ${formatValue(item.point.value,f.ind)} (${item.point.year})`);
95
+ lines.push('','## Exploratory association');if(r!==null)lines.push(`- Pearson r for ${pairs.length} matched country-year pairs: ${r.toFixed(3)}. Descriptive only; repeated country-years are not independent and **no causal or significance inference** follows.`);else lines.push('- Insufficient or unsuitable paired observations for correlation.');
96
+ lines.push('','## Methodological safeguards','- Missing years are not interpolated, carried forward or imputed.','- Gross education indicators may exceed 100%; they are not net participation rates.','- Data vintages, coverage, definitions and reporting frequency differ across countries.','- This brief is an exploratory descriptive analysis, not an impact evaluation or recommendation for resource allocation.','',`## Provenance`,`- Mode: ${mode}`,`- Query: ${STATE.url||'No live query; generated demo or imported file.'}`,'- World Bank Indicators API v2, WDI source 2: https://datahelpdesk.worldbank.org/knowledgebase/articles/889392','- Definitions: https://databank.worldbank.org/metadataglossary/world-development-indicators/','');return lines.join('\n');}
97
+ function renderBrief(f){const txt=makeBrief(f);$('brief-text').innerHTML=txt.split('\n').map(line=>{
98
+ if(line.startsWith('# '))return `<h3>${esc(line.slice(2))}</h3>`;
99
+ if(line.startsWith('## '))return `<h3>${esc(line.slice(3))}</h3>`;
100
+ if(line.startsWith('- '))return `<p>• ${esc(line.slice(2))}</p>`;
101
+ return line?`<p>${esc(line)}</p>`:'';}).join('');}
102
+ function render(){withFilters(f=>{updateTop(f);renderOverview(f);renderTrends(f);renderGaps(f);renderRelations(f);renderQuality(f);renderBrief(f);});}
103
+ function rowsForSelection(){const f=filters();return STATE.rows.filter(r=>f.countries.includes(r.iso3)&&r.year>=f.start&&r.year<=f.end);}
104
+ function download(text,name,type){const blob=new Blob([text],{type});const u=URL.createObjectURL(blob);const a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),10000);}
105
+ function sendExport(type){withFilters(f=>{const suf=STATE.mode==='demo'?'SYNTHETIC_DEMO':STATE.mode==='live'?'WORLD_BANK':'USER_IMPORTED';const pre='education-data-lab_'+suf+'_'+new Date().toISOString().slice(0,10);
106
+ if(type==='csv')download(toCSV(rowsForSelection()),pre+'.csv','text/csv;charset=utf-8');
107
+ else if(type==='md')download(makeBrief(f),pre+'_research-brief.md','text/markdown;charset=utf-8');
108
+ else download(JSON.stringify({format:'edlab-session-v1',mode:STATE.mode,sourceURL:STATE.url,exportedAt:new Date().toISOString(),filters:f,rows:rowsForSelection(),disclaimer:'Demo values are synthetic; imported files are unverified; live source is World Bank WDI.'},null,2),pre+'.json','application/json');});}
109
+ function bind(){
110
+ for(const id of ['countries','indicator','start','end','compare-indicator'])$(id).addEventListener('change',render);
111
+ document.querySelectorAll('.tab').forEach(btn=>btn.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t===btn));document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.id==='view-'+btn.dataset.view));STATE.activeView=btn.dataset.view;}));
112
+ for(const id of ['gap-a','gap-b','gap-year'])$(id).addEventListener('change',()=>withFilters(renderGapResult));
113
+ $('load-demo').addEventListener('click',()=>{STATE.rows=createDemo();STATE.mode='demo';STATE.url=null;STATE.loadedAt=null;STATE.loadedCountries=[];render();});
114
+ $('load-live').addEventListener('click',async()=>{const f=withFilters(x=>x);if(!f)return;const btn=$('load-live');btn.disabled=true;btn.textContent='Loading official data…';setAlert('Fetching official observations from the World Bank. Please wait.','live');
115
+ try{const {rows,url}=await requestWorldBank(f.countries,IDS,f.start,f.end);STATE.rows=rows;STATE.mode='live';STATE.url=url;STATE.loadedAt=new Date().toLocaleString();STATE.loadedCountries=[...f.countries];render();}
116
+ catch(e){setAlert('Live data could not be loaded: '+e.message+'. Previously shown data were not replaced. You can use the clearly labeled demo meanwhile.','error');}
117
+ finally{btn.disabled=false;btn.textContent='↗ Load World Bank data';}
118
+ });
119
+ $('download-csv').addEventListener('click',()=>sendExport('csv'));$('export-md').addEventListener('click',()=>sendExport('md'));$('export-json').addEventListener('click',()=>sendExport('json'));
120
+ $('import-json').addEventListener('click',()=>$('import-file').click());$('import-file').addEventListener('change',async(e)=>{const file=e.target.files?.[0];if(!file)return;try{if(file.size>6e6)throw new Error('Maximum file size is 6 MB');const json=JSON.parse(await file.text());const parsed=validateSession(json);STATE.rows=parsed.rows;STATE.mode='imported';STATE.url=null;STATE.loadedCountries=[];STATE.loadedAt=new Date().toLocaleString();render();}catch(error){setAlert('Import failed: '+error.message,'error');}finally{e.target.value='';}});
121
+ }
122
+ populate();bind();render();
browser_test.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json,re
2
+ from playwright.sync_api import sync_playwright
3
+ from pathlib import Path
4
+ folder=Path(__file__).parent
5
+ def bundled_html():
6
+ html=(folder/'index.html').read_text()
7
+ css=(folder/'style.css').read_text()
8
+ scripts=[]
9
+ for nm in ('core.js','worldbank.js','demo-data.js','app.js'):
10
+ body=(folder/nm).read_text()
11
+ body=re.sub(r'^import [^\n]+\n','',body,flags=re.MULTILINE)
12
+ body=re.sub(r'\bexport (?!\{)', '',body)
13
+ scripts.append(body)
14
+ html=html.replace('<link rel="stylesheet" href="style.css"/>','<style>'+css+'</style>')
15
+ html=html.replace('<script type="module" src="app.js"></script>','<script>'+'\n'.join(scripts)+'</script>')
16
+ return html
17
+ html=bundled_html()
18
+ checks=[];errors=[]
19
+ def ok(name,condition=True):
20
+ if not condition:raise AssertionError(name)
21
+ checks.append('PASS: '+name)
22
+ with sync_playwright() as p:
23
+ browser=p.chromium.launch(headless=True,executable_path='/usr/bin/chromium',args=['--no-sandbox','--disable-dev-shm-usage'])
24
+ context=browser.new_context(accept_downloads=True,viewport={'width':1440,'height':1100},device_scale_factor=1)
25
+ page=context.new_page();page.on('pageerror',lambda e:errors.append(str(e)))
26
+ page.set_content(html,wait_until='load');page.wait_for_timeout(700)
27
+ ok('Demo badge initially visible',page.locator('#mode-pill').inner_text()=='SYNTHETIC DEMO')
28
+ ok('Synthetic data populated',int(page.locator('#metric-obs').inner_text().replace(',',''))>0)
29
+ ok('Overview shows country ranking',page.locator('#rank-bars .rank-row').count()==6)
30
+ page.screenshot(path=str(folder/'Education_Data_Lab_DESKTOP_PREVIEW.png'),full_page=True)
31
+ for tab,selector in [('trends','#line-chart svg'),('gaps','#gap-result'),('relations','#scatter-chart svg'),('quality','#coverage-matrix table'),('brief','#brief-text h3')]:
32
+ page.locator(f'button[data-view="{tab}"]').click()
33
+ ok('Tab '+tab+' updates content',page.locator(selector).count()>0)
34
+ page.locator('button[data-view="trends"]').click();ok('Line series has paths',page.locator('#line-chart svg line').count()>5)
35
+ page.locator('button[data-view="gaps"]').click();page.locator('#gap-year').fill('2020');page.locator('#gap-year').dispatch_event('change');ok('Exact-year gap computed',page.locator('#gap-result .numberline').count()==1)
36
+ page.locator('button[data-view="brief"]').click()
37
+ with page.expect_download() as d:page.locator('#export-md').click()
38
+ ok('Markdown report exported',d.value.suggested_filename.endswith('.md'))
39
+ with page.expect_download() as d:page.locator('#export-json').click()
40
+ path=d.value.path();data=json.loads(Path(path).read_text())
41
+ ok('Reproducible JSON includes rows',len(data['rows'])>0 and data['format']=='edlab-session-v1')
42
+ with page.expect_download() as d:page.locator('#download-csv').click()
43
+ ok('CSV dataset exported',d.value.suggested_filename.endswith('.csv'))
44
+ # import prior session
45
+ page.locator('#import-file').set_input_files({'name':'session.json','mimeType':'application/json','buffer':json.dumps(data).encode()})
46
+ ok('Session import acknowledged',page.locator('#mode-pill').inner_text()=='IMPORTED DATA')
47
+ # live mocked data via route; test code's API parsing and mode, not actual live endpoint
48
+ from urllib.parse import urlparse
49
+ def route_handler(route):
50
+ rows=[]
51
+ for country in ['ARG','BOL','BRA','CHL','COL','PRY']:
52
+ for ind in ['SE.PRM.CMPT.ZS','SE.SEC.ENRR','SE.TER.ENRR','SE.XPD.TOTL.GD.ZS','SE.ADT.LITR.ZS','IT.NET.USER.ZS','NY.GDP.PCAP.CD','SP.POP.TOTL']:
53
+ for year in range(2010,2026):
54
+ rows.append({'countryiso3code':country,'date':str(year),'indicator':{'id':ind},'value':float(60+(year-2010)+(len(country)+len(ind))%10),'footnote':[]})
55
+ route.fulfill(status=200,content_type='application/json',body=json.dumps([{'pages':1,'per_page':20000,'total':len(rows)},rows]))
56
+ page.route('https://api.worldbank.org/**',route_handler)
57
+ page.locator('#load-live').click();page.wait_for_timeout(300)
58
+ ok('Mock World Bank response processed',page.locator('#mode-pill').inner_text()=='WORLD BANK LIVE')
59
+ ok('Official mode displays source attribution', 'World Bank' in page.locator('#status-title').inner_text())
60
+ # Error route should preserve most recent data
61
+ page.unroute('https://api.worldbank.org/**')
62
+ page.route('https://api.worldbank.org/**',lambda route:route.fulfill(status=500,body='error'))
63
+ page.locator('#load-live').click();page.wait_for_timeout(300)
64
+ ok('Fetch error visible and prior data retained', 'could not be loaded' in page.locator('#alert').inner_text() and page.locator('#mode-pill').inner_text()=='WORLD BANK LIVE')
65
+ mobile=browser.new_context(viewport={'width':390,'height':844},device_scale_factor=1,accept_downloads=True,is_mobile=True,has_touch=True)
66
+ pg=mobile.new_page();pg.on('pageerror',lambda e:errors.append(str(e)));pg.set_content(html,wait_until='load');pg.wait_for_timeout(400)
67
+ pg.screenshot(path=str(folder/'Education_Data_Lab_MOBILE_PREVIEW.png'),full_page=True)
68
+ ok('Mobile responsive without horizontal overflow',pg.evaluate('document.documentElement.scrollWidth <= innerWidth + 2'))
69
+ ok('No JavaScript page errors',len(errors)==0)
70
+ browser.close()
71
+ print('\n'.join(checks));print(f'RESULT: {len(checks)} browser checks passed; JS errors: {errors}')
core.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Education Data Lab: deterministic analytics, no external dependencies. */
2
+ export const COUNTRIES = Object.freeze({
3
+ ARG:'Argentina',BOL:'Bolivia',BRA:'Brazil',CHL:'Chile',COL:'Colombia',CRI:'Costa Rica',DOM:'Dominican Republic',ECU:'Ecuador',MEX:'Mexico',PAN:'Panama',PER:'Peru',PRY:'Paraguay',URY:'Uruguay',GTM:'Guatemala',HND:'Honduras',SLV:'El Salvador'
4
+ });
5
+ export const INDICATORS = Object.freeze({
6
+ 'SE.PRM.CMPT.ZS':{short:'Primary completion',label:'Primary completion rate',unit:'%',group:'Education',tip:'Gross intake ratio to last grade; may exceed 100. Not cohort graduation.'},
7
+ 'SE.SEC.ENRR':{short:'Secondary enrollment',label:'Secondary school enrollment, gross',unit:'%',group:'Education',tip:'Gross enrollment is not net enrollment; can exceed 100.'},
8
+ 'SE.TER.ENRR':{short:'Tertiary enrollment',label:'Tertiary school enrollment, gross',unit:'%',group:'Education',tip:'Gross enrollment, not degree completion; can exceed 100.'},
9
+ 'SE.XPD.TOTL.GD.ZS':{short:'Education spending',label:'Government expenditure on education, total',unit:'% GDP',group:'Education',tip:'Government spending as a share of GDP; not per-student investment.'},
10
+ 'SE.ADT.LITR.ZS':{short:'Adult literacy',label:'Literacy rate, adult total (15+)',unit:'%',group:'Education',tip:'Survey/reporting coverage can be sparse.'},
11
+ 'IT.NET.USER.ZS':{short:'Internet use',label:'Individuals using the Internet',unit:'%',group:'Context',tip:'Context series; not a direct education outcome.'},
12
+ 'NY.GDP.PCAP.CD':{short:'GDP per capita',label:'GDP per capita, current US$',unit:'US$',group:'Context',tip:'Nominal current USD is affected by prices and FX.'},
13
+ 'SP.POP.TOTL':{short:'Population',label:'Population, total',unit:'people',group:'Context',tip:'Context series; not a schooling population.'}
14
+ });
15
+ export const IDS = Object.keys(INDICATORS);
16
+ export const COUNTRY_IDS = Object.keys(COUNTRIES);
17
+ export function validYear(y){return Number.isInteger(Number(y))&&Number(y)>=1990&&Number(y)<=2026;}
18
+ export function finite(v){return typeof v==='number'&&Number.isFinite(v);}
19
+ export function normalizeRows(rows){
20
+ if(!Array.isArray(rows))throw new Error('Expected array of observations');
21
+ const out=[],seen=new Set();
22
+ for(const row of rows){
23
+ if(!row||!COUNTRIES[row.iso3]||!INDICATORS[row.indicator]||!validYear(row.year))continue;
24
+ if(row.value===null||row.value===undefined||row.value==='')continue;
25
+ const val=typeof row.value==='number'?row.value:Number(row.value);
26
+ if(!finite(val))continue;
27
+ const key=[row.iso3,Number(row.year),row.indicator].join('|');
28
+ if(seen.has(key))continue;seen.add(key);
29
+ out.push({iso3:row.iso3,year:Number(row.year),indicator:row.indicator,value:val,footnote:String(row.footnote||'').slice(0,240),status:String(row.status||'').slice(0,24)});
30
+ }
31
+ return out.sort((a,b)=>a.year-b.year||a.iso3.localeCompare(b.iso3)||a.indicator.localeCompare(b.indicator));
32
+ }
33
+ export function subset(rows,cc,indicator,start,end){return rows.filter(r=>cc.includes(r.iso3)&&r.indicator===indicator&&r.year>=start&&r.year<=end);}
34
+ export function latest(rows,country,ind,start,end){const a=rows.filter(r=>r.iso3===country&&r.indicator===ind&&r.year>=start&&r.year<=end); return a.length?a.reduce((best,x)=>x.year>best.year?x:best):null;}
35
+ export function paired(rows,x,y,cc,start,end){
36
+ const selected=rows.filter(r=>cc.includes(r.iso3)&&[x,y].includes(r.indicator)&&r.year>=start&&r.year<=end);const g=new Map();
37
+ for(const r of selected){const k=r.iso3+'|'+r.year;if(!g.has(k))g.set(k,{iso3:r.iso3,year:r.year});g.get(k)[r.indicator]=r.value;}
38
+ return [...g.values()].filter(o=>finite(o[x])&&finite(o[y])).sort((a,b)=>a.year-b.year||a.iso3.localeCompare(b.iso3));
39
+ }
40
+ export function pearson(points,x,y){if(points.length<3)return null;const a=points.map(p=>p[x]),b=points.map(p=>p[y]);const n=a.length,ma=a.reduce((s,v)=>s+v,0)/n,mb=b.reduce((s,v)=>s+v,0)/n;let num=0,da=0,db=0;for(let i=0;i<n;i++){const u=a[i]-ma,v=b[i]-mb;num+=u*v;da+=u*u;db+=v*v;}return da>0&&db>0?num/Math.sqrt(da*db):null;}
41
+ export function growth(oldValue,newValue){return finite(oldValue)&&finite(newValue)&&oldValue!==0?100*(newValue/oldValue-1):null;}
42
+ export function change(first,last){return first&&last&&first.year!==last.year?{amount:last.value-first.value,from:first.year,to:last.year}:null;}
43
+ export function coverage(rows,countries,inds,start,end){const years=end-start+1,eligible=countries.length*inds.length*years; const count=rows.filter(r=>countries.includes(r.iso3)&&inds.includes(r.indicator)&&r.year>=start&&r.year<=end).length;return {count,eligible,ratio:eligible?count/eligible:0};}
44
+ export function status(rows,countries,inds,start,end){const c=coverage(rows,countries,inds,start,end);let lags=[];for(const cc of countries)for(const i of inds){const x=latest(rows,cc,i,start,end);if(x)lags.push(end-x.year);}return { ...c,meanLag:lags.length?lags.reduce((a,b)=>a+b,0)/lags.length:null,availablePairs:lags.length,totalPairs:countries.length*inds.length};}
45
+ export function formatValue(n,ind){if(!finite(n))return 'N/A';const unit=INDICATORS[ind]?.unit||'%';if(unit==='US$')return '$'+Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:2}).format(n);if(unit==='people')return Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n);return n.toFixed(Math.abs(n)<10?2:1)+(unit==='% GDP'?'% GDP':'%');}
46
+ export function escapeHTML(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
47
+ export function csvCell(v){let s=String(v??'');if(typeof v==='string'&&/^[=+\-@\t\r]/.test(s))s="'"+s;return /[",\n\r]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;}
48
+ export function toCSV(rows){const cols=['iso3','country','year','indicator','indicator_label','value','unit','footnote','observation_status'];return [cols.join(','),...rows.map(r=>[r.iso3,COUNTRIES[r.iso3],r.year,r.indicator,INDICATORS[r.indicator]?.label,r.value,INDICATORS[r.indicator]?.unit,r.footnote||'',r.status||''].map(csvCell).join(','))].join('\r\n');}
49
+ export function validateSession(x){if(!x||x.format!=='edlab-session-v1'||!Array.isArray(x.rows)||x.rows.length>25000)throw new Error('Invalid Education Data Lab session');const rows=normalizeRows(x.rows);if(!rows.length)throw new Error('No supported observations');return {rows,mode:'imported',importedAt:new Date().toISOString(),sourceNote:'Imported user session; provenance not independently verified.'};}
50
+ export function timeline(rows,countries,ind,start,end){const a=subset(rows,countries,ind,start,end);return countries.map(cc=>({iso3:cc,points:a.filter(r=>r.iso3===cc).sort((a,b)=>a.year-b.year)}));}
51
+ export function compareCountries(rows,countries,ind,start,end){return countries.map(c=>({country:c,point:latest(rows,c,ind,start,end)})).filter(x=>x.point).sort((a,b)=>b.point.value-a.point.value);}
52
+ export function crossCountryGap(rows,a,b,ind,start,end,year){const p=rows.find(r=>r.iso3===a&&r.indicator===ind&&r.year===year),q=rows.find(r=>r.iso3===b&&r.indicator===ind&&r.year===year);return p&&q?{year,delta:p.value-q.value,a:p.value,b:q.value}:null;}
demo-data.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {IDS,COUNTRY_IDS,normalizeRows} from './core.js';
2
+ // Generated for interface tests only. Values are NOT World Bank observations.
3
+ function noise(a,b,c){let x=Math.sin((a+1)*12.9898+(b+1)*78.233+(c+1)*37.719)*43758.5453;return (x-Math.floor(x)-0.5)*2;}
4
+ export function createDemo(){const codes=COUNTRY_IDS,rows=[];for(let c=0;c<codes.length;c++)for(let year=2005;year<=2025;year++)for(let j=0;j<IDS.length;j++){
5
+ const t=year-2005, v=noise(c,t,j),slow=(c%5)*2.8, base=[73+slow,68+slow,31+slow,3.1+(c%4)*0.35,82+slow,29+slow,4500+c*1400,3900000+c*5200000][j];
6
+ const step=[0.64,0.66,0.96,0.025,0.32,2.6,210,11000][j];
7
+ const amp=[2.1,2.3,3.5,0.3,1.3,2.2,600,4000][j];
8
+ if(noise(c+7,t+11,j+3)>.70 && (j===3||j===4||j===2))continue;
9
+ let value=base+step*t+v*amp;
10
+ if(j===3)value=Math.max(0.6,value);if(j===4||j===5)value=Math.min(99.9,Math.max(0,value));
11
+ rows.push({iso3:codes[c],year,indicator:IDS[j],value:Number(value.toFixed(j===7?0:2))});
12
+ }return normalizeRows(rows);}
index.html CHANGED
@@ -1,19 +1,30 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
3
+ <meta name="description" content="Comparative education analytics and source-transparent Latin American dashboards."/>
4
+ <title>Education Data Lab | Comparative Education Intelligence</title><link rel="stylesheet" href="style.css"/></head>
5
+ <body>
6
+ <header class="top"><div class="top-inner"><div class="brand"><div class="mark">ED<span>·</span></div><div><b>EDUCATION DATA LAB</b><small>Comparative Research Intelligence</small></div></div><div class="top-right"><span class="mini">OPEN RESEARCH · LATIN AMERICA</span><span id="mode-pill" class="mode-pill demo">SYNTHETIC DEMO</span></div></div></header>
7
+ <section class="hero"><div class="wrap hero-grid"><div><div class="eyebrow">EDUCATION SYSTEMS · QUANTITATIVE RESEARCH · EVIDENCE</div><h1>From education indicators<br/>to <em>comparative insight.</em></h1><p>Explore participation, completion, spending and context across Latin America. Audit data gaps, investigate associations and export reproducible reports.</p><div class="hero-tags"><span>World Bank API v2</span><span>UNESCO-derived education series</span><span>Transparent statistics</span><span>No server required</span></div></div><div class="hero-side"><div class="hero-number">08<span>indicators</span></div><div class="hero-divider"></div><div class="hero-side-row"><div>16<span>economies</span></div><div>06<span>research workspaces</span></div></div></div></div></section>
8
+ <div class="wrap"><div id="alert" role="status" aria-live="polite" class="alert demo">DEMO DATA: The currently displayed figures are generated, not official statistics. Click “Load World Bank data” to retrieve actual observations.</div>
9
+ <div class="layout">
10
+ <aside class="controls card"><div class="controls-title">01 / RESEARCH SETTINGS</div><label for="countries">Countries <small>up to 8</small></label><select id="countries" multiple size="9" aria-describedby="country-help"></select><div id="country-help" class="hint">Mac: hold ⌘ to select multiple economies.</div>
11
+ <label for="indicator">Primary indicator</label><select id="indicator"></select>
12
+ <div class="input-grid"><div><label for="start">From</label><input id="start" type="number" min="1990" max="2026" value="2010"/></div><div><label for="end">Through</label><input id="end" type="number" min="1990" max="2026" value="2025"/></div></div>
13
+ <label for="compare-indicator">Association variable</label><select id="compare-indicator"></select>
14
+ <div class="btn-stack"><button id="load-live" class="btn primary">↗ Load World Bank data</button><button id="load-demo" class="btn subtle">◇ Load synthetic demo</button></div>
15
+ <div class="hint note">Live data requires internet access. Missing observations are never invented, interpolated or silently filled.</div>
16
+ <details class="filter-help"><summary>How to use this workspace</summary><p>Choose countries and indicators, select a research view and export results. Start with demo data to test the controls, then load official data.</p></details>
17
+ </aside>
18
+ <main class="main"><div class="status-row"><div><b id="status-title">Synthetic research preview</b><p id="status-sub">Demonstration values. Do not cite as factual evidence.</p></div><button id="download-csv" class="btn outline">↓ Export data CSV</button></div>
19
+ <div class="metrics"><div class="metric"><span>COUNTRIES</span><strong id="metric-countries">6</strong><small>selected economies</small></div><div class="metric"><span>OBSERVATIONS</span><strong id="metric-obs">0</strong><small>valid country-years / 8 series</small></div><div class="metric"><span>DATA COVERAGE</span><strong id="metric-coverage">—</strong><small>of possible records</small></div><div class="metric"><span>AVG. REPORTING LAG</span><strong id="metric-lag">—</strong><small>years across latest observations</small></div></div>
20
+ <nav class="tabs" aria-label="Research views"><button class="tab active" data-view="overview">01 / Overview</button><button class="tab" data-view="trends">02 / Trends</button><button class="tab" data-view="gaps">03 / Country gaps</button><button class="tab" data-view="relations">04 / Associations</button><button class="tab" data-view="quality">05 / Data quality</button><button class="tab" data-view="brief">06 / Research brief</button></nav>
21
+ <section class="view active" id="view-overview"><div class="section-head"><div><span class="overline">EXECUTIVE OVERVIEW</span><h2>Education landscape</h2></div><span class="section-meta" id="overview-meta"></span></div><div class="card plot-card"><div class="chart-title"><h3>Latest reported observation by country</h3><small>Each bar retains its reporting year. Ranking is not same-year.</small></div><div id="rank-bars"></div></div><div class="card table-card"><div class="chart-title"><h3>Source-aware ranking</h3><small>Values may originate from different years.</small></div><div id="rank-table"></div></div></section>
22
+ <section class="view" id="view-trends"><div class="section-head"><div><span class="overline">LONGITUDINAL ANALYSIS</span><h2>Education over time</h2></div></div><div class="card plot-card"><div class="chart-title"><h3 id="trend-title">Indicator trajectories</h3><small>Series gaps are shown as breaks, not interpolated.</small></div><div id="line-chart"></div><div id="legend" class="legend"></div></div><div id="trend-notes" class="card notes-card"></div></section>
23
+ <section class="view" id="view-gaps"><div class="section-head"><div><span class="overline">CROSS-COUNTRY DIAGNOSTICS</span><h2>Differences and progress</h2></div></div><div class="card plot-card"><div class="chart-title"><h3>Two-country comparison</h3><small>Differences in indicator units, not inequality within a country.</small></div><div class="input-grid three"><div><label for="gap-a">Country A</label><select id="gap-a"></select></div><div><label for="gap-b">Country B</label><select id="gap-b"></select></div><div><label for="gap-year">Compare year</label><input id="gap-year" type="number" min="1990" max="2026" value="2020"/></div></div><div id="gap-result"></div></div><div id="progress-table" class="card table-card"></div></section>
24
+ <section class="view" id="view-relations"><div class="section-head"><div><span class="overline">EXPLORATORY STATISTICS</span><h2>Indicator relationships</h2></div></div><div class="card plot-card"><div class="chart-title"><h3>Same-country, same-year pairs</h3><small>Pearson r describes linear association, not causality.</small></div><div id="scatter-chart"></div><div id="relationship-meta" class="analysis-notes"></div></div></section>
25
+ <section class="view" id="view-quality"><div class="section-head"><div><span class="overline">DATA GOVERNANCE</span><h2>Coverage, lag & provenance</h2></div></div><div class="card table-card"><div class="chart-title"><h3>Coverage matrix</h3><small>Observed years divided by selected years, per country and indicator.</small></div><div id="coverage-matrix"></div></div><div class="card notes-card" id="source-audit"></div></section>
26
+ <section class="view" id="view-brief"><div class="section-head"><div><span class="overline">REPRODUCIBLE REPORTING</span><h2>Research brief generator</h2></div></div><div class="card brief-card"><p class="hint">Descriptive observations are generated from your selected dataset. Review all conclusions before sharing.</p><div id="brief-text"></div><div class="export-actions"><button id="export-md" class="btn primary">↓ Research brief (.md)</button><button id="export-json" class="btn outline">↓ Reproducible session (.json)</button><button id="import-json" class="btn subtle">↑ Import prior session</button><input type="file" id="import-file" accept=".json,application/json" hidden/></div></div></section>
27
+ </main></div>
28
+ <footer>Education Data Lab · Applied AI & Comparative Education · <span id="footer-source">SYNTHETIC DEMO, NOT FOR CITATION</span><br/>Methods and definitions in the public repository. No personal data leaves this browser except selected public-data API queries.</footer></div>
29
+ <script type="module" src="app.js"></script>
30
+ </body></html>
package.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"name":"education-data-lab","version":"1.0.0","private":true,"type":"module","scripts":{"test":"node --test tests.mjs"},"description":"Static comparative education research dashboard with reproducible World Bank data ingestion"}
style.css CHANGED
@@ -1,28 +1 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
1
+ :root{--navy:#091a26;--ink:#132b38;--muted:#5d7380;--teal:#097d79;--tealbright:#65dbbf;--sky:#76c7df;--line:#d9e5e9;--card:#fff;--bg:#f3f8f8;--warn:#93590d;--warning:#fff4dd;--red:#ad3e38;--redbg:#fff2f0;--shadow:0 12px 38px rgba(21,60,68,.055)}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--ink);font-size:14px;line-height:1.52}button,select,input{font:inherit}button{cursor:pointer}button:disabled{opacity:.55;cursor:wait}.top{background:#061720;color:white;border-bottom:1px solid #193640}.top-inner,.wrap{max-width:1280px;padding-left:30px;padding-right:30px;margin:auto}.top-inner{display:flex;align-items:center;justify-content:space-between;min-height:66px;gap:14px}.brand,.top-right{display:flex;align-items:center;gap:15px}.brand b{display:block;letter-spacing:.16em;font-size:12px}.brand small{color:#9cb9bc;font-size:11px;letter-spacing:.035em}.mark{font-size:21px;color:var(--tealbright);border:1px solid #236c69;width:49px;height:40px;border-radius:10px;display:grid;place-items:center;font-weight:850}.mark span{color:white}.mini{font-size:10px;letter-spacing:.12em;color:#8baeb0}.mode-pill{padding:8px 10px;font-size:10px;letter-spacing:.13em;border:1px solid #3f626d;border-radius:30px;font-weight:800}.mode-pill.demo{background:#553d19;color:#ffe5aa;border-color:#8b6c35}.mode-pill.live{color:#90f1dd;background:#0e3937;border-color:#277b74}.mode-pill.imported{color:#b3d7fa;background:#153f56}.hero{background:radial-gradient(ellipse at 75% 20%,#175b5c 0,#0a3442 45%,#08202d 93%);color:white;min-height:245px;position:relative;overflow:hidden}.hero:before{content:'';position:absolute;right:8%;top:-145px;width:600px;height:600px;border:1px solid rgba(148,214,210,.12);border-radius:50%;box-shadow:0 0 0 75px rgba(148,214,210,.03),0 0 0 150px rgba(148,214,210,.025);pointer-events:none}.hero-grid{position:relative;display:grid;grid-template-columns:minmax(0,2fr) minmax(220px,.8fr);align-items:center;gap:40px;padding-top:36px;padding-bottom:36px}.eyebrow,.overline{color:#75dccc;font-weight:850;font-size:10px;letter-spacing:.17em}.hero h1{font-size:clamp(30px,3.5vw,49px);font-weight:850;letter-spacing:-.045em;line-height:1.13;margin:13px 0 13px}.hero h1 em{font-style:normal;color:#7de0c5}.hero p{max-width:700px;color:#cee1e3;font-size:13px;margin:0 0 18px}.hero-tags{display:flex;flex-wrap:wrap;gap:8px}.hero-tags span{font-size:10px;letter-spacing:.03em;border:1px solid rgba(177,220,221,.4);padding:5px 9px;border-radius:30px;color:#d9edef}.hero-side{background:rgba(6,23,32,.55);border:1px solid #38777b;padding:19px 23px;border-radius:16px;max-width:320px;backdrop-filter:blur(2px)}.hero-number{font-size:40px;font-weight:800;line-height:1.1;letter-spacing:-.05em}.hero-number span,.hero-side-row span{display:block;font-size:10px;letter-spacing:.08em;font-weight:550;color:#9bc4c9}.hero-divider{border-top:1px solid #315762;margin:16px 0}.hero-side-row{display:flex;gap:40px}.hero-side-row>div{font-size:25px;font-weight:800}.alert{margin:24px 0 16px;padding:12px 17px;border-radius:11px;font-size:12px;border:1px solid #e6c78a;background:var(--warning);color:#725010;font-weight:700}.alert.live{border-color:#a4dfcf;background:#e5fbf3;color:#075d51}.alert.imported{border-color:#b4d6e8;background:#ebf6ff;color:#275675}.alert.error{border-color:#ecc3bc;background:var(--redbg);color:var(--red)}.layout{display:grid;grid-template-columns:255px minmax(0,1fr);gap:17px;align-items:start}.card{background:white;border:1px solid var(--line);box-shadow:var(--shadow);border-radius:14px}.controls{padding:19px;position:sticky;top:13px}.controls-title{font-size:10px;letter-spacing:.13em;font-weight:900;color:var(--teal);border-bottom:1px solid var(--line);padding:0 0 14px;margin-bottom:14px}.controls label,.view label{display:flex;justify-content:space-between;font-size:11px;font-weight:750;margin:13px 0 6px}.controls label small{font-weight:400;color:#789099}.controls select,.controls input,.view select,.view input{width:100%;border:1px solid #cadcdf;background:#fafdfd;color:#203b45;border-radius:9px;padding:9px 10px;min-height:40px;outline-offset:3px}.controls select[multiple]{height:158px;padding:8px 7px}.controls select[multiple] option{padding:3px 6px}.controls select:focus,.controls input:focus,.view input:focus,.view select:focus{outline:2px solid #51bbae}.hint{font-size:11px;color:var(--muted);line-height:1.55}.note{background:#f0f7f6;border-left:3px solid #69bbb0;padding:10px;margin-top:16px}.input-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.input-grid.three{grid-template-columns:repeat(3,minmax(0,1fr));margin:14px 0 21px}.btn-stack{display:grid;gap:8px;margin-top:18px}.btn{border:1px solid transparent;border-radius:9px;padding:10px 12px;font-size:11px;font-weight:750;white-space:normal;line-height:1.3;transition:transform .1s}.btn:hover:not(:disabled){transform:translateY(-1px)}.btn.primary{background:#067e77;color:white}.btn.subtle{background:#e9f2f3;color:#23515a}.btn.outline{background:white;border-color:#b8d1d2;color:#27666b}.filter-help{font-size:11px;color:#526a76;border-top:1px solid var(--line);margin-top:15px;padding-top:15px}.filter-help summary{font-weight:750;cursor:pointer}.status-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;gap:12px}.status-row b{font-size:14px}.status-row p{margin:3px 0 0;font-size:11px;color:var(--muted)}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-bottom:18px}.metric{background:white;border:1px solid var(--line);border-radius:11px;padding:16px 17px;box-shadow:var(--shadow)}.metric span{display:block;font-size:10px;color:#67818a;letter-spacing:.08em;font-weight:750}.metric strong{display:block;font-size:27px;letter-spacing:-.055em;line-height:1.4;color:#0f3844}.metric small{color:#72909a;font-size:10px}.tabs{display:flex;gap:4px;overflow-x:auto;border-bottom:1px solid #d0e0e1;margin:0 0 19px;scrollbar-width:thin}.tab{padding:11px 12px;border:0;background:none;border-bottom:3px solid transparent;color:#637d85;font-size:11px;white-space:nowrap;font-weight:700}.tab.active{border-bottom-color:#0b9d8d;color:#087c76;background:#eaf7f4}.view{display:none}.view.active{display:block}.section-head{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:12px}.section-head h2{font-size:22px;margin:2px 0;font-weight:850;letter-spacing:-.035em}.overline{color:#00877d}.section-meta{font-size:11px;color:var(--muted)}.plot-card,.table-card,.notes-card,.brief-card{padding:20px;margin-bottom:14px}.chart-title{margin-bottom:13px}.chart-title h3{font-size:15px;margin:0 0 2px}.chart-title small{font-size:10px;color:var(--muted)}.rank-row{display:grid;grid-template-columns:130px minmax(90px,1fr) 120px;gap:13px;align-items:center;margin:12px 0}.rank-label{font-size:11px;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track{height:10px;background:#e6f1f0;border-radius:10px;overflow:hidden}.fill{height:100%;background:linear-gradient(90deg,#0a9b83,#65d3bc);border-radius:10px}.rank-value{text-align:right;font-size:11px;font-weight:700}.rank-value small{display:block;color:#7b9399;font-size:9px;font-weight:450}.scrolltable{overflow-x:auto;width:100%}table{border-collapse:collapse;width:100%;font-size:11px}th{text-align:left;color:#50717c;font-size:10px;letter-spacing:.03em;border-bottom:1px solid #c7dedf;padding:9px 8px;white-space:nowrap}td{border-bottom:1px solid #e6f0f0;padding:10px 8px;vertical-align:top}tbody tr:nth-child(even){background:#f9fcfc}td.num{text-align:right;font-variant-numeric:tabular-nums}th.num{text-align:right}.tag{background:#e6f7f3;color:#17695f;padding:3px 6px;border-radius:5px;font-size:10px}.empty{border:1px dashed #b5d3d0;background:#f8fcfc;text-align:center;color:#6a858a;padding:28px;border-radius:11px}.plot-svg{width:100%;height:auto;display:block}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:10px;padding:10px 0 0}.legend i{width:10px;height:10px;display:inline-block;border-radius:50%;margin-right:5px}.analysis-notes,.data-notes{font-size:11px;color:#42616b;background:#f1f8f8;border-radius:9px;margin-top:14px;padding:13px}.notes-card h3{margin:0 0 8px;font-size:14px}.notes-card p{font-size:11px;color:#496875}.numberline{padding:15px;border-radius:10px;background:#f0f7f6;margin:9px 0;font-weight:700;font-size:14px}.numberline strong{font-size:26px;color:#087d78}.muted{color:var(--muted)}.matrix{border-collapse:separate;border-spacing:3px;width:100%}.matrix td,.matrix th{text-align:center;padding:5px 3px;border:0;font-size:10px}.matrix td:first-child{text-align:left;min-width:100px}.matrix-square{border-radius:4px;min-width:28px;white-space:nowrap}.matrix-square.missing{background:#f3e7e4;color:#b05249}.brief-card h3{font-size:16px}.brief-card p{color:#405f68}.brief-card li{margin:7px 0}.export-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:20px}.recommendation{border-left:3px solid #51c2b2;background:#f2faf7;padding:10px 14px;margin:12px 0;font-size:12px}footer{text-align:center;margin:30px 0 35px;font-size:10px;color:#6b8890}#line-chart{min-height:150px}svg text{font-family:inherit}svg .axis{stroke:#91adaf;stroke-width:1}svg .grid{stroke:#dfeaea;stroke-width:1;stroke-dasharray:4 5}svg .chart-tick{fill:#69858b;font-size:10px}.swatch{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px}.status-ok{color:#087568;font-weight:700}.status-nodata{color:#ae6560;font-weight:650}@media(max-width:1100px){.layout{grid-template-columns:235px minmax(0,1fr)}.hero-grid{grid-template-columns:1.65fr .8fr}.tabs .tab{padding-left:9px;padding-right:9px}}@media(max-width:760px){.top-inner,.wrap{padding-left:15px;padding-right:15px}.top-right .mini{display:none}.hero-grid{grid-template-columns:1fr;gap:15px;padding-top:26px;padding-bottom:25px}.hero-side{max-width:none;display:flex;align-items:center;gap:15px}.hero-divider{height:40px;border-top:0;border-left:1px solid #315762;margin:0}.hero-number{font-size:30px}.hero-side-row{gap:20px}.hero-side-row>div{font-size:19px}.layout{display:block}.controls{position:static;margin-bottom:16px}.controls select[multiple]{height:113px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.status-row{align-items:flex-start}.status-row .btn{max-width:138px}.rank-row{grid-template-columns:90px minmax(70px,1fr) 91px;gap:7px}.plot-card,.table-card,.brief-card,.notes-card{padding:14px}.section-head h2{font-size:19px}.input-grid.three{grid-template-columns:1fr 1fr}.input-grid.three>div:last-child{grid-column:span 2}.alert{margin:15px 0}.hero h1{font-size:33px}.brand b{font-size:10px}.brand small{font-size:9px}.top-inner{min-height:60px}.mode-pill{font-size:9px;padding:6px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests.mjs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';import assert from 'node:assert/strict';
2
+ import {COUNTRIES,IDS,normalizeRows,validYear,finite,latest,paired,pearson,growth,change,coverage,status,formatValue,escapeHTML,csvCell,toCSV,validateSession,timeline,compareCountries,crossCountryGap} from './core.js';
3
+ import {makeWorldBankURL,parseWorldBank} from './worldbank.js';import {createDemo} from './demo-data.js';
4
+ const A='SE.SEC.ENRR',B='IT.NET.USER.ZS';const rows=normalizeRows([
5
+ {iso3:'ARG',year:2020,indicator:A,value:100},{iso3:'ARG',year:2021,indicator:A,value:101},{iso3:'BOL',year:2020,indicator:A,value:80},
6
+ {iso3:'BOL',year:2021,indicator:A,value:82},{iso3:'ARG',year:2020,indicator:B,value:77},{iso3:'ARG',year:2021,indicator:B,value:80},
7
+ {iso3:'BOL',year:2020,indicator:B,value:45},{iso3:'BOL',year:2021,indicator:B,value:49}]);
8
+ const mk=(iso3,year,indicator,value)=>({iso3,year,indicator,value});
9
+ test('country registry is unique and contains Bolivia and Paraguay',()=>{assert.equal(COUNTRIES.BOL,'Bolivia');assert.equal(COUNTRIES.PRY,'Paraguay');assert.equal(new Set(Object.keys(COUNTRIES)).size,16);});
10
+ test('8 indicators supported',()=>assert.equal(IDS.length,8));
11
+ test('years are constrained',()=>{assert.equal(validYear(2025),true);assert.equal(validYear(1980),false);assert.equal(validYear(2030),false);});
12
+ test('finite excludes Infinity and NaN',()=>{assert.equal(finite(Infinity),false);assert.equal(finite(0),true)});
13
+ test('normalize filters unexpected countries',()=>assert.equal(normalizeRows([mk('FAK',2020,A,1)]).length,0));
14
+ test('normalize filters unknown indicators',()=>assert.equal(normalizeRows([mk('ARG',2020,'FAKE',1)]).length,0));
15
+ test('normalize filters out-of-range year',()=>assert.equal(normalizeRows([mk('ARG',1980,A,1)]).length,0));
16
+ test('normalize filters missing values',()=>assert.equal(normalizeRows([mk('ARG',2020,A,null)]).length,0));
17
+ test('normalize preserves valid zero',()=>assert.equal(normalizeRows([mk('ARG',2020,A,0)])[0].value,0));
18
+ test('normalize de-duplicates country-year-indicator',()=>assert.equal(normalizeRows([mk('ARG',2020,A,2),mk('ARG',2020,A,3)]).length,1));
19
+ test('normalize sorts years',()=>assert.equal(normalizeRows([mk('ARG',2024,A,2),mk('ARG',2014,A,3)])[0].year,2014));
20
+ test('latest returns newest observation',()=>assert.equal(latest(rows,'ARG',A,2020,2022).year,2021));
21
+ test('latest does not forward-fill',()=>assert.equal(latest(rows,'ARG',A,2022,2024),null));
22
+ test('pairing uses exactly matching year and country',()=>assert.equal(paired(rows,A,B,['ARG','BOL'],2020,2021).length,4));
23
+ test('pairing excludes unmatched years',()=>assert.equal(paired(rows,A,B,['ARG'],2022,2023).length,0));
24
+ test('pearson returns null for insufficient data',()=>assert.equal(pearson([{x:1,y:2}], 'x','y'),null));
25
+ test('pearson is +1 for perfect positive association',()=>assert.ok(Math.abs(pearson([{x:1,y:2},{x:2,y:4},{x:3,y:6}], 'x','y')-1)<1e-9));
26
+ test('pearson is -1 for negative association',()=>assert.ok(Math.abs(pearson([{x:1,y:3},{x:2,y:2},{x:3,y:1}], 'x','y')+1)<1e-9));
27
+ test('pearson returns null for constant variable',()=>assert.equal(pearson([{x:1,y:2},{x:1,y:3},{x:1,y:4}],'x','y'),null));
28
+ test('growth is percent change, not percentage point difference',()=>assert.ok(Math.abs(growth(50,55)-10)<1e-10));
29
+ test('growth avoids zero denominator',()=>assert.equal(growth(0,2),null));
30
+ test('change tracks first and last real years',()=>assert.deepEqual(change({year:2020,value:10},{year:2022,value:13}),{amount:3,from:2020,to:2022}));
31
+ test('coverage includes expected missing cells',()=>assert.deepEqual(coverage(rows,['ARG','BOL'],[A,B],2020,2022),{count:8,eligible:12,ratio:8/12}));
32
+ test('status computes average reporting lag',()=>assert.equal(status(rows,['ARG'],[A],2020,2023).meanLag,2));
33
+ test('format value attaches valid units',()=>assert.match(formatValue(10.1,A),/%/));
34
+ test('html is escaped',()=>assert.equal(escapeHTML('<script>&"'), '&lt;script&gt;&amp;&quot;'));
35
+ test('CSV escapes quotes and commas',()=>assert.equal(csvCell('a,"b"'),'"a,""b"""'));
36
+ test('CSV export includes metadata and no executable content',()=>{let data=toCSV(rows);assert.match(data,/observation_status/);assert.match(data,/Argentina/)});
37
+ test('session rejects wrong schema',()=>assert.throws(()=>validateSession({format:'no',rows}),/Invalid/));
38
+ test('session roundtrip loads supported rows',()=>assert.equal(validateSession({format:'edlab-session-v1',rows}).rows.length,8));
39
+ test('timeline groups countries',()=>assert.equal(timeline(rows,['ARG','BOL'],A,2020,2021).length,2));
40
+ test('rank sorts by latest value',()=>assert.equal(compareCountries(rows,['ARG','BOL'],A,2020,2021)[0].country,'ARG'));
41
+ test('cross country gap exact year',()=>assert.equal(crossCountryGap(rows,'ARG','BOL',A,2020,2021,2020).delta,20));
42
+ test('cross country gap missing year returns null',()=>assert.equal(crossCountryGap(rows,'ARG','BOL',A,2020,2023,2022),null));
43
+ test('API URL uses WDI, JSON and selected economies',()=>{const u=makeWorldBankURL(['ARG','BOL'],[A,B],2010,2023);assert.match(u,/source=2/);assert.match(u,/format=json/);assert.match(u,/ARG;BOL/);assert.match(u,/2010:2023/)});
44
+ test('API parser preserves valid observation and drops null',()=>{let p=[{pages:1},[{countryiso3code:'ARG',indicator:{id:A},date:'2020',value:93,footnote:[{value:'caution'}]},{countryiso3code:'BOL',indicator:{id:A},date:'2020',value:null}]];const result=parseWorldBank(p);assert.equal(result.length,1);assert.equal(result[0].value,93);assert.equal(result[0].footnote,'caution');});
45
+ test('API parser rejects HTML/unknown formats',()=>assert.throws(()=>parseWorldBank({error:'fail'}),/Unexpected/));
46
+ test('synthetic demo covers multiple economies and indicators',()=>{const d=createDemo();assert.ok(d.length>1000);assert.ok(d.every(r=>COUNTRIES[r.iso3]&&IDS.includes(r.indicator)));});
47
+ test('synthetic demo is deterministic across loads',()=>assert.deepEqual(createDemo(),createDemo()));
48
+ test('CSV neutralizes spreadsheet formula injection in string fields',()=>assert.ok(csvCell('=SUM(1,2)').includes("'=SUM")));
worldbank.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {COUNTRIES, INDICATORS, normalizeRows} from './core.js';
2
+ // World Bank Indicators API v2, WDI source 2. One request for supported indicators.
3
+ export function makeWorldBankURL(countries,ids,start,end){
4
+ if(!countries.length||!ids.length)throw new Error('Choose countries and indicators');
5
+ const iso=countries.filter(c=>COUNTRIES[c]).join(';');const indicators=ids.filter(id=>INDICATORS[id]).join(';');
6
+ const base=`https://api.worldbank.org/v2/country/${iso}/indicator/${indicators}`;
7
+ return base+`?source=2&date=${start}:${end}&format=json&per_page=20000`;
8
+ }
9
+ export function parseWorldBank(payload){
10
+ if(!Array.isArray(payload)||!Array.isArray(payload[1]))throw new Error('Unexpected World Bank API response; try fewer countries or later.');
11
+ const objects=payload[1];
12
+ return normalizeRows(objects.map(o=>({
13
+ iso3:String(o.countryiso3code||'').toUpperCase(),year:Number(o.date),indicator:o.indicator?.id,value:o.value,
14
+ footnote:Array.isArray(o.footnote)?o.footnote.map(x=>x.value||'').join(' | '):'',status:o.obs_status||''
15
+ })));
16
+ }
17
+ export async function requestWorldBank(countries,ids,start,end,signal){const url=makeWorldBankURL(countries,ids,start,end);const resp=await fetch(url,{signal,headers:{Accept:'application/json'}});if(!resp.ok)throw new Error(`World Bank HTTP ${resp.status}`);const data=await resp.json();const rows=parseWorldBank(data);if(!rows.length)throw new Error('The World Bank returned no usable values for these filters. Try other countries or dates.');return {rows,url};}