fitbit varsa2を2022年から使っている。
健康への意識が高まってきて買った初めてのスマートウォッチ。Apple Watchは高いし毎日充電しなきゃいけないが、こいつは15000円ほどでバッテリーも数日は余裕で持つという優れものだった。支払いはできない機種だが問題ない。主な目的は睡眠トラッカー機能。pipboyみがあって最初は実際にfallout4のあいつを待ち受けにしていたこともあったけど、すぐに飽きたので飼っていたペットの写真を待ち受けに変えた。今はそのペットもいなくなってしまったけど時計を見ればいつまでもかわいい我が子が画面にいる。
睡眠トラッカー機能としては優秀だと言って差し支えないだろう。もっとも正しく記録されているかどうかを知る由はないが。基本的にはfitbitのアプリを通して記録を確認するのだが、データはPCでも確認できるという話だった。しかしfitbitがGoogleに買収されてからというもののアプリのデザインが大きく変わったりPC上で見れたダッシュボードも気付けばサービス終了してしていた。
4年分のデータを今更使いづらくて反映も遅いアプリでちまちま見るなんて耐えられたものではない。そんな苦行はこの令和に許されない。調べてみるとどうやら
https://www.fitbit.com/settings/profile からログインすればデータのエクスポートができるということでデータを回収しに行った。左に表示されてるエクスポートに飛ぶとgoogleの画面に飛ばされたが、無事データがダウンロードできる。このあとGeminiにどうしたらエクセルで睡眠データをもとにグラフを作れるか聞いたらjsonファイルをCSVにするといいと言われたので言われるがままにスプレッドシートにjsonファイルをCSVに変換できるアドオンを入れ、CSVに変換した。セルの順番がばらばらで焦ったが、行の並び替えで事なきを得た。なおこの時フィルターで並び替えると列がばらばらになってしまうので注意してほしい。
該当のCSVを入手したがここで困ったことが起こった。fitbitは睡眠中に少しでも動きがあると体が起きてた的な記録をとったり1時間以上の昼寝を記録したりする。つまり処理しなければいけないデータが膨大なのだ。プログラマーでも何でもない素人には手に余るデータを慣れないエクセルでグラフ作りなんて割に合わないと気付いた。
ふとGeminiに頼んだらどうなるのか気になった。もしかして出来のいいビューワーを作れるのでは?うまくできなかったとしてもこちらにはなんの労力もいらない。聞くだけタダってやつだ。
fitbit ダッシュボードと同じように表示させたいので自分のpc上で見れるようなビューワーアプリケーションを作成してください。
はじめこそ作成したCSVをうまく読み込んでくれなかったが何回かのやりとりのあとちゃんと動作するビューワーのコードを書いてくれた。コードといってもHTMLでメモ帳にコピペし、拡張子をhtmlに変えるだけで使えるお手軽オフラインアプリだ。それがこちら(コードは最後に記載)

CSVを読み込むと平均睡眠時間を表示してくれるだけのものだが、この画面を初めて見たとき感動してしまった。1時間くらい唸りながらなにもできなかった自分とちがいGeminiはいとも簡単にこちらの望む品を提供してくれたのだ。(AIイラスト関連は断固反対派だが)
年毎の月平均が見れるようになったのならもっと作れるはず。そうしてできたのがこちら。

月の昼寝時間、日々の就寝時間起床時間のグラフ、表、月の平均就寝時間と起床時間などとりあえず必要最低限なものを追加した。私自身が完全夜型人間のためグラフの目盛りが0時スタートなので人によっては使いづらい恐れがあるのは申し訳ない。文字も小さいかもしれないけどそれはまた今度。
fitbitダッシュボード難民の人々がもしいたらぜひ使ってほしい。感想は別にいらないので不満があったら勝手に改良して頑張ってくれ。今回は睡眠に特化したビューワーになっているが、きっとGeminiに聞けば運動だのなんだののビューワーも作ってくれることだろう。
fitbit viewer 使い方
・https://www.fitbit.com/settings/profile にアクセスして、エクスポートデータを入手。
・ダウンロードしたzipを展開し、「Global Export Data」内にあるsleepデータを全部コピー。
・google スプレッドシートを開き、jsonのインポートができるアドオンを追加。アドオンからsleepのjsonファイルをすべてインポートし、csvで出力、保存。
・メモ帳に以下のコードをコピペして保存。拡張子を .txt から .html に変える。
・ブラウザで開き、出力したcsvを選択。
・グラフが表示されれば成功
ということでした。最初のコードはいろいろなグラフが見れるスタンダード版。その次に月平均しか見れない簡易版。
fitbit viewer May
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="UTF-8">
- <title>Fitbit 睡眠アナリティクスダッシュボード</title>
- <style>
- body { font-family: 'Segoe UI', Meiryo, sans-serif; background-color: #f4f7f9; padding: 20px; color: #333; margin: 0; }
- .container { max-width: 1500px; margin: 0 auto; padding: 10px; }
- .header { text-align: center; margin-bottom: 20px; }
- .header h1 { color: #114b5f; margin-bottom: 5px; }
- /* ファイル選択エリア */
- .upload-box {
- background: #e8f5e9; border: 2px dashed #2e7d32; padding: 25px; border-radius: 12px; text-align: center; margin-bottom: 25px;
- }
- .file-input-wrapper { margin-top: 12px; }
- input[type=\"file\"] { padding: 10px; background: white; border: 1px solid #a5d6a7; border-radius: 6px; cursor: pointer; }
- .card { background: white; border-radius: 16px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
- .controls { display: flex; flex-wrap: wrap; gap: 15px; align-items: center; background: #eef1f6; padding: 15px 20px; border-radius: 12px; }
- .controls h2 { margin: 0; font-size: 18px; color: #114b5f; }
- .control-group { display: flex; align-items: center; gap: 8px; }
- select { padding: 8px 14px; font-size: 14px; border-radius: 6px; border: 1px solid #ccc; background: white; font-weight: bold; color: #333; }
- /* 左右レイアウト構成 */
- .grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
- @media(min-width: 1100px) { .grid { grid-template-columns: 50% 50%; } }
- /* グラフエリアの縦並び配置 */
- .charts-stack { display: flex; flex-direction: column; gap: 20px; }
- /* グラフ用共通スタイル(垂直バー) */
- .bar-chart { display: flex; align-items: flex-end; border-bottom: 2px solid #ddd; padding: 10px 0; gap: 6px; }
- .bar-wrapper { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; cursor: pointer; }
- .bar { width: 80%; background: #a8dadc; border-radius: 4px 4px 0 0; min-height: 1px; transition: background 0.2s; }
- .bar-wrapper:hover .bar { background: #457b9d; }
- .bar-wrapper.active .bar { background: #1d3557; }
- .bar-nap { background: #e9c46a; }
- .bar-wrapper:hover .bar-nap { background: #e6b12a; }
- .bar-value { font-size: 10px; color: #457b9d; margin-bottom: 4px; font-weight: bold; text-align: center; }
- .bar-value-nap { color: #b7840a; }
- .bar-label { font-size: 12px; color: #666; margin-top: 6px; }
- /* 時刻チャート用スタイル(水平タイムライン) */
- .time-chart-container { position: relative; border-left: 2px solid #ddd; border-bottom: 2px solid #ddd; padding: 10px 0; margin-top: 10px; background: #fafafa; }
- .time-chart-row { display: flex; height: 45px; align-items: center; position: relative; border-bottom: 1px dashed #eee; }
- .time-chart-label { width: 65px; font-size: 12px; text-align: center; color: #333; font-weight: bold; z-index: 6; }
- .time-chart-line-area { flex: 1; position: relative; height: 100%; }
- /* 就寝・起床の結合線 */
- .time-duration-line { position: absolute; height: 8px; top: 18px; background: rgba(69, 123, 157, 0.25); border-radius: 4px; z-index: 2; pointer-events: none; }
- /* 平均睡眠時間の文字バッジ */
- .time-duration-text { position: absolute; top: -2px; font-size: 9px; font-weight: bold; color: #1d3557; background: #e8f0fe; padding: 1px 5px; border-radius: 4px; transform: translateX(-50%); z-index: 4; white-space: nowrap; border: 1px solid #b3d7ff; }
- .time-dot { position: absolute; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; transform: translateX(-50%); z-index: 5; }
- .dot-symbol { font-size: 14px; line-height: 1; cursor: pointer; }
- .dot-bed { color: #1d3557; }
- .dot-wake { color: #e9c46a; }
- .time-text-val { font-size: 9px; font-weight: bold; margin-top: 2px; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
- .time-chart-axis-header { display: flex; position: relative; height: 20px; font-size: 10px; color: #888; border-bottom: 1px solid #ccc; margin-left: 65px; }
- /* 右カラム・タイムライン */
- .timeline-container { border: 1px solid #ddd; border-radius: 8px; padding: 10px; background: #fff; max-height: 480px; overflow-y: auto; margin-bottom: 20px; }
- .axis-label-row { display: flex; position: relative; height: 25px; margin-left: 50px; margin-right: 15px; border-bottom: 1px solid #ccc; font-size: 11px; color: #777; }
- .timeline-row { display: flex; align-items: center; border-bottom: 1px solid #f0f0f0; padding: 6px 0; position: relative; height: 35px; }
- .day-label { width: 50px; font-size: 12px; font-weight: bold; color: #555; }
- .time-axis-bg { position: absolute; left: 50px; right: 15px; top: 0; bottom: 0; pointer-events: none; }
- .axis-line { position: absolute; top: 0; bottom: 0; border-left: 1px dashed #e5e5e5; }
- .sleep-bar-canvas { position: relative; flex: 1; height: 100%; margin-right: 15px; margin-left: 50px; }
- .sleep-segment { position: absolute; height: 24px; top: 5px; border-radius: 4px; font-size: 10px; color: white; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: nowrap; min-width: 4px; cursor: pointer; }
- .sleep-segment .tooltip, .time-dot .tooltip { display: none; position: absolute; background: rgba(17, 75, 95, 0.95); color: #fff; padding: 8px 12px; border-radius: 6px; bottom: 35px; left: 50%; transform: translateX(-50%); z-index: 999; font-size: 11px; white-space: nowrap; line-height: 1.4; box-shadow: 0 4px 8px rgba(0,0,0,0.2); }
- .sleep-segment:hover .tooltip, .time-dot:hover .tooltip { display: block; }
- .segment-main { background: #457b9d; }
- .segment-nap { background: #e9c46a; color: #333; font-weight: bold; }
- .legend { display: flex; gap: 15px; font-size: 12px; justify-content: flex-end; margin-bottom: 10px; }
- .legend-item { display: flex; align-items: center; gap: 5px; }
- .box-main { width: 12px; height: 12px; background: #457b9d; border-radius: 2px; }
- .box-nap { width: 12px; height: 12px; background: #e9c46a; border-radius: 2px; }
- /* テーブル容器(11準拠: 全件フラット表示) */
- .table-container { border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; }
- .sleep-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; }
- .sleep-table th { background-color: #457b9d; color: white; padding: 10px; }
- .sleep-table td { padding: 10px; border-bottom: 1px solid #dee2e6; }
- .sleep-table tr:nth-child(even) { background-color: #f8f9fa; }
- #debugLogBox { background: #222; color: #7f7; font-family: monospace; padding: 12px; border-radius: 8px; margin-top: 20px; font-size: 12px; max-height: 120px; overflow-y: auto; white-space: pre-wrap; border: 2px solid #555; }
- .badge-main { background: #457b9d; color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: bold; }
- .badge-nap { background: #e9c46a; color: #333; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: bold; }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1>? Fitbit 睡眠総合マルチアナリティクス</h1>
- <p>すべての分析グラフを一画面に同時集約。時間軸を0時基点に統合した完全表示ダッシュボードです。</p>
- </div>
- <div class="upload-box" id="uploadArea">
- <p style="margin-top:0; font-weight:bold; color:#1b5e20;">? 【使い方】Fitbit の csvファイルを選択してください。</p>
- <div class="file-input-wrapper">
- <input type="file" id="csvFileInput" accept=".csv">
- </div>
- </div>
- <div id="mainDashboard" style="display: none;">
- <div class="card controls">
- <h2>? データ期間切り替え</h2>
- <div style="margin-left: auto; display: flex; gap: 15px; align-items: center;">
- <div>
- <label for="yearSelector">基準年:</label>
- <select id="yearSelector"></select>
- </div>
- <div>
- <label for="monthSelector">分析対象月:</label>
- <select id="monthSelector"></select>
- </div>
- <span id="dataCountBadge" style="background: #114b5f; color: white; padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight:bold;">全 0 件</span>
- </div>
- </div>
- <div class="grid">
- <div class="charts-stack">
- <div class="card">
- <h3 id="monthlyDurationTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 月別平均睡眠時間(本睡眠)</h3>
- <div class="bar-chart" id="monthlyChartContainer" style="height: 140px;"></div>
- </div>
- <div class="card">
- <h3 id="monthlyNapTitle" style="margin-top: 0; font-size: 15px; color: #b7840a; font-weight: bold;">? 月別 平均昼寝・仮眠時間(1日あたり)</h3>
- <div class="bar-chart" id="monthlyNapChartContainer" style="height: 140px;"></div>
- </div>
- <div class="card">
- <div style="display:flex; justify-content:space-between; align-items:center;">
- <h3 id="yearlyComparisonTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 各年の 平均就寝・起床・睡眠時間比較</h3>
- <div style="font-size:11px; display:flex; gap:10px;">
- <span>? 就寝</span> <span style="color:#457b9d;">? 平均睡眠</span> <span>? 起床</span>
- </div>
- </div>
- <div id="yearlyTimeChartAxis" class="time-chart-axis-header"></div>
- <div class="time-chart-container" id="yearlyTimeChartContainer"></div>
- </div>
- <div class="card">
- <div style="display:flex; justify-content:space-between; align-items:center;">
- <h3 id="monthlyTimeTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 月別 平均就寝・起床・睡眠時間推移</h3>
- <div style="font-size:11px; display:flex; gap:10px;">
- <span>? 就寝</span> <span style="color:#457b9d;">? 平均睡眠</span> <span>? 起床</span>
- </div>
- </div>
- <div id="monthlyTimeChartAxis" class="time-chart-axis-header"></div>
- <div class="time-chart-container" id="monthlyTimeChartContainer"></div>
- </div>
- <div class="card">
- <h3 id="clockTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold; text-align: center;">?? 睡眠サイクル(12時間表示・本睡眠のみ)</h3>
- <div style="text-align:center; padding-top:10px;"><canvas id="sleepClock" width="260" height="260" style="margin: 0 auto;"></canvas></div>
- </div>
- </div>
- <div class="charts-stack">
- <div class="card">
- <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
- <h3 id="timelineTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 日別睡眠時間分布タイムライン(0時〜24時)</h3>
- <div class="legend">
- <div class="legend-item"><div class="box-main"></div><span>本睡眠</span></div>
- <div class="legend-item"><div class="box-nap"></div><span>昼寝・仮眠</span></div>
- </div>
- </div>
- <div class="timeline-container" id="timelineContainer"></div>
- </div>
- <div class="card">
- <h3 style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold; margin-bottom: 12px;">? 就寝・起床時間 数値一覧(1ヶ月分・全表示)</h3>
- <div class="table-container">
- <table class="sleep-table">
- <thead>
- <tr><th>日付</th><th>種類</th><th>就寝時刻</th><th>起床時刻</th><th>睡眠時間</th></tr>
- </thead>
- <tbody id="sleepTableBody"></tbody>
- </table>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div id="debugLogBox">? ログ:ファイルを読み込んでください...</div>
- </div>
- <script>
- let rawParsedData = ;
- const standard24HoursAxis = [0, 3, 6, 9, 12, 15, 18, 21, 24];
- const logBox = document.getElementById('debugLogBox');
- function log(msg) { logBox.innerHTML += "\n" + msg; logBox.scrollTop = logBox.scrollHeight; }
- document.getElementById('csvFileInput').addEventListener('change', function(e) {
- const file = e.target.files[0];
- if (!file) return;
- log(`? ファイル「${file.name}」を読み込み中...`);
- const reader = new FileReader();
- reader.onload = function(evt) {
- try { parseCSVRobust(evt.target.result.trim()); } catch(err) { log(`? 解析エラー: ${err.message}`); }
- };
- reader.readAsText(file, 'UTF-8');
- });
- function splitCSVLine(text) {
- let result = ; let inQuote = false; let token = '';
- for (let i = 0; i < text.length; i++) {
- let c = text[i];
- if (c === '"') { inQuote = !inQuote; }
- else if (c === ',' && !inQuote) { result.push(token.trim()); token = ''; }
- else { token += c; }
- }
- result.push(token.trim());
- return result;
- }
- // ? 13.html準拠の堅牢なFitbit英語ヘッダーパース処理
- function parseCSVRobust(text) {
- const lines = text.split(/\r?\n/);
- if (lines.length < 2) { log("? エラー: CSVファイルの中身が不正です。"); return; }
- // ヘッダー行を取得して小文字に統一
- const headers = splitCSVLine(lines[0]).map(h => h.replace(/[\ufeff"']/g, '').trim().toLowerCase());
- const idxDate = headers.indexOf('dateofsleep');
- const idxStart = headers.indexOf('starttime');
- const idxEnd = headers.indexOf('endtime');
- const idxMins = headers.indexOf('minutesasleep');
- const idxMain = headers.indexOf('mainsleep');
- if (idxDate === -1 || idxStart === -1 || idxEnd === -1) {
- log(`? エラー: 必要な列名(dateOfSleep, startTime, endTime)が見つかりません。`);
- alert("睡眠データが見つかりませんでした。Fitbit形式の正しいCSVか確認してください。");
- return;
- }
- rawParsedData = ;
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i].trim(); if (!line) continue;
- const cells = splitCSVLine(line); if (cells.length <= Math.max(idxDate, idxStart, idxEnd)) continue;
- const clean = (idx) => (idx !== -1 && cells[idx]) ? cells[idx].replace(/["']/g, '').trim() : '';
- rawParsedData.push({
- dateOfSleep: clean(idxDate), startTime: clean(idxStart), endTime: clean(idxEnd),
- minutesAsleep: parseFloat(clean(idxMins)) || 0,
- mainSleep: clean(idxMain) ? clean(idxMain).toLowerCase() : 'true'
- });
- }
- if(rawParsedData.length > 0) {
- log(`? 成功: ${rawParsedData.length} 件のデータを読み込みました。`);
- initDashboard();
- } else {
- alert("睡眠データが見つかりませんでした。");
- }
- }
- function safeParseDate(str) {
- if (!str) return null;
- try {
- let clean = str.toLowerCase().replace('t', ' ').replace(/\//g, '-').trim();
- const isPM = clean.includes('pm'); const isAM = clean.includes('am');
- clean = clean.replace('pm', '').replace('am', '').trim();
- const parts = clean.split(' '); const dateParts = parts[0].split('-');
- let hour = 0, min = 0;
- if (parts.length > 1) {
- const timeParts = parts[1].split(':');
- hour = parseInt(timeParts[0], 10) || 0; min = parseInt(timeParts[1], 10) || 0;
- if (isPM && hour < 12) hour += 12; if (isAM && hour === 12) hour = 0;
- }
- return new Date(parseInt(dateParts[0], 10), parseInt(dateParts[1], 10) - 1, parseInt(dateParts[2], 10), hour, min, 0);
- } catch(e) { return null; }
- }
- function calculateCircularAverage(dateObjects) {
- if (!dateObjects || dateObjects.length === 0) return "--:--";
- let sumSin = 0; let sumCos = 0;
- dateObjects.forEach(dt => {
- const minutes = dt.getHours() * 60 + dt.getMinutes();
- const angle = (minutes / 1440) * 2 * Math.PI;
- sumSin += Math.sin(angle); sumCos += Math.cos(angle);
- });
- let avgAngle = Math.atan2(sumSin, sumCos);
- if (avgAngle < 0) avgAngle += 2 * Math.PI;
- let avgMinutes = Math.round((avgAngle / (2 * Math.PI)) * 1440) % 1440;
- const h = Math.floor(avgMinutes / 60); const m = avgMinutes % 60;
- return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
- }
- function getAverageDurationStr(filteredRows) {
- if(filteredRows.length === 0) return "";
- const totalMins = filteredRows.reduce((sum, r) => sum + r.minutesAsleep, 0);
- const avgMins = Math.round(totalMins / filteredRows.length);
- return `${Math.floor(avgMins / 60)}時間${(avgMins % 60)}分`;
- }
- function initDashboard() {
- const yearsSet = new Set();
- rawParsedData.forEach(row => {
- const y = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-')[0];
- if (y && y.length === 4 && !isNaN(y)) yearsSet.add(y);
- });
- const years = Array.from(yearsSet).sort();
- const yearSel = document.getElementById('yearSelector'); yearSel.innerHTML = '';
- years.forEach(y => { yearSel.innerHTML += `<option value="${y}">${y}年</option>`; });
- const monthSel = document.getElementById('monthSelector'); monthSel.innerHTML = '';
- for (let m = 1; m <= 12; m++) { monthSel.innerHTML += `<option value="${m}">${m}月</option>`; }
- yearSel.value = years[years.length - 1];
- let latestMonth = 1;
- for (let m = 12; m >= 1; m--) {
- if (rawParsedData.some(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- return p[0] === yearSel.value && parseInt(p[1], 10) === m;
- })) { latestMonth = m; break; }
- }
- monthSel.value = latestMonth;
- const refresh = () => {
- const y = yearSel.value; const m = monthSel.value;
- document.getElementById('monthlyTimeTitle').textContent = `? ${y}年 月別 平均就寝・起床・睡眠時間推移`;
- document.getElementById('monthlyDurationTitle').textContent = `? ${y}年 月別平均睡眠時間(本睡眠)`;
- document.getElementById('monthlyNapTitle').textContent = `? ${y}年 月別 平均昼寝・仮眠時間(1日あたり)`;
- renderYearlyTimeComparisonChart(m);
- renderMonthlyTimeChart(y);
- renderMonthlyChart(y);
- renderMonthlyNapChart(y);
- renderTimeline(y, m);
- renderSleepClock(y, m);
- renderNumericalTable(y, m);
- };
- yearSel.onchange = refresh; monthSel.onchange = refresh;
- document.getElementById('dataCountBadge').textContent = `全 ${rawParsedData.length} 件`;
- document.getElementById('mainDashboard').style.display = 'block'; // ここで表示させる
- refresh();
- log("? 読み込みに成功し、全ダッシュボードを展開しました。");
- }
- const convertTimeToPercent24H = (timeStr) => {
- if (timeStr === "--:--") return -1;
- const [h, min] = timeStr.split(':').map(Number);
- return ((h + (min/60)) / 24) * 100;
- };
- // 1. 各年の比較
- function renderYearlyTimeComparisonChart(month) {
- document.getElementById('yearlyComparisonTitle').textContent = `? 各年の 「${month}月」 平均就寝・起床・睡眠時間比較`;
- const axisHeader = document.getElementById('yearlyTimeChartAxis'); axisHeader.innerHTML = '';
- standard24HoursAxis.forEach(h => {
- const pos = (h / 24) * 100;
- axisHeader.innerHTML += `<div style="position:absolute; left:calc(${pos}% - 15px);">${h}:00</div>`;
- });
- const container = document.getElementById('yearlyTimeChartContainer'); container.innerHTML = '';
- const yearsSet = new Set(); rawParsedData.forEach(row => { const y = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-')[0]; if (y && y.length === 4) yearsSet.add(y); });
- const allYears = Array.from(yearsSet).sort();
- allYears.forEach(year => {
- const filtered = rawParsedData.filter(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- return p[0] === year && parseInt(p[1], 10) === parseInt(month, 10) && row.mainSleep === 'true';
- });
- let startDates = ; let endDates = ;
- filtered.forEach(row => {
- const s = safeParseDate(row.startTime); const e = safeParseDate(row.endTime);
- if (s) startDates.push(s); if (e) endDates.push(e);
- });
- const bedTimeStr = calculateCircularAverage(startDates);
- const wakeTimeStr = calculateCircularAverage(endDates);
- const avgDurationStr = getAverageDurationStr(filtered);
- const rowDiv = document.createElement('div'); rowDiv.className = 'time-chart-row';
- rowDiv.innerHTML = `<div class="time-chart-label">${year}年</div>`;
- const lineArea = document.createElement('div'); lineArea.className = 'time-chart-line-area';
- standard24HoursAxis.forEach(h => {
- const line = document.createElement('div'); line.className = 'axis-line';
- line.style.left = `${(h / 24) * 100}%`; lineArea.appendChild(line);
- });
- const bedPos = convertTimeToPercent24H(bedTimeStr);
- const wakePos = convertTimeToPercent24H(wakeTimeStr);
- if (bedPos >= 0 && wakePos >= 0) {
- if (bedPos > wakePos) {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${100 - bedPos}%;"></div>`;
- lineArea.innerHTML += `<div class="time-duration-line" style="left:0%; width:${wakePos}%;"></div>`;
- const centerPos = ((bedPos + (100 + wakePos)) / 2) % 100;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- } else {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${wakePos - bedPos}%;"></div>`;
- const centerPos = bedPos + (wakePos - bedPos) / 2;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- }
- }
- if (bedPos >= 0 && bedPos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${bedPos}%;"><span class="dot-symbol dot-bed">?</span><span class="time-text-val" style="color:#1d3557;">${bedTimeStr}</span></div>`;
- }
- if (wakePos >= 0 && wakePos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${wakePos}%;"><span class="dot-symbol dot-wake">?</span><span class="time-text-val" style="color:#b79438;">${wakeTimeStr}</span></div>`;
- }
- rowDiv.appendChild(lineArea); container.appendChild(rowDiv);
- });
- }
- // 2. 月別推移
- function renderMonthlyTimeChart(year) {
- const axisHeader = document.getElementById('monthlyTimeChartAxis'); axisHeader.innerHTML = '';
- standard24HoursAxis.forEach(h => {
- const pos = (h / 24) * 100;
- axisHeader.innerHTML += `<div style="position:absolute; left:calc(${pos}% - 15px);">${h}:00</div>`;
- });
- const container = document.getElementById('monthlyTimeChartContainer'); container.innerHTML = '';
- for (let m = 1; m <= 12; m++) {
- const filtered = rawParsedData.filter(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- return p[0] === year && parseInt(p[1], 10) === m && row.mainSleep === 'true';
- });
- let startDates = ; let endDates = ;
- filtered.forEach(row => {
- const s = safeParseDate(row.startTime); const e = safeParseDate(row.endTime);
- if (s) startDates.push(s); if (e) endDates.push(e);
- });
- const bedTimeStr = calculateCircularAverage(startDates);
- const wakeTimeStr = calculateCircularAverage(endDates);
- const avgDurationStr = getAverageDurationStr(filtered);
- const rowDiv = document.createElement('div'); rowDiv.className = 'time-chart-row';
- rowDiv.innerHTML = `<div class="time-chart-label">${m}月</div>`;
- const lineArea = document.createElement('div'); lineArea.className = 'time-chart-line-area';
- standard24HoursAxis.forEach(h => {
- const line = document.createElement('div'); line.className = 'axis-line';
- line.style.left = `${(h / 24) * 100}%`; lineArea.appendChild(line);
- });
- const bedPos = convertTimeToPercent24H(bedTimeStr);
- const wakePos = convertTimeToPercent24H(wakeTimeStr);
- if (bedPos >= 0 && wakePos >= 0) {
- if (bedPos > wakePos) {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${100 - bedPos}%;"></div>`;
- lineArea.innerHTML += `<div class="time-duration-line" style="left:0%; width:${wakePos}%;"></div>`;
- const centerPos = ((bedPos + (100 + wakePos)) / 2) % 100;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- } else {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${wakePos - bedPos}%;"></div>`;
- const centerPos = bedPos + (wakePos - bedPos) / 2;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- }
- }
- if (bedPos >= 0 && bedPos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${bedPos}%;"><span class="dot-symbol dot-bed">?</span><span class="time-text-val" style="color:#1d3557;">${bedTimeStr}</span></div>`;
- }
- if (wakePos >= 0 && wakePos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${wakePos}%;"><span class="dot-symbol dot-wake">?</span><span class="time-text-val" style="color:#b79438;">${wakeTimeStr}</span></div>`;
- }
- rowDiv.appendChild(lineArea); container.appendChild(rowDiv);
- }
- }
- // 3. 月別平均睡眠時間
- function renderMonthlyChart(year) {
- const dailySum = {};
- rawParsedData.forEach(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- if (p[0] === year && row.mainSleep === 'true') {
- const f = `${p[0]}-${parseInt(p[1],10)}-${parseInt(p[2],10)}`;
- dailySum[f] = (dailySum[f] || 0) + row.minutesAsleep;
- }
- });
- const monthStats = {}; for (let m = 1; m <= 12; m++) monthStats[m] = { total: 0, days: 0 };
- for (const [date, mins] of Object.entries(dailySum)) {
- const m = parseInt(date.split('-')[1], 10); if (monthStats[m]) { monthStats[m].total += mins; monthStats[m].days += 1; }
- }
- const container = document.getElementById('monthlyChartContainer'); container.innerHTML = '';
- const activeMonth = parseInt(document.getElementById('monthSelector').value, 10);
- for (let m = 1; m <= 12; m++) {
- const avg = monthStats[m].days > 0 ? (monthStats[m].total / monthStats[m].days) / 60 : 0;
- const hPercent = Math.min(100, (avg / 10) * 100);
- const wrapper = document.createElement('div');
- wrapper.className = 'bar-wrapper' + (m === activeMonth ? ' active' : '');
- wrapper.onclick = () => { document.getElementById('monthSelector').value = m; document.getElementById('monthSelector').dispatchEvent(new Event('change')); };
- wrapper.innerHTML = `<div class="bar-value">${avg > 0 ? avg.toFixed(1) + 'h' : ''}</div><div class="bar" style="height:${hPercent}%;"></div><div class="bar-label">${m}月</div>`;
- container.appendChild(wrapper);
- }
- }
- // 4. 月別平均昼寝時間
- function renderMonthlyNapChart(year) {
- const monthNapSum = {}; for (let m = 1; m <= 12; m++) monthNapSum[m] = { totalMins: 0, recordCount: 0 };
- rawParsedData.forEach(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- if (p[0] === year && row.mainSleep === 'false') {
- const m = parseInt(p[1], 10);
- if (m >= 1 && m <= 12) {
- monthNapSum[m].totalMins += row.minutesAsleep;
- monthNapSum[m].recordCount += 1;
- }
- }
- });
- const container = document.getElementById('monthlyNapChartContainer'); container.innerHTML = '';
- for (let m = 1; m <= 12; m++) {
- const avgMins = monthNapSum[m].totalMins / 30;
- const hPercent = Math.min(100, (avgMins / 60) * 100);
- const wrapper = document.createElement('div');
- wrapper.className = 'bar-wrapper';
- let displayVal = "";
- if (monthNapSum[m].totalMins > 0) {
- displayVal = avgMins >= 1 ? Math.round(avgMins) + '分' : '1分未満';
- }
- wrapper.innerHTML = `
- <div class="bar-value bar-value-nap">${displayVal}</div>
- <div class="bar bar-nap" style="height:${Math.max(1, hPercent)}%;"></div>
- <div class="bar-label">${m}月</div>
- `;
- container.appendChild(wrapper);
- }
- }
- // 5. 日別タイムライン
- function renderTimeline(year, month) {
- const container = document.getElementById('timelineContainer'); container.innerHTML = '';
- const axisHeader = document.createElement('div'); axisHeader.className = 'axis-label-row';
- standard24HoursAxis.forEach(h => {
- const pos = (h / 24) * 100;
- axisHeader.innerHTML += `<div style="position:absolute; left:calc(${pos}% - 15px);">${h}:00</div>`;
- });
- container.appendChild(axisHeader);
- const filtered = rawParsedData.filter(row => { const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-'); return p[0] === year && parseInt(p[1], 10) === parseInt(month, 10); });
- const dayGroups = {};
- filtered.forEach(row => { const d = parseInt(row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-')[2], 10); if (!isNaN(d)) { if (!dayGroups[d]) dayGroups[d] = ; dayGroups[d].push(row); } });
- const sortedDays = Object.keys(dayGroups).map(Number).sort*1;
- let e = Math.max(0, Math.min(24, eVal));
- if (e <= s) return;
- const isMain = row.mainSleep === 'true';
- const seg = document.createElement('div'); seg.className = `sleep-segment ${isMain?'segment-main':'segment-nap'}`;
- seg.style.left = `${(s / 24) * 100}%`; seg.style.width = `${*2 - (safeParseDate(b.startTime) || new Date()));
- if (filtered.length === 0) { tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:#999; padding:20px;">データなし</td></tr>`; return; }
- filtered.forEach(row => {
- const sDt = safeParseDate(row.startTime); const eDt = safeParseDate(row.endTime); if (!sDt || !eDt) return;
- const dateStr = `${sDt.getFullYear()}/${(sDt.getMonth()+1).toString().padStart(2,'0')}/${sDt.getDate().toString().padStart(2,'0')}`;
- const sTimeStr = `${sDt.getHours().toString().padStart(2,'0')}:${sDt.getMinutes().toString().padStart(2,'0')}`;
- const eTimeStr = `${eDt.getHours().toString().padStart(2,'0')}:${eDt.getMinutes().toString().padStart(2,'0')}`;
- const durationStr = `${Math.floor(row.minutesAsleep / 60)}時間${Math.round(row.minutesAsleep % 60)}分`;
- const typeBadge = row.mainSleep === 'true' ? `<span class="badge-main">本睡眠</span>` : `<span class="badge-nap">仮眠</span>`;
- tbody.appendChild(document.createElement('tr')).innerHTML = `<td><strong>${dateStr}</strong></td><td>${typeBadge}</td><td>? ${sTimeStr}</td><td>? ${eTimeStr}</td><td>?? ${durationStr}</td>`;
- });
- }
- // 睡眠サイクル時計
- function renderSleepClock(year, month) {
- const filtered = rawParsedData.filter(row => { const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-'); return p[0] === year && parseInt(p[1], 10) === parseInt(month, 10) && row.mainSleep === 'true'; });
- let startDates = ; let endDates = ;
- filtered.forEach(row => { const s = safeParseDate(row.startTime); const e = safeParseDate(row.endTime); if (s) startDates.push(s); if (e) endDates.push(e); });
- const bedTime = calculateCircularAverage(startDates);
- const wakeTime = calculateCircularAverage(endDates);
- const canvas = document.getElementById('sleepClock'); if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height);
- if (bedTime === "--:--" || wakeTime === "--:--") { ctx.fillStyle = '#999'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('データなし', canvas.width/2, canvas.height/2); return; }
- const cx = canvas.width / 2; const cy = canvas.height / 2; const radius = 95;
- const h12ToRad = (h) => (h / 12) * 2 * Math.PI - Math.PI / 2;
- const bHrs = *3; }
- const drawHand = (hrs, color) => { const a = h12ToRad(hrs); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + (radius - 28) * Math.cos(a), cy + (radius - 28) * Math.sin(a)); ctx.strokeStyle = color; ctx.lineWidth = 3.5; ctx.stroke(); };
- drawHand(bHrs, '#1d3557'); drawHand(wHrs, '#e9c46a');
- ctx.beginPath(); ctx.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = '#e9ecef'; ctx.stroke();
- ctx.fillStyle = '#212529'; ctx.font = 'bold 12px sans-serif'; ctx.fillText(`${month}月 平均サイクル`, cx, cy - 14);
- ctx.fillStyle = '#1d3557'; ctx.font = '11px sans-serif'; ctx.fillText(`就寝 ${bedTime}`, cx, cy + 6);
- ctx.fillStyle = '#b7840a'; ctx.font = '11px sans-serif'; ctx.fillText(`起床 ${wakeTime}`, cx, cy + 20);
- }
- </script>
- </body>
- </html>
fitbit viewer simple
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="UTF-8">
- <title>Fitbit 睡眠アナリティクスダッシュボード</title>
- <style>
- body { font-family: 'Segoe UI', Meiryo, sans-serif; background-color: #f4f7f9; padding: 20px; color: #333; margin: 0; }
- .container { max-width: 1500px; margin: 0 auto; padding: 10px; }
- .header { text-align: center; margin-bottom: 20px; }
- .header h1 { color: #114b5f; margin-bottom: 5px; }
- /* ファイル選択エリア */
- .upload-box {
- background: #e8f5e9; border: 2px dashed #2e7d32; padding: 25px; border-radius: 12px; text-align: center; margin-bottom: 25px;
- }
- .file-input-wrapper { margin-top: 12px; }
- input[type=\"file\"] { padding: 10px; background: white; border: 1px solid #a5d6a7; border-radius: 6px; cursor: pointer; }
- .card { background: white; border-radius: 16px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
- .controls { display: flex; flex-wrap: wrap; gap: 15px; align-items: center; background: #eef1f6; padding: 15px 20px; border-radius: 12px; }
- .controls h2 { margin: 0; font-size: 18px; color: #114b5f; }
- .control-group { display: flex; align-items: center; gap: 8px; }
- select { padding: 8px 14px; font-size: 14px; border-radius: 6px; border: 1px solid #ccc; background: white; font-weight: bold; color: #333; }
- /* 左右レイアウト構成 */
- .grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
- @media(min-width: 1100px) { .grid { grid-template-columns: 50% 50%; } }
- /* グラフエリアの縦並び配置 */
- .charts-stack { display: flex; flex-direction: column; gap: 20px; }
- /* グラフ用共通スタイル(垂直バー) */
- .bar-chart { display: flex; align-items: flex-end; border-bottom: 2px solid #ddd; padding: 10px 0; gap: 6px; }
- .bar-wrapper { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; cursor: pointer; }
- .bar { width: 80%; background: #a8dadc; border-radius: 4px 4px 0 0; min-height: 1px; transition: background 0.2s; }
- .bar-wrapper:hover .bar { background: #457b9d; }
- .bar-wrapper.active .bar { background: #1d3557; }
- .bar-nap { background: #e9c46a; }
- .bar-wrapper:hover .bar-nap { background: #e6b12a; }
- .bar-value { font-size: 10px; color: #457b9d; margin-bottom: 4px; font-weight: bold; text-align: center; }
- .bar-value-nap { color: #b7840a; }
- .bar-label { font-size: 12px; color: #666; margin-top: 6px; }
- /* 時刻チャート用スタイル(水平タイムライン) */
- .time-chart-container { position: relative; border-left: 2px solid #ddd; border-bottom: 2px solid #ddd; padding: 10px 0; margin-top: 10px; background: #fafafa; }
- .time-chart-row { display: flex; height: 45px; align-items: center; position: relative; border-bottom: 1px dashed #eee; }
- .time-chart-label { width: 65px; font-size: 12px; text-align: center; color: #333; font-weight: bold; z-index: 6; }
- .time-chart-line-area { flex: 1; position: relative; height: 100%; }
- /* 就寝・起床の結合線 */
- .time-duration-line { position: absolute; height: 8px; top: 18px; background: rgba(69, 123, 157, 0.25); border-radius: 4px; z-index: 2; pointer-events: none; }
- /* 平均睡眠時間の文字バッジ */
- .time-duration-text { position: absolute; top: -2px; font-size: 9px; font-weight: bold; color: #1d3557; background: #e8f0fe; padding: 1px 5px; border-radius: 4px; transform: translateX(-50%); z-index: 4; white-space: nowrap; border: 1px solid #b3d7ff; }
- .time-dot { position: absolute; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; transform: translateX(-50%); z-index: 5; }
- .dot-symbol { font-size: 14px; line-height: 1; cursor: pointer; }
- .dot-bed { color: #1d3557; }
- .dot-wake { color: #e9c46a; }
- .time-text-val { font-size: 9px; font-weight: bold; margin-top: 2px; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
- .time-chart-axis-header { display: flex; position: relative; height: 20px; font-size: 10px; color: #888; border-bottom: 1px solid #ccc; margin-left: 65px; }
- /* 右カラム・タイムライン */
- .timeline-container { border: 1px solid #ddd; border-radius: 8px; padding: 10px; background: #fff; max-height: 480px; overflow-y: auto; margin-bottom: 20px; }
- .axis-label-row { display: flex; position: relative; height: 25px; margin-left: 50px; margin-right: 15px; border-bottom: 1px solid #ccc; font-size: 11px; color: #777; }
- .timeline-row { display: flex; align-items: center; border-bottom: 1px solid #f0f0f0; padding: 6px 0; position: relative; height: 35px; }
- .day-label { width: 50px; font-size: 12px; font-weight: bold; color: #555; }
- .time-axis-bg { position: absolute; left: 50px; right: 15px; top: 0; bottom: 0; pointer-events: none; }
- .axis-line { position: absolute; top: 0; bottom: 0; border-left: 1px dashed #e5e5e5; }
- .sleep-bar-canvas { position: relative; flex: 1; height: 100%; margin-right: 15px; margin-left: 50px; }
- .sleep-segment { position: absolute; height: 24px; top: 5px; border-radius: 4px; font-size: 10px; color: white; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: nowrap; min-width: 4px; cursor: pointer; }
- .sleep-segment .tooltip, .time-dot .tooltip { display: none; position: absolute; background: rgba(17, 75, 95, 0.95); color: #fff; padding: 8px 12px; border-radius: 6px; bottom: 35px; left: 50%; transform: translateX(-50%); z-index: 999; font-size: 11px; white-space: nowrap; line-height: 1.4; box-shadow: 0 4px 8px rgba(0,0,0,0.2); }
- .sleep-segment:hover .tooltip, .time-dot:hover .tooltip { display: block; }
- .segment-main { background: #457b9d; }
- .segment-nap { background: #e9c46a; color: #333; font-weight: bold; }
- .legend { display: flex; gap: 15px; font-size: 12px; justify-content: flex-end; margin-bottom: 10px; }
- .legend-item { display: flex; align-items: center; gap: 5px; }
- .box-main { width: 12px; height: 12px; background: #457b9d; border-radius: 2px; }
- .box-nap { width: 12px; height: 12px; background: #e9c46a; border-radius: 2px; }
- /* テーブル容器(11準拠: 全件フラット表示) */
- .table-container { border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; }
- .sleep-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; }
- .sleep-table th { background-color: #457b9d; color: white; padding: 10px; }
- .sleep-table td { padding: 10px; border-bottom: 1px solid #dee2e6; }
- .sleep-table tr:nth-child(even) { background-color: #f8f9fa; }
- #debugLogBox { background: #222; color: #7f7; font-family: monospace; padding: 12px; border-radius: 8px; margin-top: 20px; font-size: 12px; max-height: 120px; overflow-y: auto; white-space: pre-wrap; border: 2px solid #555; }
- .badge-main { background: #457b9d; color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: bold; }
- .badge-nap { background: #e9c46a; color: #333; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: bold; }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1>? Fitbit 睡眠総合マルチアナリティクス</h1>
- <p>すべての分析グラフを一画面に同時集約。時間軸を0時基点に統合した完全表示ダッシュボードです。</p>
- </div>
- <div class="upload-box" id="uploadArea">
- <p style="margin-top:0; font-weight:bold; color:#1b5e20;">? 【使い方】Fitbit の csvファイルを選択してください。</p>
- <div class="file-input-wrapper">
- <input type="file" id="csvFileInput" accept=".csv">
- </div>
- </div>
- <div id="mainDashboard" style="display: none;">
- <div class="card controls">
- <h2>? データ期間切り替え</h2>
- <div style="margin-left: auto; display: flex; gap: 15px; align-items: center;">
- <div>
- <label for="yearSelector">基準年:</label>
- <select id="yearSelector"></select>
- </div>
- <div>
- <label for="monthSelector">分析対象月:</label>
- <select id="monthSelector"></select>
- </div>
- <span id="dataCountBadge" style="background: #114b5f; color: white; padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight:bold;">全 0 件</span>
- </div>
- </div>
- <div class="grid">
- <div class="charts-stack">
- <div class="card">
- <h3 id="monthlyDurationTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 月別平均睡眠時間(本睡眠)</h3>
- <div class="bar-chart" id="monthlyChartContainer" style="height: 140px;"></div>
- </div>
- <div class="card">
- <h3 id="monthlyNapTitle" style="margin-top: 0; font-size: 15px; color: #b7840a; font-weight: bold;">? 月別 平均昼寝・仮眠時間(1日あたり)</h3>
- <div class="bar-chart" id="monthlyNapChartContainer" style="height: 140px;"></div>
- </div>
- <div class="card">
- <div style="display:flex; justify-content:space-between; align-items:center;">
- <h3 id="yearlyComparisonTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 各年の 平均就寝・起床・睡眠時間比較</h3>
- <div style="font-size:11px; display:flex; gap:10px;">
- <span>? 就寝</span> <span style="color:#457b9d;">? 平均睡眠</span> <span>? 起床</span>
- </div>
- </div>
- <div id="yearlyTimeChartAxis" class="time-chart-axis-header"></div>
- <div class="time-chart-container" id="yearlyTimeChartContainer"></div>
- </div>
- <div class="card">
- <div style="display:flex; justify-content:space-between; align-items:center;">
- <h3 id="monthlyTimeTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 月別 平均就寝・起床・睡眠時間推移</h3>
- <div style="font-size:11px; display:flex; gap:10px;">
- <span>? 就寝</span> <span style="color:#457b9d;">? 平均睡眠</span> <span>? 起床</span>
- </div>
- </div>
- <div id="monthlyTimeChartAxis" class="time-chart-axis-header"></div>
- <div class="time-chart-container" id="monthlyTimeChartContainer"></div>
- </div>
- <div class="card">
- <h3 id="clockTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold; text-align: center;">?? 睡眠サイクル(12時間表示・本睡眠のみ)</h3>
- <div style="text-align:center; padding-top:10px;"><canvas id="sleepClock" width="260" height="260" style="margin: 0 auto;"></canvas></div>
- </div>
- </div>
- <div class="charts-stack">
- <div class="card">
- <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
- <h3 id="timelineTitle" style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold;">? 日別睡眠時間分布タイムライン(0時〜24時)</h3>
- <div class="legend">
- <div class="legend-item"><div class="box-main"></div><span>本睡眠</span></div>
- <div class="legend-item"><div class="box-nap"></div><span>昼寝・仮眠</span></div>
- </div>
- </div>
- <div class="timeline-container" id="timelineContainer"></div>
- </div>
- <div class="card">
- <h3 style="margin-top: 0; font-size: 15px; color: #114b5f; font-weight: bold; margin-bottom: 12px;">? 就寝・起床時間 数値一覧(1ヶ月分・全表示)</h3>
- <div class="table-container">
- <table class="sleep-table">
- <thead>
- <tr><th>日付</th><th>種類</th><th>就寝時刻</th><th>起床時刻</th><th>睡眠時間</th></tr>
- </thead>
- <tbody id="sleepTableBody"></tbody>
- </table>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div id="debugLogBox">? ログ:ファイルを読み込んでください...</div>
- </div>
- <script>
- let rawParsedData = ;
- const standard24HoursAxis = [0, 3, 6, 9, 12, 15, 18, 21, 24];
- const logBox = document.getElementById('debugLogBox');
- function log(msg) { logBox.innerHTML += "\n" + msg; logBox.scrollTop = logBox.scrollHeight; }
- document.getElementById('csvFileInput').addEventListener('change', function(e) {
- const file = e.target.files[0];
- if (!file) return;
- log(`? ファイル「${file.name}」を読み込み中...`);
- const reader = new FileReader();
- reader.onload = function(evt) {
- try { parseCSVRobust(evt.target.result.trim()); } catch(err) { log(`? 解析エラー: ${err.message}`); }
- };
- reader.readAsText(file, 'UTF-8');
- });
- function splitCSVLine(text) {
- let result = ; let inQuote = false; let token = '';
- for (let i = 0; i < text.length; i++) {
- let c = text[i];
- if (c === '"') { inQuote = !inQuote; }
- else if (c === ',' && !inQuote) { result.push(token.trim()); token = ''; }
- else { token += c; }
- }
- result.push(token.trim());
- return result;
- }
- // ? 13.html準拠の堅牢なFitbit英語ヘッダーパース処理
- function parseCSVRobust(text) {
- const lines = text.split(/\r?\n/);
- if (lines.length < 2) { log("? エラー: CSVファイルの中身が不正です。"); return; }
- // ヘッダー行を取得して小文字に統一
- const headers = splitCSVLine(lines[0]).map(h => h.replace(/[\ufeff"']/g, '').trim().toLowerCase());
- const idxDate = headers.indexOf('dateofsleep');
- const idxStart = headers.indexOf('starttime');
- const idxEnd = headers.indexOf('endtime');
- const idxMins = headers.indexOf('minutesasleep');
- const idxMain = headers.indexOf('mainsleep');
- if (idxDate === -1 || idxStart === -1 || idxEnd === -1) {
- log(`? エラー: 必要な列名(dateOfSleep, startTime, endTime)が見つかりません。`);
- alert("睡眠データが見つかりませんでした。Fitbit形式の正しいCSVか確認してください。");
- return;
- }
- rawParsedData = ;
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i].trim(); if (!line) continue;
- const cells = splitCSVLine(line); if (cells.length <= Math.max(idxDate, idxStart, idxEnd)) continue;
- const clean = (idx) => (idx !== -1 && cells[idx]) ? cells[idx].replace(/["']/g, '').trim() : '';
- rawParsedData.push({
- dateOfSleep: clean(idxDate), startTime: clean(idxStart), endTime: clean(idxEnd),
- minutesAsleep: parseFloat(clean(idxMins)) || 0,
- mainSleep: clean(idxMain) ? clean(idxMain).toLowerCase() : 'true'
- });
- }
- if(rawParsedData.length > 0) {
- log(`? 成功: ${rawParsedData.length} 件のデータを読み込みました。`);
- initDashboard();
- } else {
- alert("睡眠データが見つかりませんでした。");
- }
- }
- function safeParseDate(str) {
- if (!str) return null;
- try {
- let clean = str.toLowerCase().replace('t', ' ').replace(/\//g, '-').trim();
- const isPM = clean.includes('pm'); const isAM = clean.includes('am');
- clean = clean.replace('pm', '').replace('am', '').trim();
- const parts = clean.split(' '); const dateParts = parts[0].split('-');
- let hour = 0, min = 0;
- if (parts.length > 1) {
- const timeParts = parts[1].split(':');
- hour = parseInt(timeParts[0], 10) || 0; min = parseInt(timeParts[1], 10) || 0;
- if (isPM && hour < 12) hour += 12; if (isAM && hour === 12) hour = 0;
- }
- return new Date(parseInt(dateParts[0], 10), parseInt(dateParts[1], 10) - 1, parseInt(dateParts[2], 10), hour, min, 0);
- } catch(e) { return null; }
- }
- function calculateCircularAverage(dateObjects) {
- if (!dateObjects || dateObjects.length === 0) return "--:--";
- let sumSin = 0; let sumCos = 0;
- dateObjects.forEach(dt => {
- const minutes = dt.getHours() * 60 + dt.getMinutes();
- const angle = (minutes / 1440) * 2 * Math.PI;
- sumSin += Math.sin(angle); sumCos += Math.cos(angle);
- });
- let avgAngle = Math.atan2(sumSin, sumCos);
- if (avgAngle < 0) avgAngle += 2 * Math.PI;
- let avgMinutes = Math.round((avgAngle / (2 * Math.PI)) * 1440) % 1440;
- const h = Math.floor(avgMinutes / 60); const m = avgMinutes % 60;
- return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
- }
- function getAverageDurationStr(filteredRows) {
- if(filteredRows.length === 0) return "";
- const totalMins = filteredRows.reduce((sum, r) => sum + r.minutesAsleep, 0);
- const avgMins = Math.round(totalMins / filteredRows.length);
- return `${Math.floor(avgMins / 60)}時間${(avgMins % 60)}分`;
- }
- function initDashboard() {
- const yearsSet = new Set();
- rawParsedData.forEach(row => {
- const y = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-')[0];
- if (y && y.length === 4 && !isNaN(y)) yearsSet.add(y);
- });
- const years = Array.from(yearsSet).sort();
- const yearSel = document.getElementById('yearSelector'); yearSel.innerHTML = '';
- years.forEach(y => { yearSel.innerHTML += `<option value="${y}">${y}年</option>`; });
- const monthSel = document.getElementById('monthSelector'); monthSel.innerHTML = '';
- for (let m = 1; m <= 12; m++) { monthSel.innerHTML += `<option value="${m}">${m}月</option>`; }
- yearSel.value = years[years.length - 1];
- let latestMonth = 1;
- for (let m = 12; m >= 1; m--) {
- if (rawParsedData.some(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- return p[0] === yearSel.value && parseInt(p[1], 10) === m;
- })) { latestMonth = m; break; }
- }
- monthSel.value = latestMonth;
- const refresh = () => {
- const y = yearSel.value; const m = monthSel.value;
- document.getElementById('monthlyTimeTitle').textContent = `? ${y}年 月別 平均就寝・起床・睡眠時間推移`;
- document.getElementById('monthlyDurationTitle').textContent = `? ${y}年 月別平均睡眠時間(本睡眠)`;
- document.getElementById('monthlyNapTitle').textContent = `? ${y}年 月別 平均昼寝・仮眠時間(1日あたり)`;
- renderYearlyTimeComparisonChart(m);
- renderMonthlyTimeChart(y);
- renderMonthlyChart(y);
- renderMonthlyNapChart(y);
- renderTimeline(y, m);
- renderSleepClock(y, m);
- renderNumericalTable(y, m);
- };
- yearSel.onchange = refresh; monthSel.onchange = refresh;
- document.getElementById('dataCountBadge').textContent = `全 ${rawParsedData.length} 件`;
- document.getElementById('mainDashboard').style.display = 'block'; // ここで表示させる
- refresh();
- log("? 読み込みに成功し、全ダッシュボードを展開しました。");
- }
- const convertTimeToPercent24H = (timeStr) => {
- if (timeStr === "--:--") return -1;
- const [h, min] = timeStr.split(':').map(Number);
- return ((h + (min/60)) / 24) * 100;
- };
- // 1. 各年の比較
- function renderYearlyTimeComparisonChart(month) {
- document.getElementById('yearlyComparisonTitle').textContent = `? 各年の 「${month}月」 平均就寝・起床・睡眠時間比較`;
- const axisHeader = document.getElementById('yearlyTimeChartAxis'); axisHeader.innerHTML = '';
- standard24HoursAxis.forEach(h => {
- const pos = (h / 24) * 100;
- axisHeader.innerHTML += `<div style="position:absolute; left:calc(${pos}% - 15px);">${h}:00</div>`;
- });
- const container = document.getElementById('yearlyTimeChartContainer'); container.innerHTML = '';
- const yearsSet = new Set(); rawParsedData.forEach(row => { const y = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-')[0]; if (y && y.length === 4) yearsSet.add(y); });
- const allYears = Array.from(yearsSet).sort();
- allYears.forEach(year => {
- const filtered = rawParsedData.filter(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- return p[0] === year && parseInt(p[1], 10) === parseInt(month, 10) && row.mainSleep === 'true';
- });
- let startDates = ; let endDates = ;
- filtered.forEach(row => {
- const s = safeParseDate(row.startTime); const e = safeParseDate(row.endTime);
- if (s) startDates.push(s); if (e) endDates.push(e);
- });
- const bedTimeStr = calculateCircularAverage(startDates);
- const wakeTimeStr = calculateCircularAverage(endDates);
- const avgDurationStr = getAverageDurationStr(filtered);
- const rowDiv = document.createElement('div'); rowDiv.className = 'time-chart-row';
- rowDiv.innerHTML = `<div class="time-chart-label">${year}年</div>`;
- const lineArea = document.createElement('div'); lineArea.className = 'time-chart-line-area';
- standard24HoursAxis.forEach(h => {
- const line = document.createElement('div'); line.className = 'axis-line';
- line.style.left = `${(h / 24) * 100}%`; lineArea.appendChild(line);
- });
- const bedPos = convertTimeToPercent24H(bedTimeStr);
- const wakePos = convertTimeToPercent24H(wakeTimeStr);
- if (bedPos >= 0 && wakePos >= 0) {
- if (bedPos > wakePos) {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${100 - bedPos}%;"></div>`;
- lineArea.innerHTML += `<div class="time-duration-line" style="left:0%; width:${wakePos}%;"></div>`;
- const centerPos = ((bedPos + (100 + wakePos)) / 2) % 100;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- } else {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${wakePos - bedPos}%;"></div>`;
- const centerPos = bedPos + (wakePos - bedPos) / 2;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- }
- }
- if (bedPos >= 0 && bedPos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${bedPos}%;"><span class="dot-symbol dot-bed">?</span><span class="time-text-val" style="color:#1d3557;">${bedTimeStr}</span></div>`;
- }
- if (wakePos >= 0 && wakePos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${wakePos}%;"><span class="dot-symbol dot-wake">?</span><span class="time-text-val" style="color:#b79438;">${wakeTimeStr}</span></div>`;
- }
- rowDiv.appendChild(lineArea); container.appendChild(rowDiv);
- });
- }
- // 2. 月別推移
- function renderMonthlyTimeChart(year) {
- const axisHeader = document.getElementById('monthlyTimeChartAxis'); axisHeader.innerHTML = '';
- standard24HoursAxis.forEach(h => {
- const pos = (h / 24) * 100;
- axisHeader.innerHTML += `<div style="position:absolute; left:calc(${pos}% - 15px);">${h}:00</div>`;
- });
- const container = document.getElementById('monthlyTimeChartContainer'); container.innerHTML = '';
- for (let m = 1; m <= 12; m++) {
- const filtered = rawParsedData.filter(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- return p[0] === year && parseInt(p[1], 10) === m && row.mainSleep === 'true';
- });
- let startDates = ; let endDates = ;
- filtered.forEach(row => {
- const s = safeParseDate(row.startTime); const e = safeParseDate(row.endTime);
- if (s) startDates.push(s); if (e) endDates.push(e);
- });
- const bedTimeStr = calculateCircularAverage(startDates);
- const wakeTimeStr = calculateCircularAverage(endDates);
- const avgDurationStr = getAverageDurationStr(filtered);
- const rowDiv = document.createElement('div'); rowDiv.className = 'time-chart-row';
- rowDiv.innerHTML = `<div class="time-chart-label">${m}月</div>`;
- const lineArea = document.createElement('div'); lineArea.className = 'time-chart-line-area';
- standard24HoursAxis.forEach(h => {
- const line = document.createElement('div'); line.className = 'axis-line';
- line.style.left = `${(h / 24) * 100}%`; lineArea.appendChild(line);
- });
- const bedPos = convertTimeToPercent24H(bedTimeStr);
- const wakePos = convertTimeToPercent24H(wakeTimeStr);
- if (bedPos >= 0 && wakePos >= 0) {
- if (bedPos > wakePos) {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${100 - bedPos}%;"></div>`;
- lineArea.innerHTML += `<div class="time-duration-line" style="left:0%; width:${wakePos}%;"></div>`;
- const centerPos = ((bedPos + (100 + wakePos)) / 2) % 100;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- } else {
- lineArea.innerHTML += `<div class="time-duration-line" style="left:${bedPos}%; width:${wakePos - bedPos}%;"></div>`;
- const centerPos = bedPos + (wakePos - bedPos) / 2;
- lineArea.innerHTML += `<div class="time-duration-text" style="left:${centerPos}%;">${avgDurationStr}</div>`;
- }
- }
- if (bedPos >= 0 && bedPos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${bedPos}%;"><span class="dot-symbol dot-bed">?</span><span class="time-text-val" style="color:#1d3557;">${bedTimeStr}</span></div>`;
- }
- if (wakePos >= 0 && wakePos <= 100) {
- lineArea.innerHTML += `<div class="time-dot" style="left:${wakePos}%;"><span class="dot-symbol dot-wake">?</span><span class="time-text-val" style="color:#b79438;">${wakeTimeStr}</span></div>`;
- }
- rowDiv.appendChild(lineArea); container.appendChild(rowDiv);
- }
- }
- // 3. 月別平均睡眠時間
- function renderMonthlyChart(year) {
- const dailySum = {};
- rawParsedData.forEach(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- if (p[0] === year && row.mainSleep === 'true') {
- const f = `${p[0]}-${parseInt(p[1],10)}-${parseInt(p[2],10)}`;
- dailySum[f] = (dailySum[f] || 0) + row.minutesAsleep;
- }
- });
- const monthStats = {}; for (let m = 1; m <= 12; m++) monthStats[m] = { total: 0, days: 0 };
- for (const [date, mins] of Object.entries(dailySum)) {
- const m = parseInt(date.split('-')[1], 10); if (monthStats[m]) { monthStats[m].total += mins; monthStats[m].days += 1; }
- }
- const container = document.getElementById('monthlyChartContainer'); container.innerHTML = '';
- const activeMonth = parseInt(document.getElementById('monthSelector').value, 10);
- for (let m = 1; m <= 12; m++) {
- const avg = monthStats[m].days > 0 ? (monthStats[m].total / monthStats[m].days) / 60 : 0;
- const hPercent = Math.min(100, (avg / 10) * 100);
- const wrapper = document.createElement('div');
- wrapper.className = 'bar-wrapper' + (m === activeMonth ? ' active' : '');
- wrapper.onclick = () => { document.getElementById('monthSelector').value = m; document.getElementById('monthSelector').dispatchEvent(new Event('change')); };
- wrapper.innerHTML = `<div class="bar-value">${avg > 0 ? avg.toFixed(1) + 'h' : ''}</div><div class="bar" style="height:${hPercent}%;"></div><div class="bar-label">${m}月</div>`;
- container.appendChild(wrapper);
- }
- }
- // 4. 月別平均昼寝時間
- function renderMonthlyNapChart(year) {
- const monthNapSum = {}; for (let m = 1; m <= 12; m++) monthNapSum[m] = { totalMins: 0, recordCount: 0 };
- rawParsedData.forEach(row => {
- const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-');
- if (p[0] === year && row.mainSleep === 'false') {
- const m = parseInt(p[1], 10);
- if (m >= 1 && m <= 12) {
- monthNapSum[m].totalMins += row.minutesAsleep;
- monthNapSum[m].recordCount += 1;
- }
- }
- });
- const container = document.getElementById('monthlyNapChartContainer'); container.innerHTML = '';
- for (let m = 1; m <= 12; m++) {
- const avgMins = monthNapSum[m].totalMins / 30;
- const hPercent = Math.min(100, (avgMins / 60) * 100);
- const wrapper = document.createElement('div');
- wrapper.className = 'bar-wrapper';
- let displayVal = "";
- if (monthNapSum[m].totalMins > 0) {
- displayVal = avgMins >= 1 ? Math.round(avgMins) + '分' : '1分未満';
- }
- wrapper.innerHTML = `
- <div class="bar-value bar-value-nap">${displayVal}</div>
- <div class="bar bar-nap" style="height:${Math.max(1, hPercent)}%;"></div>
- <div class="bar-label">${m}月</div>
- `;
- container.appendChild(wrapper);
- }
- }
- // 5. 日別タイムライン
- function renderTimeline(year, month) {
- const container = document.getElementById('timelineContainer'); container.innerHTML = '';
- const axisHeader = document.createElement('div'); axisHeader.className = 'axis-label-row';
- standard24HoursAxis.forEach(h => {
- const pos = (h / 24) * 100;
- axisHeader.innerHTML += `<div style="position:absolute; left:calc(${pos}% - 15px);">${h}:00</div>`;
- });
- container.appendChild(axisHeader);
- const filtered = rawParsedData.filter(row => { const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-'); return p[0] === year && parseInt(p[1], 10) === parseInt(month, 10); });
- const dayGroups = {};
- filtered.forEach(row => { const d = parseInt(row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-')[2], 10); if (!isNaN(d)) { if (!dayGroups[d]) dayGroups[d] = ; dayGroups[d].push(row); } });
- const sortedDays = Object.keys(dayGroups).map(Number).sort*4;
- let e = Math.max(0, Math.min(24, eVal));
- if (e <= s) return;
- const isMain = row.mainSleep === 'true';
- const seg = document.createElement('div'); seg.className = `sleep-segment ${isMain?'segment-main':'segment-nap'}`;
- seg.style.left = `${(s / 24) * 100}%`; seg.style.width = `${*5 - (safeParseDate(b.startTime) || new Date()));
- if (filtered.length === 0) { tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:#999; padding:20px;">データなし</td></tr>`; return; }
- filtered.forEach(row => {
- const sDt = safeParseDate(row.startTime); const eDt = safeParseDate(row.endTime); if (!sDt || !eDt) return;
- const dateStr = `${sDt.getFullYear()}/${(sDt.getMonth()+1).toString().padStart(2,'0')}/${sDt.getDate().toString().padStart(2,'0')}`;
- const sTimeStr = `${sDt.getHours().toString().padStart(2,'0')}:${sDt.getMinutes().toString().padStart(2,'0')}`;
- const eTimeStr = `${eDt.getHours().toString().padStart(2,'0')}:${eDt.getMinutes().toString().padStart(2,'0')}`;
- const durationStr = `${Math.floor(row.minutesAsleep / 60)}時間${Math.round(row.minutesAsleep % 60)}分`;
- const typeBadge = row.mainSleep === 'true' ? `<span class="badge-main">本睡眠</span>` : `<span class="badge-nap">仮眠</span>`;
- tbody.appendChild(document.createElement('tr')).innerHTML = `<td><strong>${dateStr}</strong></td><td>${typeBadge}</td><td>? ${sTimeStr}</td><td>? ${eTimeStr}</td><td>?? ${durationStr}</td>`;
- });
- }
- // 睡眠サイクル時計
- function renderSleepClock(year, month) {
- const filtered = rawParsedData.filter(row => { const p = row.dateOfSleep.split(' ')[0].replace(/\//g, '-').split('-'); return p[0] === year && parseInt(p[1], 10) === parseInt(month, 10) && row.mainSleep === 'true'; });
- let startDates = ; let endDates = ;
- filtered.forEach(row => { const s = safeParseDate(row.startTime); const e = safeParseDate(row.endTime); if (s) startDates.push(s); if (e) endDates.push(e); });
- const bedTime = calculateCircularAverage(startDates);
- const wakeTime = calculateCircularAverage(endDates);
- const canvas = document.getElementById('sleepClock'); if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height);
- if (bedTime === "--:--" || wakeTime === "--:--") { ctx.fillStyle = '#999'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('データなし', canvas.width/2, canvas.height/2); return; }
- const cx = canvas.width / 2; const cy = canvas.height / 2; const radius = 95;
- const h12ToRad = (h) => (h / 12) * 2 * Math.PI - Math.PI / 2;
- const bHrs = *6; }
- const drawHand = (hrs, color) => { const a = h12ToRad(hrs); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + (radius - 28) * Math.cos(a), cy + (radius - 28) * Math.sin(a)); ctx.strokeStyle = color; ctx.lineWidth = 3.5; ctx.stroke(); };
- drawHand(bHrs, '#1d3557'); drawHand(wHrs, '#e9c46a');
- ctx.beginPath(); ctx.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = '#e9ecef'; ctx.stroke();
- ctx.fillStyle = '#212529'; ctx.font = 'bold 12px sans-serif'; ctx.fillText(`${month}月 平均サイクル`, cx, cy - 14);
- ctx.fillStyle = '#1d3557'; ctx.font = '11px sans-serif'; ctx.fillText(`就寝 ${bedTime}`, cx, cy + 6);
- ctx.fillStyle = '#b7840a'; ctx.font = '11px sans-serif'; ctx.fillText(`起床 ${wakeTime}`, cx, cy + 20);
- }
- </script>
- </body>
- </html>
*1:a, b) => a - b);
*2:e - s) / 24) * 100}%`;
*3:) => { const [h, m] = bedTime.split(':').map(Number); return (h % 12) + (m/60); })();
*4:a, b) => a - b);
*5:e - s) / 24) * 100}%`;
*6:) => { const [h, m] = bedTime.split(':').map(Number); return (h % 12) + (m/60); })();