Color scales, custom schemes and proxies with Apps Script
A couple of my favorite topics – object proxies and fiddling with colors – in the same article today. We’ll look at extending the bmChroma library to use custom color schemes, including the material design color palette and create color scales based on these schemes.
I’m going to use my usual goto example spreadsheet which contains a list of airports. Take a copy and we’ll work on that. You’ll notice that each airport has lat/lon co-ordinates – the latitude will be used to create a color scale depending on how far north/south it is of the equator. You can use your imagination to apply it to one of your sheets that needs colorizing according to values in a given column.
For simplicity I’m using my bmPreFiddler library for everything connected with accessing sheets. You can do your own thing if you like of course, but it’s all pretty painless with fiddler.
Create a new standalone script (or container bound if you prefer), and add the bmPreFiddler (13JUFGY18RHfjjuKmIRRfvmGlCYrEkEtN6uUm-iLUcxOUFRJD-WBX-tkR) library and the bmChroma (1zjBPTX8meADK6W2tbw-sNB0479OMN2hhT1O5MGna7v5liAj7paj-W8QE) library.
…or you could take copy of the completed script from here and work on that.
Create an Exports script
Passing all your global and library references through an Exports object helps to avoid those annoying problems you sometimes get in Apps Script when you access something before Apps Script has defined it. I’ve covered Exports in a number of articles previously such as Fix Apps Script file order problems with Exports. Here’s what your Exports.gs should loook like. There’s also a bonus proxy checker in there to make sure you don’t accidently try to access properties that don’t exist.
var Exports = {
get PreFiddler () { return bmPreFiddler.PreFiddler },
get ColorWords() { return this.libExports.ColorWords }, get Schemes() { return this.libExports.Schemes },
// used to trap access to unknown properties guard(target) { return new Proxy(target, this.validateProperties) },
/** * for validating attempts to access non existent properties */ get validateProperties() { return { get(target, prop, receiver) { // typeof and console use the inspect prop if ( typeof prop !== 'symbol' && prop !== 'inspect' && !Reflect.has(target, prop) ) throw `guard detected attempt to get non-existent property ${prop}`
return Reflect.get(target, prop, receiver) },
set(target, prop, value, receiver) { if (!Reflect.has(target, prop)) throw `guard attempt to set non-existent property ${prop}` return Reflect.set(target, prop, value, receiver) } } }
}
Exports.gs
Copy the source sheet data
We’ll use fiddler to copy the original sheet ‘airports’ to ‘color-airports’, and then play around with the copy. Subsitute the ids below with the spreadsheet id of the copy you made of my original airports spreadsheet.
// open the airports sheet const sourceFiddler = Exports.newPreFiddler({ sheetName: "airports", id: 'id of your copy of airports' })
const targetFiddler = Exports.newPreFiddler({ sheetName: "color-airports", id: 'id of your copy of airports', createIfMissing: true })
// copy the data targetFiddler.setData(sourceFiddler.getData())
set the source and target sheet
From now on we’ll manipulate the data in the targetFiddler, leaving the source sheet intact.
The sheet intially looks like this
Add some extra columns
In this article we’ll be adding and colorizing extra columns to demonstrate different color scale methodologies – let’s add the first one ‘vanilla’. We can make it re-runnable by checking if the columns already exists first.
// add some more columns for the test if they are not already there const extraCols = ['vanilla'] extraCols.filter(f => targetFiddler.getHeaders().indexOf(f) === -1) .forEach(f => targetFiddler.insertColumn(f))
add some extra columns
Create a scale function
Next we want to create a function that will return a suitablely scaled color based on latitude.
The scale is going to go blue->yellow->blue, and the latitude (which will be in the range -90 to +90 – ie the domain) will determine how to mix the base colors in the scale
// pick up the functions we'll need from the bmChroma library const { getChroma, getContrast, getColorPack } = Exports.ColorWords const chroma = getChroma()
// Vanilla scale // the idea here is to colorize using a scale according to the absolute latitude // first of all using normal colors from blue (cold) thru yellow(warm) then thru blue again // make a copy to here const vanillaScale = chroma.scale(['blue', 'yellow', 'blue'].map(f => chroma(f))).domain([-90, 0, 90]);
scale function
Add the scale
We’ll add the hex code for the chosen color in the newly created column ‘vanilla’
// now lets make a color for each airport targetFiddler.mapRows(row => { row.vanilla = vanillaScale(row.latitude_deg).hex() return row })
add vanilla scale
Sort by latitude
It’ll be more effective to see the scale if we sort the data by latitude
// lets sort by latititude targetFiddler.setData(targetFiddler.sort('latitude_deg'))
// and finally, write the fiddler data to the sheet targetFiddler.dumpValues()
sort by latitude
Initial colors
There are hundreds of airports in the sheet, so I’ve just snapped the first few – you can see that the southern hemisphere airports are now sorted to the tops, and we have a suggested color to use for them based on where their latitude lies between -90 and +90.
Colorize
You could simply use these colors to color the entire row, but instead I’m going to use a function that examines an entire sheet and sets a background an contrasting font color in any cell that looks like a color. It examines the contents of a fiddler, and returns a function which will colorize the whole thing when executed. Always quite handy for articles involving sheets and colors.
Note: The plugin im using for rendering code has a problem so the code for isHex may not show up properly on your browser – it should read.
const isHex = (value) => value && value.toString().match(/^#[0-9abcdef]+$/i)
//see if avalues is a hex code - naive test const isHex = (value) => value && value.toString().match(/^#[0-9abcdef] $/i)
// this function will return a closure that will color the fiddler const colorize = (fiddler) => { const backgrounds = fiddler.getData().map(row => { return fiddler.getHeaders().map(col => isHex(row[col]) ? row[col] : null) }) const fontColors = backgrounds.map(row => { return row.map(col => isHex(col) ? getContrast(col) : null) }) const range = fiddler.getRange() .offset(1, 0, backgrounds.length, backgrounds[0].length)
range .setBackgrounds(backgrounds) .setFontColors(fontColors)
return { range, backgrounds, fontColors } }
Colorizer
We can now apply the colorizer function. I’ll also set some formats for the header row just before dumping the fiddler data.
// getColorPack will give me a contrast to use for font color const headColor = getColorPack('black') targetFiddler.setHeaderFormat({ wraps: true, backgrounds: headColor.hex, fontWeights: 'bold', fontColors: headColor.contrast })
// dump the results targetFiddler.dumpValues()
// color anything that looks like a hex colorize(targetFiddler)
Colorizing
Initial colorization
Here’s the beginning of the scale
Custom scheme
You’ll may have noticed that we can use either a color name or a hex value to specify a color to bmChroma. In the background, bmChroma uses the excellent chroma node module which I’ve ported into Apps Script. Chroma has a list of known named colors it recognizes (I believe it’s all the named HTML colors)
But what if we want to use a different color scheme – for example Google’s material color palette? bmChroma now has the vuetify material color palette built in as a custom scheme, and uses a proxy to insert this list into chroma’s known list of colors. If you are interested you can see how it does that in the snippet below. When the proxied version of chroma is executed, it first checks to see if the color is known as part of any scheme, before returning control to chroma to validate and parse the color chosen.
const getChroma = () => { return new Proxy(Exports.chroma, { apply(target, thisArg, args) { // we can first check if the colors appear in any schemes before having chroma check if (args && args[0] && typeof args[0] === 'string') { const s = Exports.Schemes const color = s.getColor(args[0], false) return target.apply(thisArg, color ? [color].concat(args.slice(1)) : args) } else { return target.apply(thisArg, args) } } }); }
a proxy for chroma
The material color built in scheme.
The custom scheme for material colors looks like this – a small snippet below.
A color in a custom scheme is constructed as follows:
schemename-basecolor-optionalvariants
The scheme name of the built in material colors is mc, so mc-red, mc-amber-darken-2, mc-orange-accent-4 are all valid material colors
We’ll create a new scheme later, but first let’s update the code to create a scale that starts and ends on a material color rather than the standard html color.
// add some more columns for the test if they are not already there const extraCols = ['vanilla', 'material'] extraCols.filter(f => targetFiddler.getHeaders().indexOf(f) === -1) .forEach(f => targetFiddler.insertColumn(f))
...
// use material base colors const materialScale = chroma.scale([ 'mc-blue', 'mc-yellow', 'mc-blue' ].map(f => chroma(f))).domain([-90, 0, 90]);
...
// now lets make a color for each airport targetFiddler.mapRows(row => { row.vanilla = vanillaScale(row.latitude_deg).hex() row.material = materialScale(row.latitude_deg).hex() return row })
extra code for a columns with a material scale
Now we have this
Finding the nearest color
That looks a little nicer, but what if we want a scale that only features colors in a given scheme? Although our scheme currently starts and finishes on colors in the scheme, colors along the way will be a mix, so we need to find a way of finding the nearest scheme color to each intermediate scale value. You could use this same technique, for example, to convert a manually colorized sheet to only contain colors from a list – but choose the colors that are the nearest match. I’ve written a few articles on the different color spaces and how they calculate nearness – there are some links at the end of this article. For this example, we’ll use the ‘oklab’ color space which I find gives the most human match.
Finding the nearest match
Some more code updates, and a new column
// add some more columns for the test if they are not already there const extraCols = ['vanilla', 'material','material-strict'] extraCols.filter(f => targetFiddler.getHeaders().indexOf(f) === -1) .forEach(f => targetFiddler.insertColumn(f))
...
// function to caclculate nearest match // generalize this to take any mode for future reference // but we'll use the 'oklab' mode by default - it seems to give the best results const getDistance = (a, b, name = 'oklab') => name === 'deltaE' ? chroma.deltaE(a, b) : chroma.distance(a, b, name)
// we'll get the entire material color set const materialScheme = Exports.Schemes.listScheme('mc') const materialHexes = materialScheme.map(f => f.hex)
// find the closest in the set const getNearest = (color, refSet = materialHexes) => refSet.reduce((p, c) => { const distance = getDistance(color, c) return distance < p.distance ? { distance, color, target: c } : p }, { distance: Infinity, color, target: null })
...
// now lets make a color for each airport targetFiddler.mapRows(row => { row.vanilla = vanillaScale(row.latitude_deg).hex() row.material = materialScale(row.latitude_deg).hex() // how about limiting to the nearest material colors row['material-strict'] = getNearest(row.material).target return row })
nearest match from a scheme
Now we have airports at similar latitudes with the same color, and each color is strictly from the material color scheme rather than a calculated ‘inbetween’
Create your own scheme
You may want to create your own scheme – for example on status reports or for color matching to a range of paint or fabrics. For this example, I’ll create a simple scheme without any variants.
// add some more columns for the test if they are not already there const extraCols = ['vanilla', 'material','material-strict', 'tropics'] extraCols.filter(f => targetFiddler.getHeaders().indexOf(f) === -1) .forEach(f => targetFiddler.insertColumn(f))
// now lets make a color for each airport targetFiddler.mapRows(row => { row.vanilla = vanillaScale(row.latitude_deg).hex() row.material = materialScale(row.latitude_deg).hex() row['material-strict'] = getNearest(row.material).target row.tropics = tropicsScale (row.latitude_deg).hex() return row })
your own custom scheme
I’ve taken a snip round about the equator for this one.
The complete code
Heres’ the final version of the code – you can of course take a copy of the script or get it from github.
const testCustomSchemes = () => {
// open the airports sheet const sourceFiddler = Exports.newPreFiddler({ sheetName: "airports", id: 'your copy of airports id' })
// add some more columns for the test if they are not already there const extraCols = ['vanilla', 'material','material-strict', 'tropics'] extraCols.filter(f => targetFiddler.getHeaders().indexOf(f) === -1) .forEach(f => targetFiddler.insertColumn(f))
// pick up the functions we'll need from the bmChroma library const { getChroma, getContrast, getColorPack } = Exports.ColorWords const chroma = getChroma()
// Vanilla scale // the idea here is to colorize using a scale according to the absolute latitude // first of all using normal colors from blue (cold) thru yellow(warm) then thru blue again // make a copy to here const vanillaScale = chroma.scale(['blue', 'yellow', 'blue'].map(f => chroma(f))).domain([-90, 0, 90]);
// then use material base colors const materialScale = chroma.scale([ 'mc-blue', 'mc-yellow', 'mc-blue' ].map(f => chroma(f))).domain([-90, 0, 90]);
// function to caclculate nearest match // generalize this to take any mode for future reference // but we'll use the 'oklab' mode by default - it seems to give the best results const getDistance = (a, b, name = 'oklab') => name === 'deltaE' ? chroma.deltaE(a, b) : chroma.distance(a, b, name)
// we'll get the entire material color set const materialScheme = Exports.Schemes.listScheme('mc') const materialHexes = materialScheme.map(f => f.hex)
// find the closest in the set const getNearest = (color, refSet = materialHexes) => refSet.reduce((p, c) => { const distance = getDistance(color, c) return distance < p.distance ? { distance, color, target: c } : p }, { distance: Infinity, color, target: null })
// now lets make a color for each airport targetFiddler.mapRows(row => { row.vanilla = vanillaScale(row.latitude_deg).hex() row.material = materialScale(row.latitude_deg).hex() // how about limiting to the nearest material colors row['material-strict'] = getNearest(row.material).target row.tropics = tropicsScale (row.latitude_deg).hex() return row })
// lets sort by latititude targetFiddler.setData(targetFiddler.sort('latitude_deg'))
//see if avalues is a hex code - naive test const isHex = (value) => value && value.toString().match(/^#[0-9abcdef] $/i)
// this function will return a closure that will color the fiddler const colorize = (fiddler) => { const backgrounds = fiddler.getData().map(row => { return fiddler.getHeaders().map(col => isHex(row[col]) ? row[col] : null) }) const fontColors = backgrounds.map(row => { return row.map(col => isHex(col) ? getContrast(col) : null) }) const range = fiddler.getRange() .offset(1, 0, backgrounds.length, backgrounds[0].length)
range .setBackgrounds(backgrounds) .setFontColors(fontColors)
return { range, backgrounds, fontColors } }
// copy the source data and set basic header formatting
bruce mcpherson is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. Based on a work at http://www.mcpher.com. Permissions beyond the scope of this license may be available at code use guidelines