// wadamesh in the browser: the page side of web/platform/web_main.cpp. // // - checks the page is cross-origin isolated (the build uses wasm threads, which need // SharedArrayBuffer, which needs COOP: same-origin + COEP: require-corp) // - mounts IndexedDB (IDBFS) at /data and loads it BEFORE the UI boots // - persists /data after writes settle, and on tab hide / page close // - picks the canvas size (800x480 on a computer, the screen's own size on a phone or // tablet) and scales it to the window (whole device pixels = crisp nearest when that // costs little, smooth otherwise); a turn of the phone restarts the UI in the other layout // - on a phone, the phone's own keyboard types into the UI (a hidden textarea) // - pointer = finger, keyboard = the UI's external keyboard (CardKB codes) // - Connect: the demo, or the visitor's own MeshCore node over USB (Web Serial), // Bluetooth (Web Bluetooth) or Wi-Fi (WebSocket, the node's port 8765). The choice // is kept in localStorage; each backend has its own data dir under /data // (/data/demo, /data/node-), and switching reboots the UI into it. (function () { 'use strict'; // The canvas size = the board's resolution, picked once per boot from the window // (web_main.cpp reads Module.wadaSize; WEB_BUILD.md "Phones and tablets"). // - A computer (fine pointer) in a landscape window: 800x480, the framed device, scaled // to the window (the acceptance tours and the docs shots are at this size). // - A phone or tablet (coarse pointer), or a computer window clearly taller than wide: // the canvas IS the screen. It is sized from the space below the bar (safe areas // out) so it fills that space edge to edge, with about one CSS pixel per canvas pixel // (text as big as the page's own), preferring a width that is a whole number of // device pixels per canvas pixel (crisp): iPhone 14 390x616 at 3:1, Pixel 7 360x692 // at 3:1, iPad 540x686 at 3:1. Portrait or landscape follows the window. // Turning the phone restarts the UI in the other layout (see "orientation"); data stays. var touchDevice = window.matchMedia && window.matchMedia('(pointer: coarse)').matches; function wantPortrait() { // upright; a desktop window must be clearly taller than wide return window.innerHeight > window.innerWidth * (touchDevice ? 1.0 : 1.15); } var portrait = wantPortrait(); var fill = touchDevice || portrait; // the canvas fills the stage (no device frame) var bootWidth = window.innerWidth; var W = 800, H = 480; // The best logical length for `css` CSS pixels (dev = the same in device pixels): // near `target`, within [lo, hi], a whole number of device pixels per canvas pixel // when one is within 16 % of the target (a bit bigger is fine; smaller is not). function pickLen(css, dev, target, lo, hi) { var best = 0, bestErr = 1e9; for (var k = 1; k <= 6; k++) { var w = Math.round(dev / k); if (w < lo || w > hi || Math.abs(dev / k - w) > 0.75) continue; var err = (target - w) / target; // > 0: the UI is drawn bigger than 1:1 if (err < -0.02 || err > 0.16) continue; if (Math.abs(err) < bestErr) { best = w; bestErr = Math.abs(err); } } return best || Math.max(lo, Math.min(hi, Math.round(target))); } // Measured now for the first frame, and again just before the UI boots: a home-screen // app on iOS starts with a viewport that is not settled yet (taller than what it ends up // as), which cut the UI's bottom bar off until a turn of the phone restarted it. function pickSize() { if (!fill) return; document.body.classList.add('fill'); if (portrait) document.body.classList.add('portrait'); var st0 = document.getElementById('stage'), r0 = st0.getBoundingClientRect(), c0 = getComputedStyle(st0); var aw0 = Math.max(240, r0.width - parseFloat(c0.paddingLeft) - parseFloat(c0.paddingRight)); var ah0 = Math.max(200, r0.height - parseFloat(c0.paddingTop) - parseFloat(c0.paddingBottom)); var dpr0 = window.devicePixelRatio || 1; if (portrait) { // width: the phone's CSS width (360-430 on phones); a tablet stays near 600 wide W = pickLen(aw0, aw0 * dpr0, Math.min(aw0, 600), 300, 640); H = Math.max(480, Math.min(1400, Math.floor(W * ah0 / aw0))); // never taller than the space } else { // height: 1:1 up to 600 (a phone on its side: ~330; a tablet: ~500-600) H = pickLen(ah0, ah0 * dpr0, Math.min(ah0, 600), 280, 640); W = Math.max(480, Math.min(1280, Math.round(H * aw0 / ah0))); if (W * ah0 / aw0 < H) H = Math.floor(W * ah0 / aw0); // the width rules (layout): never taller } W -= W & 1; H -= H & 1; } pickSize(); var BUILD = '__WADA_BUILD__'; var DB_MOUNT = '/data'; // also the IndexedDB database name IDBFS uses var $ = function (id) { return document.getElementById(id); }; var canvas = $('screen'), stage = $('stage'), overlay = $('overlay'); canvas.width = W; canvas.height = H; var ctx = canvas.getContext('2d', { alpha: false }); var M = null; // the Emscripten module once created var running = false; // the UI has presented its first frame var resetting = false; // a "Reset demo data" reload is under way: write nothing more // ---- which mesh: the demo or a node ---------------------------------------------- var CHOICE_KEY = 'wadamesh.backend'; // {kind, arg, key?, name?}: the node to connect to var NODES_KEY = 'wadamesh.nodes'; // kind|arg -> the node key last seen there var HOST_KEY = 'wadamesh.wsHost'; function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } } function lsSet(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} } var choice = lsGet(CHOICE_KEY); if (!choice || !/^(web-serial|ble|ws)$/.test(choice.kind)) choice = null; function nodeDir(c) { return DB_MOUNT + '/node-' + (c.key || 'new'); } var dataDir = choice ? nodeDir(choice) : DB_MOUNT + '/demo'; var everUp = false; // ---- status / overlay ------------------------------------------------------------ function setStatus(text, detail, busy) { overlay.hidden = false; overlay.className = busy ? 'busy' : ''; $('status').textContent = text; $('detail').innerHTML = detail || ''; } function fatal(text, detailHtml, withReload) { overlay.hidden = false; overlay.className = 'fatal'; $('status').textContent = text; $('detail').innerHTML = detailHtml || ''; if (withReload) { var b = document.createElement('button'); b.className = 'btn'; b.type = 'button'; b.textContent = 'Reload'; b.onclick = function () { location.reload(); }; overlay.appendChild(b); } } var toastTimer = 0; function toast(text, ms) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.hidden = true; }, ms || 4200); } // ---- scaling ------------------------------------------------------------------------- function layout() { var full = document.fullscreenElement || document.webkitFullscreenElement; var r = stage.getBoundingClientRect(); var cs = getComputedStyle(stage); var aw = r.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); var ah = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); if (!full) ah = Math.max(ah, 120); if (fill && !full) { // the canvas was sized for this space: fill it edge to edge // (the keyboard or a toolbar can shorten the space later: the width rules, and // fitAboveKeyboard slides the screen up instead of shrinking it) var f = aw / W, d = f * (window.devicePixelRatio || 1); canvas.style.width = aw + 'px'; canvas.style.height = Math.round(H * f) + 'px'; canvas.classList.toggle('crisp', Math.abs(d - Math.round(d)) < 0.02 || d >= 3); overflowCheck(); return; } var dpr = window.devicePixelRatio || 1; var fit = Math.max(0.1, Math.min(aw / W, ah / H)); var dev = fit * dpr; // canvas pixel -> device pixels var k = Math.floor(dev + 1e-6); var css, crisp; if (k >= 1 && k / dev >= 0.8) { css = k / dpr; crisp = true; } // whole device pixels: nearest is exact else { css = fit; crisp = dev >= 3; } // fractional: smooth, unless big enough to hide it canvas.style.width = Math.round(W * css) + 'px'; canvas.style.height = Math.round(H * css) + 'px'; canvas.classList.toggle('crisp', crisp); } window.addEventListener('resize', layout); // The space got shorter than the UI after it booted, with no keyboard up (a viewport // that settled late): the bottom of the UI is cut off, so boot it again at the right // size. At most once every 30 s, so a browser that keeps changing its mind cannot loop. var overflowTimer = 0; // How far the UI's bottom edge sits below the visible window (the stage itself can grow // with its content, so the window is the measure). function overflowPx() { var vh = window.innerHeight; var r = canvas.getBoundingClientRect(); return r.bottom - vh; } function overflowCheck() { clearTimeout(overflowTimer); if (!running || overflowPx() <= 8) return; overflowTimer = setTimeout(function () { if (!running || resetting || imeOn || (document.activeElement && document.activeElement.id === 'ime')) return; if (overflowPx() <= 8) return; var last = 0; try { last = +sessionStorage.getItem('wada.refit') || 0; } catch (e) {} if (Date.now() - last < 30000) return; try { sessionStorage.setItem('wada.refit', String(Date.now())); } catch (e) {} restartUi('Fitting the screen'); }, 900); } // ---- orientation: the other layout needs a restart of the UI (data kept) ----------- // Debounced, only when the window's aspect really flipped AND its width changed (a phone // keyboard or a collapsing toolbar only changes the height, which is no rotation), and // never while the visitor is typing. var flipTimer = 0; function maybeFlip() { clearTimeout(flipTimer); flipTimer = setTimeout(function () { if (!running || resetting || wantPortrait() === portrait) return; if (Math.abs(window.innerWidth - bootWidth) < 40) return; if (imeOn || (document.activeElement && document.activeElement.id === 'ime')) return; restartUi(portrait ? 'Turning to landscape' : 'Turning to portrait'); }, 600); } // A keyboard that was open during the turn: check again once it has closed. window.addEventListener('focusout', function () { setTimeout(maybeFlip, 400); }); window.addEventListener('resize', maybeFlip); if (screen.orientation && screen.orientation.addEventListener) screen.orientation.addEventListener('change', maybeFlip); document.addEventListener('fullscreenchange', layout); document.addEventListener('webkitfullscreenchange', layout); if (window.matchMedia) { // a zoom / monitor change alters devicePixelRatio without a resize on some browsers var watchDpr = function () { var mq = window.matchMedia('(resolution: ' + (window.devicePixelRatio || 1) + 'dppx)'); var on = function () { layout(); watchDpr(); }; if (mq.addEventListener) mq.addEventListener('change', on, { once: true }); }; watchDpr(); } layout(); // ---- presentation (called by web_main.cpp with the changed rectangle as RGBA) -------- var imgCache = {}; var stats = { frames: 0, presents: 0, presentedPx: 0, t0: performance.now() }; function present(view, x, y, w, h) { var key = w + 'x' + h; var img = imgCache[key]; if (!img) { img = ctx.createImageData(w, h); if (Object.keys(imgCache).length > 48) imgCache = {}; imgCache[key] = img; } img.data.set(view); ctx.putImageData(img, x, y); stats.presents++; stats.presentedPx += w * h; if (!running) { running = true; if (fill) { overflowCheck(); setTimeout(overflowCheck, 1500); } // a viewport that settled while it booted overlay.hidden = true; canvas.focus({ preventScroll: true }); } } (function countFrames() { stats.frames++; requestAnimationFrame(countFrames); })(); // ---- location: the UI's GPS (web/platform/WebLocation.cpp) -------------------------------- // The UI asks for it only while its GPS is switched on (Module.wadaGeoStart / Stop). // The browser's permission prompt is never shown on page load: when the visitor has not // decided yet, the request waits for a tap (the GPS switch itself, opening the map, any // tap). Refused or unavailable: the UI keeps "searching"; nothing asks again until the // GPS is switched off and on. The position stays in this page unless the node's own // "share position in adverts" setting is on (WEB_BUILD.md "Location"). var geo = { want: false, watch: null, waitingTap: false, lastTap: -1e9, toldDenied: false }; function geoUserActive() { if (navigator.userActivation) return navigator.userActivation.isActive; return performance.now() - geo.lastTap < 5000; } function geoPermission() { if (!navigator.permissions || !navigator.permissions.query) return Promise.resolve('prompt'); return navigator.permissions.query({ name: 'geolocation' }).then(function (r) { return r.state; }, function () { return 'prompt'; }); } function geoBegin() { geo.waitingTap = false; if (!geo.want || geo.watch !== null || !M) return; if (!navigator.geolocation) { M._wadaWebGeoLost(2); return; } geo.watch = navigator.geolocation.watchPosition(function (p) { var c = p.coords, n = function (v) { return v == null ? NaN : v; }; if (M && geo.want) M._wadaWebGeoFix(c.latitude, c.longitude, n(c.accuracy), n(c.altitude), n(c.speed), n(c.heading), p.timestamp / 1000); }, function (err) { if (err.code !== 1) return; // unavailable / timeout: the browser keeps trying by itself geoEnd(); if (M) M._wadaWebGeoLost(1); if (!geo.toldDenied) { geo.toldDenied = true; toast('The browser does not let this page use your location, so the GPS has no position. You can allow it in the site settings.'); } }, { enableHighAccuracy: true, maximumAge: 30000 }); } function geoEnd() { if (geo.watch !== null && navigator.geolocation) navigator.geolocation.clearWatch(geo.watch); geo.watch = null; } function geoStart() { geo.want = true; if (geo.watch !== null) return; geoPermission().then(function (state) { if (!geo.want || geo.watch !== null) return; if (state === 'denied') { if (M) M._wadaWebGeoLost(1); return; } if (state === 'granted' || geoUserActive()) geoBegin(); else geo.waitingTap = true; // switched on at boot: ask on the visitor's next tap }); } function geoStop() { geo.want = false; geo.waitingTap = false; geoEnd(); } function geoTap() { geo.lastTap = performance.now(); if (geo.waitingTap && geo.want) geoBegin(); } // ---- input ----------------------------------------------------------------------------- var activePointer = null; var lastPt = [0, 0]; // where the finger last was, so a forced release lands there function toCanvas(e) { var r = canvas.getBoundingClientRect(); return [Math.floor((e.clientX - r.left) * W / r.width), Math.floor((e.clientY - r.top) * H / r.height)]; } function sendPointer(down, e) { if (!M || !running) return; var p = toCanvas(e); lastPt = p; M._wadaWebPointer(down ? 1 : 0, p[0], p[1]); } canvas.addEventListener('pointerdown', function (e) { // One finger: the panel is single-touch. A mouse has only one pointer, so a new // mouse press while one is "active" means its release was lost (released outside // the window, a focus change): drop the stale one instead of ignoring input forever. if (activePointer !== null) { if (e.pointerType !== 'mouse') return; activePointer = null; if (M && running) M._wadaWebPointer(0, lastPt[0], lastPt[1]); } if (e.pointerType === 'mouse' && e.button !== 0) return; activePointer = e.pointerId; geoTap(); try { canvas.setPointerCapture(e.pointerId); } catch (_) {} // While the phone keyboard is up, a tap on the UI (Send, a bubble) leaves it up. if (!(touchDevice && imeOn)) canvas.focus({ preventScroll: true }); sendPointer(true, e); e.preventDefault(); }); canvas.addEventListener('pointermove', function (e) { if (e.pointerId !== activePointer) return; sendPointer(true, e); }); function pointerEnd(e) { if (e.pointerId !== activePointer) return; activePointer = null; sendPointer(false, e); // A tap on a text field opens the phone's own keyboard, here, inside the gesture // (iOS only shows it for a focus() made in the user's own event). if (e.type === 'pointerup' && touchDevice && M && running) { var p = toCanvas(e); if (M._wadaWebTextareaAt(p[0], p[1])) imeOpen(); else if (imeOn && document.activeElement !== ime) ime.focus({ preventScroll: true }); // the edit goes on } } canvas.addEventListener('pointerup', pointerEnd); canvas.addEventListener('pointercancel', pointerEnd); canvas.addEventListener('lostpointercapture', pointerEnd); // Window loses focus mid-press (alt-tab, a dialog): release, or the UI keeps a // phantom finger down. window.addEventListener('blur', function () { if (activePointer === null) return; activePointer = null; if (M && running) M._wadaWebPointer(0, lastPt[0], lastPt[1]); }); canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); }); // The mouse wheel / two-finger trackpad scroll: scrolls the list or page under the // pointer (web/platform/WebLvglInput.cpp). Pixels, lines and pages all become canvas pixels. var wheelRest = 0; canvas.addEventListener('wheel', function (e) { if (!M || !running || e.ctrlKey) return; // ctrl+wheel / pinch: the browser's zoom e.preventDefault(); var r = canvas.getBoundingClientRect(); // deltaMode 0: CSS pixels (scaled to canvas pixels), 1: lines (16 px), 2: pages var k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? H * 0.9 : H / r.height; var dy = e.deltaY * k + wheelRest; var whole = Math.trunc(dy); wheelRest = dy - whole; if (!whole) return; var p = toCanvas(e); M._wadaWebWheel(p[0], p[1], whole); }, { passive: false }); // The computer keyboard is the UI's external keyboard (HAS_CARDKB): ASCII, Enter, // Backspace, Esc (Back), Tab, arrows. Same codes as host_main.cpp's keyDown(). var KEYS = { Enter: 0x0D, Backspace: 0x08, Escape: 0x1B, Tab: 0x09, ArrowLeft: 0xB4, ArrowUp: 0xB5, ArrowDown: 0xB6, ArrowRight: 0xB7 }; document.addEventListener('keydown', function (e) { if (!M || !running) return; if (e.ctrlKey || e.metaKey || e.altKey) return; // browser shortcuts stay the browser's var t = e.target; if ($('connect-dlg').open || (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return; // the page's own fields if (t && t !== canvas && t !== document.body && /^(BUTTON|A)$/.test(t.tagName)) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') return; // the bar's own buttons } var code = KEYS[e.key]; if (code === undefined && e.key.length === 1) { var c = e.key.charCodeAt(0); if (c >= 0x20 && c < 0x7F) code = c; } if (code === undefined && Array.from(e.key).length === 1 && e.key.codePointAt(0) >= 0xA0) { e.preventDefault(); // any other script, typed on the computer: as text (UTF-8) geoTap(); M.ccall('wadaWebText', null, ['string'], [e.key]); return; } if (code === undefined) return; e.preventDefault(); geoTap(); M._wadaWebKey(code); }); // ---- the phone's own keyboard -------------------------------------------------------- // On a touch device the UI's on-screen keys stay down (the phone keyboard counts as an // external keyboard: wadaWebNativeKeyboard) and a hidden