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(&(&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, &(Map.get(&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 && " (#{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)) && 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], &[record | &1])
_, state ->
state
end)
|> Enum.sort_by(&elem(&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)