kissyjpf_rtm-sync/running-tracker.html
Claude ffd13ea767
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
2026-06-21 23:23:56 +00:00

976 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>