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
}
comments