souplesse/frontend/src/Main.elm

680 lines
21 KiB
Elm
Raw Normal View History

2024-10-27 17:53:59 +00:00
module Main exposing (view)
import Browser
import Browser.Navigation as Nav
import Html exposing (Html, button, div, span, text, img)
2024-10-27 17:53:59 +00:00
import Html.Attributes as H exposing (src, style, width, height)
import Html.Events exposing (onClick, on)
2024-10-27 17:53:59 +00:00
import Html.Events.Extra.Pointer as Pointer
import Maybe exposing (Maybe)
2024-11-14 22:19:26 +00:00
import Lib exposing(..)
2024-11-17 16:16:46 +00:00
import List.Extra exposing(find)
import Json.Decode as D
2024-10-27 17:53:59 +00:00
import Http
2024-11-12 18:52:44 +00:00
import Point exposing(Point, Pos ,decoder)
import Svg exposing (Svg, svg, rect, g, polyline, line)
2024-10-27 17:53:59 +00:00
import Svg.Attributes as S exposing
( viewBox
2024-11-13 20:38:47 +00:00
, preserveAspectRatio
, transform
2024-10-27 17:53:59 +00:00
, x, y
2024-11-14 22:19:26 +00:00
, x1, y1 , x2, y2
2024-10-27 17:53:59 +00:00
, fill
, stroke, strokeWidth, strokeOpacity)
2024-11-21 22:15:32 +00:00
import Time
import Url.Parser exposing (Parser, (<?>), int, map, s, string)
import Url.Parser.Query as Query
import Url exposing (Url)
type Route = Timeline (Maybe Int) (Maybe Int)
routeParser : Parser (Route -> a) a
routeParser =
map Timeline (s "timeline" <?> Query.int "start" <?> Query.int "duration")
2024-10-27 17:53:59 +00:00
-- MAIN
main =
Browser.application
{ init = init
, update = update
, subscriptions = subscriptions
, onUrlRequest = \ _ -> NewUrlRequest
, onUrlChange = \ _ -> UrlChanged
, view = view }
2024-10-27 17:53:59 +00:00
-- MATHS
-- Coordinates in a Mercator projection
type alias Coord = { x: Float, y: Float }
-- zoom level
2024-11-14 15:07:33 +00:00
type alias ZoomLevel = Int
2024-11-14 16:03:32 +00:00
type FineZoomLevel = FineZoomLevel Int
zoomStep = 8
toZoom : FineZoomLevel -> ZoomLevel
toZoom (FineZoomLevel f) = f // zoomStep
incZoom : FineZoomLevel -> Int -> FineZoomLevel
incZoom (FineZoomLevel z) delta =
FineZoomLevel (clamp 0 (20 * zoomStep) (z + delta))
2024-10-27 17:53:59 +00:00
type alias TileNumber = { x: Int, y: Int }
-- project lat/long to co-ordinates based on pseudocode at
2024-10-27 17:53:59 +00:00
-- https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Zoom_levels
sec x = 1 / (cos x)
2024-11-21 16:05:47 +00:00
toCoord : Pos -> Coord
toCoord pos =
2024-10-27 17:53:59 +00:00
let
2024-11-21 16:05:47 +00:00
lat_rad = pos.lat * pi / 180
x = (pos.lon + 180) / 360
2024-10-27 17:53:59 +00:00
y = (1 - (logBase e ((tan lat_rad) + (sec lat_rad))) / pi) / 2
in
Coord x y
pixelsToCoord z (x,y) =
let x_float = toFloat x / toFloat ( 2 ^ (z + 8))
y_float = toFloat y / toFloat ( 2 ^ (z + 8))
in Coord x_float y_float
reflect : Coord -> Coord
reflect c = Coord -c.x -c.y
-- translate : a -> a -> a
translate base offset =
{ base | x = (base.x + offset.x), y = (base.y + offset.y) }
2024-11-14 15:07:33 +00:00
translatePixels : Coord -> ZoomLevel -> (Int, Int) -> Coord
2024-10-27 17:53:59 +00:00
translatePixels old z (x, y) = translate old (pixelsToCoord z (x, y))
2024-11-14 15:07:33 +00:00
tileCovering : Coord -> ZoomLevel -> TileNumber
2024-10-27 17:53:59 +00:00
tileCovering c z =
TileNumber (truncate (toFloat (2 ^ z) * c.x)) (truncate (toFloat (2 ^ z) * c.y))
2024-11-14 15:07:33 +00:00
pixelFromCoord : Coord -> ZoomLevel -> (Int, Int)
2024-10-27 17:53:59 +00:00
pixelFromCoord c z =
let {x,y} = tileCovering c (z + 8)
in (x,y)
2024-11-14 15:07:33 +00:00
boundingTiles : Coord -> ZoomLevel -> Int -> Int -> (TileNumber, TileNumber)
2024-10-27 17:53:59 +00:00
boundingTiles centre z width height =
-- find the tiles needed to cover the area (`width` x `height`)
-- about the point at `centre`
let delta = pixelsToCoord z ((width // 2), (height // 2))
minCoord = translate centre (reflect delta)
maxCoord = translate centre delta
in ((tileCovering minCoord z),
(translate (tileCovering maxCoord z) (TileNumber 1 1)))
-- MODEL
type DragTarget = Map | Graph | StartMark | EndMark | NoTarget
2024-10-27 17:53:59 +00:00
type Drag
= None
| Dragging DragTarget (Int, Int) (Int, Int)
2024-10-27 17:53:59 +00:00
dragTo : Drag -> (Int, Int) -> Drag
dragTo d dest =
case d of
None -> None
Dragging target from _ -> Dragging target from dest
2024-10-27 17:53:59 +00:00
dragDelta target d =
2024-10-27 17:53:59 +00:00
case d of
Dragging target_ (fx,fy) (tx,ty) ->
if target == target_
then (fx-tx, fy-ty)
else (0, 0)
_ -> (0, 0)
2024-11-22 17:43:33 +00:00
subtractTuple (fx,fy) (tx,ty) = (fx-tx, fy-ty)
2024-10-27 17:53:59 +00:00
2024-11-10 18:53:56 +00:00
type TrackState = Empty | Loading | Failure String | Present (List Point)
2024-10-27 17:53:59 +00:00
type alias Model =
{ centre: Coord
2024-11-14 16:03:32 +00:00
, zoom: FineZoomLevel
2024-10-27 17:53:59 +00:00
, drag: Drag
2024-11-21 22:15:32 +00:00
, startTime : Float
, duration : Float
, markedTime : (Float, Float)
2024-10-27 17:53:59 +00:00
, track: TrackState }
init : () -> Url -> Nav.Key -> (Model, Cmd Msg)
init _ url navKey =
let (start, duration) =
case Url.Parser.parse routeParser url of
2024-11-21 22:15:32 +00:00
Just (Timeline (Just s) (Just d)) -> (toFloat s, toFloat d)
_ -> (10,10)
in
2024-11-21 16:05:47 +00:00
((Model
(toCoord (Pos 0 0 Nothing))
(FineZoomLevel (1*8)) None 0 0 (0,0) Loading),
(fetchTrack start duration))
2024-10-27 17:53:59 +00:00
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model = Sub.none
fetchTrack start duration = Http.get
{ url = ("http://localhost:3000/points?start=" ++
2024-11-21 22:15:32 +00:00
String.fromInt (floor start) ++
"&duration=" ++
2024-11-21 22:15:32 +00:00
String.fromInt (ceiling duration))
2024-11-12 18:52:44 +00:00
, expect = Http.expectJson Loaded (D.list Point.decoder)
2024-10-27 17:53:59 +00:00
}
2024-11-10 18:53:56 +00:00
2024-10-27 17:53:59 +00:00
-- UPDATE
type Msg
= MapScale Int
| DragStart DragTarget (Int, Int)
| Drag (Int, Int)
| DragFinish (Int, Int)
| TimeScale (Float)
2024-11-10 18:53:56 +00:00
| Loaded (Result Http.Error (List Point))
| NewUrlRequest
| UrlChanged
| Dribble String
2024-10-27 17:53:59 +00:00
2024-11-21 15:54:18 +00:00
2024-10-27 17:53:59 +00:00
update : Msg -> Model -> (Model, Cmd Msg)
2024-11-21 11:34:41 +00:00
update msg model = (updateModel msg model, Cmd.none)
2024-10-27 17:53:59 +00:00
2024-11-22 17:43:33 +00:00
secondsFromPixels model seconds =
(toFloat seconds) * model.duration / portalWidth
handleDragFinish model target (x, y) =
case target of
Map ->
{ model |
centre = translatePixels model.centre (toZoom model.zoom) (x, y)
}
Graph ->
{ model |
startTime =
model.startTime + secondsFromPixels model x
}
StartMark ->
{ model |
markedTime =
let deltat = secondsFromPixels model x
(s, d) = model.markedTime
in (s - deltat, d + deltat)
}
EndMark ->
{ model |
markedTime =
let deltat = secondsFromPixels model x
(s, d) = model.markedTime
in (s, d - deltat)
}
NoTarget -> model
2024-11-21 11:34:41 +00:00
updateModel msg model =
2024-10-27 17:53:59 +00:00
case msg of
MapScale y ->
{ model | zoom = incZoom model.zoom y }
2024-10-27 17:53:59 +00:00
DragStart target (x,y) ->
{ model | drag = Dragging target (x,y) (x,y) }
2024-10-27 17:53:59 +00:00
Drag (x,y) ->
2024-10-27 17:53:59 +00:00
{ model | drag = dragTo model.drag (x,y) }
DragFinish (x,y) ->
case model.drag of
2024-11-22 17:43:33 +00:00
Dragging target start end ->
handleDragFinish { model | drag = None } target (subtractTuple start end)
_ -> model
TimeScale factor ->
2024-11-22 17:46:55 +00:00
{ model |
startTime = model.startTime + factor / 2
, duration = model.duration - factor
}
2024-10-27 17:53:59 +00:00
Loaded result ->
case result of
2024-11-21 11:46:45 +00:00
Ok trk ->
let start = Maybe.withDefault 0 (Point.startTime trk)
duration = Point.duration trk
in
{ model
| track = Present trk
2024-11-21 16:05:47 +00:00
, centre = toCoord (Point.centre trk)
, zoom = FineZoomLevel (13 * 8)
2024-11-21 22:15:32 +00:00
, startTime = start
, duration = duration
, markedTime = (start + 300, duration - 900)
2024-11-21 11:46:45 +00:00
}
2024-11-10 18:53:56 +00:00
Err (Http.BadBody e) -> { model | track = Debug.log e (Failure "e") }
Err e -> { model | track = Debug.log "unknown error" (Failure "e") }
NewUrlRequest -> model
UrlChanged -> model
Dribble message ->
let _ = Debug.log "dribble" message
in model
2024-10-27 17:53:59 +00:00
2024-11-17 16:16:46 +00:00
2024-10-27 17:53:59 +00:00
-- VIEW
2024-11-17 16:16:46 +00:00
formatTime epoch =
let utc = Time.utc
time = Time.millisToPosix <| floor(epoch * 1000)
zeroed i = String.padLeft 2 '0' (String.fromInt i)
in String.fromInt (Time.toHour utc time)
++ ":" ++
zeroed (Time.toMinute utc time)
++ ":" ++
zeroed (Time.toSecond utc time)
timeTick duration =
let width = duration / 6
candidates =
[ 1
, 3
, 5
, 10
, 15
, 30
, 60
, 60 * 3
, 60 * 5
, 60 * 10
, 60 * 15
, 60 * 30
, 60 * 60
]
in case List.Extra.find (\ candidate -> width <= candidate) candidates of
Just n -> n
Nothing -> width
2024-11-14 15:07:33 +00:00
tileUrl : TileNumber -> ZoomLevel -> String
2024-10-27 17:53:59 +00:00
tileUrl {x,y} z =
String.concat ["https://a.tile.openstreetmap.org",
"/", String.fromInt z,
"/", String.fromInt x,
"/", String.fromInt y,
".png" ]
tileImg zoom tilenumber = img [ width 256,
height 256,
src (tileUrl tilenumber zoom) ] []
2024-11-13 22:05:08 +00:00
type alias Colour = String
2024-11-13 22:05:08 +00:00
measureView : String -> Colour -> (Point -> Maybe Float) -> List Point -> Svg Msg
measureView title colour fn points =
let graphHeight = 180
startTime = Maybe.withDefault 0 (Point.startTime points)
coords p = case (fn p) of
Just c ->
(String.fromFloat (p.time - startTime)) ++ "," ++
(String.fromFloat c) ++ ", "
Nothing -> ""
maxY = List.foldr max 0 (List.filterMap fn points)
minY = List.foldr min maxY (List.filterMap fn points)
2024-11-14 22:19:26 +00:00
(minYaxis, maxYaxis, tickY) = looseLabels 4 minY maxY
rangeYaxis = maxYaxis - minYaxis
maxX = Point.duration points
string = String.concat (List.map coords points)
2024-11-17 16:16:46 +00:00
ttick = timeTick maxX
firstTimeTick = (toFloat (floor(startTime / ttick))) * ttick - startTime
2024-11-14 22:19:26 +00:00
ybar n = line
[ fill "none"
, style "vector-effect" "non-scaling-stroke"
, strokeWidth "1"
, stroke "#aaa"
, x1 "0"
, y1 (String.fromFloat (minYaxis + n * tickY))
, x2 (String.fromFloat (0.95 * maxX))
, y2 (String.fromFloat (minYaxis + n * tickY))
] []
2024-11-17 16:16:46 +00:00
xtick n =
let t = firstTimeTick + n * timeTick maxX
xpix = t * portalWidth/maxX
label = formatTime (t + startTime)
in
g []
[ line
[ fill "none"
, style "vector-effect" "non-scaling-stroke"
, strokeWidth "1"
, stroke "#aaa"
, x1 (String.fromFloat xpix)
, y1 "0"
, x2 (String.fromFloat xpix)
, y2 "180"
] []
]
2024-11-14 22:19:26 +00:00
ylabel n = Svg.text_
[ x "99%", y (String.fromFloat (graphHeight - graphHeight * n * (tickY/rangeYaxis)))
, style "text-anchor" "end"
, style "fill" "#222244"
] [ Svg.text (String.fromFloat (minYaxis + n * tickY)) ]
in
svg
2024-11-13 20:38:47 +00:00
[ width portalWidth
2024-11-14 22:19:26 +00:00
, height graphHeight
2024-11-13 20:38:47 +00:00
, preserveAspectRatio "none"
]
2024-11-14 22:19:26 +00:00
[ rect
[ x "0"
, width portalWidth
, height graphHeight
, fill "#eef"
, stroke "none"
] []
, g
2024-11-13 22:05:08 +00:00
[ stroke colour
2024-11-13 20:38:47 +00:00
, strokeWidth "2"
2024-11-13 22:05:08 +00:00
, transform ( "scale(" ++ (String.fromFloat (portalWidth / maxX)) ++
2024-11-14 22:19:26 +00:00
", " ++ (String.fromFloat (graphHeight / rangeYaxis)) ++")" ++
"translate(0, " ++ (String.fromFloat maxYaxis) ++") scale(1, -1)")
]
2024-11-14 22:19:26 +00:00
[ ybar 0
, ybar 1
, ybar 2
, ybar 3
, polyline
[ fill "none"
2024-11-13 20:38:47 +00:00
, style "vector-effect" "non-scaling-stroke"
, S.points string
] []
]
2024-11-13 22:05:08 +00:00
, Svg.text_
2024-11-14 22:19:26 +00:00
[ x "99%", y "12%"
2024-11-13 22:05:08 +00:00
, style "fill" "#222244"
, style "text-anchor" "end"
2024-11-14 22:19:26 +00:00
, style "font-weight" "bold"
2024-11-13 22:05:08 +00:00
, style "text-shadow" "2px 2px 1px #dddddd"
2024-11-14 22:19:26 +00:00
] [ Svg.text title
]
, ylabel 0
, ylabel 1
, ylabel 2
, ylabel 3
2024-11-17 16:16:46 +00:00
, xtick 0
, xtick 1
, xtick 2
, xtick 3
, xtick 4
, xtick 5
]
type alias TargetedPointerEvent =
{ pointerEvent : Pointer.Event
, targetId : String
}
targetedEventDecoder =
D.map2 TargetedPointerEvent
Pointer.eventDecoder
(D.at ["target", "id"] D.string)
targetFor : String -> DragTarget
targetFor s =
case s of
"left-marker" -> StartMark
"right-marker" -> EndMark
_ -> NoTarget
onDownWithTarget tag =
let
decoder =
targetedEventDecoder
|> D.map tag
|> D.map options
options message =
{ message = message
, stopPropagation = True
, preventDefault = True
}
in
Html.Events.custom "pointerdown" decoder
timeAxis model points =
let graphHeight = 30
startTime = Maybe.withDefault 0 (Point.startTime points)
maxX = Point.duration points
2024-11-17 16:16:46 +00:00
ttick = timeTick maxX
firstTimeTick = (toFloat (floor(startTime / ttick))) * ttick - startTime
xtick n =
let t = firstTimeTick + (toFloat n) * timeTick maxX
xpix = t * portalWidth/maxX
label = formatTime (t + startTime)
in
g []
[ line
[ fill "none"
, style "vector-effect" "non-scaling-stroke"
, strokeWidth "1"
, stroke "#333"
, x1 (String.fromFloat xpix)
, y1 "0"
, x2 (String.fromFloat xpix)
, y2 "10"
] []
, Svg.text_ [ x (String.fromFloat xpix)
, style "text-anchor" "middle"
, style "vertical-align" "bottom"
, y "25" ]
[ Svg.text label ]
]
xticks = List.map xtick <| List.range 0 6
bg = rect
[ x "0"
, width portalWidth
, height graphHeight
, fill "#eef"
, stroke "none"
] []
markStart x =
let x1 = String.fromInt x
in Svg.path
[ S.d ("M " ++ x1 ++ " 40 " ++
"v -50 h 10 v 10 h -10 v -10")
, fill "#7c7"
, stroke "#4e4"
, H.id "left-marker"
, strokeWidth "3"
] []
markEnd x =
let x1 = String.fromInt x
in Svg.path
[ S.d ("M " ++ x1 ++ " 40 " ++
"v -50 h -10 v 10 h 10 v -10")
, fill "#c77"
, stroke "#e44"
, H.id "right-marker"
, strokeWidth "3"
] []
markStartPix =
case model.markedTime of
(s, d) ->
floor ((s - startTime) * portalWidth/maxX) - (Tuple.first (dragDelta StartMark model.drag))
markEndPix =
case model.markedTime of
(s, d) ->
ceiling ((s - startTime + d) * portalWidth/maxX) - (Tuple.first (dragDelta EndMark model.drag))
epos e = Tuple.mapBoth floor floor e.pointer.clientPos
2024-11-17 16:16:46 +00:00
in
svg
[ width portalWidth
, height (graphHeight + 20)
, onDownWithTarget (\e -> DragStart (targetFor e.targetId) (epos e.pointerEvent))
, viewBox ("0 -10 " ++ (String.fromInt portalWidth) ++
" " ++ (String.fromInt (graphHeight + 10)))
2024-11-17 16:16:46 +00:00
]
(bg::(markStart markStartPix)::(markEnd markEndPix)::xticks)
2024-11-17 16:16:46 +00:00
cadenceView : List Point -> Svg Msg
cadenceView =
2024-11-13 22:05:08 +00:00
measureView "cadence" "#44ee44" (.cadence >> Maybe.map toFloat)
2024-11-13 22:05:08 +00:00
powerView = measureView "power" "#994444" (.power >> Maybe.map toFloat)
2024-11-13 22:05:08 +00:00
eleView = measureView "elevation" "#4444ee" (.pos >> .ele)
2024-11-14 15:07:33 +00:00
trackView : Int -> Int -> ZoomLevel -> List Point -> Svg Msg
trackView leftedge topedge zoom points =
2024-11-10 20:12:34 +00:00
let plot p =
2024-11-21 16:05:47 +00:00
let (x, y) = pixelFromCoord (toCoord p.pos) zoom
2024-11-10 20:12:34 +00:00
x_ = x - leftedge
y_ = y - topedge
2024-11-10 20:58:05 +00:00
in (String.fromInt x_) ++ ", " ++
(String.fromInt y_) ++ ", "
line = String.concat (List.map plot points)
2024-11-10 20:12:34 +00:00
in
2024-10-27 17:53:59 +00:00
svg
2024-11-10 20:12:34 +00:00
[ H.style "width" "100%"
, H.style "height" "100%"
, H.style "position" "absolute"
2024-10-27 17:53:59 +00:00
]
[ g
2024-11-10 20:12:34 +00:00
[ fill "none"
, stroke "blue"
, strokeWidth "7"
, strokeOpacity "0.5"]
2024-11-10 20:58:05 +00:00
[
polyline
[ fill "none"
, S.points line
] []
]
]
2024-11-10 20:12:34 +00:00
2024-10-27 17:53:59 +00:00
px x = String.fromInt x ++ "px"
tiles xs ys zoom =
List.map
(\ y -> div []
(List.map (\ x -> tileImg zoom (TileNumber x y)) xs))
ys
ifTrack : Model -> (List Point -> Html msg) -> Html msg
ifTrack model content =
case model.track of
Present t ->
let (dt, _) = dragDelta Graph model.drag
2024-11-21 22:15:32 +00:00
dpix = toFloat dt * model.duration / portalWidth
start = model.startTime + dpix
points = Point.subseq t start model.duration |>
Point.downsample 300
in content points
2024-11-13 22:05:08 +00:00
Failure f -> Debug.log f (div [] [ Html.text "failure", Html.text f])
Loading -> div [] [Html.text "loading"]
Empty -> div [] [Html.text "no points"]
2024-10-27 17:53:59 +00:00
canvas centre zoom width height model =
2024-10-27 17:53:59 +00:00
let (mintile, maxtile) = boundingTiles centre zoom width height
-- offset is pixel difference between centre (which *should*
-- be the middle of the image) and actual middle of the canvas
(pixelCentreX,pixelCentreY) = pixelFromCoord centre zoom
leftedge = mintile.x * 256
topedge = mintile.y * 256
offsetX = pixelCentreX - (width // 2) - leftedge
offsetY = pixelCentreY - (height // 2) - topedge
pixWidth = (1 + maxtile.x - mintile.x) * 256
pixHeight = (1 + maxtile.y - mintile.y) * 256
xs = List.range mintile.x maxtile.x
ys = List.range mintile.y maxtile.y
epos e = Tuple.mapBoth floor floor e.pointer.clientPos
tv = ifTrack model (trackView leftedge topedge zoom)
2024-10-27 17:53:59 +00:00
in div [style "position" "absolute"
,style "width" (px pixWidth)
,style "height" (px pixHeight)
,style "left" (px -offsetX)
,style "top" (px -offsetY)
,style "lineHeight" (px 0)
,Pointer.onUp (\e -> DragFinish (epos e))
,Pointer.onMove (\e -> Drag (epos e))
,Pointer.onDown (\e -> DragStart Map (epos e)) ]
2024-10-27 17:53:59 +00:00
(tv :: tiles xs ys zoom)
2024-10-27 17:53:59 +00:00
portalWidth = 600
portalHeight = 600
withSwallowing m =
{ message = m
, stopPropagation = True
, preventDefault = True
}
-- FIXME should do something useful with deltaMode as well as deltaY
mapWheelDecoder =
let sgn x = floor((abs x)/x)
in D.map (withSwallowing << MapScale << sgn) (D.field "deltaY" D.float)
timeWheelDecoder =
D.map (withSwallowing << TimeScale) (D.field "deltaY" D.float)
2024-11-14 15:05:31 +00:00
viewDiv : Model -> Html Msg
viewDiv model =
let coord = translate model.centre (pixelsToCoord (toZoom model.zoom) (dragDelta Map model.drag))
canvasV = canvas coord (toZoom model.zoom) portalWidth portalHeight model
2024-11-16 00:22:29 +00:00
epos e = Tuple.mapBoth floor floor e.pointer.clientPos
2024-11-14 22:19:26 +00:00
in div [ style "display" "flex"
, style "column-gap" "15px"
]
[ div [ style "display" "flex"
, Html.Events.custom "wheel" mapWheelDecoder
, style "flex-direction" "column"]
[ div [ style "width" (px portalWidth)
, style "height" (px portalHeight)
, style "display" "inline-block"
, style "position" "relative"
, style "overflow" "hidden"]
[canvasV]
2024-11-14 16:03:32 +00:00
, text ("Zoom level " ++ (String.fromInt (toZoom model.zoom)))
, span []
[ button [ onClick (MapScale -zoomStep) ] [ text "-" ]
, button [ onClick (MapScale zoomStep) ] [ text "+" ]
]
]
, div [ style "display" "flex"
, Html.Events.custom "wheel" timeWheelDecoder
, Pointer.onUp (\e -> DragFinish (epos e))
, Pointer.onMove (\e -> Drag (epos e))
, Pointer.onDown (\e -> DragStart Graph (epos e))
2024-11-14 22:19:26 +00:00
, style "flex-direction" "column"
, style "row-gap" "10px"
]
[ div [] [ ifTrack model cadenceView ]
, div [] [ ifTrack model powerView ]
, div [] [ ifTrack model eleView ]
, div [] [ ifTrack model (timeAxis model) ]
]
2024-10-27 17:53:59 +00:00
]
view : Model -> Browser.Document Msg
view model =
Browser.Document "Souplesse" [ (viewDiv model) ]