From 4e0c5e1a52f794cc92d4a9168741563e04db7dcf Mon Sep 17 00:00:00 2001 From: Lunfu Zhong Date: Fri, 6 Sep 2024 20:02:19 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9F=E5=BF=83=E6=AF=94=E8=B5=B0=E5=8A=BF?= =?UTF-8?q?=E5=88=86=E6=9E=90=E4=B8=AD=E6=B7=BB=E5=8A=A0=E9=85=8D=E9=80=9F?= =?UTF-8?q?=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stash. * React by select. * Fix pace order. * Fix GMT. --- .scalafmt.conf | 2 +- src/core/DateFormat.scala | 23 --------- src/core/DateTimeGMT.scala | 20 ++++++++ src/core/metrics/Interval.scala | 2 +- src/plotly/Correlate.scala | 47 +++++++++++++++++++ src/plotly/DataArrayFrom.scala | 32 ++----------- src/plotly/Layout.scala | 41 ++--------------- src/plotly/Listen.scala | 19 +++----- src/plotly/SharedAxis.scala | 26 +++++++++++ src/plotly/Title.scala | 7 +-- src/plotly/Trace.scala | 82 +++++++++++++++++++++++++++++++++ src/plotly/package.scala | 11 +++-- 12 files changed, 200 insertions(+), 112 deletions(-) delete mode 100644 src/core/DateFormat.scala create mode 100644 src/core/DateTimeGMT.scala create mode 100644 src/plotly/Correlate.scala create mode 100644 src/plotly/SharedAxis.scala create mode 100644 src/plotly/Trace.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index eb7b059..68efad8 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -21,7 +21,7 @@ rewrite.imports.groups = [ rewrite.scala3.convertToNewSyntax = true rewrite.scala3.removeOptionalBraces = true -rewrite.scala3.insertEndMarkerMinLines = 3 +rewrite.scala3.insertEndMarkerMinLines = 10 fileOverride { "glob:**/*.sc" { diff --git a/src/core/DateFormat.scala b/src/core/DateFormat.scala deleted file mode 100644 index 1a96456..0000000 --- a/src/core/DateFormat.scala +++ /dev/null @@ -1,23 +0,0 @@ -package core - -import scala.scalajs.js - -trait DateFormat[A] extends (A => js.Date): - private trait Ext extends js.Object: - def toLocaleDateString(locale: String, option: js.Any): String - - extension (a: A) - inline def ymd(locale: String) = - this(a).asInstanceOf[Ext].toLocaleDateString(locale, js.undefined) - - inline def md(locale: String) = - val opt = js.Dynamic.literal(month = "2-digit", day = "2-digit") - this(a).asInstanceOf[Ext].toLocaleDateString(locale, opt) - end md - end extension -end DateFormat - -object DateFormat: - given double: DateFormat[Double] = new js.Date(_) - given string: DateFormat[String] = new js.Date(_) -end DateFormat diff --git a/src/core/DateTimeGMT.scala b/src/core/DateTimeGMT.scala new file mode 100644 index 0000000..d068270 --- /dev/null +++ b/src/core/DateTimeGMT.scala @@ -0,0 +1,20 @@ +package core + +import scala.scalajs.js + +trait DateTimeGMT[A] extends (A => js.Date): + private trait Ext extends js.Object: + def toLocaleDateString(locale: String, option: js.Any): String + + extension (a: A) + inline def ymd(locale: String) = + this(a).asInstanceOf[Ext].toLocaleDateString(locale, js.undefined) + + end extension +end DateTimeGMT + +object DateTimeGMT: + given string: DateTimeGMT[String] = s => + val d = new js.Date(s) + new js.Date(d.getTime() - (d.getTimezoneOffset() * 60000)) +end DateTimeGMT diff --git a/src/core/metrics/Interval.scala b/src/core/metrics/Interval.scala index 0f50daa..cd12bae 100644 --- a/src/core/metrics/Interval.scala +++ b/src/core/metrics/Interval.scala @@ -15,5 +15,5 @@ end Interval object Interval: given perf(using Speed[Interval]): Performance[Interval] = i => i.speed * 60 / i.averageHR given speed: Speed[Interval] = i => i.distance / i.duration - given pace: Pace[Interval] = i => i.duration / i.distance * 1000 + given pace: Pace[Interval] = i => i.duration / i.distance end Interval diff --git a/src/plotly/Correlate.scala b/src/plotly/Correlate.scala new file mode 100644 index 0000000..63782e0 --- /dev/null +++ b/src/plotly/Correlate.scala @@ -0,0 +1,47 @@ +package plotly + +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* + +import typings.plotlyJs.anon.PartialLayout +import typings.plotlyJs.anon.PartialLegendBgcolor +import typings.plotlyJs.mod.Data +import typings.plotlyJs.plotlyJsBooleans.`false` + +trait Correlate[A]: + def data(i: Int): DataArrayFrom[A] + def layout(i: Int): Layout[A] +object Correlate: + def apply[A](using s: Correlate[A]) = s + + given [A, B <: Data]( + using SharedAxis[A, B], + Trace["mpb", A, B], + Trace["bpm", A, B], + Trace["spm", A, B], + Trace["/km", A, B], + Layout[A], + ColorPalette[Common] + ): Correlate[A] with + private val secondaries = List( + Trace["bpm", A, B], + Trace["spm", A, B], + Trace["/km", A, B] + ) + def layout(i: Int): Layout[A] = a => + a.layout + .setShowlegend(true) + .setLegend(PartialLegendBgcolor().setX(1.1).setY(0.5).setItemclick(`false`).setItemdoubleclick(`false`)) + .setXaxis(SharedAxis[A, B].axis) + .setYaxis(Trace["mpb", A, B].y(0.color)) + .setYaxis2(secondaries(i - 1).y(i.color)) + + def data(i: Int): DataArrayFrom[A] = a => + val h = Trace["mpb", A, B].data(a) + val t = secondaries.zipWithIndex.map: + case (t, n) if n + 1 == i => t.data(a) + case (t, _) => t.dummy(a) + (h :: t).map(SharedAxis[A, B].share(a)).toJSArray + end given + +end Correlate diff --git a/src/plotly/DataArrayFrom.scala b/src/plotly/DataArrayFrom.scala index 52ab6c1..7e45b92 100644 --- a/src/plotly/DataArrayFrom.scala +++ b/src/plotly/DataArrayFrom.scala @@ -7,11 +7,9 @@ import typings.plotlyJs.anon.PartialScatterLine import typings.plotlyJs.mod.Data import typings.plotlyJs.mod.PlotType import typings.plotlyJs.plotlyJsBooleans.`false` -import typings.plotlyJs.plotlyJsStrings.legendonly import typings.plotlyJs.plotlyJsStrings.y -import typings.plotlyJs.plotlyJsStrings.yPlussignname -import core.DateFormat +import core.DateTimeGMT import core.metrics.* trait DataArrayFrom[A] extends (A => js.Array[Data]): @@ -19,40 +17,18 @@ trait DataArrayFrom[A] extends (A => js.Array[Data]): object DataArrayFrom: - given intervals(using Performance[Interval]): DataArrayFrom[Intervals] = v => - val distances = - val (_, r) = v.foldLeft(0.0 -> List.empty[Meter]): - case ((a, t), i) => (i.distance + a) -> (i.distance + a :: t) - r.map(_.round).reverse - - def scatterLine[A <: Double](name: String, fy: Interval => A) = - Data - .PartialPlotDataAutobinx() - .setName(name) - .setLine(PartialScatterLine().setWidth(1)) - .setY(v.map(fy).toJSArray) - .setX(distances.toJSArray) - .setHoverinfo(yPlussignname) - - js.Array( - scatterLine("mpb", _.mpb).setShowlegend(false), - scatterLine("bpm", _.averageHR.round).setVisible(true).setYaxis("y2"), - scatterLine("spm", _.averageRunCadence.round).setVisible(legendonly).setYaxis("y2") - // scatterLine("/km", _.pace).setVisible(legendonly).setYaxis("y2") - ) - - given history(using Performance[Interval], DateFormat[String]): DataArrayFrom[History] = m => + given history(using Performance[Interval], DateTimeGMT[String]): DataArrayFrom[History] = m => inline def box: Intervals => Data = v => Data .PartialBoxPlotData() .setType(PlotType.box) .setY(v.map(_.mpb).toJSArray) .setHoverinfo(y) - .setName(s"${v.head.startTimeGMT}+08:00".ymd("fr-CA")) + .setName(s"${v.head.startTimeGMT}".ymd("fr-CA")) .setBoxpoints(`false`) .setLine(PartialScatterLine().setWidth(1)) m.filter(_.nonEmpty).map(box).toJSArray - extension (d: Double) inline def round = scala.scalajs.js.Math.round(d) + end DataArrayFrom diff --git a/src/plotly/Layout.scala b/src/plotly/Layout.scala index 450662a..2882218 100644 --- a/src/plotly/Layout.scala +++ b/src/plotly/Layout.scala @@ -5,50 +5,21 @@ import scala.scalajs.js.JSConverters.* import typings.plotlyJs.anon.PartialLayout import typings.plotlyJs.anon.PartialLayoutAxis -import typings.plotlyJs.anon.PartialLegendBgcolor import typings.plotlyJs.anon.PartialMargin -import typings.plotlyJs.plotlyJsBooleans.`false` -import typings.plotlyJs.plotlyJsStrings.right -import typings.plotlyJs.plotlyJsStrings.y2 import core.metrics.* -import typings.plotlyJs.plotlyJsStrings.array trait Layout[A] extends (A => PartialLayout): extension (a: A) inline def layout = this(a) end Layout object Layout: - given common: Layout[Common] = _ => - PartialLayout() + given common(using ColorPalette[Common]): Layout[Common] = _ => + PartialLayout().setColorPalette .setHeight(200) .setMargin(PartialMargin().setPad(4).setL(50).setR(50).setT(50).setB(50)) - given intervals(using Layout[Common], Title[Intervals], ColorPalette[Common]): Layout[Intervals] = is => - inline def inside = PartialLegendBgcolor() - .setX(1.1) - .setY(0.5) - .setItemclick(`false`) - .setItemdoubleclick(`false`) + given [A: Title](using Layout[Common]): Layout[A] = a => Common.layout.setTitle(a.title) - inline def yAxis = PartialLayoutAxis() - .setColor(0.color) - .setTickformat(".2f") - .setOverlaying(y2) - .setTickmodeSync - - inline def yAxis2 = PartialLayoutAxis() - .setColor(1.color) - .setSide(right) - - Common.layout - .setTitle(is.title) - .setShowlegend(true) - .setColorPalette - .setLegend(inside) - .setXaxis(PartialLayoutAxis().setTickformat("~s").setTicksuffix("m").setTickmode(array)) - .setYaxis(yAxis) - .setYaxis2(yAxis2) - end intervals given history(using Layout[Common], Title[History], ColorPalette[Common]): Layout[History] = h => Common.layout @@ -56,10 +27,4 @@ object Layout: .setShowlegend(false) .setColorPalette .setYaxis(PartialLayoutAxis().setTickformat(".2f").setColor(0.color)) - - extension (a: PartialLayoutAxis) - private inline def setTickmodeSync: PartialLayoutAxis = - a.asInstanceOf[js.Dynamic].tickmode = "sync" - a - end Layout diff --git a/src/plotly/Listen.scala b/src/plotly/Listen.scala index 85e0e6c..f05c8b8 100644 --- a/src/plotly/Listen.scala +++ b/src/plotly/Listen.scala @@ -2,24 +2,19 @@ package plotly import scala.language.implicitConversions import scala.scalajs.js -import scala.scalajs.js.JSConverters.* import org.scalajs.dom.HTMLElement -import typings.plotlyJs.anon.PartialLayout -import typings.plotlyJs.anon.PartialLayoutAxis import typings.plotlyJs.anon.PartialPlotDataAutobinx import typings.plotlyJs.anon.PartialScatterLine import typings.plotlyJs.mod.LegendClickEvent import typings.plotlyJs.mod.PlotlyHTMLElement import typings.plotlyJs.mod.PlotMouseEvent -import typings.plotlyJs.plotlyJsStrings.legendonly import typings.plotlyJs.plotlyJsStrings.plotly_hover import typings.plotlyJs.plotlyJsStrings.plotly_legendclick import typings.plotlyJs.plotlyJsStrings.plotly_unhover -import typings.plotlyJs.plotlyJsStrings.right -import typings.plotlyJsDistMin.mod.relayout import typings.plotlyJsDistMin.mod.restyle +import typings.plotlyJsDistMin.mod.react import core.metrics.* @@ -31,18 +26,16 @@ object Listen: given tuple[A, H, T <: Tuple](using h: Listen[A, H], t: Listen[A, T]): Listen[A, H *: T] = (a, p) => h(a, p); t(a, p) - given legendclick(using ColorPalette[Common]): Listen[Intervals, plotly_legendclick] = (_, p) => + given legendclick(using ColorPalette[Common], Correlate[Intervals]): Listen[Intervals, plotly_legendclick] = (i, p) => p.on( plotly_legendclick, accept[LegendClickEvent]: e => e.curveNumber.toInt match case 0 => - case i => - val (l, r) = Range(1, e.data.length).partition(_ == i) - restyle(p, PartialPlotDataAutobinx().setVisible(true), l.map(_.doubleValue).toJSArray) - restyle(p, PartialPlotDataAutobinx().setVisible(legendonly), r.map(_.doubleValue).toJSArray) - val yAxis2 = PartialLayoutAxis().setColor(i.color).setSide(right) - relayout(p, PartialLayout().setYaxis2(yAxis2)) + case n => + given DataArrayFrom[Intervals] = Correlate[Intervals].data(n) + given Layout[Intervals] = Correlate[Intervals].layout(n) + react(p, i.darr, i.layout) end match ) end legendclick diff --git a/src/plotly/SharedAxis.scala b/src/plotly/SharedAxis.scala new file mode 100644 index 0000000..6bf797f --- /dev/null +++ b/src/plotly/SharedAxis.scala @@ -0,0 +1,26 @@ +package plotly + +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* + +import typings.plotlyJs.anon.PartialLayoutAxis +import typings.plotlyJs.anon.PartialPlotDataAutobinx +import typings.plotlyJs.mod.Data +import typings.plotlyJs.plotlyJsStrings.array + +import core.metrics.* + +trait SharedAxis[A, B <: Data]: + def axis: PartialLayoutAxis + def share(a: A): B => B + +object SharedAxis: + def apply[A, B <: Data](using s: SharedAxis[A, B]) = s + + given SharedAxis[Intervals, PartialPlotDataAutobinx] with + def axis: PartialLayoutAxis = PartialLayoutAxis().setTickformat("~s").setTicksuffix("m").setTickmode(array) + def share(a: Intervals): PartialPlotDataAutobinx => PartialPlotDataAutobinx = + val (_, r) = a.foldLeft(0.0 -> List.empty[Meter]): + case ((s, t), i) => (i.distance + s) -> (i.distance + s :: t) + r.map(_.round).reverse + _.setX(r.toJSArray) diff --git a/src/plotly/Title.scala b/src/plotly/Title.scala index dc4ac54..606b266 100644 --- a/src/plotly/Title.scala +++ b/src/plotly/Title.scala @@ -1,11 +1,12 @@ package plotly -import core.DateFormat +import core.DateTimeGMT import core.metrics.* trait Title[A] extends (A => String): extension (a: A) inline def title = this(a) object Title: - given date(using DateFormat[String]): Title[Intervals] = oi => s"${oi.head.startTimeGMT.ymd("zh-CN")} 速心比走势" - given Title[History] = _ => "速心比分布走势" + given date(using DateTimeGMT[String]): Title[Intervals] = oi => + s"${(oi.head.startTimeGMT).ymd("fr-CA")} 速心比走势" + given Title[History] = _ => "速心比分布走势" end Title diff --git a/src/plotly/Trace.scala b/src/plotly/Trace.scala new file mode 100644 index 0000000..d43be5d --- /dev/null +++ b/src/plotly/Trace.scala @@ -0,0 +1,82 @@ +package plotly + +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* + +import typings.plotlyJs.anon.PartialLayoutAxis +import typings.plotlyJs.anon.PartialPlotDataAutobinx as LineData +import typings.plotlyJs.anon.PartialScatterLine +import typings.plotlyJs.mod.Data +import typings.plotlyJs.mod.Datum +import typings.plotlyJs.plotlyJsStrings.legendonly +import typings.plotlyJs.plotlyJsStrings.reversed +import typings.plotlyJs.plotlyJsStrings.right +import typings.plotlyJs.plotlyJsStrings.y2 +import typings.plotlyJs.plotlyJsStrings.yPlussignname + +import core.metrics.* + +trait Trace[T <: String: ValueOf, A, B <: Data]: + val name = valueOf[T] + def data(a: A): B + def dummy(a: A): B + def y(color: String): PartialLayoutAxis +object Trace: + def apply[T <: String: ValueOf, A, B <: Data](using s: Trace[T, A, B]) = s + + given primary( + using Performance[Interval] + ): Trace["mpb", Intervals, LineData] with + def y(color: String) = PartialLayoutAxis() + .setColor(color) + .setTickformat(".2f") + .setOverlaying(y2) + .setTickmodeSync + + def data(a: Intervals) = Data + .PartialPlotDataAutobinx() + .setName(name) + .setLine(PartialScatterLine().setWidth(1)) + .setHoverinfo(yPlussignname) + .setYaxis("y") + .setY(a.map(_.mpb).toJSArray) + + def dummy(a: Intervals) = throw UnsupportedOperationException() + end primary + + given bpm: Trace["bpm", Intervals, LineData] = new Secondary(_.map(_.averageHR.round)) + given spm: Trace["spm", Intervals, LineData] = new Secondary(_.map(_.averageRunCadence.round)) + given pace: Trace["/km", Intervals, LineData] with + private val s = new Secondary["/km", Intervals](_.map(_.`/km`).reverse) + def y(color: String) = s.y(color).setTickformat("%M:%S").setAutorange(reversed) + def data(a: Intervals) = s.data(a) + def dummy(a: Intervals) = s.dummy(a) + end pace + + private class Secondary[T <: String: ValueOf, A](f: A => Seq[Datum]) extends Trace[T, A, LineData]: + def y(color: String) = PartialLayoutAxis() + .setColor(color) + .setSide(right) + + def data(a: A) = dummy(a).setVisible(true).setY(f(a).toJSArray) + + def dummy(a: A) = Data + .PartialPlotDataAutobinx() + .setVisible(legendonly) + .setName(name) + .setLine(PartialScatterLine().setWidth(1)) + .setHoverinfo(yPlussignname) + .setYaxis("y2") + end Secondary + + extension (d: Double) private inline def round = scala.scalajs.js.Math.round(d) + extension (i: Interval) + private inline def `/km` = + val s = math.round(i.pace * 1000).intValue + new js.Date(0, 0, 0, s / (60 * 60), s / 60, s % 60) + + extension (a: PartialLayoutAxis) + private inline def setTickmodeSync: PartialLayoutAxis = + a.asInstanceOf[js.Dynamic].tickmode = "sync" + a +end Trace diff --git a/src/plotly/package.scala b/src/plotly/package.scala index f8cc26c..455b18f 100644 --- a/src/plotly/package.scala +++ b/src/plotly/package.scala @@ -27,16 +27,17 @@ private[plotly] given Conversion[PlotlyHTMLElement, HTMLElement] = _.asInstanceO given history( using Render[History], - DataArrayFrom[Intervals], - Layout[Intervals], + Correlate[Intervals], Config[Trend[ModeBarButton]], Listen[Intervals, plotly_legendclick], Listen[History, plotly_hover] ): Inject[History] = case (r, h) => - inline def back = ModeBarButton((_, _) => history(r -> h), Icons.home, "back", "回退") - given Config[Intervals] = i => (i -> back).config - given Render[Intervals] = Render[Intervals] + inline def back = ModeBarButton((_, _) => history(r -> h), Icons.home, "back", "回退") + given Config[Intervals] = i => (i -> back).config + given DataArrayFrom[Intervals] = Correlate[Intervals].data(1) + given Layout[Intervals] = Correlate[Intervals].layout(1) + given Render[Intervals] = Render[Intervals] h.render(r) .`then`: p =>