売上分析ツール「Mieru」
【更新履歴】
・2026/2/19 バージョン1.0公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIERU — 売上の流れを読むツール</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500;700&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #1a1410;
--paper: #faf8f4;
--paper2: #f2ede4;
--paper3: #e8e0d0;
--warm: #8b6914;
--gold: #c89b2a;
--green: #2d6a4f;
--green-l: #d8f3dc;
--red: #9b2335;
--red-l: #fde8eb;
--amber: #b5460f;
--amber-l: #fef3e2;
--blue: #1b4f7a;
--blue-l: #dbeafe;
--border: #d4c9b0;
--shadow: rgba(26,20,16,0.08);
--shadow2: rgba(26,20,16,0.16);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--paper);
color: var(--ink);
font-family: 'Noto Sans JP', sans-serif;
font-size: 14px;
min-height: 100vh;
line-height: 1.6;
}
/* ── 和紙テクスチャ風背景 ── */
body::before {
content: '';
position: fixed; inset: 0; z-index: 0;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
}
/* ── ヘッダー ── */
header {
position: sticky; top: 0; z-index: 200;
background: rgba(250,248,244,0.96);
border-bottom: 2px solid var(--ink);
backdrop-filter: blur(8px);
padding: 0 32px;
display: flex; align-items: center; gap: 24px;
height: 56px;
}
.logo {
font-family: 'DM Serif Display', serif;
font-size: 22px;
letter-spacing: 0.04em;
color: var(--ink);
white-space: nowrap;
}
.logo em {
font-style: italic;
color: var(--warm);
}
.header-sub {
font-size: 11px;
color: #888;
letter-spacing: 0.1em;
text-transform: uppercase;
border-left: 1px solid var(--border);
padding-left: 16px;
white-space: nowrap;
}
.header-status {
margin-left: auto;
display: flex; gap: 16px; align-items: center;
}
.status-pill {
display: flex; align-items: center; gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: #888;
}
.signal {
width: 8px; height: 8px; border-radius: 50%;
background: var(--border);
transition: background 0.4s;
}
.signal.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
.signal.amber { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
.signal.red { background: var(--red); box-shadow: 0 0 6px var(--red); }
/* ── レイアウト ── */
.layout {
display: grid;
grid-template-columns: 300px 1fr;
min-height: calc(100vh - 56px);
position: relative; z-index: 1;
}
/* ── サイドパネル ── */
.side {
border-right: 1px solid var(--border);
background: var(--paper2);
padding: 24px 20px;
display: flex; flex-direction: column; gap: 20px;
overflow-y: auto;
}
.side-section {
border-bottom: 1px solid var(--border);
padding-bottom: 20px;
}
.side-section:last-child { border-bottom: none; }
.side-label {
font-size: 10px; letter-spacing: 0.15em;
text-transform: uppercase; color: #999;
margin-bottom: 10px;
font-family: 'JetBrains Mono', monospace;
}
.field-group { display: flex; flex-direction: column; gap: 8px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 11px; color: #666; }
input[type=text], input[type=number], input[type=date], select, textarea {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 10px;
font-family: inherit; font-size: 13px;
color: var(--ink);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
}
input:focus, select:focus, textarea:focus {
border-color: var(--warm);
box-shadow: 0 0 0 2px rgba(139,105,20,0.12);
}
textarea { resize: vertical; min-height: 64px; font-size: 12px; }
.btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 10px 16px; border-radius: 4px;
font-family: inherit; font-size: 13px; font-weight: 500;
cursor: pointer; border: none; width: 100%;
transition: all 0.15s;
}
.btn-ink {
background: var(--ink); color: var(--paper);
border: 2px solid var(--ink);
}
.btn-ink:hover { background: #2e2620; transform: translateY(-1px); box-shadow: 0 4px 12px var(--shadow2); }
.btn-outline {
background: transparent; color: var(--ink);
border: 1px solid var(--border);
}
.btn-outline:hover { border-color: var(--ink); background: var(--paper3); }
.btn-gold {
background: var(--gold); color: #fff;
border: 2px solid var(--gold);
}
.btn-gold:hover { background: var(--warm); box-shadow: 0 4px 12px rgba(200,155,42,0.3); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 20px 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
color: #999; font-size: 13px;
background: var(--paper);
}
.drop-zone:hover, .drop-zone.drag {
border-color: var(--warm);
background: rgba(139,105,20,0.04);
color: var(--warm);
}
.drop-zone input { display: none; }
.drop-icon { font-size: 28px; margin-bottom: 8px; opacity: 0.6; }
/* ── メインエリア ── */
.main {
display: flex; flex-direction: column;
overflow-y: auto;
}
/* ── 状態パネル(上部全幅) ── */
.status-panel {
border-bottom: 1px solid var(--border);
padding: 20px 28px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 24px;
align-items: start;
background: var(--paper);
min-height: 100px;
}
.status-panel.hidden { display: none; }
.traffic-light {
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.light-circle {
width: 52px; height: 52px; border-radius: 50%;
border: 3px solid var(--border);
background: var(--paper3);
display: flex; align-items: center; justify-content: center;
font-size: 28px;
transition: all 0.4s;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.light-circle.good { background: var(--green-l); border-color: var(--green); box-shadow: 0 0 16px rgba(45,106,79,0.25); }
.light-circle.warn { background: var(--amber-l); border-color: var(--amber); box-shadow: 0 0 16px rgba(181,70,15,0.25); }
.light-circle.bad { background: var(--red-l); border-color: var(--red); box-shadow: 0 0 16px rgba(155,35,53,0.25); }
.light-label { font-size: 10px; color: #999; letter-spacing: 0.1em; font-family: 'JetBrains Mono', monospace; }
.status-message {
display: flex; flex-direction: column; justify-content: center; gap: 6px;
}
.status-headline {
font-family: 'DM Serif Display', serif;
font-size: 20px; line-height: 1.3;
}
.status-detail { font-size: 13px; color: #666; line-height: 1.7; }
.kpi-group {
display: flex; flex-direction: column; gap: 8px;
min-width: 160px;
}
.kpi-item {
display: flex; flex-direction: column; gap: 2px;
padding: 10px 14px;
background: var(--paper2);
border: 1px solid var(--border);
border-radius: 6px;
}
.kpi-label { font-size: 10px; color: #999; letter-spacing: 0.08em; text-transform: uppercase; }
.kpi-val {
font-family: 'JetBrains Mono', monospace;
font-size: 18px; font-weight: 700;
color: var(--ink);
}
.kpi-val.up { color: var(--green); }
.kpi-val.down { color: var(--red); }
.kpi-val.flat { color: var(--warm); }
.kpi-sub { font-size: 11px; color: #aaa; }
/* ── タブ ── */
.tab-bar {
display: flex; gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 28px;
background: var(--paper);
position: sticky; top: 56px; z-index: 100;
}
.tab {
padding: 12px 18px;
font-size: 12px; letter-spacing: 0.06em;
cursor: pointer; color: #999;
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
white-space: nowrap;
}
.tab:hover { color: var(--ink); }
.tab.active { color: var(--warm); border-bottom-color: var(--warm); }
/* ── タブコンテンツ ── */
.tab-content { display: none; padding: 24px 28px; }
.tab-content.active { display: block; }
/* ── カード ── */
.card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card-title {
font-family: 'DM Serif Display', serif;
font-size: 15px;
margin-bottom: 4px;
display: flex; align-items: center; gap: 8px;
}
.card-sub { font-size: 12px; color: #999; margin-bottom: 16px; }
.card canvas { max-height: 280px; }
.card canvas.tall { max-height: 360px; }
/* ── インサイトカード ── */
.insight-list { display: flex; flex-direction: column; gap: 10px; }
.insight {
display: flex; gap: 12px;
padding: 14px 16px;
border-radius: 6px;
border-left: 4px solid var(--border);
background: var(--paper2);
animation: slideIn 0.3s ease;
}
@keyframes slideIn { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform: translateY(0); } }
.insight.good { border-left-color: var(--green); background: var(--green-l); }
.insight.warn { border-left-color: var(--amber); background: var(--amber-l); }
.insight.bad { border-left-color: var(--red); background: var(--red-l); }
.insight.info { border-left-color: var(--blue); background: var(--blue-l); }
.insight-icon { font-size: 20px; flex-shrink: 0; }
.insight-body {}
.insight-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.insight-desc { font-size: 13px; color: #555; line-height: 1.6; }
/* ── 達成確率バー ── */
.goal-section { margin-top: 16px; }
.goal-bar-wrap {
background: var(--paper3);
border-radius: 20px; height: 28px;
overflow: hidden; position: relative;
border: 1px solid var(--border);
}
.goal-bar-fill {
height: 100%; border-radius: 20px;
transition: width 1s cubic-bezier(0.4,0,0.2,1);
display: flex; align-items: center;
padding-left: 14px;
font-size: 12px; font-weight: 700; color: #fff;
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
}
.goal-bar-label {
display: flex; justify-content: space-between;
font-size: 12px; color: #666; margin-bottom: 6px;
}
/* ── 季節性グリッド ── */
.season-grid {
display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px;
margin-top: 12px;
}
.season-cell {
aspect-ratio: 1;
border-radius: 4px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-size: 9px; color: #888;
transition: all 0.2s;
cursor: default;
border: 1px solid transparent;
}
.season-cell:hover { transform: scale(1.1); z-index: 1; }
.season-cell .month-name { font-weight: 700; font-size: 10px; }
.season-cell .month-val { font-size: 8px; font-family: 'JetBrains Mono', monospace; margin-top: 1px; }
/* ── 異常値テーブル ── */
.anomaly-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.anomaly-table th {
font-size: 10px; letter-spacing: 0.1em; color: #999;
text-align: left; padding: 8px 12px;
border-bottom: 2px solid var(--border);
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
}
.anomaly-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.anomaly-table tr:hover td { background: var(--paper2); }
.anomaly-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 12px;
font-size: 11px; font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.anomaly-badge.high { background: var(--red-l); color: var(--red); }
.anomaly-badge.low { background: var(--blue-l); color: var(--blue); }
/* ── ログ ── */
#log {
font-family: 'JetBrains Mono', monospace;
font-size: 10px; color: #999;
max-height: 60px; overflow-y: auto;
background: var(--paper);
border: 1px solid var(--border);
border-radius: 4px; padding: 6px 8px;
}
#log div { margin-bottom: 1px; }
#log .ok { color: var(--green); }
#log .err { color: var(--red); }
#log .info { color: var(--blue); }
/* ── ローディング ── */
#loading {
display: none; position: fixed; inset: 0;
background: rgba(250,248,244,0.85);
z-index: 999; align-items: center; justify-content: center;
flex-direction: column; gap: 12px;
backdrop-filter: blur(4px);
}
#loading.show { display: flex; }
.loader {
width: 36px; height: 36px;
border: 3px solid var(--border);
border-top-color: var(--warm);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── 空状態 ── */
.empty-state {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
min-height: 300px; color: #bbb; gap: 12px;
font-size: 14px; text-align: center;
}
.empty-icon { font-size: 48px; opacity: 0.4; }
/* グリッド */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
/* 目標入力バー */
.goal-input-row {
display: flex; gap: 8px; align-items: flex-end;
padding: 14px 16px;
background: var(--paper2); border-radius: 6px;
border: 1px solid var(--border); margin-bottom: 16px;
}
.goal-input-row .field { flex: 1; }
.goal-input-row .btn { width: auto; padding: 8px 16px; flex-shrink: 0; align-self: flex-end; }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.side { border-right: none; border-bottom: 1px solid var(--border); }
.grid-2 { grid-template-columns: 1fr; }
.status-panel { grid-template-columns: auto 1fr; }
.kpi-group { display: none; }
}
</style>
</head>
<body>
<div id="loading">
<div class="loader"></div>
<div style="font-size:13px;color:#888">分析中...</div>
</div>
<header>
<div class="logo"><em>MIERU</em></div>
<div class="header-sub">売上の流れを読むツール</div>
<div class="header-status">
<div class="status-pill">
<div class="signal" id="sig-main"></div>
<span id="sig-label">未読み込み</span>
</div>
</div>
</header>
<div class="layout">
<!-- ── サイドパネル ── -->
<div class="side">
<div class="side-section">
<div class="side-label">データを読み込む</div>
<div class="field-group">
<div class="field">
<label>データの名前(任意)</label>
<input type="text" id="dataName" placeholder="例: A店 月次売上" value="">
</div>
<div class="field">
<label>単位(任意)</label>
<input type="text" id="dataUnit" placeholder="例: 万円、件、個" value="万円">
</div>
</div>
</div>
<div class="side-section">
<div class="side-label">CSVファイル</div>
<div class="drop-zone" id="dropZone"
onclick="document.getElementById('csvFile').click()"
ondragover="onDragOver(event)" ondrop="onDrop(event)">
<input type="file" id="csvFile" accept=".csv" onchange="loadCSV(this)">
<div class="drop-icon">📂</div>
<div style="font-weight:500">ここにCSVをドロップ</div>
<div style="font-size:11px;margin-top:4px">または クリックして選択</div>
<div style="font-size:10px;margin-top:8px;color:#bbb;line-height:1.6">
1列目: 日付 (2024-01など)<br>
2列目: 数値
</div>
</div>
</div>
<div class="side-section">
<div class="side-label">手入力 / 貼り付け</div>
<div class="field">
<label>「日付,数値」を1行ずつ入力</label>
<textarea id="manualInput" placeholder="2024-01,1200 2024-02,1350 2024-03,980 …"></textarea>
</div>
<button class="btn btn-outline" style="margin-top:8px" onclick="loadManual()">
読み込む
</button>
</div>
<div class="side-section">
<div class="side-label">目標設定</div>
<div class="field">
<label>今期の目標値</label>
<input type="number" id="goalValue" placeholder="例: 15000">
</div>
<div class="field" style="margin-top:8px">
<label>目標期限(最終月)</label>
<input type="text" id="goalDeadline" placeholder="例: 2025-03">
</div>
<button class="btn btn-gold" style="margin-top:10px" onclick="updateGoal()">
目標を設定
</button>
</div>
<div class="side-section">
<div class="side-label">サンプルデータ</div>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="btn btn-outline" onclick="loadSample('retail')">🏪 小売店 月次売上</button>
<button class="btn btn-outline" onclick="loadSample('saas')">💻 サービス 月次契約数</button>
<button class="btn btn-outline" onclick="loadSample('restaurant')">🍜 飲食店 週次売上</button>
</div>
</div>
<div class="side-section">
<div class="side-label">ログ</div>
<div id="log"></div>
</div>
</div>
<!-- ── メインエリア ── -->
<div class="main">
<!-- 状態パネル -->
<div class="status-panel hidden" id="statusPanel">
<div class="traffic-light">
<div class="light-circle" id="lightCircle">─</div>
<div class="light-label" id="lightLabel">状態</div>
</div>
<div class="status-message">
<div class="status-headline" id="statusHeadline">─</div>
<div class="status-detail" id="statusDetail">─</div>
</div>
<div class="kpi-group" id="kpiGroup"></div>
</div>
<!-- タブバー -->
<div class="tab-bar" id="main-tab-bar">
<div class="tab active" onclick="switchTab('overview',this)">全体の流れ</div>
<div class="tab" onclick="switchTab('insight',this)">気づき・アドバイス</div>
<div class="tab" onclick="switchTab('season',this)">季節・周期パターン</div>
<div class="tab" onclick="switchTab('goal',this)">目標達成シミュレーション</div>
<div class="tab" onclick="switchTab('anomaly',this)">突出している日・月</div>
</div>
<!-- 全体の流れ -->
<div class="tab-content active" id="tab-overview">
<div class="empty-state" id="empty-overview">
<div class="empty-icon">📈</div>
<div>CSVを読み込むか、サンプルデータを選んでください</div>
<div style="font-size:12px;color:#ddd">売上の流れを自動で分析します</div>
</div>
<div id="overview-content" style="display:none">
<div class="card">
<div class="card-title">📊 売上の推移</div>
<div class="card-sub" id="trend-sub">─</div>
<canvas id="chart-main" class="tall"></canvas>
</div>
<div class="grid-2">
<div class="card">
<div class="card-title">📉 変動の大きさ(週・月ごと)</div>
<div class="card-sub">棒の幅が大きいほど、その期間の振れ幅が大きい</div>
<canvas id="chart-band"></canvas>
</div>
<div class="card">
<div class="card-title">🔄 前期比(どのくらい変わったか)</div>
<div class="card-sub">プラスが上昇、マイナスが下落</div>
<canvas id="chart-change"></canvas>
</div>
</div>
</div>
</div>
<!-- 気づき・アドバイス -->
<div class="tab-content" id="tab-insight">
<div class="empty-state" id="empty-insight">
<div class="empty-icon">💡</div>
<div>データを読み込むと、気づきが自動で表示されます</div>
</div>
<div id="insight-content" style="display:none">
<div class="card">
<div class="card-title">💡 自動で見つかった気づき</div>
<div class="card-sub">専門用語なしで、今の状況を説明します</div>
<div class="insight-list" id="insight-list"></div>
</div>
</div>
</div>
<!-- 季節・周期パターン -->
<div class="tab-content" id="tab-season">
<div class="empty-state" id="empty-season">
<div class="empty-icon">📅</div>
<div>12ヶ月以上のデータがあると季節パターンが見えます</div>
</div>
<div id="season-content" style="display:none">
<div class="card">
<div class="card-title">🗓️ 月ごとの傾向(ヒートマップ)</div>
<div class="card-sub">色が濃いほど平均が高い月。毎年同じ傾向があるかわかります</div>
<div class="season-grid" id="season-grid"></div>
<div style="display:flex;gap:12px;margin-top:12px;font-size:11px;color:#999;align-items:center">
<div style="display:flex;align-items:center;gap:4px">
<div style="width:14px;height:14px;background:var(--green);border-radius:2px;opacity:0.8"></div>高め
</div>
<div style="display:flex;align-items:center;gap:4px">
<div style="width:14px;height:14px;background:var(--paper3);border-radius:2px;border:1px solid var(--border)"></div>平均的
</div>
<div style="display:flex;align-items:center;gap:4px">
<div style="width:14px;height:14px;background:var(--red);border-radius:2px;opacity:0.6"></div>低め
</div>
</div>
</div>
<div class="card">
<div class="card-title">📊 月別 平均値(12ヶ月の比較)</div>
<div class="card-sub" id="season-sub">どの月が強く、どの月が弱いか</div>
<canvas id="chart-season"></canvas>
</div>
</div>
</div>
<!-- 目標達成シミュレーション -->
<div class="tab-content" id="tab-goal">
<div class="empty-state" id="empty-goal">
<div class="empty-icon">🎯</div>
<div>左のパネルで目標値を設定してください</div>
</div>
<div id="goal-content" style="display:none">
<div class="card" id="goal-card">
<div class="card-title">🎯 目標達成シミュレーション</div>
<div class="card-sub" id="goal-desc">─</div>
<div class="goal-section" id="goal-bar-section"></div>
<canvas id="chart-goal" class="tall" style="margin-top:20px"></canvas>
</div>
</div>
</div>
<!-- 突出している日・月 -->
<div class="tab-content" id="tab-anomaly">
<div class="empty-state" id="empty-anomaly">
<div class="empty-icon">🔍</div>
<div>データを読み込むと、突出した値が自動検出されます</div>
</div>
<div id="anomaly-content" style="display:none">
<div class="card">
<div class="card-title">🔍 平均から大きくはずれた期間</div>
<div class="card-sub">「なぜこの時期だけ高い(または低い)のか?」を振り返るヒントにどうぞ</div>
<div style="margin-top:8px" id="anomaly-comment"></div>
<table class="anomaly-table" style="margin-top:16px">
<thead><tr>
<th>時期</th><th>数値</th><th>平均との差</th><th>判定</th><th>考えられる理由</th>
</tr></thead>
<tbody id="anomaly-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
// ── グローバル状態 ──────────────────────────────────────────
let rawData = []; // { date:string, value:number }
let dataName = '';
let dataUnit = '万円';
let goalValue = null;
let goalDeadline = null;
let charts = {};
// ── ログ ──────────────────────────────────────────────────
function log(msg, type='info') {
const el = document.getElementById('log');
const d = document.createElement('div');
d.className = type;
d.textContent = '> ' + msg;
el.appendChild(d);
el.scrollTop = el.scrollHeight;
}
// ── ユーティリティ ─────────────────────────────────────────
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
function std(arr) {
const m = avg(arr);
return Math.sqrt(arr.reduce((a,b)=>a+(b-m)**2,0)/arr.length);
}
function fmtNum(n, dec=0) {
if (n == null || isNaN(n)) return '─';
return n.toFixed(dec).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function fmtPct(n) {
if (n == null || isNaN(n)) return '─';
return (n>=0?'+':'')+n.toFixed(1)+'%';
}
function ma(arr, n) {
return arr.map((_,i) => i<n-1 ? null : avg(arr.slice(i-n+1,i+1)));
}
function destroyChart(id) {
if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}
// Chart.js デフォルト スタイル
const CHART_DEFAULTS = {
responsive: true,
maintainAspectRatio: true,
animation: { duration: 600, easing: 'easeOutQuart' },
plugins: {
legend: {
labels: { color: '#888', font: { family: "'Noto Sans JP'", size: 11 }, boxWidth: 14 }
},
tooltip: {
backgroundColor: 'rgba(26,20,16,0.92)',
borderColor: '#d4c9b0', borderWidth: 1,
titleColor: '#faf8f4', bodyColor: '#d4c9b0',
titleFont: { family: "'JetBrains Mono'", size: 11 },
bodyFont: { family: "'JetBrains Mono'", size: 11 },
padding: 10,
}
},
scales: {
x: {
ticks: { color: '#aaa', font: { family: "'JetBrains Mono'", size: 10 }, maxTicksLimit: 14 },
grid: { color: 'rgba(212,201,176,0.3)' },
border: { color: '#d4c9b0' }
},
y: {
ticks: { color: '#aaa', font: { family: "'JetBrains Mono'", size: 10 } },
grid: { color: 'rgba(212,201,176,0.3)' },
border: { color: '#d4c9b0' }
}
}
};
function chartOpts(extra={}) {
return JSON.parse(JSON.stringify({...CHART_DEFAULTS, ...extra}));
}
// ── CSV読み込み ────────────────────────────────────────────
function onDragOver(e) { e.preventDefault(); document.getElementById('dropZone').classList.add('drag'); }
function onDrop(e) {
e.preventDefault();
document.getElementById('dropZone').classList.remove('drag');
const f = e.dataTransfer.files[0];
if (f) parseCSVFile(f);
}
function loadCSV(input) {
if (input.files[0]) parseCSVFile(input.files[0]);
}
function parseCSVFile(file) {
const reader = new FileReader();
reader.onload = e => {
try {
parseCSVText(e.target.result, file.name.replace('.csv',''));
} catch(err) { log('CSV解析エラー: ' + err.message, 'err'); }
};
reader.readAsText(file, 'UTF-8');
}
function parseCSVText(text, name='') {
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
// ヘッダー行の自動検出
let startRow = 0;
const firstCells = lines[0].split(',').map(c => c.trim().replace(/^["']|["']$/g,''));
if (isNaN(parseFloat(firstCells[1]))) startRow = 1;
const rows = [];
for (let i = startRow; i < lines.length; i++) {
const cells = lines[i].split(',').map(c => c.trim().replace(/^["']|["']$/g,''));
if (cells.length < 2) continue;
const v = parseFloat(cells[1].replace(/,/g,''));
if (!cells[0] || isNaN(v)) continue;
rows.push({ date: cells[0], value: v });
}
if (rows.length < 3) throw new Error('有効なデータが3件以上必要です');
rows.sort((a,b) => a.date.localeCompare(b.date));
rawData = rows;
dataName = document.getElementById('dataName').value || name || 'データ';
dataUnit = document.getElementById('dataUnit').value || '万円';
log(`読み込み完了: ${rows.length}件 (${rows[0].date} ─ ${rows[rows.length-1].date})`, 'ok');
runAll();
}
// ── 手入力読み込み ─────────────────────────────────────────
function loadManual() {
const text = document.getElementById('manualInput').value.trim();
if (!text) { log('データを入力してください', 'err'); return; }
try {
parseCSVText(text, document.getElementById('dataName').value || '手入力データ');
} catch(e) { log(e.message, 'err'); }
}
// ── サンプルデータ ─────────────────────────────────────────
function loadSample(type) {
const samples = {
retail: {
name: '小売店 月次売上',
unit: '万円',
goal: 85000,
deadline: '2025-12',
rows: generateSampleRetail()
},
saas: {
name: 'SaaS 月次契約数',
unit: '件',
goal: 500,
deadline: '2025-06',
rows: generateSampleSaaS()
},
restaurant: {
name: '飲食店 週次売上',
unit: '万円',
goal: null,
deadline: null,
rows: generateSampleRestaurant()
}
};
const s = samples[type];
document.getElementById('dataName').value = s.name;
document.getElementById('dataUnit').value = s.unit;
if (s.goal) {
document.getElementById('goalValue').value = s.goal;
document.getElementById('goalDeadline').value = s.deadline || '';
goalValue = s.goal;
goalDeadline = s.deadline;
}
rawData = s.rows;
dataName = s.name;
dataUnit = s.unit;
log(`サンプル読み込み: ${s.name} ${s.rows.length}件`, 'ok');
runAll();
}
function generateSampleRetail() {
const rows = [];
const base = 5800;
// 2年分 月次
const seasonality = [0.88,0.85,0.92,0.95,1.0,0.97,1.12,1.08,1.0,1.05,1.18,1.35];
let trend = 1.0;
for (let y = 2023; y <= 2025; y++) {
for (let m = 1; m <= 12; m++) {
if (y === 2025 && m > 5) break;
trend += 0.008 + (Math.random()-0.4)*0.005;
const s = seasonality[m-1];
const noise = 1 + (Math.random()-0.5)*0.08;
// 2024年1月は特別イベントで突出
const special = (y===2024 && m===1) ? 1.25 : 1.0;
// 2023年8月は改装で低下
const dip = (y===2023 && m===8) ? 0.65 : 1.0;
const v = Math.round(base * trend * s * noise * special * dip);
rows.push({ date: `${y}-${String(m).padStart(2,'0')}`, value: v });
}
}
return rows;
}
function generateSampleSaaS() {
const rows = [];
let cnt = 120;
for (let y = 2023; y <= 2025; y++) {
for (let m = 1; m <= 12; m++) {
if (y === 2025 && m > 5) break;
cnt += Math.round(8 + Math.random()*12 - (Math.random()>0.85 ? cnt*0.04 : 0));
cnt = Math.max(100, cnt);
rows.push({ date: `${y}-${String(m).padStart(2,'0')}`, value: cnt });
}
}
return rows;
}
function generateSampleRestaurant() {
const rows = [];
const base = 420;
let d = new Date('2023-10-02');
for (let w = 0; w < 78; w++) {
const mo = d.getMonth();
const seasonBonus = [0.85,0.8,0.9,0.95,1.0,0.92,1.1,1.05,1.0,1.02,1.08,1.2][mo];
const noise = 1 + (Math.random()-0.5)*0.12;
const trend = 1 + w*0.003;
const val = Math.round(base * seasonBonus * noise * trend);
const ds = d.toISOString().slice(0,10);
rows.push({ date: ds, value: val });
d.setDate(d.getDate() + 7);
}
return rows;
}
// ── 目標更新 ──────────────────────────────────────────────
function updateGoal() {
const gv = parseFloat(document.getElementById('goalValue').value);
const gd = document.getElementById('goalDeadline').value.trim();
if (isNaN(gv)) { log('目標値を数値で入力してください', 'err'); return; }
goalValue = gv;
goalDeadline = gd || null;
log(`目標設定: ${fmtNum(goalValue)} ${dataUnit}`, 'ok');
if (rawData.length) {
buildGoalTab();
buildInsights();
}
}
// ── 全体実行 ───────────────────────────────────────────────
function runAll() {
document.getElementById('loading').classList.add('show');
setTimeout(() => {
try {
updateStatusPanel();
buildOverviewTab();
buildInsights();
buildSeasonTab();
buildGoalTab();
buildAnomalyTab();
showAllContent();
} catch(e) {
log('分析エラー: ' + e.message, 'err');
console.error(e);
}
document.getElementById('loading').classList.remove('show');
}, 50);
}
// ── コンテンツ表示切り替え ─────────────────────────────────
function showAllContent() {
['overview','insight','season','goal','anomaly'].forEach(tab => {
document.getElementById('empty-' + tab).style.display = 'none';
document.getElementById(tab + '-content').style.display = 'block';
});
// goalは条件つき
if (!goalValue) {
document.getElementById('empty-goal').style.display = 'flex';
document.getElementById('goal-content').style.display = 'none';
}
// seasonは12件以上
const months = getMonthlyStats();
if (Object.keys(months).length < 6) {
document.getElementById('empty-season').style.display = 'flex';
document.getElementById('season-content').style.display = 'none';
}
}
// ── 月別統計 ──────────────────────────────────────────────
function getMonthlyStats() {
const byMonth = {};
rawData.forEach(d => {
const m = d.date.slice(0,7);
if (!byMonth[m]) byMonth[m] = [];
byMonth[m].push(d.value);
});
return byMonth;
}
function getMonthOfYearStats() {
// 1〜12月ごとの平均
const byMoy = {};
rawData.forEach(d => {
const parts = d.date.split('-');
const moy = parseInt(parts[1]);
if (!byMoy[moy]) byMoy[moy] = [];
byMoy[moy].push(d.value);
});
return byMoy;
}
// ── 状態パネル ────────────────────────────────────────────
function updateStatusPanel() {
document.getElementById('statusPanel').classList.remove('hidden');
const vals = rawData.map(d => d.value);
const n = vals.length;
const last = vals[n-1];
const prev = vals[n-2];
const recentN = Math.min(6, Math.floor(n/3));
const recent = vals.slice(-recentN);
const older = vals.slice(-recentN*2, -recentN);
const recentAvg = avg(recent);
const olderAvg = older.length ? avg(older) : recentAvg;
const trend = (recentAvg - olderAvg) / olderAvg * 100;
const chg = prev ? (last - prev) / prev * 100 : 0;
const volatility = std(vals.map((_,i) => i>0? (vals[i]-vals[i-1])/vals[i-1]*100 : 0).slice(1));
let state, emoji, headline, detail, sigClass;
if (trend > 5) {
state='good'; emoji='📈'; sigClass='green';
headline = '売上は上昇基調です';
detail = `直近${recentN}期間の平均が、その前より ${fmtPct(trend)} 高くなっています。この調子が続けば順調です。`;
} else if (trend > -3) {
state='warn'; emoji='➡️'; sigClass='amber';
headline = '売上は横ばいで推移中';
detail = `直近の平均と以前の平均はほぼ同じ水準(差: ${fmtPct(trend)})。底固めの時期か、次の変化の予兆かもしれません。`;
} else {
state='bad'; emoji='📉'; sigClass='red';
headline = '売上が下落傾向にあります';
detail = `直近${recentN}期間の平均が、その前より ${fmtPct(trend)} 下がっています。早めに原因を確認することをお勧めします。`;
}
const circle = document.getElementById('lightCircle');
circle.className = 'light-circle ' + state;
circle.textContent = emoji;
document.getElementById('lightLabel').textContent = { good:'順調', warn:'横ばい', bad:'要注意' }[state];
document.getElementById('statusHeadline').textContent = headline;
document.getElementById('statusDetail').textContent = detail;
const sig = document.getElementById('sig-main');
sig.className = 'signal ' + sigClass;
document.getElementById('sig-label').textContent = dataName || '分析済み';
// KPI
const totalAvg = avg(vals);
const growthOverall = ((last - vals[0]) / vals[0] * 100);
document.getElementById('kpiGroup').innerHTML = `
<div class="kpi-item">
<div class="kpi-label">最新の値</div>
<div class="kpi-val">${fmtNum(last)}</div>
<div class="kpi-sub">${dataUnit}</div>
</div>
<div class="kpi-item">
<div class="kpi-label">前回比</div>
<div class="kpi-val ${chg>=0?'up':'down'}">${fmtPct(chg)}</div>
<div class="kpi-sub">直近2期間</div>
</div>
<div class="kpi-item">
<div class="kpi-label">全体傾向</div>
<div class="kpi-val ${growthOverall>=0?'up':'down'}">${fmtPct(growthOverall)}</div>
<div class="kpi-sub">初回から現在</div>
</div>
`;
}
// ── 全体の流れタブ ─────────────────────────────────────────
function buildOverviewTab() {
const labels = rawData.map(d => d.date);
const vals = rawData.map(d => d.value);
const n = vals.length;
const maN = Math.min(Math.max(3, Math.floor(n/5)), 12);
const maVals = ma(vals, maN);
// トレンドの方向を説明
const chgs = vals.slice(1).map((v,i) => (v-vals[i])/vals[i]*100);
const recentChg = avg(chgs.slice(-Math.min(6,n-1)));
document.getElementById('trend-sub').textContent =
`${dataName} の全期間。直近の平均前期比: ${fmtPct(recentChg)}。` +
(Math.abs(recentChg)>3 ? (recentChg>0?'上昇傾向が続いています。':'下落傾向が続いています。') : '横ばいで推移しています。');
// メインチャート: 面グラフ + MA
destroyChart('main');
const ctxMain = document.getElementById('chart-main').getContext('2d');
const gradient = ctxMain.createLinearGradient(0, 0, 0, 280);
gradient.addColorStop(0, 'rgba(139,105,20,0.18)');
gradient.addColorStop(1, 'rgba(139,105,20,0.01)');
charts['main'] = new Chart(ctxMain, {
type: 'line',
data: {
labels,
datasets: [
{
label: dataName,
data: vals,
borderColor: '#8b6914', borderWidth: 2,
backgroundColor: gradient,
pointRadius: n > 60 ? 0 : 3,
pointBackgroundColor: '#8b6914',
fill: true, tension: 0.3, order: 2
},
{
label: `${maN}期移動平均(流れ)`,
data: maVals,
borderColor: '#c89b2a', borderWidth: 2.5,
borderDash: [5,4],
pointRadius: 0, fill: false, tension: 0.3, order: 1
}
]
},
options: {
...chartOpts(),
plugins: {
...CHART_DEFAULTS.plugins,
tooltip: {
...CHART_DEFAULTS.plugins.tooltip,
callbacks: {
label: ctx => `${ctx.dataset.label}: ${fmtNum(ctx.raw)} ${dataUnit}`
}
}
}
}
});
// 帯グラフ(変動幅)
// 月ごとにmax-min幅を計算
const byPeriod = {};
rawData.forEach(d => {
const key = d.date.slice(0,7);
if (!byPeriod[key]) byPeriod[key] = { vals:[], avg:0 };
byPeriod[key].vals.push(d.value);
});
const bandLabels = Object.keys(byPeriod).sort();
// データが月単位の場合: 自身の値を使う
const bandAvgs = bandLabels.map(k => avg(byPeriod[k].vals));
const bandMax = bandLabels.map(k => Math.max(...byPeriod[k].vals));
const bandMin = bandLabels.map(k => Math.min(...byPeriod[k].vals));
destroyChart('band');
const ctxBand = document.getElementById('chart-band').getContext('2d');
charts['band'] = new Chart(ctxBand, {
type: 'bar',
data: {
labels: bandLabels,
datasets: [
{
label: '平均',
data: bandAvgs,
backgroundColor: bandAvgs.map(v => {
const overallAvg = avg(bandAvgs);
return v > overallAvg * 1.1 ? 'rgba(45,106,79,0.7)'
: v < overallAvg * 0.9 ? 'rgba(155,35,53,0.6)'
: 'rgba(139,105,20,0.6)';
}),
borderRadius: 3, borderWidth: 0
}
]
},
options: {
...chartOpts(),
plugins: {
...CHART_DEFAULTS.plugins,
tooltip: {
...CHART_DEFAULTS.plugins.tooltip,
callbacks: {
label: ctx => `${fmtNum(ctx.raw)} ${dataUnit}`
}
}
}
}
});
// 前期比チャート
const changeVals = vals.slice(1).map((v,i) => (v - vals[i]) / vals[i] * 100);
const changeLabels = labels.slice(1);
destroyChart('change');
const ctxChg = document.getElementById('chart-change').getContext('2d');
charts['change'] = new Chart(ctxChg, {
type: 'bar',
data: {
labels: changeLabels,
datasets: [{
label: '前期比 (%)',
data: changeVals,
backgroundColor: changeVals.map(v => v >= 0 ? 'rgba(45,106,79,0.7)' : 'rgba(155,35,53,0.6)'),
borderRadius: 3, borderWidth: 0
}]
},
options: {
...chartOpts(),
plugins: {
...CHART_DEFAULTS.plugins,
tooltip: {
...CHART_DEFAULTS.plugins.tooltip,
callbacks: { label: ctx => `${fmtPct(ctx.raw)}` }
}
}
}
});
document.getElementById('empty-overview').style.display = 'none';
document.getElementById('overview-content').style.display = 'block';
}
// ── 気づき・アドバイス ─────────────────────────────────────
function buildInsights() {
const insights = [];
const vals = rawData.map(d => d.value);
const n = vals.length;
const totalAvg = avg(vals);
const totalStd = std(vals);
const last = vals[n-1];
const prev = vals[n-2];
const chgs = vals.slice(1).map((v,i) => (v-vals[i])/vals[i]*100);
const recentN = Math.min(6, Math.floor(n/3));
const recent = vals.slice(-recentN);
const older = vals.slice(-recentN*2, -recentN);
const recentAvg = avg(recent);
const olderAvg = older.length ? avg(older) : recentAvg;
const trend = (recentAvg - olderAvg) / olderAvg * 100;
const volatility = std(chgs);
// 1. トレンド
if (trend > 8) {
insights.push({ type:'good', icon:'🚀', title:'力強い上昇トレンド',
desc:`直近${recentN}期間の平均(${fmtNum(recentAvg)} ${dataUnit})は、その前の平均より ${fmtPct(trend)} 高い水準です。このペースを維持できているかどうか定期的に確認しましょう。` });
} else if (trend > 3) {
insights.push({ type:'good', icon:'📈', title:'緩やかに伸びています',
desc:`直近の売上は前の期間より ${fmtPct(trend)} 上昇。急激ではありませんが、着実に伸びている状態です。` });
} else if (trend < -8) {
insights.push({ type:'bad', icon:'⚠️', title:'下落傾向が続いています',
desc:`直近${recentN}期間の平均(${fmtNum(recentAvg)} ${dataUnit})は、その前の平均より ${fmtPct(trend)} 低い水準です。早期に原因を特定し、対策を検討してください。` });
} else if (trend < -3) {
insights.push({ type:'warn', icon:'📉', title:'じわじわ下がっています',
desc:`直近の平均が前の期間より ${fmtPct(trend)} 低下しています。まだ小幅ですが、続くようであれば要注意です。` });
} else {
insights.push({ type:'info', icon:'➡️', title:'横ばいが続いています',
desc:`大きな上下なく安定した推移です(差: ${fmtPct(trend)})。底固めの時期か、次の変化の兆しかもしれません。` });
}
// 2. 直前の変化
if (prev) {
const lastChg = (last - prev) / prev * 100;
if (lastChg > 15) {
insights.push({ type:'good', icon:'✨', title:'前回より大きく上昇しました',
desc:`最新の値(${fmtNum(last)} ${dataUnit})は、前回(${fmtNum(prev)} ${dataUnit})より ${fmtPct(lastChg)} 増えています。何か良い要因があったか振り返ると、再現のヒントになります。` });
} else if (lastChg < -15) {
insights.push({ type:'bad', icon:'💧', title:'前回より大きく下落しました',
desc:`最新の値(${fmtNum(last)} ${dataUnit})は、前回(${fmtNum(prev)} ${dataUnit})より ${fmtPct(lastChg)} 減っています。一時的な要因か、継続的な変化かを確認してください。` });
}
}
// 3. 変動の大きさ
if (volatility > 12) {
insights.push({ type:'warn', icon:'🌊', title:'売上の波が大きい状態です',
desc:`期間によって売上の変動幅が大きいです(平均変動率: ±${fmtNum(volatility,1)}%)。曜日・季節・イベントなど、変動を引き起こす要因がないか確認してみましょう。` });
} else if (volatility < 4) {
insights.push({ type:'info', icon:'🎯', title:'売上が非常に安定しています',
desc:`各期間の変動幅が小さく(±${fmtNum(volatility,1)}%)、予測しやすい状態です。安定した運営ができている証拠です。` });
}
// 4. 異常値
const anomalies = findAnomalies();
if (anomalies.filter(a=>a.type==='high').length > 0) {
const top = anomalies.filter(a=>a.type==='high')[0];
insights.push({ type:'info', icon:'🔍', title:`${top.date} が突出して高い値でした`,
desc:`${fmtNum(top.value)} ${dataUnit}(平均の ${fmtNum(top.ratio*100,0)}%)。イベント・キャンペーン・特需などがあったかもしれません。記録しておくと次回の計画に役立ちます。` });
}
if (anomalies.filter(a=>a.type==='low').length > 0) {
const bot = anomalies.filter(a=>a.type==='low')[0];
insights.push({ type:'warn', icon:'🔍', title:`${bot.date} が平均より大幅に低い値でした`,
desc:`${fmtNum(bot.value)} ${dataUnit}(平均の ${fmtNum(bot.ratio*100,0)}%)。休業・天候・競合など一時要因があれば記録しておきましょう。` });
}
// 5. 目標に対して
if (goalValue && n > 0) {
const recent6Avg = avg(vals.slice(-Math.min(6,n)));
const pct = recent6Avg / goalValue * 100;
if (pct >= 100) {
insights.push({ type:'good', icon:'🎯', title:'目標を達成しています!',
desc:`直近6期間の平均(${fmtNum(recent6Avg)} ${dataUnit})が目標(${fmtNum(goalValue)} ${dataUnit})を上回っています(達成率 ${fmtNum(pct,0)}%)。` });
} else if (pct >= 80) {
insights.push({ type:'warn', icon:'🎯', title:`目標まであと ${fmtNum(100-pct,0)}% です`,
desc:`現状の平均(${fmtNum(recent6Avg)} ${dataUnit})は目標の ${fmtNum(pct,0)}% 水準。このペースで進んだ場合の達成見込みは「目標達成シミュレーション」タブで確認できます。` });
} else {
insights.push({ type:'bad', icon:'🎯', title:'目標と現状に大きな開きがあります',
desc:`現状の平均(${fmtNum(recent6Avg)} ${dataUnit})は目標の ${fmtNum(pct,0)}% 水準。戦略の見直しが必要かもしれません。` });
}
}
// 6. 季節性の有無
const moyStats = getMonthOfYearStats();
const moyKeys = Object.keys(moyStats).map(Number);
if (moyKeys.length >= 8) {
const moyAvgs = moyKeys.map(k => ({ m:k, a: avg(moyStats[k]) }));
moyAvgs.sort((a,b) => b.a - a.a);
const bestMonth = moyAvgs[0];
const worstMonth = moyAvgs[moyAvgs.length-1];
const mNames = ['','1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const ratio = bestMonth.a / worstMonth.a;
if (ratio > 1.25) {
insights.push({ type:'info', icon:'📅', title:'季節による変動パターンがあります',
desc:`最も売上が高い月は ${mNames[bestMonth.m]}(平均 ${fmtNum(bestMonth.a)} ${dataUnit})、低い月は ${mNames[worstMonth.m]}(平均 ${fmtNum(worstMonth.a)} ${dataUnit})。この差(${fmtNum((ratio-1)*100,0)}%)を踏まえて人員・在庫計画を立てると効果的です。` });
}
}
// 描画
const list = document.getElementById('insight-list');
list.innerHTML = insights.map(ins => `
<div class="insight ${ins.type}">
<div class="insight-icon">${ins.icon}</div>
<div class="insight-body">
<div class="insight-title">${ins.title}</div>
<div class="insight-desc">${ins.desc}</div>
</div>
</div>
`).join('');
document.getElementById('empty-insight').style.display = 'none';
document.getElementById('insight-content').style.display = 'block';
}
// ── 季節・周期パターン ─────────────────────────────────────
function buildSeasonTab() {
const moyStats = getMonthOfYearStats();
const moyKeys = Object.keys(moyStats).map(Number).sort((a,b)=>a-b);
if (moyKeys.length < 6) return;
const moyAvgs = {};
moyKeys.forEach(k => moyAvgs[k] = avg(moyStats[k]));
const allAvgs = Object.values(moyAvgs);
const maxAvg = Math.max(...allAvgs);
const minAvg = Math.min(...allAvgs);
const totalAvg = avg(allAvgs);
// ヒートマップ
const mNames = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const grid = document.getElementById('season-grid');
grid.innerHTML = mNames.map((name, i) => {
const moy = i + 1;
const a = moyAvgs[moy];
if (a == null) {
return `<div class="season-cell" style="background:var(--paper3);opacity:0.3">
<div class="month-name">${name}</div><div class="month-val">─</div></div>`;
}
const ratio = (a - minAvg) / (maxAvg - minAvg + 0.0001);
// 色を green ↔ red でグラデーション
const r = Math.round(45 + (155-45) * (1-ratio));
const g = Math.round(106 + (35-106) * (1-ratio));
const bk = Math.round(79 + (53-79) * (1-ratio));
const bg = `rgba(${r},${g},${bk},${0.15 + ratio*0.55})`;
const border = ratio > 0.5 ? `rgba(${r},${g},${bk},0.4)` : 'transparent';
const diff = ((a - totalAvg) / totalAvg * 100);
return `<div class="season-cell" title="${name}: ${fmtNum(a)} ${dataUnit}" style="background:${bg};border-color:${border}">
<div class="month-name">${name}</div>
<div class="month-val">${diff>=0?'+':''}${fmtNum(diff,0)}%</div>
</div>`;
}).join('');
// 月別棒グラフ
const labels = mNames.filter((_,i) => moyAvgs[i+1] != null);
const avgs = moyKeys.map(k => moyAvgs[k]);
document.getElementById('season-sub').textContent =
`全${moyKeys.length}ヶ月のデータから算出した月ごとの平均。平均 ${fmtNum(totalAvg)} ${dataUnit}。`;
destroyChart('season');
const ctxSeason = document.getElementById('chart-season').getContext('2d');
charts['season'] = new Chart(ctxSeason, {
type: 'bar',
data: {
labels: moyKeys.map(k => mNames[k-1]),
datasets: [{
label: '月平均',
data: avgs,
backgroundColor: avgs.map(v => v > totalAvg * 1.05 ? 'rgba(45,106,79,0.75)'
: v < totalAvg * 0.95 ? 'rgba(155,35,53,0.65)'
: 'rgba(139,105,20,0.55)'),
borderRadius: 4, borderWidth: 0
}]
},
options: {
...chartOpts(),
plugins: {
...CHART_DEFAULTS.plugins,
tooltip: {
...CHART_DEFAULTS.plugins.tooltip,
callbacks: {
label: ctx => `${fmtNum(ctx.raw)} ${dataUnit}(平均比: ${fmtPct((ctx.raw/totalAvg-1)*100)})`
}
}
}
}
});
}
// ── 目標達成シミュレーション ───────────────────────────────
function buildGoalTab() {
if (!goalValue || !rawData.length) return;
document.getElementById('empty-goal').style.display = 'none';
document.getElementById('goal-content').style.display = 'block';
const vals = rawData.map(d => d.value);
const n = vals.length;
const labels = rawData.map(d => d.date);
// 直近の平均成長率を計算
const recentN = Math.min(6, Math.floor(n/2));
const recentVals = vals.slice(-recentN);
const chgRates = recentVals.slice(1).map((v,i) => (v - recentVals[i]) / recentVals[i]);
const avgGrowth = chgRates.length ? avg(chgRates) : 0;
// 現在の累積 vs 目標
const totalSoFar = vals.reduce((a,b) => a+b, 0);
const pctSoFar = Math.min(100, totalSoFar / goalValue * 100);
const last = vals[n-1];
const currentPeriodPct = last / goalValue * 100;
// 目標期限までの残り期間をシミュレート
let forecastLabels = [...labels];
let forecastVals = new Array(n).fill(null);
let simVals = [...vals];
let projectedTotal = totalSoFar;
// 期限が設定されている場合は期限まで予測
let periodsNeeded = 0;
if (goalDeadline) {
let lastDate = rawData[n-1].date;
let targetDate = goalDeadline;
// 月単位でシミュレート
let cur = lastDate;
let projected = last;
while (cur < targetDate && periodsNeeded < 36) {
periodsNeeded++;
// 月をインクリメント
const parts = cur.split('-');
let y = parseInt(parts[0]), m = parseInt(parts[1]);
m++; if (m > 12) { m = 1; y++; }
cur = `${y}-${String(m).padStart(2,'0')}`;
projected = projected * (1 + avgGrowth);
forecastLabels.push(cur);
forecastVals.push(projected);
simVals.push(null);
projectedTotal += projected;
}
}
// 達成確率(シンプルな経験則ベース)
let achieveProb = 0;
if (goalDeadline && forecastLabels.length > n) {
const projectedLast = forecastVals[forecastVals.length-1];
const ratio = projectedLast / goalValue;
if (ratio >= 1.1) achieveProb = 90;
else if (ratio >= 1.0) achieveProb = 75;
else if (ratio >= 0.9) achieveProb = 50;
else if (ratio >= 0.8) achieveProb = 25;
else achieveProb = 10;
// 変動が小さければ確率UP
const vol = std(chgRates);
if (vol < 0.03) achieveProb = Math.min(95, achieveProb + 10);
else if (vol > 0.1) achieveProb = Math.max(5, achieveProb - 15);
} else {
achieveProb = Math.min(95, Math.max(5, Math.round(pctSoFar)));
}
// 説明文
const trendWord = avgGrowth > 0.02 ? '上昇傾向' : avgGrowth < -0.02 ? '下落傾向' : '横ばい';
document.getElementById('goal-desc').textContent =
`目標: ${fmtNum(goalValue)} ${dataUnit}。直近の${trendWord}(${fmtPct(avgGrowth*100)}/期)が続いた場合の予測です。`;
// 達成確率バー
const probColor = achieveProb >= 70 ? 'var(--green)' : achieveProb >= 40 ? 'var(--gold)' : 'var(--red)';
document.getElementById('goal-bar-section').innerHTML = `
<div class="goal-bar-label">
<span>このままのペースだと…</span>
<span style="font-family:'JetBrains Mono';font-weight:700;color:${probColor}">${achieveProb}% の確率で達成見込み</span>
</div>
<div class="goal-bar-wrap">
<div class="goal-bar-fill" style="width:${achieveProb}%;background:${probColor}">
${achieveProb >= 20 ? achieveProb + '%' : ''}
</div>
</div>
<div style="margin-top:10px;font-size:12px;color:#888">
※直近の成長率(${fmtPct(avgGrowth*100)}/期)が続いた場合の試算です。外部要因により変動します。
</div>
`;
// グラフ: 実績 + 目標ライン + 予測
const targetLine = forecastLabels.map(() => goalValue);
destroyChart('goal');
const ctxGoal = document.getElementById('chart-goal').getContext('2d');
const gradientGoal = ctxGoal.createLinearGradient(0, 0, 0, 300);
gradientGoal.addColorStop(0, 'rgba(139,105,20,0.15)');
gradientGoal.addColorStop(1, 'rgba(139,105,20,0.01)');
const ds = [
{
label: '実績',
data: [...vals, ...new Array(forecastLabels.length - n).fill(null)],
borderColor: '#8b6914', borderWidth: 2,
backgroundColor: gradientGoal,
pointRadius: n > 60 ? 0 : 3,
fill: true, tension: 0.3, order: 2
},
{
label: '目標ライン',
data: targetLine,
borderColor: 'rgba(155,35,53,0.7)', borderWidth: 1.5,
borderDash: [6,4], pointRadius: 0, fill: false, order: 1
}
];
if (periodsNeeded > 0) {
// 予測部分
const forecastOnly = [...new Array(n-1).fill(null), vals[n-1], ...forecastVals.slice(n)];
ds.push({
label: '予測(現在のペースが続いた場合)',
data: forecastOnly,
borderColor: 'rgba(45,106,79,0.8)', borderWidth: 2,
borderDash: [4,3], pointRadius: 0,
fill: false, tension: 0.3, order: 3
});
}
charts['goal'] = new Chart(ctxGoal, {
type: 'line',
data: { labels: forecastLabels, datasets: ds },
options: {
...chartOpts(),
plugins: {
...CHART_DEFAULTS.plugins,
tooltip: {
...CHART_DEFAULTS.plugins.tooltip,
callbacks: { label: ctx => ctx.raw != null ? `${ctx.dataset.label}: ${fmtNum(ctx.raw)} ${dataUnit}` : '' }
}
}
}
});
}
// ── 異常値検出 ────────────────────────────────────────────
function findAnomalies() {
const vals = rawData.map(d => d.value);
const m = avg(vals);
const s = std(vals);
const threshold = 1.8;
return rawData
.map((d, i) => {
const z = Math.abs(d.value - m) / s;
if (z < threshold) return null;
return {
date: d.date,
value: d.value,
z, ratio: d.value / m,
type: d.value > m ? 'high' : 'low',
diff: d.value - m,
diffPct: (d.value - m) / m * 100
};
})
.filter(Boolean)
.sort((a,b) => b.z - a.z)
.slice(0, 10);
}
function buildAnomalyTab() {
const anomalies = findAnomalies();
const vals = rawData.map(d => d.value);
const meanVal = avg(vals);
// コメント
const nHigh = anomalies.filter(a => a.type==='high').length;
const nLow = anomalies.filter(a => a.type==='low').length;
let comment = '';
if (!anomalies.length) {
comment = '✅ 大きな突出値は見つかりませんでした。全体的に安定しています。';
} else {
comment = `${anomalies.length}件の突出した値が見つかりました(高め: ${nHigh}件、低め: ${nLow}件)。` +
'原因に心当たりがあれば記録しておくと、将来の計画に役立ちます。';
}
document.getElementById('anomaly-comment').innerHTML =
`<div class="insight info" style="margin-bottom:0">
<div class="insight-icon">🔍</div>
<div class="insight-body"><div class="insight-desc">${comment}</div></div>
</div>`;
// 理由候補の自動生成
function guessCause(a) {
const month = parseInt(a.date.split('-')[1]) || 0;
const hints = [];
if (a.type === 'high') {
if ([12,1].includes(month)) hints.push('年末年始・冬の需要増');
if ([7,8].includes(month)) hints.push('夏季需要・お盆');
if ([3,4].includes(month)) hints.push('年度末・新年度需要');
hints.push('キャンペーン・イベント実施', '特需・大口注文', '競合の休業');
} else {
if ([1,2].includes(month)) hints.push('年始の需要落ち着き');
if ([9].includes(month)) hints.push('夏の反動、シーズンオフ');
hints.push('休業・定休日増', '天候・自然災害', '競合参入・値下げ', '一時的な外部要因');
}
return hints.slice(0,2).join(' / ') || '─';
}
const tbody = document.getElementById('anomaly-tbody');
if (!anomalies.length) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:#bbb;padding:24px">突出した値はありません</td></tr>`;
return;
}
tbody.innerHTML = anomalies.map(a => `
<tr>
<td style="font-family:'JetBrains Mono',monospace;font-weight:700">${a.date}</td>
<td style="font-family:'JetBrains Mono',monospace">${fmtNum(a.value)} <span style="font-size:10px;color:#aaa">${dataUnit}</span></td>
<td>
<span style="font-family:'JetBrains Mono',monospace;color:${a.type==='high'?'var(--green)':'var(--red)'}">
${fmtPct(a.diffPct)}
</span>
<span style="font-size:10px;color:#aaa;margin-left:4px">平均: ${fmtNum(meanVal)}</span>
</td>
<td>
<span class="anomaly-badge ${a.type}">
${a.type==='high' ? '▲ 突出して高い' : '▼ 突出して低い'}
</span>
</td>
<td style="font-size:12px;color:#666">${guessCause(a)}</td>
</tr>
`).join('');
document.getElementById('empty-anomaly').style.display = 'none';
document.getElementById('anomaly-content').style.display = 'block';
}
// ── タブ切り替え ──────────────────────────────────────────
function switchTab(name, el) {
document.getElementById('main-tab-bar').querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
el.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
// 初期ログ
log('CSVを読み込むか、サンプルデータを選んでください', 'info');
</script>
</body>
</html>

コメント