diff --git a/.gitignore b/.gitignore index 24ed50878..2f757c433 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,14 @@ dist **/.ipynb_checkpoints .idea .DS_Store.firebase/*.cache +.firebase/ +.firebase/*.cache **/.DS_Store test-results/ lib/ .env .idea/ +.vscode/ +task-launcher/.venv/ +task-launcher/data/ +task-launcher/firestore-debug.log diff --git a/README.md b/README.md index c223252a1..09a3ecc26 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,38 @@ npm run dev You can now locally run tasks e.g. TROG `http://localhost:8080/?task=trog`. Task parameters are documented here (TODO linkme). +### Location Selection Save Debug (Emulator) + +For testing location saves against the Firebase emulators: + +1. Start the emulators from the functions repo (auth `9290`, functions `5005`, firestore `8185`, UI `4002`). +2. Open the task with debug save enabled: + `http://localhost:8080/?task=locationselection&locationSaveDebug=true` +3. Click **Save** and confirm a `locations` doc appears in the Emulator UI. + +### Kontur Population Cache + +The population lookup uses a local Kontur cache if available, otherwise it falls back to WorldPop. +You can point the dev server at a compressed, sparse Kontur cache stored elsewhere (e.g. GCS) by +setting one of these environment variables before starting `npm run dev`. The cache is sharded +by R5 parent cell, so the URL/path should be a *folder* containing `{r5CellId}.json.gz` files: + +- `KONTUR_H3_CACHE_URL` (base URL; supports `.gz` shards) +- `KONTUR_H3_CACHE_PATH` (base folder for local shards) + +#### Build the R5 shard cache + +We provide a repeatable script to download the Kontur dataset and build R5 shards: + +```bash +cd /home/david/levante/core-tasks/task-launcher +pip install h3 pyarrow +python scripts/build_kontur_r5_shards.py --download --gzip --output data/kontur-h3-r5 +``` + +This uses the latest 400m Kontur dataset from HDX and requires `ogr2ogr` (GDAL) to convert +the GeoPackage into Parquet/CSV for streaming. + Task details: 1. [Matrix Reasoning](https://hs-levante-assessment-dev.web.app/?task=matrix-reasoning) [George] diff --git a/task-launcher/.firebase/hosting.ZGlzdA.cache b/task-launcher/.firebase/hosting.ZGlzdA.cache deleted file mode 100644 index 6e1f48ddd..000000000 --- a/task-launcher/.firebase/hosting.ZGlzdA.cache +++ /dev/null @@ -1,22 +0,0 @@ -npm.bdelab.b6553285b766506d4db5.bundle.js,1722533652525,2d96480fb8658650b3fe94e8d0286fd5d024119053b12031c602306c390c6604 -npm.firebase.6b32e8187203c686e4b2.bundle.js,1722533652528,b5d94cf2d6f439a3e9e1fb43598e89223536b5828d4ad7d4db6eb5c4bfa77c7a -npm.firebase.6b32e8187203c686e4b2.bundle.js.LICENSE.txt,1722533652525,4facc61d515b997f114bfd9357915a2a5054e55a5cd437f8506a162972c52462 -npm.i18next.b0c0bd6865a29e21ac76.bundle.js,1726598233268,e74d8657bd930e6c5a2b6b792009f704ed950a50f1fe3e67b52269649e07ea63 -npm.jspsych-contrib.f6c74028b169a83d4351.bundle.js,1733854695144,3199fd713a82115e6ceee2c4323dd13299867e6e11b40f7fc812573f6c641aec -npm.lodash.f0b5bb3a2760e06abc17.bundle.js,1722533652528,937377b47de2d4b66281e97899301b645149b6d51256f908be54265de34db3a2 -npm.lodash.f0b5bb3a2760e06abc17.bundle.js.LICENSE.txt,1722533652525,e4189bf402a38a846ec375b1e65623511a1cff5534831bf64fe5564e12819e17 -npm.mime-db.6d1b18fe3806282a717a.bundle.js,1722533652528,9a44d22bef26270890f9e15407927a028b97f7988d445e115858f4643b11324d -npm.mime-db.6d1b18fe3806282a717a.bundle.js.LICENSE.txt,1722533652525,ccebece32d012b87132843042aff3330b815143c26ae6139d855d8ff32dbaf78 -npm.optimization-js.bb90cc2a952c02e153ba.bundle.js,1722533652525,ff1d6686ae23246b064f8d3d4d4d8793113efb40998ef61042e33f3c516744d6 -npm.regenerator-runtime.23db65d00e22e8c7aeaf.bundle.js,1722533652525,c96f1393f54fb03c5cc95d6e0ac7d94da21d8149ff8565f2c64f4c39e549513f -npm.seedrandom.d986754f3cc49359baa8.bundle.js,1722533652525,e5640b055d54dc04413e6a6756f1dc2d26c8598565f5862dfb47786208be4152 -npm.sentry-internal.bbae1cf39156d54cc56f.bundle.js,1722533652525,56645c017a494ba6d63ad49c036fa14830ab6b8139bcb5813b2454c292426192 -npm.sentry.fac9ab9a8e0fe3d8122c.bundle.js,1722533652525,18698036f54a331fd41b960d4639e924adc418b16b3a8d6f28b6b079ac2af8e3 -npm.vue.fe7a35d6a11f146ff855.bundle.js,1722533652528,7765107d56b64d324ca9146476da779c801fbe51663bf84553122f313f15c082 -npm.vue.fe7a35d6a11f146ff855.bundle.js.LICENSE.txt,1722533652528,84cf056599808ada84a456250d570c969a6a1472b547d2e0c0cacb6432d42865 -runtime.0ab4a229aba0b5c5cc38.bundle.js,1733183801845,9a6c0d9d89eadcbb960d28ea7f29b194692bc2c094d4cd3f1bda7a15a1a246d0 -img/levante-background..png,1733183801845,e500a8c007bd34b2683d218265b0820a38a1ea72d73f7b3f08678a1689f4434e -index.html,1734464710407,d375ef97413df77ec645ac0101ced3ceee0bbcf3947223ecc5cc97cb489d4c10 -index.1df0b721d4b077dc4c6d.bundle.js.LICENSE.txt,1734464710406,f63d602f34796886bb91d302239771de3638cd0b9a251b516d4a6662efda3a49 -npm.jspsych.69ed24913b56ee675cb1.bundle.js,1734464710406,e36008fc9f86913ae1dcfe6f2539d8d152c69f561d7daef3e3cdd9a72e5de4fb -index.1df0b721d4b077dc4c6d.bundle.js,1734464710406,f262bbaef581916cb2e21b654af1eabace52cd784ea79cad2e6917e46164dba4 diff --git a/task-launcher/.gitignore b/task-launcher/.gitignore index 37e71335d..92eb73163 100644 --- a/task-launcher/.gitignore +++ b/task-launcher/.gitignore @@ -1,3 +1,6 @@ +.venv/ +data/ +firestore-debug.log node_modules dist **/.Rhistory diff --git a/task-launcher/firebase.json b/task-launcher/firebase.json index 8a2d96e80..c2b359735 100644 --- a/task-launcher/firebase.json +++ b/task-launcher/firebase.json @@ -7,15 +7,15 @@ "emulators": { "auth": { "host": "127.0.0.1", - "port": 9199 + "port": 9290 }, "firestore": { "host": "127.0.0.1", - "port": 8180 + "port": 8185 }, "functions": { "host": "127.0.0.1", - "port": 5002 + "port": 5005 }, "ui": { "host": "127.0.0.1", diff --git a/task-launcher/package-lock.json b/task-launcher/package-lock.json index 7093fed59..4a60124b4 100644 --- a/task-launcher/package-lock.json +++ b/task-launcher/package-lock.json @@ -23,6 +23,7 @@ "@sentry/browser": "^8.7.0", "cypress-real-events": "^1.13.0", "fscreen": "^1.2.0", + "h3-js": "^4.4.0", "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", "jspsych": "^7.2.1", @@ -7867,29 +7868,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -9609,6 +9587,17 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", diff --git a/task-launcher/package.json b/task-launcher/package.json index ca060183d..86908032b 100644 --- a/task-launcher/package.json +++ b/task-launcher/package.json @@ -52,6 +52,7 @@ "@sentry/browser": "^8.7.0", "cypress-real-events": "^1.13.0", "fscreen": "^1.2.0", + "h3-js": "^4.4.0", "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", "jspsych": "^7.2.1", diff --git a/task-launcher/scripts/build_kontur_r5_shards.py b/task-launcher/scripts/build_kontur_r5_shards.py new file mode 100644 index 000000000..88ddb89a1 --- /dev/null +++ b/task-launcher/scripts/build_kontur_r5_shards.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +import argparse +import csv +import gzip +import json +import os +import shutil +import subprocess +import sys +import urllib.request +from collections import OrderedDict + +try: + import h3 +except ImportError as exc: + raise SystemExit("Missing dependency: pip install h3") from exc + +try: + import pyarrow.dataset as ds +except ImportError: + ds = None + + +DEFAULT_DATASET_URL = ( + "https://geodata-eu-central-1-kontur-public.s3.eu-central-1.amazonaws.com/" + "kontur_datasets/kontur_population_20231101.gpkg.gz" +) + + +def download_file(url: str, dest_path: str) -> None: + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with urllib.request.urlopen(url) as response, open(dest_path, "wb") as out_file: + shutil.copyfileobj(response, out_file) + + +def gunzip_file(src_path: str, dest_path: str) -> None: + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with gzip.open(src_path, "rb") as src, open(dest_path, "wb") as dest: + shutil.copyfileobj(src, dest) + + +def ensure_tabular_from_gpkg(gpkg_path: str, parquet_path: str, csv_path: str) -> tuple[str, str]: + if os.path.exists(parquet_path): + return "parquet", parquet_path + if os.path.exists(csv_path): + return "csv", csv_path + ogr2ogr = shutil.which("ogr2ogr") + if not ogr2ogr: + raise SystemExit("ogr2ogr not found; install GDAL to convert GPKG.") + os.makedirs(os.path.dirname(parquet_path), exist_ok=True) + if ds is not None: + cmd = [ + ogr2ogr, + "-f", + "Parquet", + parquet_path, + gpkg_path, + "-select", + "h3,population", + ] + try: + subprocess.check_call(cmd) + return "parquet", parquet_path + except subprocess.CalledProcessError: + pass + cmd = [ + ogr2ogr, + "-f", + "CSV", + csv_path, + gpkg_path, + "-select", + "h3,population", + ] + subprocess.check_call(cmd) + return "csv", csv_path + + +def iter_rows_from_parquet(parquet_path: str): + if ds is None: + raise SystemExit("pyarrow not available for parquet parsing.") + dataset = ds.dataset(parquet_path, format="parquet") + for batch in dataset.to_batches(columns=["h3", "population"]): + h3_col = batch.column(0).to_pylist() + pop_col = batch.column(1).to_pylist() + for h3_id, pop in zip(h3_col, pop_col): + yield h3_id, pop + + +def iter_rows_from_csv(csv_path: str): + with open(csv_path, "r", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row in reader: + yield row.get("h3"), row.get("population") + + +def merge_into(existing: dict, incoming: dict) -> dict: + for res, cells in incoming.items(): + res_map = existing.setdefault(res, {}) + for cell_id, pop in cells.items(): + res_map[cell_id] = res_map.get(cell_id, 0) + pop + return existing + + +def flush_shard(output_dir: str, r5_cell_id: str, shard_data: dict, gzip_output: bool) -> None: + os.makedirs(output_dir, exist_ok=True) + filename = f"{r5_cell_id}.json.gz" if gzip_output else f"{r5_cell_id}.json" + output_path = os.path.join(output_dir, filename) + existing = {} + if os.path.exists(output_path): + if gzip_output: + with gzip.open(output_path, "rt", encoding="utf-8") as fh: + existing = json.load(fh) + else: + with open(output_path, "r", encoding="utf-8") as fh: + existing = json.load(fh) + merged = merge_into(existing.get("resolutions", {}), shard_data) + payload = {"resolutions": merged} + if gzip_output: + with gzip.open(output_path, "wt", encoding="utf-8") as fh: + json.dump(payload, fh, separators=(",", ":")) + else: + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, separators=(",", ":")) + + +def build_shards( + input_path: str, + input_format: str, + output_dir: str, + resolutions: list[int], + max_shards: int, + gzip_output: bool, +) -> None: + shard_cache: OrderedDict[str, dict] = OrderedDict() + + def get_shard(r5_cell_id: str) -> dict: + if r5_cell_id in shard_cache: + shard_cache.move_to_end(r5_cell_id) + return shard_cache[r5_cell_id] + if len(shard_cache) >= max_shards: + oldest_r5, oldest_data = shard_cache.popitem(last=False) + flush_shard(output_dir, oldest_r5, oldest_data, gzip_output) + shard_cache[r5_cell_id] = {str(res): {} for res in resolutions} + return shard_cache[r5_cell_id] + + if input_format == "parquet": + row_iter = iter_rows_from_parquet(input_path) + else: + row_iter = iter_rows_from_csv(input_path) + + for h3_id, pop in row_iter: + if h3_id is None or pop is None: + continue + try: + pop_val = float(pop) + except (TypeError, ValueError): + continue + if pop_val <= 0: + continue + try: + base_resolution = h3.get_resolution(h3_id) + except Exception: + continue + try: + r5_cell = h3.cell_to_parent(h3_id, 5) + except Exception: + continue + + shard = get_shard(r5_cell) + for res in resolutions: + if res > base_resolution: + continue + try: + parent = h3.cell_to_parent(h3_id, res) + except Exception: + continue + res_map = shard[str(res)] + res_map[parent] = res_map.get(parent, 0) + pop_val + + for r5_cell, data in shard_cache.items(): + flush_shard(output_dir, r5_cell, data, gzip_output) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build R5-sharded Kontur H3 population cache.") + parser.add_argument("--input", help="Input Parquet file with h3,population columns.") + parser.add_argument("--output", default="data/kontur-h3-r5", help="Output shard directory.") + parser.add_argument("--resolutions", default="5,6,7", help="Comma-separated resolutions to build.") + parser.add_argument("--max-shards", type=int, default=64, help="Max in-memory shard count.") + parser.add_argument("--download", action="store_true", help="Download and convert dataset.") + parser.add_argument("--gzip", action="store_true", help="Write .json.gz shards.") + args = parser.parse_args() + + if args.download: + raw_dir = os.path.join("data", "kontur", "raw") + os.makedirs(raw_dir, exist_ok=True) + gz_path = os.path.join(raw_dir, "kontur_population_20231101.gpkg.gz") + gpkg_path = os.path.join(raw_dir, "kontur_population_20231101.gpkg") + parquet_path = os.path.join(raw_dir, "kontur_population_20231101.parquet") + csv_path = os.path.join(raw_dir, "kontur_population_20231101.csv") + if not os.path.exists(gz_path): + print(f"Downloading {DEFAULT_DATASET_URL} ...") + download_file(DEFAULT_DATASET_URL, gz_path) + if not os.path.exists(gpkg_path): + print("Extracting .gpkg.gz ...") + gunzip_file(gz_path, gpkg_path) + input_format, input_path = ensure_tabular_from_gpkg(gpkg_path, parquet_path, csv_path) + else: + input_path = args.input + input_format = "parquet" if input_path and input_path.endswith(".parquet") else "csv" + + if not input_path or not os.path.exists(input_path): + raise SystemExit("Input Parquet file not found. Use --input or --download.") + + resolutions = [int(x.strip()) for x in args.resolutions.split(",") if x.strip()] + build_shards( + input_path=input_path, + input_format=input_format, + output_dir=args.output, + resolutions=resolutions, + max_shards=max(args.max_shards, 1), + gzip_output=args.gzip, + ) + print(f"Shards written to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/task-launcher/serve/populationApi.cjs b/task-launcher/serve/populationApi.cjs new file mode 100644 index 000000000..b3d7afff5 --- /dev/null +++ b/task-launcher/serve/populationApi.cjs @@ -0,0 +1,387 @@ +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { cellToBoundary, cellToParent, getResolution } = require('h3-js'); + +const WORLDPOP_STATS_URL = 'https://api.worldpop.org/v1/services/stats'; +const WORLDPOP_TASK_URL = 'https://api.worldpop.org/v1/tasks'; + +const konturShardCache = new Map(); +const konturShardInFlight = new Map(); +const defaultShardBaseUrl = 'https://storage.googleapis.com/levante-assets-dev/maps/kontur-h3-r5'; +const defaultShardBasePath = path.resolve(process.cwd(), 'data', 'kontur-h3-r5'); + +function sendJson(res, statusCode, payload) { + res.status(statusCode).set('Content-Type', 'application/json').send(JSON.stringify(payload)); +} + +function parsePositiveInt(value, fallback) { + const n = Number(value); + if (Number.isFinite(n) && n >= 0) return Math.round(n); + return fallback; +} + +function parseResolution(value) { + const n = Number(value); + if (!Number.isInteger(n) || n < 0 || n > 15) return null; + return n; +} + +function parseCellIds(value) { + if (!value) return []; + return String(value) + .split(',') + .map((cellId) => String(cellId).trim()) + .filter((cellId) => cellId.length > 0); +} + +function buildCellPolygon(cellId) { + const boundary = cellToBoundary(cellId); + if (!Array.isArray(boundary) || !boundary.length) { + throw new Error('Invalid H3 cell boundary'); + } + const ring = boundary.map((pair) => [Number(pair[1]), Number(pair[0])]); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) { + ring.push([first[0], first[1]]); + } + return { + type: 'Polygon', + coordinates: [ring], + }; +} + +function getShardCacheLimit() { + const raw = Number(process.env.KONTUR_H3_CACHE_MAX_SHARDS || 64); + return Number.isFinite(raw) && raw > 0 ? Math.round(raw) : 64; +} + +function parseKonturShardJson(json, sourceLabel) { + const resolutions = json?.resolutions; + if (!resolutions || typeof resolutions !== 'object') return null; + return { resolutions, loadedFrom: sourceLabel }; +} + +async function loadKonturShardFromUrl(shardUrl) { + try { + const response = await fetch(shardUrl); + if (!response.ok) return null; + const buffer = Buffer.from(await response.arrayBuffer()); + const isGzip = + String(response.headers.get('content-encoding') || '').includes('gzip') + || shardUrl.endsWith('.gz'); + const jsonText = isGzip ? zlib.gunzipSync(buffer).toString('utf-8') : buffer.toString('utf-8'); + const json = JSON.parse(jsonText); + return parseKonturShardJson(json, shardUrl); + } catch (_err) { + return null; + } +} + +function getShardCacheKey(shardCellId) { + return `r5:${shardCellId}`; +} + +function getShardBaseUrl() { + const raw = String(process.env.KONTUR_H3_CACHE_URL || '').trim(); + return raw || defaultShardBaseUrl; +} + +function getShardBasePath() { + const raw = String(process.env.KONTUR_H3_CACHE_PATH || '').trim(); + return raw || defaultShardBasePath; +} + +function resolveShardUrl(shardCellId) { + const base = getShardBaseUrl(); + if (!base) return ''; + return `${base}/${shardCellId}.json.gz`; +} + +function resolveShardPath(shardCellId) { + const base = getShardBasePath(); + if (!base) return ''; + return path.resolve(base, `${shardCellId}.json`); +} + +async function loadKonturShard(shardCellId) { + const cacheKey = getShardCacheKey(shardCellId); + const existing = konturShardCache.get(cacheKey); + if (existing) { + konturShardCache.delete(cacheKey); + konturShardCache.set(cacheKey, existing); + return existing; + } + const inflight = konturShardInFlight.get(cacheKey); + if (inflight) return inflight; + + const loader = (async () => { + const shardUrl = resolveShardUrl(shardCellId); + if (shardUrl) { + const remote = await loadKonturShardFromUrl(shardUrl); + if (remote) return remote; + } + const shardPath = resolveShardPath(shardCellId); + if (shardPath && fs.existsSync(shardPath)) { + try { + const raw = fs.readFileSync(shardPath, 'utf-8'); + const json = JSON.parse(raw); + return parseKonturShardJson(json, shardPath); + } catch (_err) { + return null; + } + } + return null; + })(); + konturShardInFlight.set(cacheKey, loader); + try { + const loaded = await loader; + if (loaded) { + konturShardCache.set(cacheKey, loaded); + const limit = getShardCacheLimit(); + while (konturShardCache.size > limit) { + const oldestKey = konturShardCache.keys().next().value; + konturShardCache.delete(oldestKey); + } + } + return loaded; + } finally { + konturShardInFlight.delete(cacheKey); + } +} + +async function wait(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseWorldPopSum(payload) { + const candidates = [ + payload?.data?.total_population, + payload?.stats?.sum, + payload?.data?.stats?.sum, + payload?.result?.stats?.sum, + ]; + for (let i = 0; i < candidates.length; i += 1) { + const value = Number(candidates[i]); + if (Number.isFinite(value) && value >= 0) return value; + } + return null; +} + +function parseWorldPopTaskId(payload) { + const candidates = [payload?.taskid, payload?.taskId, payload?.task_id, payload?.id]; + for (let i = 0; i < candidates.length; i += 1) { + const value = String(candidates[i] || '').trim(); + if (value) return value; + } + return null; +} + +async function queryWorldPopForPolygon(polygon, year) { + const url = new URL(WORLDPOP_STATS_URL); + url.searchParams.set('dataset', 'wpgppop'); + url.searchParams.set('year', String(year)); + url.searchParams.set('geojson', JSON.stringify(polygon)); + url.searchParams.set('runasync', 'false'); + const firstResponse = await fetch(url.toString(), { method: 'GET' }); + if (!firstResponse.ok) { + throw new Error(`WorldPop stats request failed (${firstResponse.status})`); + } + const firstPayload = await firstResponse.json().catch(() => ({})); + const directSum = parseWorldPopSum(firstPayload); + if (typeof directSum === 'number') return directSum; + + const taskId = parseWorldPopTaskId(firstPayload); + if (!taskId) { + throw new Error('WorldPop response missing stats and task id'); + } + + for (let attempt = 0; attempt < 8; attempt += 1) { + const taskResponse = await fetch(`${WORLDPOP_TASK_URL}/${encodeURIComponent(taskId)}`); + if (!taskResponse.ok) { + throw new Error(`WorldPop task polling failed (${taskResponse.status})`); + } + const taskPayload = await taskResponse.json().catch(() => ({})); + const sum = parseWorldPopSum(taskPayload); + if (typeof sum === 'number') return sum; + + const status = String(taskPayload?.status || taskPayload?.state || '').toLowerCase(); + if (status.includes('failed') || status.includes('error')) { + throw new Error(`WorldPop task ${taskId} failed`); + } + await wait(600); + } + + throw new Error(`WorldPop task ${taskId} timed out`); +} + +async function resolveKonturPopulation(cellId, resolution) { + if (resolution < 5) return null; + let shardCellId = null; + try { + shardCellId = cellToParent(cellId, 5); + } catch (_err) { + return null; + } + const shard = await loadKonturShard(shardCellId); + const byRes = shard?.resolutions?.[String(resolution)]; + if (!byRes || typeof byRes !== 'object') return null; + const value = Number(byRes[cellId]); + if (!Number.isFinite(value) || value < 0) return null; + return Math.round(value); +} + +async function resolveKonturPopulationBatch(cellIds, fallbackToWorldpop) { + const items = []; + for (let i = 0; i < cellIds.length; i += 1) { + const cellId = cellIds[i]; + let resolution = null; + try { + resolution = getResolution(cellId); + } catch (_err) { + items.push({ + cellId, + resolution: null, + population: null, + source: 'unknown', + error: 'invalid cellId', + }); + continue; + } + const konturPopulation = await resolveKonturPopulation(cellId, resolution); + if (typeof konturPopulation === 'number') { + items.push({ + cellId, + resolution, + population: konturPopulation, + source: 'kontur', + }); + continue; + } + if (!fallbackToWorldpop) { + items.push({ + cellId, + resolution, + population: null, + source: 'unknown', + }); + continue; + } + try { + const polygon = buildCellPolygon(cellId); + const worldpopPopulation = await queryWorldPopForPolygon(polygon, 2020); + items.push({ + cellId, + resolution, + population: Math.round(worldpopPopulation), + source: 'worldpop', + fallbackFrom: 'kontur', + }); + } catch (error) { + items.push({ + cellId, + resolution, + population: null, + source: 'unknown', + error: error?.message || 'Unknown error', + }); + } + } + return items; +} + +function registerPopulationApi(app) { + app.get('/api/population-kontur-h3', async (req, res) => { + try { + const cellId = String(req.query?.cellId || '').trim(); + const resolution = parseResolution(req.query?.resolution); + const worldpopYear = parsePositiveInt(req.query?.year, 2020); + if (!cellId || resolution == null) { + sendJson(res, 400, { success: false, error: 'Missing/invalid cellId or resolution' }); + return; + } + + const konturPopulation = await resolveKonturPopulation(cellId, resolution); + if (typeof konturPopulation === 'number') { + let cachePath = null; + try { + cachePath = resolveShardUrl(cellToParent(cellId, 5)); + } catch (_err) { + cachePath = null; + } + sendJson(res, 200, { + success: true, + source: 'kontur', + population: konturPopulation, + resolution, + cellId, + cachePath, + }); + return; + } + + // Real-data fallback: use WorldPop when Kontur cache not available for this cell. + const polygon = buildCellPolygon(cellId); + const worldpopPopulation = await queryWorldPopForPolygon(polygon, worldpopYear); + sendJson(res, 200, { + success: true, + source: 'worldpop', + fallbackFrom: 'kontur', + population: Math.round(worldpopPopulation), + resolution, + cellId, + }); + } catch (error) { + sendJson(res, 500, { success: false, error: error?.message || 'Unknown error' }); + } + }); + + app.get('/api/population-kontur-h3-batch', async (req, res) => { + try { + const cellIds = parseCellIds(req.query?.cellIds); + if (!cellIds.length) { + sendJson(res, 400, { success: false, error: 'Missing cellIds' }); + return; + } + const fallback = String(req.query?.fallback || '').trim().toLowerCase(); + const fallbackToWorldpop = fallback === 'worldpop'; + const items = await resolveKonturPopulationBatch(cellIds, fallbackToWorldpop); + sendJson(res, 200, { + success: true, + source: 'kontur', + cachePath: getShardBaseUrl(), + items, + }); + } catch (error) { + sendJson(res, 500, { success: false, error: error?.message || 'Unknown error' }); + } + }); + + app.get('/api/population-worldpop-h3', async (req, res) => { + try { + const cellId = String(req.query?.cellId || '').trim(); + const resolution = parseResolution(req.query?.resolution); + const worldpopYear = parsePositiveInt(req.query?.year, 2020); + if (!cellId || resolution == null) { + sendJson(res, 400, { success: false, error: 'Missing/invalid cellId or resolution' }); + return; + } + const polygon = buildCellPolygon(cellId); + const population = await queryWorldPopForPolygon(polygon, worldpopYear); + sendJson(res, 200, { + success: true, + source: 'worldpop', + population: Math.round(population), + resolution, + cellId, + }); + } catch (error) { + sendJson(res, 500, { success: false, error: error?.message || 'Unknown error' }); + } + }); +} + +module.exports = { + registerPopulationApi, +}; diff --git a/task-launcher/serve/serve.js b/task-launcher/serve/serve.js index ac879bf27..9bf0f59c8 100644 --- a/task-launcher/serve/serve.js +++ b/task-launcher/serve/serve.js @@ -32,7 +32,13 @@ Sentry.init({ // TODO: Add game params for all tasks const queryString = new URL(window.location).search; const urlParams = new URLSearchParams(queryString); -const taskName = urlParams.get('task') ?? 'egma-math'; +const requestedTaskRaw = String(urlParams.get('task') || '').trim(); +const requestedTask = requestedTaskRaw.toLowerCase(); +const taskName = ( + requestedTask === 'locationselection' + ? 'location-selection' + : requestedTaskRaw || 'egma-math' +); const corpus = urlParams.get('corpus'); const buttonLayout = urlParams.get('buttonLayout'); const numOfPracticeTrials = urlParams.get('practiceTrials'); @@ -47,6 +53,12 @@ const inferenceNumStories = urlParams.get('inferenceNumStories') === null ? null : parseInt(urlParams.get('inferenceNumStories'), 10); const semThreshold = Number(urlParams.get('semThreshold') || '0'); const startingTheta = Number(urlParams.get('theta') || '0'); +const populationSourcePreference = String(urlParams.get('populationSourcePreference') || 'kontur').trim().toLowerCase(); +const konturPopulationApiUrl = urlParams.get('konturPopulationApiUrl') || undefined; +const konturPopulationBatchApiUrl = urlParams.get('konturPopulationBatchApiUrl') || undefined; +const worldpopPopulationApiUrl = urlParams.get('worldpopPopulationApiUrl') || undefined; +const populationApiTimeoutMs = + urlParams.get('populationApiTimeoutMs') === null ? undefined : parseInt(urlParams.get('populationApiTimeoutMs'), 10); // Boolean parameters const keyHelpers = stringToBoolean(urlParams.get('keyHelpers')); @@ -56,6 +68,7 @@ const sequentialStimulus = stringToBoolean(urlParams.get('sequentialStimulus'), const storeItemId = stringToBoolean(urlParams.get('storeItemId'), false); const cat = stringToBoolean(urlParams.get('cat'), false); const heavyInstructions = stringToBoolean(urlParams.get('heavyInstructions'), false); +const populationBatchEnabled = stringToBoolean(urlParams.get('populationBatch'), false); const emulatorConfig = EMULATORS ? firebaseJSON.emulators : undefined; // if running in demo mode, no data will be saved to Firestore @@ -63,6 +76,10 @@ const demoMode = DEMO; async function startWebApp() { const appKit = await initializeFirebaseProject(firebaseConfig, 'admin', emulatorConfig, 'none'); + if (typeof window !== 'undefined') { + window.__firebaseApp = appKit.firebaseApp; + window.__firebaseConfig = firebaseConfig; + } onAuthStateChanged(appKit.auth, (user) => { if (user) { @@ -95,6 +112,12 @@ async function startWebApp() { inferenceNumStories, semThreshold, startingTheta, + populationSourcePreference, + konturPopulationApiUrl, + konturPopulationBatchApiUrl, + worldpopPopulationApiUrl, + populationApiTimeoutMs, + populationBatchEnabled, heavyInstructions, demoMode, }; diff --git a/task-launcher/src/index.ts b/task-launcher/src/index.ts index c2185b2ff..7de679e99 100644 --- a/task-launcher/src/index.ts +++ b/task-launcher/src/index.ts @@ -73,16 +73,20 @@ export class TaskLauncher { await getTranslations(isDev, config.language); // TODO: make hearts and flowers corpus? make list of tasks that don't need corpora? - if (taskName !== 'hearts-and-flowers' && taskName !== 'memory-game' && taskName !== 'intro') { + if ( + taskName !== 'hearts-and-flowers' && + taskName !== 'memory-game' && + taskName !== 'intro' && + taskName !== 'location-selection' + ) { await getCorpus(config, isDev); } await getAssetsPerTask(isDev); - const taskAudioAssetNames = [ - ...taskStore().assetsPerTask[taskName].audio, - ...taskStore().assetsPerTask.shared.audio, - ]; + const taskAssetEntry = taskStore().assetsPerTask?.[taskName] || { audio: [] }; + const sharedAssetEntry = taskStore().assetsPerTask?.shared || { audio: [] }; + const taskAudioAssetNames = [...(taskAssetEntry.audio || []), ...(sharedAssetEntry.audio || [])]; // filter out language audio not relevant to current task languageAudioAssets = filterMedia(languageAudioAssets, [], taskAudioAssetNames, []); diff --git a/task-launcher/src/tasks/location-selection/helpers/config.ts b/task-launcher/src/tasks/location-selection/helpers/config.ts new file mode 100644 index 000000000..226f39d4a --- /dev/null +++ b/task-launcher/src/tasks/location-selection/helpers/config.ts @@ -0,0 +1,50 @@ +export const H3_MAX_RESOLUTION = 7; + +export type LocationSelectionTaskConfig = { + populationThreshold: number; + baselineResolution: number; + maxResolution: number; + populationSourcePreference: 'kontur' | 'worldpop' | 'auto'; + konturPopulationApiUrl: string; + konturPopulationBatchApiUrl: string; + worldpopPopulationApiUrl: string; + populationApiTimeoutMs: number; + populationBatchEnabled: boolean; +}; + +export function getLocationSelectionTaskConfig(config: Record): LocationSelectionTaskConfig { + const threshold = Number(config?.populationThreshold); + const baseline = Number(config?.baselineResolution); + const maxRes = Number(config?.maxResolution); + const sourcePreference = String(config?.populationSourcePreference || 'kontur').trim().toLowerCase(); + const konturPopulationApiUrl = String(config?.konturPopulationApiUrl || '/api/population-kontur-h3').trim(); + const konturPopulationBatchApiUrl = + String(config?.konturPopulationBatchApiUrl || '/api/population-kontur-h3-batch').trim(); + const worldpopPopulationApiUrl = String(config?.worldpopPopulationApiUrl || '/api/population-worldpop-h3').trim(); + const populationApiTimeoutMs = Number(config?.populationApiTimeoutMs); + const populationBatchEnabled = Boolean(config?.populationBatchEnabled); + + return { + populationThreshold: Number.isFinite(threshold) && threshold > 0 ? Math.round(threshold) : 50000, + baselineResolution: + Number.isFinite(baseline) && Number.isInteger(baseline) && baseline >= 0 && baseline <= 15 + ? baseline + : 5, + maxResolution: + Number.isFinite(maxRes) && Number.isInteger(maxRes) && maxRes >= 0 && maxRes <= 15 + ? Math.min(maxRes, H3_MAX_RESOLUTION) + : H3_MAX_RESOLUTION, + populationSourcePreference: + sourcePreference === 'kontur' || sourcePreference === 'worldpop' || sourcePreference === 'auto' + ? sourcePreference + : 'kontur', + konturPopulationApiUrl, + konturPopulationBatchApiUrl, + worldpopPopulationApiUrl, + populationApiTimeoutMs: + Number.isFinite(populationApiTimeoutMs) && populationApiTimeoutMs > 0 + ? Math.round(populationApiTimeoutMs) + : 2500, + populationBatchEnabled, + }; +} diff --git a/task-launcher/src/tasks/location-selection/helpers/locationCommitPreview.ts b/task-launcher/src/tasks/location-selection/helpers/locationCommitPreview.ts new file mode 100644 index 000000000..68232d61c --- /dev/null +++ b/task-launcher/src/tasks/location-selection/helpers/locationCommitPreview.ts @@ -0,0 +1,202 @@ +import { cellToLatLng, latLngToCell } from 'h3-js'; +import { type LocationSelectionDraft } from './state'; +import { H3_MAX_RESOLUTION, type LocationSelectionTaskConfig } from './config'; +import { lookupPopulationBatch, lookupPopulationForCell } from './populationApi'; + +type LocationCommitPreview = { + schemaVersion: 'location_v1'; + latLon: { + lat: number; + lon: number; + source: 'h3_center'; + }; + h3: { + scheme: 'h3_v1'; + baseline: { + cellId: string; + resolution: number; + }; + effective: { + cellId: string; + resolution: number; + }; + populationThreshold: number; + }; + populationSource: 'kontur' | 'worldpop' | 'unknown'; + computedAt: string; +}; + +export type PopulationCandidateDebug = { + resolution: number; + cellId: string; + population: number | null; + source: 'kontur' | 'worldpop' | 'unknown'; + privacyMet: boolean; +}; + +export type LocationCommitComputation = { + preview: LocationCommitPreview; + candidates: PopulationCandidateDebug[]; +}; + +function getPreferredPopulationSource( + config: Partial | null | undefined, +): 'kontur' | 'worldpop' { + const preference = String(config?.populationSourcePreference || 'kontur').toLowerCase(); + if (preference === 'worldpop') return 'worldpop'; + return 'kontur'; +} + +function roundTo(value: number, decimals = 6): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +export function buildLocationCommitPreview( + draft: LocationSelectionDraft | null, + config: Partial | null | undefined, +): LocationCommitPreview | null { + if (!draft) return null; + + const baselineResolution = Number(config?.baselineResolution); + const populationThreshold = Number(config?.populationThreshold); + const safeBaselineResolution = Number.isInteger(baselineResolution) ? baselineResolution : 5; + const safePopulationThreshold = Number.isFinite(populationThreshold) && populationThreshold > 0 + ? Math.round(populationThreshold) + : 50000; + const preferredSource = getPreferredPopulationSource(config); + + const baselineCell = latLngToCell(draft.lat, draft.lon, safeBaselineResolution); + + const effectiveCell = baselineCell; + const [centerLat, centerLon] = cellToLatLng(effectiveCell); + + return { + schemaVersion: 'location_v1', + latLon: { + lat: roundTo(centerLat, 6), + lon: roundTo(centerLon, 6), + source: 'h3_center', + }, + h3: { + scheme: 'h3_v1', + baseline: { + cellId: baselineCell, + resolution: safeBaselineResolution, + }, + effective: { + cellId: effectiveCell, + resolution: safeBaselineResolution, + }, + populationThreshold: safePopulationThreshold, + }, + populationSource: preferredSource, + computedAt: draft.selectedAt || new Date().toISOString(), + }; +} + +export async function buildLocationCommitPreviewWithPopulation( + draft: LocationSelectionDraft | null, + config: Partial | null | undefined, +): Promise { + const computed = await buildLocationCommitComputationWithPopulation(draft, config); + return computed?.preview || null; +} + +export async function buildLocationCommitComputationWithPopulation( + draft: LocationSelectionDraft | null, + config: Partial | null | undefined, +): Promise { + if (!draft) return null; + + const baselineResolution = Number(config?.baselineResolution); + const maxResolution = Number(config?.maxResolution); + const populationThreshold = Number(config?.populationThreshold); + const safeBaselineResolution = Number.isInteger(baselineResolution) ? baselineResolution : 5; + const safeMaxResolution = Math.min( + H3_MAX_RESOLUTION, + Number.isInteger(maxResolution) && maxResolution >= safeBaselineResolution + ? maxResolution + : Math.max(H3_MAX_RESOLUTION, safeBaselineResolution), + ); + const safePopulationThreshold = Number.isFinite(populationThreshold) && populationThreshold > 0 + ? Math.round(populationThreshold) + : 50000; + const preferredSource = getPreferredPopulationSource(config); + + const baselineCell = latLngToCell(draft.lat, draft.lon, safeBaselineResolution); + let effectiveCell = baselineCell; + let effectiveResolution = safeBaselineResolution; + let effectivePopulationSource: 'kontur' | 'worldpop' | 'unknown' = 'unknown'; + let observedPopulationSource: 'kontur' | 'worldpop' | 'unknown' = 'unknown'; + let foundPassingCell = false; + const candidates: PopulationCandidateDebug[] = []; + const useBatch = Boolean(config?.populationBatchEnabled); + const cellIds: string[] = []; + for (let resolution = safeBaselineResolution; resolution <= safeMaxResolution; resolution += 1) { + cellIds.push(latLngToCell(draft.lat, draft.lon, resolution)); + } + const batchResults = useBatch ? await lookupPopulationBatch(cellIds, config) : null; + + for (let index = 0; index < cellIds.length; index += 1) { + const resolution = safeBaselineResolution + index; + const cellId = cellIds[index]; + const populationResult = + batchResults?.[cellId] ?? (await lookupPopulationForCell(cellId, resolution, config)); + const population = populationResult.population; + if (populationResult.source !== 'unknown' && observedPopulationSource === 'unknown') { + observedPopulationSource = populationResult.source; + } + const privacyMet = typeof population === 'number' ? population >= safePopulationThreshold : false; + candidates.push({ + resolution, + cellId, + population, + source: populationResult.source, + privacyMet, + }); + + if (privacyMet) { + effectiveCell = cellId; + effectiveResolution = resolution; + effectivePopulationSource = populationResult.source; + foundPassingCell = true; + continue; + } + + // Privacy-first: once we found an acceptable cell, stop at first finer failure. + if (foundPassingCell) break; + } + + const [centerLat, centerLon] = cellToLatLng(effectiveCell); + + const preview: LocationCommitPreview = { + schemaVersion: 'location_v1', + latLon: { + lat: roundTo(centerLat, 6), + lon: roundTo(centerLon, 6), + source: 'h3_center', + }, + h3: { + scheme: 'h3_v1', + baseline: { + cellId: baselineCell, + resolution: safeBaselineResolution, + }, + effective: { + cellId: effectiveCell, + resolution: effectiveResolution, + }, + populationThreshold: safePopulationThreshold, + }, + populationSource: + effectivePopulationSource !== 'unknown' + ? effectivePopulationSource + : (observedPopulationSource !== 'unknown' ? observedPopulationSource : preferredSource), + computedAt: draft.selectedAt || new Date().toISOString(), + }; + return { + preview, + candidates, + }; +} diff --git a/task-launcher/src/tasks/location-selection/helpers/populationApi.ts b/task-launcher/src/tasks/location-selection/helpers/populationApi.ts new file mode 100644 index 000000000..06b66d9e2 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/helpers/populationApi.ts @@ -0,0 +1,236 @@ +import { cellToBoundary } from 'h3-js'; + +type PopulationSource = 'kontur' | 'worldpop'; + +export type PopulationLookupConfig = { + populationSourcePreference?: 'kontur' | 'worldpop' | 'auto'; + konturPopulationApiUrl?: string; + konturPopulationBatchApiUrl?: string; + worldpopPopulationApiUrl?: string; + populationApiTimeoutMs?: number; + populationBatchEnabled?: boolean; +}; + +type PopulationLookupResult = { + population: number | null; + source: PopulationSource | 'unknown'; +}; + +type PopulationBatchItem = { + cellId: string; + resolution: number | null; + population: number | null; + source: PopulationSource | 'unknown'; +}; + +type PopulationBatchResult = { + success: boolean; + items?: PopulationBatchItem[]; +}; + +function parsePopulation(payload: any): number | null { + const candidates = [ + payload?.data?.total_population, + payload?.population, + payload?.pop, + payload?.estimatedPopulation, + payload?.data?.population, + payload?.result?.population, + ]; + for (let i = 0; i < candidates.length; i += 1) { + const n = Number(candidates[i]); + if (Number.isFinite(n) && n >= 0) return n; + } + return null; +} + +function buildCellPolygon(cellId: string) { + const boundary = cellToBoundary(cellId); + if (!Array.isArray(boundary) || !boundary.length) return null; + const ring = boundary.map((pair) => [Number(pair[1]), Number(pair[0])]); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (!first || !last) return null; + if (first[0] !== last[0] || first[1] !== last[1]) { + ring.push([first[0], first[1]]); + } + return { + type: 'Polygon', + coordinates: [ring], + }; +} + +function parseWorldPopTaskId(payload: any): string | null { + const candidates = [payload?.taskid, payload?.taskId, payload?.task_id, payload?.id]; + for (let i = 0; i < candidates.length; i += 1) { + const value = String(candidates[i] || '').trim(); + if (value) return value; + } + return null; +} + +async function queryWorldPopDirect(cellId: string, timeoutMs: number): Promise { + const polygon = buildCellPolygon(cellId); + if (!polygon) return null; + const statsUrl = new URL('https://api.worldpop.org/v1/services/stats'); + statsUrl.searchParams.set('dataset', 'wpgppop'); + statsUrl.searchParams.set('year', '2020'); + statsUrl.searchParams.set('geojson', JSON.stringify(polygon)); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), Math.max(timeoutMs, 5000)); + try { + const statsResponse = await fetch(statsUrl.toString(), { + method: 'GET', + signal: controller.signal, + }); + if (!statsResponse.ok) return null; + const statsPayload = await statsResponse.json().catch(() => ({})); + const direct = parsePopulation(statsPayload); + if (typeof direct === 'number') return direct; + + const taskId = parseWorldPopTaskId(statsPayload); + if (!taskId) return null; + for (let attempt = 0; attempt < 8; attempt += 1) { + const taskResponse = await fetch(`https://api.worldpop.org/v1/tasks/${encodeURIComponent(taskId)}`, { + method: 'GET', + signal: controller.signal, + }); + if (!taskResponse.ok) return null; + const taskPayload = await taskResponse.json().catch(() => ({})); + const value = parsePopulation(taskPayload); + if (typeof value === 'number') return value; + const status = String(taskPayload?.status || '').toLowerCase(); + if (status.includes('failed') || status.includes('error')) return null; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; + } catch { + return null; + } finally { + window.clearTimeout(timeout); + } +} + +async function fetchPopulation( + endpoint: string, + source: PopulationSource, + cellId: string, + resolution: number, + timeoutMs: number, +): Promise { + if (!endpoint) return { population: null, source: 'unknown' }; + + try { + const url = new URL(endpoint, window.location.origin); + url.searchParams.set('cellId', cellId); + url.searchParams.set('resolution', String(resolution)); + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(url.toString(), { + method: 'GET', + signal: controller.signal, + }); + window.clearTimeout(timeout); + if (!response.ok) { + const directPopulation = await queryWorldPopDirect(cellId, timeoutMs); + if (typeof directPopulation === 'number') { + return { population: directPopulation, source: 'worldpop' }; + } + return { population: null, source }; + } + const payload = await response.json().catch(() => ({})); + const resolvedSourceRaw = String(payload?.source || '').trim().toLowerCase(); + const resolvedSource: PopulationSource | 'unknown' = + resolvedSourceRaw === 'kontur' || resolvedSourceRaw === 'worldpop' + ? resolvedSourceRaw + : source; + const parsed = parsePopulation(payload); + if (typeof parsed === 'number') return { population: parsed, source: resolvedSource }; + const directPopulation = await queryWorldPopDirect(cellId, timeoutMs); + if (typeof directPopulation === 'number') { + return { population: directPopulation, source: 'worldpop' }; + } + return { population: null, source: resolvedSource }; + } catch { + const directPopulation = await queryWorldPopDirect(cellId, timeoutMs); + if (typeof directPopulation === 'number') { + return { population: directPopulation, source: 'worldpop' }; + } + return { population: null, source }; + } +} + +async function fetchPopulationBatch( + endpoint: string, + cellIds: string[], + timeoutMs: number, +): Promise> { + if (!endpoint || !cellIds.length) return {}; + try { + const url = new URL(endpoint, window.location.origin); + url.searchParams.set('cellIds', cellIds.join(',')); + url.searchParams.set('fallback', 'worldpop'); + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(url.toString(), { + method: 'GET', + signal: controller.signal, + }); + window.clearTimeout(timeout); + if (!response.ok) return {}; + const payload = (await response.json().catch(() => ({}))) as PopulationBatchResult; + const items = Array.isArray(payload?.items) ? payload.items : []; + return items.reduce>((acc, item) => { + if (!item?.cellId) return acc; + const resolvedSource: PopulationSource | 'unknown' = + item.source === 'kontur' || item.source === 'worldpop' ? item.source : 'unknown'; + acc[item.cellId] = { + population: typeof item.population === 'number' ? item.population : null, + source: resolvedSource, + }; + return acc; + }, {}); + } catch { + return {}; + } +} + +export async function lookupPopulationForCell( + cellId: string, + resolution: number, + config: PopulationLookupConfig | null | undefined, +): Promise { + const preference = String(config?.populationSourcePreference || 'auto').toLowerCase(); + const konturUrl = String(config?.konturPopulationApiUrl || '/api/population-kontur-h3'); + const worldpopUrl = String(config?.worldpopPopulationApiUrl || '/api/population-worldpop-h3'); + const timeoutMs = Number(config?.populationApiTimeoutMs) > 0 ? Number(config?.populationApiTimeoutMs) : 2500; + + const orderedSources: PopulationSource[] = + preference === 'kontur' + ? ['kontur', 'worldpop'] + : preference === 'worldpop' + ? ['worldpop', 'kontur'] + : ['kontur', 'worldpop']; + + let attemptedKnownSource: PopulationSource | 'unknown' = 'unknown'; + for (let i = 0; i < orderedSources.length; i += 1) { + const source = orderedSources[i]; + const endpoint = source === 'kontur' ? konturUrl : worldpopUrl; + const result = await fetchPopulation(endpoint, source, cellId, resolution, timeoutMs); + if (result.source !== 'unknown') attemptedKnownSource = result.source; + if (typeof result.population === 'number') return result; + } + + return { population: null, source: attemptedKnownSource }; +} + +export async function lookupPopulationBatch( + cellIds: string[], + config: PopulationLookupConfig | null | undefined, +): Promise> { + const batchUrl = String(config?.konturPopulationBatchApiUrl || '/api/population-kontur-h3-batch'); + const timeoutMs = Number(config?.populationApiTimeoutMs) > 0 ? Number(config?.populationApiTimeoutMs) : 2500; + return fetchPopulationBatch(batchUrl, cellIds, timeoutMs); +} diff --git a/task-launcher/src/tasks/location-selection/helpers/state.ts b/task-launcher/src/tasks/location-selection/helpers/state.ts new file mode 100644 index 000000000..8217fb3d8 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/helpers/state.ts @@ -0,0 +1,26 @@ +import { taskStore } from '../../../taskStore'; + +export type LocationSelectionMode = 'gps' | 'map' | 'city_postal'; + +export interface LocationSelectionDraft { + mode: LocationSelectionMode; + lat: number; + lon: number; + label?: string | null; + source?: string | null; + accuracyMeters?: number | null; + metadata?: Record | null; + selectedAt: string; +} + +export function setLocationSelectionDraft(draft: LocationSelectionDraft) { + taskStore('locationSelectionDraft', draft); +} + +export function getLocationSelectionDraft(): LocationSelectionDraft | null { + return (taskStore().locationSelectionDraft as LocationSelectionDraft | null) || null; +} + +export function clearLocationSelectionDraft() { + taskStore('locationSelectionDraft', null); +} diff --git a/task-launcher/src/tasks/location-selection/helpers/ui.ts b/task-launcher/src/tasks/location-selection/helpers/ui.ts new file mode 100644 index 000000000..a25808e33 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/helpers/ui.ts @@ -0,0 +1,173 @@ +export function ensureLocationSelectionStyles() { + const displayEl = document.querySelector('.jspsych-display-element'); + if (displayEl) { + displayEl.classList.add('location-selection-scroll-enabled'); + } + + const styleId = 'location-selection-shared-ui-styles'; + let style = document.getElementById(styleId) as HTMLStyleElement | null; + if (!style) { + style = document.createElement('style'); + style.id = styleId; + document.head.appendChild(style); + } + style.textContent = ` + .jspsych-display-element.location-selection-scroll-enabled { + overflow-y: auto !important; + overflow-x: hidden; + } + .jspsych-display-element.location-selection-scroll-enabled .jspsych-content-wrapper { + height: auto; + min-height: 100%; + align-items: flex-start; + } + .jspsych-display-element.location-selection-scroll-enabled .jspsych-content { + height: auto; + min-height: 100%; + justify-content: flex-start; + padding-top: 1.25rem; + padding-bottom: 1.25rem; + } + .lev-row-container.instruction.location-selection-panel { + width: min(92vw, 680px); + max-width: 680px; + margin: 0 auto; + text-align: left; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0.35rem; + } + .lev-row-container.instruction.location-selection-panel h2 { + margin: 0 0 0.6rem 0; + line-height: 1.25; + font-size: 1.35rem; + width: 100%; + } + .lev-row-container.instruction.location-selection-panel p { + margin: 0 0 0.7rem 0; + line-height: 1.45; + font-size: 0.96rem; + width: 100%; + } + .location-selection-note { + margin-top: 0.8rem; + padding: 0.75rem 0.9rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + } + .location-selection-note p:last-child, + .location-selection-copy p:last-child { + margin-bottom: 0; + } + .location-selection-field { + margin-top: 0.85rem; + } + .location-selection-field label { + display: block; + margin-bottom: 0.3rem; + font-weight: 600; + } + .lev-row-container.instruction.location-selection-panel .location-selection-status { + margin-top: 0.8rem; + min-height: 1.4em; + line-height: 1.35; + white-space: normal; + } + .location-selection-stack { + display: block; + width: 100%; + } + .location-selection-stack > * { + width: 100%; + box-sizing: border-box; + } + .location-selection-json { + margin: 0; + padding: 0.65rem; + background: #0f172a; + color: #e2e8f0; + border-radius: 6px; + font-size: 0.8rem; + line-height: 1.3; + overflow: auto; + max-height: 280px; + white-space: pre; + } + .location-selection-debug-table-wrap { + margin-top: 0.5rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + overflow: auto; + max-height: 240px; + background: #ffffff; + } + .location-selection-debug-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; + line-height: 1.25; + } + .location-selection-debug-table th, + .location-selection-debug-table td { + border-bottom: 1px solid #e2e8f0; + padding: 0.35rem 0.45rem; + text-align: left; + white-space: nowrap; + } + .location-selection-debug-table th { + position: sticky; + top: 0; + background: #f8fafc; + z-index: 1; + font-weight: 700; + } + .location-selection-pass { + color: #166534; + font-weight: 700; + } + .location-selection-fail { + color: #b91c1c; + font-weight: 700; + } + .location-selection-intro h2 { + font-size: 1.15rem; + margin-bottom: 0.4rem; + } + .location-selection-intro p { + font-size: 0.9rem; + line-height: 1.35; + margin-bottom: 0.45rem; + max-width: 34rem; + white-space: normal; + overflow-wrap: break-word; + } + .location-method-buttons { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + margin-top: 0.7rem; + } + .location-method-button { + width: 100%; + text-align: left; + line-height: 1.25; + padding: 0.65rem 0.75rem; + white-space: normal; + } + .location-method-title { + display: block; + font-weight: 700; + font-size: 0.95rem; + margin-bottom: 0.15rem; + } + .location-method-meta { + display: block; + font-size: 0.82rem; + opacity: 0.9; + } + `; +} diff --git a/task-launcher/src/tasks/location-selection/timeline.ts b/task-launcher/src/tasks/location-selection/timeline.ts new file mode 100644 index 000000000..483dd0b18 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/timeline.ts @@ -0,0 +1,41 @@ +import 'regenerator-runtime/runtime'; +import { initTrialSaving, initTimeline } from '../shared/helpers'; +import { jsPsych } from '../taskSetup'; +import { enterFullscreen, exitFullscreen, finishExperiment, taskFinished } from '../shared/trials'; +import { instructions } from './trials/instructions'; +import { gpsCapture } from './trials/gpsCapture'; +import { mapPicker } from './trials/mapPicker'; +import { searchCityPostal } from './trials/searchCityPostal'; +import { reviewAndConfirm } from './trials/reviewAndConfirm'; +import { getLocationSelectionTaskConfig } from './helpers/config'; +import { taskStore } from '../../taskStore'; +import { clearLocationSelectionDraft } from './helpers/state'; + +export default function buildLocationSelectionTimeline(config: Record, _mediaAssets: MediaAssetsType) { + initTrialSaving(config); + const initialTimeline = initTimeline(config, enterFullscreen, finishExperiment); + const locationConfig = getLocationSelectionTaskConfig(config); + + taskStore('locationSelectionConfig', locationConfig); + taskStore('locationSelectionMode', null); + taskStore('locationSelectionLastStep', null); + taskStore('locationSelectionCommitPreview', null); + taskStore('locationSelectionCommitCandidates', null); + taskStore('locationSelectionPendingSuggestion', null); + taskStore('locationSelectionPendingCountry', null); + clearLocationSelectionDraft(); + + const timeline: Record[] = [ + initialTimeline, + ...instructions, + gpsCapture, + mapPicker, + searchCityPostal, + reviewAndConfirm, + ]; + + timeline.push(taskFinished('taskFinished')); + timeline.push(exitFullscreen); + + return { jsPsych, timeline }; +} diff --git a/task-launcher/src/tasks/location-selection/trials/gpsCapture.ts b/task-launcher/src/tasks/location-selection/trials/gpsCapture.ts new file mode 100644 index 000000000..71b0f82ef --- /dev/null +++ b/task-launcher/src/tasks/location-selection/trials/gpsCapture.ts @@ -0,0 +1,124 @@ +import jsPsychHtmlMultiResponse from '@jspsych-contrib/plugin-html-multi-response'; +import { taskStore } from '../../../taskStore'; +import { getLocationSelectionDraft, setLocationSelectionDraft } from '../helpers/state'; +import { ensureLocationSelectionStyles } from '../helpers/ui'; + +async function reverseGeocode(lat: number, lon: number): Promise { + try { + const params = new URLSearchParams({ + lat: String(lat), + lon: String(lon), + format: 'jsonv2', + zoom: '10', + addressdetails: '1', + }); + const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params.toString()}`); + if (!response.ok) return null; + const payload = await response.json(); + return typeof payload?.display_name === 'string' ? payload.display_name : null; + } catch { + return null; + } +} + +export const gpsCapture = { + timeline: [ + { + type: jsPsychHtmlMultiResponse, + stimulus: ` +
+
+

GPS location

+

We are requesting your browser location permission now.

+

Requesting GPS location…

+

+
+ +
+
+
+ `, + prompt_above_buttons: true, + button_choices: ['Continue'], + button_html: '', + keyboard_choices: 'NO_KEYS', + data: { + assessment_stage: 'gps_capture', + task: 'location-selection', + }, + on_load: () => { + ensureLocationSelectionStyles(); + const continueButton = document.querySelector('#jspsych-html-multi-response-button-0'); + const retryButton = document.getElementById('gps-retry-btn') as HTMLButtonElement | null; + const statusEl = document.getElementById('gps-status'); + const valueEl = document.getElementById('gps-value'); + if (continueButton) { + continueButton.disabled = true; + continueButton.style.display = 'none'; + } + + if (!navigator.geolocation) { + if (statusEl) statusEl.textContent = 'Geolocation is not supported by this browser.'; + if (retryButton) retryButton.style.display = 'inline-block'; + return; + } + + const requestGps = () => { + if (statusEl) statusEl.textContent = 'Requesting GPS location…'; + if (retryButton) retryButton.style.display = 'none'; + navigator.geolocation.getCurrentPosition( + async (position) => { + const lat = Number(position.coords.latitude); + const lon = Number(position.coords.longitude); + const accuracyMeters = Number(position.coords.accuracy); + const label = await reverseGeocode(lat, lon); + setLocationSelectionDraft({ + mode: 'gps', + lat, + lon, + label, + source: 'browser_geolocation', + accuracyMeters: Number.isFinite(accuracyMeters) ? accuracyMeters : null, + metadata: { + altitude: position.coords.altitude ?? null, + speed: position.coords.speed ?? null, + }, + selectedAt: new Date().toISOString(), + }); + if (statusEl) statusEl.textContent = 'GPS location captured.'; + if (valueEl) { + valueEl.textContent = + `${lat.toFixed(5)}, ${lon.toFixed(5)} · accuracy ≈ ${Math.round(accuracyMeters || 0)}m` + + (label ? ` · ${label}` : ''); + } + if (continueButton) { + continueButton.disabled = false; + continueButton.click(); + } + }, + (error) => { + if (statusEl) statusEl.textContent = `GPS error: ${error.message}`; + if (retryButton) retryButton.style.display = 'inline-block'; + }, + { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 0, + }, + ); + }; + + retryButton?.addEventListener('click', requestGps); + requestGps(); + }, + on_finish: (data: Record) => { + const draft = getLocationSelectionDraft(); + data.mode = 'gps'; + data.geolocationSupported = typeof navigator !== 'undefined' && !!navigator.geolocation; + data.selectedLocation = draft || null; + taskStore('locationSelectionLastStep', 'gps_capture'); + }, + }, + ], + conditional_function: () => taskStore().locationSelectionMode === 'gps', +}; diff --git a/task-launcher/src/tasks/location-selection/trials/instructions.ts b/task-launcher/src/tasks/location-selection/trials/instructions.ts new file mode 100644 index 000000000..3c43c8fe2 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/trials/instructions.ts @@ -0,0 +1,42 @@ +import jsPsychHtmlMultiResponse from '@jspsych-contrib/plugin-html-multi-response'; +import { taskStore } from '../../../taskStore'; +import { ensureLocationSelectionStyles } from '../helpers/ui'; + +const modes = ['gps', 'map', 'city_postal'] as const; + +export const instructions = [ + { + type: jsPsychHtmlMultiResponse, + stimulus: ` +
+
+

How would you like to share location?

+

Choose one option below.
We only use an approximate location.

+
+
+ `, + prompt_above_buttons: true, + button_choices: [ + 'Use GPSUse browser location permission on this device.', + 'Pick on mapClick an approximate point in the United States.', + 'Type city/postalChoose country first, then autocomplete a place or code.', + ], + button_html: '', + keyboard_choices: 'NO_KEYS', + data: { + assessment_stage: 'mode_select_intro', + task: 'location-selection', + }, + on_load: () => { + ensureLocationSelectionStyles(); + const btnGroup = document.getElementById('jspsych-html-multi-response-btngroup'); + if (btnGroup) btnGroup.classList.add('location-method-buttons'); + }, + on_finish: (data: Record) => { + const idx = Number(data?.response ?? data?.button_response ?? -1); + const selectedMode = modes[idx] || 'gps'; + data.selectedMode = selectedMode; + taskStore('locationSelectionMode', selectedMode); + }, + }, +]; diff --git a/task-launcher/src/tasks/location-selection/trials/mapPicker.ts b/task-launcher/src/tasks/location-selection/trials/mapPicker.ts new file mode 100644 index 000000000..fae051fdb --- /dev/null +++ b/task-launcher/src/tasks/location-selection/trials/mapPicker.ts @@ -0,0 +1,289 @@ +import jsPsychHtmlMultiResponse from '@jspsych-contrib/plugin-html-multi-response'; +import { taskStore } from '../../../taskStore'; +import { getLocationSelectionDraft, setLocationSelectionDraft } from '../helpers/state'; +import { ensureLocationSelectionStyles } from '../helpers/ui'; + +declare global { + interface Window { + L?: any; + } +} + +let leafletLoader: Promise | null = null; +const US_LOWER_48_BOUNDS: [[number, number], [number, number]] = [[24.396308, -124.848974], [49.384358, -66.885444]]; +const US_LABEL_POINTS: Array<{ name: string; lat: number; lon: number }> = [ + { name: 'Seattle', lat: 47.6062, lon: -122.3321 }, + { name: 'San Francisco', lat: 37.7749, lon: -122.4194 }, + { name: 'Los Angeles', lat: 34.0522, lon: -118.2437 }, + { name: 'Denver', lat: 39.7392, lon: -104.9903 }, + { name: 'Dallas', lat: 32.7767, lon: -96.7970 }, + { name: 'Houston', lat: 29.7604, lon: -95.3698 }, + { name: 'Chicago', lat: 41.8781, lon: -87.6298 }, + { name: 'Atlanta', lat: 33.7490, lon: -84.3880 }, + { name: 'Miami', lat: 25.7617, lon: -80.1918 }, + { name: 'Washington, DC', lat: 38.9072, lon: -77.0369 }, + { name: 'New York', lat: 40.7128, lon: -74.0060 }, + { name: 'Boston', lat: 42.3601, lon: -71.0589 }, +]; +const US_STATE_LABEL_POINTS: Array<{ abbr: string; lat: number; lon: number }> = [ + { abbr: 'AL', lat: 32.8067, lon: -86.7911 }, { abbr: 'AZ', lat: 34.0489, lon: -111.0937 }, + { abbr: 'AR', lat: 35.2010, lon: -91.8318 }, { abbr: 'CA', lat: 36.7783, lon: -119.4179 }, + { abbr: 'CO', lat: 39.5501, lon: -105.7821 }, { abbr: 'CT', lat: 41.6032, lon: -73.0877 }, + { abbr: 'DE', lat: 38.9108, lon: -75.5277 }, { abbr: 'FL', lat: 27.6648, lon: -81.5158 }, + { abbr: 'GA', lat: 32.1574, lon: -82.9071 }, { abbr: 'ID', lat: 44.0682, lon: -114.7420 }, + { abbr: 'IL', lat: 40.6331, lon: -89.3985 }, { abbr: 'IN', lat: 39.7684, lon: -86.1581 }, + { abbr: 'IA', lat: 41.8780, lon: -93.0977 }, { abbr: 'KS', lat: 39.0119, lon: -98.4842 }, + { abbr: 'KY', lat: 37.8393, lon: -84.2700 }, { abbr: 'LA', lat: 30.9843, lon: -91.9623 }, + { abbr: 'ME', lat: 45.2538, lon: -69.4455 }, { abbr: 'MD', lat: 39.0458, lon: -76.6413 }, + { abbr: 'MA', lat: 42.4072, lon: -71.3824 }, { abbr: 'MI', lat: 44.3148, lon: -85.6024 }, + { abbr: 'MN', lat: 46.7296, lon: -94.6859 }, { abbr: 'MS', lat: 32.3547, lon: -89.3985 }, + { abbr: 'MO', lat: 37.9643, lon: -91.8318 }, { abbr: 'MT', lat: 46.8797, lon: -110.3626 }, + { abbr: 'NE', lat: 41.4925, lon: -99.9018 }, { abbr: 'NV', lat: 38.8026, lon: -116.4194 }, + { abbr: 'NH', lat: 43.1939, lon: -71.5724 }, { abbr: 'NJ', lat: 40.0583, lon: -74.4057 }, + { abbr: 'NM', lat: 34.5199, lon: -105.8701 }, { abbr: 'NY', lat: 43.2994, lon: -74.2179 }, + { abbr: 'NC', lat: 35.7596, lon: -79.0193 }, { abbr: 'ND', lat: 47.5515, lon: -101.0020 }, + { abbr: 'OH', lat: 40.4173, lon: -82.9071 }, { abbr: 'OK', lat: 35.0078, lon: -97.0929 }, + { abbr: 'OR', lat: 43.8041, lon: -120.5542 }, { abbr: 'PA', lat: 41.2033, lon: -77.1945 }, + { abbr: 'RI', lat: 41.5801, lon: -71.4774 }, { abbr: 'SC', lat: 33.8361, lon: -81.1637 }, + { abbr: 'SD', lat: 43.9695, lon: -99.9018 }, { abbr: 'TN', lat: 35.5175, lon: -86.5804 }, + { abbr: 'TX', lat: 31.9686, lon: -99.9018 }, { abbr: 'UT', lat: 39.3210, lon: -111.0937 }, + { abbr: 'VT', lat: 44.5588, lon: -72.5778 }, { abbr: 'VA', lat: 37.4316, lon: -78.6569 }, + { abbr: 'WA', lat: 47.7511, lon: -120.7401 }, { abbr: 'WV', lat: 38.5976, lon: -80.4549 }, + { abbr: 'WI', lat: 43.7844, lon: -88.7879 }, { abbr: 'WY', lat: 43.0759, lon: -107.2903 }, +]; +const US_STATE_LABEL_PRIORITY: string[] = [ + 'CA', 'TX', 'FL', 'NY', 'WA', 'CO', 'AZ', 'IL', 'GA', 'NC', 'PA', 'MI', 'OH', 'TN', 'MN', 'MA', 'VA', 'MO', 'NJ', 'WI', +]; + +function ensureMapLabelStyles() { + const styleId = 'location-selection-map-label-styles'; + if (document.getElementById(styleId)) return; + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .location-map-state-label { + background: transparent; + border: none; + box-shadow: none; + color: #334155; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + } + .location-map-city-label { + background: transparent; + border: none; + box-shadow: none; + color: #1f2937; + font-size: 11px; + font-weight: 500; + } + `; + document.head.appendChild(style); +} + +function shouldKeepLabel(candidate: { x: number; y: number }, used: Array<{ x: number; y: number }>, minPxDistance: number) { + for (let i = 0; i < used.length; i += 1) { + const dx = candidate.x - used[i].x; + const dy = candidate.y - used[i].y; + if ((dx * dx) + (dy * dy) < (minPxDistance * minPxDistance)) { + return false; + } + } + return true; +} + +function loadLeaflet(): Promise { + if (window.L) return Promise.resolve(window.L); + if (leafletLoader) return leafletLoader; + + leafletLoader = new Promise((resolve, reject) => { + const cssId = 'leaflet-css-location-selection'; + if (!document.getElementById(cssId)) { + const link = document.createElement('link'); + link.id = cssId; + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; + document.head.appendChild(link); + } + + const scriptId = 'leaflet-js-location-selection'; + const existing = document.getElementById(scriptId) as HTMLScriptElement | null; + if (existing && window.L) { + resolve(window.L); + return; + } + if (existing) { + existing.addEventListener('load', () => resolve(window.L)); + existing.addEventListener('error', () => reject(new Error('Failed to load Leaflet script'))); + return; + } + + const script = document.createElement('script'); + script.id = scriptId; + script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; + script.async = true; + script.onload = () => resolve(window.L); + script.onerror = () => reject(new Error('Failed to load Leaflet script')); + document.head.appendChild(script); + }); + + return leafletLoader; +} + +export const mapPicker = { + timeline: [ + { + type: jsPsychHtmlMultiResponse, + stimulus: ` +
+
+

Pick on map (United States)

+

Click your approximate location on the US map. The map is limited to the contiguous US.

+
+

Pick a point on the map.

+
+
+ `, + prompt_above_buttons: true, + button_choices: ['Continue'], + button_html: '', + keyboard_choices: 'NO_KEYS', + data: { + assessment_stage: 'map_picker', + task: 'location-selection', + }, + on_load: async () => { + ensureLocationSelectionStyles(); + const continueButton = document.querySelector('#jspsych-html-multi-response-button-0'); + const statusEl = document.getElementById('map-picker-status'); + if (continueButton) continueButton.disabled = true; + + try { + const L = await loadLeaflet(); + ensureMapLabelStyles(); + const mapEl = document.getElementById('location-map-picker'); + if (!mapEl) throw new Error('Map container not found'); + const bounds = L.latLngBounds(US_LOWER_48_BOUNDS[0], US_LOWER_48_BOUNDS[1]); + const map = L.map(mapEl, { + zoomControl: true, + attributionControl: false, + maxBounds: bounds.pad(0.12), + maxBoundsViscosity: 1.0, + minZoom: 3, + }); + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + subdomains: 'abcd', + attribution: '© OpenStreetMap contributors © CARTO', + maxZoom: 18, + }).addTo(map); + map.fitBounds(bounds.pad(0.02), { padding: [20, 20] }); + + const cityLabelsLayer = L.layerGroup().addTo(map); + const stateLabelsLayer = L.layerGroup().addTo(map); + + const drawLabelsForZoom = () => { + const zoom = Number(map.getZoom?.() || 4); + cityLabelsLayer.clearLayers(); + stateLabelsLayer.clearLayers(); + const usedScreenPoints: Array<{ x: number; y: number }> = []; + const mapBounds = map.getBounds?.(); + const stateRank = new Map(US_STATE_LABEL_PRIORITY.map((abbr, idx) => [abbr, idx])); + + if (zoom <= 6) { + const maxLabels = zoom <= 4 ? 10 : zoom === 5 ? 16 : 28; + const minPxDistance = zoom <= 4 ? 52 : zoom === 5 ? 42 : 34; + const rankedStates = US_STATE_LABEL_POINTS + .slice() + .sort((a, b) => { + const aRank = stateRank.has(a.abbr) ? Number(stateRank.get(a.abbr)) : 999; + const bRank = stateRank.has(b.abbr) ? Number(stateRank.get(b.abbr)) : 999; + return aRank - bRank; + }); + + rankedStates.forEach((point) => { + if (stateLabelsLayer.getLayers().length >= maxLabels) return; + if (mapBounds && !mapBounds.contains([point.lat, point.lon])) return; + const screen = map.latLngToContainerPoint([point.lat, point.lon]); + const spot = { x: Number(screen.x), y: Number(screen.y) }; + if (!shouldKeepLabel(spot, usedScreenPoints, minPxDistance)) return; + usedScreenPoints.push(spot); + const marker = L.circleMarker([point.lat, point.lon], { + radius: 1, + color: '#334155', + fillColor: '#334155', + fillOpacity: 0.05, + weight: 0.5, + }).addTo(stateLabelsLayer); + marker.bindTooltip(point.abbr, { + permanent: true, + direction: 'center', + className: 'location-map-state-label', + opacity: 0.9, + }); + marker.openTooltip(); + }); + return; + } + + US_LABEL_POINTS.forEach((point) => { + if (mapBounds && !mapBounds.contains([point.lat, point.lon])) return; + const screen = map.latLngToContainerPoint([point.lat, point.lon]); + const spot = { x: Number(screen.x), y: Number(screen.y) }; + if (!shouldKeepLabel(spot, usedScreenPoints, 40)) return; + usedScreenPoints.push(spot); + const marker = L.circleMarker([point.lat, point.lon], { + radius: 2.5, + color: '#1f2937', + fillColor: '#ffffff', + fillOpacity: 0.9, + weight: 1, + }).addTo(cityLabelsLayer); + marker.bindTooltip(point.name, { + permanent: true, + direction: 'right', + offset: [4, 0], + className: 'location-map-city-label', + opacity: 0.9, + }); + marker.openTooltip(); + }); + }; + drawLabelsForZoom(); + map.on('zoomend', drawLabelsForZoom); + + let marker: any = null; + map.on('click', (e: any) => { + const lat = Number(e?.latlng?.lat); + const lon = Number(e?.latlng?.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + if (marker) { + marker.setLatLng([lat, lon]); + } else { + marker = L.marker([lat, lon]).addTo(map); + } + setLocationSelectionDraft({ + mode: 'map', + lat, + lon, + label: null, + source: 'leaflet_map_click', + selectedAt: new Date().toISOString(), + }); + if (statusEl) statusEl.textContent = `Selected: ${lat.toFixed(5)}, ${lon.toFixed(5)}`; + if (continueButton) continueButton.disabled = false; + }); + } catch (error: any) { + if (statusEl) statusEl.textContent = `Map failed to load: ${error?.message || error}`; + } + }, + on_finish: (data: Record) => { + const draft = getLocationSelectionDraft(); + data.mode = 'map'; + data.selectedLocation = draft || null; + taskStore('locationSelectionLastStep', 'map_picker'); + }, + }, + ], + conditional_function: () => taskStore().locationSelectionMode === 'map', +}; diff --git a/task-launcher/src/tasks/location-selection/trials/modeSelect.ts b/task-launcher/src/tasks/location-selection/trials/modeSelect.ts new file mode 100644 index 000000000..a9d5de3d4 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/trials/modeSelect.ts @@ -0,0 +1,34 @@ +import jsPsychHtmlMultiResponse from '@jspsych-contrib/plugin-html-multi-response'; +import { taskStore } from '../../../taskStore'; +import { ensureLocationSelectionStyles } from '../helpers/ui'; + +const modes = ['gps', 'map', 'city_postal'] as const; + +export const modeSelect = { + type: jsPsychHtmlMultiResponse, + stimulus: ` +
+
+

Choose how to provide location

+

Select one method to continue.

+
+
+ `, + prompt_above_buttons: true, + button_choices: ['Use GPS', 'Pick on map', 'Type city/postal'], + button_html: '', + keyboard_choices: 'NO_KEYS', + data: { + assessment_stage: 'mode_select', + task: 'location-selection', + }, + on_load: () => { + ensureLocationSelectionStyles(); + }, + on_finish: (data: Record) => { + const idx = Number(data?.response ?? data?.button_response ?? -1); + const selectedMode = modes[idx] || 'gps'; + data.selectedMode = selectedMode; + taskStore('locationSelectionMode', selectedMode); + }, +}; diff --git a/task-launcher/src/tasks/location-selection/trials/reviewAndConfirm.ts b/task-launcher/src/tasks/location-selection/trials/reviewAndConfirm.ts new file mode 100644 index 000000000..0ae5aa096 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/trials/reviewAndConfirm.ts @@ -0,0 +1,273 @@ +import jsPsychHtmlMultiResponse from '@jspsych-contrib/plugin-html-multi-response'; +import { getApp, getApps, initializeApp } from 'firebase/app'; +import { connectFunctionsEmulator, getFunctions, httpsCallable } from 'firebase/functions'; +import { getAuth } from 'firebase/auth'; +import { taskStore } from '../../../taskStore'; +import { getLocationSelectionDraft } from '../helpers/state'; +import { ensureLocationSelectionStyles } from '../helpers/ui'; +import { + buildLocationCommitPreview, + buildLocationCommitComputationWithPopulation, +} from '../helpers/locationCommitPreview'; + +function escapeHtml(value: string): string { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>'); +} + +const DEFAULT_LOCATION_SAVE_FUNCTION = 'upsertLocation'; +const DEFAULT_LOCATION_SAVE_PROJECT = 'hs-levante-admin-dev'; +const DEFAULT_LOCATION_SAVE_EMULATOR_HOST = 'http://localhost:5005'; +const DEFAULT_LOCATION_SAVE_COLLECTION = 'locations'; + +function isLocationSaveDebugEnabled() { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + return params.get('locationSaveDebug') === 'true' + || ['localhost', '127.0.0.1'].includes(window.location.hostname); +} + +function getLocationSaveSettings() { + const params = new URLSearchParams(window.location.search); + return { + functionName: params.get('locationSaveFunction') || DEFAULT_LOCATION_SAVE_FUNCTION, + projectId: params.get('locationSaveProject') || DEFAULT_LOCATION_SAVE_PROJECT, + emulatorHost: params.get('locationSaveEmulatorHost') || DEFAULT_LOCATION_SAVE_EMULATOR_HOST, + collection: params.get('locationSaveCollection') || DEFAULT_LOCATION_SAVE_COLLECTION, + }; +} + +function buildLocationSavePayload() { + const params = new URLSearchParams(window.location.search); + const draft = getLocationSelectionDraft(); + const config = taskStore().locationSelectionConfig || null; + const preview = taskStore().locationSelectionCommitPreview || buildLocationCommitPreview(draft, config); + return { + location: preview, + collection: params.get('locationSaveCollection') || DEFAULT_LOCATION_SAVE_COLLECTION, + meta: { + task: 'location-selection', + mode: taskStore().locationSelectionMode || null, + selectedLocation: draft, + locationCommitPreview: preview, + locationCommitCandidates: taskStore().locationSelectionCommitCandidates || [], + pid: params.get('pid'), + language: params.get('lng'), + sentAt: new Date().toISOString(), + }, + }; +} + +function renderCandidateTable(candidates: Array, threshold: number): string { + if (!Array.isArray(candidates) || !candidates.length) { + return '

No candidate data.

'; + } + + const rows = candidates.map((candidate) => { + const pass = Boolean(candidate?.pass); + const pop = candidate?.population == null ? 'n/a' : String(candidate.population); + const source = escapeHtml(String(candidate?.source || 'unknown')); + const cellId = escapeHtml(String(candidate?.cellId || '')); + const r = escapeHtml(String(candidate?.r ?? '')); + return ` + + ${r} + ${escapeHtml(pop)} + ${source} + ${pass ? 'pass' : 'fail'} + ${escapeHtml(String(threshold))} + ${cellId} + + `; + }).join(''); + + return ` +
+ + + + + + + + + + + + ${rows} +
respopulationsourcepassthresholdcellId
+
+ `; +} + +export const reviewAndConfirm = { + type: jsPsychHtmlMultiResponse, + stimulus: () => { + const draft = getLocationSelectionDraft(); + const commitPreview = buildLocationCommitPreview(draft, taskStore().locationSelectionConfig || null); + const commitPreviewJson = commitPreview + ? escapeHtml(JSON.stringify(commitPreview, null, 2)) + : '{"error":"No location selected yet. Go back and choose a location."}'; + const draftDetails = draft + ? ` +
+

Selected method: ${draft.mode}

+

Source: ${draft.source || 'unknown'}

+ ${draft.label ? `

Label: ${draft.label}

` : ''} + ${draft.accuracyMeters ? `

Accuracy: ~${Math.round(draft.accuracyMeters)}m

` : ''} +

Raw coordinates: hidden

+
+ ` + : '

No location selected yet.

'; + return ` +
+
+
+

Review selection

+ ${draftDetails} +
+

Location object to commit (schema preview):

+

Computing effective H3 with population lookup…

+
${commitPreviewJson}
+
+
+ ${isLocationSaveDebugEnabled() ? ` +
+

Test save location

+
+ + +
+ +

+

+

+
+ ` : ''} +
+
+
+ `; + }, + prompt_above_buttons: true, + button_choices: ['Confirm'], + button_html: '', + keyboard_choices: 'NO_KEYS', + data: { + assessment_stage: 'review_confirm', + task: 'location-selection', + }, + on_load: () => { + ensureLocationSelectionStyles(); + const draft = getLocationSelectionDraft(); + const config = taskStore().locationSelectionConfig || null; + const previewEl = document.getElementById('location-commit-preview-json'); + const candidatesEl = document.getElementById('location-commit-candidates-table'); + const statusEl = document.getElementById('location-commit-preview-status'); + + buildLocationCommitComputationWithPopulation(draft, config) + .then((computed) => { + const finalPreview = computed?.preview || buildLocationCommitPreview(draft, config); + taskStore('locationSelectionCommitPreview', finalPreview); + taskStore('locationSelectionCommitCandidates', computed?.candidates || []); + if (previewEl) { + previewEl.textContent = JSON.stringify(finalPreview, null, 2); + } + if (candidatesEl) { + const threshold = Number(finalPreview?.h3?.populationThreshold || 0); + const candidateLines = (computed?.candidates || []).map((candidate) => ({ + r: candidate.resolution, + population: candidate.population, + source: candidate.source, + pass: candidate.privacyMet, + threshold, + cellId: candidate.cellId, + })); + candidatesEl.innerHTML = renderCandidateTable(candidateLines, threshold); + } + if (statusEl) { + const source = finalPreview?.populationSource || 'unknown'; + statusEl.textContent = `Population source used: ${source}`; + } + }) + .catch(() => { + const fallback = buildLocationCommitPreview(draft, config); + taskStore('locationSelectionCommitPreview', fallback); + taskStore('locationSelectionCommitCandidates', []); + if (statusEl) statusEl.textContent = 'Population lookup unavailable; using baseline-only preview.'; + if (candidatesEl) candidatesEl.innerHTML = '

No candidate data.

'; + }); + + if (!isLocationSaveDebugEnabled()) return; + + const settings = getLocationSaveSettings(); + const targetSelect = document.getElementById('location-save-target') as HTMLSelectElement | null; + const saveButton = document.getElementById('location-save-button') as HTMLButtonElement | null; + const saveStatus = document.getElementById('location-save-status'); + const saveMeta = document.getElementById('location-save-meta'); + const saveDebug = document.getElementById('location-save-debug'); + + if (saveMeta) { + saveMeta.textContent = `Function: ${settings.functionName} | Project: ${settings.projectId} | Collection: ${settings.collection}`; + } + if (saveDebug) { + const globalApp = (window as any).__firebaseApp; + const hasGlobalConfig = Boolean((window as any).__firebaseConfig); + const appName = globalApp?.name || getApps()[0]?.name || 'none'; + const authApp = globalApp || getApps()[0] || null; + const authed = authApp ? Boolean(getAuth(authApp).currentUser) : false; + saveDebug.textContent = `Firebase app: ${appName} | Config: ${hasGlobalConfig ? 'window' : 'none'} | Authed: ${authed ? 'yes' : 'no'}`; + } + + if (!targetSelect || !saveButton || !saveStatus) return; + + saveButton.addEventListener('click', async () => { + const target = (targetSelect.value || 'emulator') as 'emulator' | 'dev'; + const startedAt = Date.now(); + saveStatus.textContent = `Saving to ${target}...`; + try { + const payload = buildLocationSavePayload(); + const globalApp = (typeof window !== 'undefined' ? (window as any).__firebaseApp : null) || null; + const globalConfig = (typeof window !== 'undefined' ? (window as any).__firebaseConfig : null) || null; + const apps = getApps(); + let app = globalApp || apps[0] || null; + if (!app && globalConfig) { + app = initializeApp(globalConfig); + } + if (!app) { + throw new Error('Firebase app not initialized'); + } + + const functionsInstance = getFunctions(app, 'us-central1'); + if (target === 'emulator') { + const emulatorUrl = new URL(settings.emulatorHost); + const host = emulatorUrl.hostname === '127.0.0.1' ? 'localhost' : emulatorUrl.hostname; + connectFunctionsEmulator(functionsInstance, host, Number(emulatorUrl.port || 5002)); + } + + const callable = httpsCallable(functionsInstance, settings.functionName, { timeout: 15000 }); + const result = await callable(payload); + const elapsedMs = Date.now() - startedAt; + saveStatus.textContent = `Saved ✓ (${elapsedMs}ms) ${JSON.stringify(result.data)}`; + } catch (error) { + const elapsedMs = Date.now() - startedAt; + const err = error as { code?: string; message?: string; details?: unknown }; + const details = err?.details ? ` | details: ${JSON.stringify(err.details)}` : ''; + const code = err?.code ? `code: ${err.code} | ` : ''; + const message = err?.message || (error instanceof Error ? error.message : String(error)); + saveStatus.textContent = `Save failed (${elapsedMs}ms): ${code}${message}${details}`; + } + }); + }, + on_finish: (data: Record) => { + data.mode = taskStore().locationSelectionMode || null; + data.selectedLocation = getLocationSelectionDraft(); + data.locationCommitPreview = taskStore().locationSelectionCommitPreview + || buildLocationCommitPreview(getLocationSelectionDraft(), taskStore().locationSelectionConfig || null); + }, +}; diff --git a/task-launcher/src/tasks/location-selection/trials/searchCityPostal.ts b/task-launcher/src/tasks/location-selection/trials/searchCityPostal.ts new file mode 100644 index 000000000..864555086 --- /dev/null +++ b/task-launcher/src/tasks/location-selection/trials/searchCityPostal.ts @@ -0,0 +1,339 @@ +import jsPsychHtmlMultiResponse from '@jspsych-contrib/plugin-html-multi-response'; +import { taskStore } from '../../../taskStore'; +import { getLocationSelectionDraft, setLocationSelectionDraft } from '../helpers/state'; +import { ensureLocationSelectionStyles } from '../helpers/ui'; + +interface NominatimResult { + place_id?: number; + display_name?: string; + lat?: string; + lon?: string; + type?: string; +} + +interface CountryOption { + code: string; + label: string; +} + +const SUPPORTED_COUNTRY_CODES: string[] = ['US', 'DE', 'GB', 'NL', 'CA', 'CO', 'IN', 'AR', 'GH', 'CH']; +const SUPPORTED_COUNTRY_NAMES: Record = { + US: 'United States', + DE: 'Germany', + GB: 'United Kingdom', + NL: 'Netherlands', + CA: 'Canada', + CO: 'Colombia', + IN: 'India', + AR: 'Argentina', + GH: 'Ghana', + CH: 'Switzerland', +}; + +function escapeHtml(value: string): string { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeRegex(value: string): string { + return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function highlightLabel(label: string, query: string): string { + const safeLabel = escapeHtml(label); + const q = String(query || '').trim(); + if (!q || q.length < 2) return safeLabel; + const expr = new RegExp(`(${escapeRegex(q)})`, 'ig'); + return safeLabel.replace(expr, '$1'); +} + +function getCountryLabel(code: string): string { + const iso = String(code || '').trim().toUpperCase(); + const explicitName = SUPPORTED_COUNTRY_NAMES[iso]; + if (explicitName) return explicitName; + try { + if (typeof Intl !== 'undefined' && (Intl as any).DisplayNames) { + const dn = new Intl.DisplayNames(['en'], { type: 'region' }); + return dn.of(iso) || iso; + } + } catch { + // Ignore and use fallback. + } + return iso; +} + +async function loadCountryOptions(): Promise { + return SUPPORTED_COUNTRY_CODES.map((code) => ({ + code, + label: `${getCountryLabel(code)} — ${code}`, + })); +} + +async function searchLocations(query: string, countryCode?: string): Promise { + const params = new URLSearchParams({ + q: query, + format: 'jsonv2', + addressdetails: '1', + limit: '10', + }); + if (countryCode) params.set('countrycodes', String(countryCode || '').toLowerCase()); + const response = await fetch(`https://nominatim.openstreetmap.org/search?${params.toString()}`); + if (!response.ok) return []; + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +function buildDraftFromSuggestion(selected: NominatimResult, selectedCountry: string) { + const lat = Number(selected?.lat); + const lon = Number(selected?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { + mode: 'city_postal' as const, + lat, + lon, + label: String(selected.display_name || ''), + source: 'nominatim_search', + metadata: { + placeId: selected.place_id ?? null, + resultType: selected.type ?? null, + countryCode: selectedCountry, + }, + selectedAt: new Date().toISOString(), + }; +} + +export const searchCityPostal = { + timeline: [ + { + type: jsPsychHtmlMultiResponse, + stimulus: ` +
+
+

City / postal search

+

Select a country, then type a city/town or postal code.

+
+ + +
+
+ + + +
+

Choose a country to begin.

+
+
+ `, + prompt_above_buttons: true, + button_choices: ['Continue'], + button_html: '', + keyboard_choices: 'NO_KEYS', + data: { + assessment_stage: 'city_postal_search', + task: 'location-selection', + }, + on_load: () => { + ensureLocationSelectionStyles(); + const continueButton = document.querySelector('#jspsych-html-multi-response-button-0'); + const inputEl = document.getElementById('location-query-input') as HTMLInputElement | null; + const countryEl = document.getElementById('location-country-select') as HTMLSelectElement | null; + const statusEl = document.getElementById('location-search-status'); + const dropdownEl = document.getElementById('location-autocomplete-dropdown'); + + if (continueButton) continueButton.disabled = true; + let selectedCountry = 'US'; + taskStore('locationSelectionPendingCountry', selectedCountry); + let debounceHandle: number | null = null; + let latestRequestId = 0; + let latestResults: NominatimResult[] = []; + let highlightedIndex = -1; + let latestQuery = ''; + let hasExplicitSelection = false; + + const hideDropdown = () => { + if (!dropdownEl) return; + dropdownEl.style.display = 'none'; + dropdownEl.innerHTML = ''; + highlightedIndex = -1; + }; + + const selectResult = (selected: NominatimResult) => { + const draft = buildDraftFromSuggestion(selected, selectedCountry); + if (!draft) return; + setLocationSelectionDraft(draft); + taskStore('locationSelectionPendingSuggestion', selected); + hasExplicitSelection = true; + if (statusEl) statusEl.textContent = `Selected: ${selected.display_name || `${draft.lat.toFixed(5)}, ${draft.lon.toFixed(5)}`}`; + if (inputEl) inputEl.value = String(selected.display_name || inputEl.value); + hideDropdown(); + if (continueButton) continueButton.disabled = false; + }; + + const renderResults = (results: NominatimResult[]) => { + if (!dropdownEl) return; + latestResults = results.slice(); + taskStore('locationSelectionPendingSuggestion', null); + if (highlightedIndex >= latestResults.length) highlightedIndex = latestResults.length - 1; + if (!results.length) { + dropdownEl.innerHTML = '
No matches found.
'; + dropdownEl.style.display = 'block'; + return; + } + dropdownEl.innerHTML = results + .map((result, index) => { + const label = String(result.display_name || 'Unknown location'); + const bg = index === highlightedIndex ? '#eff6ff' : '#fff'; + const labelHtml = highlightLabel(label, latestQuery); + return ``; + }) + .join(''); + dropdownEl.style.display = 'block'; + if (highlightedIndex >= 0) { + const active = dropdownEl.querySelector(`button[data-result-index="${highlightedIndex}"]`); + active?.scrollIntoView({ block: 'nearest' }); + } + dropdownEl.querySelectorAll('button[data-result-index]').forEach((button) => { + button.addEventListener('mousedown', (event) => { + event.preventDefault(); + const idx = Number(button.dataset.resultIndex); + const selected = latestResults[idx]; + if (!selected) return; + selectResult(selected); + }); + }); + }; + + const runSearch = async () => { + const query = String(inputEl?.value || '').trim(); + latestQuery = query; + if (!selectedCountry) { + hideDropdown(); + if (statusEl) statusEl.textContent = 'Select a country first.'; + return; + } + if (!query) { + hideDropdown(); + if (statusEl) statusEl.textContent = 'Type at least 2 characters.'; + return; + } + if (query.length < 2) { + hideDropdown(); + taskStore('locationSelectionPendingSuggestion', null); + return; + } + if (statusEl) statusEl.textContent = `Searching in ${selectedCountry}…`; + const requestId = latestRequestId + 1; + latestRequestId = requestId; + const results = await searchLocations(query, selectedCountry); + if (requestId !== latestRequestId) return; + highlightedIndex = results.length ? 0 : -1; + renderResults(results); + if (statusEl) statusEl.textContent = results.length ? 'Pick the best match from the dropdown.' : 'No matches found.'; + }; + + loadCountryOptions() + .then((countries) => { + if (!countryEl) return; + countryEl.innerHTML = countries + .map((country) => ``) + .join(''); + selectedCountry = countryEl.value || 'US'; + taskStore('locationSelectionPendingCountry', selectedCountry); + if (statusEl) statusEl.textContent = 'Country selected. Start typing a city or postal code.'; + }) + .catch((error: any) => { + if (statusEl) statusEl.textContent = `Could not load full country list (${error?.message || error}).`; + }); + + countryEl?.addEventListener('change', () => { + selectedCountry = String(countryEl.value || '').toUpperCase(); + taskStore('locationSelectionPendingCountry', selectedCountry); + hasExplicitSelection = false; + if (continueButton) continueButton.disabled = true; + hideDropdown(); + if (inputEl) inputEl.value = ''; + taskStore('locationSelectionDraft', null); + taskStore('locationSelectionPendingSuggestion', null); + latestResults = []; + if (statusEl) statusEl.textContent = `Country set to ${selectedCountry}. Start typing to see matches.`; + }); + + inputEl?.addEventListener('input', () => { + hasExplicitSelection = false; + taskStore('locationSelectionDraft', null); + taskStore('locationSelectionPendingSuggestion', null); + if (continueButton) continueButton.disabled = true; + if (debounceHandle) { + window.clearTimeout(debounceHandle); + } + debounceHandle = window.setTimeout(() => { + runSearch().catch((error: any) => { + if (statusEl) statusEl.textContent = `Search failed: ${error?.message || error}`; + }); + }, 250); + }); + + inputEl?.addEventListener('focus', () => { + const hasOptions = Boolean(dropdownEl && dropdownEl.innerHTML.trim()); + if (hasOptions && dropdownEl) dropdownEl.style.display = 'block'; + }); + + document.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + if (!target) return; + const clickedInside = Boolean(target.closest('#location-autocomplete-dropdown') || target.closest('#location-query-input')); + if (!clickedInside) hideDropdown(); + }); + + inputEl?.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + if (latestResults.length) { + event.preventDefault(); + highlightedIndex = Math.min(latestResults.length - 1, highlightedIndex + 1); + renderResults(latestResults); + } + return; + } + if (event.key === 'ArrowUp') { + if (latestResults.length) { + event.preventDefault(); + highlightedIndex = Math.max(0, highlightedIndex - 1); + renderResults(latestResults); + } + return; + } + if (event.key === 'Escape') { + hideDropdown(); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < latestResults.length) { + selectResult(latestResults[highlightedIndex]); + return; + } + if (!hasExplicitSelection) { + if (statusEl) statusEl.textContent = 'Select a result from the dropdown first.'; + if (continueButton) continueButton.disabled = true; + return; + } + runSearch().catch((error: any) => { + if (statusEl) statusEl.textContent = `Search failed: ${error?.message || error}`; + }); + } + }); + }, + on_finish: (data: Record) => { + const draft = getLocationSelectionDraft(); + data.mode = 'city_postal'; + data.selectedLocation = draft || null; + taskStore('locationSelectionLastStep', 'city_postal_search'); + }, + }, + ], + conditional_function: () => taskStore().locationSelectionMode === 'city_postal', +}; diff --git a/task-launcher/src/tasks/shared/helpers/config.ts b/task-launcher/src/tasks/shared/helpers/config.ts index 7d0d342c0..c513498bc 100644 --- a/task-launcher/src/tasks/shared/helpers/config.ts +++ b/task-launcher/src/tasks/shared/helpers/config.ts @@ -93,6 +93,12 @@ export const setSharedConfig = async ( semThreshold, startingTheta, demoMode, + populationSourcePreference, + konturPopulationApiUrl, + konturPopulationBatchApiUrl, + worldpopPopulationApiUrl, + populationApiTimeoutMs, + populationBatchEnabled, } = cleanParams; const config = { @@ -123,6 +129,12 @@ export const setSharedConfig = async ( semThreshold: Number(semThreshold), startingTheta: Number(startingTheta), demoMode: !!demoMode, + populationSourcePreference: String(populationSourcePreference || 'kontur'), + konturPopulationApiUrl: konturPopulationApiUrl ? String(konturPopulationApiUrl) : undefined, + konturPopulationBatchApiUrl: konturPopulationBatchApiUrl ? String(konturPopulationBatchApiUrl) : undefined, + worldpopPopulationApiUrl: worldpopPopulationApiUrl ? String(worldpopPopulationApiUrl) : undefined, + populationApiTimeoutMs: Number(populationApiTimeoutMs) || undefined, + populationBatchEnabled: !!populationBatchEnabled, }; // default corpus if nothing is passed in diff --git a/task-launcher/src/tasks/shared/helpers/getMediaAssets.ts b/task-launcher/src/tasks/shared/helpers/getMediaAssets.ts index 712f60d55..cf4c70cf5 100644 --- a/task-launcher/src/tasks/shared/helpers/getMediaAssets.ts +++ b/task-launcher/src/tasks/shared/helpers/getMediaAssets.ts @@ -21,8 +21,8 @@ type ResponseDataType = { export async function getMediaAssets( bucketName: string, whitelist: Record = {}, - language: string, taskName: string, + language: string, nextPageToken = '', categorizedObjects: CategorizedObjectsType = { images: {}, audio: {}, video: {} }, ) { @@ -38,10 +38,15 @@ export async function getMediaAssets( } const response = await fetch(url); + if (!response.ok) { + // New/scaffold tasks may not have media buckets yet; return empty assets rather than throw. + return categorizedObjects; + } const data: ResponseDataType = await response.json(); + const items = Array.isArray(data?.items) ? data.items : []; - data.items.forEach((item) => { - if (isLanguageAndDeviceValid(item.name, taskName, language) && isWhitelisted(item.name, whitelist)) { + items.forEach((item) => { + if (isLanguageAndDeviceValid(item.name, language, taskName) && isWhitelisted(item.name, whitelist)) { const contentType = item.contentType; const id = item.name; const path = `https://storage.googleapis.com/${bucket}/${id}`; diff --git a/task-launcher/src/tasks/taskConfig.ts b/task-launcher/src/tasks/taskConfig.ts index 957bdd888..f24ed7fc2 100644 --- a/task-launcher/src/tasks/taskConfig.ts +++ b/task-launcher/src/tasks/taskConfig.ts @@ -15,6 +15,7 @@ import tROGTimeline from './trog/timeline'; import inferenceTimeline from './roar-inference/timeline'; import adultReasoningTimeline from './adult-reasoning/timeline'; import childSurveyTimeline from './child-survey/timeline'; +import locationSelectionTimeline from './location-selection/timeline'; // TODO: Abstract to import config from specifc task folder // Will allow for multiple devs to work on the repo without merge conflicts @@ -130,4 +131,11 @@ export default { buildTaskTimeline: childSurveyTimeline, variants: {}, }, + locationSelection: { + setConfig: setSharedConfig, + getCorpus: getCorpus, + getTranslations: getTranslations, + buildTaskTimeline: locationSelectionTimeline, + variants: {}, + }, }; diff --git a/task-launcher/webpack.config.cjs b/task-launcher/webpack.config.cjs index 1c29b3715..8dada6d1c 100644 --- a/task-launcher/webpack.config.cjs +++ b/task-launcher/webpack.config.cjs @@ -2,6 +2,7 @@ const path = require('path'); const webpack = require('webpack'); const { merge } = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const { registerPopulationApi } = require('./serve/populationApi.cjs'); const commonConfig = { optimization: { @@ -126,6 +127,12 @@ const developmentConfig = merge(webConfig, { devtool: 'inline-source-map', devServer: { static: './dist', + setupMiddlewares: (middlewares, devServer) => { + if (devServer?.app) { + registerPopulationApi(devServer.app); + } + return middlewares; + }, }, });