Add running tracker web app with GPS, accelerometer, heart rate, and speed-zone calibration

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
This commit is contained in:
Claude 2026-06-21 23:23:56 +00:00
parent 244aa09725
commit ffd13ea767
No known key found for this signature in database

976
running-tracker.html Normal file
View file

@ -0,0 +1,976 @@
<!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.5m150m 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>