Skip to content
Skip to chapter

Chapter 36

Advanced integrations and data

Keep one Sheets chart up to date

Build one embedded Sheets chart with Sheet.newChart() in Apps Script, save its chart ID, and update its legend without inserting duplicate charts.

By the end

Create and inspect one embedded chart, change a source value and the legend position, and restore the example while keeping the same chart ID.

Chapter navigation

Compare ready planters with capacity in three fictional garden zones. Create a column chart beside the table, inspect its saved ID, then change a data value and a legend option. Later updates change the same chart without inserting a copy.

You need Read and write Sheets in batches first. This project uses that chapter's bound-script setup and rectangular arrays, and it adds a chart builder and a saved record. It needs no other API, deployment, or trigger.

Choose which kind of chart to build

Three Google chart tools share words such as "column chart" but produce different objects:

Kind of chartInput and outputWhere the code runs
Spreadsheet embedded chartA Range supplies cells to an EmbeddedChart placed over a sheet. Sheet.newChart() starts its builder.Apps Script server code using SpreadsheetApp
Standalone Charts serviceA Charts data table supplies typed columns and rows to a chart that can be rendered as an image. It is not automatically attached to a spreadsheet.Apps Script server code using builders such as Charts.newColumnChart()
Browser Google ChartsBrowser data and options are drawn into a page element after the chart library loads.Browser JavaScript using google.charts and google.visualization

Use an embedded chart when the result belongs beside a working Sheets table. The standalone Charts example builds a data table and renders a chart image. The browser library guide loads packages before drawing into an HTML element. Neither produces the EmbeddedChart that Sheet.insertChart() accepts. Do not paste browser loading code into Code.gs, and do not pass a standalone chart to insertChart().

This project's builder does use Charts.ChartType.COLUMN. That enum only names a chart type. The builder still comes from Sheet.newChart() and still produces an EmbeddedChart. The Sheet reference shows column-chart updates with this enum.

Shape the data before styling it

The supplied sample.csv contains this four-row, three-column table:

ZoneReadyCapacity
North bed46
Courtyard14
Roof beds58

The first row names the columns. The first column holds the category labels, also called the chart's domain. Each remaining column is a numeric series: the Ready counts and the Capacity counts. Three categories and two series give two bars side by side for each zone. The legend names the series, not the zones.

A column chart makes the two counts easy to compare within each zone. A line chart would suggest a sequence or trend between zones. A pie chart would suggest parts of a whole, but Ready is part of Capacity, not a separate part of one total. Do not stack the bars either. Stacking adds the ready planters to the capacity, which counts the ready places twice.

The source range is exactly A1:C4. A whole column or getDataRange() would let unrelated notes or later rows change the input. Three settings tell the builder how to read that range:

  • setNumHeaders(1) uses one header row.

  • setTransposeRowsAndColumns(false) keeps rows as categories.

  • useFirstColumnAsDomain picks the category labels.

The builder reference documents the header and orientation settings, and the column-chart options document the domain option.

The code rejects missing or reordered headers, blank or duplicate zone labels, formulas, merged cells, and counts outside the lesson's limits (0 <= Ready <= Capacity <= 8). A count must be a number, not text that looks like one. getValues() keeps the value types Sheets returns, and a blank cell comes back as an empty string. Turning every blank into a number would record missing data as a measured 0. See the Range reference.

Prepare a disposable spreadsheet

  1. Create a blank spreadsheet and name its tab Garden chart. Keep the first four rows and first three columns visible, unmerged, and unfiltered.

  2. Copy the sample.csv data shown below into the empty cell A1. If it lands in one column, select only the four pasted cells A1:A4, choose Data > Split text to columns, and select Comma. Check that the table fills exactly A1:C4 and that the six counts are numbers. Do not split or replace other cells.

  3. Leave empty space to the right of the table for a chart anchored at E1, 600 by 360 pixels. The chart floats over the grid. It does not store an image in E1 and does not erase the cells under it. The script refuses to run if a chart is already anchored at E1, but that check cannot tell whether the rest of the display area is clear. Check that the full chart area is clear.

  4. Open Extensions > Apps Script from this spreadsheet. Replace Code.gs with the complete supplied file. In Project Settings, enable Show appsscript.json manifest file in editor, then replace that file with the supplied manifest.

  5. In GARDEN_CHART, replace spreadsheetId with the string between /d/ and /edit in the spreadsheet URL. Select the Garden chart tab and replace sheetId: null with the numeric gid from its URL, without quotes. A sheet ID of 0 is valid. The Spreadsheet reference describes these IDs.

  6. Keep dataRange as 'A1:C4', legendPosition as 'bottom', and confirmedChartId as null. Save the project.

sample.csv
Zone,Ready,Capacity
North bed,4,6
Courtyard,1,4
Roof beds,5,8

Download sample.csv
Code.gs
/** @OnlyCurrentDoc */
/**
 * @type {{
 *   spreadsheetId: string,
 *   sheetId: number|null,
 *   dataRange: string,
 *   legendPosition: string,
 *   confirmedChartId: number|null
 * }}
 */
const GARDEN_CHART = {
  spreadsheetId: 'PASTE_SPREADSHEET_ID',
  sheetId: null, // Replace with the numeric gid from the practice sheet's URL; 0 is valid.
  dataRange: 'A1:C4', // The lesson owns this exact input rectangle.
  legendPosition: 'bottom', // The exercise changes this to 'right', then restores 'bottom'.
  confirmedChartId: null, // Set only after inspecting the newly inserted chart.
};
const GARDEN_CHART_RECORD_KEY = 'gardenChart.v1';

/**
 * @typedef {{
 *   spreadsheet: SpreadsheetApp.Spreadsheet,
 *   sheet: SpreadsheetApp.Sheet,
 *   range: SpreadsheetApp.Range,
 *   properties: PropertiesService.Properties
 * }} GardenChartContext
 */
/**
 * @typedef {{
 *   version: number,
 *   spreadsheetId: string,
 *   sheetId: number|null,
 *   dataRange: string,
 *   status: 'insert_pending'|'ready',
 *   beforeChartIds: number[],
 *   chartId: number|null
 * }} GardenChartRecord
 */

function createGardenChart() {
  withGardenChartLock_((context) => {
    const record = readGardenChartRecord_(context);
    const charts = getGardenCharts_(context.sheet);
    if (record !== null) {
      if (record.status === 'insert_pending') {
        throw new Error('An insertion is pending. Inspect and record its chart; do not insert again.');
      }
      requireRecordedGardenChart_(context, record, charts);
      console.log('No insertion: chart ID ' + record.chartId + ' is already recorded.');
      return;
    }
    requireGardenChartData_(context.range);
    if (charts.length >= 10) throw new Error('Use a practice sheet with fewer than ten charts.');
    if (charts.some((chart) => {
      const position = chart.getContainerInfo();
      return position.getAnchorRow() === 1 && position.getAnchorColumn() === 5;
    })) {
      throw new Error('A chart is already anchored at E1. Inspect it; this script will not replace it.');
    }
    const chart = context.sheet.newChart()
      .setChartType(Charts.ChartType.COLUMN)
      .addRange(context.range)
      .setNumHeaders(1)
      .setTransposeRowsAndColumns(false)
      .setHiddenDimensionStrategy(Charts.ChartHiddenDimensionStrategy.IGNORE_ROWS)
      .setOption('useFirstColumnAsDomain', true)
      .setOption('title', 'Garden readiness')
      .setOption('hAxis', { title: 'Garden zone' })
      .setOption('vAxis', { title: 'Planters', minValue: 0, maxValue: 8 })
      .setOption('colors', ['#188038', '#1a73e8'])
      .setOption('isStacked', false)
      .setOption('legend', { position: GARDEN_CHART.legendPosition })
      .setOption('width', 600)
      .setOption('height', 360)
      .setPosition(1, 5, 0, 0)
      .build();
    // A built, uninserted chart has no spreadsheet chart ID to record.
    const pending = {
      version: 1,
      spreadsheetId: GARDEN_CHART.spreadsheetId,
      sheetId: GARDEN_CHART.sheetId,
      dataRange: GARDEN_CHART.dataRange,
      status: 'insert_pending',
      beforeChartIds: charts.map((existing) => existing.getChartId()),
      chartId: null,
    };
    context.properties.setProperty(GARDEN_CHART_RECORD_KEY, JSON.stringify(pending));
    // Do not delete the pending record if insertion, flush, or logging fails.
    context.sheet.insertChart(chart);
    SpreadsheetApp.flush();
    console.log('Insertion call returned. Run inspectGardenChart, then record the actual chart ID.');
  });
}

function inspectGardenChart() {
  withGardenChartLock_((context) => {
    const record = readGardenChartRecord_(context);
    const charts = getGardenCharts_(context.sheet);
    console.log(JSON.stringify({
      spreadsheetId: context.spreadsheet.getId(),
      sheetId: context.sheet.getSheetId(),
      sheetName: context.sheet.getName(),
      dataRange: GARDEN_CHART.dataRange,
      values: context.range.getValues(),
      record,
      requestedLegend: GARDEN_CHART.legendPosition,
      charts: charts.map((chart) => ({
        ...describeGardenChart_(chart),
        newSinceIntent: record !== null && !record.beforeChartIds.includes(requireGardenChartId_(chart)),
        matchesLessonShape: matchesGardenChart_(context, chart),
      })),
    }, null, 2));
  });
}

function recordGardenChart() {
  withGardenChartLock_((context) => {
    const confirmedId = GARDEN_CHART.confirmedChartId;
    if (typeof confirmedId !== 'number' || !Number.isSafeInteger(confirmedId)) {
      throw new Error('Inspect the output, then set confirmedChartId to its numeric chart ID.');
    }
    const record = readGardenChartRecord_(context);
    if (record === null) throw new Error('No insertion intent exists. Do not adopt an unrelated chart.');
    const charts = getGardenCharts_(context.sheet);
    if (record.status === 'ready') {
      if (record.chartId !== confirmedId) throw new Error('The recorded chart ID cannot be replaced.');
      requireRecordedGardenChart_(context, record, charts);
      console.log('No change: this chart ID is already recorded.');
      return;
    }
    const newCharts = charts.filter((chart) => !record.beforeChartIds.includes(requireGardenChartId_(chart)));
    const originalChartsRemain = record.beforeChartIds.every((id) =>
      charts.some((chart) => chart.getChartId() === id));
    if (!originalChartsRemain || newCharts.length !== 1 || newCharts[0].getChartId() !== confirmedId) {
      throw new Error('Expected exactly one new chart and every original chart. Stop and inspect; do not guess.');
    }
    if (!matchesGardenChart_(context, newCharts[0])) {
      throw new Error('The confirmed chart does not have the lesson range, type, headers, and E1 placement.');
    }
    requireGardenChartData_(context.range);
    record.chartId = confirmedId;
    record.status = 'ready';
    context.properties.setProperty(GARDEN_CHART_RECORD_KEY, JSON.stringify(record));
    console.log('Recorded chart ID ' + confirmedId + '. Reset confirmedChartId to null in Code.gs.');
  });
}

function updateGardenChart() {
  withGardenChartLock_((context) => {
    const record = readGardenChartRecord_(context);
    if (record === null || record.status !== 'ready') {
      throw new Error('Inspect and record the inserted chart before updating it.');
    }
    const chart = requireRecordedGardenChart_(context, record, getGardenCharts_(context.sheet));
    requireGardenChartData_(context.range);
    const changedChart = chart.modify()
      .setOption('legend', { position: GARDEN_CHART.legendPosition })
      .build();
    if (changedChart.getChartId() !== record.chartId) {
      throw new Error('The rebuilt chart did not retain the recorded ID. No update was submitted.');
    }
    // Updating an existing chart is not inserting another chart.
    context.sheet.updateChart(changedChart);
    SpreadsheetApp.flush();
    const observed = requireRecordedGardenChart_(context, record, getGardenCharts_(context.sheet));
    console.log(JSON.stringify({ requestedLegend: GARDEN_CHART.legendPosition, observed: describeGardenChart_(observed) }, null, 2));
  });
}

/**
 * @param {function(GardenChartContext): void} action
 * @returns {void}
 */
function withGardenChartLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) throw new Error('Another lesson run holds the lock. Let it finish, then inspect.');
  try {
    return action(getGardenChartContext_());
  } finally {
    lock.releaseLock();
  }
}

/**
 * @returns {GardenChartContext}
 */
function getGardenChartContext_() {
  const sheetId = GARDEN_CHART.sheetId;
  if (GARDEN_CHART.spreadsheetId === 'PASTE_SPREADSHEET_ID' ||
      !/^[A-Za-z0-9_-]+$/.test(GARDEN_CHART.spreadsheetId) ||
      typeof sheetId !== 'number' || !Number.isSafeInteger(sheetId) || sheetId < 0) {
    throw new Error('Configure the practice spreadsheet ID and numeric sheet ID before running.');
  }
  if (GARDEN_CHART.dataRange !== 'A1:C4' ||
      !['bottom', 'right'].includes(GARDEN_CHART.legendPosition)) {
    throw new Error('Keep dataRange A1:C4 and use legendPosition bottom or right.');
  }
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (spreadsheet === null || spreadsheet.getId() !== GARDEN_CHART.spreadsheetId) {
    throw new Error('Run from the Apps Script project bound to the configured practice spreadsheet.');
  }
  const sheet = spreadsheet.getSheetById(sheetId);
  if (sheet === null) throw new Error('The configured practice sheet is missing. Do not choose another by position.');
  if (sheet.getMaxRows() < 4 || sheet.getMaxColumns() < 5) {
    throw new Error('The practice sheet must contain A1:C4 and anchor cell E1.');
  }
  return {
    spreadsheet,
    sheet,
    range: sheet.getRange(GARDEN_CHART.dataRange),
    properties: PropertiesService.getScriptProperties(),
  };
}

/**
 * @param {SpreadsheetApp.Range} range
 * @returns {void}
 */
function requireGardenChartData_(range) {
  if (range.isPartOfMerge()) throw new Error('Use twelve unmerged cells in A1:C4.');
  if (range.getFormulas().some((row) => row.some((formula) => formula !== ''))) {
    throw new Error('Use the supplied plain values, not formulas, in A1:C4.');
  }
  const values = range.getValues();
  const headers = ['Zone', 'Ready', 'Capacity'];
  if (!headers.every((header, column) => values[0][column] === header)) {
    throw new Error('A1:C1 must contain Zone, Ready, Capacity in that order.');
  }
  const labels = new Set();
  for (let row = 1; row < 4; row += 1) {
    const [zone, ready, capacity] = values[row];
    if (typeof zone !== 'string' || zone.trim() === '' || zone !== zone.trim() || labels.has(zone)) {
      throw new Error('Use three distinct nonempty zone labels without surrounding spaces.');
    }
    if (typeof ready !== 'number' || typeof capacity !== 'number' ||
        !Number.isInteger(ready) || !Number.isInteger(capacity) ||
        ready < 0 || capacity < 1 || ready > capacity || capacity > 8) {
      throw new Error('Use numeric whole counts: 0 <= Ready <= Capacity <= 8, with Capacity at least 1.');
    }
    labels.add(zone);
  }
}

/**
 * @param {GardenChartContext} context
 * @returns {GardenChartRecord|null}
 */
function readGardenChartRecord_(context) {
  const raw = context.properties.getProperty(GARDEN_CHART_RECORD_KEY);
  if (raw === null) return null;
  /** @type {GardenChartRecord} */
  const record = JSON.parse(raw);
  if (record === null || typeof record !== 'object' || Array.isArray(record) || record.version !== 1 ||
      record.spreadsheetId !== GARDEN_CHART.spreadsheetId || record.sheetId !== GARDEN_CHART.sheetId ||
      record.dataRange !== GARDEN_CHART.dataRange || !['insert_pending', 'ready'].includes(record.status) ||
      !Array.isArray(record.beforeChartIds) || record.beforeChartIds.length >= 10 ||
      !record.beforeChartIds.every(Number.isSafeInteger) ||
      new Set(record.beforeChartIds).size !== record.beforeChartIds.length ||
      (record.status === 'insert_pending' && record.chartId !== null) ||
      (record.status === 'ready' && (typeof record.chartId !== 'number' ||
        !Number.isSafeInteger(record.chartId) || record.beforeChartIds.includes(record.chartId)))) {
    throw new Error('The saved chart record does not match this lesson target. Preserve it and inspect project settings.');
  }
  return record;
}

/**
 * @param {SpreadsheetApp.Sheet} sheet
 * @returns {SpreadsheetApp.EmbeddedChart[]}
 */
function getGardenCharts_(sheet) {
  const charts = sheet.getCharts();
  if (charts.length > 10) throw new Error('This lesson inspects at most ten charts on its target sheet.');
  const ids = charts.map((chart) => chart.getChartId());
  if (!ids.every(Number.isSafeInteger) || new Set(ids).size !== ids.length) {
    throw new Error('Could not read distinct stable chart IDs. Stop and inspect the spreadsheet.');
  }
  return charts;
}

/**
 * @param {GardenChartContext} context
 * @param {SpreadsheetApp.EmbeddedChart} chart
 * @returns {boolean}
 */
function matchesGardenChart_(context, chart) {
  const ranges = chart.getRanges();
  const position = chart.getContainerInfo();
  return ranges.length === 1 && ranges[0].getSheet().getSheetId() === context.sheet.getSheetId() &&
    ranges[0].getA1Notation() === GARDEN_CHART.dataRange &&
    chart.modify().getChartType() === Charts.ChartType.COLUMN && chart.getNumHeaders() === 1 &&
    chart.getTransposeRowsAndColumns() === false &&
    position.getAnchorRow() === 1 && position.getAnchorColumn() === 5 &&
    position.getOffsetX() === 0 && position.getOffsetY() === 0;
}

/**
 * @param {GardenChartContext} context
 * @param {GardenChartRecord} record
 * @param {SpreadsheetApp.EmbeddedChart[]} charts
 * @returns {SpreadsheetApp.EmbeddedChart}
 */
function requireRecordedGardenChart_(context, record, charts) {
  const chart = charts.find((candidate) => candidate.getChartId() === record.chartId);
  if (!chart) throw new Error('The recorded chart is missing from this sheet. Do not create a replacement automatically.');
  if (!matchesGardenChart_(context, chart)) {
    throw new Error('The recorded chart was moved or its data/type/header structure changed. Inspect before updating.');
  }
  return chart;
}

/**
 * @param {SpreadsheetApp.EmbeddedChart} chart
 * @returns {number}
 */
function requireGardenChartId_(chart) {
  const chartId = chart.getChartId();
  if (typeof chartId !== 'number' || !Number.isSafeInteger(chartId)) {
    throw new Error('Could not read a stable chart ID. Stop and inspect the spreadsheet.');
  }
  return chartId;
}

/**
 * @param {SpreadsheetApp.EmbeddedChart} chart
 * @returns {{
 *   chartId: number,
 *   type: string,
 *   ranges: {sheetId: number, a1: string}[],
 *   headers: number,
 *   transposed: boolean,
 *   hiddenDimensions: string,
 *   anchor: {row: number, column: number, x: number, y: number},
 *   title: *,
 *   legend: *,
 *   hAxis: *,
 *   vAxis: *
 * }}
 */
function describeGardenChart_(chart) {
  const position = chart.getContainerInfo();
  const options = chart.getOptions();
  return {
    chartId: requireGardenChartId_(chart),
    type: String(chart.modify().getChartType()),
    ranges: chart.getRanges().map((range) => ({ sheetId: range.getSheet().getSheetId(), a1: range.getA1Notation() })),
    headers: chart.getNumHeaders(),
    transposed: chart.getTransposeRowsAndColumns(),
    hiddenDimensions: String(chart.getHiddenDimensionStrategy()),
    anchor: { row: position.getAnchorRow(), column: position.getAnchorColumn(), x: position.getOffsetX(), y: position.getOffsetY() },
    title: options.get('title'),
    legend: options.get('legend'),
    hAxis: options.get('hAxis'),
    vAxis: options.get('vAxis'),
  };
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/spreadsheets.currentonly"
  ]
}

Download appsscript.json

You run these functions from the Apps Script editor under your own Google account. On the first run, read the authorization request. The manifest requests spreadsheets.currentonly, which grants read and write access to this whole spreadsheet, including cells beyond the twelve the lesson uses, and to no other file. The code checks which cells and charts it changes, while the OAuth grant covers the whole spreadsheet. The authorization guide covers scopes and execution identity, and the Sheet reference lists the scopes of the chart methods.

The script never changes cell values, formatting, rows, or columns. You supply the table and make the exercise's one-cell edit yourself. The script can insert one chart, update that chart's legend, save one script property, and write inspection details to the execution log. Those details include file, sheet, and chart IDs, so keep the log private.

Create, inspect, and record the chart

  1. Select inspectGardenChart in the editor and click Run. Approve the current-spreadsheet access if asked. Check the logged spreadsheet ID, sheet ID, sheet name, dataRange, and values. The values should match the table above. In a fresh project, record is null and the chart list is empty. If a record already exists, inspect it rather than resetting it.

  2. Run createGardenChart once. When the insertion call returns, the log tells you to inspect and record the actual chart ID. The saved status stays insert_pending, because creating the chart and confirming its ID are separate steps.

  3. Run inspectGardenChart again. Find exactly one new chart with newSinceIntent: true and matchesLessonShape: true. Compare the old chart IDs in record.beforeChartIds with the current list, and confirm that every old chart is still there. In the fresh sheet, there is one chart in total.

  4. Return to Sheets and find the Garden readiness chart at E1. It shows the three zones, a Ready bar and a Capacity bar side by side for each, and a legend below the chart. The starting pairs are 4 and 6, 1 and 4, and 5 and 8.

  5. Copy that chart's numeric chartId from the inspection log into confirmedChartId, without quotes. Run recordGardenChart. It requires the confirmed ID to be the one new chart. It checks that chart's range, column type, header count, orientation, and exact placement, then saves the ID with status ready.

  6. Set confirmedChartId back to null, save, and inspect again. Keep the recorded spreadsheet ID, sheet ID, and chart ID for the rest of the exercise.

If there is no new chart, more than one new chart, a missing old chart, or a shape mismatch, stop before you confirm and follow the recovery section. newSinceIntent only means the chart ID was not in the list saved before insertion. It does not prove that this script created the chart, so look at the chart yourself.

Understand what the builder saves

Sheet.newChart() returns an EmbeddedChartBuilder, which describes a chart that you can change before you apply it to the sheet. The chained setters fill in that description. build() produces an EmbeddedChart but does not insert or draw anything. insertChart() adds it to the sheet. To update an inserted chart:

  1. Call its modify() method to get a builder.

  2. Change the builder settings and call build().

  3. Pass the result to updateChart().

The builder documentation describes these steps.

A chart that has been built but not inserted has no chart ID, so getChartId() returns null. After the chart is inserted, its ID is stable and unique within its spreadsheet. The code reads inserted charts through getCharts() instead of assuming that the builder object gained an ID or that insertChart() returned the new chart. It records the inspected ID together with the spreadsheet and sheet IDs. See EmbeddedChart.getChartId.

A chart's position in the getCharts() array can shift when another chart is added, moved, or deleted, and two charts can share a title. The script therefore finds the chart by its saved ID, then compares the range, type, headers, and position to detect edits.

Placement uses one-based coordinates. setPosition(1, 5, 0, 0) means row 1, column 5 (cell E1), with zero horizontal and vertical pixel offsets. These are not the zero-based indices of the values array. getContainerInfo() returns that anchor and those offsets for inspection. See ContainerInfo.

The chart title names the comparison. hAxis.title labels the horizontal axis Garden zone, and vAxis.title labels the vertical axis Planters. The requested vertical bounds run from zero through eight, so the bars start at zero instead of at a cut-off baseline that would exaggerate the differences. Sheets still chooses the tick layout. The two colors tell the series apart, and the legend names them.

Options such as legend and vAxis are objects passed to setOption(). That method does not check an option's spelling, structure, or fit for the chart type. The option names used here and the 'bottom' and 'right' legend positions come from the column-chart configuration reference. An execution that finishes without an exception does not prove that a visual option took effect, so look at the chart.

Repeat without inserting duplicates

After you record the ID, run createGardenChart again. It reports that the chart is already recorded and inserts nothing. If the recorded chart is missing, or its range, type, header structure, or placement has changed, it stops instead of inserting a replacement.

The script stores the record as JSON under gardenChart.v1 in Script Properties, which every run of this script project shares. See the Properties guide for storage scopes and string values.

Change a value and a chart option, then restore them

The cells hold the data. The chart's range and options decide how that data is shown. Editing a source cell changes the chart's input, whereas changing a legend option leaves the cells alone.

  1. In the Courtyard row, Ready is 1 and Capacity is 4. Predict what changing B3 to 3 does to the gap between the two bars. Then predict what moving the legend from below the chart to its right changes and what it cannot change.

  2. In Sheets, change only B3 from 1 to 3. Leave C3 as 4. Do not edit the CSV or paste the whole table again. Look at the chart after Sheets refreshes it, then run inspectGardenChart to check that the logged values include the new count.

  3. In Code.gs, change only legendPosition from 'bottom' to 'right' and save. Run updateGardenChart, not createGardenChart. It finds the recorded chart, rebuilds its legend setting, and calls updateChart().

  4. The Courtyard Ready bar now shows 3, so the gap to Capacity shrinks from 3 planters to 1. The other two pairs are still 4 and 6, and 5 and 8. The legend is on the right, and the plot may get a different amount of space. Compare the values in the log, because bar heights change with the layout. Check that the observed chart ID in the update log matches your recorded ID, then inspect again and confirm that the chart count did not grow.

  5. Run updateGardenChart again without changing anything. It requests the same legend setting for the same ID. Inspect again and confirm that the chart count did not change.

  6. Change B3 back to 1, set legendPosition back to 'bottom', save, and run updateGardenChart. Inspect again and confirm the original three pairs, the legend below the chart, and the same recorded chart ID.

The update starts from chart.modify(), so it keeps the chart's current settings instead of building a new chart with guessed defaults. It replaces only the legend setting. A legend change you made by hand may therefore be replaced, whereas a title or color change you made by hand is not. A structural change, such as dragging the chart, changing its source range, or switching its type, makes the script stop so you can undo or investigate it.

getOptions() returns read-only options, and get() returns a configured value or null when none is set. Changing a JavaScript object read from those options saves nothing. Save changes through a builder and updateChart(). See ChartOptions and EmbeddedChart.modify.

Separate pending work from a missing output

The code calls SpreadsheetApp.flush() after an insertion or update, so pending spreadsheet changes are applied before it releases the lock. That does not guarantee that your browser has repainted the chart, and it does not make the chart change and the Script Properties write one transaction. After an execution finishes, refresh Sheets and inspect the same ID before you decide what happened.

Keep the input rows visible. The example uses the hidden-row strategy IGNORE_ROWS, so hiding a data row removes it from the chart. See the SpreadsheetApp reference and the embedded builder's hidden-dimension strategy.

If an execution fails after the insertion call, the chart may exist even though the record still says insert_pending. The property is written before the insertion call, and the insertion can succeed even if the execution then fails while flushing or logging. A pending record means the insertion needs inspection, not that there is no chart. Even if the insertion itself throws, do not assume it had no effect.

SituationNext action
Bad configuration or invalid tableCorrect only the identified practice setting or cell. The script runs these checks before it saves a pending record or inserts a chart. Inspect any existing record before creating.
insert_pending, with one identifiable new chartLet the earlier execution finish. Inspect the chart's ID, its comparison with the baseline, its range, and what it displays. Run recordGardenChart with that confirmed ID to save the chart ID without inserting again.
insert_pending, with no candidate or with conflicting chartsRefresh and inspect after the execution has ended. If you still cannot identify the output, keep the record and stop. Do not clear it, and do not create another chart in the same spreadsheet.
recordGardenChart reports an errorInspect again. The property may still be pending or may already be ready if the write succeeded before a later failure. Confirming the same ID again is allowed, whereas replacing a ready ID is not.
The update or its final inspection failsThe legend change may already be in place. Inspect the recorded ID and the actual chart. Once the cause is fixed, repeating the same legend update needs no insertion.
The recorded chart is missing or structurally changedStop and inspect the actual file and sheet. The script does not search by title, move a replacement into place, or recreate a deleted chart.
Permission, service, or lock errorRead the execution error. Check the acting account and the target, and wait for the execution to finish before you try again. Do not grant broader access or remove safety checks to get past it.

The Lock reference explains taking a lock and flushing before release. The five-second wait limit keeps one run of these functions from waiting forever for another. The lock does not stop you or another script project from editing the data or deleting a chart, and it cannot guarantee exactly-once creation if someone deletes the saved record.

Remove only the identified practice output

Run inspectGardenChart and match the recorded spreadsheet, sheet, and chart ID to the chart with the A1:C4 data range, column type, and E1 anchor. In Sheets, select that chart and delete it from its chart menu. Leave the saved record in place, so a later creation run stops at the missing chart instead of recreating it. If nothing else depends on the twelve practice cells, clear A1:C4 by hand.

Next, Package CSV, JSON, and a PDF for handoff.

Project files

Complete source files for this chapter's examples.

Create, identify, and update one garden chart
Code.gs
/** @OnlyCurrentDoc */
/**
 * @type {{
 *   spreadsheetId: string,
 *   sheetId: number|null,
 *   dataRange: string,
 *   legendPosition: string,
 *   confirmedChartId: number|null
 * }}
 */
const GARDEN_CHART = {
  spreadsheetId: 'PASTE_SPREADSHEET_ID',
  sheetId: null, // Replace with the numeric gid from the practice sheet's URL; 0 is valid.
  dataRange: 'A1:C4', // The lesson owns this exact input rectangle.
  legendPosition: 'bottom', // The exercise changes this to 'right', then restores 'bottom'.
  confirmedChartId: null, // Set only after inspecting the newly inserted chart.
};
const GARDEN_CHART_RECORD_KEY = 'gardenChart.v1';

/**
 * @typedef {{
 *   spreadsheet: SpreadsheetApp.Spreadsheet,
 *   sheet: SpreadsheetApp.Sheet,
 *   range: SpreadsheetApp.Range,
 *   properties: PropertiesService.Properties
 * }} GardenChartContext
 */
/**
 * @typedef {{
 *   version: number,
 *   spreadsheetId: string,
 *   sheetId: number|null,
 *   dataRange: string,
 *   status: 'insert_pending'|'ready',
 *   beforeChartIds: number[],
 *   chartId: number|null
 * }} GardenChartRecord
 */

function createGardenChart() {
  withGardenChartLock_((context) => {
    const record = readGardenChartRecord_(context);
    const charts = getGardenCharts_(context.sheet);
    if (record !== null) {
      if (record.status === 'insert_pending') {
        throw new Error('An insertion is pending. Inspect and record its chart; do not insert again.');
      }
      requireRecordedGardenChart_(context, record, charts);
      console.log('No insertion: chart ID ' + record.chartId + ' is already recorded.');
      return;
    }
    requireGardenChartData_(context.range);
    if (charts.length >= 10) throw new Error('Use a practice sheet with fewer than ten charts.');
    if (charts.some((chart) => {
      const position = chart.getContainerInfo();
      return position.getAnchorRow() === 1 && position.getAnchorColumn() === 5;
    })) {
      throw new Error('A chart is already anchored at E1. Inspect it; this script will not replace it.');
    }
    const chart = context.sheet.newChart()
      .setChartType(Charts.ChartType.COLUMN)
      .addRange(context.range)
      .setNumHeaders(1)
      .setTransposeRowsAndColumns(false)
      .setHiddenDimensionStrategy(Charts.ChartHiddenDimensionStrategy.IGNORE_ROWS)
      .setOption('useFirstColumnAsDomain', true)
      .setOption('title', 'Garden readiness')
      .setOption('hAxis', { title: 'Garden zone' })
      .setOption('vAxis', { title: 'Planters', minValue: 0, maxValue: 8 })
      .setOption('colors', ['#188038', '#1a73e8'])
      .setOption('isStacked', false)
      .setOption('legend', { position: GARDEN_CHART.legendPosition })
      .setOption('width', 600)
      .setOption('height', 360)
      .setPosition(1, 5, 0, 0)
      .build();
    // A built, uninserted chart has no spreadsheet chart ID to record.
    const pending = {
      version: 1,
      spreadsheetId: GARDEN_CHART.spreadsheetId,
      sheetId: GARDEN_CHART.sheetId,
      dataRange: GARDEN_CHART.dataRange,
      status: 'insert_pending',
      beforeChartIds: charts.map((existing) => existing.getChartId()),
      chartId: null,
    };
    context.properties.setProperty(GARDEN_CHART_RECORD_KEY, JSON.stringify(pending));
    // Do not delete the pending record if insertion, flush, or logging fails.
    context.sheet.insertChart(chart);
    SpreadsheetApp.flush();
    console.log('Insertion call returned. Run inspectGardenChart, then record the actual chart ID.');
  });
}

function inspectGardenChart() {
  withGardenChartLock_((context) => {
    const record = readGardenChartRecord_(context);
    const charts = getGardenCharts_(context.sheet);
    console.log(JSON.stringify({
      spreadsheetId: context.spreadsheet.getId(),
      sheetId: context.sheet.getSheetId(),
      sheetName: context.sheet.getName(),
      dataRange: GARDEN_CHART.dataRange,
      values: context.range.getValues(),
      record,
      requestedLegend: GARDEN_CHART.legendPosition,
      charts: charts.map((chart) => ({
        ...describeGardenChart_(chart),
        newSinceIntent: record !== null && !record.beforeChartIds.includes(requireGardenChartId_(chart)),
        matchesLessonShape: matchesGardenChart_(context, chart),
      })),
    }, null, 2));
  });
}

function recordGardenChart() {
  withGardenChartLock_((context) => {
    const confirmedId = GARDEN_CHART.confirmedChartId;
    if (typeof confirmedId !== 'number' || !Number.isSafeInteger(confirmedId)) {
      throw new Error('Inspect the output, then set confirmedChartId to its numeric chart ID.');
    }
    const record = readGardenChartRecord_(context);
    if (record === null) throw new Error('No insertion intent exists. Do not adopt an unrelated chart.');
    const charts = getGardenCharts_(context.sheet);
    if (record.status === 'ready') {
      if (record.chartId !== confirmedId) throw new Error('The recorded chart ID cannot be replaced.');
      requireRecordedGardenChart_(context, record, charts);
      console.log('No change: this chart ID is already recorded.');
      return;
    }
    const newCharts = charts.filter((chart) => !record.beforeChartIds.includes(requireGardenChartId_(chart)));
    const originalChartsRemain = record.beforeChartIds.every((id) =>
      charts.some((chart) => chart.getChartId() === id));
    if (!originalChartsRemain || newCharts.length !== 1 || newCharts[0].getChartId() !== confirmedId) {
      throw new Error('Expected exactly one new chart and every original chart. Stop and inspect; do not guess.');
    }
    if (!matchesGardenChart_(context, newCharts[0])) {
      throw new Error('The confirmed chart does not have the lesson range, type, headers, and E1 placement.');
    }
    requireGardenChartData_(context.range);
    record.chartId = confirmedId;
    record.status = 'ready';
    context.properties.setProperty(GARDEN_CHART_RECORD_KEY, JSON.stringify(record));
    console.log('Recorded chart ID ' + confirmedId + '. Reset confirmedChartId to null in Code.gs.');
  });
}

function updateGardenChart() {
  withGardenChartLock_((context) => {
    const record = readGardenChartRecord_(context);
    if (record === null || record.status !== 'ready') {
      throw new Error('Inspect and record the inserted chart before updating it.');
    }
    const chart = requireRecordedGardenChart_(context, record, getGardenCharts_(context.sheet));
    requireGardenChartData_(context.range);
    const changedChart = chart.modify()
      .setOption('legend', { position: GARDEN_CHART.legendPosition })
      .build();
    if (changedChart.getChartId() !== record.chartId) {
      throw new Error('The rebuilt chart did not retain the recorded ID. No update was submitted.');
    }
    // Updating an existing chart is not inserting another chart.
    context.sheet.updateChart(changedChart);
    SpreadsheetApp.flush();
    const observed = requireRecordedGardenChart_(context, record, getGardenCharts_(context.sheet));
    console.log(JSON.stringify({ requestedLegend: GARDEN_CHART.legendPosition, observed: describeGardenChart_(observed) }, null, 2));
  });
}

/**
 * @param {function(GardenChartContext): void} action
 * @returns {void}
 */
function withGardenChartLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) throw new Error('Another lesson run holds the lock. Let it finish, then inspect.');
  try {
    return action(getGardenChartContext_());
  } finally {
    lock.releaseLock();
  }
}

/**
 * @returns {GardenChartContext}
 */
function getGardenChartContext_() {
  const sheetId = GARDEN_CHART.sheetId;
  if (GARDEN_CHART.spreadsheetId === 'PASTE_SPREADSHEET_ID' ||
      !/^[A-Za-z0-9_-]+$/.test(GARDEN_CHART.spreadsheetId) ||
      typeof sheetId !== 'number' || !Number.isSafeInteger(sheetId) || sheetId < 0) {
    throw new Error('Configure the practice spreadsheet ID and numeric sheet ID before running.');
  }
  if (GARDEN_CHART.dataRange !== 'A1:C4' ||
      !['bottom', 'right'].includes(GARDEN_CHART.legendPosition)) {
    throw new Error('Keep dataRange A1:C4 and use legendPosition bottom or right.');
  }
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (spreadsheet === null || spreadsheet.getId() !== GARDEN_CHART.spreadsheetId) {
    throw new Error('Run from the Apps Script project bound to the configured practice spreadsheet.');
  }
  const sheet = spreadsheet.getSheetById(sheetId);
  if (sheet === null) throw new Error('The configured practice sheet is missing. Do not choose another by position.');
  if (sheet.getMaxRows() < 4 || sheet.getMaxColumns() < 5) {
    throw new Error('The practice sheet must contain A1:C4 and anchor cell E1.');
  }
  return {
    spreadsheet,
    sheet,
    range: sheet.getRange(GARDEN_CHART.dataRange),
    properties: PropertiesService.getScriptProperties(),
  };
}

/**
 * @param {SpreadsheetApp.Range} range
 * @returns {void}
 */
function requireGardenChartData_(range) {
  if (range.isPartOfMerge()) throw new Error('Use twelve unmerged cells in A1:C4.');
  if (range.getFormulas().some((row) => row.some((formula) => formula !== ''))) {
    throw new Error('Use the supplied plain values, not formulas, in A1:C4.');
  }
  const values = range.getValues();
  const headers = ['Zone', 'Ready', 'Capacity'];
  if (!headers.every((header, column) => values[0][column] === header)) {
    throw new Error('A1:C1 must contain Zone, Ready, Capacity in that order.');
  }
  const labels = new Set();
  for (let row = 1; row < 4; row += 1) {
    const [zone, ready, capacity] = values[row];
    if (typeof zone !== 'string' || zone.trim() === '' || zone !== zone.trim() || labels.has(zone)) {
      throw new Error('Use three distinct nonempty zone labels without surrounding spaces.');
    }
    if (typeof ready !== 'number' || typeof capacity !== 'number' ||
        !Number.isInteger(ready) || !Number.isInteger(capacity) ||
        ready < 0 || capacity < 1 || ready > capacity || capacity > 8) {
      throw new Error('Use numeric whole counts: 0 <= Ready <= Capacity <= 8, with Capacity at least 1.');
    }
    labels.add(zone);
  }
}

/**
 * @param {GardenChartContext} context
 * @returns {GardenChartRecord|null}
 */
function readGardenChartRecord_(context) {
  const raw = context.properties.getProperty(GARDEN_CHART_RECORD_KEY);
  if (raw === null) return null;
  /** @type {GardenChartRecord} */
  const record = JSON.parse(raw);
  if (record === null || typeof record !== 'object' || Array.isArray(record) || record.version !== 1 ||
      record.spreadsheetId !== GARDEN_CHART.spreadsheetId || record.sheetId !== GARDEN_CHART.sheetId ||
      record.dataRange !== GARDEN_CHART.dataRange || !['insert_pending', 'ready'].includes(record.status) ||
      !Array.isArray(record.beforeChartIds) || record.beforeChartIds.length >= 10 ||
      !record.beforeChartIds.every(Number.isSafeInteger) ||
      new Set(record.beforeChartIds).size !== record.beforeChartIds.length ||
      (record.status === 'insert_pending' && record.chartId !== null) ||
      (record.status === 'ready' && (typeof record.chartId !== 'number' ||
        !Number.isSafeInteger(record.chartId) || record.beforeChartIds.includes(record.chartId)))) {
    throw new Error('The saved chart record does not match this lesson target. Preserve it and inspect project settings.');
  }
  return record;
}

/**
 * @param {SpreadsheetApp.Sheet} sheet
 * @returns {SpreadsheetApp.EmbeddedChart[]}
 */
function getGardenCharts_(sheet) {
  const charts = sheet.getCharts();
  if (charts.length > 10) throw new Error('This lesson inspects at most ten charts on its target sheet.');
  const ids = charts.map((chart) => chart.getChartId());
  if (!ids.every(Number.isSafeInteger) || new Set(ids).size !== ids.length) {
    throw new Error('Could not read distinct stable chart IDs. Stop and inspect the spreadsheet.');
  }
  return charts;
}

/**
 * @param {GardenChartContext} context
 * @param {SpreadsheetApp.EmbeddedChart} chart
 * @returns {boolean}
 */
function matchesGardenChart_(context, chart) {
  const ranges = chart.getRanges();
  const position = chart.getContainerInfo();
  return ranges.length === 1 && ranges[0].getSheet().getSheetId() === context.sheet.getSheetId() &&
    ranges[0].getA1Notation() === GARDEN_CHART.dataRange &&
    chart.modify().getChartType() === Charts.ChartType.COLUMN && chart.getNumHeaders() === 1 &&
    chart.getTransposeRowsAndColumns() === false &&
    position.getAnchorRow() === 1 && position.getAnchorColumn() === 5 &&
    position.getOffsetX() === 0 && position.getOffsetY() === 0;
}

/**
 * @param {GardenChartContext} context
 * @param {GardenChartRecord} record
 * @param {SpreadsheetApp.EmbeddedChart[]} charts
 * @returns {SpreadsheetApp.EmbeddedChart}
 */
function requireRecordedGardenChart_(context, record, charts) {
  const chart = charts.find((candidate) => candidate.getChartId() === record.chartId);
  if (!chart) throw new Error('The recorded chart is missing from this sheet. Do not create a replacement automatically.');
  if (!matchesGardenChart_(context, chart)) {
    throw new Error('The recorded chart was moved or its data/type/header structure changed. Inspect before updating.');
  }
  return chart;
}

/**
 * @param {SpreadsheetApp.EmbeddedChart} chart
 * @returns {number}
 */
function requireGardenChartId_(chart) {
  const chartId = chart.getChartId();
  if (typeof chartId !== 'number' || !Number.isSafeInteger(chartId)) {
    throw new Error('Could not read a stable chart ID. Stop and inspect the spreadsheet.');
  }
  return chartId;
}

/**
 * @param {SpreadsheetApp.EmbeddedChart} chart
 * @returns {{
 *   chartId: number,
 *   type: string,
 *   ranges: {sheetId: number, a1: string}[],
 *   headers: number,
 *   transposed: boolean,
 *   hiddenDimensions: string,
 *   anchor: {row: number, column: number, x: number, y: number},
 *   title: *,
 *   legend: *,
 *   hAxis: *,
 *   vAxis: *
 * }}
 */
function describeGardenChart_(chart) {
  const position = chart.getContainerInfo();
  const options = chart.getOptions();
  return {
    chartId: requireGardenChartId_(chart),
    type: String(chart.modify().getChartType()),
    ranges: chart.getRanges().map((range) => ({ sheetId: range.getSheet().getSheetId(), a1: range.getA1Notation() })),
    headers: chart.getNumHeaders(),
    transposed: chart.getTransposeRowsAndColumns(),
    hiddenDimensions: String(chart.getHiddenDimensionStrategy()),
    anchor: { row: position.getAnchorRow(), column: position.getAnchorColumn(), x: position.getOffsetX(), y: position.getOffsetY() },
    title: options.get('title'),
    legend: options.get('legend'),
    hAxis: options.get('hAxis'),
    vAxis: options.get('vAxis'),
  };
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/spreadsheets.currentonly"
  ]
}

sample.csv

Download file
sample.csv
Zone,Ready,Capacity
North bed,4,6
Courtyard,1,4
Roof beds,5,8