Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
// data.js — Making Sense Bali · sensor data layer
// Aggregates environmental sensor data from multiple public networks for Bali.
// Loaded by both index.html (home) and dashboard/index.html.
//
// Adding a new source: write a fetch* function returning the normalized shape
// described below, then add it to fetchAllSensors() Promise.all.
//
// Normalized sensor shape:
// {
// id: string unique cross-source ID, prefixed by source
// rawId: number|string original platform ID (for direct API calls)
// source: string 'smartcitizen' | 'openaq' | 'purpleair' | ...
// sourceLabel: string human label for popup attribution
// name: string sensor name
// description: string optional contextual text
// lat: number, lng: number
// lastReading: ISO string|null
// reading: { pm25?, pm10?, temp?, rh?, noise? } pre-fetched values where available
// detailsUrl: string public URL on the source platform
// }
'use strict';
// ============================================================
// CONFIG
// ============================================================
const BALI_BOUNDS = {
minLat: -8.95, maxLat: -8.00,
minLng: 114.40, maxLng: 115.85,
};
const BALI_CENTER = [-8.4095, 115.1889];
// Private/personal sensors — never shown publicly.
// (Currently empty: Tomas's house + office sensors are part of the public network.)
const EXCLUDED_DEVICE_IDS = new Set([]);
// Known Bali devices to add explicitly even if the world_map endpoint
// doesn't return them (or returns them without coordinates). This is a
// safety net — the pagination in fetchSmartCitizenSensors() below
// should catch all Bali devices automatically. Only add here if a
// sensor needs to appear even when world_map is broken / unreachable.
const KNOWN_BALI_SCK_IDS = [19236, 19600, 19618, 19651];
// ============================================================
// HELPERS
// ============================================================
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
// Classify PM2.5 reading against WHO 2021 24-hour guideline (15 µg/m³).
// Returns 'good' | 'warn' | 'bad' | 'unknown'.
function classifyPM25(v){
if(v == null || isNaN(v)) return 'unknown';
if(v <= 15) return 'good'; // WHO 24h
if(v <= 30) return 'warn'; // 1–2× WHO
return 'bad'; // > 2× WHO
}
function inBali(lat, lng){
if(typeof lat !== 'number' || typeof lng !== 'number') return false;
if(lat < BALI_BOUNDS.minLat || lat > BALI_BOUNDS.maxLat) return false;
if(lng < BALI_BOUNDS.minLng || lng > BALI_BOUNDS.maxLng) return false;
return true;
}
// Sensor "active" if reading within last 14 days (matches Bali Air Dispatch's
// "Archive of Silence" threshold of 2 weeks before considering a sensor dark).
function isActive(lastReadingIso){
if(!lastReadingIso) return false;
const ageMs = Date.now() - new Date(lastReadingIso).getTime();
return ageMs <= 14 * 24 * 3600 * 1000;
}
// ============================================================
// ADAPTER: Smart Citizen Platform
// Direct: https://api.smartcitizen.me/v0
// Via Cloudflare Worker proxy: ${SCK_PROXY_BASE} (same worker as OpenAQ)
//
// We default to the proxy because api.smartcitizen.me's CORS policy can
// block browser calls from github.io origins. The proxy also caches
// responses for ~60s at the Cloudflare edge.
// ============================================================
const SCK_PROXY_BASE = 'https://scb-bali.tomas-74b.workers.dev/sck';
async function sckFetch(path){
const base = SCK_PROXY_BASE || 'https://api.smartcitizen.me/v0';
const url = base + path;
const r = await fetch(url, {headers:{'Accept':'application/json'}});
if(!r.ok) throw new Error(`SCK HTTP ${r.status} at ${path}`);
return r.json();
}
async function fetchSmartCitizenSensors(){
// Strategy:
// 1. Seed with the known Bali devices (safety net for when discovery breaks)
// 2. Paginate world_map until empty/short — discovers any new Bali sensors
// that get registered with Smart Citizen, no code change required
const ids = new Set(KNOWN_BALI_SCK_IDS.filter(id => !EXCLUDED_DEVICE_IDS.has(id)));
// Client-side world_map discovery is DISABLED. Scanning the global device
// list (up to 30×500) to find Bali sensors is slow and was exceeding the
// per-source timeout — which made the ENTIRE Smart Citizen source return
// empty (no sensors on the map at all). The known Bali kits load directly
// below (fast, well under the timeout). Full discovery of new Bali sensors
// belongs server-side in the sync generator, which writes the complete list
// to data/sensors.json without any client-time pressure. Set >0 only if you
// re-enable a bounded client scan.
const SCK_PAGE_SIZE = 500;
const SCK_MAX_PAGES = 0;
let pagesFetched = 0;
let totalReturned = 0;
let baliFound = 0;
let lastError = null;
for (let page = 1; page <= SCK_MAX_PAGES; page++) {
let batch;
try {
batch = await sckFetch(`/devices/world_map?per_page=${SCK_PAGE_SIZE}&page=${page}`);
} catch (e) {
lastError = e;
console.log(`[SCK] world_map page ${page} failed: ${e.message}`);
break;
}
if (!Array.isArray(batch)) {
console.log(`[SCK] world_map page ${page} returned non-array, stopping`);
break;
}
pagesFetched++;
totalReturned += batch.length;
for (const d of batch) {
const lat = d.latitude ?? d.lat;
const lng = d.longitude ?? d.lng ?? d.lon;
if (!inBali(lat, lng)) continue;
if (EXCLUDED_DEVICE_IDS.has(d.id)) continue;
if (!ids.has(d.id)) baliFound++;
ids.add(d.id);
}
// Stop when we get a short page (API has no more) — saves the
// remaining cap when nothing's left to fetch.
if (batch.length < SCK_PAGE_SIZE) break;
}
console.log(
`[SCK] world_map: ${pagesFetched} page(s), ${totalReturned} devices scanned, `
+ `${baliFound} new in Bali bbox (plus ${KNOWN_BALI_SCK_IDS.length} known) → ${ids.size} total to fetch`
+ (lastError ? ` (last error: ${lastError.message})` : '')
);
// Helper: extract coords from any of several response shapes
function extractCoords(detail){
// Try multiple field locations and types — Smart Citizen has shipped
// different shapes over the years
const candidates = [
[detail.latitude, detail.longitude],
[detail.lat, detail.lng],
[detail.location && detail.location.latitude, detail.location && detail.location.longitude],
[detail.location && detail.location.lat, detail.location && detail.location.lng],
[detail.data && detail.data.location && detail.data.location.latitude, detail.data && detail.data.location && detail.data.location.longitude],
];
for(const [la, ln] of candidates){
const latNum = typeof la === 'string' ? parseFloat(la) : la;
const lngNum = typeof ln === 'string' ? parseFloat(ln) : ln;
if(typeof latNum === 'number' && typeof lngNum === 'number' && !isNaN(latNum) && !isNaN(lngNum)){
return [latNum, lngNum];
}
}
return [null, null];
}
// Fetch each device's full detail (parallel)
const isKnown = id => KNOWN_BALI_SCK_IDS.includes(id);
const results = await Promise.all([...ids].map(async id => {
try {
const detail = await sckFetch(`/devices/${id}`);
const [lat, lng] = extractCoords(detail);
console.log(`[SCK] device ${id}: coords (${lat}, ${lng}), keys: [${Object.keys(detail).slice(0, 10).join(',')}]`);
if(lat == null || lng == null){
console.warn(`[SCK] device ${id}: no coordinates found`);
return null;
}
// Trust known IDs even if outside the bbox heuristic — they're explicitly
// listed because we know they're in Bali, even if coords look weird.
if(!isKnown(id) && !inBali(lat, lng)){
console.warn(`[SCK] device ${id}: outside Bali bbox (${lat}, ${lng})`);
return null;
}
const sensors = (detail.data && detail.data.sensors) || detail.sensors || [];
const findVal = names => {
const m = sensors.find(x => names.some(n => (x.name||'').includes(n)));
return m && typeof m.value === 'number' ? m.value : null;
};
return {
id: `sck-${detail.id || id}`,
rawId: detail.id || id,
source: 'smartcitizen',
sourceLabel: 'Smart Citizen',
name: detail.name || `Device ${detail.id || id}`,
description: detail.description || '',
lat, lng,
lastReading: (detail.data && detail.data.recorded_at) || detail.last_reading_at || null,
reading: {
pm25: findVal(['PM 2.5','PM2.5']),
pm10: findVal(['PM 10','PM10']),
temp: findVal(['Air Temperature','Temperature']),
rh: findVal(['Relative Humidity','Humidity']),
noise: findVal(['Noise']),
},
detailsUrl: `https://smartcitizen.me/kits/${detail.id || id}`,
};
} catch(e){
console.warn(`[SCK] device ${id} failed:`, e.message);
return null;
}
}));
const valid = results.filter(Boolean);
console.log(`[SCK] returning ${valid.length} sensor(s)`);
return valid;
}
// Per-device detail call (used by dashboard's "click pin" panel)
async function fetchSmartCitizenDetail(rawId){
return sckFetch(`/devices/${rawId}`);
}
// ------------------------------------------------------------
// Historical readings (used for dashboard time-series charts)
//
// Smart Citizen's /v0/devices/{id}/readings endpoint takes:
// sensor_id required, global sensor catalog ID
// from / to ISO 8601 window bounds
// rollup optional bucketing — '5m', '1h', etc.
//
// Response shape (observed):
// {
// device_id, sensor_key, sensor_id, rollup, from, to,
// readings: [[ ISO_timestamp, value ], ...], // chronological
// }
//
// We normalize to [{ recordedAt, value }, ...] for the charting code.
//
// CACHING (localStorage):
// Key: scb-sckhist:{deviceId}:{sensorId}:{rollup}:{from}:{to}
// TTL: 5 minutes. Reduces SC API pressure when the dashboard auto-refreshes
// and when users toggle between sensors on the same device.
// ------------------------------------------------------------
const SCK_HISTORY_TTL_MS = 5 * 60 * 1000;
const SCK_HISTORY_KEY_PREFIX = 'scb-sckhist:';
function _historyCacheKey(deviceId, sensorId, fromIso, toIso, rollup){
return `${SCK_HISTORY_KEY_PREFIX}${deviceId}:${sensorId}:${rollup || 'raw'}:${fromIso}:${toIso}`;
}
function _historyCacheGet(key){
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj || typeof obj.savedAt !== 'number') return null;
if (Date.now() - obj.savedAt > SCK_HISTORY_TTL_MS) {
localStorage.removeItem(key);
return null;
}
return Array.isArray(obj.points) ? obj.points : null;
} catch (_){
return null;
}
}
function _historyCachePut(key, points){
try {
localStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), points }));
} catch (_){
// Quota exhausted — best-effort GC of our own keys, then give up silently.
try {
for (let i = localStorage.length - 1; i >= 0; i--){
const k = localStorage.key(i);
if (k && k.startsWith(SCK_HISTORY_KEY_PREFIX)) localStorage.removeItem(k);
}
} catch(_){}
}
}
// fetchSmartCitizenHistory(19651, 87, '2026-05-29T...', '2026-05-30T...', '5m')
async function fetchSmartCitizenHistory(deviceId, sensorId, fromIso, toIso, rollup = null){
const cacheKey = _historyCacheKey(deviceId, sensorId, fromIso, toIso, rollup);
const cached = _historyCacheGet(cacheKey);
if (cached) return cached;
const params = new URLSearchParams();
params.set('sensor_id', String(sensorId));
params.set('from', fromIso);
params.set('to', toIso);
if (rollup) params.set('rollup', rollup);
const path = `/devices/${deviceId}/readings?${params.toString()}`;
let data;
try {
data = await sckFetch(path);
} catch (e){
console.warn(`[SCK history] device=${deviceId} sensor=${sensorId} failed:`, e.message);
return [];
}
// Defensive: the API has shipped multiple shapes over the years.
const rawReadings =
(Array.isArray(data && data.readings) && data.readings) ||
(Array.isArray(data) && data) ||
[];
const points = rawReadings.map(r => {
// Each reading is typically [iso, value] but some endpoints return objects.
if (Array.isArray(r)) return { recordedAt: r[0], value: typeof r[1] === 'number' ? r[1] : parseFloat(r[1]) };
if (r && typeof r === 'object'){
const recordedAt = r.recorded_at || r.timestamp || r[0];
const value = r.value != null ? r.value : r[1];
return { recordedAt, value: typeof value === 'number' ? value : parseFloat(value) };
}
return null;
}).filter(p => p && p.recordedAt && typeof p.value === 'number' && !isNaN(p.value));
_historyCachePut(cacheKey, points);
return points;
}
// Fetch history for ALL sensors on a device in parallel, returned as
// { [sensorId]: [{recordedAt, value}, ...] }. Pass the sensor list from
// the device's detail call. Used by the "Selected" panel.
async function fetchSmartCitizenHistoryBulk(deviceId, sensorIds, fromIso, toIso, rollup = null){
const ids = Array.from(new Set(sensorIds.filter(id => id != null)));
const entries = await Promise.all(ids.map(async sid => {
const points = await fetchSmartCitizenHistory(deviceId, sid, fromIso, toIso, rollup);
return [sid, points];
}));
const out = {};
for (const [sid, points] of entries) out[sid] = points;
return out;
}
// ============================================================
// ADAPTER: OpenAQ v3 (via Cloudflare Worker proxy)
// https://api.openaq.org/v3 doesn't support CORS preflight for X-API-Key,
// so the browser can't call it directly. We route through a Cloudflare
// Worker that adds the API key server-side and returns CORS headers.
//
// Configure: set OPENAQ_PROXY_BASE to your worker URL + '/openaq'.
// If left empty, the adapter falls back to direct fetch (will fail with CORS,
// but won't break the page — error shows in the source-status diagnostic).
// ============================================================
const OPENAQ_PROXY_BASE = 'https://scb-bali.tomas-74b.workers.dev/openaq';
const OPENAQ_API_KEY = 'd85c862e2685ab786503f7648fc9581158527bcc617383e6b95db460594baf6f';
async function fetchOpenAQSensors(){
const useProxy = !!OPENAQ_PROXY_BASE;
const baseUrl = useProxy ? OPENAQ_PROXY_BASE : 'https://api.openaq.org/v3';
const headers = useProxy
? {'Accept': 'application/json'} // worker adds the key
: {'X-API-Key': OPENAQ_API_KEY, 'Accept': 'application/json'}; // direct (will hit CORS)
const bbox = `${BALI_BOUNDS.minLng},${BALI_BOUNDS.minLat},${BALI_BOUNDS.maxLng},${BALI_BOUNDS.maxLat}`;
try {
// Step 1: locations in Bali bounding box
const locUrl = `${baseUrl}/locations?bbox=${bbox}&limit=1000`;
const r = await fetch(locUrl, {headers});
if(!r.ok){
const body = await r.text().catch(()=>'');
throw new Error(`OpenAQ locations HTTP ${r.status} ${body.slice(0,200)}`);
}
const data = await r.json();
const locations = (data && data.results) || [];
if(!locations.length){
console.log('[OpenAQ] no locations in Bali bbox');
return [];
}
console.log(`[OpenAQ] ${locations.length} location(s) in Bali bbox — fetching latest values`);
// Step 2: for each location, fetch latest measurements (parallel)
const enriched = await Promise.all(locations.map(async loc => {
// Build sensorId → parameter.name map from location's sensors[] array
const sensorParamMap = {};
for(const s of (loc.sensors || [])){
if(s.parameter && s.parameter.name && s.id != null){
sensorParamMap[s.id] = s.parameter.name;
}
}
try {
const lr = await fetch(`${baseUrl}/locations/${loc.id}/latest`, {headers});
if(!lr.ok) return {loc, readings: {}};
const ldata = await lr.json();
const measurements = (ldata && ldata.results) || [];
const readings = {};
for(const m of measurements){
const param = sensorParamMap[m.sensorsId];
if(param && typeof m.value === 'number'){
readings[param] = {
value: m.value,
datetime: (m.datetime && m.datetime.utc) || null,
};
}
}
return {loc, readings};
} catch(e){
console.warn(`[OpenAQ] latest ${loc.id} failed:`, e);
return {loc, readings: {}};
}
}));
// Step 3: normalize to our common shape
const sensors = enriched.map(({loc, readings}) => {
const lat = loc.coordinates && loc.coordinates.latitude;
const lng = loc.coordinates && loc.coordinates.longitude;
if(typeof lat !== 'number' || typeof lng !== 'number') return null;
const providerNames = (loc.providers || []).map(p => p && p.name).filter(Boolean).join(', ');
const sourceLabel = providerNames ? `OpenAQ · ${providerNames}` : 'OpenAQ';
// Latest reading datetime — prefer freshest from measurements,
// fall back to location's datetimeLast metadata.
let lastReading = (loc.datetimeLast && loc.datetimeLast.utc) || null;
for(const v of Object.values(readings)){
if(v.datetime && (!lastReading || v.datetime > lastReading)) lastReading = v.datetime;
}
return {
id: `openaq-${loc.id}`,
rawId: loc.id,
source: 'openaq',
sourceLabel,
name: loc.name || `OpenAQ #${loc.id}`,
description: [loc.locality, providerNames].filter(Boolean).join(' · '),
lat, lng,
lastReading,
reading: {
pm25: readings.pm25 ? readings.pm25.value : null,
pm10: readings.pm10 ? readings.pm10.value : null,
temp: readings.temperature ? readings.temperature.value : null,
rh: readings.relativehumidity ? readings.relativehumidity.value :
(readings.humidity ? readings.humidity.value : null),
},
detailsUrl: `https://explore.openaq.org/locations/${loc.id}`,
};
}).filter(Boolean);
console.log(`[OpenAQ] returning ${sensors.length} normalized sensor(s)`);
return sensors;
} catch(e){
console.warn('[OpenAQ] fetch failed:', e);
return [];
}
}
// ============================================================
// ADAPTERS — TODO
// ============================================================
async function fetchPurpleAirSensors(){
// TODO. https://api.purpleair.com/v1/sensors?bbox=...
// Requires free X-API-Key. Two known active Bali devices (Jimbaran, Klungkung).
return [];
}
// ============================================================
// ADAPTER: Sensor.Community
// https://data.sensor.community/static/v2/data.json
// Static global feed, refreshed every ~5 minutes, no auth, CORS-friendly.
// Returns the most recent reading per sensor worldwide (~5-15MB JSON).
// We filter to Bali client-side, group measurements by location.
//
// Sensor.Community uses non-standard parameter codes:
// P1 = PM10 · P2 = PM2.5 · temperature, humidity, pressure
// ============================================================
async function fetchSensorCommunitySensors(){
const url = 'https://data.sensor.community/static/v2/data.json';
try {
const r = await fetch(url, {headers: {'Accept':'application/json'}});
if(!r.ok) throw new Error(`Sensor.Community HTTP ${r.status}`);
const items = await r.json();
if(!Array.isArray(items)) throw new Error('unexpected response shape');
// Group measurements by location
const byLoc = new Map();
for(const it of items){
const loc = it.location;
if(!loc) continue;
const lat = parseFloat(loc.latitude);
const lng = parseFloat(loc.longitude);
if(!inBali(lat, lng)) continue;
if(!byLoc.has(loc.id)){
byLoc.set(loc.id, {
locId: loc.id, lat, lng, country: loc.country,
city: loc.city || loc.name || '',
measurements: [],
});
}
byLoc.get(loc.id).measurements.push({
timestamp: it.timestamp,
sensorType: it.sensor && it.sensor.sensor_type && it.sensor.sensor_type.name,
values: it.sensordatavalues || [],
});
}
const sensors = [];
for(const agg of byLoc.values()){
let pm25 = null, pm10 = null, temp = null, rh = null, latestTs = null;
for(const m of agg.measurements){
for(const v of m.values){
const val = parseFloat(v.value);
if(isNaN(val)) continue;
if(v.value_type === 'P2' && pm25 === null) pm25 = val; // PM2.5
if(v.value_type === 'P1' && pm10 === null) pm10 = val; // PM10
if(/temperature/i.test(v.value_type) && temp === null) temp = val;
if(/humidity/i.test(v.value_type) && rh === null) rh = val;
}
if(!latestTs || m.timestamp > latestTs) latestTs = m.timestamp;
}
const sensorTypes = [...new Set(agg.measurements.map(m => m.sensorType).filter(Boolean))];
sensors.push({
id: `sensorcomm-${agg.locId}`,
rawId: agg.locId,
source: 'sensor_community',
sourceLabel: 'Sensor.Community',
name: sensorTypes.length ? `${sensorTypes[0]} · #${agg.locId}` : `Sensor.Community #${agg.locId}`,
description: [agg.city, sensorTypes.join(', ')].filter(Boolean).join(' · '),
lat: agg.lat, lng: agg.lng,
lastReading: latestTs ? new Date(latestTs.replace(' ','T') + 'Z').toISOString() : null,
reading: { pm25, pm10, temp, rh },
detailsUrl: `https://maps.sensor.community/#13/${agg.lat}/${agg.lng}`,
});
}
console.log(`[SensorCommunity] ${sensors.length} sensor(s) in Bali (${items.length} global measurements)`);
return sensors;
} catch(e){
console.warn('[SensorCommunity] fetch failed:', e);
return [];
}
}
// ============================================================
// AGGREGATOR
// ============================================================
// Diagnostic: track per-source results so the UI can show what worked
const SOURCE_STATUS = {
smartcitizen: {count: 0, error: null},
openaq: {count: 0, error: null},
sensor_community: {count: 0, error: null},
purpleair: {count: 0, error: 'not configured'},
};
// Bound every source so one slow/hanging API (OpenAQ, Sensor.Community) can't
// block the whole map render. The underlying fetch may still finish in the
// background — we just stop waiting past the timeout and render what we have.
const SOURCE_TIMEOUT_MS = 8000;
function withTimeout(promise, ms, label){
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),
]);
}
async function tracked(key, fn){
try {
const r = await withTimeout(Promise.resolve().then(fn), SOURCE_TIMEOUT_MS, key);
SOURCE_STATUS[key].count = r.length;
SOURCE_STATUS[key].error = null;
return r;
} catch(e){
SOURCE_STATUS[key].error = String(e.message || e);
console.error(`[${key}]`, e);
return [];
}
}
async function fetchAllSensors(){
const results = await Promise.all([
tracked('smartcitizen', fetchSmartCitizenSensors),
tracked('openaq', fetchOpenAQSensors),
tracked('sensor_community', fetchSensorCommunitySensors),
// tracked('purpleair', fetchPurpleAirSensors), // not configured
]);
const all = results.flat();
// Deduplicate by approx coordinate (~10m precision) — handles cases where
// the same physical sensor publishes to two networks.
const seen = new Set();
const deduped = all.filter(s => {
if(typeof s.lat !== 'number' || typeof s.lng !== 'number') return false;
const key = `${s.lat.toFixed(4)},${s.lng.toFixed(4)}`;
if(seen.has(key)) return false;
seen.add(key);
return true;
});
console.log(`[SCB] aggregated ${deduped.length} unique sensors across ${results.filter(r=>r.length).length} sources`);
return deduped;
}
// ============================================================
// SNAPSHOT-FIRST LOADING (sensors + areas)
// ============================================================
// The mini's sync generator (tools/makingsense-sync/) writes data/sensors.json
// and data/areas.json every ~15 min. Loading those static files is instant;
// we only fall back to the live (slow) API fan-out when the snapshot is
// absent — with a short localStorage cache so even that path is fast on
// repeat visits. This degrades gracefully: before the generator is deployed,
// behaviour is exactly the old live fetch.
function dataBaseUrl(){
const path = window.location.pathname;
if(/\/dashboard\/?(index\.html)?$/.test(path)) return '../data';
return './data';
}
const SENSORS_CACHE_KEY = 'scb-sensors-cache-v1';
const SENSORS_CACHE_TTL_MS = 10 * 60 * 1000;
function _sensorsCacheGet(){
try{
const o = JSON.parse(localStorage.getItem(SENSORS_CACHE_KEY) || 'null');
if(!o || (Date.now() - o.savedAt) > SENSORS_CACHE_TTL_MS) return null;
return (Array.isArray(o.sensors) && o.sensors.length) ? o.sensors : null;
}catch(_){ return null; }
}
function _sensorsCachePut(sensors){
try{ localStorage.setItem(SENSORS_CACHE_KEY, JSON.stringify({savedAt: Date.now(), sensors})); }catch(_){}
}
async function fetchSensorsSnapshot(){
try{
const r = await fetch(`${dataBaseUrl()}/sensors.json`, {cache:'no-cache'});
if(!r.ok) return null;
const j = await r.json();
const s = Array.isArray(j.sensors) ? j.sensors : null;
return (s && s.length) ? s : null;
}catch(_){ return null; }
}
// Snapshot first (instant) → recent live cache → full live fan-out (cold path).
async function fetchSensorsFast(){
const snap = await fetchSensorsSnapshot();
if(snap){ console.log(`[SCB] sensors from snapshot (${snap.length})`); return snap; }
const cached = _sensorsCacheGet();
if(cached){ console.log(`[SCB] sensors from cache (${cached.length})`); return cached; }
const live = await fetchAllSensors();
if(live && live.length) _sensorsCachePut(live);
return live;
}
async function fetchAreas(){
try{
const r = await fetch(`${dataBaseUrl()}/areas.json`, {cache:'no-cache'});
if(!r.ok) return [];
const j = await r.json();
return Array.isArray(j.areas) ? j.areas : [];
}catch(_){ return []; }
}
// ============================================================
// REPORTS — citizen observations via WhatsApp bot
// ============================================================
// Approved reports from the Sense Making bot (reports/ component) are
// published to data/reports/ in this repo by the bot's sync_profiles.sh
// script. Each report is a sanitized Murmurations profile (PII-stripped).
//
// File layout in the repo:
// data/reports/index.json — { profiles: ["AQ_…json", …], count: N, generated_at: ISO }
// data/reports/AQ_<id>.json — one Murmurations profile per approved report
//
// This function reads the index, fetches each profile, and normalizes
// to the report shape the map renderer expects (lat, lng, title,
// description, category, severity, submittedAt, locality).
// Resolve the data/reports/ URL relative to the current page. Home page
// is at the repo root; dashboard is one level deeper.
function reportsBaseUrl(){
const path = window.location.pathname;
// Match any path that ends in /dashboard or /dashboard/ (with optional trailing index.html)
if(/\/dashboard\/?(index\.html)?$/.test(path)) return '../data/reports';
return './data/reports';
}
function profileToReport(profile){
if(!profile) return null;
const lat = profile.latitude;
const lng = profile.longitude;
if(typeof lat !== 'number' || typeof lng !== 'number') return null;
const ai = profile.ai_analysis || {};
const id = profile.primary_url
? profile.primary_url.split('/').pop().replace('.json','')
: (profile.name || 'report');
// photo_path is only present when the operator explicitly checked
// "Publish photo too" at approval time. Resolves to an absolute URL
// the browser can load, sitting in the same data/reports/ tree as
// the profile JSON itself.
let photoUrl = null;
if(typeof profile.photo_path === 'string' && profile.photo_path){
photoUrl = `${reportsBaseUrl()}/${profile.photo_path.replace(/^\/+/, '')}`;
}
return {
id,
lat, lng,
title: profile.name || 'Citizen report',
description: profile.description || '',
category: profile.pollution_category || 'other',
severity: ai.severity || null,
// The model's natural-language description of the scene. Often
// useful to compare against the reporter's own description.
aiDescription: (ai.description || '').trim() || null,
indicators: Array.isArray(ai.indicators) ? ai.indicators : [],
// Worker writes this when MLX inference succeeds — handy on the
// admin side to filter "AI-analyzed" vs "needs review" reports.
aiConfidence: typeof ai.confidence === 'number' ? ai.confidence : null,
submittedAt: profile.date_added || null,
locality: profile.locality || '',
photoUrl,
source: 'whatsapp_bot',
sourceLabel: 'Resident report',
profileUrl: profile.primary_url || null,
};
}
async function fetchReports(){
const base = reportsBaseUrl();
try {
const indexResp = await fetch(`${base}/index.json`, {cache: 'no-cache'});
if(!indexResp.ok){
// 404 just means no approved reports yet — silent
if(indexResp.status === 404) return [];
throw new Error(`reports index HTTP ${indexResp.status}`);
}
const index = await indexResp.json();
const filenames = Array.isArray(index.profiles) ? index.profiles : [];
if(!filenames.length) return [];
console.log(`[reports] fetching ${filenames.length} approved profile(s)`);
const results = await Promise.all(filenames.map(async fn => {
try {
const r = await fetch(`${base}/${fn}`, {cache: 'no-cache'});
if(!r.ok) return null;
const profile = await r.json();
return profileToReport(profile);
} catch(e){
console.warn(`[reports] failed to fetch ${fn}:`, e.message);
return null;
}
}));
const valid = results.filter(Boolean);
console.log(`[reports] returning ${valid.length} valid report(s)`);
return valid;
} catch(e){
console.warn('[reports] fetch failed:', e);
return [];
}
}
// Daily/weekly summary written by sync_profiles.sh via generate_summary.py.
// Optional — if the file is missing (older deployments, sync not yet run),
// callers should fall back to their existing category-count rendering.
async function fetchReportsSummary(){
const base = reportsBaseUrl();
try {
const r = await fetch(`${base}/summary.json`, {cache: 'no-cache'});
if(!r.ok) return null;
return await r.json();
} catch(e){
console.warn('[reports] summary fetch failed:', e);
return null;
}
}
// ============================================================
// EXPORT
// ============================================================
if(typeof window !== 'undefined'){
window.SCB_DATA = {
BALI_BOUNDS, BALI_CENTER, EXCLUDED_DEVICE_IDS,
escapeHtml, classifyPM25, inBali, isActive,
fetchSmartCitizenSensors, fetchSmartCitizenDetail,
fetchSmartCitizenHistory, fetchSmartCitizenHistoryBulk,
fetchOpenAQSensors,
fetchPurpleAirSensors, fetchSensorCommunitySensors,
fetchAllSensors, fetchSensorsFast, fetchSensorsSnapshot, fetchAreas, dataBaseUrl,
fetchReports, fetchReportsSummary, profileToReport, reportsBaseUrl,
SOURCE_STATUS, // exposed for in-page diagnostic
};
}
320cc4a61This file is pinned to the selected release. Download the original to open it in your design tool.