「JavaScriptでグラフを表示したいけど、どう書けばいいの?」
「Chart.jsを使ってみたいけど、実際のコード例が欲しい!」
そんな方に向けて、この記事では HTMLとJavaScriptだけで動く「目標管理&進捗可視化Webツール」 の作り方を解説します。
このツールでは、
- 折れ線グラフ で進捗を時系列に可視化
- ドーナツグラフ で最新の達成率を直感的に表示
- 入力データを ローカルストレージに保存して再利用可能
といった機能を備えており、コピペですぐ完成するコードも用意しています。
Chart.jsを用いた実践的なサンプルとしても最適で、グラフの基本的な描画方法から応用まで自然に学べます。
初心者の方はもちろん、「具体的なサンプルコードを動かして理解したい!」という方におすすめです。
完成イメージと機能紹介
今回作成する「目標管理&進捗可視化Webツール」は、以下のような画面構成になっています。
- 目標名の入力欄
追いかけたい目標(例:「英語学習」「筋トレ」など)を入力できます。 - 日付と達成率の入力表(初期5行)
進捗データを入力するための表形式。行の追加・削除が可能で、柔軟に使えます。 - 折れ線グラフ(進捗の推移)
入力したデータを時系列で表示。達成率がどのように変化したかを一目で把握できます。 - ドーナツグラフ(最新の達成率)
直近のデータを反映して、今どのくらい達成できているかを視覚的に確認できます。 - 保存&エクスポート機能
入力したデータは自動でブラウザに保存され、再度ページを開いた時に復元されます。
また、CSV形式での書き出しや、グラフをPNG画像として保存することも可能です。
【実際の目標管理&進捗可視化Webツールの動作を試してみたい方はこちらからどうぞ】▼
まずは完成イメージをしっかり掴んでおくことが大切です。どういう機能があるのか理解すれば、コードのどこで何をしているのかが分かりやすくなりますよ。

完成コード全文|コピペで動かしてみよう
ここからは、実際に動作する「目標管理&進捗グラフWebツール」の完成コードを紹介します。
以下のHTMLをまるごとコピーして、index.html
として保存するだけで動作します。特別な環境構築は不要で、ブラウザで開けばすぐにツールを利用できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>目標進捗トラッカー(達成率% 推移)</title> <style> :root{ --bg:#f7f7f8; --card:#fff; --line:#e5e7eb; --text:#222; --muted:#666; --primary:#3B82F6; --amber:#F59E0B; --violet:#8B5CF6; --green:#10B981; } *{ box-sizing: border-box; } body{ margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,"Hiragino Kaku Gothic ProN",Meiryo,sans-serif; color:var(--text); background:var(--bg); } .wrap{ max-width: 1180px; margin: 28px auto 40px; padding: 0 16px; } h1{ margin:0 0 6px; font-size: clamp(22px,3.2vw,28px); letter-spacing:.2px; } p.lead{ margin:0 0 16px; color:var(--muted); } /* 2カラム:PCで左右、モバイルで縦積み */ .grid-desktop{ display: grid; gap: 16px; grid-template-columns: 1fr; } @media (min-width: 980px){ .grid-desktop{ grid-template-columns: 1.05fr 1fr; /* 左やや広め */ align-items: start; } } .panel{ background:#fff; border:1px solid var(--line); border-radius: 12px; padding: 14px; } .section-title{ font-weight: 700; margin: 4px 0 10px; font-size: 16px; } .row{ display:flex; gap:12px; flex-wrap:wrap; align-items:center; } .lbl{ display:flex; flex-direction:column; gap:6px; font-size:13px; color:#444; } input[type="text"]{ width:100%; border:1px solid #d1d5db; border-radius:10px; padding:10px 12px; font-size:14px; background:#fff; } input[type="text"]:focus{ outline:2px solid #dbeafe; outline-offset:2px; } /* table */ table{ width:100%; border-collapse: separate; border-spacing: 0 10px; } thead th{ text-align:left; color:#444; font-weight:700; padding: 6px 10px; font-size:14px; } tbody tr{ background:#fff; border:1px solid var(--line); box-shadow: 0 1px 0 rgb(0 0 0 / 2%); } tbody td{ padding: 8px 10px; vertical-align: middle; } tbody tr td:first-child{ border-top-left-radius:10px; border-bottom-left-radius:10px; } tbody tr td:last-child{ border-top-right-radius:10px; border-bottom-right-radius:10px; } /* 入力欄はコンパクト */ .cell-date, .cell-rate { display:flex; align-items:center; gap:8px; } .cell-date input[type="date"]{ width: 160px; min-width: 140px; border:1px solid #d1d5db; border-radius:10px; padding:8px 10px; font-size:14px; background:#fff; } .cell-rate input[type="number"]{ width: 120px; min-width: 110px; text-align:right; border:1px solid #d1d5db; border-radius:10px; padding:8px 10px; font-size:14px; background:#fff; } input[type="date"]:focus, input[type="number"]:focus{ outline:2px solid #dbeafe; outline-offset:2px; } .actions{ display:flex; gap:8px; align-items:center; } .ctrls{ display:flex; gap:10px; flex-wrap:wrap; margin-top:10px; } button{ cursor:pointer; border:1px solid #d1d5db; background:#f6f7fb; padding:10px 14px; border-radius:10px; font-weight:600; font-size:14px; } button:hover{ background:#eef0f6; } .primary{ background:#eef2ff; border-color:#c7d2fe; } .danger{ background:#fee2e2; border-color:#fecaca; } .note{ color:var(--muted); font-size:12px; margin-top:6px; } .msg{ min-height:1.2em; color:#b91c1c; font-size:13px; margin-top:6px; } .canvas-wrap{ height: 380px; border:1px solid var(--line); border-radius:12px; padding:10px; background:#fff; } /* 右カラム:PCで軽く固定(視線移動最小化) */ @media (min-width: 980px){ .sticky{ position: sticky; top: 16px; } } /* グラフだけのラッパ(PNG書き出し対象) */ #graphsWrap{ display: grid; gap: 14px; } </style> </head> <body> <div class="wrap"> <h1>目標進捗トラッカー(達成率% 推移)</h1> <p class="lead">習慣・目標名と<strong>日付+達成率(%)</strong>を入力して「グラフ表示」。最新はドーナツ、推移は折れ線(常時ラベル表示)で可視化します。<br>この端末に自動保存・手動保存が可能(ローカルストレージ)。</p> <div class="grid-desktop"> <!-- 左カラム:入力 --> <div class="left-col"> <div class="panel"> <div class="section-title">習慣・目標名</div> <div class="row"> <label class="lbl" style="flex:1; min-width:260px;">タイトル(例:英単語学習、腕立て伏せ など) <input id="goalName" type="text" maxlength="40" placeholder="例:英単語学習 30日プラン"> </label> </div> </div> <div class="panel" style="margin-top:14px;"> <div class="section-title">入力(初期5行|行の追加・削除可) <span id="rowCount" class="note" style="margin-left:8px;"></span> </div> <table aria-label="日付・達成率入力テーブル"> <colgroup> <col style="width:50%"><col style="width:50%"> </colgroup> <thead> <tr> <th>日付(必須)</th> <th>達成率(0〜100%)</th> </tr> </thead> <tbody id="rows"></tbody> </table> <div class="ctrls"> <button id="addRow" type="button" title="行を追加">+ 行を追加</button> <button id="sample" type="button">サンプル入力</button> <button id="clear" class="danger" type="button">入力をクリア</button> <button id="render" class="primary" type="button">グラフ表示</button> </div> <div id="msg" class="msg" aria-live="polite"></div> <p class="note">※ 入力例:達成率は 0〜100 の数値です。空行は無視します。データは自動保存されます(この端末のブラウザ)。</p> </div> </div> <!-- 右カラム:保存/書き出し → グラフ(PCでsticky) --> <aside class="right-col sticky"> <!-- 保存とエクスポート(最上段に移動) --> <div class="panel"> <div class="section-title">保存とエクスポート</div> <div class="ctrls"> <button id="btnSave" type="button">保存(この端末)</button> <button id="btnReset" class="danger" type="button">保存データを削除</button> <button id="btnCsv" type="button">CSV書き出し(日付,達成率)</button> <button id="btnPng" type="button">PNG書き出し(グラフのみ)</button> </div> <p class="note">※ ローカルストレージ保存のため、別ブラウザ・別端末では共有されません。</p> </div> <!-- グラフ --> <div id="graphsWrap" style="margin-top:14px;"> <div class="panel"> <div class="section-title">最新達成率(ドーナツ)</div> <div class="canvas-wrap"><canvas id="donut" aria-label="最新達成率ドーナツ" role="img"></canvas></div> </div> <div class="panel"> <div class="section-title">推移(折れ線・常時ラベル)</div> <div class="canvas-wrap"><canvas id="line" aria-label="達成率推移グラフ" role="img"></canvas></div> </div> </div> </aside> </div> </div> <!-- libs --> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script> <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script> <script> const $ = (s, p=document)=>p.querySelector(s); const tbody = $('#rows'); const msgEl = $('#msg'); const rowCountEl = $('#rowCount'); const KEY = 'goal-tracker-v5'; const MAX_ROWS = 60; // 一般的な利用を想定した上限 // 行操作(コンパクト入力) function addRow(date='', rate=''){ const tr = document.createElement('tr'); tr.innerHTML = ` <td><div class="cell-date"><input type="date" value="${date}"></div></td> <td> <div class="cell-rate"> <input type="number" min="0" max="100" step="0.1" placeholder="%" value="${rate}"> <button class="danger del" type="button" aria-label="この行を削除">削除</button> </div> </td>`; tr.querySelector('.del').addEventListener('click', ()=> { tr.remove(); updateRowLimitUI(); autoSave(); }); for(const inp of tr.querySelectorAll('input')) inp.addEventListener('input', autoSave); tbody.appendChild(tr); updateRowLimitUI(); } function initRows(n=5){ tbody.innerHTML=''; for(let i=0;i<n;i++) addRow(); updateRowLimitUI(); } function updateRowLimitUI(){ const count = tbody.querySelectorAll('tr').length; rowCountEl.textContent = `行数:${count} / ${MAX_ROWS}`; const addBtn = $('#addRow'); const atLimit = count >= MAX_ROWS; addBtn.disabled = atLimit; addBtn.title = atLimit ? `これ以上は追加できません(最大 ${MAX_ROWS} 行)` : '行を追加'; } // Chart.js Chart.register(ChartDataLabels); const colorByRate = (r)=>{ const root = document.documentElement; if(r>=100) return getComputedStyle(root).getPropertyValue('--green').trim(); if(r>=80) return getComputedStyle(root).getPropertyValue('--violet').trim(); if(r>=50) return getComputedStyle(root).getPropertyValue('--primary').trim(); return getComputedStyle(root).getPropertyValue('--amber').trim(); }; let donutChart=null, lineChart=null; // 収集 function collect(){ const arr=[]; for(const tr of tbody.querySelectorAll('tr')){ const d = tr.querySelector('input[type="date"]').value; const rStr = tr.querySelector('input[type="number"]').value.trim(); if(!d && !rStr) continue; if(!d) throw new Error('日付が未入力の行があります。'); if(rStr==='') throw new Error('達成率が未入力の行があります。'); const r = Number(rStr); if(!Number.isFinite(r) || r<0 || r>100) throw new Error('達成率は0〜100で入力してください。'); arr.push({date: d, rate: r, dt: new Date(d+'T00:00')}); } if(arr.length<1) throw new Error('1行以上入力してください。'); arr.sort((a,b)=> a.dt - b.dt); return arr; } // 描画 function render(){ try{ msgEl.textContent=''; const name = $('#goalName').value.trim(); const rows = collect(); const latest = rows[rows.length-1]; const latestRate = latest.rate; const mainColor = colorByRate(latestRate); // --- ドーナツ --- const donutData = [latestRate, Math.max(100 - latestRate, 0)]; if(donutChart) donutChart.destroy(); donutChart = new Chart($('#donut'), { type:'doughnut', data:{ labels:['現在の達成率','残り'], datasets:[{ data:donutData, backgroundColor:[mainColor,'#e5e7eb'], borderColor:'#fff', borderWidth:2 }]} , options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false }, datalabels:{ color:'#fff', font:{ weight:700 }, formatter:(v,ctx)=> ctx.dataIndex===0 ? latestRate.toFixed(1)+'%' : '', anchor:'center', align:'center' }, title:{ display: !!name, text: name } }, cutout:'68%' } }); // --- 折れ線(常時ラベルON) --- const labels = rows.map(r=>{ const d=new Date(r.dt); return d.toLocaleDateString('ja-JP',{month:'numeric',day:'numeric'}); }); const data = rows.map(r=>r.rate); if(lineChart) lineChart.destroy(); lineChart = new Chart($('#line'), { type:'line', data:{ labels, datasets:[{ label:(name? name+'の' : '')+'達成率(%)', data, borderColor: mainColor, backgroundColor: mainColor+'22', tension:0.25, fill:true, pointRadius:2 }]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false }, tooltip:{ enabled:true }, datalabels:{ display:true, formatter:(v)=> v.toFixed(1)+'%', anchor:'end', align:'top', offset: 4, color:'#333', backgroundColor:'rgba(255,255,255,0.9)', borderColor:'#ddd', borderWidth:1, borderRadius:4, padding:{top:2,right:4,bottom:2,left:4}, font:{ weight:600 } } }, scales:{ x:{ grid:{ display:false } }, y:{ beginAtZero:true, max:100, ticks:{ stepSize:20 }, grid:{ color:'#eee' } } } } }); autoSave(true); }catch(e){ msgEl.textContent = e.message || '入力内容を確認してください。'; } } // 保存系 function getDataObj(){ const name = $('#goalName').value || ''; const rows=[]; for(const tr of tbody.querySelectorAll('tr')){ const d = tr.querySelector('input[type="date"]').value || ''; const r = tr.querySelector('input[type="number"]').value || ''; if(!d && !r) continue; rows.push({d,r}); } return { name, rows }; } function setDataObj(obj){ $('#goalName').value = obj?.name || ''; tbody.innerHTML=''; if(obj?.rows?.length){ for(const row of obj.rows){ addRow(row.d||'', row.r||''); } }else{ initRows(5); } } function save(){ try{ const obj = getDataObj(); localStorage.setItem(KEY, JSON.stringify(obj)); msgEl.textContent = '保存しました(この端末)。'; }catch{ msgEl.textContent = '保存に失敗しました。'; } } const autoSave = (silent=false)=>{ try{ const obj = getDataObj(); localStorage.setItem(KEY, JSON.stringify(obj)); if(!silent) msgEl.textContent = '自動保存しました。'; }catch{} }; function restore(){ try{ const raw = localStorage.getItem(KEY); if(!raw){ initRows(5); return; } const obj = JSON.parse(raw); setDataObj(obj); msgEl.textContent = '前回の入力内容を復元しました。'; if(obj?.rows?.some(r => (r.d||'') && (r.r||'')!=='')){ render(); } }catch{ initRows(5); } } function resetStorage(){ localStorage.removeItem(KEY); msgEl.textContent = '保存データを削除しました。'; } // CSV出力(date,rate) function exportCSV(){ try{ const name = ($('#goalName').value || 'goal').replace(/[,\r\n]/g,' ').trim() || 'goal'; const rows=[]; for(const tr of tbody.querySelectorAll('tr')){ const d = tr.querySelector('input[type="date"]').value; const r = tr.querySelector('input[type="number"]').value.trim(); if(!d && !r) continue; if(!d || r==='') continue; rows.push({d,r}); } if(!rows.length){ msgEl.textContent = '書き出すデータがありません。'; return; } let csv = 'date,rate\n'; for(const r of rows){ csv += `${r.d},${r.r}\n`; } const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${name}_progress.csv`; a.click(); URL.revokeObjectURL(a.href); msgEl.textContent = 'CSVを書き出しました。'; }catch{ msgEl.textContent = 'CSV書き出しに失敗しました。'; } } // PNG(グラフ領域のみ書き出し) async function exportPNG(){ try{ msgEl.textContent = '画像を書き出し中...'; const node = $('#graphsWrap'); // グラフのみ const canvas = await html2canvas(node, {backgroundColor:'#ffffff', scale:2}); const url = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = url; a.download = 'progress-graphs.png'; a.click(); msgEl.textContent = 'PNGを書き出しました。'; }catch{ msgEl.textContent = 'PNG書き出しに失敗しました。'; } } // イベント $('#addRow').addEventListener('click', ()=>{ if(tbody.querySelectorAll('tr').length < MAX_ROWS){ addRow(); autoSave(); } }); $('#clear').addEventListener('click', ()=>{ initRows(5); $('#goalName').value=''; msgEl.textContent='入力をクリアしました。'; autoSave(true); if(donutChart){donutChart.destroy(); donutChart=null;} if(lineChart){lineChart.destroy(); lineChart=null;} }); $('#sample').addEventListener('click', ()=>{ const today=new Date(); const rates=[14,28,43,61,78]; // 5行サンプル tbody.innerHTML=''; for(let i=0;i<rates.length;i++){ const d=new Date(today.getFullYear(), today.getMonth(), today.getDate()-(rates.length-1-i)); const yyyy=d.getFullYear(); const mm=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); addRow(`${yyyy}-${mm}-${dd}`, rates[i]); } $('#goalName').value='英単語学習 30日プラン'; msgEl.textContent='サンプルを入力しました。「グラフ表示」を押してください。'; autoSave(true); }); $('#render').addEventListener('click', render); $('#goalName').addEventListener('input', autoSave); $('#btnSave').addEventListener('click', save); $('#btnReset').addEventListener('click', resetStorage); $('#btnCsv').addEventListener('click', exportCSV); $('#btnPng').addEventListener('click', exportPNG); // 初期 restore(); if(!localStorage.getItem(KEY)){ msgEl.textContent = 'まずは表に「日付」と「達成率(%)」を入力するか、「サンプル入力」をお試しください。'; } </script> </body> </html> |
まずはそのまま動かしてみることが大切です。コピー&ペーストで完成版を体験してみれば、コードがどう動いているかイメージしやすくなりますよ。次のセクションで詳しく仕組みを解説しますので、安心して進めてくださいね。

コード解説|HTML・CSS・JavaScriptを理解しよう
この章では、完成コードの仕組みを HTML・CSS・JavaScript の3つの視点から分解して解説します。コード全文は前章で紹介したので、ここでは主要なポイントにフォーカスします。
1. HTML部分:構造を作る
HTMLはツールの骨組みを構成します。主な要素は以下のとおりです。
- 入力フォーム部分
- 目標名(
<input id="goalName">
)で習慣や目標を入力します。 - 日付と達成率の入力テーブル(初期5行、行追加・削除可)。
- 「サンプル入力」「入力をクリア」「グラフ表示」ボタンが揃っています。
- 目標名(
- 保存・エクスポート部分
- 「保存(この端末)」「保存データを削除」「CSV書き出し」「PNG書き出し」といったボタン群。
- すべてローカル保存で、サーバー送信はありません。
- グラフ表示部分
- 最新達成率を示す ドーナツグラフ
- 推移を示す 折れ線グラフ(常時ラベル付き)
2. CSS部分:デザインを整える
CSSでツール全体を見やすく調整しています。
- 2カラムレイアウト
- PCでは「入力フォーム」が左、「保存+グラフ」が右に並ぶレイアウト。
- スマホでは縦に積み上がるデザイン。
- テーブル入力
- 日付と達成率を入力しやすいようにコンパクト設計。
- 行追加・削除ボタンのスタイルも統一。
- グラフ表示
canvas
部分に余白と枠をつけて、見やすいカード風に。
3. JavaScript部分:動きを作る
JavaScriptでは入力・保存・描画などのロジックを担当しています。
主な機能
- 行操作
- 行を追加・削除する関数
addRow
。 - 最大60行まで追加可能。
- 行を追加・削除する関数
- 入力データの収集と検証
collect()
関数で、日付と達成率(0〜100%)をチェック。- 空行は無視し、不正な値はエラー表示。
- グラフ描画(Chart.js利用)
- ドーナツグラフ
最新の達成率を1つの円グラフで可視化。 - 折れ線グラフ
推移を常時ラベル付きで表示し、直感的に進捗を確認。
- ドーナツグラフ
- 保存・復元
- ローカルストレージにデータを保存(自動保存+手動保存)。
- ページを再読み込みしてもデータ復元可能。
- エクスポート機能
- CSV形式(
date,rate
)でデータを保存。 - html2canvasを使ってグラフ部分だけをPNG書き出し。
- CSV形式(
詳細解説:グラフ描画とPNG書き出しの仕組み
データ収集 → 描画の全体フロー
- 入力テーブルから日付と達成率を読み取る
- 日付順にソートし、ラベル配列(表示用の日付)とデータ配列(達成率)を作る
- 最新の達成率を使ってドーナツ、推移データで折れ線を描く
- 描画後に自動保存(localStorage)を行い、次回復元時は自動レンダリング
Chart.js:ドーナツ(最新達成率)描画
- 最新行(いちばん新しい日付)だけを取り出して達成率 / 残りを円グラフで見せる
- 1枚で「今どれくらい進んでいるか」を直感表示
主要コード断片(概念)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
const latestRate = rows[rows.length - 1].rate; // 最新の達成率 const donutData = [latestRate, Math.max(100 - latestRate, 0)]; donutChart = new Chart($('#donut'), { type: 'doughnut', data: { labels: ['現在の達成率', '残り'], datasets: [{ data: donutData, backgroundColor: [mainColor, '#e5e7eb'], // mainColorは達成率に応じて動的着色 borderColor: '#fff', borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '68%', // 中心の穴の大きさ plugins: { legend: { display: false }, // 凡例は省略 datalabels: { // 値を“常時”表示 color: '#fff', font: { weight: 700 }, formatter: (v, ctx) => ctx.dataIndex === 0 ? latestRate.toFixed(1) + '%' : '', anchor: 'center', align: 'center' }, title: { display: !!name, text: name } // 目標名があればタイトル表示 } } }); |
設定のポイント
cutout
:ドーナツの中心穴をどれだけ空けるか。大きめ(68%)にして数値が目立つように。legend.display: false
:凡例を消して情報密度を最適化。chartjs-plugin-datalabels
:“ホバー不要で常時数値表示”を実現。ドーナツの内側中央に大きく%を出します。- 色(
mainColor
):達成率によって緑/紫/青/アンバーに分け、達成感が直感的に伝わる配色にしています。
Chart.js:折れ線(推移)描画
- 過去→現在の達成率の変化を“折れ線”で見せる
- 常時ラベル(数値)を泡のように表示し、値読み取りを楽に
主要コード断片(概念)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
// ラベル(日付)とデータ(達成率)を作成 const labels = rows.map(r => new Date(r.dt) .toLocaleDateString('ja-JP', { month:'numeric', day:'numeric' })); const data = rows.map(r => r.rate); lineChart = new Chart($('#line'), { type: 'line', data: { labels, datasets: [{ label: (name ? name + 'の' : '') + '達成率(%)', data, borderColor: mainColor, // ドーナツと統一感 backgroundColor: mainColor + '22',// うっすら塗り tension: 0.25, // 線を少しなめらかに fill: true, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: true }, datalabels: { // ← 常時ラベル display: true, formatter: v => v.toFixed(1) + '%', anchor: 'end', align: 'top', offset: 4, color: '#333', backgroundColor: 'rgba(255,255,255,0.9)', borderColor: '#ddd', borderWidth: 1, borderRadius: 4, padding: { top: 2, right: 4, bottom: 2, left: 4 }, font: { weight: 600 } } }, scales: { x: { grid: { display: false } }, y: { beginAtZero: true, max: 100, ticks: { stepSize: 20 }, grid: { color: '#eee' } } } } }); |
設定のポイント
tension: 0.25
:線をなめらかにして視認性UP(0だと直線)。plugins.datalabels.display: true
:常時%ラベルを表示。小さめのラベル背景・枠線で視認性と可読性を両立。y.max = 100
:達成率なので0–100%に固定。比較しやすくなります。
chartjs-plugin-datalabels のポイント
- 導入:CDNで読み込み →
Chart.register(ChartDataLabels)
を必ず実行。 - 役割:ツールチップ(ホバー時)ではなく、データラベルを常時表示するためのプラグイン。
- カスタム:
formatter
で表示テキストを自由に加工(%付与・小数一桁など)。 - 位置:
anchor
,align
,offset
で細かい位置調整が可能。折れ線ではend/top
、ドーナツではcenter/center
が見やすい構成。
html2canvas:グラフ部分だけをPNG書き出し
- DOM(HTML要素)を“そのまま画像化”するライブラリ
- このツールでは グラフ部分のみ(入力テーブルは除外)を書き出し
主要コード断片(概念)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
async function exportPNG(){ try{ msgEl.textContent = '画像を書き出し中...'; const node = $('#graphsWrap'); // ← グラフだけを包む要素 const canvas = await html2canvas(node, { backgroundColor: '#ffffff', // 透明でなく白背景に scale: 2 // ★ 高解像度化(2倍) }); const url = canvas.toDataURL('image/png'); // PNGデータURLへ変換 const a = document.createElement('a'); a.href = url; a.download = 'progress-graphs.png'; a.click(); msgEl.textContent = 'PNGを書き出しました。'; }catch{ msgEl.textContent = 'PNG書き出しに失敗しました。'; } } |
設定のポイント
node
の選び方:#graphsWrap
のように書き出したい範囲だけをラップしておくと、入力フォーム等が混ざらない。backgroundColor
:PNGはデフォルト透明になりがち。資料用途では白が安全。scale
:2倍にして高DPI出力 → スライドやSNSで鮮明に。端末性能に応じて 1.5〜3 で調整。
データの整形(並び替えと正規化)
- 入力順ではなく、日付の古い→新しいに並べてからグラフ化すると推移が正しく表示されます。
概念コード
1 2 3 4 5 6 7 |
const arr = []; // {date:'YYYY-MM-DD', rate:number, dt:Date} for(const tr of tbody.querySelectorAll('tr')){ // 未入力行はスキップ、値の妥当性チェック(0〜100) // ... arr.push({ date: d, rate: r, dt: new Date(d+'T00:00') }); } arr.sort((a,b) => a.dt - b.dt); // ← ここが肝 |
バリデーションの考え方
- 必須:日付がある、達成率が空でない、数値、0〜100の範囲
- 空行は無視:テーブル編集の自由度を確保しつつ、無関係な空行でエラーを出さない
よくあるつまずき(トラブルシュート)
- ラベルが表示されない/位置がずれる
→chartjs-plugin-datalabels
のCDN読み込み忘れ、Chart.register(ChartDataLabels)
未実行が原因になりがち。 - ドーナツが真っ白/真っ黒
→ データ([達成率, 残り]
)がNaN
になっていないか確認。0/100 の境界値もケア。 - PNGがぼやける
→html2canvas
のscale
を 2 以上に、backgroundColor
を白に設定。 - スマホで文字が切れる
→maintainAspectRatio:false
の上で、親要素の高さ(.canvas-wrap
)を十分確保。 - 日付の順序がおかしい
→ 文字列比較ではなく Dateオブジェクトでソートしているか確認。
今回は「折れ線グラフ」と「ドーナツグラフ」で進捗を表現しましたが、Chart.jsには他にも棒グラフやレーダーチャートなど多彩な表現方法があります。

おすすめの学習リソース|JavaScript&グラフ化をもっと学びたい人へ
「今回のツールをもっと改造してみたい!」「画像処理の仕組みやJavaScriptを深く理解したい!」という方に向けて、厳選した学習リソースをご紹介します。
書籍編
『確かな力が身につくJavaScript超入門』(SBクリエイティブ)
- JavaScriptの基礎文法からDOM操作、イベント処理まで体系的に学べます。
- 今回のようなWebツールを自作するスキルが自然と身につきます。
- 実例が豊富なので、「こういう機能を作りたい!」という時の参考書にもなります。
『スラスラわかるHTML&CSSのきほん』(SBクリエイティブ)
- HTMLやCSSの基本構造から、見た目を整えるためのテクニックまでを網羅。
- ツールのUI(ユーザーインターフェース)を改良する際に役立ちます。
オンライン講座編
Udemy|世界最大級のオンライン学習プラットフォーム
世界中で利用されるオンライン学習サイト。
HTML、CSS、JavaScriptの入門から応用まで、高評価の講座が数百種類揃っています。
初心者でも動画を見ながら手を動かせるので、挫折しにくいのが魅力です。
Udemyおすすめ講座
【HTML,CSS,JS,PHP,Git,Docker】プログラミング初心者OK! ゼロからわかるWebシステム開発
- 実際に手を動かしながら学べる実践型講座。
- 初心者でも、コードの意味が理解できるよう丁寧に解説されています。
- 自作ツールのカスタマイズや新機能追加にも応用可能。
他にも講座を探したい方はこちら → UdemyのWeb開発カテゴリー講座
まとめ
今回は、「目標管理&進捗可視化Webツール」 をHTMLとJavaScriptでゼロから作る方法を解説しました。完成コードをそのままコピペすればすぐに動作するだけでなく、コードの仕組みを理解すればカスタマイズの幅が一気に広がります。
- 入力フォームで日付と達成率を記録できる
- Chart.jsで折れ線グラフ&ドーナツグラフを描画して進捗を可視化できる
- html2canvasを利用してグラフをPNGとして保存できる
- ローカルストレージ保存により、サーバー不要でデータを保持できる
といった機能を通して、実用性の高いWebアプリの基本を体験できたはずです。
さらに今回学んだコードをベースにすれば、発展的なツール開発へとステップアップすることも可能です。
ぜひこのツールを、あなたのWebアプリ開発の第一歩として活用してみてください。