Published
Edited
Jul 27, 2020
1 star
Insert cell
Insert cell
Insert cell
Insert cell
photos = new SocialPhoto(playlist, mood_description)
Insert cell
html`<img src=${URL.createObjectURL(await photos.squarePhotoUrl())} />`
Insert cell
html`<img src=${URL.createObjectURL(await photos.verticalPhotoUrl())} />`
Insert cell
class SocialPhoto {

constructor(spotifyResult, mood_description) {
this.playlist = spotifyResult
.tracks
.items
.slice(0,4)
.map(track => ({
artists: track.track.artists.map(artists => this.escapeXml(artists.name)),
albumArt: track.track.album.images.filter(image => image.width < 600)[0].url
}));
this.playlist_uri = spotifyResult.uri;

this.mood_description = this.escapeXml(mood_description);
}

async squarePhotoUrl() {
let coverArtSizeGrid = 240;
let playlistPadding = 220;
let playlistPaddingVertical = 85;
const svgSource = `
<svg viewbox="0 0 1080 1080" width="1080" height="1080" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="background" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d86ae3;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(227,145,116,.7);stop-opacity:1" />
</linearGradient>

<filter id="noise">
<feTurbulence
result="f1"
type="turbulence"
baseFrequency="0.4"
numOctaves="1"
seed="0"
y="0"
x="0"
height="1920"
width="1080" />
<feColorMatrix
in="f1"
result="f2"
type="matrix"
values="1.0 0 0 0.5 0
0 1.0 0 0.5 0
0 0 1.0 0.5 0
0 0 1 1.0 0" />
<feColorMatrix
in="f2"
result="f3"
type="matrix"
values="1.0 0 0 0 0
0 1.0 0 0 0
0 0 1.0 0 0
0 0 0 0.3 0" />

<feMerge>
<feMergeNode in="SourceGraphic" />
<feMergeNode in="f3" />
</feMerge>
</filter>
<style>
svg {
font-family: Poppins, "Helvetica Neue", sans-serif;
}
text {
fill: white;
font-weight: 250;
}
text.heading {
fill: black;
}
text.subheading1 {
font-weight: 800;
}
text.result {
font-weight: 600;
}
text.number {
font-weight: 600;
fill: black;
}
text.artist {
font-weight: 600;
text-overflow: ellipsis;
}
rect.result {
fill: rgba(112, 114, 128, 0.2);
}
</style>

</defs>
<rect fill="white" width="100%" height="100%" />
<rect fill="url(#background)" filter="url(#noise)" width="100%" height="100%"></rect>
<g transform="translate(344, 26) scale(0.8)" transform-origin="196 81" fill="black">
<path d="M57.64,69q3.78,3.84,8.31,8.38l9.42,9.41q4.88,4.88,9.54,9.48l8.8,8.66q4.13,4.05,7,6.94v24.73L80,115.83a51.11,51.11,0,0,1-13.4,6.59,48.9,48.9,0,0,1-15.18,2.34,46.67,46.67,0,0,1-34.21-14.43A48.87,48.87,0,0,1,7.08,94.67a52.46,52.46,0,0,1,0-38.4A47.55,47.55,0,0,1,32.36,30.09a46.7,46.7,0,0,1,19-3.85,48.33,48.33,0,0,1,19.3,3.88A49.58,49.58,0,0,1,86.36,40.7,50.91,50.91,0,0,1,96.94,56.4a48.82,48.82,0,0,1,3.36,26.45,40.05,40.05,0,0,1-1.65,6.87L78.87,87a32.2,32.2,0,0,0,1.65-5.5,30.59,30.59,0,0,0,.55-5.91,32.27,32.27,0,0,0-2.34-12.23,31.73,31.73,0,0,0-6.39-10,30.54,30.54,0,0,0-9.41-6.73,27.18,27.18,0,0,0-11.54-2.47,25.87,25.87,0,0,0-11.34,2.47,27.23,27.23,0,0,0-8.86,6.71,32,32,0,0,0-5.84,10,34.9,34.9,0,0,0-2.13,12.2,35.28,35.28,0,0,0,2.13,12.34,32.8,32.8,0,0,0,5.84,10.08,26.91,26.91,0,0,0,8.86,6.79,26,26,0,0,0,11.34,2.46,26.67,26.67,0,0,0,8-1.23,30.82,30.82,0,0,0,7.35-3.44L51.39,87.11V62.66Q53.86,65.12,57.64,69Z"/>
<path d="M129.29,83.82A25,25,0,0,0,131,93.09a23.87,23.87,0,0,0,4.74,7.56,23.36,23.36,0,0,0,7.08,5.15,19.79,19.79,0,0,0,8.59,1.92v16.9a40.74,40.74,0,0,1-16.22-3.23,42.19,42.19,0,0,1-13.26-8.86,41,41,0,0,1-12.16-29.4V27.34h19.51V83.82Zm63.48-56.48V83.13a39.71,39.71,0,0,1-2.4,13.81,42.28,42.28,0,0,1-6.6,11.81,41.69,41.69,0,0,1-10,9.07,42.47,42.47,0,0,1-12.5,5.57l-8.52-15.8a19.34,19.34,0,0,0,8-2.27,23.28,23.28,0,0,0,6.53-5.22,24.21,24.21,0,0,0,4.39-7.42,24.79,24.79,0,0,0,1.58-8.86.28.28,0,0,0-.07-.21.31.31,0,0,1-.06-.21c0-.27,0-.32.06-.13s.07,0,.07-.48V27.34Z"/>
<path d="M221.76,27.34v96.18H201.7V27.34Z"/>
<path d="M274.94,67.33a116.53,116.53,0,0,1,12.16,4,38.54,38.54,0,0,1,9.61,5.36A22.79,22.79,0,0,1,303,84.23a22.52,22.52,0,0,1,2.26,10.44v.41a27.14,27.14,0,0,1-2.68,12.09,28,28,0,0,1-7.42,9.42,34.31,34.31,0,0,1-11.13,6,43.86,43.86,0,0,1-13.94,2.13,52.07,52.07,0,0,1-13.4-1.7,54.35,54.35,0,0,1-11.61-4.5,46,46,0,0,1-9.14-6.34,27.85,27.85,0,0,1-6-7.38l16.07-10.17a61.41,61.41,0,0,0,5.22,5.15,31.38,31.38,0,0,0,5.5,3.85,25.55,25.55,0,0,0,6.32,2.4,32.36,32.36,0,0,0,7.56.83,26,26,0,0,0,5.29-.55,16.65,16.65,0,0,0,4.87-1.79,11.28,11.28,0,0,0,3.58-3.1,7.57,7.57,0,0,0,1.37-4.61,7.74,7.74,0,0,0-1-3.71,10.92,10.92,0,0,0-3.3-3.51,28.59,28.59,0,0,0-6.32-3.31,82.34,82.34,0,0,0-10-3.1,107.74,107.74,0,0,1-13.33-4,38.62,38.62,0,0,1-10-5.51,22,22,0,0,1-6.32-7.78,24.43,24.43,0,0,1-2.2-10.73A28.31,28.31,0,0,1,235.85,43a25.75,25.75,0,0,1,7.07-9.16,32.88,32.88,0,0,1,10.65-5.71,42.07,42.07,0,0,1,13.12-2,49,49,0,0,1,13.19,1.65A49.56,49.56,0,0,1,290.46,32a40.59,40.59,0,0,1,8.18,5.77A52.15,52.15,0,0,1,304.48,44l-15.8,10.17a39.21,39.21,0,0,0-11.2-7.9A27.85,27.85,0,0,0,266.42,44a18.74,18.74,0,0,0-6.12.89,14.58,14.58,0,0,0-4.19,2.2,9.67,9.67,0,0,0-2.54,3,6.55,6.55,0,0,0-.89,3.16,7.88,7.88,0,0,0,1.44,4.74,13.75,13.75,0,0,0,4.26,3.64,36,36,0,0,0,7,3Q269.58,66,274.94,67.33Z"/>
<path d="M360.26,27.34v96.18H340.48V44.11H312.17V27.34Zm2.89,0h25.42V44.11h-17Z"/>
</g>

<text
x="540"
y="220"
text-anchor="middle"
font-family="sans-serif"
class="subheading1"
font-size="2.5em"
>
Find your lockdown tracks at quist.app/start
</text>

<g font-size="2em"
transform="translate(0, 265)">
<rect
x="86"
rx="15"
width="908"
height="94"
class="result"
/>
<text x="126" y="58" text-anchor="left">MY LOCKDOWN MOOD</text>
<text x="954" y="58" class="result" text-anchor="end">${this.mood_description.toUpperCase()}</text>
</g>

<g
transform="translate(${(1080-(2*coverArtSizeGrid + playlistPadding))/2}, 445)"
>
${await Promise.all(this.playlist.slice(0, 4).map(async (d, i) => `
<image
width="${coverArtSizeGrid}"
height="${coverArtSizeGrid}"
y="${Math.floor(i/2)*(coverArtSizeGrid+playlistPaddingVertical)}"
x="${i%2 ? coverArtSizeGrid+playlistPadding : 0}"
href="${await this.dataUrlForImage(d.albumArt)}" />

<g transform="translate(${i%2 ? coverArtSizeGrid+playlistPadding : 0}, ${Math.floor(i/2)*(coverArtSizeGrid+playlistPaddingVertical)}) translate(${coverArtSizeGrid/2}, 0)">
<text
class="artist"
dominant-baseline="middle"
text-anchor="middle"
y="-30"
font-size="2em"
>
${d.artists[0]}
</text>
</g>
`)).then(l => l.join("\n"))}
</g>
</svg>
`
var parser = new DOMParser();
var svgElement = parser.parseFromString(svgSource, "image/svg+xml").childNodes[0];
this.shortenArtistLabels(svgElement, coverArtSizeGrid+playlistPadding*0.9, 1);

// Dirty hack because safari doesn't draw child images unless they've already been drawn once.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return this.rasterize(svgElement)
.then(firstAttempt => isSafari ? this.rasterize(svgElement) : firstAttempt);
}

async verticalPhotoUrl() {
let coverArtSizeGrid = 300
let playlistPadding = 175
let playlistPaddingVertical = 140;

const spotify_qr = await this.dataUrlForImage(`https://scannables.scdn.co/uri/plain/png/F88D25/white/640/${this.playlist_uri}`);
const svgSource = await `
<svg viewbox="0 0 1080 1920" width="1080" height="1920" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="background" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d86ae3;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(227,145,116,.7);stop-opacity:1" />
</linearGradient>

<filter id="noise">
<feTurbulence
result="f1"
type="turbulence"
baseFrequency="0.4"
numOctaves="1"
seed="0"
y="0"
x="0"
height="1920"
width="1080" />
<feColorMatrix
in="f1"
result="f2"
type="matrix"
values="1.0 0 0 0.5 0
0 1.0 0 0.5 0
0 0 1.0 0.5 0
0 0 1 1.0 0" />
<feColorMatrix
in="f2"
result="f3"
type="matrix"
values="1.0 0 0 0 0
0 1.0 0 0 0
0 0 1.0 0 0
0 0 0 0.3 0" />

<feMerge>
<feMergeNode in="SourceGraphic" />
<feMergeNode in="f3" />
</feMerge>
</filter>
<style>
svg {
font-family: Poppins, "Helvetica Neue", sans-serif;
}
text {
fill: white;
font-weight: 250;
}
text.heading {
fill: black;
}
text.subheading1 {
font-weight: 800;
}
text.result {
font-weight: 600;
}
text.number {
font-weight: 600;
fill: black;
}
text.artist {
font-weight: 600;
text-overflow: ellipsis;
}
rect.result {
fill: rgba(112, 114, 128, 0.2);
}
</style>

</defs>
<rect fill="white" width="100%" height="100%" />
<rect fill="url(#background)" filter="url(#noise)" width="100%" height="100%"></rect>
<g transform="translate(344, 76)" fill="black">
<path d="M57.64,69q3.78,3.84,8.31,8.38l9.42,9.41q4.88,4.88,9.54,9.48l8.8,8.66q4.13,4.05,7,6.94v24.73L80,115.83a51.11,51.11,0,0,1-13.4,6.59,48.9,48.9,0,0,1-15.18,2.34,46.67,46.67,0,0,1-34.21-14.43A48.87,48.87,0,0,1,7.08,94.67a52.46,52.46,0,0,1,0-38.4A47.55,47.55,0,0,1,32.36,30.09a46.7,46.7,0,0,1,19-3.85,48.33,48.33,0,0,1,19.3,3.88A49.58,49.58,0,0,1,86.36,40.7,50.91,50.91,0,0,1,96.94,56.4a48.82,48.82,0,0,1,3.36,26.45,40.05,40.05,0,0,1-1.65,6.87L78.87,87a32.2,32.2,0,0,0,1.65-5.5,30.59,30.59,0,0,0,.55-5.91,32.27,32.27,0,0,0-2.34-12.23,31.73,31.73,0,0,0-6.39-10,30.54,30.54,0,0,0-9.41-6.73,27.18,27.18,0,0,0-11.54-2.47,25.87,25.87,0,0,0-11.34,2.47,27.23,27.23,0,0,0-8.86,6.71,32,32,0,0,0-5.84,10,34.9,34.9,0,0,0-2.13,12.2,35.28,35.28,0,0,0,2.13,12.34,32.8,32.8,0,0,0,5.84,10.08,26.91,26.91,0,0,0,8.86,6.79,26,26,0,0,0,11.34,2.46,26.67,26.67,0,0,0,8-1.23,30.82,30.82,0,0,0,7.35-3.44L51.39,87.11V62.66Q53.86,65.12,57.64,69Z"/>
<path d="M129.29,83.82A25,25,0,0,0,131,93.09a23.87,23.87,0,0,0,4.74,7.56,23.36,23.36,0,0,0,7.08,5.15,19.79,19.79,0,0,0,8.59,1.92v16.9a40.74,40.74,0,0,1-16.22-3.23,42.19,42.19,0,0,1-13.26-8.86,41,41,0,0,1-12.16-29.4V27.34h19.51V83.82Zm63.48-56.48V83.13a39.71,39.71,0,0,1-2.4,13.81,42.28,42.28,0,0,1-6.6,11.81,41.69,41.69,0,0,1-10,9.07,42.47,42.47,0,0,1-12.5,5.57l-8.52-15.8a19.34,19.34,0,0,0,8-2.27,23.28,23.28,0,0,0,6.53-5.22,24.21,24.21,0,0,0,4.39-7.42,24.79,24.79,0,0,0,1.58-8.86.28.28,0,0,0-.07-.21.31.31,0,0,1-.06-.21c0-.27,0-.32.06-.13s.07,0,.07-.48V27.34Z"/>
<path d="M221.76,27.34v96.18H201.7V27.34Z"/>
<path d="M274.94,67.33a116.53,116.53,0,0,1,12.16,4,38.54,38.54,0,0,1,9.61,5.36A22.79,22.79,0,0,1,303,84.23a22.52,22.52,0,0,1,2.26,10.44v.41a27.14,27.14,0,0,1-2.68,12.09,28,28,0,0,1-7.42,9.42,34.31,34.31,0,0,1-11.13,6,43.86,43.86,0,0,1-13.94,2.13,52.07,52.07,0,0,1-13.4-1.7,54.35,54.35,0,0,1-11.61-4.5,46,46,0,0,1-9.14-6.34,27.85,27.85,0,0,1-6-7.38l16.07-10.17a61.41,61.41,0,0,0,5.22,5.15,31.38,31.38,0,0,0,5.5,3.85,25.55,25.55,0,0,0,6.32,2.4,32.36,32.36,0,0,0,7.56.83,26,26,0,0,0,5.29-.55,16.65,16.65,0,0,0,4.87-1.79,11.28,11.28,0,0,0,3.58-3.1,7.57,7.57,0,0,0,1.37-4.61,7.74,7.74,0,0,0-1-3.71,10.92,10.92,0,0,0-3.3-3.51,28.59,28.59,0,0,0-6.32-3.31,82.34,82.34,0,0,0-10-3.1,107.74,107.74,0,0,1-13.33-4,38.62,38.62,0,0,1-10-5.51,22,22,0,0,1-6.32-7.78,24.43,24.43,0,0,1-2.2-10.73A28.31,28.31,0,0,1,235.85,43a25.75,25.75,0,0,1,7.07-9.16,32.88,32.88,0,0,1,10.65-5.71,42.07,42.07,0,0,1,13.12-2,49,49,0,0,1,13.19,1.65A49.56,49.56,0,0,1,290.46,32a40.59,40.59,0,0,1,8.18,5.77A52.15,52.15,0,0,1,304.48,44l-15.8,10.17a39.21,39.21,0,0,0-11.2-7.9A27.85,27.85,0,0,0,266.42,44a18.74,18.74,0,0,0-6.12.89,14.58,14.58,0,0,0-4.19,2.2,9.67,9.67,0,0,0-2.54,3,6.55,6.55,0,0,0-.89,3.16,7.88,7.88,0,0,0,1.44,4.74,13.75,13.75,0,0,0,4.26,3.64,36,36,0,0,0,7,3Q269.58,66,274.94,67.33Z"/>
<path d="M360.26,27.34v96.18H340.48V44.11H312.17V27.34Zm2.89,0h25.42V44.11h-17Z"/>
</g>

<text
x="540"
y="300"
text-anchor="middle"
font-family="sans-serif"
class="subheading1"
font-size="3.5em"
>
Find your lockdown tracks
<tspan x="540" y="370">quist.app/start</tspan>
</text>

<g font-size="3em"
transform="translate(0, 430)">
<rect
x="86"
rx="25"
width="908"
height="200"
class="result"
/>
<text x="50%" y="78" text-anchor="middle">MY LOCKDOWN MOOD</text>
<text x="50%" y="154" class="result" text-anchor="middle">${this.mood_description.toUpperCase()}</text>
</g>

<g
transform="translate(${(1080-(2*coverArtSizeGrid + playlistPadding))/2}, 760)"
>
${await Promise.all(this.playlist.slice(0, 4).map(async (d, i) => `
<image
width="${coverArtSizeGrid}"
height="${coverArtSizeGrid}"
y="${Math.floor(i/2)*(coverArtSizeGrid+playlistPaddingVertical)}"
x="${i%2 ? coverArtSizeGrid+playlistPadding : 0}"
href="${await this.dataUrlForImage(d.albumArt)}" />

<g transform="translate(${i%2 ? coverArtSizeGrid+playlistPadding : 0}, ${Math.floor(i/2)*(coverArtSizeGrid+playlistPaddingVertical)}) translate(${coverArtSizeGrid/2}, 0)">
<text
class="artist"
dominant-baseline="middle"
text-anchor="middle"
y="-50"
font-size="3em"
>
${d.artists[0]}
</text>
</g>
`)).then(l => l.join('\n'))}
</g>

<rect
y="1800"
height="120"
width="1080"
fill="#F88D25"
/>
<text
dominant-baseline="middle"
y="1860"
x="${(1080-(2*coverArtSizeGrid + playlistPadding))/2}"
font-size="3em"
>
My playlist:
</text>
<image
href="${spotify_qr}"
y="1800"
x="${600-(1080-(2*coverArtSizeGrid + playlistPadding))/2}"
height="120"
/>

</svg>
`

var parser = new DOMParser();
var svgElement = parser.parseFromString(svgSource, "image/svg+xml").childNodes[0];
this.shortenArtistLabels(svgElement, coverArtSizeGrid+playlistPadding*0.9, 1);

// Dirty hack because safari doesn't draw child images unless they've already been drawn once.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return this.rasterize(svgElement)
.then(firstAttempt => isSafari ? this.rasterize(svgElement) : firstAttempt);
}

shortenArtistLabels(svg, maxWidth, minFontSize) {
const div = document.createElement('div');
div.setAttribute("style", "position:absolute; visibility:hidden; width:0; height:0");
div.appendChild(svg)
document.body.appendChild(div);

for (let artistLabel of svg.querySelectorAll('text.artist'))
this.shortenLabelToMaxWidth(artistLabel, maxWidth, minFontSize);

document.body.removeChild(div);
}

shortenLabelToMaxWidth(textNode, maxWidth, minTextSize) {
let prevLength = null;
while (textNode.getComputedTextLength() > maxWidth) {
const fontSize = Number(textNode.attributes['font-size'].value.split('em')[0]);
if (fontSize - 0.5 > minTextSize) {
textNode.setAttribute('font-size', `${fontSize-0.5}em`);
continue
}
if (textNode.innerHTML.trim().length > 2)
textNode.innerHTML = textNode.innerHTML.trim().slice(0,-2).trim() + '…'

if (textNode.getComputedTextLength() == prevLength)
break;
prevLength = textNode.getComputedTextLength();
}
}

async rasterize(svg) {
let resolve, reject;
const promise = new Promise((y, n) => (resolve = y, reject = n));
const image = new Image();
const loadPromise = new Promise((resolve, reject) => {
image.onerror = reject;
image.onload = resolve;
image.src = URL.createObjectURL(this.serialize(svg));
});

await loadPromise;

var canvas = document.createElement('canvas');
canvas.width = svg.width.baseVal.value;
canvas.height = svg.height.baseVal.value;
var context = canvas.getContext('2d');

context.drawImage(image, 0, 0, svg.width.baseVal.value, svg.height.baseVal.value);

context.canvas.toBlob(resolve);

return promise;
}

serialize(svg) {
const xmlns = "http://www.w3.org/2000/xmlns/";
const xlinkns = "http://www.w3.org/1999/xlink";
const svgns = "http://www.w3.org/2000/svg";

svg = svg.cloneNode(true);
svg.setAttributeNS(xmlns, "xmlns", svgns);
svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
const serializer = new window.XMLSerializer;
const string = serializer.serializeToString(svg);
return new Blob([string], {type: "image/svg+xml"});
};

async dataUrlForImage(imageUrl) {
let blob = await fetch(imageUrl).then(r => r.blob());
return new Promise(resolve => {
let reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}

escapeXml(unsafe) {
return unsafe.replace(/[<>&'"]/g, function (c) {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '\'': return '&apos;';
case '"': return '&quot;';
}
});
}

}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more