Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Running insights (WIP)

examples/running.livemd

Running insights (WIP)

Mix.install([
  {:ext_fit, path: "./"},
  {:kino_maplibre, "~> 0.1.11"},
  {:req, "~> 0.4.0"},
  {:kino_vega_lite, "~> 0.1.10"},
  {:geocalc, "~> 0.8"}
])

FIT Decode

alias ExtFit.{Record, Field}

records =
  "./test/support/files/2023-06-04-garmin-955-running.fit"
  |> File.read!()
  |> ExtFit.Decode.decode!()

record_msgs = Record.records_by_message(records, :record)

IO.puts("Decoded #{length(records)} records from FIT file")

Map based on GPS

points =
  record_msgs
  |> Enum.map(fn record ->
    with [%{value: plat}] when not is_nil(plat) <- Record.fields_by_name(record, :position_lat),
         [%{value: plong}] when not is_nil(plat) <- Record.fields_by_name(record, :position_long) do
      {plong, plat}
    else
      _ -> nil
    end
  end)
  |> Enum.filter(&amp;(&amp;1 != nil))

pstart = Enum.at(points, 0)
pend = Enum.at(points, -1)

MapLibre.new(
  center: Geocalc.geographic_center(points),
  zoom: 10,
  # key takens from official LiveBook examples, use your own!
  style: "https://api.maptiler.com/maps/basic/style.json?key=Q4UbchekCfyvXvZcWRoU"
)
|> MapLibre.add_source("route",
  type: :geojson,
  data: [
    type: "Feature",
    geometry: [
      type: "LineString",
      coordinates: points
    ]
  ]
)
|> Kino.MapLibre.add_marker(pstart, color: "#65a30d")
|> Kino.MapLibre.add_marker(pend, color: "#dc2626")
|> MapLibre.add_layer(
  id: "route",
  type: :line,
  source: "route",
  layout: [
    line_join: "round",
    line_cap: "round"
  ],
  paint: [
    line_color: "#f87171",
    line_width: 4
  ]
)
|> Kino.MapLibre.new()

Prepare records for DataView

alias ExtFit.{Record, Types, Field}

friendly_name = fn name ->
  "#{name}"
  |> String.split("_", trim: true)
  |> Enum.join(" ")
  |> String.capitalize()
  |> String.replace(~r/\bgps\b/i, "GPS")
  |> String.replace(~r/\bhr\b/i, "HR")
  |> String.replace(~r/\bpwr\b/i, "PWR")
  |> String.replace(~r/\bhrv\b/i, "HRV")
  |> String.replace(~r/\bid\b/i, "ID")
  |> String.replace(~r/\bcum\b/i, "cumulative")
end

drop_headers_without_values = fn headers, records ->
  headers
  |> Enum.filter(fn {name, _} ->
    !!Enum.find(records, &amp;(Map.get(&amp;1, name) != nil))
  end)
  |> Enum.into(%{})
end

# Generate map with msg names as keys and as values, number of records and all matching
# data for given msg names. In FIT files, the same msg may appear multiple times
# with different set of fields which makes it more complicated to work with as maps
summary =
  records
  |> Enum.reduce(%{}, fn
    %Record.FitData{def_mesg: %{mesg_type: %{} = mesg_type}, fields: fields}, state ->
      msg_name = to_string(mesg_type.name)

      state =
        state
        |> Map.put_new_lazy(msg_name, fn ->
          %{
            name: friendly_name.(mesg_type.name),
            id: msg_name,
            headers: %{},
            records_count: 0,
            records: []
          }
        end)

      %{headers: headers} =
        Enum.reduce(
          fields,
          %{
            headers: get_in(state, [to_string(mesg_type.name), Access.key(:headers)])
          },
          fn
            %Types.FieldData{field: nil}, acc ->
              acc

            %Types.FieldData{field: %{name: name, units: units}}, %{headers: headers} = acc ->
              %{
                acc
                | headers:
                    Map.put_new_lazy(headers, name, fn ->
                      friendly_name.(name) <>
                        ((units &amp;&amp; " (#{units})") || "")
                    end)
              }
          end
        )

      state =
        put_in(state, [to_string(mesg_type.name), :headers], headers)

      record =
        Enum.reduce(fields, %{}, fn
          %Types.FieldData{
            value: value,
            value_label: value_label,
            raw_value: raw_value,
            field: %{name: name, units: units}
          },
          record ->
            value =
              value_label ||
                cond do
                  (units == nil || units in ~w(m)) &amp;&amp; is_float(value) ->
                    Float.round(value, 4)

                  true ->
                    value || raw_value
                end

            Map.put(record, name, value)

          _, record ->
            record
        end)

      update_in(state, [to_string(mesg_type.name), :records], &amp;[record | &amp;1])

    _, state ->
      state
  end)
  |> Enum.sort_by(&amp;elem(&amp;1, 1).name)
  |> Enum.map(fn {key, %{records: records, headers: headers} = message} ->
    headers = drop_headers_without_values.(headers, records)

    {key,
     %{
       message
       | records:
           Enum.reverse(records)
           |> Enum.map(fn record ->
             headers
             |> Enum.reduce(%{}, fn {name, friendly_name}, new_record ->
               # ensure every record has all the same keys for all headers
               # this makes it work nicely with Kino.DataTable
               Map.put(new_record, name, %{
                 value: Map.get(record, name) || nil,
                 name: friendly_name
               })
             end)
           end),
         records_count: length(records),
         headers: headers
     }}
  end)
  |> Enum.into(%{})

Inspect

summary
|> Enum.map(fn {_, msg} ->
  rows =
    msg.records
    |> Enum.map(fn record ->
      Enum.map(record, fn {_, %{name: name, value: value}} ->
        {name, value || "-"}
      end)
    end)

  {"#{msg.name} (#{msg.records_count})", Kino.DataTable.new(rows, sorting_enabled: false)}
end)
|> Kino.Layout.tabs()

Graphs!

alias VegaLite, as: Vl

records_dataframe_ =
  summary["record"].records
  |> Enum.map(fn record ->
    Enum.map(record, fn {id, %{value: value}} ->
      {id, value}
    end)
  end)

Vl.new(width: 700, height: 300, title: "HR")
|> Vl.data_from_values(records_dataframe_, only: ["distance", "heart_rate"])
|> Vl.mark(:point, tooltip: %{content: "data"})
|> Vl.encode_field(:x, "distance", type: :quantitative)
|> Vl.encode_field(:y, "heart_rate", type: :quantitative, title: "HR")
|> Vl.encode_field(:color, "heart_rate", type: :quantitative)