2023 musical retrospective

import io.data2viz.color.* import io.data2viz.geom.* import io.data2viz.math.* import io.data2viz.viz.* import io.data2viz.force.* import io.data2viz.dsv.Dsv import kotlin.math.min import kotlin.math.sqrt import org.w3c.fetch.Response import kotlinx.browser.window import kotlin.js.Promise val vizSize = 1000.0 val rockColor = Colors.Web.red val folkColor = Colors.Web.orange val popColor = Colors.Web.blue val punkColor = Colors.Web.green // store the week of the simulation val weeks = 45 var currentWeek = 0 // double speed on 120fps screen var fps = 60 var fpsDecay = 0.02 // use these to manage the "speed" and duration of the simulation val simulationDefaultIntensity = .11.pct val simulationIntensityDecay = 0.pct data class Style( val rock: Percent, val folk: Percent, val pop: Percent, val punk: Percent ) // An artist with the number of different songs played during time and musical style data class Artist( val name:String, val index: Int, val style: Style, val songs: List<Int>, var alreadyPresent: Boolean = songs[0] > 0 ) { // return the "musical style" color at a given week fun color(week: Int) = Colors.rgb( ((rockColor.r * style.rock) + (folkColor.r * style.folk) + (popColor.r * style.pop) + (punkColor.r * style.punk)).toInt(), ((rockColor.g * style.rock) + (folkColor.g * style.folk) + (popColor.g * style.pop) + (punkColor.g * style.punk)).toInt(), ((rockColor.b * style.rock) + (folkColor.b * style.folk) + (popColor.b * style.pop) + (punkColor.b * style.punk)).toInt() ) // If the artist is or has been in the playlist at given week return 1.0 else .0 fun hasDisplayed(week: Int): Double { alreadyPresent = alreadyPresent || songs[week] > 0 return if (alreadyPresent) 1.0 else .0 } // If the artist is currently in the playlist return 1.0 else return .0 fun display(week: Int): Double = min(currentSongCount(week), 1.0) // Return the song count for the given artist for the current week as a Double fun currentSongCount(week: Int): Double = songs[week].toDouble() } // Parse an artist from the CSV file private fun parsePlaylist(row: List<String>) = Artist( name = row[0], index = row[1].toInt(), style = Style( rock = (row[2].toInt() * 10.0).pct, folk = (row[3].toInt() * 10.0).pct, pop = (row[4].toInt() * 10.0).pct, punk = (row[5].toInt() * 10.0).pct ), songs = (0 .. weeks).map { row[6+it].toInt() } ) fun main() { // Animation counter var animationCount = 0 // Storing the visual nodes (texts and bubbles) val particleTexts = mutableListOf<TextNode>() val particleNodes = mutableListOf<CircleNode>() // Requesting data (CSV file) val request: Promise<Response> = window.fetch("https://docs.google.com/spreadsheets/d/e/2PACX-1vRRlQ8Cd69-0ysrA2TK33hIN1HkqF37XtbjCiS5jvBWj8A6BzuoaWzNUyPdCdFnCLMf-keu-6V8KYDf/pub?gid=1506504853&single=true&output=csv") // On download, parse file and build playlist request.then { result -> result.text().then { csvText -> val parsedArtists = Dsv() .parseRows(csvText) .drop(1) .map { parsePlaylist(it) } // Node 0 is the listener keep it apart as it will be treated differently val playlist = mutableListOf( Artist("L", 20, Style(0.pct, 0.pct, 0.pct, 0.pct), (0..weeks).map {0}) ) playlist.addAll(parsedArtists) // Creating force simulation val simulation = createSimulation(playlist) // Finally create the vizualisation itself viz { size = size(vizSize, vizSize) val button = rect { fill = Colors.Web.lightgray x = 10.0 y = 10.0 height = 30.0 width = 90.0 } val buttonText = text { textContent = "60 FPS screen" textAlign = textAlign(TextHAlign.MIDDLE, TextVAlign.MIDDLE) fontWeight = FontWeight.BOLD x = 55.0 y = 25.0 } on(KPointerClick) { event -> if (button.contains(event.pos)) { if (fps == 60) { fps = 120 fpsDecay = 0.01 } else { fps = 60 fpsDecay = 0.02 } buttonText.textContent = "$fps FPS screen" } } simulation.nodes.forEachIndexed { index, node -> particleNodes += circle { //radius = if (index == 0) 20.0 else .0 } particleTexts += text { textColor = Colors.Web.black.withAlpha(0.pct) textContent = playlist[node.index].name textAlign = textAlign(TextHAlign.MIDDLE, TextVAlign.MIDDLE) fontWeight = FontWeight.BOLD fontSize = 14.0 } } animation { animationCount++ // every X frames... if (animationCount > fps) { animationCount = 0 // go to next week currentWeek = min(weeks - 1, currentWeek + 1) // update domain objects (force recompute of forces' strengths...) simulation.domainObjects = playlist // restart simulation intensity simulation.intensity = simulationDefaultIntensity } simulation.nodes.forEachIndexed { index, node -> if (index > 0) { val previousAlpha = (particleTexts[node.index].textColor as Color).alpha.value val nextAlpha = node.domain.display(currentWeek) val currentAlpha = Percent((fpsDecay * nextAlpha) + ((1.0 - fpsDecay) * previousAlpha)) val currentColor = node.domain.color(currentWeek).withAlpha(currentAlpha) particleTexts[node.index].textColor = currentColor particleNodes[node.index].fill = currentColor val previousRadius = particleNodes[node.index].radius val nextRadius = sqrt(10 * node.domain.currentSongCount(currentWeek)) val currentRadius = (fpsDecay * nextRadius) + ((1.0 - fpsDecay) * previousRadius) particleNodes[node.index].radius = currentRadius particleTexts[node.index].y = node.y - 10.0 - currentRadius } particleTexts[node.index].x = node.x particleNodes[node.index].x = node.x particleNodes[node.index].y = node.y } } }.bindRendererOnNewCanvas() } } } private fun createSimulation(playlist: List<Artist>) = forceSimulation<Artist> { // This is used to center the whole simulation on screen forceCenter { center = point(vizSize / 2, vizSize / 2) } // Links between artists and their musical style forcePoint { pointGet = { Point(100.0, 100.0) } strengthGet = { Percent(domain.style.rock * domain.hasDisplayed(currentWeek) * 4.0) } } forcePoint { pointGet = { Point(vizSize - 100.0, 100.0) } strengthGet = { Percent(domain.style.folk * domain.hasDisplayed(currentWeek) * 4.0) } } forcePoint { pointGet = { Point(vizSize - 100.0, vizSize - 100.0) } strengthGet = { Percent(domain.style.pop * domain.hasDisplayed(currentWeek) * 4.0) } } forcePoint { pointGet = { Point(100.0, vizSize - 100.0) } strengthGet = { Percent(domain.style.punk * domain.hasDisplayed(currentWeek) * 4.0) } } // Building links between artists and the listener forceLink { linkGet = { if (this.index == 0) { (1 .. playlist.size).mapIndexed { index, artist -> Link<Artist>(this, nodes[index], domain.hasDisplayed(currentWeek) * (270.0 - (playlist[index].currentSongCount(currentWeek) * 40.0)) ) } } else { listOf() } } iterations = 1 } // Add collisions to avoid overlapping of artists forceCollision { radiusGet = { if (index == 0) .0 else domain.display(currentWeek) * (20.0 + (domain.currentSongCount(currentWeek) * 2.0)) } iterations = 1 } // The whole dataset for this simulation is the "playlist" object domainObjects = playlist intensity = simulationDefaultIntensity intensityDecay = simulationIntensityDecay }
pierre avatar

Sketch created by

pierre

My daughter takes out my streaming account to start a rogue playlist with some of the artists I listened to and that she liked the most, then adds some more during the year guided by the app's suggestions. Size of the bubble is the number of tracks from the most listened artists during a week. Color is more or less the musical type (from the songs selection). It is interesting to see patterns when she discovers an artist and then similar groups, as they come (and go). As you can see, after a short domination from Nirvana, 2023 has been the year of Måneskin.

comments