方角と高さの案内
端末を、空の決まった方角と高さへ向けてもらう画面の事例です。上の文字盤で方角を、下の弧で見上げる高さを案内します。状態ごとに何をどう伝えるかをまとめた事例です。
プレビュー
状態とバリエーション
探している
文字盤は、端末がいま向いている方角を上にして回ります。矢印は目標の方角を指し、下に「左へ 24 度」と、回る向きと角度を言葉で出します。高さは「いまの構えから上へ 8度」と出します。
いま出ています
左へ 24 度
地平線から 49°、腕を伸ばして、こぶし 5 つぶん上
いまの構えから上へ 8度
現在地から。またたかない明るい点が、空を動いていきます。見えるのは約7分。
合った
方角の差が 8° 未満になると「そこです」と出し、矢印と文字盤の外周を緑にします。高さは差が 8° 未満で「その高さです」と出し、弧の線を緑にします。高さだけは、いったん合ったら差が 12° を超えるまで合ったままとします。構えた手の揺れで、合う・外れるを細かく繰り返さないためです。方角と高さは別々に判定し、別々の行に出します。1つの緑で両方を表すと、どちらが合ったのか分からなくなるためです。
いま出ています
そこです
地平線から 49°、腕を伸ばして、こぶし 5 つぶん上
その高さです
現在地から。またたかない明るい点が、空を動いていきます。見えるのは約7分。
目標が昇る前
画面上部の見出しに残り時間を「あと 8分39秒」と出します。1時間より先なら「9/21 18:40 ごろ(2日後)」のように、日時と、あと何時間か何日かを出します。矢印と弧は、目標が最初に見えてくる方角と高さを指します。そこへ端末を向けて待てば、出てきた目標をそのまま追えます。いちばん高く昇るときの高さは、弧に薄い線と「最高 60°」で添えるだけにします。
あと 8分39秒
左へ 24 度
現れるのは地平線から 10°、腕を伸ばして、こぶし 1 つぶん上
いまの構えから上へ 8度
現在地から。またたかない明るい点が、空を動いていきます。見えるのは約7分。
方位の誤差が大きい
端末が返す方位の誤差が ±20° を超えたときだけ、下に一言出します。「端末を大きく8の字に動かしてください。磁石の付いたカバーやペンが近いとずれます」。ふだんでも 10〜15° の誤差はあるので、いつも出すと誰も読まなくなります。
あと 8分39秒
左へ 24 度
現れるのは地平線から 10°、腕を伸ばして、こぶし 1 つぶん上
いまの構えから上へ 8度
現在地から。またたかない明るい点が、空を動いていきます。見えるのは約7分。方位の誤差が大きくなっています(±35°)。端末を大きく8の字に動かしてください。磁石の付いたカバーやペンが近いとずれます。
方位を測れない端末
パソコンのように方位を測れない端末では、文字盤を北が上の図にして、「西南西の方角(図は北が上)」と言葉で伝えます。端末の向きが分からないので、「合った」はありません。
あと 8分39秒
西南西の方角(図は北が上)
現れるのは地平線から 10°、腕を伸ばして、こぶし 1 つぶん上
現在地から。またたかない明るい点が、空を動いていきます。見えるのは約7分。
触覚
外では画面より空を見ています。合ったかどうかを、端末の震え方だけで分かるようにします。
| いつ | 震え方 |
|---|---|
| 方角が合った | 重く1回 |
| 方角から外れた | 軽く2回 |
| 高さが合った | 小刻みに3回 |
- 震わせるのは、合った瞬間と外れた瞬間だけです。画面は1秒に60回描き直すので、そのたびに震わせると1本の長い震えになります。
- 強さは手で持っていると区別しにくいので、回数で見分けられるようにします。
- 方角と高さが同時に合ったら、0.35 秒あけて順に震わせます。同時に出すと、1回の震えと区別できません。
- 高さは、外れたときには震わせません。手の揺れで何度も震えるのを避けるためです。
外で使う前提
- 画面を暗くするボタンと、震えのボタンを、下の左右の隅に置きます。端末を持った手の親指で押せる位置です。
- 暗くする設定は、開くたびにオフから始めます。暗いまま閉じると、次に明るい場所で開いたとき何も見えません。震えの設定は覚えます。
- 探しているあいだは、画面が自動で消えないようにします(ブラウザでは Screen Wake Lock API)。閉じるときは、画面の明るさを暗くする前に戻します。
- 高さは度数だけでなく、「地平線から、腕を伸ばしてこぶし 5 つぶん上」とも言います。腕を伸ばしたこぶし1つの幅が、およそ 10° です。
- 閉じるボタンは × だけにせず、「閉じる」と書いて大きく取ります。暗い屋外で押すためです。
決めている値
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| 方角の合う差 | ° | 8 | 差がこれ未満で「そこです」。以上で外れ。 |
| 高さの合う差 | ° | 8 | 差がこれ未満で「その高さです」。 |
| 高さの外れる差 | ° | 12 | いったん合ったら、差がこれを超えるまで合ったまま。 |
| 誤差の一言を出す幅 | ° | ±20 | 方位の誤差がこれを超えたときだけ出す。 |
| 震えのあいだ | 秒 | 0.35 | 方角と高さが同時に合ったとき、2つめまで待つ時間。 |
| こぶし1つ | ° | 10 | 腕を伸ばしたこぶし1つの幅。高さを言葉にするときに使う。 |
使い方
"use client";
import * as React from "react";
import { Label, Slider } from "@gunjo/ui";
// 方角の差を -180〜180 に畳む(359° と 1° の差は 2°)
function turnDelta(target: number, heading: number) {
return ((target - heading + 540) % 360) - 180;
}
const TARGET = { az: 247, el: 10 }; // 目標の方位と、地平線からの高さ
const HIT = 8; // 合ったとみなす差
const KEEP = 12; // 高さは、いったん合ったらこの差まで合ったまま
// 震えは順に出す。同時に出すと1回に感じる
let freeAt = 0;
function buzz(pattern: number[]) {
const now = performance.now();
const at = Math.max(now, freeAt);
freeAt = at + pattern.reduce((a, b) => a + b, 0) + 350;
setTimeout(() => navigator.vibrate?.(pattern), at - now);
}
export function DirectionGuide() {
// 実機では DeviceOrientationEvent から入れる
const [heading, setHeading] = React.useState(271);
const [pitch, setPitch] = React.useState(2);
const delta = turnDelta(TARGET.az, heading);
const onAz = Math.abs(delta) < HIT;
const dEl = TARGET.el - pitch;
const [onEl, setOnEl] = React.useState(false);
const nextOnEl = Math.abs(dEl) < (onEl ? KEEP : HIT);
if (nextOnEl !== onEl) setOnEl(nextOnEl);
const azRef = React.useRef<boolean | null>(null);
const elRef = React.useRef(false);
React.useEffect(() => {
if (azRef.current !== null && onAz !== azRef.current) {
buzz(onAz ? [30] : [10, 90, 10]); // 重く1回/軽く2回
}
azRef.current = onAz;
if (!elRef.current && onEl) buzz([12, 33, 12, 33, 12]); // 小刻みに3回
elRef.current = onEl;
}, [onAz, onEl]);
return (
<div className="space-y-4">
<p className="text-2xl font-bold">
{onAz ? "そこです" : `${delta > 0 ? "右" : "左"}へ ${Math.round(Math.abs(delta))} 度`}
</p>
<p>
{onEl ? "その高さです" : `いまの構えから${dEl > 0 ? "上" : "下"}へ ${Math.round(Math.abs(dEl))}度`}
</p>
<Label htmlFor="heading">端末の向き</Label>
<Slider id="heading" min={0} max={359} value={heading} onValueChange={setHeading} />
<Label htmlFor="pitch">端末の上端の角度</Label>
<Slider id="pitch" min={-30} max={90} value={pitch} onValueChange={setPitch} />
</div>
);
}設計の判断
- 方角は文字盤、高さは弧と、絵を分けています。空を上から見た地図に両方を載せると、見る人が頭の中で向きを合わせ直す必要があります。
- 高さの絵には、地面に立つ人・地平線(0°)・真上(90°)を描きます。数字だけだと、目線からの角度か、端末の傾きかが分かりません。
- 文字盤の上を北にしないのは、外では自分の体の向きが基準になるためです。
使用コンポーネント
関連コンポーネント
- MapControlButton
地図の上の丸いボタン。端末の向きは案内しない。
- ScaleBar
地図の縮尺を見せる。空の方角や高さは扱わない。
- Toast
一瞬で消える知らせ。合ったことは字と震えで出し続ける。
いつ・なぜ使うか(UIXHERO)
「いつ・なぜ使うか」は姉妹サイト UIXHERO の記事で解説する予定です。記事を書くまでは、起票した issue を置いています。