売上分析ツール「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&#10;2024-02,1350&#10;2024-03,980&#10;…"></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>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
売上分析ツール「Mieru」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1