VBAのタイマーを「モダンなWebアプリ」にリファクタリングしてみた【ソースコード公開】
業務でちょっとした時間を計りたいとき、Excel VBAでサクッとタイマーを作ったことはありませんか? VBAは手軽で強力ですが、「スマホで見られない」「Macやタブレットで動かない」「見た目が地味」といった悩みもつきものです。
今回は、「Excel VBAで作られたタイマーロジック」を、ブラウザだけで動く「モダンなWebアプリ(SPA)」に完全移植してみました。
単なる翻訳ではなく、Chart.jsによる可視化やPDF出力機能も盛り込んでいます。HTMLファイル1つで動くので、ぜひ試してみてください。
📱 完成イメージ
今回作成したのは、以下の機能を備えたWebタイマーです。
時間設定自由: 分・秒を自由に変更可能。視覚的カウントダウン: 円グラフで残り時間を可視化(Chart.js)。アラーム音: 時間が来たら「ピピピッ」とお知らせ(Web Audio API)。記録の保存: 現在の画面をPDFとしてダウンロード(html2canvas + jsPDF)。完全レスポンシブ: PCでもスマホでも動作可能。🔄 VBAとJavaScriptの考え方の違い
移植にあたり、プロのWeb開発者の視点でロジックを再設計(リファクタリング)しました。
状態管理:
VBAでは「セルの値」を読み書きして時間を管理していましたが、Webアプリでは「JavaScriptのクラス(変数)」で状態を管理し、表示のみをDOMに反映させるMVCに近い設計に変更しました。非同期処理:
VBAの Application.OnTime の代わりに、JavaScriptの setInterval を使用し、ブラウザをブロックせずにスムーズに動作させます。外部ライブラリの活用:グラフ描画やPDF化は、ゼロから作るのではなく優秀なOSSライブラリ(CDN)を活用して実装コストを下げています。💻 ソースコード(コピペOK)
末尾のコードをメモ帳などに貼り付け、timer.html という名前で保存してください。それをブラウザ(ChromeやEdge)で開くだけで動きます。
🛠️ 工夫したポイント
残り時間が少なくなると赤くなるChart.jsの設定を動的に書き換え、残り20%を切ると色が変化する演出を入れました。電子音の生成音声ファイルを用意しなくてもいいように、ブラウザの「Web Audio API」を使って、プログラムで「ピッ」という音を作っています。PDF化の裏側画面上のHTML要素を一度画像(Canvas)に変換し、それをPDFに貼り付ける処理を html2canvas と jsPDF で実現しています。まとめ
VBAは素晴らしいツールですが、Web技術(HTML/CSS/JS)を使えば、OSを問わず配布しやすいリッチなアプリケーションが作れます。 「業務効率化ツールを自作したい!」という方は、ぜひこのコードを改造して遊んでみてください。
以下 コード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configurable Web Timer</title>
<!-- 外部ライブラリ (Chart.js, HTML2Canvas, jsPDF) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
:root {
--primary-color: #007bff;
--danger-color: #dc3545;
--secondary-color: #6c757d;
--bg-color: #f0f2f5;
--input-border: #ced4da;
}
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background-color: var(--bg-color);
color: #333;
}
.container {
background: white;
padding: 2.5rem;
border-radius: 20px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
width: 90%;
}
/* グラフと時間の表示エリア */
#capture-area {
position: relative;
margin-bottom: 20px;
padding: 10px;
background: white;
}
.chart-container {
position: relative;
height: 280px;
width: 280px;
margin: 0 auto;
}
.timer-display {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3.5rem;
font-weight: 700;
color: #2c3e50;
font-feature-settings: "tnum"; /* 等幅数字 */
}
/* 設定エリア */
.settings-area {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 10px;
}
.input-group {
display: flex;
align-items: center;
gap: 5px;
}
input[type="number"] {
width: 60px;
padding: 8px;
font-size: 1.2rem;
text-align: center;
border: 1px solid var(--input-border);
border-radius: 5px;
}
input:disabled {
background-color: #e9ecef;
color: #6c757d;
}
/* コントロールボタン */
.controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
button {
padding: 12px;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
button:active { transform: translateY(1px); box-shadow: none; }
button:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
#btn-start { background-color: var(--primary-color); }
#btn-start:hover { background-color: #0056b3; }
#btn-stop { background-color: var(--danger-color); }
#btn-stop:hover { background-color: #a71d2a; }
#btn-reset { background-color: var(--secondary-color); }
#btn-reset:hover { background-color: #545b62; }
/* PDFボタン */
.pdf-section {
border-top: 2px dashed #e9ecef;
padding-top: 20px;
}
#btn-pdf {
background-color: #28a745;
width: 100%;
}
#btn-pdf:hover { background-color: #218838; }
</style>
</head>
<body>
<div class="container">
<!-- PDF化対象エリア -->
<div id="capture-area">
<h2 style="margin-top:0; color:#444;">Timer</h2>
<div class="chart-container">
<canvas id="timerChart"></canvas>
<div id="timerValue" class="timer-display">05:00</div>
</div>
</div>
<!-- 時間設定エリア -->
<div class="settings-area">
<div class="input-group">
<input type="number" id="set-min" min="0" max="99" value="5">
<label>分</label>
</div>
<div class="input-group">
<input type="number" id="set-sec" min="0" max="59" value="0">
<label>秒</label>
</div>
</div>
<!-- コントロールボタン -->
<div class="controls">
<button id="btn-start">START</button>
<button id="btn-stop">STOP</button>
<button id="btn-reset">RESET</button>
</div>
<!-- PDF出力 -->
<div class="pdf-section">
<button id="btn-pdf">記録をPDFで保存</button>
</div>
</div>
<script>
/**
* TimerApp Class
* ユーザー設定可能なタイマーロジック
*/
class TimerApp {
constructor() {
// DOM要素の取得
this.uiDisplay = document.getElementById('timerValue');
this.inputMin = document.getElementById('set-min');
this.inputSec = document.getElementById('set-sec');
// 状態変数
this.defaultSeconds = this.getInputTotalSeconds(); // 初期設定値
this.remainingSeconds = this.defaultSeconds;
this.timerId = null;
this.isRunning = false;
// Chart.js インスタンス
this.chartInstance = null;
// 初期化
this.initChart();
this.updateDisplay();
this.toggleInputs(true); // 入力可能状態
}
// ==========================================
// 設定値の計算ヘルパー
// ==========================================
getInputTotalSeconds() {
const m = parseInt(this.inputMin.value, 10) || 0;
const s = parseInt(this.inputSec.value, 10) || 0;
return (m * 60) + s;
}
// 入力欄の有効/無効切り替え
toggleInputs(isEnabled) {
this.inputMin.disabled = !isEnabled;
this.inputSec.disabled = !isEnabled;
}
// 設定変更時の反映
applySettings() {
if (this.isRunning) return; // 動作中は無視
this.defaultSeconds = this.getInputTotalSeconds();
this.remainingSeconds = this.defaultSeconds;
this.updateDisplay();
}
// ==========================================
// Chart.js 初期化
// ==========================================
initChart() {
const ctx = document.getElementById('timerChart').getContext('2d');
this.chartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['残り時間', '経過時間'],
datasets: [{
data: [this.remainingSeconds, 0],
backgroundColor: ['#007bff', '#e9ecef'],
borderWidth: 0
}]
},
options: {
responsive: true,
cutout: '85%',
plugins: { legend: { display: false }, tooltip: { enabled: false } },
animation: { duration: 0 }
}
});
}
// ==========================================
// 表示更新 (VBA: Range(TARGET_CELL).Value = ...)
// ==========================================
updateDisplay() {
// MM:SS 形式に変換
const minutes = Math.floor(this.remainingSeconds / 60).toString().padStart(2, '0');
const seconds = (this.remainingSeconds % 60).toString().padStart(2, '0');
this.uiDisplay.textContent = `${minutes}:${seconds}`;
// グラフ更新
if (this.chartInstance) {
const elapsed = Math.max(0, this.defaultSeconds - this.remainingSeconds);
this.chartInstance.data.datasets[0].data = [
this.remainingSeconds,
elapsed
];
// グラフの色を残り時間に応じて変更(残り20%で赤くなる演出)
if (this.remainingSeconds / this.defaultSeconds <= 0.2 && this.remainingSeconds > 0) {
this.chartInstance.data.datasets[0].backgroundColor[0] = '#dc3545'; // 赤
} else {
this.chartInstance.data.datasets[0].backgroundColor[0] = '#007bff'; // 青
}
this.chartInstance.update();
}
}
// ==========================================
// 操作ロジック (Start/Stop/Reset)
// ==========================================
start() {
if (this.isRunning) return;
// 0秒ならリセットしてから開始
if (this.remainingSeconds <= 0) {
this.applySettings(); // 現在の入力値を再適用
}
// 設定値が0の場合は動かさない
if (this.remainingSeconds <= 0) return;
this.isRunning = true;
this.toggleInputs(false); // 入力ロック
// 1秒ごとに減算
this.timerId = setInterval(() => this.tick(), 1000);
}
stop() {
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
this.isRunning = false;
this.toggleInputs(true); // 入力解除
}
reset() {
this.stop();
this.applySettings(); // 入力値に戻す
}
// ==========================================
// タイマー更新 (Tick)
// ==========================================
tick() {
if (this.remainingSeconds > 0) {
this.remainingSeconds--;
this.updateDisplay();
} else {
this.finish();
}
}
// ==========================================
// 終了処理
// ==========================================
finish() {
this.stop();
this.remainingSeconds = 0;
this.updateDisplay();
// 音を鳴らす
this.playAlarmSequence();
// 少し待ってからアラート
setTimeout(() => {
alert("時間になりました!");
}, 500);
}
// ==========================================
// サウンド機能 (Web Audio API)
// ==========================================
playAlarmSequence() {
// 3回鳴らす (ピッ、ピッ、ピッ)
this.beep(0);
setTimeout(() => this.beep(100), 400);
setTimeout(() => this.beep(200), 800);
}
beep(delayVal) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine'; // 正弦波
osc.frequency.value = 880; // ラの音 (少し高め)
// 音量エンベロープ(徐々に消える)
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.3);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.3);
}
}
// ==========================================
// イベントハンドリング
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
const app = new TimerApp();
// ボタン操作
document.getElementById('btn-start').addEventListener('click', () => app.start());
document.getElementById('btn-stop').addEventListener('click', () => app.stop());
document.getElementById('btn-reset').addEventListener('click', () => app.reset());
// 入力変更時に時間を即反映させる
const inputs = [document.getElementById('set-min'), document.getElementById('set-sec')];
inputs.forEach(input => {
input.addEventListener('change', () => app.applySettings());
input.addEventListener('keyup', () => app.applySettings());
});
// PDFダウンロード処理
document.getElementById('btn-pdf').addEventListener('click', async () => {
const btn = document.getElementById('btn-pdf');
btn.disabled = true;
btn.textContent = '生成中...';
document.body.style.cursor = 'wait';
try {
const element = document.getElementById('capture-area');
// html2canvasの設定(高画質化)
const canvas = await html2canvas(element, { scale: 3 });
const imgData = canvas.toDataURL('image/png');
// A4 PDF作成
const { jsPDF } = window.jspdf;
const pdf = new jsPDF('p', 'mm', 'a4');
const imgProps = pdf.getImageProperties(imgData);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;
pdf.addImage(imgData, 'PNG', 0, 30, pdfWidth, pdfHeight);
pdf.save(`timer_result_${new Date().getTime()}.pdf`);
} catch (e) {
alert('PDF作成に失敗しました');
console.error(e);
} finally {
btn.disabled = false;
btn.textContent = '記録をPDFで保存';
document.body.style.cursor = 'default';
}
});
});
</script>
</body>
</html>
