mirror of
https://github.com/kissyjpf/rtm-sync.git
synced 2026-07-22 17:50:26 +00:00
Features: - GPS distance tracking (Haversine formula) + accelerometer step-based fallback - Real-time pace (min/km), elapsed time, lap time and lap pace - Lap recording with full history list - Web Bluetooth heart rate monitor support - 2000m calibration run that saves m/step coefficients (max 10) - Speed-zone matching: selects the best stored coefficient using GPS speed (65% weight) + vertical acceleration variance (35% weight) - Screen wake lock to prevent sleep during runs - Fully self-contained single HTML file (no build step) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Qdb58wRZ4AhTg1MepKPD
976 lines
39 KiB
HTML
976 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ja">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="theme-color" content="#0a0a0f">
|
||
<title>Running Tracker</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||
:root{
|
||
--bg:#0a0a0f;--surface:#13131f;--surface2:#1c1c2e;
|
||
--accent:#00d4ff;--accent2:#7c3aed;
|
||
--green:#22c55e;--red:#ef4444;--orange:#f97316;--yellow:#eab308;
|
||
--text:#f8fafc;--muted:#94a3b8;--border:#2d2d4e;
|
||
}
|
||
body{background:var(--bg);color:var(--text);
|
||
font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',sans-serif;
|
||
min-height:100dvh;overflow-x:hidden;overscroll-behavior:none}
|
||
|
||
/* ── Screens ─────────────────────────────── */
|
||
.screen{display:none;min-height:100dvh;flex-direction:column}
|
||
.screen.active{display:flex}
|
||
|
||
/* ── Header ──────────────────────────────── */
|
||
.hdr{display:flex;align-items:center;justify-content:space-between;
|
||
padding:12px 16px;background:var(--surface);border-bottom:1px solid var(--border);
|
||
flex-shrink:0}
|
||
.hdr h1{font-size:17px;font-weight:700;letter-spacing:-.3px}
|
||
.hdr-btn{background:none;border:none;color:var(--accent);font-size:22px;
|
||
padding:4px 8px;cursor:pointer;line-height:1}
|
||
|
||
/* ── Main timer ───────────────────────────── */
|
||
.big-time{text-align:center;padding:20px 16px 8px;flex-shrink:0}
|
||
.big-label{font-size:11px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;
|
||
color:var(--muted);margin-bottom:4px}
|
||
.big-val{font-size:80px;font-weight:800;letter-spacing:-4px;line-height:1;
|
||
font-variant-numeric:tabular-nums}
|
||
.big-val .sec{font-size:48px;letter-spacing:-2px;color:var(--muted)}
|
||
|
||
/* ── Metrics grid ─────────────────────────── */
|
||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;
|
||
padding:8px 16px;flex-shrink:0}
|
||
.card{background:var(--surface);border-radius:14px;padding:12px 14px;
|
||
border:1px solid var(--border)}
|
||
.card.hi{border-color:var(--accent)}
|
||
.card .lbl{font-size:10px;font-weight:700;letter-spacing:1.5px;
|
||
text-transform:uppercase;color:var(--muted);margin-bottom:2px}
|
||
.card .val{font-size:34px;font-weight:800;letter-spacing:-1.5px;
|
||
font-variant-numeric:tabular-nums;line-height:1}
|
||
.card .unit{font-size:13px;font-weight:500;color:var(--muted)}
|
||
|
||
/* ── Status chips ─────────────────────────── */
|
||
.chips{display:flex;flex-wrap:wrap;gap:6px;padding:6px 16px;flex-shrink:0}
|
||
.chip{display:flex;align-items:center;gap:5px;
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:20px;padding:3px 10px;font-size:11px;color:var(--muted)}
|
||
.dot{width:7px;height:7px;border-radius:50%;background:var(--red);flex-shrink:0}
|
||
.dot.on{background:var(--green);animation:blink 2s infinite}
|
||
.dot.warn{background:var(--yellow)}
|
||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.35}}
|
||
|
||
/* ── Controls ────────────────────────────── */
|
||
.controls{display:flex;gap:14px;padding:10px 16px;
|
||
justify-content:center;align-items:center;flex-shrink:0}
|
||
.btn{border:none;border-radius:50%;cursor:pointer;font-weight:700;
|
||
display:flex;align-items:center;justify-content:center;
|
||
flex-direction:column;gap:1px;transition:transform .12s}
|
||
.btn:active{transform:scale(.91)}
|
||
.btn span{font-size:10px;font-weight:600;letter-spacing:.3px}
|
||
.btn-go{width:84px;height:84px;background:var(--green);color:#fff;font-size:22px}
|
||
.btn-go.pause{background:var(--orange)}
|
||
.btn-md{width:66px;height:66px;background:var(--surface2);
|
||
color:var(--text);font-size:18px;border:1px solid var(--border)}
|
||
.btn-lap{color:var(--accent)}
|
||
.btn-stop-col{color:var(--red)}
|
||
|
||
/* ── Laps ────────────────────────────────── */
|
||
.laps-wrap{flex:1;overflow-y:auto;padding:0 16px 16px}
|
||
.laps-hdr{display:grid;grid-template-columns:28px 1fr 80px 80px;
|
||
padding:6px 0;font-size:10px;font-weight:700;letter-spacing:1.2px;
|
||
text-transform:uppercase;color:var(--muted);
|
||
border-bottom:1px solid var(--border);margin-bottom:2px}
|
||
.lap-row{display:grid;grid-template-columns:28px 1fr 80px 80px;
|
||
align-items:center;padding:9px 0;border-bottom:1px solid var(--border);
|
||
font-size:13px;font-variant-numeric:tabular-nums}
|
||
.lap-row.cur{color:var(--accent)}
|
||
.lap-n{font-size:11px;color:var(--muted)}
|
||
|
||
/* ── Calibration screen ───────────────────── */
|
||
.calib-body{flex:1;display:flex;flex-direction:column;
|
||
align-items:center;padding:20px;overflow-y:auto}
|
||
.ring-wrap{position:relative;width:200px;height:200px;flex-shrink:0}
|
||
.ring-wrap svg{width:200px;height:200px}
|
||
.ring-center{position:absolute;inset:0;display:flex;flex-direction:column;
|
||
align-items:center;justify-content:center}
|
||
.ring-dist{font-size:44px;font-weight:800;letter-spacing:-2px;
|
||
color:var(--accent);font-variant-numeric:tabular-nums}
|
||
.ring-target{font-size:12px;color:var(--muted)}
|
||
.calib-info{background:var(--surface);border-radius:14px;padding:14px 16px;
|
||
width:100%;max-width:320px;margin-top:14px;border:1px solid var(--border)}
|
||
.ci-row{display:flex;justify-content:space-between;font-size:13px;padding:4px 0}
|
||
.ci-lbl{color:var(--muted)}
|
||
|
||
/* ── Coefficient list ────────────────────── */
|
||
.coeff-wrap{flex:1;overflow-y:auto;padding:10px 16px}
|
||
.coeff-card{background:var(--surface);border:1px solid var(--border);
|
||
border-radius:14px;padding:14px 16px;margin-bottom:8px}
|
||
.coeff-card.best{border-color:var(--accent)}
|
||
.coeff-main{font-size:22px;font-weight:800;color:var(--accent);
|
||
font-variant-numeric:tabular-nums;margin-bottom:6px}
|
||
.coeff-row{display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:6px}
|
||
.coeff-d{font-size:12px;color:var(--muted)}
|
||
.btn-del{width:100%;background:none;border:1px solid var(--border);
|
||
color:var(--red);border-radius:8px;padding:6px;font-size:12px;cursor:pointer}
|
||
|
||
/* ── Settings ────────────────────────────── */
|
||
.set-wrap{padding:12px 16px}
|
||
.set-card{background:var(--surface);border:1px solid var(--border);
|
||
border-radius:14px;padding:14px 16px;margin-bottom:8px;
|
||
display:flex;align-items:center;justify-content:space-between}
|
||
.set-t{font-size:14px;font-weight:500}
|
||
.set-d{font-size:11px;color:var(--muted);margin-top:2px}
|
||
.btn-link{background:none;border:none;color:var(--accent);
|
||
font-size:13px;font-weight:600;cursor:pointer;padding:4px}
|
||
.btn-link.danger{color:var(--red)}
|
||
|
||
/* ── Bottom action ───────────────────────── */
|
||
.bottom-action{padding:14px 16px;flex-shrink:0}
|
||
.btn-full{width:100%;height:50px;border:none;border-radius:14px;
|
||
background:var(--accent2);color:#fff;font-size:15px;font-weight:700;
|
||
cursor:pointer;transition:opacity .15s}
|
||
.btn-full:active{opacity:.8}
|
||
|
||
/* ── Toast ───────────────────────────────── */
|
||
.toast{position:fixed;bottom:28px;left:50%;
|
||
transform:translateX(-50%) translateY(80px);
|
||
background:var(--surface2);color:var(--text);
|
||
border:1px solid var(--border);border-radius:14px;
|
||
padding:11px 20px;font-size:13px;font-weight:500;
|
||
transition:transform .28s ease;z-index:999;white-space:nowrap;
|
||
pointer-events:none}
|
||
.toast.show{transform:translateX(-50%) translateY(0)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ═══════════════ SCREEN: MAIN ═══════════════ -->
|
||
<div id="s-main" class="screen active">
|
||
<div class="hdr">
|
||
<button class="hdr-btn" onclick="nav('s-settings')" title="設定">⚙</button>
|
||
<h1>Running Tracker</h1>
|
||
<button class="hdr-btn" onclick="nav('s-coeffs')" title="係数">📊</button>
|
||
</div>
|
||
|
||
<div class="big-time">
|
||
<div class="big-label">経過時間</div>
|
||
<div class="big-val" id="d-time">00:00</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="card hi">
|
||
<div class="lbl">距離</div>
|
||
<div class="val" id="d-dist">0.00<span class="unit">km</span></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="lbl">現在ペース</div>
|
||
<div class="val" id="d-pace">--:--<span class="unit">/km</span></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="lbl">心拍</div>
|
||
<div class="val" id="d-hr">--<span class="unit">bpm</span></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="lbl">ラップペース</div>
|
||
<div class="val" id="d-lp">--:--<span class="unit">/km</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chips">
|
||
<div class="chip"><div class="dot" id="dot-gps"></div><span id="lb-gps">GPS待機</span></div>
|
||
<div class="chip"><div class="dot" id="dot-acc"></div><span id="lb-acc">加速度計</span></div>
|
||
<div class="chip" id="chip-hr" style="display:none">
|
||
<div class="dot" id="dot-hr"></div><span id="lb-hr">心拍計</span>
|
||
</div>
|
||
<div class="chip" id="chip-coeff" style="display:none">
|
||
<span id="lb-coeff">係数 --</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<button class="btn btn-md btn-stop-col" id="btn-stop" onclick="stopRun()" style="display:none">
|
||
⏹<br><span>停止</span>
|
||
</button>
|
||
<button class="btn btn-go" id="btn-go" onclick="toggleRun()">
|
||
▶<br><span id="go-lbl">開始</span>
|
||
</button>
|
||
<button class="btn btn-md btn-lap" id="btn-lap" onclick="recordLap()" style="display:none">
|
||
◎<br><span>ラップ</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div id="laps-wrap" class="laps-wrap" style="display:none">
|
||
<div class="laps-hdr">
|
||
<span>#</span><span>距離</span><span>時間</span><span>ペース</span>
|
||
</div>
|
||
<div id="laps-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════ SCREEN: CALIBRATION ═══════════════ -->
|
||
<div id="s-calib" class="screen">
|
||
<div class="hdr">
|
||
<button class="hdr-btn" onclick="cancelCalib()">✕</button>
|
||
<h1>2000m キャリブレーション</h1>
|
||
<div></div>
|
||
</div>
|
||
|
||
<!-- Idle state -->
|
||
<div id="ci-idle" class="calib-body" style="justify-content:center;text-align:center">
|
||
<div style="font-size:60px;margin-bottom:16px">🏃</div>
|
||
<div style="font-size:18px;font-weight:700;margin-bottom:10px">歩幅キャリブレーション</div>
|
||
<div style="font-size:13px;color:var(--muted);line-height:1.7;max-width:280px;margin-bottom:28px">
|
||
2000m走ることで、現在の速度帯における<br>
|
||
<strong style="color:var(--text)">m/歩 係数</strong>を計測します。<br>
|
||
係数はGPS速度と上下動の組み合わせで<br>管理されます(最大10件)。
|
||
</div>
|
||
<button class="btn btn-go" style="width:110px;height:110px;font-size:20px;
|
||
border-radius:55px" onclick="startCalib()">
|
||
▶<br><span style="font-size:13px">開始</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Running state -->
|
||
<div id="ci-run" class="calib-body" style="display:none">
|
||
<div class="ring-wrap">
|
||
<svg viewBox="0 0 200 200">
|
||
<circle fill="none" stroke="var(--surface2)" stroke-width="10" cx="100" cy="100" r="86"/>
|
||
<circle id="ring-arc" fill="none" stroke="var(--accent)" stroke-width="10"
|
||
stroke-linecap="round" cx="100" cy="100" r="86"
|
||
stroke-dasharray="540.35" stroke-dashoffset="540.35"
|
||
transform="rotate(-90 100 100)"/>
|
||
</svg>
|
||
<div class="ring-center">
|
||
<div class="ring-dist" id="ci-dist">0</div>
|
||
<div class="ring-target">/ 2000 m</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="calib-info">
|
||
<div class="ci-row"><span class="ci-lbl">歩数</span><span id="ci-steps">0 歩</span></div>
|
||
<div class="ci-row"><span class="ci-lbl">GPS速度</span><span id="ci-speed">-- m/s</span></div>
|
||
<div class="ci-row"><span class="ci-lbl">上下動分散</span><span id="ci-var">--</span></div>
|
||
<div class="ci-row"><span class="ci-lbl">経過時間</span><span id="ci-time">00:00</span></div>
|
||
<div class="ci-row"><span class="ci-lbl">現在係数(仮)</span><span id="ci-coeff">-- m/歩</span></div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:12px;margin-top:20px">
|
||
<button class="btn btn-md" onclick="finishCalib()">⏹<br><span>完了</span></button>
|
||
<button class="btn btn-md btn-stop-col" onclick="cancelCalib()">✕<br><span>中止</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════ SCREEN: COEFFICIENTS ═══════════════ -->
|
||
<div id="s-coeffs" class="screen">
|
||
<div class="hdr">
|
||
<button class="hdr-btn" onclick="nav('s-main')">‹</button>
|
||
<h1>係数一覧</h1>
|
||
<button class="hdr-btn" onclick="nav('s-calib')">+</button>
|
||
</div>
|
||
|
||
<div style="padding:10px 16px 6px;background:var(--surface);
|
||
border-bottom:1px solid var(--border);flex-shrink:0">
|
||
<div style="font-size:12px;color:var(--muted);line-height:1.6">
|
||
現在のGPS速度と上下動に最も近い速度帯の係数が自動選択されます。
|
||
<span style="color:var(--accent)">青枠</span>が現在の選択係数です。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="coeff-wrap" id="coeff-list"></div>
|
||
|
||
<div class="bottom-action">
|
||
<button class="btn-full" onclick="nav('s-calib')">
|
||
+ 新しいキャリブレーションを実行
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════ SCREEN: SETTINGS ═══════════════ -->
|
||
<div id="s-settings" class="screen">
|
||
<div class="hdr">
|
||
<button class="hdr-btn" onclick="nav('s-main')">‹</button>
|
||
<h1>設定</h1>
|
||
<div></div>
|
||
</div>
|
||
|
||
<div class="set-wrap">
|
||
<div class="set-card">
|
||
<div>
|
||
<div class="set-t">心拍計 (Bluetooth LE)</div>
|
||
<div class="set-d" id="hr-device-name">未接続</div>
|
||
</div>
|
||
<button class="btn-link" onclick="connectHR()">接続</button>
|
||
</div>
|
||
<div class="set-card">
|
||
<div>
|
||
<div class="set-t">キャリブレーション係数</div>
|
||
<div class="set-d" id="coeff-count-label">0件 保存済み</div>
|
||
</div>
|
||
<button class="btn-link" onclick="nav('s-coeffs')">表示</button>
|
||
</div>
|
||
<div class="set-card">
|
||
<div>
|
||
<div class="set-t">係数・データをリセット</div>
|
||
<div class="set-d">すべての保存済み係数を削除</div>
|
||
</div>
|
||
<button class="btn-link danger" onclick="resetData()">削除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast" class="toast"></div>
|
||
|
||
<script>
|
||
// ════════════════════════════════════════════════════════
|
||
// CONSTANTS
|
||
// ════════════════════════════════════════════════════════
|
||
const STORAGE_KEY = 'rtracker_v1';
|
||
const CALIB_TARGET = 2000; // meters
|
||
const MAX_COEFFS = 10;
|
||
const DEFAULT_M_STEP = 0.75; // fallback coefficient
|
||
const STEP_THRESH = 1.0; // g-force delta to detect step peak
|
||
const STEP_MIN_MS = 220; // minimum ms between steps
|
||
const RING_CIRCUM = 2 * Math.PI * 86; // SVG circle r=86
|
||
const SPEED_WEIGHT = 0.65; // weight for speed in zone matching
|
||
const VAR_WEIGHT = 0.35; // weight for vertical variance
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// RUN STATE
|
||
// ════════════════════════════════════════════════════════
|
||
const S = {
|
||
running: false,
|
||
paused: false,
|
||
startMs: 0,
|
||
pauseOffset: 0, // accumulated pause time in ms
|
||
pauseStart: 0,
|
||
gpsDistM: 0,
|
||
stepDistM: 0,
|
||
stepCount: 0,
|
||
lapStartDistM:0,
|
||
lapStartSec: 0,
|
||
laps: [],
|
||
heartRate: null,
|
||
gpsOk: false,
|
||
accOk: false,
|
||
gpsSpeedMS: 0, // current GPS speed m/s
|
||
lastPos: null,// {lat,lon}
|
||
dispTimer: null,
|
||
gpsWatchId: null,
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// CALIBRATION STATE
|
||
// ════════════════════════════════════════════════════════
|
||
const C = {
|
||
running: false,
|
||
startMs: 0,
|
||
gpsDistM: 0,
|
||
stepCount: 0,
|
||
speeds: [],
|
||
lastPos: null,
|
||
dispTimer: null,
|
||
gpsWatchId: null,
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// ACCELEROMETER STATE
|
||
// ════════════════════════════════════════════════════════
|
||
const ACC = {
|
||
lastZ: 0,
|
||
lastStepMs: 0,
|
||
rising: false,
|
||
buf: [], // recent z-values for variance
|
||
BUF_SIZE: 60,
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// STORAGE
|
||
// ════════════════════════════════════════════════════════
|
||
function loadStore() {
|
||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{"coeffs":[]}'); }
|
||
catch { return { coeffs: [] }; }
|
||
}
|
||
function saveStore(d) { localStorage.setItem(STORAGE_KEY, JSON.stringify(d)); }
|
||
function loadCoeffs() { return loadStore().coeffs || []; }
|
||
|
||
/**
|
||
* @param {{metersPerStep:number, speedZone:number, vertVariance:number,
|
||
* steps:number, distM:number, timestamp:number}} entry
|
||
*/
|
||
function saveCoeff(entry) {
|
||
const d = loadStore();
|
||
d.coeffs = d.coeffs || [];
|
||
d.coeffs.unshift(entry);
|
||
if (d.coeffs.length > MAX_COEFFS) d.coeffs = d.coeffs.slice(0, MAX_COEFFS);
|
||
saveStore(d);
|
||
}
|
||
function deleteCoeff(i) {
|
||
const d = loadStore();
|
||
d.coeffs.splice(i, 1);
|
||
saveStore(d);
|
||
renderCoeffs();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// COEFFICIENT SELECTION
|
||
// Matches by GPS speed (65%) + vertical variance (35%)
|
||
// ════════════════════════════════════════════════════════
|
||
function bestCoeff(curSpeedMS, curVar) {
|
||
const coeffs = loadCoeffs();
|
||
if (!coeffs.length) return DEFAULT_M_STEP;
|
||
|
||
let best = coeffs[0], bestScore = Infinity;
|
||
for (const c of coeffs) {
|
||
const denomSpeed = Math.max(curSpeedMS, 0.3);
|
||
const speedScore = Math.abs(curSpeedMS - c.speedZone) / denomSpeed;
|
||
const denomVar = Math.max(curVar, c.vertVariance, 1e-4);
|
||
const varScore = Math.abs(curVar - c.vertVariance) / denomVar;
|
||
const score = SPEED_WEIGHT * speedScore + VAR_WEIGHT * varScore;
|
||
if (score < bestScore) { bestScore = score; best = c; }
|
||
}
|
||
return best.metersPerStep;
|
||
}
|
||
|
||
function bestCoeffIdx(curSpeedMS, curVar) {
|
||
const coeffs = loadCoeffs();
|
||
if (!coeffs.length) return -1;
|
||
let bestIdx = 0, bestScore = Infinity;
|
||
coeffs.forEach((c, i) => {
|
||
const denomSpeed = Math.max(curSpeedMS, 0.3);
|
||
const speedScore = Math.abs(curSpeedMS - c.speedZone) / denomSpeed;
|
||
const denomVar = Math.max(curVar, c.vertVariance, 1e-4);
|
||
const varScore = Math.abs(curVar - c.vertVariance) / denomVar;
|
||
const score = SPEED_WEIGHT * speedScore + VAR_WEIGHT * varScore;
|
||
if (score < bestScore) { bestScore = score; bestIdx = i; }
|
||
});
|
||
return bestIdx;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// MATH HELPERS
|
||
// ════════════════════════════════════════════════════════
|
||
function haversine(lat1, lon1, lat2, lon2) {
|
||
const R = 6371000, D = Math.PI / 180;
|
||
const dLat = (lat2 - lat1) * D, dLon = (lon2 - lon1) * D;
|
||
const a = Math.sin(dLat/2)**2
|
||
+ Math.cos(lat1*D) * Math.cos(lat2*D) * Math.sin(dLon/2)**2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||
}
|
||
|
||
function variance(arr) {
|
||
if (!arr.length) return 0;
|
||
const mean = arr.reduce((a,b) => a+b, 0) / arr.length;
|
||
return arr.reduce((s,x) => s + (x-mean)**2, 0) / arr.length;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// FORMAT HELPERS
|
||
// ════════════════════════════════════════════════════════
|
||
function fmtTime(sec) {
|
||
sec = Math.max(0, Math.floor(sec));
|
||
const h = Math.floor(sec / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
const s = sec % 60;
|
||
return h > 0
|
||
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||
: `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||
}
|
||
|
||
/** pace in min/km → "M:SS" string, or "--:--" if invalid */
|
||
function fmtPace(minPerKm) {
|
||
if (!minPerKm || !isFinite(minPerKm) || minPerKm <= 0 || minPerKm > 30) return '--:--';
|
||
const m = Math.floor(minPerKm);
|
||
const s = Math.round((minPerKm - m) * 60);
|
||
return `${m}:${String(s).padStart(2,'0')}`;
|
||
}
|
||
|
||
function speedToMinKm(ms) { return ms > 0.1 ? (1000/ms)/60 : null; }
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// CURRENT DISTANCE (GPS preferred, step fallback)
|
||
// ════════════════════════════════════════════════════════
|
||
function curDistM() {
|
||
// If GPS has tracked data, use it; otherwise use step-based estimate
|
||
return S.gpsDistM > 0 ? S.gpsDistM : S.stepDistM;
|
||
}
|
||
|
||
function elapsedSec() {
|
||
if (!S.running) return 0;
|
||
const now = S.paused ? S.pauseStart : Date.now();
|
||
return (now - S.startMs - S.pauseOffset) / 1000;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// GPS
|
||
// ════════════════════════════════════════════════════════
|
||
function startGPS(onPos, onErr) {
|
||
if (!navigator.geolocation) { onErr && onErr('GPS非対応'); return null; }
|
||
return navigator.geolocation.watchPosition(onPos, onErr,
|
||
{ enableHighAccuracy: true, maximumAge: 1000, timeout: 15000 });
|
||
}
|
||
|
||
function handleMainGPS(pos) {
|
||
const { latitude:lat, longitude:lon, accuracy, speed } = pos.coords;
|
||
S.gpsOk = true;
|
||
|
||
const qual = accuracy < 8 ? `GPS ±${Math.round(accuracy)}m` :
|
||
accuracy < 20 ? `GPS ±${Math.round(accuracy)}m` : 'GPS 精度低';
|
||
setStatus('gps', accuracy < 25 ? 'on' : 'warn', qual);
|
||
|
||
if (speed !== null) S.gpsSpeedMS = speed;
|
||
|
||
if (S.running && !S.paused && S.lastPos && accuracy < 30) {
|
||
const d = haversine(S.lastPos.lat, S.lastPos.lon, lat, lon);
|
||
if (d > 0.5 && d < 150) S.gpsDistM += d; // sanity: 0.5m–150m per fix
|
||
}
|
||
S.lastPos = { lat, lon };
|
||
}
|
||
|
||
function handleCalibGPS(pos) {
|
||
const { latitude:lat, longitude:lon, accuracy, speed } = pos.coords;
|
||
if (speed !== null) {
|
||
C.speeds.push(speed);
|
||
el('ci-speed').textContent = speed.toFixed(2) + ' m/s';
|
||
}
|
||
if (C.lastPos && accuracy < 30) {
|
||
const d = haversine(C.lastPos.lat, C.lastPos.lon, lat, lon);
|
||
if (d > 0.5 && d < 150) {
|
||
C.gpsDistM += d;
|
||
updateCalibProgress();
|
||
}
|
||
}
|
||
C.lastPos = { lat, lon };
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// ACCELEROMETER
|
||
// ════════════════════════════════════════════════════════
|
||
function initAcc() {
|
||
if (!window.DeviceMotionEvent) {
|
||
setStatus('acc', '', '非対応');
|
||
return;
|
||
}
|
||
const start = () => {
|
||
window.addEventListener('devicemotion', onMotion, { passive: true });
|
||
setStatus('acc', 'on', '加速度計ON');
|
||
S.accOk = true;
|
||
};
|
||
if (typeof DeviceMotionEvent.requestPermission === 'function') {
|
||
DeviceMotionEvent.requestPermission()
|
||
.then(p => p === 'granted' ? start() : setStatus('acc','','権限なし'))
|
||
.catch(() => setStatus('acc','','権限エラー'));
|
||
} else {
|
||
start();
|
||
}
|
||
}
|
||
|
||
function onMotion(e) {
|
||
const a = e.accelerationIncludingGravity;
|
||
if (!a) return;
|
||
const z = a.z ?? 0;
|
||
const now = Date.now();
|
||
|
||
// Rolling buffer for variance
|
||
ACC.buf.push(z);
|
||
if (ACC.buf.length > ACC.BUF_SIZE) ACC.buf.shift();
|
||
|
||
// Peak detection: detect rising → falling edge (one step per cycle)
|
||
const delta = z - ACC.lastZ;
|
||
if (delta > STEP_THRESH && !ACC.rising) {
|
||
ACC.rising = true;
|
||
} else if (delta < -STEP_THRESH && ACC.rising) {
|
||
ACC.rising = false;
|
||
if (now - ACC.lastStepMs > STEP_MIN_MS) {
|
||
ACC.lastStepMs = now;
|
||
|
||
// Main run step counting
|
||
if (S.running && !S.paused) {
|
||
S.stepCount++;
|
||
const var_ = variance(ACC.buf);
|
||
const coeff = bestCoeff(S.gpsSpeedMS, var_);
|
||
S.stepDistM += coeff;
|
||
// Update coeff chip
|
||
el('lb-coeff').textContent = `係数 ${coeff.toFixed(3)}m/歩`;
|
||
el('chip-coeff').style.display = 'flex';
|
||
}
|
||
|
||
// Calibration step counting
|
||
if (C.running) {
|
||
C.stepCount++;
|
||
const var_ = variance(ACC.buf);
|
||
el('ci-steps').textContent = C.stepCount + ' 歩';
|
||
el('ci-var').textContent = var_.toFixed(4);
|
||
if (C.stepCount > 0) {
|
||
const tempCoeff = (C.gpsDistM > 0 ? C.gpsDistM : C.stepCount * DEFAULT_M_STEP) / C.stepCount;
|
||
el('ci-coeff').textContent = tempCoeff.toFixed(3) + ' m/歩';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ACC.lastZ = z;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// HEART RATE (Web Bluetooth)
|
||
// ════════════════════════════════════════════════════════
|
||
async function connectHR() {
|
||
if (!navigator.bluetooth) { toast('このブラウザはBluetooth非対応です'); return; }
|
||
try {
|
||
toast('Bluetoothデバイスを検索中...');
|
||
const dev = await navigator.bluetooth.requestDevice({
|
||
filters: [{ services: ['heart_rate'] }]
|
||
});
|
||
const srv = await dev.gatt.connect();
|
||
const svc = await srv.getPrimaryService('heart_rate');
|
||
const char = await svc.getCharacteristic('heart_rate_measurement');
|
||
await char.startNotifications();
|
||
char.addEventListener('characteristicvaluechanged', e => {
|
||
const v = e.target.value;
|
||
const flags = v.getUint8(0);
|
||
S.heartRate = (flags & 1) ? v.getUint16(1, true) : v.getUint8(1);
|
||
});
|
||
el('hr-device-name').textContent = dev.name || '接続済み';
|
||
el('chip-hr').style.display = 'flex';
|
||
setStatus('hr','on', dev.name || '心拍計');
|
||
toast(`心拍計「${dev.name || 'HR'}」接続完了`);
|
||
} catch(e) {
|
||
toast('接続失敗: ' + (e.message || String(e)));
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// RUN CONTROLS
|
||
// ════════════════════════════════════════════════════════
|
||
function toggleRun() {
|
||
if (!S.running) startRun();
|
||
else if (!S.paused) pauseRun();
|
||
else resumeRun();
|
||
}
|
||
|
||
function startRun() {
|
||
// Request accelerometer permission on first start
|
||
if (!S.accOk) initAcc();
|
||
|
||
// Start GPS
|
||
S.gpsWatchId = startGPS(handleMainGPS, err => {
|
||
setStatus('gps','','GPS: ' + (err.message || err));
|
||
});
|
||
|
||
Object.assign(S, {
|
||
running:true, paused:false,
|
||
startMs:Date.now(), pauseOffset:0, pauseStart:0,
|
||
gpsDistM:0, stepDistM:0, stepCount:0,
|
||
lapStartDistM:0, lapStartSec:0, laps:[],
|
||
lastPos:null, gpsOk:false,
|
||
});
|
||
|
||
el('btn-go').classList.add('pause');
|
||
el('go-lbl').textContent = '一時停止';
|
||
el('btn-stop').style.display = 'flex';
|
||
el('btn-lap').style.display = 'flex';
|
||
el('laps-wrap').style.display = 'block';
|
||
el('laps-list').innerHTML = '';
|
||
|
||
S.dispTimer = setInterval(refreshDisplay, 500);
|
||
requestWakeLock();
|
||
toast('ランニング開始!');
|
||
}
|
||
|
||
function pauseRun() {
|
||
S.paused = true;
|
||
S.pauseStart = Date.now();
|
||
el('btn-go').classList.remove('pause');
|
||
el('go-lbl').textContent = '再開';
|
||
toast('一時停止');
|
||
}
|
||
|
||
function resumeRun() {
|
||
S.pauseOffset += Date.now() - S.pauseStart;
|
||
S.paused = false;
|
||
el('btn-go').classList.add('pause');
|
||
el('go-lbl').textContent = '一時停止';
|
||
toast('再開');
|
||
}
|
||
|
||
function stopRun() {
|
||
if (!S.running) return;
|
||
if (!confirm('ランを終了しますか?')) return;
|
||
|
||
clearInterval(S.dispTimer);
|
||
if (S.gpsWatchId !== null) {
|
||
navigator.geolocation.clearWatch(S.gpsWatchId);
|
||
S.gpsWatchId = null;
|
||
}
|
||
S.running = S.paused = false;
|
||
|
||
el('btn-go').classList.remove('pause');
|
||
el('go-lbl').textContent = '開始';
|
||
el('btn-stop').style.display = 'none';
|
||
el('btn-lap').style.display = 'none';
|
||
setStatus('gps','','GPS停止');
|
||
|
||
const distKm = (curDistM()/1000).toFixed(2);
|
||
toast(`完了! ${distKm}km`);
|
||
}
|
||
|
||
function recordLap() {
|
||
const sec = elapsedSec();
|
||
const dist = curDistM();
|
||
|
||
const lapDist = dist - S.lapStartDistM;
|
||
const lapSec = sec - S.lapStartSec;
|
||
const pace = lapDist > 20 ? speedToMinKm(lapDist / lapSec) : null;
|
||
|
||
S.laps.unshift({
|
||
n: S.laps.length + 1,
|
||
dist: lapDist,
|
||
sec: lapSec,
|
||
pace,
|
||
hr: S.heartRate,
|
||
});
|
||
|
||
S.lapStartDistM = dist;
|
||
S.lapStartSec = sec;
|
||
|
||
renderLaps();
|
||
toast(`ラップ ${S.laps.length} 記録`);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// DISPLAY REFRESH
|
||
// ════════════════════════════════════════════════════════
|
||
function refreshDisplay() {
|
||
if (!S.running || S.paused) return;
|
||
|
||
const sec = elapsedSec();
|
||
const distM = curDistM();
|
||
const distKm = distM / 1000;
|
||
const pace = distKm > 0.01 ? (sec/60) / distKm : null;
|
||
|
||
const lapDistM = distM - S.lapStartDistM;
|
||
const lapSec = sec - S.lapStartSec;
|
||
const lapPace = lapDistM > 20 ? speedToMinKm(lapDistM / lapSec) : null;
|
||
|
||
// Time — large display with colored seconds
|
||
const h = Math.floor(sec / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
const s = Math.floor(sec % 60);
|
||
const timeHtml = h > 0
|
||
? `${h}:${pad(m)}:<span class="sec">${pad(s)}</span>`
|
||
: `${pad(m)}:<span class="sec">${pad(s)}</span>`;
|
||
el('d-time').innerHTML = timeHtml;
|
||
|
||
el('d-dist').innerHTML = `${distKm.toFixed(2)}<span class="unit">km</span>`;
|
||
el('d-pace').innerHTML = `${fmtPace(pace)}<span class="unit">/km</span>`;
|
||
el('d-lp').innerHTML = `${fmtPace(lapPace)}<span class="unit">/km</span>`;
|
||
|
||
if (S.heartRate !== null)
|
||
el('d-hr').innerHTML = `${S.heartRate}<span class="unit">bpm</span>`;
|
||
}
|
||
|
||
function renderLaps() {
|
||
const ul = el('laps-list');
|
||
ul.innerHTML = '';
|
||
S.laps.forEach((lap, i) => {
|
||
const div = document.createElement('div');
|
||
div.className = 'lap-row' + (i === 0 ? ' cur' : '');
|
||
div.innerHTML = `<span class="lap-n">${lap.n}</span>
|
||
<span>${(lap.dist/1000).toFixed(2)}km</span>
|
||
<span>${fmtTime(lap.sec)}</span>
|
||
<span>${fmtPace(lap.pace)}/km</span>`;
|
||
ul.appendChild(div);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// CALIBRATION
|
||
// ════════════════════════════════════════════════════════
|
||
function startCalib() {
|
||
Object.assign(C, {
|
||
running:true, startMs:Date.now(),
|
||
gpsDistM:0, stepCount:0, speeds:[], lastPos:null,
|
||
});
|
||
|
||
el('ci-idle').style.display = 'none';
|
||
el('ci-run').style.display = 'flex';
|
||
|
||
C.gpsWatchId = startGPS(handleCalibGPS, e => console.warn('CalibGPS:', e));
|
||
|
||
if (!S.accOk) initAcc();
|
||
|
||
C.dispTimer = setInterval(() => {
|
||
el('ci-time').textContent = fmtTime((Date.now()-C.startMs)/1000);
|
||
}, 500);
|
||
}
|
||
|
||
function updateCalibProgress() {
|
||
const pct = Math.min(C.gpsDistM / CALIB_TARGET, 1);
|
||
const offset = RING_CIRCUM * (1 - pct);
|
||
el('ring-arc').style.strokeDashoffset = offset;
|
||
el('ci-dist').textContent = Math.round(C.gpsDistM);
|
||
if (C.gpsDistM >= CALIB_TARGET) finishCalib();
|
||
}
|
||
|
||
function finishCalib() {
|
||
if (!C.running) return;
|
||
C.running = false;
|
||
clearInterval(C.dispTimer);
|
||
if (C.gpsWatchId !== null) {
|
||
navigator.geolocation.clearWatch(C.gpsWatchId);
|
||
C.gpsWatchId = null;
|
||
}
|
||
|
||
const distM = C.gpsDistM > 100 ? C.gpsDistM : CALIB_TARGET;
|
||
const steps = C.stepCount;
|
||
|
||
if (steps < 20) {
|
||
toast('歩数が少なすぎます(センサーを確認してください)');
|
||
resetCalibUI();
|
||
return;
|
||
}
|
||
|
||
const avgSpeed = C.speeds.length
|
||
? C.speeds.reduce((a,b)=>a+b,0)/C.speeds.length : 0;
|
||
const vertVar = variance(ACC.buf);
|
||
const mPerStep = distM / steps;
|
||
const paceLabel = avgSpeed > 0.1 ? fmtPace(speedToMinKm(avgSpeed)) + '/km' : '未計測';
|
||
|
||
saveCoeff({
|
||
metersPerStep: mPerStep,
|
||
speedZone: avgSpeed,
|
||
vertVariance: vertVar,
|
||
steps,
|
||
distM,
|
||
timestamp: Date.now(),
|
||
label: paceLabel,
|
||
});
|
||
|
||
toast(`保存完了: ${mPerStep.toFixed(3)} m/歩 [${paceLabel}]`);
|
||
resetCalibUI();
|
||
setTimeout(() => nav('s-coeffs'), 800);
|
||
}
|
||
|
||
function cancelCalib() {
|
||
C.running = false;
|
||
clearInterval(C.dispTimer);
|
||
if (C.gpsWatchId !== null) {
|
||
navigator.geolocation.clearWatch(C.gpsWatchId);
|
||
C.gpsWatchId = null;
|
||
}
|
||
resetCalibUI();
|
||
nav('s-main');
|
||
}
|
||
|
||
function resetCalibUI() {
|
||
el('ci-idle').style.display = 'flex';
|
||
el('ci-run').style.display = 'none';
|
||
el('ring-arc').style.strokeDashoffset = RING_CIRCUM;
|
||
el('ci-dist').textContent = '0';
|
||
el('ci-steps').textContent = '0 歩';
|
||
el('ci-speed').textContent = '-- m/s';
|
||
el('ci-var').textContent = '--';
|
||
el('ci-time').textContent = '00:00';
|
||
el('ci-coeff').textContent = '-- m/歩';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// COEFFICIENTS SCREEN
|
||
// ════════════════════════════════════════════════════════
|
||
function renderCoeffs() {
|
||
const list = el('coeff-list');
|
||
const coeffs = loadCoeffs();
|
||
list.innerHTML = '';
|
||
|
||
// Update settings count label
|
||
el('coeff-count-label').textContent = `${coeffs.length}件 保存済み (最大${MAX_COEFFS}件)`;
|
||
|
||
if (!coeffs.length) {
|
||
list.innerHTML = `<div style="text-align:center;padding:48px 16px;color:var(--muted)">
|
||
係数がありません<br>キャリブレーションを実行してください</div>`;
|
||
return;
|
||
}
|
||
|
||
const curVar = variance(ACC.buf);
|
||
const bestIdx = bestCoeffIdx(S.gpsSpeedMS, curVar);
|
||
|
||
coeffs.forEach((c, i) => {
|
||
const div = document.createElement('div');
|
||
div.className = 'coeff-card' + (i === bestIdx ? ' best' : '');
|
||
const date = new Date(c.timestamp).toLocaleDateString('ja-JP');
|
||
const paceStr = c.speedZone > 0.1
|
||
? fmtPace(speedToMinKm(c.speedZone)) + '/km'
|
||
: '--:--';
|
||
|
||
div.innerHTML = `
|
||
<div class="coeff-main">${c.metersPerStep.toFixed(4)} m/歩</div>
|
||
<div class="coeff-row">
|
||
<div class="coeff-d">📅 ${date}</div>
|
||
<div class="coeff-d">🏃 ${paceStr}</div>
|
||
<div class="coeff-d">👣 ${c.steps}歩</div>
|
||
<div class="coeff-d">📏 ${(c.distM/1000).toFixed(2)}km</div>
|
||
<div class="coeff-d">↕ 分散 ${c.vertVariance.toFixed(4)}</div>
|
||
<div class="coeff-d ${i===bestIdx?'':''}">
|
||
${i === bestIdx ? '✓ <span style="color:var(--accent)">現在の速度帯に最適</span>' : ''}
|
||
</div>
|
||
</div>
|
||
<button class="btn-del" onclick="deleteCoeff(${i})">削除</button>`;
|
||
list.appendChild(div);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// MISC
|
||
// ════════════════════════════════════════════════════════
|
||
function nav(id) {
|
||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||
el(id).classList.add('active');
|
||
if (id === 's-coeffs') renderCoeffs();
|
||
if (id === 's-settings') el('coeff-count-label').textContent =
|
||
`${loadCoeffs().length}件 保存済み (最大${MAX_COEFFS}件)`;
|
||
}
|
||
|
||
function setStatus(type, state, text) {
|
||
const dot = el('dot-'+type), lb = el('lb-'+type);
|
||
if (dot) dot.className = 'dot' + (state ? ' '+state : '');
|
||
if (lb) lb.textContent = text;
|
||
}
|
||
|
||
let _toastTimer = null;
|
||
function toast(msg) {
|
||
const t = el('toast');
|
||
t.textContent = msg;
|
||
t.classList.add('show');
|
||
clearTimeout(_toastTimer);
|
||
_toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
|
||
}
|
||
|
||
function el(id) { return document.getElementById(id); }
|
||
function pad(n) { return String(n).padStart(2,'0'); }
|
||
|
||
function resetData() {
|
||
if (confirm('すべての係数を削除しますか?')) {
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
toast('データを削除しました');
|
||
renderCoeffs();
|
||
}
|
||
}
|
||
|
||
// Screen Wake Lock to prevent sleep during run
|
||
let _wakeLock = null;
|
||
async function requestWakeLock() {
|
||
if (!('wakeLock' in navigator)) return;
|
||
try {
|
||
_wakeLock = await navigator.wakeLock.request('screen');
|
||
_wakeLock.addEventListener('release', () => { _wakeLock = null; });
|
||
document.addEventListener('visibilitychange', async () => {
|
||
if (_wakeLock === null && document.visibilityState === 'visible' && S.running)
|
||
_wakeLock = await navigator.wakeLock.request('screen').catch(()=>{});
|
||
});
|
||
} catch {}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|