545 lines
16 KiB
Svelte
545 lines
16 KiB
Svelte
<script lang="ts">
|
||
import { onDestroy, onMount } from 'svelte';
|
||
import { browser } from '$app/environment';
|
||
import { m } from '$lib/paraglide/messages';
|
||
|
||
// Geolocation
|
||
let geoWatchId: number | null = $state(null);
|
||
let geolocation: GeolocationPosition | null = $state(null);
|
||
let geoError: string | null = $state(null);
|
||
let geoSupported = $state<boolean>(false);
|
||
|
||
// DeviceMotion / DeviceOrientation (useful fallbacks on iOS/Safari)
|
||
type Motion = { ax?: number; ay?: number; az?: number; gx?: number; gy?: number; gz?: number };
|
||
let deviceMotion: Motion | null = $state(null);
|
||
let deviceOrientation: { alpha?: number; beta?: number; gamma?: number } | null = $state(null);
|
||
let motionSupported = $state(false);
|
||
let orientationSupported = $state(false);
|
||
|
||
// iOS Safari permission flow for motion/orientation
|
||
let motionPermissionAvailable = $state(false);
|
||
let orientationPermissionAvailable = $state(false);
|
||
let motionPermission: 'unknown' | 'granted' | 'denied' = $state('unknown');
|
||
let orientationPermission: 'unknown' | 'granted' | 'denied' = $state('unknown');
|
||
|
||
// Generic Sensor API (subject to browser/flag support)
|
||
type SensorHandle = {
|
||
instance?: any;
|
||
supported: boolean;
|
||
active: boolean;
|
||
error?: string | null;
|
||
data: Record<string, number | string | undefined>;
|
||
};
|
||
|
||
let accelerometer: SensorHandle = $state({ supported: false, active: false, data: {} });
|
||
let gyroscope: SensorHandle = $state({ supported: false, active: false, data: {} });
|
||
let magnetometer: SensorHandle = $state({ supported: false, active: false, data: {} });
|
||
let ambientLight: SensorHandle = $state({ supported: false, active: false, data: {} });
|
||
let barometer: SensorHandle = $state({ supported: false, active: false, data: {} });
|
||
|
||
const w = browser ? (window as any) : undefined;
|
||
|
||
function detectSupport() {
|
||
geoSupported = browser && 'geolocation' in navigator;
|
||
motionSupported = browser && 'DeviceMotionEvent' in (window as any);
|
||
orientationSupported = browser && 'DeviceOrientationEvent' in (window as any);
|
||
|
||
accelerometer.supported = Boolean(w?.Accelerometer);
|
||
gyroscope.supported = Boolean(w?.Gyroscope);
|
||
magnetometer.supported = Boolean(w?.Magnetometer);
|
||
ambientLight.supported = Boolean(w?.AmbientLightSensor);
|
||
barometer.supported = Boolean(w?.Barometer);
|
||
}
|
||
|
||
onMount(() => {
|
||
detectSupport();
|
||
|
||
// Check for iOS-style permission request APIs
|
||
motionPermissionAvailable =
|
||
browser &&
|
||
typeof (window as any).DeviceMotionEvent !== 'undefined' &&
|
||
typeof (DeviceMotionEvent as any).requestPermission === 'function';
|
||
orientationPermissionAvailable =
|
||
browser &&
|
||
typeof (window as any).DeviceOrientationEvent !== 'undefined' &&
|
||
typeof (DeviceOrientationEvent as any).requestPermission === 'function';
|
||
|
||
if (orientationSupported) {
|
||
const handler = (e: DeviceOrientationEvent) => {
|
||
deviceOrientation = {
|
||
alpha: e.alpha ?? undefined,
|
||
beta: e.beta ?? undefined,
|
||
gamma: e.gamma ?? undefined
|
||
};
|
||
};
|
||
window.addEventListener('deviceorientation', handler);
|
||
onDestroy(() => window.removeEventListener('deviceorientation', handler));
|
||
}
|
||
|
||
if (motionSupported) {
|
||
const handler = (e: DeviceMotionEvent) => {
|
||
deviceMotion = {
|
||
ax: e.acceleration?.x ?? undefined,
|
||
ay: e.acceleration?.y ?? undefined,
|
||
az: e.acceleration?.z ?? undefined,
|
||
gx: e.rotationRate?.alpha ?? undefined,
|
||
gy: e.rotationRate?.beta ?? undefined,
|
||
gz: e.rotationRate?.gamma ?? undefined
|
||
};
|
||
};
|
||
window.addEventListener('devicemotion', handler);
|
||
onDestroy(() => window.removeEventListener('devicemotion', handler));
|
||
}
|
||
});
|
||
|
||
onDestroy(() => {
|
||
stopGeolocation();
|
||
stopSensor(accelerometer);
|
||
stopSensor(gyroscope);
|
||
stopSensor(magnetometer);
|
||
stopSensor(ambientLight);
|
||
stopSensor(barometer);
|
||
});
|
||
|
||
// (Permissions are requested implicitly when starting sensors where applicable)
|
||
|
||
// Geolocation controls
|
||
async function startGeolocation() {
|
||
if (!geoSupported) return;
|
||
try {
|
||
geoError = null;
|
||
geolocation = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||
enableHighAccuracy: true,
|
||
timeout: 10000
|
||
});
|
||
});
|
||
geoWatchId = navigator.geolocation.watchPosition(
|
||
(pos) => (geolocation = pos),
|
||
(err) => (geoError = err?.message || String(err)),
|
||
{ enableHighAccuracy: true }
|
||
);
|
||
} catch (e: any) {
|
||
geoError = e?.message ?? String(e);
|
||
}
|
||
}
|
||
|
||
function stopGeolocation() {
|
||
if (geoWatchId != null) {
|
||
navigator.geolocation.clearWatch(geoWatchId);
|
||
geoWatchId = null;
|
||
}
|
||
}
|
||
|
||
// Generic Sensor helpers
|
||
function startSensor(handle: SensorHandle, ctorName: string, options: any = { frequency: 60 }) {
|
||
try {
|
||
handle.error = null;
|
||
if (!w?.[ctorName]) {
|
||
handle.supported = false;
|
||
return;
|
||
}
|
||
handle.instance = new w[ctorName](options);
|
||
handle.instance.addEventListener('reading', () => {
|
||
// Populate based on sensor type
|
||
if (ctorName === 'Accelerometer') {
|
||
handle.data = { x: handle.instance.x, y: handle.instance.y, z: handle.instance.z };
|
||
} else if (ctorName === 'Gyroscope') {
|
||
handle.data = { x: handle.instance.x, y: handle.instance.y, z: handle.instance.z };
|
||
} else if (ctorName === 'Magnetometer') {
|
||
handle.data = { x: handle.instance.x, y: handle.instance.y, z: handle.instance.z };
|
||
} else if (ctorName === 'AmbientLightSensor') {
|
||
handle.data = { illuminance: handle.instance.illuminance };
|
||
} else if (ctorName === 'Barometer') {
|
||
handle.data = {
|
||
pressure: handle.instance.pressure,
|
||
temperature: handle.instance?.temperature
|
||
};
|
||
}
|
||
});
|
||
handle.instance.addEventListener('error', (event: any) => {
|
||
handle.error = event?.error?.message || String(event);
|
||
});
|
||
handle.instance.start();
|
||
handle.active = true;
|
||
} catch (e: any) {
|
||
handle.error = e?.message ?? String(e);
|
||
handle.active = false;
|
||
}
|
||
}
|
||
|
||
function stopSensor(handle: SensorHandle) {
|
||
try {
|
||
handle.instance?.stop?.();
|
||
} catch {}
|
||
handle.active = false;
|
||
}
|
||
|
||
// UI helpers
|
||
function toFixed(n: number | undefined, digits = 2) {
|
||
return typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : '—';
|
||
}
|
||
|
||
async function copyJSON(data: unknown) {
|
||
try {
|
||
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||
alert(m.sensors_copied());
|
||
} catch {}
|
||
}
|
||
|
||
async function requestMotionOrientation() {
|
||
try {
|
||
if (motionPermissionAvailable) {
|
||
const res = await (DeviceMotionEvent as any).requestPermission();
|
||
motionPermission = res === 'granted' ? 'granted' : 'denied';
|
||
}
|
||
if (orientationPermissionAvailable) {
|
||
const res2 = await (DeviceOrientationEvent as any).requestPermission();
|
||
orientationPermission = res2 === 'granted' ? 'granted' : 'denied';
|
||
}
|
||
} catch (_) {
|
||
if (motionPermissionAvailable && motionPermission === 'unknown') motionPermission = 'denied';
|
||
if (orientationPermissionAvailable && orientationPermission === 'unknown')
|
||
orientationPermission = 'denied';
|
||
}
|
||
}
|
||
|
||
// Kick off light permission checks lazily in UI; starting sensors will request permissions where needed.
|
||
</script>
|
||
|
||
<h2><i class="ti ti-cpu-2"></i> {m.sensors_title()}</h2>
|
||
|
||
<div class="sections">
|
||
{#if motionPermissionAvailable || orientationPermissionAvailable}
|
||
<section>
|
||
<h3>{m.sensors_permissions()}</h3>
|
||
<div class="row">
|
||
<button onclick={requestMotionOrientation}
|
||
><i class="ti ti-key"></i> {m.sensors_enableMotionOrientation()}</button
|
||
>
|
||
</div>
|
||
<ul class="kv">
|
||
{#if motionPermissionAvailable}
|
||
<li>
|
||
<span class="key">{m.sensors_motion()}</span>
|
||
<span>
|
||
{#if motionPermission === 'granted'}{m.sensors_status_granted()}
|
||
{:else if motionPermission === 'denied'}{m.sensors_status_denied()}
|
||
{:else}{m.sensors_status_unknown()}{/if}
|
||
</span>
|
||
</li>
|
||
{/if}
|
||
{#if orientationPermissionAvailable}
|
||
<li>
|
||
<span class="key">{m.sensors_orientation()}</span>
|
||
<span>
|
||
{#if orientationPermission === 'granted'}{m.sensors_status_granted()}
|
||
{:else if orientationPermission === 'denied'}{m.sensors_status_denied()}
|
||
{:else}{m.sensors_status_unknown()}{/if}
|
||
</span>
|
||
</li>
|
||
{/if}
|
||
</ul>
|
||
</section>
|
||
{/if}
|
||
<section>
|
||
<h3>{m.sensors_geolocation()}</h3>
|
||
{#if geoSupported}
|
||
<div class="row">
|
||
<button onclick={startGeolocation} disabled={geoWatchId !== null}>
|
||
<i class="ti ti-player-play"></i>
|
||
{m.sensors_start()}
|
||
</button>
|
||
<button onclick={stopGeolocation} disabled={geoWatchId === null}>
|
||
<i class="ti ti-player-stop"></i>
|
||
{m.sensors_stop()}
|
||
</button>
|
||
</div>
|
||
{#if geoError}
|
||
<div class="error">{geoError}</div>
|
||
{/if}
|
||
{#if geolocation}
|
||
<ul class="kv">
|
||
<li><span class="key">lat</span><span>{geolocation.coords.latitude}</span></li>
|
||
<li><span class="key">lon</span><span>{geolocation.coords.longitude}</span></li>
|
||
<li>
|
||
<span class="key">{m.sensors_accuracy()}</span><span
|
||
>{toFixed(geolocation.coords.accuracy)}</span
|
||
>
|
||
</li>
|
||
<li>
|
||
<span class="key">{m.sensors_altitude()}</span><span
|
||
>{geolocation.coords.altitude ?? '—'}</span
|
||
>
|
||
</li>
|
||
<li>
|
||
<span class="key">{m.sensors_heading()}</span><span
|
||
>{toFixed(geolocation.coords.heading ?? undefined)}</span
|
||
>
|
||
</li>
|
||
<li>
|
||
<span class="key">{m.sensors_speed()}</span><span
|
||
>{toFixed(geolocation.coords.speed ?? undefined)}</span
|
||
>
|
||
</li>
|
||
<li>
|
||
<span class="key">{m.sensors_timestamp()}</span><span
|
||
>{new Date(geolocation.timestamp).toLocaleString()}</span
|
||
>
|
||
</li>
|
||
</ul>
|
||
<div class="row">
|
||
<button onclick={() => copyJSON(geolocation)}
|
||
><i class="ti ti-copy"></i> {m.sensors_copy()}</button
|
||
>
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_deviceMotion()}</h3>
|
||
{#if motionSupported}
|
||
<ul class="kv">
|
||
<li><span class="key">ax</span><span>{toFixed(deviceMotion?.ax)}</span></li>
|
||
<li><span class="key">ay</span><span>{toFixed(deviceMotion?.ay)}</span></li>
|
||
<li><span class="key">az</span><span>{toFixed(deviceMotion?.az)}</span></li>
|
||
<li><span class="key">α</span><span>{toFixed(deviceMotion?.gx)}</span></li>
|
||
<li><span class="key">β</span><span>{toFixed(deviceMotion?.gy)}</span></li>
|
||
<li><span class="key">γ</span><span>{toFixed(deviceMotion?.gz)}</span></li>
|
||
</ul>
|
||
<div class="row">
|
||
<button onclick={() => copyJSON(deviceMotion)}
|
||
><i class="ti ti-copy"></i> {m.sensors_copy()}</button
|
||
>
|
||
</div>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_deviceOrientation()}</h3>
|
||
{#if orientationSupported}
|
||
<ul class="kv">
|
||
<li><span class="key">alpha</span><span>{toFixed(deviceOrientation?.alpha)}</span></li>
|
||
<li><span class="key">beta</span><span>{toFixed(deviceOrientation?.beta)}</span></li>
|
||
<li><span class="key">gamma</span><span>{toFixed(deviceOrientation?.gamma)}</span></li>
|
||
</ul>
|
||
<div class="row">
|
||
<button onclick={() => copyJSON(deviceOrientation)}
|
||
><i class="ti ti-copy"></i> {m.sensors_copy()}</button
|
||
>
|
||
</div>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_accelerometer()}</h3>
|
||
{#if accelerometer.supported}
|
||
<div class="row">
|
||
{#if !accelerometer.active}
|
||
<button onclick={() => startSensor(accelerometer, 'Accelerometer')}
|
||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
||
>
|
||
{:else}
|
||
<button onclick={() => stopSensor(accelerometer)}
|
||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if accelerometer.error}
|
||
<div class="error">{accelerometer.error}</div>
|
||
{/if}
|
||
<ul class="kv">
|
||
<li><span class="key">x</span><span>{toFixed(accelerometer.data.x as number)}</span></li>
|
||
<li><span class="key">y</span><span>{toFixed(accelerometer.data.y as number)}</span></li>
|
||
<li><span class="key">z</span><span>{toFixed(accelerometer.data.z as number)}</span></li>
|
||
</ul>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_gyroscope()}</h3>
|
||
{#if gyroscope.supported}
|
||
<div class="row">
|
||
{#if !gyroscope.active}
|
||
<button onclick={() => startSensor(gyroscope, 'Gyroscope')}
|
||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
||
>
|
||
{:else}
|
||
<button onclick={() => stopSensor(gyroscope)}
|
||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if gyroscope.error}
|
||
<div class="error">{gyroscope.error}</div>
|
||
{/if}
|
||
<ul class="kv">
|
||
<li><span class="key">x</span><span>{toFixed(gyroscope.data.x as number)}</span></li>
|
||
<li><span class="key">y</span><span>{toFixed(gyroscope.data.y as number)}</span></li>
|
||
<li><span class="key">z</span><span>{toFixed(gyroscope.data.z as number)}</span></li>
|
||
</ul>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_magnetometer()}</h3>
|
||
{#if magnetometer.supported}
|
||
<div class="row">
|
||
{#if !magnetometer.active}
|
||
<button onclick={() => startSensor(magnetometer, 'Magnetometer', { frequency: 10 })}
|
||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
||
>
|
||
{:else}
|
||
<button onclick={() => stopSensor(magnetometer)}
|
||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if magnetometer.error}
|
||
<div class="error">{magnetometer.error}</div>
|
||
{/if}
|
||
<ul class="kv">
|
||
<li><span class="key">x</span><span>{toFixed(magnetometer.data.x as number)}</span></li>
|
||
<li><span class="key">y</span><span>{toFixed(magnetometer.data.y as number)}</span></li>
|
||
<li><span class="key">z</span><span>{toFixed(magnetometer.data.z as number)}</span></li>
|
||
</ul>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_ambientLight()}</h3>
|
||
{#if ambientLight.supported}
|
||
<div class="row">
|
||
{#if !ambientLight.active}
|
||
<button onclick={() => startSensor(ambientLight, 'AmbientLightSensor')}
|
||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
||
>
|
||
{:else}
|
||
<button onclick={() => stopSensor(ambientLight)}
|
||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if ambientLight.error}
|
||
<div class="error">{ambientLight.error}</div>
|
||
{/if}
|
||
<ul class="kv">
|
||
<li>
|
||
<span class="key">{m.sensors_illuminance()}</span><span
|
||
>{toFixed(ambientLight.data.illuminance as number)}</span
|
||
>
|
||
</li>
|
||
</ul>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>{m.sensors_barometer()}</h3>
|
||
{#if barometer.supported}
|
||
<div class="row">
|
||
{#if !barometer.active}
|
||
<button onclick={() => startSensor(barometer, 'Barometer')}
|
||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
||
>
|
||
{:else}
|
||
<button onclick={() => stopSensor(barometer)}
|
||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if barometer.error}
|
||
<div class="error">{barometer.error}</div>
|
||
{/if}
|
||
<ul class="kv">
|
||
<li>
|
||
<span class="key">{m.sensors_pressure()}</span><span
|
||
>{toFixed(barometer.data.pressure as number)}</span
|
||
>
|
||
</li>
|
||
<li>
|
||
<span class="key">{m.sensors_temperature()}</span><span
|
||
>{barometer.data.temperature ?? '—'}</span
|
||
>
|
||
</li>
|
||
</ul>
|
||
{:else}
|
||
<div class="subdued">{m.sensors_notSupported()}</div>
|
||
{/if}
|
||
</section>
|
||
</div>
|
||
|
||
<style>
|
||
.sections {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
}
|
||
|
||
section {
|
||
border: 1px solid currentColor;
|
||
border-radius: 0.5rem;
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
h3 {
|
||
margin: 0 0 0.5rem 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.kv {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0.5rem 0 0 0;
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 0.25rem 0.75rem;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.kv .key {
|
||
opacity: 0.8;
|
||
margin-right: 0.25em;
|
||
}
|
||
|
||
.error {
|
||
color: #ff6b6b;
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
.subdued {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
button {
|
||
background: none;
|
||
color: inherit;
|
||
border: 1px solid currentColor;
|
||
border-radius: 0.25rem;
|
||
padding: 0.25rem 0.5rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
</style>
|