Keep one Sheets chart up to date
Use Sheet.newChart() and EmbeddedChartBuilder in Google Apps Script to build a chart from Sheet rows, record its chart ID, and update presentation.
Create and inspect one embedded chart, update its legend against changing source values, and restore the example while retaining the same chart ID.
Compare ready planters with capacity in three fictional garden zones. You will create one chart beside a small table, inspect the chart's saved identity, and change a data value and a presentation option. Repeating the update keeps the same chart rather than inserting a new copy on every run.
This project uses the bound-script setup and rectangular arrays from the Sheets batching lesson. It adds a chart builder and a small saved record. You do not need another API, a deployment, or a trigger.
Choose the chart surface
Three Google chart interfaces share terminology but produce different objects:
| Surface | Input and output | Where the code runs |
|---|---|---|
| Spreadsheet embedded chart | A Range supplies cells to an EmbeddedChart placed over a sheet. Sheet.newChart() starts its builder. | Apps Script server code using SpreadsheetApp |
| Standalone Charts service | A 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 Charts | Browser data and options are drawn into a page element after the chart library loads. | Browser JavaScript using google.charts and google.visualization |
Use the first surface 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 or pass a standalone chart into the embedded-chart insertion method.
Our builder still uses Charts.ChartType.COLUMN. That enum names a chart type; it does not change the builder's service or output type. The Sheet reference demonstrates column-chart updates using this enum.
Shape the data before styling it
The supplied sample.csv contains this four-row, three-column table:
| Zone | Ready | Capacity |
|---|---|---|
| North bed | 4 | 6 |
| Courtyard | 1 | 4 |
| Roof beds | 5 | 8 |
The first row names the columns. The first column supplies category labels, also called the chart's domain. Each remaining column is a numeric series: one set of Ready counts and one set of Capacity counts. Three categories and two series produce two adjacent columns per 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 the zones, which is not the question here. A pie chart would suggest parts of a whole; Ready and Capacity overlap rather than forming two disjoint parts. Do not stack them either: adding ready planters to the total capacity would double-count ready places.
The source rectangle is exactly A1:C4. Using an entire column or getDataRange() would let unrelated notes or later rows change the input. setNumHeaders(1) tells the builder to use one header row, setTransposeRowsAndColumns(false) keeps rows as categories, and useFirstColumnAsDomain selects the labels. The builder reference documents the header and orientation controls; 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 this lesson's range. A count must be a number, not text that looks numeric. getValues() preserves the value types returned by Sheets; a blank cell is an empty string, not zero. Converting every blank to a number could quietly turn missing data into a measured zero. See the Range contract.
Prepare a disposable spreadsheet
Create a blank spreadsheet and name its practice tab Garden chart. Keep the first four rows and first three columns visible, unmerged, and unfiltered.
Copy the
sample.csvdata shown below into empty cellA1. If it stays in one column, select only the four pasted cells inA1:A4, choose Data > Split text to columns, and select Comma. Verify the table occupies exactlyA1:C4and the six counts are numbers. Do not split or replace unrelated cells.Leave clear space to the right of the table for a chart anchored at
E1, with a requested size of 600 by 360 pixels. It floats over the grid; it does not store its image inE1or erase the cells beneath it. The script refuses an existing chart anchored atE1, but that anchor check cannot tell whether the whole display area is clear. Inspect the space yourself.Open Extensions > Apps Script from this spreadsheet. Replace
Code.gswith the complete supplied file. In Project Settings, enable Show appsscript.json manifest file in editor, then replace that file with the supplied manifest.In
GARDEN_CHART, replacespreadsheetIdwith the string between/d/and/editin the spreadsheet URL. Select the Garden chart tab and replacesheetId: nullwith the numericgidfrom its URL, without quotes. A sheet ID of0is valid. The Spreadsheet reference describes these file and sheet ID lookups.Keep
dataRangeas'A1:C4',legendPositionas'bottom', andconfirmedChartIdasnull. Save the project.
Zone,Ready,Capacity
North bed,4,6
Courtyard,1,4
Roof beds,5,8
/** @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'),
};
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly"
]
}
You run these functions from the editor under your own Google account. On the first run, review the authorization request for the current spreadsheet. The manifest requests spreadsheets.currentonly, not Drive access or access to all spreadsheets. That scope still grants read/write access to the whole current file, not only to the twelve cells the lesson uses. The code's target checks limit what this lesson touches, but they do not enforce access. The authorization guide covers scopes and execution identity, and the Sheet reference lists the chart methods' scopes.
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 configuration, save one script property, and write inspection details to the execution log. Those details include file, sheet, and chart IDs; keep the log private.
Create, inspect, and record the chart
Select inspectGardenChart in the editor and click Run. Approve the expected current-spreadsheet access if asked. Check the logged spreadsheet ID, sheet ID, sheet name,
dataRange, andvalues. The values should match the table above. In a fresh project,recordisnulland the chart list is empty. If a record already exists, inspect it rather than resetting it.Run createGardenChart once. A successful insertion call logs that you should inspect and record the actual chart ID. The saved status stays
insert_pendingbecause chart creation and identity confirmation are separate steps.Run inspectGardenChart again. Find exactly one new chart with
newSinceIntent: trueandmatchesLessonShape: true. Compare the old chart IDs inrecord.beforeChartIdswith the current list; every old chart must still be present. In the fresh sheet, there is one chart total.Return to Sheets. Identify the Garden readiness chart at
E1. Check three zone labels, Ready and Capacity series, paired unstacked columns, and a legend below the chart. The starting pairs are 4 and 6, 1 and 4, then 5 and 8.Copy that chart's numeric
chartIdfrom the inspection log intoconfirmedChartId, without quotes. Run recordGardenChart. It requires the confirmed ID to be the single new chart, checks its range, column type, header count, orientation, and exact placement, and saves it as thereadychart.Reset
confirmedChartIdtonull, save, and inspect again. Keep the recorded spreadsheet ID, sheet ID, and chart ID for the rest of the exercise.
If there are no new charts, multiple new charts, missing old charts, or a shape mismatch, stop before confirmation and follow the recovery section. newSinceIntent means absent from the saved baseline. It does not prove that this script created the chart, so you still need the one-operator setup and your own inspection.
Understand what the builder saves
Sheet.newChart() returns an EmbeddedChartBuilder: a description you can change before applying it to the sheet. Chained setters configure that description. build() produces an EmbeddedChart, but does not itself insert or draw a new chart. insertChart() is the operation that adds it to the sheet. For a later change, start with the inserted chart's modify() builder, call build(), then pass the result to updateChart(). The builder documentation describes these steps.
A newly built chart that is not in a spreadsheet has no spreadsheet chart ID: getChartId() returns null. Once inserted, the ID is stable and unique across its containing spreadsheet. The code reads inserted charts through getCharts() instead of assuming that the old builder object acquired an ID, or that insertChart() returned the new chart. It records the inspected ID together with the spreadsheet and sheet IDs. See EmbeddedChart.getChartId.
Array position in getCharts() can shift when another chart is added, moved, or deleted, and two charts can share a title. The saved chart ID selects the output, while range, type, headers, and placement check for unexpected edits.
Placement also has two coordinate systems. setPosition(1, 5, 0, 0) uses one-based row and column numbers for E1, then zero horizontal and vertical pixel offsets. It does not use the zero-based indices of the values array. getContainerInfo() exposes that anchor and those offsets for inspection. See ContainerInfo.
The chart title describes the comparison. hAxis.title labels the categorical horizontal axis Garden zone; vAxis.title labels the numeric vertical axis Planters. The requested vertical bounds include zero and all values through eight, so the bars do not start from a misleading truncated baseline. Sheets still chooses the displayed tick layout. The two colors distinguish the series, while the legend supplies their names.
Options such as legend and vAxis are objects passed to setOption(). That method does not validate an arbitrary option's spelling, structure, or suitability for the chart type. The supplied option names and 'bottom'/'right' legend positions come from the column-chart configuration reference. An execution that finishes without an exception does not prove that a new visual option took effect, so inspect the actual chart.
Repeat without inserting duplicates
Run createGardenChart again after recording the ID. It should report that the chart is already recorded and make no insertion. If the recorded chart is missing or has a changed range, type, header structure, or placement, it stops instead of quietly making a replacement.
The saved record lives under gardenChart.v1 in Script Properties, serialized as JSON and shared across runs of this script project. See the Properties guide for storage scopes and string values.
Change a value and a chart option, then restore them
The cells hold the underlying data, while the chart's range and options control how that data is presented. Editing source cells updates the chart's input; changing a legend option leaves the cells untouched.
Predict. In the Courtyard row, Ready is 1 and Capacity is 4. Predict what changing
B3to 3 will do to the gap between the two bars. Separately, predict what moving the legend from below the chart to its right will change, and what it cannot change.Change the data. In Sheets, change only
B3from1to3. LeaveC3as4. Do not edit the CSV or paste the whole table again. Inspect the chart after Sheets refreshes it, then run inspectGardenChart to check that the current values include the new count.Change the presentation. In
Code.gs, change onlylegendPositionfrom'bottom'to'right'and save. Run updateGardenChart, not the creation function. It selects the recorded chart, rebuilds its legend configuration, and submitsupdateChart().Observe. The Courtyard Ready value should now be 3, reducing the gap to Capacity from 3 planters to 1. The other two pairs should remain 4/6 and 5/8. The legend should be to the right; the layout may give the plot a different amount of space. Compare values, not an assumed pixel-height ratio. Check the update log's observed chart ID against your recorded ID, then inspect again and confirm that the chart count has not increased.
Repeat. Run updateGardenChart again without changing anything. It requests the same legend configuration for the same ID. Inspect the same output and unchanged chart count; a repeated call does not add a chart.
Restore. Change only
B3back to1, setlegendPositionback to'bottom', save, and run updateGardenChart. Inspect the original three pairs, the legend below the chart, and the same recorded chart ID.
The update uses chart.modify(), so it starts from the current chart rather than constructing a new one with guessed defaults. It replaces the legend configuration and leaves the other settings alone. A manual legend customization may therefore be replaced; a manual title or color change is not reset by this function. Structural changes such as dragging the chart, changing its source range, or switching its chart type cause the guard to stop so you can undo or investigate the change.
getOptions() exposes immutable options, and get() returns a configured value or null when it is not 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 source and the rendered view can be observed at different times. The code calls SpreadsheetApp.flush() after insertion or update to apply pending spreadsheet changes before it releases its lock. It 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 deciding what happened. Keep the input rows visible: the example uses the hidden-row strategy IGNORE_ROWS, so hiding a data row changes what is included in the view. See the SpreadsheetApp contract and embedded builder's hidden-dimension strategy.
Creation has an uncertainty window. The property write happens before the insertion call. The insertion can then succeed even if the execution later fails during flushing or logging. A pending record therefore means "this insertion needs inspection," not "there is definitely no chart." Even if the insertion itself throws, do not assume it had no effect.
| Situation | Next action |
|---|---|
| Bad configuration or invalid table | Correct only the identified practice setting or cell. These checks precede the insertion intent and chart operation. Inspect any existing record before creating. |
insert_pending, with one identifiable new chart | Let the previous execution finish. Inspect its ID, baseline comparison, range, and displayed output. Use recordGardenChart with that confirmed ID; it saves identity but does not insert again. |
insert_pending, with no candidate or conflicting charts | Refresh and inspect after the execution has ended. If the output still cannot be identified, preserve the record and stop rather than clearing it or creating another chart in the same spreadsheet. |
| Identity confirmation reports an error | Inspect 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; replacing a ready ID is not. |
| Update or its final inspection fails | The legend change may already exist. Inspect the recorded ID and actual chart. Once the cause is resolved, repeating the same desired legend update does not require insertion. |
| Missing or structurally changed recorded chart | Stop 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 error | Read the execution error. Verify the acting account, target, and completed execution before trying again. Do not grant broader access or remove safety checks to work around it. |
The Lock reference explains lock acquisition and flushing before release. Our five-second acquisition limit keeps one cooperating run from waiting indefinitely for another. It does not prevent you or another script project from editing the data or deleting a chart. It also cannot guarantee exactly-once creation if someone erases the saved intent. Keep the practice environment isolated and retain the record when the outcome is uncertain.
Remove only the identified practice output
Run inspectGardenChart and match the recorded spreadsheet, sheet, and chart ID to its A1:C4 data range, column type, and E1 location. In Sheets, select that identified practice chart and use its chart menu to delete it. Leave the saved record in place so a later creation run refuses the missing chart rather than recreating it. If the twelve practice cells are no longer needed and nothing else depends on them, clear A1:C4 manually.
Next, export CSV, JSON, and PDF files into a Drive ZIP bundle.
Project files
Complete source files for this chapter’s examples.
Create, identify, and update one garden chart
Code.gs
Download file/** @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{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly"
]
}
sample.csv
Download fileZone,Ready,Capacity
North bed,4,6
Courtyard,1,4
Roof beds,5,8