0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AG Gridでセレクトボックスの変更時に表示を変更するサンプルコード

0
Last updated at Posted at 2026-09-16

AG Gridでセレクトボックスの変更時に表示を変更するサンプルコード

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>AG Grid 地域切り替えデモ</title>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community@33.0.4/dist/ag-grid-community.min.js"></script>
<style>
  body { font-family: system-ui, sans-serif; margin: 24px; }
  #grid { width: 100%; height: 320px; margin-top: 12px; }
  select { padding: 6px 10px; font-size: 14px; }
</style>
</head>
<body>

<label>地域:
  <select id="regionSelect"></select>
</label>

<div id="grid"></div>

<script>
// ---------- 1. 地域 → 都道府県 のマスタ ----------
const REGIONS = {
  '北海道': ['北海道'],
  '東北':   ['青森', '秋田', '岩手', '山形', '宮城', '福島'],
  '関東':   ['茨城', '栃木', '群馬', '埼玉', '千葉', '東京', '神奈川'],
  '四国':   ['香川', '徳島', '愛媛', '高知'],
};

// ---------- 2. 行として並べたい指標 ----------
const METRICS = [
  { key: 'convenience', label: 'コンビニの数' },
  { key: 'hospital',    label: '病院の数' },
  { key: 'school',      label: '学校の数' },
];

// ---------- 3. API から取ったデータ(都道府県名をキーにした形に整形しておく) ----------
// 例: { '香川': { convenience: 412, hospital: 89, school: 231 }, ... }
let apiData = {};

async function fetchData() {
  // 実際はここで fetch()
  // const res = await fetch('/api/prefectures');
  // const json = await res.json();
  // return Object.fromEntries(json.map(d => [d.name, d]));
  const all = Object.values(REGIONS).flat();
  return Object.fromEntries(all.map(name => [name, {
    convenience: Math.floor(Math.random() * 3000) + 200,
    hospital:    Math.floor(Math.random() * 600) + 50,
    school:      Math.floor(Math.random() * 1200) + 100,
  }]));
}

// ---------- 4. 選択地域から columnDefs / rowData を組み立てる ----------
function buildColumnDefs(prefs) {
  return [
    { headerName: '情報', field: 'metric', pinned: 'left', width: 160, cellClass: 'metric' },
    ...prefs.map(pref => ({
      headerName: pref,
      field: pref,               // rowData のキーと一致させる
      width: 110,
      type: 'numericColumn',
      valueFormatter: p => p.value == null ? '-' : p.value.toLocaleString(),
    })),
  ];
}

function buildRowData(prefs) {
  return METRICS.map(metric => {
    const row = { metric: metric.label };
    prefs.forEach(pref => {
      row[pref] = apiData[pref]?.[metric.key] ?? null;
    });
    return row;
  });
}

// ---------- 5. グリッド生成 ----------
const gridOptions = {
  columnDefs: [],
  rowData: [],
  defaultColDef: { resizable: true, sortable: false },
  getRowId: p => p.data.metric,   // 行の同一性を保って再描画を滑らかに
};

const gridApi = agGrid.createGrid(document.querySelector('#grid'), gridOptions);

// ---------- 6. セレクトボックスと連動 ----------
function applyRegion(region) {
  const prefs = REGIONS[region] ?? [];
  // v31 以降は setGridOption。v30 以前は gridApi.setColumnDefs() / setRowData()
  gridApi.setGridOption('columnDefs', buildColumnDefs(prefs));
  gridApi.setGridOption('rowData', buildRowData(prefs));
  gridApi.sizeColumnsToFit();
}

(async function init() {
  const select = document.querySelector('#regionSelect');
  select.innerHTML = Object.keys(REGIONS)
    .map(r => `<option value="${r}">${r}</option>`).join('');

  apiData = await fetchData();

  select.addEventListener('change', e => applyRegion(e.target.value));
  applyRegion(select.value);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>AG Grid 地域切り替えデモ(追跡調査フラグ付き)</title>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community@33.0.4/dist/ag-grid-community.min.js"></script>
<style>
  body { font-family: system-ui, sans-serif; margin: 24px; }
  #grid { width: 100%; height: 360px; margin-top: 12px; }
  select { padding: 6px 10px; font-size: 14px; }

  /* ---- AG Grid の外に置くツールバー ---- */
  .toolbar {
    margin-top: 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
  }
  .toolbar button {
    padding: 8px 16px;
    font-size: 14px;
    cursor: pointer;
    border: 1px solid #2b6cb0;
    background: #2b6cb0;
    color: #fff;
    border-radius: 4px;
  }
  .toolbar button:disabled { opacity: .5; cursor: default; }
  .toolbar button.secondary { background: #fff; color: #2b6cb0; }
  .status { font-size: 13px; color: #555; }
  .status.error { color: #c53030; }
  .status.success { color: #2f855a; }
  .summary { margin-top: 8px; font-size: 13px; color: #555; }

  /* ---- 調査結果セルの背景色(500以下:red / 1000以下:yellow / それ以外:white / 未調査:gray) ---- */
  .ag-cell.val-red        { background-color: red; color: #fff; }
  .ag-cell.val-yellow     { background-color: yellow; }
  .ag-cell.val-white      { background-color: white; }
  .ag-cell.val-unsurveyed { background-color: gray; color: #fff; }

  /* ---- 追跡調査列(ヘッダー/セル共通) ---- */
  .track-box {
    display: flex;
    align-items: center;
    gap: 6px;
    height: 100%;
    white-space: nowrap;
  }
  .track-box input[type="checkbox"] {
    width: 15px;
    height: 15px;
    margin: 0;
    cursor: pointer;
  }
  .track-box input[type="checkbox"]:disabled { cursor: not-allowed; opacity: .6; }
  .track-header { font-weight: 600; }
  .track-cell-locked { color: #888; }         /* 一括フラグで固定されているセル */
  .track-cell-unsurveyed { color: #a0aec0; }  /* 未調査のためチェック不可のセル */
</style>
</head>
<body>

<label>地域:
  <select id="regionSelect"></select>
</label>

<div id="grid"></div>

<!-- ---- AG Grid とは別の保存ボタン ---- -->
<div class="toolbar">
  <button id="saveBtn" disabled>チェックを保存</button>
  <button id="reloadBtn" class="secondary" type="button">再取得(変更を破棄)</button>
  <span id="saveStatus" class="status"></span>
</div>
<div id="summary" class="summary"></div>

<script>
// ---------- 1. 地域 → 都道府県 のマスタ ----------
const REGIONS = {
  '北海道': ['北海道'],
  '東北':   ['青森', '秋田', '岩手', '山形', '宮城', '福島'],
  '関東':   ['茨城', '栃木', '群馬', '埼玉', '千葉', '東京', '神奈川'],
  '四国':   ['香川', '徳島', '愛媛', '高知'],
};

const ALL_PREFS = Object.values(REGIONS).flat();

// ---------- 2. 行として並べたい指標 ----------
const METRICS = [
  { key: 'convenience', label: 'コンビニの数' },
  { key: 'hospital',    label: '病院の数' },
  { key: 'school',      label: '学校の数' },
];

// 保存対象の範囲: 'all' = 全都道府県 / 'region' = 表示中の地域のみ
const SAVE_SCOPE = 'all';

// 調査結果セルの背景色の閾値(red: この値以下 / yellow: この値以下 / 超えたら white)
const THRESHOLDS = { red: 500, yellow: 1000 };

// ---------- 3. API から取ったデータ ----------
// 数値データ: { '香川': { convenience: 412, hospital: '', school: 231 }, ... }
//   未調査の都道府県は値が空文字 '' で返る
let apiData = {};

// 追跡調査フラグ(2種類)
//  bulkFlags : 都道府県ごとの一括フラグ(= 全調査項目が対象)… グリッドのヘッダーに表示
//  itemFlags : 調査項目 × 都道府県 のフラグ … 各セルに表示
let bulkFlags = {};   // { '香川': true,  '徳島': false, ... }
let itemFlags = {};   // { convenience: { '香川': true, ... }, hospital: {...}, ... }

let isDirty = false;  // 未保存の変更があるか

// 未調査判定:空文字 '' / null / undefined は未調査扱い
const isSurveyed = v => v !== '' && v !== null && v !== undefined;
// その都道府県 × その調査項目が調査済みか
const isMetricSurveyed = (pref, key) => isSurveyed(apiData[pref]?.[key]);
// その都道府県に調査済みの項目が1つでもあるか(= 一括フラグを立てる意味があるか)
const hasAnySurveyed = pref => METRICS.some(m => isMetricSurveyed(pref, m.key));

async function fetchData() {
  // 実際はここで fetch()
  // const res = await fetch('/api/prefectures');
  // const json = await res.json();
  // return Object.fromEntries(json.map(d => [d.name, d]));
  //
  // 未調査の項目は '' で返ってくる想定:
  // { "name": "高知", "convenience": 210, "hospital": "", "school": 340 }
  const rand = (min, range) => Math.random() < 0.25 ? '' : Math.floor(Math.random() * range) + min;
  return Object.fromEntries(ALL_PREFS.map(name => [name, {
    convenience: rand(200, 3000),
    hospital:    rand(50, 600),
    school:      rand(100, 1200),
  }]));
}

// ---------- 3-b. 追跡調査フラグの取得 API(GET) ----------
async function fetchTrackingFlags() {
  // 実際はここで fetch()
  // const res = await fetch('/api/tracking-flags');
  // if (!res.ok) throw new Error(`取得に失敗しました (${res.status})`);
  // return await res.json();
  //
  // 期待するレスポンス形式:
  // {
  //   "bulk":  { "香川": false, "徳島": false, "愛媛": true, ... },
  //   "items": {
  //     "convenience": { "香川": false, "徳島": true, "愛媛": true, ... },
  //     "hospital":    { ... },
  //     "school":      { ... }
  //   }
  // }
  await new Promise(r => setTimeout(r, 200));
  return {
    bulk: Object.fromEntries(ALL_PREFS.map(n => [n, Math.random() < 0.2])),
    items: Object.fromEntries(METRICS.map(m =>
      [m.key, Object.fromEntries(ALL_PREFS.map(n => [n, Math.random() < 0.3]))]
    )),
  };
}

// ---------- 3-c. 追跡調査フラグの保存 API(POST) ----------
async function saveTrackingFlags(payload) {
  // 実際はここで fetch()
  // const res = await fetch('/api/tracking-flags', {
  //   method: 'POST',
  //   headers: { 'Content-Type': 'application/json' },
  //   body: JSON.stringify(payload),
  // });
  // if (!res.ok) throw new Error(`保存に失敗しました (${res.status})`);
  // return await res.json();
  console.log('[保存ペイロード]\n' + JSON.stringify(payload, null, 2));
  await new Promise(r => setTimeout(r, 500));
  return { ok: true };
}

// 取得したフラグを内部形式へ正規化
//  - 欠けている都道府県は false 扱い
//  - 未調査の組み合わせは強制的に false(画面表示と保存内容を一致させるため)
//    ※ apiData を先に取得してから呼ぶこと
function applyFlags(flags) {
  const bulkSrc = flags?.bulk ?? {};
  const itemSrc = flags?.items ?? {};
  bulkFlags = Object.fromEntries(ALL_PREFS.map(n => [n, !!bulkSrc[n] && hasAnySurveyed(n)]));
  itemFlags = Object.fromEntries(METRICS.map(m =>
    [m.key, Object.fromEntries(ALL_PREFS.map(n =>
      [n, !!(itemSrc[m.key]?.[n]) && isMetricSurveyed(n, m.key)]
    ))]
  ));
}

// ---------- 4. 追跡調査列のヘッダー(都道府県ごとの一括フラグ) ----------
class TrackHeader {
  init(params) {
    this.params = params;
    this.pref = params.pref;

    this.eGui = document.createElement('div');
    this.eGui.className = 'track-box track-header';

    this.cb = document.createElement('input');
    this.cb.type = 'checkbox';

    this.label = document.createElement('span');
    this.label.textContent = this.pref;

    this.eGui.append(this.cb, this.label);

    this.onChange = () => {
      bulkFlags[this.pref] = this.cb.checked;
      markDirty();
      // この列のセル(個別フラグ)の有効/無効表示を更新
      params.api.refreshCells({ columns: [params.column.getColId()], force: true });
      updateSummary();
    };
    this.cb.addEventListener('change', this.onChange);

    // ヘッダーのチェックボックスクリックでソート等が走らないように
    this.eGui.addEventListener('click', e => e.stopPropagation());

    this.render();
  }
  render() {
    // 全項目が未調査の都道府県は一括フラグを立てる意味がないので無効化
    const available = hasAnySurveyed(this.pref);
    this.cb.checked = available && !!bulkFlags[this.pref];
    this.cb.disabled = !available;
    this.eGui.classList.toggle('track-cell-unsurveyed', !available);
    this.eGui.title = available
      ? `${this.pref}:全調査項目を追跡調査の対象にする`
      : `${this.pref} は全調査項目が未調査です`;
  }
  getGui() { return this.eGui; }
  refresh(params) {
    this.pref = params.pref;
    this.label.textContent = this.pref;
    this.render();
    return true;
  }
  destroy() {
    if (this.cb) this.cb.removeEventListener('change', this.onChange);
  }
}

// ---------- 4-b. 追跡調査列のセル(調査項目 × 都道府県のフラグ) ----------
class TrackCell {
  init(params) {
    this.params = params;
    this.pref = params.pref;
    this.metricKey = params.data.key;

    this.eGui = document.createElement('div');
    this.eGui.className = 'track-box';

    this.cb = document.createElement('input');
    this.cb.type = 'checkbox';

    this.label = document.createElement('span');
    this.label.textContent = this.pref;

    this.eGui.append(this.cb, this.label);

    this.onChange = () => {
      itemFlags[this.metricKey][this.pref] = this.cb.checked;
      markDirty();
      updateSummary();
    };
    this.cb.addEventListener('change', this.onChange);

    this.render();
  }
  render() {
    // 値が空文字 '' の(= 未調査の)都道府県はチェック不可
    const surveyed = isSurveyed(this.params.data[this.pref]);
    const locked = !!bulkFlags[this.pref];   // 一括フラグ ON の間は個別設定不可

    if (!surveyed) {
      this.cb.checked = false;
      this.cb.disabled = true;
      this.eGui.title = `${this.pref} はこの調査項目が未調査です`;
    } else if (locked) {
      this.cb.checked = true;
      this.cb.disabled = true;
      this.eGui.title = `${this.pref} は一括フラグで全調査項目が追跡対象のため、個別設定は不要です`;
    } else {
      this.cb.checked = !!itemFlags[this.metricKey][this.pref];
      this.cb.disabled = false;
      this.eGui.title = `${this.pref} のこの調査項目を追跡調査の対象にする`;
    }
    this.eGui.classList.toggle('track-cell-unsurveyed', !surveyed);
    this.eGui.classList.toggle('track-cell-locked', surveyed && locked);
  }
  getGui() { return this.eGui; }
  refresh(params) {
    this.params = params;
    this.pref = params.pref;
    this.metricKey = params.data.key;
    this.label.textContent = this.pref;
    this.render();
    return true;   // DOM を作り直さずに再利用
  }
  destroy() {
    if (this.cb) this.cb.removeEventListener('change', this.onChange);
  }
}

// ---------- 5. 選択地域から columnDefs / rowData を組み立てる ----------
function buildColumnDefs(prefs) {
  return [
    { headerName: '情報', field: 'metric', pinned: 'left', width: 160, cellClass: 'metric' },
    {
      headerName: '調査結果',
      marryChildren: true,
      children: prefs.map(pref => ({
        headerName: pref,
        field: pref,               // rowData のキーと一致させる
        width: 110,
        minWidth: 90,
        type: 'numericColumn',
        valueFormatter: p => isSurveyed(p.value) ? Number(p.value).toLocaleString() : '未調査',
        // 値に応じた背景色。閾値は THRESHOLDS で調整する
        cellClassRules: {
          'val-unsurveyed': p => !isSurveyed(p.value),
          'val-red':        p => isSurveyed(p.value) && Number(p.value) <= THRESHOLDS.red,
          'val-yellow':     p => isSurveyed(p.value) && Number(p.value) > THRESHOLDS.red
                                                    && Number(p.value) <= THRESHOLDS.yellow,
          'val-white':      p => isSurveyed(p.value) && Number(p.value) > THRESHOLDS.yellow,
        },
      })),
    },
    {
      headerName: '追跡調査',
      marryChildren: true,
      children: prefs.map(pref => ({
        colId: `track:${pref}`,
        headerName: pref,
        width: 120,
        minWidth: 100,
        sortable: false,
        headerComponent: TrackHeader,
        headerComponentParams: { pref },
        cellRenderer: TrackCell,
        cellRendererParams: { pref },
      })),
    },
  ];
}

function buildRowData(prefs) {
  return METRICS.map(metric => {
    const row = { key: metric.key, metric: metric.label };
    prefs.forEach(pref => {
      const v = apiData[pref]?.[metric.key];
      row[pref] = isSurveyed(v) ? v : '';   // 未調査は空文字に正規化
    });
    return row;
  });
}

// ---------- 6. グリッド生成 ----------
const gridOptions = {
  columnDefs: [],
  rowData: [],
  defaultColDef: { resizable: true, sortable: false },
  getRowId: p => p.data.key,   // 行の同一性を保って再描画を滑らかに
  suppressCellFocus: true,
};

const gridApi = agGrid.createGrid(document.querySelector('#grid'), gridOptions);

// ---------- 7. セレクトボックスと連動 ----------
let currentRegion = null;

function applyRegion(region) {
  currentRegion = region;
  const prefs = REGIONS[region] ?? [];
  // v31 以降は setGridOption。v30 以前は gridApi.setColumnDefs() / setRowData()
  gridApi.setGridOption('columnDefs', buildColumnDefs(prefs));
  gridApi.setGridOption('rowData', buildRowData(prefs));
  gridApi.sizeColumnsToFit();
  updateSummary();
}

// ---------- 8. 保存用ペイロードの組み立て ----------
// 未調査の都道府県は checked / unchecked のどちらにも入れず notSurveyed へ分ける
function buildSavePayload() {
  const prefs = SAVE_SCOPE === 'region' ? (REGIONS[currentRegion] ?? []) : ALL_PREFS;

  const bulkTarget = prefs.filter(p => hasAnySurveyed(p));

  return {
    scope: SAVE_SCOPE,
    region: SAVE_SCOPE === 'region' ? currentRegion : null,
    savedAt: new Date().toISOString(),
    // 全調査項目に対する、都道府県ごとの一括フラグ
    bulk: {
      checked:     bulkTarget.filter(p => bulkFlags[p]),
      unchecked:   bulkTarget.filter(p => !bulkFlags[p]),
      notSurveyed: prefs.filter(p => !hasAnySurveyed(p)),
    },
    // 調査項目ごとの、都道府県フラグ
    items: METRICS.map(m => {
      const surveyed = prefs.filter(p => isMetricSurveyed(p, m.key));
      return {
        metric: m.key,
        label: m.label,
        checked:     surveyed.filter(p => itemFlags[m.key][p]),
        unchecked:   surveyed.filter(p => !itemFlags[m.key][p]),
        notSurveyed: prefs.filter(p => !isMetricSurveyed(p, m.key)),
      };
    }),
  };
}

// ---------- 9. 状態表示 ----------
const saveBtn = document.querySelector('#saveBtn');
const reloadBtn = document.querySelector('#reloadBtn');
const saveStatus = document.querySelector('#saveStatus');
const summaryEl = document.querySelector('#summary');

function setStatus(text, kind = '') {
  saveStatus.textContent = text;
  saveStatus.className = 'status' + (kind ? ' ' + kind : '');
}

function markDirty() {
  isDirty = true;
  saveBtn.disabled = false;
  setStatus('未保存の変更があります');
}

function updateSummary() {
  const prefs = REGIONS[currentRegion] ?? [];
  const bulkTarget = prefs.filter(p => hasAnySurveyed(p));
  const bulkOn = bulkTarget.filter(p => bulkFlags[p]);
  let itemTotal = 0, itemOn = 0, notSurveyed = 0;
  METRICS.forEach(m => prefs.forEach(p => {
    if (!isMetricSurveyed(p, m.key)) { notSurveyed++; return; }
    itemTotal++;
    if (bulkFlags[p] || itemFlags[m.key][p]) itemOn++;
  }));
  summaryEl.textContent =
    `【${currentRegion}】一括フラグ: ${bulkOn.length}/${bulkTarget.length} 件` +
    (bulkOn.length ? `(${bulkOn.join('')})` : '') +
    ` / 項目別チェック: ${itemOn}/${itemTotal} 件` +
    ` / 未調査: ${notSurveyed} 件`;
}

// ---------- 10. 保存ボタン ----------
saveBtn.addEventListener('click', async () => {
  saveBtn.disabled = true;
  reloadBtn.disabled = true;
  setStatus('保存中…');
  try {
    await saveTrackingFlags(buildSavePayload());
    isDirty = false;
    setStatus(`保存しました(${new Date().toLocaleTimeString()})`, 'success');
  } catch (err) {
    setStatus(err.message || '保存に失敗しました', 'error');
    saveBtn.disabled = false;
  } finally {
    reloadBtn.disabled = false;
  }
});

reloadBtn.addEventListener('click', async () => {
  if (isDirty && !confirm('未保存の変更があります。破棄して再取得しますか?')) return;
  setStatus('取得中…');
  const [data, flags] = await Promise.all([fetchData(), fetchTrackingFlags()]);
  apiData = data;
  applyFlags(flags);   // apiData を更新してから正規化する
  isDirty = false;
  saveBtn.disabled = true;
  applyRegion(currentRegion);
  setStatus('サーバの状態を再取得しました');
});

window.addEventListener('beforeunload', e => {
  if (isDirty) { e.preventDefault(); e.returnValue = ''; }
});

// ---------- 11. 初期化 ----------
(async function init() {
  const select = document.querySelector('#regionSelect');
  select.innerHTML = Object.keys(REGIONS)
    .map(r => `<option value="${r}">${r}</option>`).join('');

  const [data, flags] = await Promise.all([fetchData(), fetchTrackingFlags()]);
  apiData = data;
  applyFlags(flags);

  select.addEventListener('change', e => applyRegion(e.target.value));
  applyRegion(select.value);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>AG Grid 地域切り替えデモ(追跡調査フラグ付き)</title>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community@33.0.4/dist/ag-grid-community.min.js"></script>
<style>
  body { font-family: system-ui, sans-serif; margin: 24px; }
  #grid { width: 100%; height: 360px; margin-top: 12px; }
  select { padding: 6px 10px; font-size: 14px; }

  /* ---- AG Grid の外に置くツールバー ---- */
  .toolbar {
    margin-top: 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
  }
  .toolbar button {
    padding: 8px 16px;
    font-size: 14px;
    cursor: pointer;
    border: 1px solid #2b6cb0;
    background: #2b6cb0;
    color: #fff;
    border-radius: 4px;
  }
  .toolbar button:disabled { opacity: .5; cursor: default; }
  .toolbar button.secondary { background: #fff; color: #2b6cb0; }
  .status { font-size: 13px; color: #555; }
  .status.error { color: #c53030; }
  .status.success { color: #2f855a; }
  .summary { margin-top: 8px; font-size: 13px; color: #555; }

  /* ---- 調査結果セルの背景色(500以下:red / 1000以下:yellow / それ以外:white / 未調査:gray) ---- */
  .ag-cell.val-red        { background-color: red; color: #fff; }
  .ag-cell.val-yellow     { background-color: yellow; }
  .ag-cell.val-white      { background-color: white; }
  .ag-cell.val-unsurveyed { background-color: gray; color: #fff; }
  /* 追跡調査のチェック(ヘッダー・同じ行のセルとも)が入っていない調査結果セル */
  .ag-cell.val-untracked  { background-color: gray; color: #fff; }

  /* ---- 追跡調査列(ヘッダー/セル共通) ---- */
  .track-box {
    display: flex;
    align-items: center;
    gap: 6px;
    height: 100%;
    white-space: nowrap;
  }
  .track-box input[type="checkbox"] {
    width: 15px;
    height: 15px;
    margin: 0;
    cursor: pointer;
  }
  .track-box input[type="checkbox"]:disabled { cursor: not-allowed; opacity: .6; }
  .track-header { font-weight: 600; }
  .track-cell-locked { color: #888; }         /* 一括フラグで固定されているセル */
  .track-cell-unsurveyed { color: #a0aec0; }  /* 未調査のためチェック不可のセル */
</style>
</head>
<body>

<label>地域:
  <select id="regionSelect"></select>
</label>

<div id="grid"></div>

<!-- ---- AG Grid とは別の保存ボタン ---- -->
<div class="toolbar">
  <button id="saveBtn" disabled>チェックを保存</button>
  <button id="reloadBtn" class="secondary" type="button">再取得(変更を破棄)</button>
  <span id="saveStatus" class="status"></span>
</div>
<div id="summary" class="summary"></div>

<script>
// ---------- 1. 地域 → 都道府県 のマスタ ----------
const REGIONS = {
  '北海道': ['北海道'],
  '東北':   ['青森', '秋田', '岩手', '山形', '宮城', '福島'],
  '関東':   ['茨城', '栃木', '群馬', '埼玉', '千葉', '東京', '神奈川'],
  '四国':   ['香川', '徳島', '愛媛', '高知'],
};

const ALL_PREFS = Object.values(REGIONS).flat();

// ---------- 2. 行として並べたい指標 ----------
const METRICS = [
  { key: 'convenience', label: 'コンビニの数' },
  { key: 'hospital',    label: '病院の数' },
  { key: 'school',      label: '学校の数' },
];

// 保存対象の範囲: 'all' = 全都道府県 / 'region' = 表示中の地域のみ
const SAVE_SCOPE = 'all';

// 調査結果セルの背景色の閾値(red: この値以下 / yellow: この値以下 / 超えたら white)
const THRESHOLDS = { red: 500, yellow: 1000 };

// ---------- 3. API から取ったデータ ----------
// 数値データ: { '香川': { convenience: 412, hospital: '', school: 231 }, ... }
//   未調査の都道府県は値が空文字 '' で返る
let apiData = {};

// 追跡調査フラグ(2種類)
//  bulkFlags : 都道府県ごとの一括フラグ(= 全調査項目が対象)… グリッドのヘッダーに表示
//  itemFlags : 調査項目 × 都道府県 のフラグ … 各セルに表示
let bulkFlags = {};   // { '香川': true,  '徳島': false, ... }
let itemFlags = {};   // { convenience: { '香川': true, ... }, hospital: {...}, ... }

let isDirty = false;  // 未保存の変更があるか

// 未調査判定:空文字 '' / null / undefined は未調査扱い
const isSurveyed = v => v !== '' && v !== null && v !== undefined;
// その都道府県 × その調査項目が調査済みか
const isMetricSurveyed = (pref, key) => isSurveyed(apiData[pref]?.[key]);
// その都道府県に調査済みの項目が1つでもあるか(= 一括フラグを立てる意味があるか)
const hasAnySurveyed = pref => METRICS.some(m => isMetricSurveyed(pref, m.key));
// その都道府県 × その調査項目が追跡対象か(ヘッダーの一括フラグ or 同じ行のセルのフラグ)
const isTracked = (pref, key) => !!bulkFlags[pref] || !!itemFlags[key]?.[pref];

// 調査結果列の背景色を再評価(追跡調査のチェック変更時に呼ぶ)
//  rowNodes を省略すると、その都道府県の全行を更新
function refreshResultCells(api, pref, rowNodes) {
  api.refreshCells({ columns: [pref], rowNodes, force: true });
}

async function fetchData() {
  // 実際はここで fetch()
  // const res = await fetch('/api/prefectures');
  // const json = await res.json();
  // return Object.fromEntries(json.map(d => [d.name, d]));
  //
  // 未調査の項目は '' で返ってくる想定:
  // { "name": "高知", "convenience": 210, "hospital": "", "school": 340 }
  const rand = (min, range) => Math.random() < 0.25 ? '' : Math.floor(Math.random() * range) + min;
  return Object.fromEntries(ALL_PREFS.map(name => [name, {
    convenience: rand(200, 3000),
    hospital:    rand(50, 600),
    school:      rand(100, 1200),
  }]));
}

// ---------- 3-b. 追跡調査フラグの取得 API(GET) ----------
async function fetchTrackingFlags() {
  // 実際はここで fetch()
  // const res = await fetch('/api/tracking-flags');
  // if (!res.ok) throw new Error(`取得に失敗しました (${res.status})`);
  // return await res.json();
  //
  // 期待するレスポンス形式:
  // {
  //   "bulk":  { "香川": false, "徳島": false, "愛媛": true, ... },
  //   "items": {
  //     "convenience": { "香川": false, "徳島": true, "愛媛": true, ... },
  //     "hospital":    { ... },
  //     "school":      { ... }
  //   }
  // }
  await new Promise(r => setTimeout(r, 200));
  return {
    bulk: Object.fromEntries(ALL_PREFS.map(n => [n, Math.random() < 0.2])),
    items: Object.fromEntries(METRICS.map(m =>
      [m.key, Object.fromEntries(ALL_PREFS.map(n => [n, Math.random() < 0.3]))]
    )),
  };
}

// ---------- 3-c. 追跡調査フラグの保存 API(POST) ----------
async function saveTrackingFlags(payload) {
  // 実際はここで fetch()
  // const res = await fetch('/api/tracking-flags', {
  //   method: 'POST',
  //   headers: { 'Content-Type': 'application/json' },
  //   body: JSON.stringify(payload),
  // });
  // if (!res.ok) throw new Error(`保存に失敗しました (${res.status})`);
  // return await res.json();
  console.log('[保存ペイロード]\n' + JSON.stringify(payload, null, 2));
  await new Promise(r => setTimeout(r, 500));
  return { ok: true };
}

// 取得したフラグを内部形式へ正規化
//  - 欠けている都道府県は false 扱い
//  - 未調査の組み合わせは強制的に false(画面表示と保存内容を一致させるため)
//    ※ apiData を先に取得してから呼ぶこと
function applyFlags(flags) {
  const bulkSrc = flags?.bulk ?? {};
  const itemSrc = flags?.items ?? {};
  bulkFlags = Object.fromEntries(ALL_PREFS.map(n => [n, !!bulkSrc[n] && hasAnySurveyed(n)]));
  itemFlags = Object.fromEntries(METRICS.map(m =>
    [m.key, Object.fromEntries(ALL_PREFS.map(n =>
      [n, !!(itemSrc[m.key]?.[n]) && isMetricSurveyed(n, m.key)]
    ))]
  ));
}

// ---------- 4. 追跡調査列のヘッダー(都道府県ごとの一括フラグ) ----------
class TrackHeader {
  init(params) {
    this.params = params;
    this.pref = params.pref;

    this.eGui = document.createElement('div');
    this.eGui.className = 'track-box track-header';

    this.cb = document.createElement('input');
    this.cb.type = 'checkbox';

    this.label = document.createElement('span');
    this.label.textContent = this.pref;

    this.eGui.append(this.cb, this.label);

    this.onChange = () => {
      bulkFlags[this.pref] = this.cb.checked;
      markDirty();
      // この列のセル(個別フラグ)の有効/無効表示を更新
      params.api.refreshCells({ columns: [params.column.getColId()], force: true });
      // 同じ都道府県の調査結果セル(全行)の背景色を更新
      refreshResultCells(params.api, this.pref);
      updateSummary();
    };
    this.cb.addEventListener('change', this.onChange);

    // ヘッダーのチェックボックスクリックでソート等が走らないように
    this.eGui.addEventListener('click', e => e.stopPropagation());

    this.render();
  }
  render() {
    // 全項目が未調査の都道府県は一括フラグを立てる意味がないので無効化
    const available = hasAnySurveyed(this.pref);
    this.cb.checked = available && !!bulkFlags[this.pref];
    this.cb.disabled = !available;
    this.eGui.classList.toggle('track-cell-unsurveyed', !available);
    this.eGui.title = available
      ? `${this.pref}:全調査項目を追跡調査の対象にする`
      : `${this.pref} は全調査項目が未調査です`;
  }
  getGui() { return this.eGui; }
  refresh(params) {
    this.pref = params.pref;
    this.label.textContent = this.pref;
    this.render();
    return true;
  }
  destroy() {
    if (this.cb) this.cb.removeEventListener('change', this.onChange);
  }
}

// ---------- 4-b. 追跡調査列のセル(調査項目 × 都道府県のフラグ) ----------
class TrackCell {
  init(params) {
    this.params = params;
    this.pref = params.pref;
    this.metricKey = params.data.key;

    this.eGui = document.createElement('div');
    this.eGui.className = 'track-box';

    this.cb = document.createElement('input');
    this.cb.type = 'checkbox';

    this.label = document.createElement('span');
    this.label.textContent = this.pref;

    this.eGui.append(this.cb, this.label);

    this.onChange = () => {
      itemFlags[this.metricKey][this.pref] = this.cb.checked;
      markDirty();
      // 同じ行・同じ都道府県の調査結果セルの背景色を更新
      refreshResultCells(this.params.api, this.pref, [this.params.node]);
      updateSummary();
    };
    this.cb.addEventListener('change', this.onChange);

    this.render();
  }
  render() {
    // 値が空文字 '' の(= 未調査の)都道府県はチェック不可
    const surveyed = isSurveyed(this.params.data[this.pref]);
    const locked = !!bulkFlags[this.pref];   // 一括フラグ ON の間は個別設定不可

    if (!surveyed) {
      this.cb.checked = false;
      this.cb.disabled = true;
      this.eGui.title = `${this.pref} はこの調査項目が未調査です`;
    } else if (locked) {
      this.cb.checked = true;
      this.cb.disabled = true;
      this.eGui.title = `${this.pref} は一括フラグで全調査項目が追跡対象のため、個別設定は不要です`;
    } else {
      this.cb.checked = !!itemFlags[this.metricKey][this.pref];
      this.cb.disabled = false;
      this.eGui.title = `${this.pref} のこの調査項目を追跡調査の対象にする`;
    }
    this.eGui.classList.toggle('track-cell-unsurveyed', !surveyed);
    this.eGui.classList.toggle('track-cell-locked', surveyed && locked);
  }
  getGui() { return this.eGui; }
  refresh(params) {
    this.params = params;
    this.pref = params.pref;
    this.metricKey = params.data.key;
    this.label.textContent = this.pref;
    this.render();
    return true;   // DOM を作り直さずに再利用
  }
  destroy() {
    if (this.cb) this.cb.removeEventListener('change', this.onChange);
  }
}

// ---------- 5. 選択地域から columnDefs / rowData を組み立てる ----------
function buildColumnDefs(prefs) {
  return [
    { headerName: '情報', field: 'metric', pinned: 'left', width: 160, cellClass: 'metric' },
    {
      headerName: '調査結果',
      marryChildren: true,
      children: prefs.map(pref => ({
        headerName: pref,
        field: pref,               // rowData のキーと一致させる
        width: 110,
        minWidth: 90,
        type: 'numericColumn',
        valueFormatter: p => isSurveyed(p.value) ? Number(p.value).toLocaleString() : '未調査',
        // 値に応じた背景色。閾値は THRESHOLDS で調整する
        //  追跡調査のチェック(ヘッダー/同じ行のセル)がどちらも無い場合は gray
        cellClassRules: {
          'val-unsurveyed': p => !isSurveyed(p.value),
          'val-untracked':  p => isSurveyed(p.value) && !isTracked(pref, p.data.key),
          'val-red':        p => isSurveyed(p.value) && isTracked(pref, p.data.key)
                                 && Number(p.value) <= THRESHOLDS.red,
          'val-yellow':     p => isSurveyed(p.value) && isTracked(pref, p.data.key)
                                 && Number(p.value) > THRESHOLDS.red
                                 && Number(p.value) <= THRESHOLDS.yellow,
          'val-white':      p => isSurveyed(p.value) && isTracked(pref, p.data.key)
                                 && Number(p.value) > THRESHOLDS.yellow,
        },
      })),
    },
    {
      headerName: '追跡調査',
      marryChildren: true,
      children: prefs.map(pref => ({
        colId: `track:${pref}`,
        headerName: pref,
        width: 120,
        minWidth: 100,
        sortable: false,
        headerComponent: TrackHeader,
        headerComponentParams: { pref },
        cellRenderer: TrackCell,
        cellRendererParams: { pref },
      })),
    },
  ];
}

function buildRowData(prefs) {
  return METRICS.map(metric => {
    const row = { key: metric.key, metric: metric.label };
    prefs.forEach(pref => {
      const v = apiData[pref]?.[metric.key];
      row[pref] = isSurveyed(v) ? v : '';   // 未調査は空文字に正規化
    });
    return row;
  });
}

// ---------- 6. グリッド生成 ----------
const gridOptions = {
  columnDefs: [],
  rowData: [],
  defaultColDef: { resizable: true, sortable: false },
  getRowId: p => p.data.key,   // 行の同一性を保って再描画を滑らかに
  suppressCellFocus: true,
};

const gridApi = agGrid.createGrid(document.querySelector('#grid'), gridOptions);

// ---------- 7. セレクトボックスと連動 ----------
let currentRegion = null;

function applyRegion(region) {
  currentRegion = region;
  const prefs = REGIONS[region] ?? [];
  // v31 以降は setGridOption。v30 以前は gridApi.setColumnDefs() / setRowData()
  gridApi.setGridOption('columnDefs', buildColumnDefs(prefs));
  gridApi.setGridOption('rowData', buildRowData(prefs));
  gridApi.sizeColumnsToFit();
  updateSummary();
}

// ---------- 8. 保存用ペイロードの組み立て ----------
// 未調査の都道府県は checked / unchecked のどちらにも入れず notSurveyed へ分ける
function buildSavePayload() {
  const prefs = SAVE_SCOPE === 'region' ? (REGIONS[currentRegion] ?? []) : ALL_PREFS;

  const bulkTarget = prefs.filter(p => hasAnySurveyed(p));

  return {
    scope: SAVE_SCOPE,
    region: SAVE_SCOPE === 'region' ? currentRegion : null,
    savedAt: new Date().toISOString(),
    // 全調査項目に対する、都道府県ごとの一括フラグ
    bulk: {
      checked:     bulkTarget.filter(p => bulkFlags[p]),
      unchecked:   bulkTarget.filter(p => !bulkFlags[p]),
      notSurveyed: prefs.filter(p => !hasAnySurveyed(p)),
    },
    // 調査項目ごとの、都道府県フラグ
    items: METRICS.map(m => {
      const surveyed = prefs.filter(p => isMetricSurveyed(p, m.key));
      return {
        metric: m.key,
        label: m.label,
        checked:     surveyed.filter(p => itemFlags[m.key][p]),
        unchecked:   surveyed.filter(p => !itemFlags[m.key][p]),
        notSurveyed: prefs.filter(p => !isMetricSurveyed(p, m.key)),
      };
    }),
  };
}

// ---------- 9. 状態表示 ----------
const saveBtn = document.querySelector('#saveBtn');
const reloadBtn = document.querySelector('#reloadBtn');
const saveStatus = document.querySelector('#saveStatus');
const summaryEl = document.querySelector('#summary');

function setStatus(text, kind = '') {
  saveStatus.textContent = text;
  saveStatus.className = 'status' + (kind ? ' ' + kind : '');
}

function markDirty() {
  isDirty = true;
  saveBtn.disabled = false;
  setStatus('未保存の変更があります');
}

function updateSummary() {
  const prefs = REGIONS[currentRegion] ?? [];
  const bulkTarget = prefs.filter(p => hasAnySurveyed(p));
  const bulkOn = bulkTarget.filter(p => bulkFlags[p]);
  let itemTotal = 0, itemOn = 0, notSurveyed = 0;
  METRICS.forEach(m => prefs.forEach(p => {
    if (!isMetricSurveyed(p, m.key)) { notSurveyed++; return; }
    itemTotal++;
    if (bulkFlags[p] || itemFlags[m.key][p]) itemOn++;
  }));
  summaryEl.textContent =
    `【${currentRegion}】一括フラグ: ${bulkOn.length}/${bulkTarget.length} 件` +
    (bulkOn.length ? `(${bulkOn.join('')})` : '') +
    ` / 項目別チェック: ${itemOn}/${itemTotal} 件` +
    ` / 未調査: ${notSurveyed} 件`;
}

// ---------- 10. 保存ボタン ----------
saveBtn.addEventListener('click', async () => {
  saveBtn.disabled = true;
  reloadBtn.disabled = true;
  setStatus('保存中…');
  try {
    await saveTrackingFlags(buildSavePayload());
    isDirty = false;
    setStatus(`保存しました(${new Date().toLocaleTimeString()})`, 'success');
  } catch (err) {
    setStatus(err.message || '保存に失敗しました', 'error');
    saveBtn.disabled = false;
  } finally {
    reloadBtn.disabled = false;
  }
});

reloadBtn.addEventListener('click', async () => {
  if (isDirty && !confirm('未保存の変更があります。破棄して再取得しますか?')) return;
  setStatus('取得中…');
  const [data, flags] = await Promise.all([fetchData(), fetchTrackingFlags()]);
  apiData = data;
  applyFlags(flags);   // apiData を更新してから正規化する
  isDirty = false;
  saveBtn.disabled = true;
  applyRegion(currentRegion);
  setStatus('サーバの状態を再取得しました');
});

window.addEventListener('beforeunload', e => {
  if (isDirty) { e.preventDefault(); e.returnValue = ''; }
});

// ---------- 11. 初期化 ----------
(async function init() {
  const select = document.querySelector('#regionSelect');
  select.innerHTML = Object.keys(REGIONS)
    .map(r => `<option value="${r}">${r}</option>`).join('');

  const [data, flags] = await Promise.all([fetchData(), fetchTrackingFlags()]);
  apiData = data;
  applyFlags(flags);

  select.addEventListener('change', e => applyRegion(e.target.value));
  applyRegion(select.value);
})();
</script>
</body>
</html>

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?