Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
a975d96aaThis file is pinned to the selected release. Download the original to open it in your design tool.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Map and reports · Making Sense Bali</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#f6efe1">
<link rel="manifest" href="manifest.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Air Bali">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="description" content="Every sensor and every resident report across Bali on one map, and what residents are reporting. A Fab Lab Bali campaign.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='%233f8a5b'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;1,6..72,400;1,6..72,500&family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<style>
:root{
--paper:#f6efe1; --paper2:#efe6d2; --ink:#16171a; --ink-soft:#3a3a3e;
--mono:'IBM Plex Mono',monospace; --serif:'Newsreader',Georgia,serif; --sans:'Inter',system-ui,sans-serif;
--good:#3f8a5b; --teal:#1e5959;
--aq:#3f8a5b; --sky-top:#cfe0dd; --sky-mid:#eef0e6; --sky-low:#f6e6c8;
}
*{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth}
body{background:var(--paper);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:inherit;text-decoration:none}
.bar{position:fixed;top:0;left:0;right:0;z-index:40;display:flex;justify-content:space-between;align-items:center;
padding:16px 5vw;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;
color:var(--ink);transition:background .4s,box-shadow .4s}
.bar.solid{background:rgba(246,239,225,.9);backdrop-filter:blur(10px);border-bottom:1px solid rgba(0,0,0,.07)}
.bar .brand{font-weight:600;color:var(--aq);transition:color .6s}
.bar-right{display:flex;align-items:center;gap:22px}
.bar-nav{display:flex;gap:18px}
.bar-nav a{color:var(--ink);opacity:.72;transition:opacity .2s}
.bar-nav a:hover{opacity:1}
.bar .live{display:flex;align-items:center;gap:8px}
.bar .dot{width:7px;height:7px;border-radius:50%;background:var(--aq);animation:pulse 2.4s infinite;transition:background .6s}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,138,91,.5)}70%{box-shadow:0 0 0 9px rgba(63,138,91,0)}100%{box-shadow:0 0 0 0 rgba(63,138,91,0)}}
@media(max-width:720px){.bar-nav{display:none}}
.langtoggle{display:inline-flex;gap:1px;font-family:var(--mono);font-size:11px;letter-spacing:.1em;border:1px solid rgba(0,0,0,.25);border-radius:3px;overflow:hidden}
.langtoggle button{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink);background:transparent;border:0;padding:5px 8px;cursor:pointer;line-height:1;transition:background .2s,color .2s}
.langtoggle button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}
.langtoggle button:hover:not([aria-pressed="true"]){background:rgba(0,0,0,.08)}
.btn{font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;padding:15px 24px;border-radius:3px;cursor:pointer;transition:transform .15s,background .2s,color .2s;border:1px solid var(--ink);display:inline-flex;gap:9px;align-items:center}
.btn:hover{transform:translateY(-2px)}
.btn:disabled{opacity:.55;cursor:default;transform:none}
.btn-fill{background:var(--ink);color:var(--paper)}
.btn-ghost{background:transparent;color:var(--ink)}
button.btn{font-family:var(--mono)}
.rv{opacity:0;transform:translateY(26px);transition:opacity .9s ease,transform .9s ease}
.rv.in{opacity:1;transform:none}
.story{background:#111619;color:#ece3d2;padding:14vh 5vw;position:relative;overflow:hidden}
.story::before{content:"";position:absolute;right:-10%;top:-20%;width:60vw;height:60vw;border-radius:50%;background:radial-gradient(circle,rgba(204,96,40,.22),transparent 62%);pointer-events:none}
.story-wrap{position:relative;max-width:1080px;margin:0 auto}
.kicker{font-family:var(--mono);font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:#c9742f}
.story h2{font-family:var(--serif);font-weight:400;font-size:clamp(32px,5vw,62px);line-height:1.05;letter-spacing:-.015em;margin:18px 0 0;max-width:20ch}
.story h2 em{font-style:italic;color:#e9a05a}
.story-grid{display:grid;grid-template-columns:1.05fr .95fr;gap:48px;margin-top:54px;align-items:center}
@media(max-width:820px){.story-grid{grid-template-columns:1fr;gap:34px}}
.quote{font-family:var(--serif);font-style:italic;font-size:clamp(23px,3.2vw,38px);line-height:1.3;color:#f3ead9}
.quote .by{display:block;font-family:var(--mono);font-style:normal;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:#9a8f7d;margin-top:18px}
.story p{font-family:var(--sans);font-size:16px;line-height:1.7;color:#b8ae9c;margin-top:22px;max-width:46ch}
.chartcard{background:#171d20;border:1px solid #28302f;border-radius:10px;padding:20px 20px 14px}
.chartcard .ch-top{display:flex;justify-content:space-between;align-items:baseline;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#8d8474;margin-bottom:8px}
.ch-foot{font-family:var(--mono);font-size:10px;letter-spacing:.08em;color:#7d7565;margin-top:8px;display:flex;gap:16px;flex-wrap:wrap}
.story-line{font-family:var(--serif);font-size:clamp(20px,2.6vw,30px);font-style:italic;color:#ece3d2;margin-top:64px;text-align:center;max-width:30ch;margin-left:auto;margin-right:auto}
.island{background:var(--paper);padding:13vh 5vw}
.island-head{max-width:1080px;margin:0 auto 38px}
.island-head h2{font-family:var(--serif);font-weight:400;font-size:clamp(30px,4.6vw,56px);line-height:1.04;letter-spacing:-.015em;margin-top:14px}
.island-head h2 em{font-style:italic;color:var(--teal)}
.island-head p{font-size:16px;line-height:1.6;color:var(--ink-soft);max-width:54ch;margin-top:16px}
.mapwrap{max-width:1180px;margin:0 auto;position:relative;border-radius:14px;overflow:hidden;border:1px solid rgba(0,0,0,.1);box-shadow:0 30px 60px rgba(40,30,15,.12)}
#map{width:100%;height:62vh;min-height:420px;background:#dfe7e6}
.maptag{position:absolute;z-index:500;left:18px;bottom:18px;background:rgba(246,239,225,.92);backdrop-filter:blur(8px);border-radius:8px;padding:12px 15px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-soft);line-height:1.7}
.maptag b{color:var(--ink);font-weight:600}
.maptag .mapsrc{display:inline-block;margin-top:5px;opacity:.85}
.maptag .mapsrc a{color:var(--teal);text-decoration:underline;text-underline-offset:2px}
.foot{background:var(--ink);color:#7c766a;padding:40px 5vw 60px;font-family:var(--mono);font-size:11px;letter-spacing:.08em;line-height:1.9;border-top:1px solid #2a2d31}
.foot a{color:#b9b2a4}
.reports{background:var(--paper);padding:12vh 5vw;border-top:1px solid rgba(0,0,0,.06)}
.reports-wrap{max-width:1080px;margin:0 auto}
.reports h2{font-family:var(--serif);font-weight:400;font-size:clamp(28px,4.4vw,52px);line-height:1.05;letter-spacing:-.015em;margin-top:14px}
.reports h2 em{font-style:italic;color:var(--teal)}
.rep-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:26px}
.rep-chip{font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);background:#fff;border:1px solid rgba(0,0,0,.1);border-radius:100px;padding:7px 14px}
.rep-chip b{color:var(--ink);font-weight:600;margin-left:6px}
.rep-list{margin-top:24px;display:flex;flex-direction:column;border-top:1px solid rgba(0,0,0,.1)}
.rep-row{display:grid;grid-template-columns:118px 1fr auto;gap:18px;align-items:baseline;padding:15px 0;border-bottom:1px solid rgba(0,0,0,.08)}
@media(max-width:680px){.rep-row{grid-template-columns:1fr;gap:4px}}
.rep-cat{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--teal);font-weight:500}
.rep-desc{font-family:var(--serif);font-size:16.5px;line-height:1.4;color:var(--ink)}
.rep-meta{font-family:var(--mono);font-size:11px;color:var(--ink-soft);white-space:nowrap}
.map-veil{position:absolute;inset:0;z-index:600;display:flex;align-items:center;justify-content:center;background:rgba(246,239,225,.28);cursor:pointer}
.map-veil span{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);background:rgba(246,239,225,.94);border:1px solid rgba(0,0,0,.14);border-radius:100px;padding:11px 20px}
@media(hover:hover) and (pointer:fine){.map-veil{display:none}}
.bar-nav a[aria-current="page"]{opacity:1;border-bottom:2px solid currentColor;padding-bottom:2px}
.island{padding-top:max(13vh,120px)}
.btn-ghost{border-color:var(--ink)}
@media(max-width:720px){
.bar{padding:10px 5vw}
/* Brand and language on the first row, the nav on its own full-width row
underneath. display:contents lifts the nav out of .bar-right so it can
take the whole width instead of being squeezed beside the brand. */
.bar{flex-wrap:wrap;row-gap:0}
.bar-right{display:contents}
.bar .brand{white-space:nowrap}
.langtoggle{order:2}
.bar .live{display:none}
.bar-nav{display:flex;order:3;flex:0 0 100%;gap:18px;justify-content:flex-start;min-width:0;
overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.bar-nav::-webkit-scrollbar{display:none}
.bar-nav a{white-space:nowrap;padding:12px 0;opacity:.85}
.island{padding-top:130px}
}
@media(max-width:520px){
.langtoggle button{padding:12px 13px}
.story,.reports,.island{padding-bottom:9vh;padding-left:6vw;padding-right:6vw}
.story,.reports{padding-top:9vh}
.story-line{margin-top:44px}
#map{height:58svh;min-height:300px}
.maptag{left:10px;right:10px;bottom:10px;font-size:9.5px;padding:10px 12px}
.foot{padding:34px 6vw 48px;line-height:1.85}
}
@media(prefers-reduced-motion:reduce){.rv{transition:none;opacity:1;transform:none}}
/* ---------- add to home screen ---------- */
.install-btn{appearance:none;border:1px solid var(--ink);background:var(--ink);color:var(--paper);font-family:var(--mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;border-radius:100px;padding:6px 12px;cursor:pointer;white-space:nowrap;align-self:center;flex:0 0 auto}
.install-btn[hidden]{display:none}
.install-btn:hover{background:#2c2e33}
.install-sheet{position:fixed;inset:0;z-index:1000;background:rgba(22,23,26,.45);display:flex;align-items:flex-end;justify-content:center;padding:16px}
.install-card{background:var(--paper);color:var(--ink);border-radius:16px;max-width:440px;width:100%;padding:22px 20px 18px;box-shadow:0 20px 50px rgba(0,0,0,.25);display:flex;flex-direction:column;gap:10px}
.install-card img{border-radius:12px}
.install-card h2{font-family:var(--serif);font-weight:400;font-size:23px;line-height:1.2}
.install-card p{font-family:var(--sans);font-size:15px;line-height:1.6;color:var(--ink-soft)}
.install-card .install-how{color:var(--ink);font-weight:500}
.install-card .install-close{width:100%;justify-content:center;margin-top:6px}
.install-card .install-now{width:100%;justify-content:center;margin-top:6px}
.install-card .install-now[hidden]{display:none}
@media(min-width:720px){.install-sheet{align-items:center}}
@media(max-width:260px){.install-btn{display:none!important}}
</style>
</head>
<body>
<div class="bar" id="bar">
<a class="brand" href="index.html">Making Sense Bali</a>
<div class="bar-right">
<nav class="bar-nav"><button type="button" class="install-btn" data-install hidden data-i18n="install.btn">+ Add to home screen</button><a href="index.html" data-i18n="nav.air">Air near you</a><a href="map.html" aria-current="page" data-i18n="nav.map">Map & reports</a><a href="about.html" data-i18n="idx.nav.about">About</a><a href="dashboard/" data-i18n="idx.nav.data">The data</a></nav>
<div class="langtoggle" id="langtoggle" role="group" aria-label="Bahasa Indonesia / English / Español"><button type="button" data-lang="id">ID</button><button type="button" data-lang="en">EN</button><button type="button" data-lang="es">ES</button></div>
</div>
</div>
<section class="island" id="island">
<div class="island-head">
<div class="kicker rv" style="color:var(--teal)" data-i18n="idx.island.kicker">The whole island</div>
<h2 class="rv" data-i18n-html="idx.island.h2">One map. Every sensor, every report, <em>breathing in real time.</em></h2>
<p class="rv" data-i18n="idx.island.lede">Pinch into your own banjar. The colour is always the air, never the noise of how many people reported. Clean stays calm, even where the reports pile up.</p>
</div>
<div class="mapwrap rv">
<div id="map"></div>
<div class="maptag" data-i18n-html="idx.island.maptag"><b id="map-sensors">—</b> sensors live · <b id="map-reports">—</b> reports<br>tap any point for the reading<br><span class="mapsrc">data: <a href="https://baliairdispatch.com/" target="_blank" rel="noopener">Bali Air Dispatch</a> · eight networks</span></div>
</div>
</section>
<section class="reports" id="reports">
<div class="reports-wrap">
<div class="kicker rv" style="color:var(--teal)" data-i18n="idx.reports.kicker">What residents are reporting</div>
<h2 class="rv" data-i18n-html="idx.reports.h2">Humans report, <em>AI analyses.</em></h2>
<div class="rep-cats rv" id="rep-cats"></div>
<div class="rep-list rv" id="rep-list"><div class="rep-row"><span class="rep-cat">—</span><span class="rep-desc" data-i18n="idx.reports.loading">Loading reports…</span><span class="rep-meta"></span></div></div>
<a class="btn btn-ghost rv" href="https://bali-aq.fab.city/report" target="_blank" rel="noopener" style="margin-top:30px" data-i18n="idx.reports.add">Add your report →</a>
</div>
</section>
<section class="story" id="story">
<div class="story-wrap">
<div class="kicker rv" id="story-kicker" data-i18n="idx.story.kicker">Sensors meet residents</div>
<h2 class="rv" data-i18n-html="idx.story.h2">When the data and the residents tell the <em>same story.</em></h2>
<div class="story-grid">
<div>
<div class="quote rv" id="story-quote" data-i18n-html="idx.story.quote">"They're burning at night to avoid the sanctions."
<span class="by" id="story-by">— resident report</span>
</div>
<p class="rv" id="story-body" data-i18n="idx.story.body">Nobody saw a headline. One neighbour smelled smoke and sent a message. A low-cost sensor nearby felt the same thing: particulate matter climbing while the kampung slept. A measurement and a human voice, pointing at the same moment.</p>
</div>
<div class="chartcard rv">
<div class="ch-top"><span id="story-sensor">nearest sensor · PM2.5</span><span data-i18n="idx.story.recent_trend">recent trend</span></div>
<svg id="spark" viewBox="0 0 520 240" width="100%" preserveAspectRatio="none" aria-label="Recent PM2.5 trend with a peak aligned to a citizen report"></svg>
<div class="ch-foot"><span style="color:#7bb893">— PM2.5 µg/m³</span><span style="color:#e9a05a" data-i18n="idx.story.legend_report">● resident report</span><span style="color:#8d8474" data-i18n="idx.story.legend_who">- - WHO 24h guideline</span></div>
</div>
</div>
<p class="story-line rv" data-i18n-html="idx.story.line">A sensor measures. A neighbour testifies. Together, they become <em>evidence</em>.</p>
</div>
</section>
<footer class="foot" data-i18n-html="idx.foot">
Making Sense Bali, led by <a href="https://www.fablabbali.com/">Fab Lab Bali</a> · open hardware: Smart Citizen Kit (Fab Lab Barcelona / IAAC) · methodology: EU Making Sense · part of <a href="https://fab.city">Fab City</a> + Murmurations · <a href="https://github.com/Meaningful-Design-Group/makingsensebali">source & contribute on GitHub</a>.<br>
Low-cost sensors, ±10–15% from regulatory-grade, so trends and relative magnitudes are reliable.
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<!-- ?v= is a cache buster; see index.html. Bump it whenever these files change. -->
<script src="./data.js?v=20260926e"></script>
<script src="./peaks.js?v=20260926e"></script>
<script src="./near.js?v=20260926e"></script>
<script src="./i18n.js?v=20260926e"></script>
<script src="./i18n-home.js?v=20260926e"></script>
<script src="./install.js?v=20260926f"></script>
<script>
/* Map & reports. Moved here from the home page on 26 Sep 2026, when the home
page became "the air where you are" and nothing else. The code below is the
same code that ran there, unchanged except for the boot. */
var D=window.SCB_DATA||{}, P=window.SCB_PEAKS||{};
var S={sensors:[],reports:[]};
/* ---------- air-quality bands → atmosphere + particles ---------- */
var BANDS={
good: {accent:'#3f8a5b', sky:['#cfe0dd','#eef0e6','#f6e6c8'], count:60, pcol:[150,168,150]},
moderate: {accent:'#c98a2a', sky:['#e7ddc4','#efe7d4','#f3dcb0'], count:230, pcol:[150,128,86]},
unhealthy:{accent:'#bb4a30', sky:['#d8c3ad','#e3cdb0','#e6bd96'], count:520, pcol:[128,96,74]}
};
function bandOf(pm){ return pm<=12?'good':(pm<=35?'moderate':'unhealthy'); }
function tr(k,vars){ return window.SCB_I18N ? window.SCB_I18N.t(k,null,null,vars) : k; }
function bandWord(b){ return tr('idx.band.'+b); }
function bandColor(pm){ return BANDS[bandOf(pm)].accent; }
function setText(id,t){ var e=document.getElementById(id); if(e) e.textContent=t; }
function esc(s){ return String(s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
async function loadReports(){
var rs=[];
try{ rs=(await (D.fetchReports?D.fetchReports():[]))||[]; }catch(e){ console.warn('[reports] fetchReports threw:', e); }
if(rs.length) return rs;
try{
var base=(D.reportsBaseUrl?D.reportsBaseUrl():'./data/reports');
var ir=await fetch(base+'/index.json',{cache:'no-cache'});
if(!ir.ok){ console.warn('[reports] index.json HTTP '+ir.status+' at '+base); return rs; }
var idx=await ir.json(), files=(idx&&idx.profiles)||[];
var profs=await Promise.all(files.map(function(f){ return fetch(base+'/'+f).then(function(x){return x.ok?x.json():null;}).catch(function(){return null;}); }));
rs=profs.filter(Boolean).map(function(p){ return D.profileToReport?D.profileToReport(p):null; }).filter(Boolean);
console.log('[reports] direct loader recovered '+rs.length+' of '+files.length);
}catch(e){ console.warn('[reports] direct loader failed:', e); }
return rs;
}
/* Pre-baked sensor history (data/history.json) — instant + reliable. The live
Smart Citizen /readings endpoint hangs through the worker, so we never block
on it: snapshot first, live only as a timed-out fallback. */
var HIST=null, HIST_TRIED=false;
async function getHistory(){
if(HIST_TRIED) return HIST;
HIST_TRIED=true;
try{ var base=(D.dataBaseUrl?D.dataBaseUrl():'./data'); var r=await fetch(base+'/history.json',{cache:'no-cache'}); if(r.ok){ var j=await r.json(); HIST=(j&&j.sensors)||null; } }catch(_){}
return HIST;
}
function withTimeout(p,ms){ return Promise.race([p, new Promise(function(_,rej){ setTimeout(function(){rej(new Error('timeout'));}, ms); })]); }
/* ---------- helpers ---------- */
function activeSensors(){ return S.sensors.filter(function(s){ return s && s.reading && typeof s.reading.pm25==='number' && (!D.isActive || D.isActive(s.lastReading)); }); }
/* Sensors that COUNT toward the average: active, minus indoor/outlier sensors the
generator tagged excluded_from_avg (they read unrealistically and mislead). */
function statSensors(){ return activeSensors().filter(function(s){ return !(s && s.excluded_from_avg); }); }
function median(a){ if(!a.length) return null; var x=a.slice().sort(function(p,q){return p-q;}); return x[Math.floor(x.length/2)]; }
function dkm(a,b,c,d){ return (P.distanceKm)?P.distanceKm(a,b,c,d):haversine(a,b,c,d); }
function haversine(a,b,c,d){ var R=6371,r=Math.PI/180,dla=(c-a)*r,dlo=(d-b)*r,s=Math.sin(dla/2)*Math.sin(dla/2)+Math.cos(a*r)*Math.cos(c*r)*Math.sin(dlo/2)*Math.sin(dlo/2); return 2*R*Math.asin(Math.min(1,Math.sqrt(s))); }
/* ---------- bar solidify ---------- */
var bar=document.getElementById('bar');
addEventListener('scroll',function(){ bar.classList.toggle('solid', scrollY>60); },{passive:true});
/* ---------- reveals ---------- */
var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in');io.unobserve(e.target);}});},{threshold:.16});
document.querySelectorAll('.rv:not(.in)').forEach(function(el){io.observe(el);});
/* ---------- story TEXT (from the most notable real report) ---------- */
var KNOWN_SCK=[19236,19600,19618,19651];
function notableReport(){
if(!S.reports.length) return null;
var rank={high:3,medium:2,low:1};
return S.reports.slice().sort(function(a,b){ return (rank[b.severity]||0)-(rank[a.severity]||0) || (new Date(b.submittedAt||0))-(new Date(a.submittedAt||0)); })[0];
}
function renderStory(){
var r=notableReport(); if(!r) return; /* no reports yet → keep evergreen copy */
var quote=(r.aiDescription||r.description||r.title||'').trim();
if(quote.length>140) quote=quote.slice(0,137)+'…';
if(quote) setText('story-quote','“'+quote+'”');
var rel=(window.SCB_I18N&&r.submittedAt)?window.SCB_I18N.formatRelative(r.submittedAt):'';
setText('story-by',tr('idx.story.by_prefix')+(r.locality?' · '+r.locality:'')+(rel?' · '+rel:''));
setText('story-body',tr('idx.story.body_dyn',{loc:(r.locality?tr('idx.story.in',{loc:r.locality}):'')}));
}
/* ---------- home reports summary (categories + most recent, in their words) ---------- */
function renderReportsSummary(){
var cats=document.getElementById('rep-cats'), list=document.getElementById('rep-list');
if(!S.reports.length){ if(list) list.innerHTML='<div class="rep-row"><span class="rep-cat">—</span><span class="rep-desc">'+esc(tr('idx.reports.empty'))+'</span><span class="rep-meta"></span></div>'; return; }
if(cats){ var c={}; S.reports.forEach(function(r){ var k=r.category||'other'; c[k]=(c[k]||0)+1; }); cats.innerHTML=Object.keys(c).sort(function(a,b){return c[b]-c[a];}).map(function(k){ return '<span class="rep-chip">'+esc(k)+'<b>'+c[k]+'</b></span>'; }).join(''); }
if(list){ var rs=S.reports.slice().sort(function(a,b){ return new Date(b.submittedAt||0)-new Date(a.submittedAt||0); }).slice(0,6);
list.innerHTML=rs.map(function(r){ var rel=(window.SCB_I18N&&r.submittedAt)?window.SCB_I18N.formatRelative(r.submittedAt):''; var desc=(r.aiDescription||r.description||r.title||'').trim(); if(desc.length>96) desc=desc.slice(0,93)+'…'; return '<div class="rep-row"><span class="rep-cat">'+esc(r.category||'other')+'</span><span class="rep-desc">'+esc(desc||tr('idx.reports.no_desc'))+'</span><span class="rep-meta">'+esc(r.locality||'Bali')+(rel?' · '+rel:'')+'</span></div>'; }).join(''); }
}
/* ---------- story CHART : real Smart Citizen 24h PM2.5 with clock labels ---------- */
function activeSCK(){ return activeSensors().filter(function(s){ return s.source==='smartcitizen' && s.rawId; }); }
async function loadStoryChart(){
drawSparkIllustrative(8); /* draw something immediately — never blank */
/* 1) pre-baked snapshot — instant, no slow worker call */
var hist=await getHistory();
if(hist){
var key=pickHistKey(hist);
if(key && hist[key] && hist[key].h24 && hist[key].h24.length>=3){
setText('story-sensor', (hist[key].name||'Smart Citizen')+' · PM2.5 · 24h');
drawSparkPairs(hist[key].h24, notableReport());
return;
}
}
/* 2) live fallback through the worker — timed out so it never hangs */
var scks=activeSCK();
var known=scks.filter(function(s){ return KNOWN_SCK.indexOf(Number(s.rawId))>=0; });
var pool=known.length?known:scks, pick=null;
if(pool.length){ pick=pool[0]; var r=notableReport(); if(r){ var best=1e9; pool.forEach(function(s){ var k=dkm(r.lat,r.lng,s.lat,s.lng); if(k<best){best=k;pick=s;} }); } }
var cand=[];
if(pick) cand.push({rid:pick.rawId, label:pick.name});
KNOWN_SCK.forEach(function(id){ if(!pick||Number(pick.rawId)!==id) cand.push({rid:id, label:'Smart Citizen · Bali'}); });
for(var i=0;i<cand.length;i++){ if(await tryStorySensor(cand[i].rid, cand[i].label)) return; }
}
function pickHistKey(hist){
var keys=Object.keys(hist).filter(function(k){ return hist[k]&&Array.isArray(hist[k].h24)&&hist[k].h24.length>=3; });
if(!keys.length) return null;
var r=notableReport();
if(r){ var nearest=null,best=1e9; activeSCK().forEach(function(s){ if(keys.indexOf(String(s.rawId))<0) return; var k=dkm(r.lat,r.lng,s.lat,s.lng); if(k<best){best=k;nearest=String(s.rawId);} }); if(nearest) return nearest; }
for(var i=0;i<KNOWN_SCK.length;i++){ if(keys.indexOf(String(KNOWN_SCK[i]))>=0) return String(KNOWN_SCK[i]); }
return keys[0];
}
function drawSparkPairs(pairs, report){
var svg=document.getElementById('spark'); if(!svg)return;
pairs=pairs.slice().sort(function(a,b){return new Date(a[0])-new Date(b[0]);}); /* series arrives newest-first */
svg.innerHTML=_sparkSVG(pairs.map(function(p){return p[1];}), pairs.map(function(p){return new Date(p[0]).getTime();}), report);
}
async function tryStorySensor(rid, label){
try{
var detail=await withTimeout(D.fetchSmartCitizenDetail(rid), 8000);
var list=(detail&&detail.data&&detail.data.sensors)||(detail&&detail.sensors)||[];
var pm=list.find(function(x){ return /pm\s*2\.?5/i.test(x.name||''); });
if(!pm) return false;
var to=new Date(), from=new Date(to.getTime()-24*3600*1000);
var pts=await withTimeout(D.fetchSmartCitizenHistory(rid, pm.id, from.toISOString(), to.toISOString(), '1h'), 12000);
if(pts&&pts.length>=3){ setText('story-sensor', label+' · PM2.5 · 24h'); drawSparkReal(pts, notableReport()); return true; }
}catch(_){}
return false;
}
function drawSparkIllustrative(baseline){
var svg=document.getElementById('spark'); if(!svg)return;
var n=72,data=[],b=Math.max(4,baseline);
for(var i=0;i<n;i++){ data.push(Math.max(2, b*0.8+Math.sin(i/6)*2+Math.random()*1.6)); }
svg.innerHTML=_sparkSVG(data,null,null);
}
function drawSparkReal(pts,report){
var svg=document.getElementById('spark'); if(!svg)return;
pts=pts.slice().sort(function(a,b){return new Date(a.recordedAt)-new Date(b.recordedAt);}); /* newest-first → chronological */
svg.innerHTML=_sparkSVG(pts.map(function(p){return p.value;}), pts.map(function(p){return new Date(p.recordedAt).getTime();}), report);
}
function _fmtClock(ms){ try{ return new Date(ms).toLocaleTimeString('en-GB',{hour:'2-digit',minute:'2-digit',timeZone:'Asia/Makassar'}); }catch(_){ return ''; } }
function _sparkSVG(vals, times, report){
var w=520,h=240,pad=16,padB=30,n=vals.length;
var max=Math.max(Math.max.apply(null,vals)*1.1,18);
var t0=times?times[0]:0, span=times?Math.max(1,times[n-1]-times[0]):1;
function X(i){ return times ? pad+(times[i]-t0)/span*(w-2*pad) : pad+i*(w-2*pad)/(n-1); }
function Y(v){ return (h-padB)-(v/max)*((h-padB)-pad); }
var d=''; for(var i=0;i<n;i++){ d+=(i?'L':'M')+X(i).toFixed(1)+' '+Y(vals[i]).toFixed(1); }
var who=Y(15).toFixed(1);
var out='<line x1="'+pad+'" y1="'+who+'" x2="'+(w-pad)+'" y2="'+who+'" stroke="#5b5347" stroke-width="1" stroke-dasharray="4 5"/>'+
'<path d="'+d+'" fill="none" stroke="#4f9e6e" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>';
if(times){ var tk=4; for(var k=0;k<=tk;k++){ var idx=Math.round(k*(n-1)/tk), x=X(idx); out+='<text x="'+x.toFixed(1)+'" y="'+(h-9)+'" text-anchor="middle" fill="#8d8474" font-family="IBM Plex Mono,monospace" font-size="10">'+_fmtClock(times[idx])+'</text>'; } }
if(report&&report.submittedAt&×){ var rt=new Date(report.submittedAt).getTime(); if(rt>=times[0]&&rt<=times[n-1]){ var rx=pad+(rt-t0)/span*(w-2*pad), ni=0,bd=1e18; for(var j=0;j<n;j++){var dd=Math.abs(times[j]-rt); if(dd<bd){bd=dd;ni=j;}} var ry=Y(vals[ni]); out+='<line x1="'+rx.toFixed(1)+'" y1="'+ry.toFixed(1)+'" x2="'+rx.toFixed(1)+'" y2="'+(h-padB)+'" stroke="#e9a05a" stroke-width="1.4" stroke-dasharray="3 4"/><circle cx="'+rx.toFixed(1)+'" cy="'+ry.toFixed(1)+'" r="5.5" fill="#e9a05a"/><text x="'+rx.toFixed(1)+'" y="'+(ry-11).toFixed(1)+'" text-anchor="middle" fill="#e9a05a" font-family="IBM Plex Mono,monospace" font-size="10">'+_fmtClock(rt)+'</text>'; } }
return out;
}
/* ---------- live map ---------- */
/* report glows are drawn once, whenever BOTH the map and reports are ready
(sensors + reports resolve independently, so either can finish first). */
function renderGlows(){
if(!window._map || window._glowsDone || !S.reports.length) return;
window._glowsDone=true;
S.reports.forEach(function(r){ if(typeof r.lat==='number'&&typeof r.lng==='number'){ L.circle([r.lat,r.lng],{radius:1500,color:'transparent',fillColor:'#dd8a3c',fillOpacity:.13}).addTo(window._map); } });
}
function initMap(){
if(typeof L==='undefined'){ return; }
var map=L.map('map',{zoomControl:true,scrollWheelZoom:false}).setView([-8.66,115.16],10);
window._map=map;
/* Touch: a full-width map inside a scrolling page eats the swipe, and the
reader gets stuck panning Bali when they wanted to read the next section.
Leaflet stays inert until they tap in; tapping outside gives it back. */
if(matchMedia('(hover:none)').matches){
map.dragging.disable(); map.touchZoom.disable(); map.doubleClickZoom.disable();
var veil=document.createElement('div');
veil.className='map-veil';
veil.innerHTML='<span data-i18n="idx.island.tap">Tap to explore the map</span>';
veil.addEventListener('click',function(){
map.dragging.enable(); map.touchZoom.enable(); map.doubleClickZoom.enable();
veil.remove();
});
document.getElementById('map').appendChild(veil);
if(window.SCB_I18N) window.SCB_I18N.applyTo(veil);
document.addEventListener('click',function(e){
if(!map.dragging.enabled()) return;
if(document.getElementById('map').contains(e.target)) return;
map.dragging.disable(); map.touchZoom.disable(); map.doubleClickZoom.disable();
if(!document.querySelector('.map-veil')) document.getElementById('map').appendChild(veil);
});
}
/* Basemap. CARTO now requires an API key: without one it still returns HTTP
200 with a PNG, but every tile is stamped "API KEY REQUIRED". A curl check
passes while the map is visibly defaced, which is how this went unnoticed.
Default below is keyless OpenStreetMap, which supports z19 — the copy
promises "pinch into your own banjar", so deep zoom has to keep working.
To use a keyed provider, set TILE_URL/TILE_ATTR from that provider's own
documentation. Do not guess the query-parameter name. */
var TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
var TILE_ATTR = '© OpenStreetMap';
L.tileLayer(TILE_URL,{maxZoom:19,attribution:TILE_ATTR}).addTo(map);
renderGlows();
/* Pins live in their own layer so the live upgrade can replace them wholesale
rather than drawing a second set on top of the snapshot's. */
window._pinLayer = L.layerGroup().addTo(map);
var pts=[];
activeSensors().forEach(function(s){
var col=bandColor(s.reading.pm25);
var ex=s.excluded_from_avg;
L.circleMarker([s.lat,s.lng],{radius:9,color:'#fff',weight:2,fillColor:col,fillOpacity:ex?0.4:1,dashArray:ex?'2,3':null})
.addTo(window._pinLayer)
.bindPopup('<b>'+(D.escapeHtml?D.escapeHtml(s.name):s.name)+'</b><br>PM2.5 '+(Math.round(s.reading.pm25*10)/10)+' µg/m³'+(ex?'<br><small>indoor / not counted in the average</small>':''));
pts.push([s.lat,s.lng]);
});
if(pts.length){ try{ map.fitBounds(pts,{padding:[60,60],maxZoom:12}); }catch(_){ } }
}
function renderLivePins(sensors){
if(!window._map || !window._pinLayer) return;
window._pinLayer.clearLayers();
var pts=[];
sensors.forEach(function(s){
if(s.pm25==null) return;
L.circleMarker([s.lat,s.lng],{radius:9,color:'#fff',weight:2,
fillColor:s.ambient===false?'#9a948a':bandColor(s.pm25),fillOpacity:s.ambient===false?0.45:1,dashArray:s.ambient===false?'2,3':null})
.addTo(window._pinLayer)
.bindPopup('<b>'+esc(s.name)+'</b><br>PM2.5 '+(Math.round(s.pm25*10)/10)+' µg/m³<br>'+
'<small>'+esc(s.sources.join(' · '))+(s.excluded?' · '+esc(tr('near.reason_pin.'+s.excluded)):'')+'</small>');
pts.push([s.lat,s.lng]);
});
if(pts.length){ try{ window._map.fitBounds(pts,{padding:[60,60],maxZoom:12}); }catch(_){ } }
}
function upgradeFromLive(){
var N=window.SCB_NEAR; if(!N||typeof N.load!=='function') return;
N.load().then(function(data){
setText('map-sensors', data.sensors.length||'—');
renderLivePins(data.sensors);
}).catch(function(e){ console.warn('[map] live upgrade unavailable, snapshot stands:', e); });
}
function reRenderI18n(){
if(window.SCB_I18N) window.SCB_I18N.applyTo(document);
setText('story-sensor', tr('idx.story.sensor_default'));
setText('map-reports', (S.reports.length||'0'));
renderStory(); renderReportsSummary(); loadStoryChart();
}
function setupLangToggle(){
var box=document.getElementById('langtoggle'); if(!box||!window.SCB_I18N) return;
var btns=box.querySelectorAll('button[data-lang]');
function sync(){ var cur=window.SCB_I18N.currentLang(); btns.forEach(function(b){ b.setAttribute('aria-pressed', b.getAttribute('data-lang')===cur?'true':'false'); }); }
sync();
btns.forEach(function(b){ b.addEventListener('click', function(){ window.SCB_I18N.setLang(b.getAttribute('data-lang')); sync(); reRenderI18n(); }); });
}
function boot(){
if(window.SCB_I18N){ document.documentElement.lang=window.SCB_I18N.currentLang(); window.SCB_I18N.applyTo(document); }
setText('story-sensor', tr('idx.story.sensor_default'));
setupLangToggle();
(D.fetchSensorsFast?D.fetchSensorsFast():Promise.resolve([])).then(function(s){
S.sensors=s||[];
setText('map-sensors', activeSensors().length||'—');
initMap(); loadStoryChart();
if(window.requestIdleCallback) requestIdleCallback(upgradeFromLive,{timeout:3500}); else setTimeout(upgradeFromLive,1200);
}).catch(function(){ initMap(); loadStoryChart(); upgradeFromLive(); });
loadReports().then(function(r){
S.reports=r||[];
setText('map-reports', S.reports.length||'0');
renderStory(); renderReportsSummary(); renderGlows(); loadStoryChart();
}).catch(function(e){ console.warn('[reports] load failed:', e); });
}
if(document.readyState!=='loading'){ boot(); } else { addEventListener('DOMContentLoaded', boot); }
</script>
</body>
</html>