Skip to content

Commit

Permalink
Use Chart.js for gender structure and start/duration plots (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
v6ak committed Oct 15, 2023
1 parent ec05a7b commit 30a1cea
Show file tree
Hide file tree
Showing 4 changed files with 196 additions and 90 deletions.
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,11 @@ lazy val client = (project in file("client")).settings(
"org.webjars" % "momentjs" % "2.10.6" / "min/moment.min.js",
"org.webjars" % "moment-timezone" % "0.4.0-1" / "moment-timezone-with-data.js" dependsOn "min/moment.min.js",
"org.webjars" % "jquery" % "2.1.4" / jqueryName minified "jquery/2.1.4/jquery.min.js",
"org.webjars.npm" % "chart.js" % "4.4.0" / "chart.umd.js",
"org.webjars.npm" % "chartjs-adapter-moment" % "1.0.0" / "chartjs-adapter-moment.min.js" dependsOn "chart.umd.js",
jqPlot / "jquery.jqplot.min.js" dependsOn jqueryName,
jqPlot / "jqplot.dateAxisRenderer.min.js" dependsOn "jquery.jqplot.min.js",
jqPlot / "jqplot.categoryAxisRenderer.min.js" dependsOn "jquery.jqplot.min.js",
jqPlot / "jqplot.bubbleRenderer.min.js" dependsOn "jquery.jqplot.min.js",
jqPlot / "jqplot.pieRenderer.min.js" dependsOn "jquery.jqplot.min.js",
jqPlot / "jqplot.barRenderer.min.js" dependsOn "jquery.jqplot.min.js",
jqPlot / "jqplot.pointLabels.min.js" dependsOn "jquery.jqplot.min.js",
jqPlot / "jqplot.highlighter.min.js" dependsOn "jquery.jqplot.min.js",
Expand Down
105 changes: 105 additions & 0 deletions client/src/main/scala/com/v6ak/zbdb/ChartJsUtils.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.v6ak.zbdb

import scala.scalajs.js
import scala.scalajs.js.annotation._
import org.scalajs.dom._
import scala.scalajs.js.Dynamic.literal
import scala.scalajs.js.JSConverters._
import scalatags.JsDom.all._
import Bootstrap.DialogUtils
import com.example.moment.{Moment, moment}

@JSGlobal @js.native class Chart(el: Element, data: js.Any) extends js.Object {
def resize(): Unit = js.native
def update(): Unit = js.native
def destroy(): Unit = js.native
}

object ChartJsUtils {
// Chart.register(…)

def zeroMoment = moment("2000-01-01") // We don't want to mutate it

def timeAxis(label: String) = literal(
`type` = "time",
time = literal(
unit = "minute",
displayFormats = literal(
minute = "HH:mm",
),
),
ticks = literal(
stepSize = 5,
),
title = literal(
display = true,
text = label,
),
)

def durationAxis(label: String, min: js.Any) = literal(
`type` = "time",
min = min,
ticks = literal(
callback = (value: Moment, _index: js.Dynamic, _ticks: js.Dynamic) => {
val diff = value - zeroMoment
val hours = diff / 1000 / 60 / 60
val minutes = diff / 1000 / 60 - hours * 60
f"$hours%02d:$minutes%02d"
},
stepSize = 30,
),
title = literal(
display = true,
text = label,
),
)

def dataFromPairs(seq: Seq[(Any, Any)]) = literal(
labels = seq.map(_._1).toJSArray,
datasets = js.Array(
literal(
data = seq.map(_._2).toJSArray,
)
)
)

def dataFromTriples(seq: Seq[(Any, Any, String)]) = literal(
labels = seq.map(_._1).toJSArray,
datasets = js.Array(
literal(
backgroundColor = seq.map(_._3).toJSArray,
data = seq.map(_._2).toJSArray,
)
)
)

def showChartInModal(title: String = null)(f: Seq[Participant] => js.Any): (Option[String], (String, => Seq[Participant], ParticipantTable) => Unit) =
Option(title) -> ((modalBodyId: String, rowsLoader, participantTable: Any) => {
val el = window.document.getElementById(modalBodyId).asInstanceOf[HTMLElement]
el.style.maxHeight = "90vh"
el.style.maxWidth = "90vw"

val can = canvas().render
el.appendChild(can)

val plotParams = f(rowsLoader)
console.log("plotParams", plotParams)
val chart = new Chart(can, plotParams)
val resizeHandler: Event => Unit = _ => {
//chart.update()
chart.resize()
}
window.addEventListener("resize", resizeHandler)

def destroy(): Unit = {
window.removeEventListener("resize", resizeHandler)
chart.destroy()
}

val modalElement = el.parentNode.parentNode.parentNode.asInstanceOf[Element]
modalElement.onBsModalHidden(() => destroy())
}
)

}
173 changes: 87 additions & 86 deletions client/src/main/scala/com/v6ak/zbdb/PlotRenderer.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package com.v6ak.zbdb

import com.example.moment._
import com.example.moment.{moment, _}
import com.v6ak.zbdb.`$`.jqplot.DateAxisRenderer
import org.scalajs.dom
import org.scalajs.dom._

import scala.collection.immutable
import scala.scalajs.js
import scala.scalajs.js.Dictionary
import scala.scalajs.js
import scala.scalajs.js.JSConverters._
import scala.scalajs.js.Dynamic.literal
import scala.scalajs.js.annotation._
import Bootstrap.DialogUtils
import com.example.moment._

@JSGlobal @js.native object `$` extends js.Object{
@js.native object jqplot extends js.Object{
Expand All @@ -22,6 +26,7 @@ import scala.scalajs.js.annotation._
}

final class PlotRenderer(participantTable: ParticipantTable) {
import ChartJsUtils._

import participantTable._

Expand All @@ -31,42 +36,28 @@ final class PlotRenderer(participantTable: ParticipantTable) {
ParticipantPlotGenerator("pauz", "pauzy", Glyphs.Pause, generatePausesPlotData)
)

val GlobalPlots = Seq(
"Porovnání startu a času" -> startTimeToTotalDurationPlot _,
"Genderová struktura" -> genderStructurePlot _
) ++
(if(participantTable.formatVersion.ageType == AgeType.BirthYear) Seq("Věková struktura" -> ageStructurePlot _) else Seq()) ++ Seq(
"Počet lidí" -> remainingParticipantsCountPlot _,
"Počet lidí v %" -> remainingRelativeCountPlot _
val GlobalPlots = Seq[(String, (Option[String], ((String, =>Seq[Participant], ParticipantTable) => Unit)))](
"Porovnání startu a času" -> startTimeToTotalDurationPlot,
"Genderová struktura" -> genderStructurePlot,
"Počet lidí" -> (None -> remainingParticipantsCountPlot _),
"Počet lidí v %" -> (None -> remainingRelativeCountPlot _)
)

private val GenderNames = Map[Gender, String](
Gender.Male -> "muž",
Gender.Female -> "žena"
)

private def zeroMoment = moment("2000-01-01") // We don't want to mutate it

private val DurationRenderer: js.ThisFunction = (th: js.Dynamic) => {
dom.window.asInstanceOf[js.Dynamic].$.jqplot.DateAxisRenderer.call(th)
}
DurationRenderer.asInstanceOf[js.Dynamic].prototype = new DateAxisRenderer()
DurationRenderer.asInstanceOf[js.Dynamic].prototype.init = ((th: js.Dynamic) => {
dom.window.asInstanceOf[js.Dynamic].$.jqplot.DateAxisRenderer.prototype.init.call(th)
th.tickOptions.formatter = ((format: String, value: Moment) => {
val diff = value - zeroMoment
val hours = diff/1000/60/60
val minutes = diff/1000/60 - hours*60
f"$hours%02d:$minutes%02d"
}): js.Function
}): js.ThisFunction
private val GenderColors = Map[Gender, String](
Gender.Female -> "#FFC0CB",
Gender.Male -> "#008080",
)

private val BarRenderer = dom.window.asInstanceOf[js.Dynamic].$.jqplot.BarRenderer

private val DateAxisRenderer = dom.window.asInstanceOf[js.Dynamic].$.jqplot.DateAxisRenderer

def generateWalkPlotData(rowsLoader: Seq[Participant]) = {
import com.example.moment._
val data = rowsLoader.map(processTimes)
val series = js.Array(data.map(line =>
line.seriesOptions
Expand Down Expand Up @@ -108,56 +99,73 @@ final class PlotRenderer(participantTable: ParticipantTable) {
)
}

def startTimeToTotalDurationPlot(modalBodyId: String, rowsLoader: => Seq[Participant], participantTable: ParticipantTable): Unit ={
val finishers = rowsLoader.filter(p => p.hasFinished).groupBy(p => (p.startTime.toString, p.partTimes.last.endTimeOption.get - p.startTime))
import com.example.moment._
val plotParams = js.Dictionary(
"title" -> "Porovnání startu a času chůze (pouze finalisti)",
"seriesDefaults" -> js.Dictionary(
"renderer" -> dom.window.asInstanceOf[js.Dynamic].$.jqplot.BubbleRenderer,
"rendererOptions" -> js.Dictionary(
"bubbleGradients" -> true
),
"shadow" -> true
),
/*"highlighter" -> js.Dictionary(
"show" -> true,
"showTooltip" -> true,
"formatString" -> "%s|%s|%s|%s",
"bringSeriesToFront" -> true
),*/
"height" -> 500,
"legend" -> Dictionary("show" -> true),
"axes" -> Dictionary(
"xaxis" -> Dictionary(
"renderer" -> dom.window.asInstanceOf[js.Dynamic].$.jqplot.DateAxisRenderer,
"tickOptions" -> Dictionary("formatString" -> "%#H:%M"),
"min" -> moment(startTime).minutes(0).toString,
"tickInterval" -> "5 minutes"
),
"yaxis" -> Dictionary(
"renderer" -> DurationRenderer,
"tickOptions" -> Dictionary(
"formatString" -> "aaa %#H:%M",
"formatter" -> ((format: String, value: Moment) => value.toString)
),
"tickInterval" -> "30 minutes"
)
private val startTimeToTotalDurationRadius = (context: js.Dynamic) => {
// It doesn't seem to be recalculated when resized, even though this function is called
val baseSize = math.max(
5, // minimal base size in px
math.min(
context.chart.height.asInstanceOf[Double] / 20,
context.chart.width.asInstanceOf[Double] / 20,
)
)
val plotPoints = js.Array(
js.Array(
finishers.map{case ((moment, time), participants) =>
js.Array(
moment/*.hours()*60+moment.minutes()*/,
zeroMoment.add({dom.console.log(time+""+participants); time+1}, "milliseconds").toString,
math.sqrt(participants.size.toDouble),
participants.map(_.fullName).mkString(", ") + " ("+participants.size+")",
val res = baseSize
// area should scale linearly, so we need sqrt for radius
res * math.sqrt(context.raw.participants.length.asInstanceOf[Int])
}

private def startTimeToTotalDurationTooltip = {
literal(
callbacks = literal(
label = (context: js.Dynamic) => {
val participants = context.raw.participants.asInstanceOf[js.Array[Participant]]
participants.map(_.fullName).mkString(", ") + " (" + participants.size + ")"
},
),
)
}

private def startTimeToTotalDurationPlot = showChartInModal(
title = "Porovnání startu a času (pouze finalisti)"
) { rows =>
val finishers = rows.filter(p => p.hasFinished).groupBy(p =>
(p.startTime.toString, p.partTimes.last.endTimeOption.get - p.startTime)
)
literal(
`type` = "bubble",
data = literal(
datasets = js.Array(
literal(
radius = startTimeToTotalDurationRadius,
data = finishers.map { case ((_startTimeString, time), participants) =>
val startTime = participants(0).startTime
literal(
x = startTime,
y = zeroMoment.add(time, "milliseconds"),
participants = participants.toJSArray
)
}.toJSArray
)
}.toSeq
:_*)
)
),
options = literal(
scales = literal(
x = timeAxis("Čas startu"),
y = durationAxis(
label="Celková doba",
min=zeroMoment.add(
finishers.values.flatten.map(p =>
(p.partTimes.last.endTimeOption.get - p.startTime) / 3600 / 1000
).min - 1,
"hours"
)
),
),
plugins = literal(
legend = literal(display = false),
tooltip = startTimeToTotalDurationTooltip,
),
),
)
dom.window.asInstanceOf[js.Dynamic].$.jqplot(modalBodyId, plotPoints, plotParams)
}

private def computeCumulativeMortality(rows: Seq[Participant]) = {
Expand Down Expand Up @@ -237,22 +245,15 @@ final class PlotRenderer(participantTable: ParticipantTable) {
dom.window.asInstanceOf[js.Dynamic].$.jqplot(modalBodyId, plotPoints, plotParams)
}

def genderStructurePlot(modalBodyId: String, rowsLoader: => Seq[Participant], participantTable: ParticipantTable): Unit ={
val structure = rowsLoader.groupBy(_.gender)
val plotParams = js.Dictionary(
"title" -> "Genderová struktura startujících",
"seriesDefaults" -> js.Dictionary(
"renderer" -> dom.window.asInstanceOf[js.Dynamic].$.jqplot.PieRenderer,
"rendererOptions" -> js.Dictionary(
"showDataLabels" -> true
),
"shadow" -> true
),
"height" -> 500,
"legend" -> Dictionary("show" -> true)
def genderStructurePlot = showChartInModal() { rows =>
val structure = rows.groupBy(_.gender)
js.Dynamic.literal(
`type` = "pie",
data = dataFromTriples(structure.toSeq.map { case (gender, p) =>
(GenderNames(gender), p.size, GenderColors(gender))
}),
title = "Genderová struktura startujících",
)
val plotPoints = js.Array(js.Array(structure.map{case (gender, p) => js.Array(GenderNames(gender), p.size)}.toSeq: _*))
dom.window.asInstanceOf[js.Dynamic].$.jqplot(modalBodyId, plotPoints, plotParams)
}

def ageStructurePlot(modalBodyId: String, rowsLoader: => Seq[Participant], participantTable: ParticipantTable): Unit ={
Expand Down
4 changes: 2 additions & 2 deletions client/src/main/scala/com/v6ak/zbdb/Renderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ final class Renderer private(participantTable: ParticipantTable, processingError
).render

private val globalStats = div(id := "global-stats")(
GlobalPlots.map{case (plotName, plotFunction) =>
GlobalPlots.map { case (plotName, (detailedTitleOption, plotFunction)) =>
button(plotName)(cls := "btn btn-secondary d-print-none")(onclick := {(e: Event) =>
val (dialog, modalBodyId, bsMod) = modal(plotName)
val (dialog, modalBodyId, bsMod) = modal(detailedTitleOption.getOrElse(plotName): String)
dialog.onBsModalShown{ () => plotFunction(modalBodyId, data, participantTable) }
dom.document.body.appendChild(dialog)
bsMod.show()
Expand Down

0 comments on commit 30a1cea

Please sign in to comment.