2023 musical retrospective (presbyopic version)

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 = 26.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 - 15.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