Public
Edited
Oct 4
Paused
19 forks
Importers
46 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function labeledDot(genre) {
const slug = genre.replaceAll(" ", "");
return `<a href="https://everynoise.com/engenremap-${slug}.html" style="white-space: nowrap"><span style="color:${
noises.find((n) => n.genre === genre).color
}">⬤</span> ${genre}</a> `;
}
Insert cell
md`${labeledDot("escape room")}`
Insert cell
Insert cell
everyNoiseAtOnce = FileAttachment("every_noise_at_once@2.html").html()
Insert cell
children = everyNoiseAtOnce.getElementsByClassName("canvas")[0].children
Insert cell
Insert cell
stripArrowSuffix = (str) => str.substring(0, str.length - 2)
Insert cell
stripArrowSuffix(children[0].innerText)
Insert cell
Insert cell
stripPxSuffix(children[0].style.left)
Insert cell
rawNoises = Array.from(
everyNoiseAtOnce.getElementsByClassName("canvas")[0].children
).map((child) => ({
genre: stripArrowSuffix(child.innerText),
x: parseInt(stripPxSuffix(child.style.left)),
y: -parseInt(stripPxSuffix(child.style.top)), // top = -y; we have to flip
color: child.style.color,
link: child.getElementsByClassName("navlink")[0].href
}))
Insert cell
Insert cell
xExtent = Math.max(...rawNoises.map((child) => child.x))
Insert cell
yExtent = -Math.min(...rawNoises.map((noise) => noise.y))
Insert cell
Insert cell
noises = rawNoises.map((noise) => ({
...noise,
y: noise.y + yExtent
}))
Insert cell
Insert cell
Insert cell
yScale = ({
label: "← organic, mechanical & electric →",
labelAnchor: "center",
// https://observablehq.com/@observablehq/plot-cheatsheets-scales
tickFormat: "0s",
labelOffset: 50
})
Insert cell
Insert cell
hull = Plot.hull(noises, {
x: "x",
y: "y",
strokeOpacity: 0.1
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
genreHighlights = highlightedGenres.flatMap((facet) => {
// We need to precompile as early as possible or we'll tank the performance.
const regex = new RegExp(facet);

return noises.map((noise) => ({
...noise,
facet,
highlighted: regex.test(noise.genre)
}));
})
Insert cell
Insert cell
hyperlinkGenre = (genre) => {
// strip spaces from the genre name and tack it onto the prefix
const slug = genre.split(" ").join("");
return htl.html`<a href="https://everynoise.com/engenremap-${slug}.html">${genre}</a>, `;
}
Insert cell
renderNoise = (genre) => {
return md`[${genre}](${hyperlinkGenre(genre)}) <span style="color:${
noises.find((n) => n.genre === genre).color
}">⬤</span>`;
}
Insert cell
renderNoise("shoegaze")
Insert cell
Insert cell
Insert cell
Insert cell
chroma = require("https://bundle.run/chroma-js@2.1.2")
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
response
Insert cell
Insert cell
mutable jumped = false
Insert cell
handleJumping = {
if (jumped) {
return;
}

await Promises.delay(2000);
if (response.access_token) {
mutable jumped = true;

const elem = document.getElementById("step-2");
console.log("Jumping to ", elem);
scrollToElem(elem);
await Promises.delay(500);
scrollToElem(elem);
}
}
Insert cell
function scrollToElem(elem) {
elem.scrollIntoView();
}
Insert cell
Insert cell
SpotifyWebApi = require('https://bundle.run/spotify-web-api-node@5.0.2');
Insert cell
api = {
const api = new SpotifyWebApi({
clientId,
redirectUri: "https://observablehq.com/@mjbo/genre-map-explorer-for-spotify"
});
api.setAccessToken(token);
return api;
}
Insert cell
Insert cell
me = (await api.getMe()).body
Insert cell
Insert cell
paginate = async (f) => {
const pageSize = 50;
const allItems = [];

function requestPage(offset, limit = pageSize) {
return f({ limit, offset })
.then((response) => {
mutable paginationMonitoring = [
...mutable paginationMonitoring,
{ itemCount: response.body.items.length, time: Date.now() }
];
return response;
})
.catch((error) => {
console.error(error);
mutable paginationMonitoring = [
...mutable paginationMonitoring,
{ error, time: Date.now() }
];
return {
body: {
items: [],
total: 0
}
};
});
}

const head = await requestPage(/*offset=*/ 0);

allItems.push(head.body.items);

const pages = Math.floor(head.body.total / pageSize);

const offsets = Array(pages)
.fill(0)
.map((e, i) => i + 1)
.map((x) => x * pageSize);

const pageResponses = offsets.map((offset) => requestPage(offset));

for await (const pageResponse of pageResponses) {
allItems.push(pageResponse.body.items);
}

return allItems.flat();
}
Insert cell
Insert cell
mutable paginationMonitoring = []
Insert cell
Insert cell
Insert cell
playlists = paginate((pageInfo) => api.getUserPlaylists(targetUserId, pageInfo))
Insert cell
Insert cell
Insert cell
selectedPlaylists = settle(viewof playlistTable, /*delay=*/ 3000)
Insert cell
Insert cell
examplePlaylist = selectedPlaylists[0]
Insert cell
Insert cell
getTracksForPlaylistAsync = async ({ playlistId }) => {
return await paginate((pageInfo) =>
api.getPlaylistTracks(playlistId, pageInfo)
);
}
Insert cell
exampleTracks = getTracksForPlaylistAsync({ playlistId: examplePlaylist.id })
Insert cell
Insert cell
minByAddedAt = ({ tracks }) => {
if (!tracks.length) {
return new Date()
}
const dateString = _(tracks).minBy("added_at").added_at;
return new Date(dateString);
}
Insert cell
maxByAddedAt = ({ tracks }) => {
if (!tracks.length) {
return new Date();
}

const dateString = _(tracks).maxBy("added_at").added_at;
return new Date(dateString);
}
Insert cell
minByAddedAt({
tracks: exampleTracks
})
Insert cell
Insert cell
extractArtistIdsFromTracks = ({ tracks }) => {
const ids = tracks.flatMap((track) =>
track.track.album.artists.map((artist) => artist.id)
);
// Remove duplicates
return [...new Set(ids)];
}
Insert cell
extractArtistIdsFromTracks({ tracks: exampleTracks })
Insert cell
Insert cell
Insert cell
chunks = (arr, n) => {
const chunks = new Array()
for (let i = 0; i < arr.length; i += n) {
chunks.push(arr.slice(i, i + n))
}
return chunks
}
Insert cell
getArtistsAsync = async ({ artistIds }) => {
const chunkedIds = chunks(artistIds, ARTIST_API_CHUNK_SIZE);

const callApi = (chunkIds) => {
return (
api
.getArtists(chunkIds)
.then((response) => response.body.artists)
// We don't care if we don't get all pages,
// so long as we don't break the chart.
.catch((error) => {
console.error(error);
return [];
})
);
};

const chunkedResults = await Promise.all(chunkedIds.map(callApi));

return chunkedResults.flat();
}
Insert cell
getArtistsAsync({ artistIds: extractArtistIdsFromTracks({ tracks: exampleTracks }) })
Insert cell
Insert cell
Insert cell
hydratePlaylistAsync({ playlist: examplePlaylist })
Insert cell
Insert cell
hydratedPlaylists = Promise.all(selectedPlaylists.map(playlist => hydratePlaylistAsync({ playlist })))
Insert cell
Insert cell
genresInPlaylists = new Set(hydratedPlaylists.flatMap(playlist => playlist.artists).flatMap(artist => artist.genres))
Insert cell
artistsInPlaylists = new Set(hydratedPlaylists.flatMap(playlist => playlist.artists).map(artist => artist.name))
Insert cell
Insert cell
searchedNoisesSet = new Set(searchedNoises)
Insert cell
searchedGenresSet = new Set(searchedGenres)
Insert cell
searchedArtistsSet = new Set(searchedArtists)
Insert cell
Insert cell
hydratedArtists = hydratedPlaylists.flatMap((playlist) =>
playlist.artists.map((artist) => {
const artistTracks = playlist.tracks.filter(
(track) =>
// tracks may be created by uncredited artists
[...track.track.artists, ...track.track.album.artists].filter(
(a) => a.name === artist.name
).length
);

const artistAlbums = _(artistTracks)
.map((track) => track.track.album)
.uniqBy((album) => album.id)
.value();

if (artistTracks.length === 0) {
console.log(artist);
}

return {
albums: artistAlbums,
tracks: artistTracks,
// There's an odd scenario where an artist will be a coartist on an album
// but none of the tracks, e.g. Kids See Ghosts
discovery_date:
artistTracks.length && minByAddedAt({ tracks: artistTracks }),
center_of_mass: calcCenterOfMass({ genres: artist.genres }),
from_playlist: playlist,
// for performance
genres_set: new Set(artist.genres),
...artist
};
})
)
Insert cell
exampleArtist = hydratedArtists[0]
Insert cell
Insert cell
getGenreArtists = ({ genre }) =>
hydratedArtists.filter((artist) => artist.genres.includes(genre))
Insert cell
Insert cell
getGenreArtists({ genre: exampleGenre })
Insert cell
Insert cell
Insert cell
makeClusterTooltip = ({ cluster }) => {
const groups = getArtistsGroupedByGenre({ genre: cluster.genre });

let acc = cluster.genre;

for (const [playlist, artists] of Object.entries(groups)) {
const artistNames = artists
.map((artist) => artist.name)
.filter((name) => searchedArtistsSet.has(name));

if (artistNames.length === 0) {
continue;
}

if (condensedTooltips) {
acc += `\n--- ${playlist} ---`;

for (const chunk of _.chunk(artistNames, 5)) {
acc += "\n" + chunk.join(", ");
}
} else {
for (const artistName of artistNames) {
acc += `\n${artistName} from ${playlist}`;
}
}
}

return acc;
}
Insert cell
exampleCluster = hydratedClusters[0]
Insert cell
makeClusterTooltip({ cluster: exampleCluster })
Insert cell
Insert cell
getArtistsGroupedByGenre = ({ genre }) => {
const artists = getGenreArtists({ genre });

if (artists.length === 0) {
return undefined;
}

return _(artists)
.groupBy((artist) => artist.from_playlist.name)
.entries()
.sortBy(([genre, artists]) => artists.length)
.reverse()
.fromPairs()
.value();
}
Insert cell
getArtistsGroupedByGenre({ genre: exampleGenre })
Insert cell
Insert cell
getMostCommonPlaylistOrigin({ genre: exampleGenre })
Insert cell
Insert cell
genresInPlaylists
Insert cell
Insert cell
clusters = aq
.table({ genre: [...genresInPlaylists] })
.join(aq.from(noises), "genre")
.objects()
Insert cell
Insert cell
hydratedClusters = clusters.map((cluster) => {
const artists = getGenreArtists({ genre: cluster.genre });

return {
...cluster,
artists,
radius: artists.length,
representative_playlist_name: getMostCommonPlaylistOrigin({
genre: cluster.genre
})
};
})
Insert cell
Insert cell
Insert cell
exampleArtist.genres
Insert cell
calcCenterOfMass({ genres: exampleArtist.genres })
Insert cell
Insert cell
Insert cell
exampleArtist
Insert cell
makeArtistTooltip({ artist: exampleArtist })
Insert cell
Insert cell
comSearchedArtistsSet = new Set(comSearchedArtists)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
minAddedAt = minByAddedAt({ tracks: hydratedTracks })
Insert cell
maxAddedAt = maxByAddedAt({ tracks: hydratedTracks })
Insert cell
Insert cell
artistsToSmooth = hydratedArtists.filter(
(artist) =>
artist.from_playlist.name === playlistToSmooth && artist.center_of_mass
)
Insert cell
Insert cell
Insert cell
noiseMap.scale("y")
Insert cell
Insert cell
noiseMap.scale("x")
Insert cell
artistsToSmoothPoints = artistsToSmooth.map((artist) => {
const point = [
noiseMap.scale("x").apply(artist.center_of_mass.x),
noiseMap
.scale("y")
.apply(noiseMap.scale("y").domain[0] - artist.center_of_mass.y) // gotta invert
];
// squirrel away the artist so we're able to identify the points that come back later on
// putting the artist in index 2 breaks things so we're forced to do this instead
point.artist = artist;
return point;
})
Insert cell
Insert cell
optimize
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
sortedPoints = {
let iterations = 0;
for (const points of order_by_distance(artistsToSmoothPoints)) {
iterations++;

if (iterations > 40) {
console.warn(
"Failed to stabilize path optimisation. Stopping further refinement."
);
return points;
}

yield points;
}

console.info(`Path took ${iterations} iterations to optimise`);
}
Insert cell
smoothedArtists = sortedPoints.map((point) => point.artist)
Insert cell
Insert cell
plotPathMap = ({ artists }) => {
return addTooltips(
Plot.plot({
caption: "Click to open artist on Spotify",
width,
height: height * 0.8,
color: {
legend: true,
domain: [...clusterMap.scale("color").domain, "#0000FF"]
},
x: xScale,
y: yScale,
marks: [
hull,
Plot.density(
hydratedArtists.filter((a) => a.center_of_mass),
{
x: (d) => d.center_of_mass.x,
y: (d) => d.center_of_mass.y,
fill: (d) => d.from_playlist.name,
fillOpacity: 0.03,
clip: true
}
),
Plot.line(artists, {
x: (d) => d.center_of_mass.x,
y: (d) => d.center_of_mass.y,
curve: "catmull-rom",
marker: "arrow"
}),
on(
Plot.image(artists, {
x: (d) => d.center_of_mass.x,
y: (d) => d.center_of_mass.y,
// last is typically the smallest
src: (d) => (_.last(d.images) ? _.last(d.images).url : undefined),
width: (d) => 24,
title: (artist) => makeArtistTooltip({ artist })
}),
{
click: (event, d) =>
window.open(d.datum.external_urls.spotify, "_blank")
}
),
Plot.text(artists, {
x: (d) => d.center_of_mass.x,
y: (d) => d.center_of_mass.y,
fill: "blue",
href: (d) => d.external_urls.spotify,
target: () => "_blank",
text: (d) => d.name,
dy: -15
})
]
})
);
}
Insert cell
renderArtistsTable = ({ artists }) => {
const data = artists.map((artist) => {
return {
...artist,
Name: artist.name,
Genres: artist.genres,
Albums: artist.albums,
Link: "OPEN SPOTIFY"
};
});

return Inputs.table(data, {
columns: ["Name", "Link", "Genres", "Albums"],
format: {
Name: (name, i) =>
html`<a href=${artists[i].external_urls.spotify}>${name}</a>`,
Genres: (genres) =>
html`<div style="white-space: normal">${genres.map(
hyperlinkGenre
)}</div>`,
Albums: (albums) =>
html`<div>${albums.map((album) => {
return html`
<div>
<a href=${album.external_urls.spotify} target="_blank">
<img src="${album.images[2].url}" height="64" width="64" loading="lazy"/>
</a>
<a style="display: block" href=${album.external_urls.spotify}>${album.name}</a>
<a style="display: inline-block" class="spotify" href=${album.external_urls.spotify}>OPEN SPOTIFY</a>
</div>`;
})}</div>`,
Link: (link, i) =>
html`<a class="spotify" href=${artists[i].external_urls.spotify}>${link}</a>`
}
});
}
Insert cell
Insert cell
urlBlob = smoothedArtists
.flatMap((artist) => artist.tracks)
.map((track) => track.track.external_urls.spotify)
.join("\n")
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
enao_genres
Insert cell
Insert cell
bt_genres
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart_param = ({ ...chart_param_init, width: width / 2, height: width / 2 })
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more