モバイル&ワイヤレスブロードバンドでインターネットへ
株価CSVファイルをご用意ください。形式は「"日付(yyyy/mm/dd)","終値","始値","高値","安値"」です。
機械学習を行い、翌営業日予測終値を出力します。
デモ版では、パラメータの変更は不可で、10エポックの実行のみとしています。
モデルの学習構造や計算負荷を制御する、ユーザー入力可能な変数群です。
| 項目名 | 役割と解説 |
|---|---|
| ウィンドウサイズ | 過去何日分のデータを「1つの塊」として学習に使うか。20日なら直近約1ヶ月の動きを参考にします。 |
| LSTMユニット数 | ニューラルネットワークの「脳の大きさ」に相当。第1層、第2層の2段階でパターンを抽出します。 |
| Dropout率 | 学習中の「ノードの間引き」率。過学習(過去データにのみ特化してしまう現象)を防ぎます。 |
| エポック数 | 同じデータセットを何回繰り返し学習させるか。精度と時間のトレードオフを決定します。 |
| 訓練データ比率 | 全データのうち、学習に使う割合(例:80%)。残りの20%は予測が当たっているかの検証に使用。 |
| 学習率 | 計算ごとに重みを調整する歩幅。小さすぎると進まず、大きすぎると最適解を通り過ぎます。 |
学習の結果、どれほどの精度が得られたか、および将来の予測値を出力します。
ブラウザの負荷を軽減するための独自ロジックが組み込まれています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>株価 LSTM 予測システム</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tensorflow/4.10.0/tf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<script>
// ─── バックエンド自動選択(学習開始前に呼ぶ)─────────────────
// バッチサイズ < 32 かつ LSTMユニット最大値 < 128 の場合は CPU を使用
async function selectBackend(batchSz, units1, units2) {
const useCPU = batchSz < 32 && Math.max(units1, units2) < 128;
const backend = useCPU ? 'cpu' : 'webgl';
await tf.setBackend(backend);
await tf.ready();
return backend;
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Zen+Kaku+Gothic+New:wght@300;400;700&family=Share+Tech+Mono&display=swap');
:root {
--bg: #08090d;
--surface: #0f1118;
--surface2: #161923;
--border: #1e2535;
--accent: #00d4ff;
--accent2: #ff6b35;
--accent3: #39ff14;
--text: #c8d6e8;
--text-dim: #4a5a72;
--font-main: 'Zen Kaku Gothic New', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-main);
min-height: 100vh;
overflow-x: hidden;
}
/* Grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,212,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,255,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
}
header {
display: flex;
align-items: baseline;
gap: 16px;
margin-bottom: 8px;
}
h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.05em;
color: #fff;
}
h1 span { color: var(--accent); }
.subtitle {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-dim);
letter-spacing: 0.15em;
margin-bottom: 32px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.backend-badge {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 2px 10px;
border-radius: 2px;
letter-spacing: 0.1em;
border: 1px solid;
}
.backend-badge.cpu { color: #ff6b35; border-color: rgba(255,107,53,0.4); background: rgba(255,107,53,0.08); }
.backend-badge.webgl { color: #39ff14; border-color: rgba(57,255,20,0.4); background: rgba(57,255,20,0.08); }
/* Upload zone */
.upload-zone {
border: 1.5px dashed var(--border);
border-radius: 4px;
padding: 36px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 24px;
position: relative;
}
.upload-zone:hover, .upload-zone.drag {
border-color: var(--accent);
background: rgba(0,212,255,0.04);
}
.upload-zone input[type=file] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.upload-icon {
font-size: 2rem;
margin-bottom: 8px;
color: var(--accent);
}
.upload-label {
font-size: 0.95rem;
color: var(--text-dim);
}
.upload-label strong { color: var(--text); }
/* Config panel */
.config-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.config-item label {
display: block;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.12em;
margin-bottom: 6px;
}
.config-item input, .config-item select {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.9rem;
padding: 8px 12px;
outline: none;
transition: border-color 0.2s;
}
.config-item input:focus, .config-item select:focus {
border-color: var(--accent);
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: transparent;
border: 1.5px solid var(--accent);
color: var(--accent);
font-family: var(--font-mono);
font-size: 0.85rem;
letter-spacing: 0.1em;
padding: 10px 28px;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
margin-bottom: 24px;
}
.btn:hover:not(:disabled) {
background: var(--accent);
color: #000;
box-shadow: 0 0 20px rgba(0,212,255,0.3);
}
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Log */
.log-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text-dim);
height: 130px;
overflow-y: auto;
margin-bottom: 24px;
line-height: 1.7;
}
.log-box .ok { color: var(--accent3); }
.log-box .warn { color: var(--accent2); }
.log-box .info { color: var(--accent); }
/* Stats row */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
}
.stat-card .stat-label {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--text-dim);
letter-spacing: 0.12em;
margin-bottom: 6px;
}
.stat-card .stat-value {
font-family: var(--font-mono);
font-size: 1.35rem;
color: #fff;
}
.stat-card .stat-value.accent { color: var(--accent); }
.stat-card .stat-value.accent2 { color: var(--accent2); }
.stat-card .stat-value.accent3 { color: var(--accent3); }
/* Canvas charts */
.charts-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.chart-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
}
.chart-title {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0.15em;
margin-bottom: 14px;
}
canvas {
width: 100% !important;
display: block;
}
/* Progress bar */
.progress-wrap {
height: 3px;
background: var(--border);
border-radius: 2px;
margin-bottom: 24px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent3));
transition: width 0.3s;
width: 0%;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>株価 <span>LSTM</span> 予測</h1>
</header>
<div class="subtitle">
<span class="backend-badge" id="backendBadge">BACKEND: —</span>
</div>
<!-- Upload -->
<div class="upload-zone" id="uploadZone">
<input type="file" id="csvFile">
<div class="upload-icon">📂</div>
<div class="upload-label"><strong>CSVファイルをドロップ</strong>またはクリックして選択</div>
<div class="upload-label" style="margin-top:6px;font-size:0.75rem;">
形式: "日付","終値","始値","高値","安値"
</div>
</div>
<!-- Config -->
<div class="config-panel">
<div class="config-item">
<label>ウィンドウサイズ(日)</label>
<input type="number" id="windowSize" value="20" min="5" max="60">
</div>
<div class="config-item">
<label>LSTMユニット数(第1層)</label>
<input type="number" id="lstmUnits1" value="64" min="16" max="256">
</div>
<div class="config-item">
<label>LSTMユニット数(第2層)</label>
<input type="number" id="lstmUnits2" value="32" min="8" max="128">
</div>
<div class="config-item">
<label>Dropout率</label>
<input type="number" id="dropoutRate" value="0.2" min="0" max="0.5" step="0.05">
</div>
<div class="config-item">
<label>エポック数</label>
<input type="number" id="epochs" value="100" min="10" max="500">
</div>
<div class="config-item">
<label>バッチサイズ</label>
<input type="number" id="batchSize" value="16" min="8" max="128">
</div>
<div class="config-item">
<label>訓練データ比率 (%)</label>
<input type="number" id="trainRatio" value="80" min="50" max="90">
</div>
<div class="config-item">
<label>学習率</label>
<input type="number" id="learningRate" value="0.001" min="0.0001" max="0.01" step="0.0001">
</div>
</div>
<button class="btn" id="trainBtn" disabled>▶ 学習開始</button>
<!-- Progress -->
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar" id="progressBar"></div>
</div>
<!-- Log -->
<div class="log-box" id="logBox">
<span class="info">[SYSTEM]</span> CSVファイルを読み込んでください...<br>
</div>
<!-- Stats -->
<div class="stats-row" id="statsRow" style="display:none;">
<div class="stat-card">
<div class="stat-label">TOTAL RECORDS</div>
<div class="stat-value accent" id="statRecords">—</div>
</div>
<div class="stat-card">
<div class="stat-label">TRAIN SAMPLES</div>
<div class="stat-value" id="statTrain">—</div>
</div>
<div class="stat-card">
<div class="stat-label">TEST SAMPLES</div>
<div class="stat-value" id="statTest">—</div>
</div>
<div class="stat-card">
<div class="stat-label">BEST LOSS</div>
<div class="stat-value accent3" id="statLoss">—</div>
</div>
<div class="stat-card">
<div class="stat-label">TEST MAE (円)</div>
<div class="stat-value accent2" id="statMae">—</div>
</div>
<div class="stat-card">
<div class="stat-label">DIRECTION ACC.</div>
<div class="stat-value accent" id="statAcc">—</div>
</div>
</div>
<!-- Charts -->
<div class="charts-grid" id="chartsGrid" style="display:none;">
<div class="chart-box">
<div class="chart-title">── 学習曲線(Loss / MAE)<span id="lossEpochLabel" style="margin-left:12px;color:var(--accent);"></span></div>
<canvas id="lossChart" height="200"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">── 予測 vs 実測(テストデータ)</div>
<canvas id="predChart" height="220"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">── 翌日予測値(直近20日+予測)</div>
<canvas id="nextChart" height="160"></canvas>
</div>
</div>
</div>
<script>
// ─── グローバル状態
let rawData = [];
let trainMin, trainMax;
// ─── ログ出力
function log(msg, type='') {
const box = document.getElementById('logBox');
const span = document.createElement('span');
if (type) span.className = type;
span.textContent = msg;
box.appendChild(span);
box.appendChild(document.createElement('br'));
box.scrollTop = box.scrollHeight;
}
// ─── CSV読み込み
document.getElementById('csvFile').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
log(`[CSV] ${file.name} を読み込み中...`, 'info');
Papa.parse(file, {
skipEmptyLines: true,
complete: result => {
const rows = result.data;
rawData = [];
let skipped = 0;
rows.forEach((row, i) => {
// クォートや空白を除去
const clean = row.map(c => String(c).replace(/["\s,]/g, '').trim());
if (clean.length < 5) { skipped++; return; }
const [date, close, open, high, low] = clean;
const vals = [close, open, high, low].map(v => parseFloat(v.replace(/,/g, '')));
if (vals.some(isNaN)) { skipped++; return; }
rawData.push({ date, close: vals[0], open: vals[1], high: vals[2], low: vals[3] });
});
// 日付昇順にソート
rawData.sort((a, b) => a.date.localeCompare(b.date));
log(`[CSV] ${rawData.length}件読み込み完了 (スキップ: ${skipped})`, 'ok');
log(`[CSV] 期間: ${rawData[0].date} 〜 ${rawData[rawData.length-1].date}`, 'info');
document.getElementById('trainBtn').disabled = false;
},
error: err => log(`[ERROR] ${err.message}`, 'warn')
});
});
// ─── ドラッグ&ドロップ
const zone = document.getElementById('uploadZone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag');
const file = e.dataTransfer.files[0];
if (file) {
document.getElementById('csvFile').files = e.dataTransfer.files;
document.getElementById('csvFile').dispatchEvent(new Event('change'));
}
});
// ─── 学習ボタン
document.getElementById('trainBtn').addEventListener('click', runTraining);
async function runTraining() {
if (rawData.length < 50) { log('[ERROR] データが不足しています', 'warn'); return; }
const W = parseInt(document.getElementById('windowSize').value);
const units1 = parseInt(document.getElementById('lstmUnits1').value);
const units2 = parseInt(document.getElementById('lstmUnits2').value);
const dropout = parseFloat(document.getElementById('dropoutRate').value);
const epochsNum = parseInt(document.getElementById('epochs').value);
const batchSz = parseInt(document.getElementById('batchSize').value);
const trainRatio = parseInt(document.getElementById('trainRatio').value) / 100;
const lr = parseFloat(document.getElementById('learningRate').value);
document.getElementById('trainBtn').disabled = true;
document.getElementById('statsRow').style.display = 'grid';
document.getElementById('chartsGrid').style.display = 'block';
document.getElementById('progressWrap').style.display = 'block';
// ── バックエンド自動選択
const backend = await selectBackend(batchSz, units1, units2);
const badge = document.getElementById('backendBadge');
badge.textContent = 'BACKEND: ' + backend.toUpperCase();
badge.className = 'backend-badge ' + (backend === 'cpu' ? 'cpu' : 'webgl');
log(`[BACKEND] ${backend.toUpperCase()} を使用 (batch=${batchSz}, units=${units1}/${units2})`, backend === 'cpu' ? 'warn' : 'ok');
log('──────────────────────────────────', '');
log(`[CONFIG] window=${W}, units=${units1}/${units2}, dropout=${dropout}`, 'info');
log(`[CONFIG] epochs=${epochsNum}, batch=${batchSz}, lr=${lr}`, 'info');
// ── 特徴量抽出 (close, open, high, low)
const allFeatures = rawData.map(d => [d.close, d.open, d.high, d.low]);
const n = allFeatures.length;
const splitIdx = Math.floor(n * trainRatio);
// ── スケーリング(訓練データのmin/maxのみ)
const trainFlat = allFeatures.slice(0, splitIdx).flat();
trainMin = Math.min(...trainFlat);
trainMax = Math.max(...trainFlat);
const scale = v => (v - trainMin) / (trainMax - trainMin);
const unscale = v => v * (trainMax - trainMin) + trainMin;
const scaled = allFeatures.map(row => row.map(scale));
// ── ウィンドウ分割
const X = [], Y = [];
for (let i = W; i < n; i++) {
X.push(scaled.slice(i - W, i));
Y.push(scaled[i][0]);
}
const splitX = Math.floor((splitIdx - W) * 1);
const xTrain = X.slice(0, splitX);
const yTrain = Y.slice(0, splitX);
const xTest = X.slice(splitX);
const yTest = Y.slice(splitX);
log(`[DATA] 訓練: ${xTrain.length}件 / テスト: ${xTest.length}件`, 'ok');
document.getElementById('statRecords').textContent = rawData.length;
document.getElementById('statTrain').textContent = xTrain.length;
document.getElementById('statTest').textContent = xTest.length;
// ── TensorFlow.js テンソル
const xs = tf.tensor3d(xTrain);
const ys = tf.tensor2d(yTrain, [yTrain.length, 1]);
// ── モデル構築
log('[MODEL] LSTM モデルを構築中...', 'info');
const model = tf.sequential();
model.add(tf.layers.lstm({ units: units1, inputShape: [W, 4], returnSequences: true }));
model.add(tf.layers.dropout({ rate: dropout }));
model.add(tf.layers.lstm({ units: units2, returnSequences: false }));
model.add(tf.layers.dropout({ rate: dropout }));
model.add(tf.layers.dense({ units: 1, activation: 'linear' }));
model.compile({ optimizer: tf.train.adam(lr), loss: 'meanSquaredError', metrics: ['mae'] });
model.summary();
log('[MODEL] 構築完了。学習開始...', 'ok');
// ── 学習履歴
const lossHistory = [], maeHistory = [];
let bestLoss = Infinity;
// ── テスト予測&グラフ更新関数(10エポックごとに呼ぶ)
// xTest[i] は rawData[splitIdx + i] の翌日 = rawData[splitIdx + i + 1] を予測する
// → 実測値は rawData.slice(splitIdx + 1) の先頭 xTest.length 件と対応
const actualClose = rawData.slice(splitIdx + 1, splitIdx + 1 + xTest.length).map(d => d.close);
const testDates = rawData.slice(splitIdx + 1, splitIdx + 1 + xTest.length).map(d => d.date);
async function updateCharts(currentEpoch) {
document.getElementById('lossEpochLabel').textContent =
`Epoch ${currentEpoch} / ${epochsNum}`;
drawLossChart(lossHistory, maeHistory);
// テスト予測
const xTestT = tf.tensor3d(xTest);
const predScaled = model.predict(xTestT).dataSync();
const predActual = Array.from(predScaled).map(unscale);
xTestT.dispose();
// 長さを揃えてから描画(念のため短い方に合わせる)
const len = Math.min(predActual.length, actualClose.length);
if (len === 0) return;
const predTrimmed = predActual.slice(0, len);
const actualTrimmed = actualClose.slice(0, len);
const datesTrimmed = testDates.slice(0, len);
drawPredChart(datesTrimmed, actualTrimmed, predTrimmed);
// 翌日予測
const lastWindow = scaled.slice(-W);
const nextInput = tf.tensor3d([lastWindow]);
const nextScaled = model.predict(nextInput).dataSync()[0];
const nextPred = unscale(nextScaled);
nextInput.dispose();
drawNextChart(rawData.slice(-20).map(d => d.close), rawData.slice(-20).map(d => d.date), nextPred);
// MAE
let mae = 0;
for (let i = 0; i < len; i++) mae += Math.abs(predTrimmed[i] - actualTrimmed[i]);
mae /= len;
document.getElementById('statMae').textContent = isNaN(mae) ? '—' : mae.toFixed(0) + '円';
// 方向的中率
let correct = 0;
for (let i = 1; i < len; i++) {
if ((predTrimmed[i] > predTrimmed[i-1]) === (actualTrimmed[i] > actualTrimmed[i-1])) correct++;
}
const acc = len > 1 ? (correct / (len - 1) * 100).toFixed(1) : '—';
document.getElementById('statAcc').textContent = acc === '—' ? '—' : acc + '%';
return nextPred;
}
await model.fit(xs, ys, {
epochs: epochsNum,
batchSize: batchSz,
shuffle: false,
callbacks: {
onEpochEnd: async (epoch, logs) => {
lossHistory.push(logs.loss);
maeHistory.push(logs.mae);
if (logs.loss < bestLoss) bestLoss = logs.loss;
const pct = ((epoch + 1) / epochsNum * 100).toFixed(0);
document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('statLoss').textContent = bestLoss.toFixed(6);
// 10エポックごと(または最終エポック)にグラフ更新
if ((epoch + 1) % 10 === 0 || epoch === epochsNum - 1) {
log(`[EPOCH ${String(epoch+1).padStart(3,'0')}] loss=${logs.loss.toFixed(6)} mae=${logs.mae.toFixed(6)}`, '');
await updateCharts(epoch + 1);
// UIを描画させるために1フレーム待機
await new Promise(r => setTimeout(r, 0));
}
}
}
});
log('[TRAIN] 学習完了!', 'ok');
// 最終予測ログ
const lastWindow = scaled.slice(-W);
const nextInput2 = tf.tensor3d([lastWindow]);
const nextPredFinal = unscale(model.predict(nextInput2).dataSync()[0]);
nextInput2.dispose();
const diff = nextPredFinal - rawData[rawData.length - 1].close;
log(`[NEXT] 翌営業日予測終値: ${nextPredFinal.toFixed(2)}円 (${diff >= 0 ? '+' : ''}${diff.toFixed(2)}円)`, diff >= 0 ? 'ok' : 'warn');
document.getElementById('trainBtn').disabled = false;
xs.dispose(); ys.dispose();
}
// ─── 学習曲線グラフ
function drawLossChart(lossArr, maeArr) {
const canvas = document.getElementById('lossChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * devicePixelRatio;
canvas.height = 200 * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
const W = canvas.offsetWidth, H = 200;
const pad = { t: 20, r: 20, b: 40, l: 60 };
const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;
ctx.fillStyle = '#0f1118';
ctx.fillRect(0, 0, W, H);
const maxLoss = Math.max(...lossArr, ...maeArr);
const minLoss = Math.min(...lossArr, ...maeArr);
const n = lossArr.length;
function toX(i) { return pad.l + (i / (n - 1)) * iw; }
function toY(v) { return pad.t + (1 - (v - minLoss) / (maxLoss - minLoss)) * ih; }
// Grid
ctx.strokeStyle = '#1e2535';
ctx.lineWidth = 0.5;
for (let g = 0; g <= 5; g++) {
const y = pad.t + (g / 5) * ih;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iw, y); ctx.stroke();
const val = maxLoss - (g / 5) * (maxLoss - minLoss);
ctx.fillStyle = '#4a5a72';
ctx.font = '10px Share Tech Mono';
ctx.textAlign = 'right';
ctx.fillText(val.toFixed(5), pad.l - 6, y + 4);
}
// Loss line
function drawLine(arr, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
arr.forEach((v, i) => {
i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v));
});
ctx.stroke();
}
drawLine(lossArr, '#00d4ff');
drawLine(maeArr, '#ff6b35');
// Labels
ctx.fillStyle = '#00d4ff'; ctx.font = '11px Share Tech Mono'; ctx.textAlign = 'left';
ctx.fillText('Loss', pad.l + 8, pad.t + 14);
ctx.fillStyle = '#ff6b35';
ctx.fillText('MAE', pad.l + 60, pad.t + 14);
// X axis labels
ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'center';
const steps = Math.min(10, n);
for (let s = 0; s <= steps; s++) {
const i = Math.round(s / steps * (n - 1));
ctx.fillText(i + 1, toX(i), H - pad.b + 16);
}
ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'center';
ctx.fillText('Epoch', pad.l + iw / 2, H - 6);
}
// ─── 予測 vs 実測グラフ
function drawPredChart(dates, actual, pred) {
const canvas = document.getElementById('predChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * devicePixelRatio;
canvas.height = 220 * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
const W = canvas.offsetWidth, H = 220;
const pad = { t: 20, r: 20, b: 40, l: 80 };
const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;
const n = actual.length;
ctx.fillStyle = '#0f1118';
ctx.fillRect(0, 0, W, H);
const allVals = [...actual, ...pred];
const minV = Math.min(...allVals) * 0.995;
const maxV = Math.max(...allVals) * 1.005;
function toX(i) { return pad.l + (i / (n - 1)) * iw; }
function toY(v) { return pad.t + (1 - (v - minV) / (maxV - minV)) * ih; }
// Grid
ctx.strokeStyle = '#1e2535'; ctx.lineWidth = 0.5;
for (let g = 0; g <= 5; g++) {
const y = pad.t + (g / 5) * ih;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iw, y); ctx.stroke();
const val = maxV - (g / 5) * (maxV - minV);
ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'right';
ctx.fillText(Math.round(val).toLocaleString(), pad.l - 6, y + 4);
}
// Actual fill area
ctx.beginPath();
actual.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
ctx.lineTo(toX(n-1), pad.t + ih); ctx.lineTo(toX(0), pad.t + ih); ctx.closePath();
ctx.fillStyle = 'rgba(0,212,255,0.06)'; ctx.fill();
// Actual line
ctx.beginPath(); ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1.5;
actual.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
ctx.stroke();
// Pred line
ctx.beginPath(); ctx.strokeStyle = '#ff6b35'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
pred.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
ctx.stroke(); ctx.setLineDash([]);
// X axis labels
ctx.fillStyle = '#4a5a72'; ctx.font = '9px Share Tech Mono'; ctx.textAlign = 'center';
const step = Math.max(1, Math.floor(n / 8));
for (let i = 0; i < n; i += step) {
ctx.fillText(dates[i] ? dates[i].slice(2, 10) : '', toX(i), H - pad.b + 14);
}
// Legend
ctx.fillStyle = '#00d4ff'; ctx.font = '11px Share Tech Mono'; ctx.textAlign = 'left';
ctx.fillText('実測値', pad.l + 8, pad.t + 14);
ctx.fillStyle = '#ff6b35';
ctx.fillText('予測値', pad.l + 70, pad.t + 14);
}
// ─── 翌日予測グラフ
function drawNextChart(recent, recentDates, nextVal) {
const canvas = document.getElementById('nextChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * devicePixelRatio;
canvas.height = 160 * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
const W = canvas.offsetWidth, H = 160;
const pad = { t: 20, r: 20, b: 36, l: 80 };
const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;
const allVals = [...recent, nextVal];
const n = allVals.length;
const minV = Math.min(...allVals) * 0.997;
const maxV = Math.max(...allVals) * 1.003;
ctx.fillStyle = '#0f1118'; ctx.fillRect(0, 0, W, H);
function toX(i) { return pad.l + (i / (n - 1)) * iw; }
function toY(v) { return pad.t + (1 - (v - minV) / (maxV - minV)) * ih; }
ctx.strokeStyle = '#1e2535'; ctx.lineWidth = 0.5;
for (let g = 0; g <= 4; g++) {
const y = pad.t + (g / 4) * ih;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iw, y); ctx.stroke();
const val = maxV - (g / 4) * (maxV - minV);
ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'right';
ctx.fillText(Math.round(val).toLocaleString(), pad.l - 6, y + 4);
}
// Divider line before prediction
const divX = toX(recent.length - 1);
ctx.beginPath(); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.moveTo(divX, pad.t); ctx.lineTo(divX, pad.t + ih); ctx.stroke();
ctx.setLineDash([]);
// Recent line
ctx.beginPath(); ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1.8;
recent.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
ctx.stroke();
// Connect to prediction
ctx.beginPath(); ctx.strokeStyle = '#ff6b35'; ctx.lineWidth = 1.8; ctx.setLineDash([5, 3]);
ctx.moveTo(toX(recent.length - 1), toY(recent[recent.length - 1]));
ctx.lineTo(toX(recent.length), toY(nextVal));
ctx.stroke(); ctx.setLineDash([]);
// Prediction dot
ctx.beginPath();
ctx.arc(toX(recent.length), toY(nextVal), 5, 0, Math.PI * 2);
ctx.fillStyle = '#ff6b35'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
// Prediction label
ctx.fillStyle = '#ff6b35'; ctx.font = 'bold 11px Share Tech Mono'; ctx.textAlign = 'left';
ctx.fillText(Math.round(nextVal).toLocaleString() + '円', toX(recent.length) - 36, toY(nextVal) - 10);
// X labels
ctx.fillStyle = '#4a5a72'; ctx.font = '9px Share Tech Mono'; ctx.textAlign = 'center';
const step = Math.max(1, Math.floor(recent.length / 5));
for (let i = 0; i < recent.length; i += step) {
ctx.fillText(recentDates[i] ? recentDates[i].slice(5) : '', toX(i), H - pad.b + 14);
}
ctx.fillStyle = '#ff6b35';
ctx.fillText('予測', toX(recent.length), H - pad.b + 14);
}
</script>
</body>
</html>