CompassDial
A dial from the device's orientation with calibration advice, smoothed across north, and the permission iOS requires.
Demo
--
on_heading: nothing yet
Get it
It needs theme.qwe, as every component does, and button.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/button.qwe$ curl -O https://quillolang.org/components/ui/compass_dial.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/compass_dial.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/compass_dial.qwe
component CompassDialDemoLive() { state shown = true state seen = "nothing yet" <div.demo-stack> {shown && <CompassDial bearing={132} target="Base camp" on_heading={(h) => { seen = str(h) }}/>} <p.demo-note id="compass-dial-demo-note">on_heading: {seen}</p> <Button size="lg" variant="ghost" id="compass-dial-demo-show" on_click={() => { shown = !shown }}>{shown ? "Remove it" : "Put it back"}</Button> </div>}view CompassDialDemo() = <CompassDialDemoLive/>Source
ui/compass_dial.qwe
// CompassDial: a dial that turns as the phone turns, so its N stays north. It takes the// heading from where each system keeps it: iOS's own compass heading, Android's absolute// orientation, and only then an orientation that is relative to where the page began,// which it says is not north. A heading crossing north goes the short way, and it// is smoothed so a hand's shake does not show. It counts the screen being turned. When the// reading is poor, it says how to calibrate; a phone near metal wanders, and it notices.//// <CompassDial/>// <CompassDial bearing={132} target="Base camp"/>// <CompassDial on_heading={(h) => { facing = h }}/>//// bearing: degrees from north of a place to go; it marks it on the rim and says which way// to turn. target: what that place is called. smooth: how much of each new reading// is taken, 0 to 1; less is steadier and slower (0.2). on_heading(degrees): inside one of// your components, called as the whole-degree heading changes. Android's Chrome and a// phone in the Quillo app ask nothing; iOS asks once with a tap, and the button to do that shows// only where needed. A person who refused can turn it on in Settings.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet compass_dial_css = css`.ui-compass { container-type: inline-size; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-compass-in { display: grid; gap: 12px; justify-items: center; }.ui-compass-face { position: relative; width: min(100%, 280px); aspect-ratio: 1; }.ui-compass-rose { position: absolute; inset: 0; width: 100%; height: 100%; will-change: transform; }.ui-compass-ring { fill: var(--ui-surface); stroke: var(--ui-border); stroke-width: 1; }.ui-compass-tick { stroke: var(--ui-muted); stroke-width: 1; }.ui-compass-tick-big { stroke: var(--ui-fg); stroke-width: 2; }.ui-compass-letter { fill: var(--ui-fg); font: 600 14px/1 var(--ui-font); text-anchor: middle; }.ui-compass-north { fill: var(--ui-danger); }.ui-compass-mark { fill: var(--ui-accent); }.ui-compass-lubber { position: absolute; left: 50%; top: -2px; width: 0; height: 0; margin-left: -8px; border: 8px solid transparent; border-top: 14px solid var(--ui-danger); }.ui-compass-side { display: grid; gap: 12px; justify-items: center; min-width: 0; }.ui-compass-read { display: grid; gap: 2px; justify-items: center; text-align: center; }.ui-compass-big { font: 600 30px/1.15 var(--ui-font); font-variant-numeric: tabular-nums; }.ui-compass-turn { color: var(--ui-accent); font-weight: 500; }.ui-compass-say { margin: 0; color: var(--ui-muted); max-width: 34ch; }.ui-compass-say:empty { display: none; }.ui-compass-say[data-bad] { color: var(--ui-fg); }.ui-compass-bar { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }/* With room, the readings and advice stand beside the dial. */@container (min-width: 420px) { .ui-compass-in { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; column-gap: 20px; } .ui-compass-face { width: min(100%, 420px, 70dvh); }}`fn compass_dial_num(x) = (type_of(x) == "int" || type_of(x) == "float") && math.isfinite(x)// Into 0 to 360, for a degree of any size.fn compass_dial_wrap(d) = d - 360 * floor(d / 360)// The shortest way from one heading to another: -180 to 180, positive clockwise.fn compass_dial_delta(from, to) = compass_dial_wrap(to - from + 180) - 180// One step towards a new heading, the short way. `turned` is not wrapped: it goes// on past 360, so a dial set by it never swings back round through south to cross north.fn compass_dial_ease(turned, heading, f) = turned + compass_dial_delta(turned, heading) * f// The device's heading from its orientation. Alpha turns the opposite way to a compass.// Lying down, the top edge points where the heading is, whatever the roll. Held up// to read, the top edge points at the sky and says little, so this uses the way the back// faces, from the Device Orientation spec's worked example.fn compass_dial_heading(alpha, beta, gamma) { let b = compass_dial_num(beta) ? beta : 0 let g = compass_dial_num(gamma) ? gamma : 0 if abs(b) < 65 { compass_dial_wrap(360 - alpha) } else { let ra = math.radians(alpha) let rb = math.radians(b) let rg = math.radians(g) let east = -math.cos(ra) * math.sin(rg) - math.sin(ra) * math.sin(rb) * math.cos(rg) let north = -math.sin(ra) * math.sin(rg) + math.cos(ra) * math.sin(rb) * math.cos(rg) compass_dial_wrap(math.degrees(math.atan2(east, north))) }}// N, NE and so on: eight points, 45° each, with north running from 337.5 to 22.5.fn compass_dial_point(h) { let names = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] names[floor(compass_dial_wrap(h + 22.5) / 45) % 8]}// How far the last few headings wander, in degrees: the circular standard deviation,// as a plain average of 359 and 1 would be 180.fn compass_dial_spread(samples) { let n = len(samples) if n < 2 { 0 } else { let s = sum(samples | map((d) => math.sin(math.radians(d)))) / n let c = sum(samples | map((d) => math.cos(math.radians(d)))) / n let r = sqrt(s * s + c * c) r >= 1 ? 0 : (r < 0.000001 ? 180 : math.degrees(sqrt(-2 * math.log(r)))) }}// What to tell a person. iOS gives an accuracy, in degrees, with -1 for none at all;// Android gives nothing, so there the wander stands in for it.fn compass_dial_advice(source, accuracy, spread) { if source == "relative" { "This device reports only turns from where it started, so this is not true north." } else if compass_dial_num(accuracy) && (accuracy < 0 || accuracy > 25) { "The compass needs calibrating: move the phone in a figure of eight." } else if spread > 15 { "The reading is wandering. Move away from metal and magnets, then move the phone in a figure of eight." } else { "" }}// Which way to turn for a bearing: "Turn 12° right", or "Straight ahead" within 3°.fn compass_dial_turn(heading, bearing) { let d = round(compass_dial_delta(heading, bearing)) if abs(d) <= 3 { "Straight ahead" } else { `Turn ${abs(d)}° ${d > 0 ? "right" : "left"}` }}fn compass_dial_note(status) = match status { "tap" => "This phone asks before a page can read the compass." "refused" => "The compass was refused. Allow Motion and Orientation access to use it." "none" => "There is no compass here. Try it on a phone." _ => ""}component CompassDial(bearing: num? = null, target: str? = null, smooth: num = 0.2, on_heading = null, class: str? = null) { style global compass_dial_css state status = "wait" // wait, tap, on, refused, none state source = null // "true", or "relative" where it is not north state shown = null // the heading in whole degrees state turned = null // the smoothed heading, unwrapped: goes past 360 and below 0 state accuracy = null state samples = [] state spread = 0 state count = 0 state turn = 0 // the screen's angle state rose = null state strong = false // an absolute reading has come: relative ones are ignored after that state ears = false state heard = false state silent = false let feed = (h, acc, src) => { heard = true silent = false if status != "on" { status = "on" } if src != source { source = src } // The dial shows where the top of the screen points, however the screen is turned. let top = compass_dial_wrap(h + turn) turned = turned == null ? top : compass_dial_ease(turned, top, max([min([smooth, 1]), 0.01])) if rose != null { rose.style.transform = `rotate(${round(-turned, 2)}deg)` } let whole = round(compass_dial_wrap(turned)) % 360 if whole != shown { shown = whole on_heading && on_heading(whole) } accuracy = acc // The wander is worked out now and then: the last 24 readings, every eighth. count += 1 if count % 8 == 0 { samples = slice(samples | push(top), -24, len(samples) + 1) spread = compass_dial_spread(samples) } } // An empty event here says nothing: the other event's says whether there is a sensor. let absolute = (e) => { if compass_dial_num(e.alpha) { strong = true feed(compass_dial_heading(e.alpha, e.beta, e.gamma), null, "true") } } let relative = (e) => { if compass_dial_num(e.webkitCompassHeading) { strong = true feed(e.webkitCompassHeading, e.webkitCompassAccuracy, "true") } else if compass_dial_num(e.alpha) { if !strong { feed(compass_dial_heading(e.alpha, e.beta, e.gamma), null, e.absolute == true ? "true" : "relative") } } else if !heard { status = "none" } } let hush = () => { if ears { ears = false js("window.removeEventListener", "deviceorientationabsolute", absolute) js("window.removeEventListener", "deviceorientation", relative) } silent = false } let listen = () => { hush() ears = true heard = false strong = false status = "on" silent = true samples = [] spread = 0 turned = null js("window.addEventListener", "deviceorientationabsolute", absolute) js("window.addEventListener", "deviceorientation", relative) } // As in ShakeDetector: only asking tells whether it needs asking. let start = (tapped) => { let ok = js("DeviceOrientationEvent.requestPermission") if is_err(ok) && !contains(ok.error, "not defined") { hush() status = tapped ? "refused" : "tap" } else if ok == "denied" { hush() status = "refused" } else { listen() } } if silent { every(2500, () => { if !heard { hush(); status = "none" } }) } let note = compass_dial_note(status) let advice = status == "on" && shown != null ? compass_dial_advice(source, accuracy, spread) : "" let say = note != "" ? note : advice <div class={ui_cls(["ui-compass", class])} data-source={source} @mount={(_el) => { let so = js("screen.orientation") let rotated = (_e = null) => { turn = is_err(so) ? 0 : (so.angle ?? 0) } if !is_err(so) && so != null { so.addEventListener("change", rotated) } rotated() start(false) () => { hush() if !is_err(so) && so != null { so.removeEventListener("change", rotated) } } }}> <div.ui-compass-in> <div.ui-compass-face role="img" aria-label={shown == null ? "Compass" : `Heading ${shown} degrees ${compass_dial_point(shown)}`}> <svg.ui-compass-rose viewBox="0 0 200 200" aria-hidden="true" @mount={(el) => { rose = el }}> <circle.ui-compass-ring cx="100" cy="100" r="98"/> {for d in [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330] => <line key={d} class={d % 90 == 0 ? "ui-compass-tick ui-compass-tick-big" : "ui-compass-tick"} x1="100" y1="6" x2="100" y2="18" transform={`rotate(${d} 100 100)`}/>} <text.ui-compass-letter x="100" y="36" class="ui-compass-north">N</text> <text.ui-compass-letter x="170" y="105" transform="rotate(90 170 100)">E</text> <text.ui-compass-letter x="100" y="174" transform="rotate(180 100 170)">S</text> <text.ui-compass-letter x="30" y="105" transform="rotate(270 30 100)">W</text> {bearing != null && <path.ui-compass-mark d="M100 4l7 13h-14z" transform={`rotate(${round(compass_dial_wrap(bearing))} 100 100)`}/>} </svg> <div.ui-compass-lubber aria-hidden="true"></div> </div> <div.ui-compass-side> <div.ui-compass-read> <span.ui-compass-big>{shown == null ? "--" : `${shown}° ${compass_dial_point(shown)}`}</span> {bearing != null && shown != null && <span.ui-compass-turn>{target != null ? `${target}: ` : ""}{compass_dial_turn(shown, bearing)}</span>} </div> <p.ui-compass-say role="status" data-bad={say != "" ? "" : null}>{say}</p> {(status == "tap" || status == "refused" || status == "none") && <div.ui-compass-bar> <Button size="lg" class="ui-compass-start" on_click={() => { start(true) }}>{status == "tap" ? "Turn on the compass" : "Try again"}</Button> </div>} </div> </div> </div>}Related
ShakeDetectorDetects a shake from motion with a threshold, the iOS permission and a debounce, with a button as its equal.4 views · 7 downloadsSpiritLevelA bubble level from the device's motion, with a zero, a haptic when level, and a message where there is no sensor.4 views · 7 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 6 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloads
Thread
Thread 0 posts
On CompassDial: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.