📊

【GAS】グラフの色を一括で変更する

に公開

できること

Google Apps Script(GAS)を使って、各種グラフの色を一括で変更できる。

背景

複数のグラフを扱うとき、同じ系列には同じ色を使いたいというケースがあると思います。
しかしスプレッドシートには系列名ごとに色を設定する機能はなさそうです。

そこで、GASを使ってグラフの色設定を自動化してみようと思いました。
グラフの色指定について調べると主に新規作成についての情報が多かったため、備忘録も兼ねて色変更についての情報をまとめます。

スクリプトの解説

グラフの変更

グラフを編集するには、直接変更できず一度EmbeddedChartBuilderを経由する必要があります。
.modify() を用いて EmbeddedChartBuilder に変換し、そこに設定変更処理を行います。
その後 .build() で新しいグラフを生成し、updateChart() でシートに反映します。

const chartBuilder = chart.modify();
/* グラフの設定を変更*/
sheet.updateChart(chartBuilder.build());

またEmbeddedChartBuilderには.asPieChart()など各種のグラフ専用のビルダーBuilderを呼び出すメソッドが用意されていて、今回のスクリプトでは専用のビルダーに変換しています。

const chartType = chartBuilder.getChartType();
  switch (chartType) {
    case Charts.ChartType.COLUMN:
      chartBuilder = chartBuilder.asColumnChart();
      break;
    case Charts.ChartType.BAR:
      chartBuilder = chartBuilder.asBarChart();
      break;
    case Charts.ChartType.LINE:
      chartBuilder = chartBuilder.asLineChart();
      break;
    case Charts.ChartType.SCATTER:
      chartBuilder = chartBuilder.asScatterChart();
      break;
    case Charts.ChartType.COMBO:
      chartBuilder = chartBuilder.asComboChart();
      break;
    case Charts.ChartType.PIE:
      chartBuilder = chartBuilder.asPieChart();
      break;

色の指定について

グラフの色はグラフ構成オプションのcolorsに配列として保持されています。
色変更には、各グラフ種別ごとに用意されているビルダーの.setColors()を使います。
たとえば、以下のように書くことでグラフに3色を適用できます。

chartBuilder.setColors(['#FF0000', '#00FF00', '#0000FF']);

色は系列の順番に対応して適用され、配列の要素数と系列数が一致していないと想定通りに動作しません。そのため、グラフに含まれる系列数を事前に取得する必要があります。

  • 足りない場合:配列の前から適応
  • 多すぎる場合:エラーになる
変更前 入力 変更後
1 #FF0000 #000000 #000000
2 #00FF00 #000000 #000000
3 #0000FF #0000FF

系列数の取得

GASには系列数を直接取得できる機能がなさそうです。
そのため、次のようなロジックで系列数を推定する関数を自作しました。

基本的な仕組み

系列数は、グラフが参照しているデータ範囲の中で、主要な軸(行または列)に存在する非空セルの数として定義しています。

たとえば以下のようなデータがあるとします(行が系列の場合):

年月 売上 利益 コスト
1月 100 40 60
2月 200 80 120

この場合、1行目はヘッダーなので除外し、売上・利益・コストの3系列が対象になります。
この3つの項目が主要な軸のラベルになり、系列数3とカウントされます。

コード上では以下のようにして、データ範囲と中身を取得しています:

const chartRanges = chart.getRanges();
const mainDataRange = chartRanges[0];
const rangeValues = mainDataRange.getValues();

その後、縦軸(列)または横軸(行)方向で、以下のように空でない値をカウントしています。

if (rangeValues.length > 0) { // データがあるか確認
  const firstRowOfValues = rangeValues[0]; // スプレッドシートの物理的な最初の行
  for (let i = 0; i < firstRowOfValues.length; i++) {
    const cellVal = firstRowOfValues[i];
    if (cellVal !== "" && cellVal !== null && typeof cellVal !== 'undefined') {
      nonEmptyCellsInAxis++;
    }
  }
}

ヘッダーへの対応

スプレッドシートのグラフには、.getNumHeaders() というメソッドがあり、これで「ヘッダー行または列の数」が取得できます。
この数を系列数から引くことで、ヘッダーを考慮した数値を得られます。

const configuredHeaders = chart.getNumHeaders();
let headersToSubtract = configuredHeaders;

また、左上セルが空白の場合は、ヘッダーの数を1つ減らすという調整も行っています。

const isTopLeftCellEmpty = (rangeValues[0][0] === '' || rangeValues[0][0] === null);
const adjustedHeadersToSubtract = isTopLeftCellEmpty ? Math.max(0, headersToSubtract - 1) : headersToSubtract;

この調整により、空白セルがラベルとしてカウントされる誤判定を避けています。

行列入れ替えへの対応

グラフには「行と列を入れ替え」という設定があり、これが有効な場合は行と列が逆転します。
そのため、この設定の状況を確認して、カウント対象が行か列かを判断します:

const isTransposed = chart.getTransposeRowsAndColumns();

円グラフへの対応

円グラフ(PIE)は他のグラフと異なり、行列の扱いが逆転する特性があります。
このため、行列の転置設定とチャートタイプを見て、軸の向きを補正しています:

const chartType = chart.modify().getChartType();
const isAxisVertical = (isTransposed !== (chartType === Charts.ChartType.PIE));

これにより、PIE のときだけ軸の判断を反転させることで、正しい系列数を取得できるようにしています。

これらの仕組みにより、棒グラフや折れ線グラフ、円グラフなど、さまざまなケースに対応しながら正しい系列数を推定できます。

おわりに

GASを使ってグラフの色を変更する方法を説明しました。
実際に系列名ごとに色を設定するには追加で系列と色の対応表を作り、それを参照して色の配列を作る必要があります。

同じ課題で悩んでいる方の参考になれば嬉しいです。

コード全文

コード全文
/**
 * 指定されたシート上のすべてのグラフの系列色をランダムな色に変更します。
 *
 * @param {string} [sheetName] 対象シートの名前。省略した場合、アクティブなシートが選択されます。
 */
function setAllChartsSeriesRandomColor(sheetName) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const targetSheet = sheetName ? ss.getSheetByName(sheetName) : ss.getActiveSheet();

  if (!targetSheet) {
    Logger.log(`エラー: 指定されたシート '${sheetName}' が見つからないか、アクティブなシートがありません。`);
    return;
  }

  // 1. 対象シート上のすべてのグラフを抽出
  const charts = getChartsOnSheet(targetSheet);

  // グラフが見つからなければ処理を終了
  if (!charts) {
    Logger.log(`シート '${targetSheet.getName()}' にグラフが見つからなかったため、処理を終了します。`);
    return;
  }

  // 2. 抽出した各グラフに対して色変更処理を実行
  charts.forEach(chart => {
    // グラフの系列数を取得
    // この関数は、グラフの転置やヘッダーの有無、および左上セルの状態を考慮して系列数を算出します。
    const seriesCount = getSeriesCount(chart); 
    
    // 系列数が取得できない、または0の場合は色変更をスキップ
    if (seriesCount === null || seriesCount === 0) {
      Logger.log(`グラフ ID: ${chart.getChartId() || 'N/A'} は有効な系列データを持たないため、色変更をスキップします。`);
      return;
    }

    // 変更したい色の配列を生成 (系列数分のランダムな色)
    const randomColors = [];
    for (let i = 0; i < seriesCount; i++) {
      randomColors.push(generateRandomHexColor()); 
    }
    
    // 生成した色をグラフの系列に適用
    // ここで targetSheet を明示的に渡すことで、getChartsOnSheet と setChartSeriesColors が独立性を保ちます。
    setChartSeriesColors(chart, targetSheet, randomColors);
  });

  Logger.log(`\n--- シート '${targetSheet.getName()}' のすべてのグラフの系列色の変更処理が完了しました ---`);
}

/**
 * 指定されたシートからすべての埋め込みグラフを抽出します。
 *
 * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet グラフが存在するシートオブジェクト。
 * @returns {GoogleAppsScript.Spreadsheet.Chart[] | null} シート上のグラフの配列。グラフがない場合は null。
 */
function getChartsOnSheet(sheet) {
  // シートオブジェクトが有効かチェック
  if (!sheet) {
    Logger.log(`エラー: getChartsOnSheet に無効なシートオブジェクトが渡されました。`);
    return null;
  }

  // シートからすべてのグラフを取得
  const charts = sheet.getCharts();

  // グラフが一つも見つからなければ null を返す
  if (charts.length === 0) {
    Logger.log(`シート '${sheet.getName()}' にグラフが見つかりません。`);
    return null;
  }

  Logger.log(`シート '${sheet.getName()}' から ${charts.length} 個のグラフを抽出しました。`);
  return charts;
}

/**
 * 指定されたグラフの系列の色を設定します。
 * グラフの種類に応じて適切なビルダーにキャストし、色を適用します。
 *
 * @param {GoogleAppsScript.Spreadsheet.Chart} chart 色を変更するグラフオブジェクト。
 * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet グラフが存在するシートオブジェクト(updateChart のために必要)。
 * @param {string[]} colors 系列に適用する色のHEXコード配列(例: ['#FF0000', '#00FF00'])。
 */
function setChartSeriesColors(chart, sheet, colors) {
  // 引数の妥当性をチェック
  if (!chart || !sheet || !colors || !Array.isArray(colors) || colors.length === 0) {
    Logger.log("エラー: setChartSeriesColors に無効な引数が渡されました。色の変更をスキップします。");
    return;
  }

  const chartId = chart.getChartId() || 'N/A';
  Logger.log(`\n--- グラフ ID: ${chartId}, 種類: ${chart.getType()} の色を変更中 ---`);

  // グラフを変更するためのビルダーを取得
  let chartBuilder = chart.modify(); 

  // グラフの種類に応じて、より具体的なビルダーにキャスト
  // これにより、setColors() のような特定のメソッドが利用可能になる場合があります。
  const chartType = chartBuilder.getChartType();
  switch (chartType) {
    case Charts.ChartType.COLUMN:
      chartBuilder = chartBuilder.asColumnChart();
      break;
    case Charts.ChartType.BAR:
      chartBuilder = chartBuilder.asBarChart();
      break;
    case Charts.ChartType.LINE:
      chartBuilder = chartBuilder.asLineChart();
      break;
    case Charts.ChartType.SCATTER:
      chartBuilder = chartBuilder.asScatterChart();
      break;
    case Charts.ChartType.COMBO:
      chartBuilder = chartBuilder.asComboChart();
      break;
    case Charts.ChartType.PIE:
      chartBuilder = chartBuilder.asPieChart();
      break;
    // その他のグラフタイプは、汎用的な ChartBuilder のまま処理を続行
  }

  // setColors メソッドが利用可能かチェックし、利用可能ならそちらを使用
  // そうでなければ、setOption('colors') をフォールバックとして使用します。
  if (typeof chartBuilder.setColors === 'function') {
    chartBuilder.setColors(colors);
    Logger.log(`  setColors メソッドを使用して色を設定しました。`);
  } else {
    chartBuilder.setOption('colors', colors);
    Logger.log(`  setOption('colors') を使用して色を設定しました。`);
  }

  // シートにグラフの変更を反映
  try {
    sheet.updateChart(chartBuilder.build());
    Logger.log(`  グラフ ID: ${chartId} の色を正常に変更しました。`);
  } catch (e) {
    Logger.log(`エラー: グラフ ID: ${chartId} の色変更中にエラーが発生しました: ${e.message}`);
  }
}

/**
 * 指定されたグラフの系列数を算出します。
 * 系列数は、グラフが解釈するデータ範囲の主要な「軸」 (通常はX軸のカテゴリまたは円グラフのラベル) に
 * 入力されている非空セルの数として定義されます。
 * グラフの転置(行と列の入れ替え)と、グラフ設定で指定されたヘッダーの数を考慮します。
 * 特に、データ範囲の左上セルが空である場合にヘッダーの減算ロジックを調整します。
 *
 * @param {GoogleAppsScript.Spreadsheet.Chart} chart 系列数を取得するグラフオブジェクト。
 * @returns {number | null} グラフの系列数。取得できない場合は null。
 */
function getSeriesCount(chart) {
  // 引数の妥当性をチェック
  if (!chart) {
    Logger.log("エラー: getSeriesCount に無効なグラフオブジェクトが渡されました。");
    return null;
  }

  const chartId = chart.getChartId() || 'N/A';
  const chartType = chart.modify().getChartType();
  Logger.log(`\n--- グラフ ID: ${chartId}, 種類: ${chartType} の系列数を取得中 ---`);

  // グラフが参照するデータ範囲を取得
  const chartRanges = chart.getRanges();

  // データ範囲が設定されていない場合は系列数を0とみなす
  if (chartRanges.length === 0) {
    Logger.log("  このグラフにはデータ範囲が設定されていません。");
    return 0;
  }

  // 複数のデータ範囲がある場合、最初のデータ範囲を主要なものとして扱う
  const mainDataRange = chartRanges[0];
  Logger.log(`  主要データ範囲: ${mainDataRange.getA1Notation()}`);

  // データ範囲内のすべての値を取得
  const rangeValues = mainDataRange.getValues();
  // グラフがデータを転置して解釈しているかを確認
  const isTransposed = chart.getTransposeRowsAndColumns();
  Logger.log(`  グラフは転置されていますか (isTransposed): ${isTransposed}`);

  let nonEmptyCellsInAxis = 0; // 主要な軸(行または列)の非空セル数
  let headersToSubtract = 0;   // 最終的に系列数から差し引くヘッダーの数

  // グラフ設定で指定されているヘッダーの数を取得
  // getNumHeaders() の値は、転置されているかどうかで意味合いが変わります。
  const configuredHeaders = chart.getNumHeaders();
  Logger.log(`  グラフ設定でのヘッダー数 (getNumHeaders()): ${configuredHeaders}`);

  // グラフの種類と転置の状態に基づいて、主要な軸の非空セルをカウント
  // isAxisVertical は、「主要な軸が垂直方向(行方向)であるか」を示します。
  // これは「isTransposed と chartType が PIE であるかどうかが異なる場合」に真となります。
  const isAxisVertical = (isTransposed !== (chartType === Charts.ChartType.PIE));

  if (isAxisVertical) {
    // 主要な軸が垂直(通常は行)の場合:最初の列の非空セルをカウント
    // 例: 転置されていない棒グラフ、円グラフ(通常)
    const firstColIndex = 0; // 2次元配列の最初の列はインデックス0

    for (let i = 0; i < rangeValues.length; i++) {
      if (rangeValues[i] && rangeValues[i].length > firstColIndex) {
        const cellVal = rangeValues[i][firstColIndex];
        if (cellVal !== "" && cellVal !== null && typeof cellVal !== 'undefined') {
          nonEmptyCellsInAxis++;
        }
      }
    }
    // 転置されていない、または円グラフで、getNumHeaders() はヘッダー「行」数を意味する
    headersToSubtract = configuredHeaders;
    Logger.log(`  (主要軸が垂直と判断されたため、getNumHeaders()はヘッダー「行」数を意味します。)`);
    
  } else {
    // 主要な軸が水平(通常は列)の場合:最初の行の非空セルをカウント
    // 例: 転置された棒グラフ
    if (rangeValues.length > 0) { // データがあるか確認
      const firstRowOfValues = rangeValues[0]; // スプレッドシートの物理的な最初の行
      for (let i = 0; i < firstRowOfValues.length; i++) {
        const cellVal = firstRowOfValues[i];
        if (cellVal !== "" && cellVal !== null && typeof cellVal !== 'undefined') {
          nonEmptyCellsInAxis++;
        }
      }
    }
    // 転置されている場合、getNumHeaders() はヘッダー「列」数を意味する
    headersToSubtract = configuredHeaders; 
    Logger.log(`  (主要軸が水平と判断されたため、getNumHeaders()はヘッダー「列」数を意味します。)`);
  }

  let finalSeriesCount = nonEmptyCellsInAxis;

  // ヘッダーがある場合、系列数から減算
  // configuredHeaders が負の値(自動検出)の場合、ここでは差し引かない方針とします。
  if (headersToSubtract > 0) {
    // isOrigin ロジック: データ範囲の左上セル (rangeValues[0][0]) が空である場合に、
    // ヘッダー数を調整します。これは、一部のグラフで空の左上セルが特殊な意味を持つ場合に有効です。
    // 例: A1が空で、A2からラベルが始まるが、グラフがA1もデータ範囲に含む場合など。
    const isTopLeftCellEmpty = (rangeValues.length > 0 && rangeValues[0].length > 0 && 
                                (rangeValues[0][0] === '' || rangeValues[0][0] === null || typeof rangeValues[0][0] === 'undefined'));
    
    // ヘッダー数を減算する前に、isTopLeftCellEmpty の状態を考慮して調整
    // isTopLeftCellEmpty が true であれば、headersToSubtract を1減らす(ヘッダーとして扱わない)
    // このロジックは、グラフのデータ構造と、headersToSubtract の意味合いによって微調整が必要です。
    // 例えば、`configuredHeaders` が1のときに isTopLeftCellEmpty で0にする、など。
    
    // ここでは、isTopLeftCellEmpty が真の場合に、headersToSubtract を1減らすシンプル版を適用
    const adjustedHeadersToSubtract = isTopLeftCellEmpty ? Math.max(0, headersToSubtract - 1) : headersToSubtract;
    Logger.log(`  左上セルが空かのチェック: ${isTopLeftCellEmpty}`);
    Logger.log(`  調整されたヘッダー数: ${adjustedHeadersToSubtract}`);

    if (finalSeriesCount > adjustedHeadersToSubtract) {
      finalSeriesCount -= adjustedHeadersToSubtract;
      Logger.log(`  グラフ設定のヘッダー (${adjustedHeadersToSubtract}個) を除外しました。`);
    } else {
      // 非空セル数 <= 調整済みヘッダー数 の場合、データとしては0とする
      finalSeriesCount = 0;
      Logger.log("  注意: 非空セル数がヘッダー数以下であるため、系列数を0としました。");
    }
  } else if (headersToSubtract === 0) {
      Logger.log("  グラフ設定でヘッダーは0と指定されています。");
  } else if (headersToSubtract < 0) {
      Logger.log(`  グラフ設定でヘッダーが自動検出されています (値: ${headersToSubtract})。`);
      // 自動検出の場合、ヘッダーを差し引くかどうかの追加ロジックが必要な場合がある。
      // 現状は、自動検出された値が負なので、ここでは自動的に差し引かない方針です。
  }

  Logger.log(`  最終的な系列数: ${finalSeriesCount}`);
  return finalSeriesCount;
}

/**
 * 完全にランダムなHEXカラー文字列を生成します。
 *
 * @returns {string} ランダムに生成されたHEXカラー文字列(例: '#3a7fbe')。
 */
function generateRandomHexColor() {
  return '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
}

Discussion