ElmでSVGの要素をドラッグ移動したいと思った。ドラッグ操作を実現するパッケージにelm-draggableがある。今回は勉強として、それに頼らず実装することを試みる。elm-draggableを用いた方法については次回やる。

初期状態

詳細は省略するが、Elmプロジェクトを作成してelm/svgelm/jsonをインストールしておく。

src/Main.elmは以下のようにしておく。elm reactorで動くことを確認する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
module Main exposing (..)

import Browser
import Browser.Events as BE
import Html exposing (..)
import Html.Attributes exposing (..)
import Json.Decode as JD
import Svg as S exposing (Svg)
import Svg.Attributes as SA
import Svg.Events as SE


main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }


type alias Model =
    {}


type Msg
    = Dummy


init : () -> ( Model, Cmd Msg )
init _ =
    ( {}, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    ( model, Cmd.none )


view : Model -> Html Msg
view model =
    div [] []


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none

目標

  • SVGの領域に円が複数存在する
  • 円をドラッグ移動できるようにしたい。
  • 円のドラッグ中はその色を橙色にし、それ以外のときは白にする

方針

ドラッグ処理については次の処理で実現することになる。

  • ドラッグ開始はSvg.Events.onMouseDownciecle要素に付ける
  • ドラッグ中はSvg.Events.onMouseMovesvg要素に付ける
  • ドラッグ終了はBrowser.Events.onMouseUpsubscriptionで登録する

いくつかの方針が考えられ、かつ慎重に考えなければならないことは、次の3点。

  • 円をどんな形で扱うか: 特に、色の情報を円に持たせるか否か
  • 円の集合をどんな形で扱うか: 円がCircleだとして、List Circleにするか、Set Circleにするか。もしくは何かIdを持たせて、Dict Id Circleにするか、など
  • ドラッグ中の円をどんな形で扱うか: Modelにドラッグ中のCircleを直接持たせるか、それともCircleIdを持たせるか、など。

今回の方針

結局何がベストなのかはよく分からないのだが、とりあえず今回は次のようにやってみる。

  • 円は ( id, x座標, y座標, 半径 ) の情報を持つ。idはIntとして扱う。
  • 円の集合はList Circleとして持つ。また、円のidは一意であって欲しいので、「次付与するid」も情報として持たせることにする。これをまとめてCirclesと呼ぶことにする。
  • ドラッグされている状態のCircleをのidModel内に持たせる。具体的には、hold : Maybe Idとしてレコードに持たせる。

3点目についての補足

ModelCircleを直接持たせる場合、ドラッグ中のCircleCirclesに含まれているCircleとのデータの同期を取る必要がある。同期を取るのは面倒なので、次のようにやる方が良い。

  • ドラッグ中のCircleと他のCircleを別々に管理する
  • ドラッグ開始時、Circlesからドラッグ中のCircleを一旦削除して、ドラッグ終了後に再び戻す。
  • 描画するときは、ドラッグ中のCircleと他のCircleを統合する

個人的にはこの処理を書くのが少し面倒に感じたので、Modelにはドラッグ中のCircleIdだけ持たせておいて、それを使ってCircles内のCircleを操作する方針を選んだ。

ただし、Circleを持たせることが活きてくる状況として、「ドラッグ中に何か特別な情報をCircleに持たせる場合」が考えられる。例えば、ドラッグ中の円に対して、「ドラッグ開始時の座標が何だったか」「他の円との距離」を持たせる場合が考えられる。このような場合は、ドラッグ中の円とドラッグしていない円とで分けてデータを管理したほうが良さそう。

追記(2020/02/26): elm-draggableのサンプルの1つに、上記と似た実装があった。ただし、ここではドラッグ中の要素movingBoxModelではなく要素の集合BoxGroup内に持たせている。座標を更新したいときはmovingBoxを変更すればよいだけ。この方針も悪くない、と思った。

Circle/Circles の定義

src/Circle.elmを作成し、そちらでCircleCirclesを定義する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
module Circle exposing (..)

type alias Id = Int

type alias Circle =
    { id : Id
    , x : Float
    , y : Float
    , r : Float
    }


type alias Circles =
    { all : List Circle
    , nextId : Id
    }

ユーティリティ関数の定義

Circlesの内部表現を気にせずに扱えるように、いくつかのユーティリティ関数を定義しておく。add関数は円を追加したいときに呼び出す関数だが、今回は利用しない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
empty : Circles
empty =
    { all = []
    , nextId = 0
    }


type alias CircleNoId =
    { x : Float
    , y : Float
    , r : Float
    }


fromList : List CircleNoId -> Circles
fromList list =
    { all = List.indexedMap (\i c -> { id = i, x = c.x, y = c.y, r = c.r }) list
    , nextId = List.length list
    }


toList : Circles -> List Circle
toList circles =
    circles.all

add : CircleNoId -> Circles -> Circles
add c circles =
    let
        circle =
            { id = circles.nextId
            , x = c.x
            , y = c.y
            , r = c.r
            }
    in
    { circles
        | all = circle :: circles.all
        , nextId = circles.nextId + 1
    }

update : Id -> (Circle -> Circle) -> Circles -> Circles
update id f circles =
    let
        new =
            List.foldr
                (\c acc ->
                    if c.id == id then
                        f c :: acc

                    else
                        c :: acc
                )
                []
                circles.all
    in
    { circles | all = new }

円の描画

src/Main.elmに戻る。次のimport文を追加。

1
import Circle as C exposing (Circle, CircleNoId, Circles, Id)

Modelの変更

ModelCirclesを追加しておく。circlesとしていくつかの初期データを投入しておく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type alias Model =
    { circles : Circles
    }

...

init : () -> ( Model, Cmd Msg )
init _ =
    ( { circles =
            C.fromList
                [ CircleNoId 10 10 10
                , CircleNoId 20 100 20
                , CircleNoId 250 250 30
                ]
      }
    , Cmd.none
    )

Viewの変更

SVG描画の処理はviewSvgに任せる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
view : Model -> Html Msg
view model =
    div []
        [ viewSvg model
        ]


viewSvg : Model -> Svg Msg
viewSvg model =
    S.svg
        [ style "border" "1px solid #000"
        , style "width" "500px"
        , style "height" "500px"
        ]
        [ viewCircles model
        ]

Circle(s)の描画をviewCircle(s)に任せる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
viewCircles : Model -> Svg Msg
viewCircles model =
    S.g []
        (List.map (viewCircle model) (C.toList model.circles))


viewCircle : Model -> Circle -> Svg Msg
viewCircle model circle =
    S.g
        [ SA.transform (translate circle.x circle.y)
        ]
        [ S.circle
            [ SA.r (String.fromFloat circle.r)
            , SA.fill "#fff"
            , SA.stroke "#000"
            ]
            []
        ]


translate : Float -> Float -> String
translate x y =
    "translate(" ++ String.fromFloat x ++ "," ++ String.fromFloat y ++ ")"

ここで、viewCircleviewCirclesModelを引数にとるようにした。一般に、viewと同じく要素の描画を担う関数に関しては、Modelを引数にとった方が、後々の修正や変更が楽になると思われる。なぜなら、Modelこそが要素描画のための全ての情報を持っているからである。「今はCircleの情報をもとに描画してたけど、それだけじゃなくてXXの情報も必要になった」などの変更に、比較的簡単に対応できる。

追記(2020/03/04): ただし、Modelを引数にとる、という情報だけでは「この関数の機能を実現するためにはどんな値が必要なのか」が分からないため、ソースコードから関数の機能を予想するのが難しくなるかもしれない。なので一概に良いとは言い切れない。

ここまで実装すると、実行結果は以下のようになる。

円をクリック中の色変化

holdの定義

ドラッグ状態にある円をholdとしてModelに持たせる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type alias Model =
    { circles : Circles
    , hold : Maybe Id
    }

...

init : () -> ( Model, Cmd Msg )
init _ =
    ( { circles = ...
      , hold = Nothing
      }
    , Cmd.none
    )

ドラッグ開始イベントの定義

circle要素に対してマウスが押下されたときに、CircleHeld idというMsgを送る。CircleHeldを受け取ったとき、holdに円のデータを入れる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Msg
    = CircleHeld Id

...

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        CircleHeld id ->
            ( { model | hold = Just id }
            , Cmd.none
            )

...

viewCircle : Model -> Circle -> Svg Msg
viewCircle model circle =
    S.g
        [ ...
        ]
        [ S.circle
            [ ...
            , SE.onMouseDown (CircleHeld circle.id)
            ]
            []
        ]

ドラッグ中の色の変更

fillCircle関数が担う。これは、ドラッグ中の円のときだけ橙色のカラーコードを返す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
viewCircle : Model -> Circle -> Svg Msg
viewCircle model circle =
    S.g
        [ ...
        ]
        [ S.circle
            [ ...
            , SA.fill (fillCircle model circle)
            ...
            ]
            []
        ]


fillCircle : Model -> Circle -> String
fillCircle model circle =
    case model.hold of
        Nothing ->
            "#fff"

        Just id ->
            if circle.id == id then
                "#f80"

            else
                "#fff"

ドラッグ終了イベントの定義

マウスが離されたときに、CircleReleasedというメッセージを送る。特定のDOM要素に依存しないイベントを監視する場合は、subscriptionを用いる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type Msg
    = CircleHeld Id
    | CircleReleased

...


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        CircleReleased ->
            ( { model | hold = Nothing }
            , Cmd.none
            )

...

subscriptions : Model -> Sub Msg
subscriptions model =
    subHold model


subHold : Model -> Sub Msg
subHold model =
    case model.hold of
        Nothing ->
            Sub.none

        Just _ ->
            BE.onMouseUp (JD.succeed CircleReleased)

Browser.Event.onMouseUpを用いた。Browser.Eventで提供される関数の多くは、引数にJson.Decode.Decoder msgをとる。これは何をDecodeするのかというと、JSのEventオブジェクトをDecodeする。今回はEventから何も取得する必要はないので、Json.Decode.succeedを用いる。

実行結果は以下のようになる。

マウス位置の取得

さて円のドラッグ移動とは、より詳しくいうと「円をクリックしている間にマウスを移動すると、円がそれに追従する」機能だ。これを実現するためには、マウスの位置ではなく「マウスがどれだけ動いたか」という情報も欲しい。マウスの位置は(Mouse)EventオブジェクトのoffsetXoffsetYで取得できる。マウスの移動量は、1つ前に発火したイベントのx yの位置の差分として計算する。

補足。マウスの移動量についてはmovementXmovementYが使えそうかなと思ったが、ドラッグ位置がずれた。movementXの説明的にはずれなさそうなのだが。よくわからない。

Modelの定義

mouse : MousePositionを定義する。「マウスがどれだけ動いたか」の情報はdx: Floatdy: Floatで持たせる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type alias Model =
    { circles : Circles
    , hold : Maybe Circle
    , mouse : MousePosition
    }


type alias MousePosition =
    { x : Float
    , y : Float
    , dx : Float
    , dy : Float
    }

...

init : () -> ( Model, Cmd Msg )
init _ =
    ( { ...
      , mouse = MousePosition 0 0 0 0
      }
    , Cmd.none
    )

マウス移動のイベント

SVG領域内の左上を原点として座標を取得したいので、SVGに対してmousemoveイベントを登録したい。しかしSvg.Event.onMouseMoveみたいな関数は用意されていないので、自作する。Svg.Event.on関数を使う。これもBrowser.Event.onMouseUpのときと同様引数にJson.Decode.Decoder msgをとり、JSのEventをDecodeする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
type Msg
    = ...
    | MouseMoved Float Float

...

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        MouseMoved x y ->
            let
                newMouse =
                    { x = x
                    , y = y
                    , dx = x - model.mouse.x
                    , dy = y - model.mouse.y
                    }
            in
            ( { model
                | mouse = newMouse
              }
            , Cmd.none
            )

...

viewSvg : Model -> Svg Msg
viewSvg model =
    S.svg
        [...
        , onMouseMove MouseMoved
        ]
        [ ...
        ]


onMouseMove : (Float -> Float -> msg) -> S.Attribute msg
onMouseMove msg =
    SE.on "mousemove"
        (JD.map2 msg
            (JD.field "offsetX" JD.float)
            (JD.field "offsetY" JD.float)
        )

試しにマウス座標を出力してみる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
view : Model -> Html Msg
view model =
    div []
        [ ...
        , viewMousePosition model
        ]


viewMousePosition : Model -> Html Msg
viewMousePosition model =
    div []
        [ p []
            [ text (textMousePosition model.mouse) ]
        , p []
            [ text (textMouseMovement model.mouse) ]
        ]


textMousePosition : MousePosition -> String
textMousePosition mouse =
    "(" ++ String.fromFloat mouse.x ++ "," ++ String.fromFloat mouse.y ++ ")"


textMouseMovement : MousePosition -> String
textMouseMovement mouse =
    "(" ++ String.fromFloat mouse.dx ++ "," ++ String.fromFloat mouse.dy ++ ")"

試しにマウスを動かしてみると、下にその座標と移動量が表示される。

円を動かす

MouseMovedを受け取ったときに、Circleの座標を更新すればよい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...
        MouseMoved x y ->
            let
              ...
            in
            ( { model
                | mouse = newMouse
                , circles = updateCircles model newMouse
              }
            , Cmd.none
            )


updateCircles : Model -> MousePosition -> Circles
updateCircles model mouse =
    case model.hold of
        Nothing ->
            model.circles

        Just id ->
            C.update id
                (\c ->
                    { c
                        | x = c.x + mouse.dx
                        , y = c.y + mouse.dy
                    }
                )
                model.circles

良い感じで動いている。