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.
Create and inspect one embedded chart, change a source value and the legend position, and restore the example while keeping the same chart ID.
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 chart | 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 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:
| Zone | Ready | Capacity |
|---|---|---|
| North bed | 4 | 6 |
| Courtyard | 1 | 4 |
| Roof beds | 5 | 8 |
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.useFirstColumnAsDomainpicks 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
Create a blank spreadsheet and name its tab Garden chart. Keep the first four rows and first three columns visible, unmerged, and unfiltered.
Copy the
sample.csvdata shown below into the empty cellA1. If it lands in one column, select only the four pasted cellsA1:A4, choose Data > Split text to columns, and select Comma. Check that the table fills exactlyA1:C4and that the six counts are numbers. Do not split or replace other cells.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 inE1and does not erase the cells under it. The script refuses to run if a chart is already anchored atE1, but that check cannot tell whether the rest of the display area is clear. Check that the full chart area is clear.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 IDs.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 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
Select
inspectGardenChartin the editor and click Run. Approve the 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
createGardenChartonce. When the insertion call returns, the log tells you to inspect and record the actual chart ID. The saved status staysinsert_pending, because creating the chart and confirming its ID are separate steps.Run
inspectGardenChartagain. Find exactly one new chart withnewSinceIntent: trueandmatchesLessonShape: true. Compare the old chart IDs inrecord.beforeChartIdswith the current list, and confirm that every old chart is still there. In the fresh sheet, there is one chart in total.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.Copy that chart's numeric
chartIdfrom the inspection log intoconfirmedChartId, without quotes. RunrecordGardenChart. 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 statusready.Set
confirmedChartIdback tonull, 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:
Call its
modify()method to get a builder.Change the builder settings and call
build().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.
In the Courtyard row, Ready is 1 and Capacity is 4. Predict what changing
B3to 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.In Sheets, change only
B3from1to3. LeaveC3as4. Do not edit the CSV or paste the whole table again. Look at the chart after Sheets refreshes it, then runinspectGardenChartto check that the logged values include the new count.In
Code.gs, change onlylegendPositionfrom'bottom'to'right'and save. RunupdateGardenChart, notcreateGardenChart. It finds the recorded chart, rebuilds its legend setting, and callsupdateChart().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.
Run
updateGardenChartagain without changing anything. It requests the same legend setting for the same ID. Inspect again and confirm that the chart count did not change.Change
B3back to1, setlegendPositionback to'bottom', save, and runupdateGardenChart. 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.
| Situation | Next action |
|---|---|
| Bad configuration or invalid table | Correct 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 chart | Let 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 charts | Refresh 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 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, whereas replacing a ready ID is not. |
| The update or its final inspection fails | The 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 changed | 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. 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.
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