Display vertically an embedded combo chart - google-apps-script

I am trying to display a bar chart combined to a line using an EmbeddedComboChartBuilder.
For this I'm using the following code and data:
function createChartSerie() {
var ss = SpreadsheetApp.openById('XXX...');
var sheet = ss.getSheetByName("sheet1");
var chartTableXRange = sheet.getRange("A1:A24");
var chartTableYRange = sheet.getRange("B1:C24");
var chart = sheet.newChart()
.setOption('useFirstColumnAsDomain', true)
.setOption('hAxis', {
title: 'X',
format: '#,##'
})
.setOption('vAxes', {0: {
title: 'Y', format: '#,##'
}})
.setChartType(Charts.ChartType.COMBO)
.addRange(chartTableXRange) // x axis
.addRange(chartTableYRange) //first y data
.setPosition(5, 5, 0, 0)
.setOption('orientation', 'vertical')
.setOption('series', {
0: {
type: 'line',
color: 'red'
},
1: {
type: 'bars',
color: 'blue'
}
})
.build();
sheet.insertChart(chart);
}
The data range used to display the combo chart:
A B C
1 0.0001 0
1.5 0.0009 0.0006
2 0.0044 0.0037
2.5 0.0175 0.0133
3 0.054 0.0236
3.5 0.1296 0.0533
4 0.242 0.0073
4.5 0.3522 0.2468
5 0.399 0.0843
5.5 0.3522 0.3352
6 0.242 0.2201
6.5 0.1296 0.0607
7 0.054 0.0256
7.5 0.0175 0.0006
8 0.0044 0.003
8.5 0.0009 0.0005
9 0.0001 0.0001
It seems that the option .setOption('orientation', 'vertical') has no effect as you can see in the following picture:
This option is working well when using with google visualization like you can see in this JSFiddle.

Answer
Unfortunately Sheets API does not support this option (see the embedded charts resource).
There seems to be a feature request in Google’s Issue Tracker. You can click the white start (☆) so Google knows that you want this to be done.
Workarounds
There are 2 workarounds: use Google Charts itself on a web (works with WebApps and modals), and generate the chart with Google Charts, convert the SVG on the page to PNG and embed that to the spreadsheet. Generating a PNG requires making a modal, so this set instructions will work for both workarounds.
Step 1: Make a page that uses Google Charts to make a chart
Get some mocking data and make your chart, design it to your taste, and make sure everything works fine.
Step 2: Show it using a modal
Add a new HTML file named Chart (you can change the name but you’ll need to change it in the code) and paste the code you made.
Once you have a mock chart, we need to show it using a modal. We first need to add a menu to the spreadsheet:
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Chart generator')
.addItem('Create and insert chart', 'showChart')
.addToUi()
}
Then we add the function that shows the modal:
function showChart() {
const html = HtmlService.createHtmlOutputFromFile('Chart')
.setWidth(900)
.setHeight(600)
SpreadsheetApp.getUi().showModalDialog(html, 'Chart')
}
You can also set up a WebApp with the same template:
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('Chart')
.setWidth(900)
.setHeight(600)
}
Step 3: Get the data from the spreadsheet
Now it’s time to remove the mock data and get the actual data. To do this we’ll use the google.script.run function to asynchronously load the data. This example uses asynchronous logic so it can load the charts library and the data in parallel.
google.charts.load('current', {'packages':['corechart']})
const readyPromise = toAsync(resolve => google.charts.setOnLoadCallback(resolve))
const dataPromise = toAsync((resolve, reject) => {
google.script.run
.withSuccessHandler(resolve)
.withFailureHandler(reject)
.getData()
})
toAsync is a helper function that makes sure to reject if there is an error that’s not handled by a failure callback:
function toAsync(callback) {
return new Promise((resolve, reject) => {
try {
callback(resolve, reject)
} catch(e) {
reject(e)
}
})
}
Then we need to add the function in Google Apps Script that actually reads the data from the spreadsheet:
function getData() {
return SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('Sheet1')
.getRange('A1:C18')
.getValues()
}
Then simply use both in the main function:
async function drawVisualization() {
const el = document.getElementById('chart_div')
await readyPromise
const data = google.visualization.arrayToDataTable(await dataPromise)
const options = { … }
new google.visualization.ComboChart(el).draw(data, options)
}
Make sure that the call handles the promise rejection otherwise you may silence errors unknowingly:
document.addEventListener('DOMContentLoaded', () => drawVisualization().catch(console.error))
(Optional) Step 4: Embedding chart as PNG
Google Charts generate SVGs. Unfortunately we cannot embed a SVG so we need to convert it to a PNG. The easiest way to do it is using an external library like canvg which allows us to draw an SVG into a canvas. Then we can export the canvas as PNG.
Add a canvas to your body and the library to your head.
Then you can draw on the canvas using (append to drawVisualization):
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
v = canvg.Canvg.fromString(ctx, el.getElementsByTagName('svg')[0].outerHTML)
v.start()
await v.ready()
This will draw it and wait until it’s done.
Now we need a way of sending the generated image to GAS so it can embed it. Thankfully we can get a base64 of the PNG and send it to there:
const base64 = canvas.toDataURL('image/png').split(';base64,')[1]
await toAsync((resolve, reject) => {
google.script.run
.withSuccessHandler(resolve)
.withFailureHandler(reject)
.insertChart(base64)
})
And in Google Apps Script, we define insertChart:
function insertChart(base64png) {
const blob = Utilities.newBlob(
Utilities.base64Decode(base64png, Utilities.Charset.US_ASCII),
'image/png',
'chart.png'
)
SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('Sheet1')
.insertImage(blob, 5, 5, 0, 0)
}
(Optional) Step 5: Close modal and hide charts while being generated
Now that it’s being inserted, I like to automatically close the modal. To do so just append this to drawVisualization:
google.script.host.close()
I also like to not show the chart while being processed. A bit of CSS should be enough:
canvas, #chart_div {
visibility: hidden;
}
body {
overflow: hidden;
}
You cannot use display: none because then it will generate charts of the wrong size. Overflow is set to avoid the scroll bars into nothing.
You can also change the screen title and even a loading animation to the modal.
References
Custom Menus in Google Workspace (Google Developers)
Serve HTML as a Google Docs, Sheets, Slides, or Forms user interface (Google Developers)
HtmlService.createHtmlOutputFromFile (Google Developers)
Serve HTML as a web app (Google Developers)
google.script.run (Google Developers)
Range getValues() (Google Developers)
Utilities.newBlob(data, contentType, name) (Google Developers)
Utilities.base64Decode(encoded, charset) (Google Developers)
Sheet insertImage(blobSource, column, row) (Google Developers)
google.script.host close() (Google Developers)
Canvas toDataURL (MDN)
Promise (MDN)

Related

How can I pass a base64 pdf to the server-side in Google Apps Script?

Overall Goal
Generate a pdf of the currently displayed html page;
Pass it to the server-side;
Save into a specific Google Drive
Item 2 is the one I'm having trouble with!
I'm using this to try to get the converted file passed to the server-side:
Client Side
function saveToGDrive(){
var element = document.getElementById('pgBody');
var opt = {
margin: 1,
filename: 'myfile.pdf',
image: {
type: 'jpeg',
quality: 0.98
},
html2canvas: {
scale: 5
},
jsPDF: {
unit: 'in',
format: 'A4',
orientation: 'landscape'
}
};
const pg = html2pdf().set(opt).from(element).outputPdf().then(function(p) {
console.log('PDF file: ' + typeof btoa(p))
return btoa(p);
});
google.script.run.savePdf(pg)
}
Item 2 gives me the error: Failed due to illegal value in property: state and I'm taking the variable pg is too large to be passed as a parameter like this.
Appreciate any help!
When your showing script is modified, how about the following modification?
From:
const pg = html2pdf().set(opt).from(element).outputPdf().then(function(p) {
console.log('PDF file: ' + typeof btoa(p))
return btoa(p);
});
google.script.run.savePdf(pg)
To:
html2pdf().set(opt).from(element).outputPdf().then(function(p) {
google.script.run.savePdf(btoa(p));
});
When this script is run, the base64 data of btoa(p) is sent to the Google Apps Script side.
When savePdf is the following script, the base64 data is converted to a Blob and saved as a PDF file on Google Drive.
const savePdf = e => DriveApp.createFile(Utilities.newBlob(Utilities.base64Decode(e), MimeType.PDF).setName("samplename.pdf"));

Is it possible to view a list of all objects used in Google Slides?

When some objects in Google Slides get hidden behind another object it may be later hard to find them on the slide.
Is it possible, for example, to see a panel with a list of all objects which are present on a given slide? And possibly edit them even if they are in the bottom layer (completely hidden behind another object)? This might be useful for animations when an object is displayed later and fully covers a previously displayed object.
Your goal I believe is as follows.
Your Google Slides has several text boxes of the same size and the same position.
You want to retrieve the list of texts from the text boxes and want to change the texts using a simpler method.
In this case, I thought that when the sidebar created by Google Apps Script is used for changing the texts, your goal might be able to be simply achieved.
The sample script is as follows.
Usage:
1. Prepare script:
Please copy and paste the following script to the script editor of Google Slides and save the script. And then, please reopen the Google Slides. By this, the custom menu "sample" is created for the Google Slides. When "RUN" in the custom menu "sample" is opened, the script is run.
Code.gs
Please copy and paste this script as Code.gs.
function onOpen() {
SlidesApp.getUi().createMenu("sample").addItem("RUN", "openSidebar").addToUi();
}
function openSidebar() {
const html = HtmlService.createHtmlOutputFromFile("index").setTitle("sample");
SlidesApp.getUi().showSidebar(html);
}
function getSelectedShapes() {
const select = SlidesApp.getActivePresentation().getSelection();
const pageElementRange = select.getPageElementRange();
if (pageElementRange) {
const obj = pageElementRange.getPageElements().reduce((ar, e) => {
if (e.getPageElementType() == SlidesApp.PageElementType.SHAPE) {
const shape = e.asShape();
ar.push({objectId: shape.getObjectId(), text: shape.getText().asString().trim()});
}
return ar;
}, []).reverse();
return obj;
}
return [];
}
function updatedTexts(o) {
const select = SlidesApp.getActivePresentation().getSelection();
const slide = select.getCurrentPage();
const obj = slide.getShapes().reduce((o, e) => Object.assign(o, {[e.getObjectId()]: {shape: e, text: e.getText().asString().trim()}}), {});
o.forEach(({objectId, text}) => {
if (obj[objectId] && obj[objectId].text != text) {
obj[objectId].shape.getText().setText(text);
}
});
return "Done";
}
index.html
Please copy and paste this script as index.html.
<input type="button" id="main" value="Get selected shapes" onClick="main()">
<div id="shapes"></div>
<input type="button" id="update" value="Updated texts" onClick="updatedTexts()" style="display:none">
<script>
function main() {
document.getElementById("main").disabled = true;
document.getElementById("shapes").innerHTML = "";
google.script.run.withSuccessHandler(o => {
if (o.length == 0) {
document.getElementById("update").style.display = "none";
return;
}
const div = document.getElementById("shapes");
o.forEach(({objectId, text}) => {
const input = document.createElement("input");
input.setAttribute("type", "text");
input.setAttribute("id", objectId);
input.setAttribute("value", text);
div.appendChild(input);
});
document.getElementById("update").style.display = "";
document.getElementById("main").disabled = false;
}).getSelectedShapes();
}
function updatedTexts() {
const inputs = document.getElementById("shapes").getElementsByTagName('input');
const obj = [...inputs].map(e => ({objectId: e.id, text: e.value}));
console.log(obj)
google.script.run.withSuccessHandler(e => console.log(e)).updatedTexts(obj);
}
</script>
2. Testing:
Please reopen Google Slides. By this, the custom menu is created. Please open "sample" -> "RUN". By this, the sidebar is opened.
Please select the text boxes on Google Slides.
Click "Get selected shapes" button.
By this, the selected text boxes are retrieved and you can see the texts of text boxes.
Modify the texts.
Click "Updated texts" button.
By this, the modified texts are reflected in the text boxes.
Also, you can see it with the following demonstration movie.
Note:
This is a simple sample script. So please modify the above script and HTML style for your actual situation.
References:
Custom sidebars

Is this possible to send and get back the value from google app script to html page without rendering html output?

After much discussion and R&D, image cropping is not possible with Google APP scripts. So I decided to try one using the Canvas API.
I am trying to pass the value from server script(.gs) to the HTML file and get back the value in the server side script without opening HTML output as in sidebar or model/modelLess dialog box. You can say silently call HTML, complete the process and return the value to server script method.
I tried but getFromFileArg() is not running when i am running the callToHtml().
Is this possible with below script? what you will suggest?
Server side (.gs)
function callToHtml() {
var ui = SlidesApp.getUi();
var htmlTemp = HtmlService.createTemplateFromFile('crop_img');
htmlTemp["data"] = pageElements.asImage().getBlob();
var htmlOutput = htmlTemp.evaluate();
}
function getFromFileArg(data) {
Logger.log(data);
}
crop_img.html template :
<script>
var data = <?= data ?>;
//call the server script method
google.script.run
.withSuccessHandler(
function(result, element) {
element.disabled = false;
})
.withFailureHandler(
function(msg, element) {
console.log(msg);
element.disabled = false;
})
.withUserObject(this)
.getFromFileArg(data);
</script>
You cannot "silently" call the HTML this way, no.
The HTML needs to go to the user and the user is not inside of your web app, but Google's web app (Slides), so you have to play by their rules.
You need to use one of the available UI methods such as showSidebar. You could have the displayed HTML be a spinner or message like "processing..." while the JavaScript runs.
function callToHtml() {
var ui = SlidesApp.getUi();
var htmlTemp = HtmlService.createTemplateFromFile('crop_img');
htmlTemp["data"] = pageElements.asImage().getBlob();
ui.showSidebar(htmlTemp.evaluate());
}

How to deal with Access-Control-Allow-Origin for myDrive files

The question arises from this note:
Someone here suggested using div's. The HTML requirement is very
skeletal. The 3D display is basically canvas, but it requires seven
three.js files, ten js files of my own making to exchange parameters
and other variables with the global variable and .dae collada files
for each of the 3D models you can see. If they could be linked in like
jQuery that might be the solution but I wonder about conflicts.
on Questions on extending GAS spreadsheet usefulness
principally, if they can be linked like jQuery part
The files to be linked are on myDrive. The thinking is that if I can copy the files into GAS editor, it seems as secure and more flexible to bring them into the html directly.
code.gs
function sendUrls(){
var folder = DriveApp.getFoldersByName("___Blazer").next();
var sub = folder.getFoldersByName("assembler").next();
var contents = sub.getFiles();
var file;
var data = []
while(contents.hasNext()) {
file = contents.next();
type = file.getName().split(".")[1];
url = file.getUrl();
data.push([type,url]);
}
return data;
}
html
google.script.run.withSuccessHandler(function (files) {
$.each(files,function(i,v){
if(v[0] === "js"){
$.get(v[1])
}
})
})
.sendUrls();
The first url opens the proper script file but the origin file is not recognisable to me.
I am not sure that this is a proper answer as it relies on cors-anywhere, viz:
function importFile(name){
var myUrl = 'http://glasier.hk/cors/tba.html';
var proxy = 'https://cors-anywhere.herokuapp.com/';
var finalURL = proxy + myUrl;
$.get(finalURL,function(data) {
$("body").append(data);
importNset();
})
}
function importNset(){
google.script.run
.withSuccessHandler(function (code) {
path = "https://api.myjson.com/bins/"+code;
$.get(path)
.done((data, textStatus, jqXHR) => {
nset = data;
cfig = nset.cfig;
start();
})
})
.sendCode();
}
var nset,cfig;
$(document).ready(function(){
importFile();
});
but it works, albeit on my machine, using my own website as the resource.
I used the Gas function in gas Shop to make the eight previously tested js files into the single tba.html script only file. I swapped the workshop specific script files for those needed for google.script.run but otherwise that was it. If I could find out how to cors-enable my site, I think I might be able to demonstrate how scripts might be imported to generate different views from the same TBA and spreadsheet interfaces.

OpenLayers - clear 'map' div

I am using OpenLayers to display dynamically loaded images. The code I am using is:
var map;
function init(strURL) {
map = new OpenLayers.Map('map');
var options = { numZoomLevels: 3,
isBaseLayer: true, };
var graphic = new OpenLayers.Layer.Image(
'City Lights',
strURL + "?sc=page",
new OpenLayers.Bounds(-180, -88.759, 180, 88.759),
new OpenLayers.Size(580, 288),
options
);
// graphic.events.on({
// loadstart: function () {
// OpenLayers.Console.log("loadstart");
// },
// loadend: function () {
// OpenLayers.Console.log("loadend");
// }
// });
var jpl_wms = new OpenLayers.Layer.WMS("NASA Global Mosaic",
"http://t1.hypercube.telascience.org/cgi-bin/landsat7",
{ layers: "landsat7" }, options);
map.addLayers([graphic, jpl_wms]);
map.addControl(new OpenLayers.Control.LayerSwitcher());
map.zoomToMaxExtent();
}
I am calling this function on a button click event and passing the strURL (sources of the images) at that time. The result is, with each click a different image is loaded and displayed on the web page but is not clearing the previous image. So I 5 different images on the webpage are shown with 5 clicks and so on.
My javascript knowledge is limited, so my apologies if this is a stupid question. How to stop this behavior? Thanks for any assistance.
Also, I didn't quite understand the lines:
var jpl_wms = new OpenLayers.Layer.WMS("NASA Global Mosaic",
"http://t1.hypercube.telascience.org/cgi-bin/landsat7",
{ layers: "landsat7" }, options);
But I know these lines are needed since I'm getting js error if I remove them.
So currently you are calling init(strURL) with each button click? Divide that code into two parts:
On page load, create map and layer objects
On button click, just update URL of existing image layer. Image layer has setUrl(url) method for that: http://dev.openlayers.org/apidocs/files/OpenLayers/Layer/Image-js.html#OpenLayers.Layer.Image.setUrl
Here is sample, that should explain it: http://jsfiddle.net/mEHrN/6/
About var jpl_wms = ... - it creates WMS layer, that should display Landsat imagery (but that URL doesn't seem to work). If you remove it, remember also to remove it from map.addLayers:
map.addLayers([graphic]);
Probably this was reason, why you got JS error.