diff --git a/README.md b/README.md index 4602de5..597584e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ ![Activities Chart](./mpb-activities.png) ![Activity Chart](./mpb-activity.png) +![Profile Chart](./mpb-profile.png) 这是一款面向 **入门跑者** 和 **[MAF训练法][maf]跑者** 的,聚焦于跑步训练中 **[速心比](#速心比meters-per-beat)** 指标数据可视化的浏览器扩展程序(俗称:插件)。 diff --git a/mpb-profile.png b/mpb-profile.png new file mode 100644 index 0000000..e190f93 Binary files /dev/null and b/mpb-profile.png differ diff --git a/mpb-workout.png b/mpb-workout.png index 42cc539..589f75d 100644 Binary files a/mpb-workout.png and b/mpb-workout.png differ diff --git a/src/Main.scala b/src/Main.scala index a1c8f39..f9a2e01 100644 --- a/src/Main.scala +++ b/src/Main.scala @@ -3,7 +3,7 @@ import org.scalajs.dom.* object Main: def main(args: Array[String]): Unit = - val route = OnPageActivities() orElse OnPageActivity() orElse OnPageWorkout() + val route = OnPageActivities() orElse OnPageActivity() orElse OnPageWorkout() orElse OnPageProfile() MutationObserver: (rs, _) => route.applyOrElse((new URL(window.location.href), rs.toSeq), _ => ()) .observe( diff --git a/src/OnPageActivities.scala b/src/OnPageActivities.scala index d0c904a..befb308 100644 --- a/src/OnPageActivities.scala +++ b/src/OnPageActivities.scala @@ -12,11 +12,13 @@ import plot.* object OnPageActivities: - def apply()(using a: Anchor[HTMLElement], s: Scatter[Activities]): Route = + def apply()(using a: Anchor[HTMLElement], s: Scatter[SearchResult]): Route = case (Activities(tp), HasListItem()) => val root = a.init(document.querySelector("div.advanced-filtering").before(_), "mpb") - if tp != "running" then root.remove() - else for gas <- Service.activities(SearchFilter(tp.get)) do Plot(root, Array(gas), "速心比变化趋势")(using _.scatter) + if tp == "running" || tp == undefined then + for gas <- Service.activities(SearchFilter("running")) if gas.nonEmpty do + Plot(root, Array(gas), "速心比变化趋势")(using _.scatter) + else root.remove() end apply private val Activities = Extract[URL, UndefOr[String]]: diff --git a/src/OnPageActivity.scala b/src/OnPageActivity.scala index c08be68..2cfb2a5 100644 --- a/src/OnPageActivity.scala +++ b/src/OnPageActivity.scala @@ -21,7 +21,7 @@ object OnPageActivity: t <- a.activityTypeDTO k <- t.typeKey if k == "running" do - for laps <- Service.laps(id.toDouble) do + for laps <- Service.laps(id.toDouble) if laps.nonEmpty do Plot(aa.init(e.before(_), "scatter"), Arr(laps), "速心比变化趋势")(using _.scatter) end apply diff --git a/src/OnPageProfile.scala b/src/OnPageProfile.scala new file mode 100644 index 0000000..9ceec21 --- /dev/null +++ b/src/OnPageProfile.scala @@ -0,0 +1,44 @@ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.scalajs.js + +import org.scalajs.dom.* + +import common.Anchor +import common.Extract +import common.Functional.* +import common.GetElement.query +import common.GetMutationRecord.addedNodes +import common.GetURL.path +import common.Regex.* +import garmin.Pagination +import garmin.SearchResult +import garmin.Service +import plot.Plot +import plot.Scatter + +object OnPageProfile: + def apply()(using a: Anchor[HTMLElement], s: Scatter[SearchResult]): Route = + case (Profile(Seq(_, id: String)), PageContent(e)) => + val p: Pagination = new: + start = 1 + limit = 30 + for + ap <- Service.activitiesByProfile(id, p) + al <- ap.activityList + do + val sr = al.filter(_.activityType.get.typeKey == "running") + if sr.nonEmpty then + val layout = Plot.Layout("近期跑步速心比变化趋势") + val config = Plot.Config().setDisplayModeBar(false) + Plot(a.init(e.before(_), "scatter"), js.Array(sr), layout, config)(using _.scatter) + end apply + + private val Profile = Extract[URL, Seq[js.UndefOr[String]]]: + path |> "/modern/profile/(.+)".capture + + private val PageContent = Extract[Seq[MutationRecord], Element]: + inline def E = Extract[Element, Element]: + query("div[class^=\"PageContent\"]").?? + + _.flatMap(addedNodes[Element]).collectFirst({ case E(e) => e }) +end OnPageProfile diff --git a/src/OnPageWorkout.scala b/src/OnPageWorkout.scala index 52535cf..2622ae2 100644 --- a/src/OnPageWorkout.scala +++ b/src/OnPageWorkout.scala @@ -14,12 +14,14 @@ import garmin.* import plot.* object OnPageWorkout: - def apply()(using a: Anchor[HTMLElement], s: Scatter[ActivityLaps], b: Box[ActivityLaps]): Route = + def apply()(using a: Anchor[HTMLElement], s: Scatter[ActivityByWorkout], b: Box[ActivityByWorkout]): Route = case (Workout(Seq(_, id: String), "running"), PageHeader(e)) => - for list <- Service.activityLapsList(id.toDouble, 7) do - val r = list.reverse - Plot(a.init(e.appendChild, "box"), r, "近七日速心比分布趋势")(using _.box) - Plot(a.init(e.appendChild, "scatter"), r, "近七日速心比趋势对比")(using _.scatter) + for list <- Service.activityLapsList(id.toDouble, 30) if list.nonEmpty do + val limit = Math.min(list.length, 14) + val latest = Math.min(limit, 5) + val layout = Plot.Layout(s"最近${limit}次速心比分布趋势").setShowlegend(false) + Plot(a.init(e.appendChild, "box"), list.take(limit).reverse, layout)(using _.box) + Plot(a.init(e.appendChild, "scatter"), list.take(latest).reverse, s"近${latest}次速心比趋势对比")(using _.scatter) private val Workout = Extract[URL, (Seq[UndefOr[String]], UndefOr[String])]: (path |> "/modern/workout/(\\d+)".capture) ~> param("workoutType") diff --git a/src/common/GetMutationRecord.scala b/src/common/GetMutationRecord.scala index 679f538..27ca6d6 100644 --- a/src/common/GetMutationRecord.scala +++ b/src/common/GetMutationRecord.scala @@ -4,5 +4,5 @@ import org.scalajs.dom.* object GetMutationRecord: import scala.reflect.Typeable - inline def addedNodes[A >: Element: Typeable]: MutationRecord => Seq[A] = - _.addedNodes.toSeq.collect({ case a: A => a }) + inline def addedNodes[A >: Element: Typeable]: MutationRecord => Seq[A] = r => + for case a: A <- r.addedNodes.toSeq yield a diff --git a/src/garmin/ActivitiesByProfile.scala b/src/garmin/ActivitiesByProfile.scala new file mode 100644 index 0000000..fa3027b --- /dev/null +++ b/src/garmin/ActivitiesByProfile.scala @@ -0,0 +1,6 @@ +package garmin + +import scala.scalajs.js + +trait ActivitiesByProfile extends js.Object: + var activityList: js.UndefOr[SearchResult] = js.undefined diff --git a/src/garmin/Activity.scala b/src/garmin/Activity.scala index b10601e..fd2d8e1 100644 --- a/src/garmin/Activity.scala +++ b/src/garmin/Activity.scala @@ -5,5 +5,6 @@ import scala.scalajs.js trait Activity extends Performance: var activityId: js.UndefOr[Double] = js.undefined var activityName: js.UndefOr[String] = js.undefined - var summaryDTO: js.UndefOr[Performance] = js.undefined + var summaryDTO: js.UndefOr[Performance] = js.undefined var activityTypeDTO: js.UndefOr[ActivityType] = js.undefined + var activityType: js.UndefOr[ActivityType] = js.undefined diff --git a/src/garmin/Pagination.scala b/src/garmin/Pagination.scala new file mode 100644 index 0000000..7d06047 --- /dev/null +++ b/src/garmin/Pagination.scala @@ -0,0 +1,8 @@ +package garmin + +import scala.scalajs.js + +trait Pagination extends js.Object: + var excludeChildren: js.UndefOr[Boolean] = js.undefined + var start: js.UndefOr[Int | String] = js.undefined + var limit: js.UndefOr[Int | String] = js.undefined diff --git a/src/garmin/SearchFilter.scala b/src/garmin/SearchFilter.scala index 9965a22..371304e 100644 --- a/src/garmin/SearchFilter.scala +++ b/src/garmin/SearchFilter.scala @@ -3,11 +3,8 @@ package garmin import scala.scalajs.js import scala.scalajs.js.annotation.JSName -trait SearchFilter extends js.Object: +trait SearchFilter extends Pagination: var activityType: js.UndefOr[String] = js.undefined - var start: js.UndefOr[Int | String] = js.undefined - var limit: js.UndefOr[Int | String] = js.undefined - var excludeChildren: js.UndefOr[Boolean] = js.undefined var startDate: js.UndefOr[String] = js.undefined @JSName("_") var now: js.UndefOr[Double] = js.undefined diff --git a/src/garmin/Service.scala b/src/garmin/Service.scala index 42e589c..3915be0 100644 --- a/src/garmin/Service.scala +++ b/src/garmin/Service.scala @@ -11,12 +11,11 @@ import common.* import common.Functional.* object Service: - def activityLapsList(workoutId: Double, latestDays: Double)(using + def activityLapsList(workoutId: Double, limit: Int)(using DateFormat[Double] - ): Future[js.Array[ActivityLaps]] = - val cur = js.Date.now() + ): Future[js.Array[ActivityByWorkout]] = val filter = SearchFilter("running") - filter.startDate = cur.dayBefore(7).ymd("fr-CA") + filter.limit = limit activities(filter) .flatMap: sa => @@ -25,6 +24,10 @@ object Service: .map(_.toJSArray) end activityLapsList + def activitiesByProfile(id: String, p: Pagination) = + val url = s"${base}/activitylist-service/activities/$id${params(p)}" + get[ActivitiesByProfile](url, s"https://connect.garmin.cn/modern/profile/$id") + def activity(id: Double) = val url = s"${base}/activity-service/activity/$id?_=${js.Date.now()}" get[Activity](url, s"https://connect.garmin.cn/modern/activity/$id") @@ -32,7 +35,7 @@ object Service: def activities(filter: SearchFilter) = val url = s"${base}/activitylist-service/activities/search/activities${params(filter)}" // TODO improve referer - get[Activities](url, "https://connect.garmin.cn/modern/activities") + get[SearchResult](url, "https://connect.garmin.cn/modern/activities") def laps(activityId: Double) = inline def equal[A](a: A): js.UndefOr[A] => Boolean = a == _ diff --git a/src/garmin/package.scala b/src/garmin/package.scala index d270731..bb76e60 100644 --- a/src/garmin/package.scala +++ b/src/garmin/package.scala @@ -4,7 +4,7 @@ import scala.scalajs.js inline val base = "https://connect.garmin.cn" -type Activities = js.Array[ActivityItem] -type Laps = js.Array[Lap] -type ActivityItem = Activity & Workout & Performance -type ActivityLaps = (Activity & Workout, Laps) +type SearchResult = js.Array[ActivityBySearch] +type Laps = js.Array[Lap] +type ActivityBySearch = Activity & Workout & Performance +type ActivityByWorkout = (Activity & Workout, Laps) diff --git a/src/plot/Box.scala b/src/plot/Box.scala index 0ca37dc..d913c2d 100644 --- a/src/plot/Box.scala +++ b/src/plot/Box.scala @@ -18,10 +18,10 @@ object Box: .PartialPlotDataAutobinx() .setY(laps.map(_.mpb)) .setHoverinfo(cs.y) - .setWidth(0.1) + .setWidth(0.3) .setType(cs.box) - given activityLaps(using Box[Laps], DateFormat[String]): Box[ActivityLaps] = ga => + given activityLaps(using Box[Laps], DateFormat[String]): Box[ActivityByWorkout] = ga => ga match case (a, laps) => laps.box diff --git a/src/plot/Plot.scala b/src/plot/Plot.scala index 351bb1d..6fa12ac 100644 --- a/src/plot/Plot.scala +++ b/src/plot/Plot.scala @@ -1,6 +1,6 @@ package plot -import scala.scalajs.js.* +import scala.scalajs.js import org.scalajs.dom.* @@ -9,8 +9,15 @@ import typings.plotlyJs.mod.* import typings.plotlyJsDistMin.mod object Plot: - def apply[A](root: HTMLElement, data: Array[A], title: String)(using Conversion[A, Data]) = - mod.newPlot(root, data.map(_.convert), Layout(title), Config()) + def apply[A](root: HTMLElement, data: js.Array[A], title: String)(using + Conversion[A, Data] + ): js.Promise[PlotlyHTMLElement] = + apply(root, data, Layout(title), Config()) + + def apply[A](root: HTMLElement, data: js.Array[A], layout: PartialLayout, config: PartialConfig = Config())(using + Conversion[A, Data] + ): js.Promise[PlotlyHTMLElement] = + mod.newPlot(root, data.map(_.convert), layout, config) object Layout: def apply(title: String) = PartialLayout() diff --git a/src/plot/Scatter.scala b/src/plot/Scatter.scala index 94022a0..1e3b22d 100644 --- a/src/plot/Scatter.scala +++ b/src/plot/Scatter.scala @@ -26,7 +26,7 @@ object Scatter: .setHovertext(laps.map(_.text)) .setHovertemplate("%{y:.3f}
%{hovertext}") - given activityLaps(using Scatter[Laps], DateFormat[String]): Scatter[ActivityLaps] = ga => + given activityLaps(using Scatter[Laps], DateFormat[String]): Scatter[ActivityByWorkout] = ga => ga match case (a, laps) => laps.scatter @@ -36,7 +36,7 @@ object Scatter: given activities(using MetersPerBeat[Performance] - ): Scatter[js.Array[ActivityItem]] = gas => + ): Scatter[SearchResult] = gas => Data .PartialPlotDataAutobinx() .setY(gas.map(_.mpb))