🍴 Meu Garfo é uma visualização em grafo dos CNPJs
cuducos.tngl.io/meu-garfo
1module Main exposing (main)
2
3import Api
4import Browser
5import Browser.Dom as Dom
6import Browser.Events
7import Browser.Navigation as Nav
8import Dict exposing (Dict)
9import Format
10import Graph
11import Html exposing (Html)
12import Http
13import Json.Decode as Decode
14import Set exposing (Set)
15import Task
16import Types exposing (..)
17import Url exposing (Url)
18import View
19
20
21main : Program Flags Model Msg
22main =
23 Browser.application
24 { init = init
25 , view = \model -> { title = "Meu Garfo", body = [ View.view model ] }
26 , update = update
27 , subscriptions = subscriptions
28 , onUrlRequest = LinkClicked
29 , onUrlChange = UrlChanged
30 }
31
32
33type alias Flags =
34 { graphApi : String
35 , jsonApi : String
36 }
37
38
39blankModel : Float -> Float -> String -> String -> Nav.Key -> Url -> Model
40blankModel w h graphApi jsonApi key url =
41 { input = ""
42 , connectionInput1 = ""
43 , connectionInput2 = ""
44 , activeTab = CnpjTab
45 , nodes = Dict.empty
46 , edges = Set.empty
47 , visited = Set.empty
48 , pending = Set.empty
49 , queryQueue = []
50 , currentQueries = []
51 , error = Nothing
52 , simulation = Graph.initSimulation w h []
53 , width = w
54 , height = h
55 , graphApi = graphApi
56 , jsonApi = jsonApi
57 , dragNode = Nothing
58 , startPos = Nothing
59 , isPanning = False
60 , zoom = 1.0
61 , pan = { x = 0, y = 0 }
62 , navKey = key
63 , url = url
64 , manyBody = -150
65 , collisionRadius = 50
66 , linkDistance = 80
67 , isInitialSearch = True
68 }
69
70
71init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
72init flags url key =
73 let
74 initialModel =
75 blankModel 800 600 flags.graphApi flags.jsonApi key url
76
77 ( finalModel, initialCmd ) =
78 case parseUrl url of
79 CnpjRoute cnpj ->
80 let
81 seeded =
82 { initialModel | input = Format.mask cnpj }
83 |> enqueueQueries (queriesFor cnpj True 0)
84 in
85 triggerNextQuery seeded
86
87 ConnectionRoute id1 id2 ->
88 let
89 seeded =
90 { initialModel
91 | connectionInput1 = Format.mask id1
92 , connectionInput2 = Format.mask id2
93 , activeTab = ConnectionTab
94 }
95 |> enqueueQueries [ QueryRequest (id1 ++ ";" ++ id2) 0 (ConnectionQuery id1 id2) ]
96 in
97 triggerNextQuery seeded
98
99 HomeRoute ->
100 ( initialModel, Cmd.none )
101 in
102 ( finalModel
103 , Cmd.batch
104 [ initialCmd
105 , Task.perform (\{ viewport } -> Resize viewport.width viewport.height) Dom.getViewport
106 ]
107 )
108
109
110update : Msg -> Model -> ( Model, Cmd Msg )
111update msg model =
112 case msg of
113 UpdateInput s ->
114 if String.contains "," s then
115 let
116 allowed c =
117 Char.isAlphaNum c || List.member c [ ',', ' ', '.', '/', '-', '*' ]
118
119 filtered =
120 String.filter allowed s
121 in
122 ( { model | input = String.left 500 filtered }, Cmd.none )
123
124 else if String.length (String.filter Char.isAlphaNum s) > 14 then
125 ( { model | input = String.left 32 s }, Cmd.none )
126
127 else
128 ( { model | input = Format.maskCnpjInput model.input s }, Cmd.none )
129
130 UpdateConnectionInput1 s ->
131 if String.length (String.filter Char.isAlphaNum s) > 14 then
132 ( { model | connectionInput1 = String.left 32 s }, Cmd.none )
133
134 else
135 ( { model | connectionInput1 = Format.maskCnpjInput model.connectionInput1 s }, Cmd.none )
136
137 UpdateConnectionInput2 s ->
138 if String.length (String.filter Char.isAlphaNum s) > 14 then
139 ( { model | connectionInput2 = String.left 32 s }, Cmd.none )
140
141 else
142 ( { model | connectionInput2 = Format.maskCnpjInput model.connectionInput2 s }, Cmd.none )
143
144 SwitchTab tab ->
145 ( { model | activeTab = tab }, Cmd.none )
146
147 Search ->
148 case model.activeTab of
149 CnpjTab ->
150 let
151 cnpjs =
152 if String.contains "," model.input then
153 String.split "," model.input
154 |> List.map (String.filter (\c -> Char.isAlphaNum c || c == '*'))
155 |> List.filter (not << String.isEmpty)
156
157 else
158 [ String.filter (\c -> Char.isAlphaNum c || c == '*') model.input ]
159
160 unmasked =
161 String.join "," cnpjs
162
163 currentRoute =
164 parseUrl model.url
165 in
166 if List.isEmpty cnpjs then
167 ( { model | error = Just "Por favor, digite um CNPJ" }, Cmd.none )
168
169 else if currentRoute == CnpjRoute unmasked then
170 ( model, Cmd.none )
171
172 else
173 ( { model
174 | nodes = Dict.empty
175 , edges = Set.empty
176 , visited = Set.empty
177 , pending = Set.empty
178 , queryQueue = []
179 , currentQueries = []
180 , error = Nothing
181 , simulation = Graph.initSimulation model.width model.height []
182 , pan = { x = 0, y = 0 }
183 , zoom = 1.0
184 , manyBody = -150
185 , collisionRadius = 50
186 , linkDistance = 80
187 , isInitialSearch = True
188 }
189 , Nav.pushUrl model.navKey ("#/grafo/" ++ unmasked)
190 )
191
192 ConnectionTab ->
193 let
194 id1 =
195 String.filter Char.isAlphaNum model.connectionInput1
196
197 id2 =
198 String.filter Char.isAlphaNum model.connectionInput2
199
200 currentRoute =
201 parseUrl model.url
202 in
203 if String.isEmpty id1 || String.isEmpty id2 then
204 ( { model | error = Just "Por favor, digite ambos os campos" }, Cmd.none )
205
206 else if currentRoute == ConnectionRoute id1 id2 then
207 ( model, Cmd.none )
208
209 else
210 ( { model
211 | nodes = Dict.empty
212 , edges = Set.empty
213 , visited = Set.empty
214 , pending = Set.empty
215 , queryQueue = []
216 , currentQueries = []
217 , error = Nothing
218 , simulation = Graph.initSimulation model.width model.height []
219 , pan = { x = 0, y = 0 }
220 , zoom = 1.0
221 , manyBody = -150
222 , collisionRadius = 50
223 , linkDistance = 80
224 , isInitialSearch = True
225 }
226 , Nav.pushUrl model.navKey ("#/conexao/" ++ id1 ++ "/" ++ id2)
227 )
228
229 Clear ->
230 ( blankModel model.width model.height model.graphApi model.jsonApi model.navKey model.url
231 |> (\m -> { m | activeTab = model.activeTab })
232 , Nav.pushUrl model.navKey "#"
233 )
234
235 NodeClicked id ->
236 case Dict.get id model.nodes of
237 Just node ->
238 let
239 queries =
240 queriesFor id False node.depth
241 in
242 triggerNextQuery (enqueueQueries queries model)
243
244 Nothing ->
245 ( model, Cmd.none )
246
247 GotResponse id url depth result ->
248 let
249 key =
250 if String.contains ";" id then
251 ( id, "conexao" )
252
253 else
254 ( id, "relacoes" )
255
256 isMatchingQuery q =
257 queryKey q.queryType == key
258
259 updatedCurrentQueries =
260 List.filter (\q -> not (isMatchingQuery q)) model.currentQueries
261
262 baseModel =
263 { model
264 | currentQueries = updatedCurrentQueries
265 , pending = Set.remove key model.pending
266 , visited = Set.insert key model.visited
267 }
268
269 finalBaseModel =
270 if model.isInitialSearch && Set.isEmpty baseModel.pending then
271 { baseModel | isInitialSearch = False }
272
273 else
274 baseModel
275 in
276 case result of
277 Err apiErr ->
278 let
279 failedQuery =
280 List.filter isMatchingQuery model.currentQueries
281 |> List.head
282 in
283 triggerNextQuery (handleApiError id url failedQuery apiErr finalBaseModel)
284
285 Ok response ->
286 let
287 ( updatedModel, newQueue, extraCmd ) =
288 processResponse id response depth { finalBaseModel | error = Nothing }
289
290 ( nextModel, nextCmd ) =
291 triggerNextQuery
292 (Graph.layout (enqueueQueries newQueue updatedModel))
293 in
294 ( nextModel, Cmd.batch [ extraCmd, nextCmd ] )
295
296 GotCompanyName cnpj _ result ->
297 case result of
298 Ok info ->
299 let
300 status =
301 codeToStatus info.situacaoCadastral
302
303 updatedNodes =
304 Dict.update cnpj
305 (Maybe.map
306 (\node ->
307 { node
308 | entity =
309 case node.entity of
310 Company _ id _ ->
311 Company info.name id status
312
313 Person _ _ ->
314 Company info.name cnpj status
315 }
316 )
317 )
318 model.nodes
319 in
320 ( { model | nodes = updatedNodes }, Cmd.none )
321
322 Err _ ->
323 let
324 updatedNodes =
325 Dict.update cnpj
326 (Maybe.map
327 (\node ->
328 { node
329 | entity =
330 case node.entity of
331 Company name id _ ->
332 Company name id StatusUnknown
333
334 Person _ _ ->
335 Company cnpj cnpj StatusUnknown
336 }
337 )
338 )
339 model.nodes
340 in
341 ( { model | nodes = updatedNodes }, Cmd.none )
342
343 Tick ->
344 ( Graph.tick model, Cmd.none )
345
346 Resize w h ->
347 ( { model | width = w, height = h }, Cmd.none )
348
349 InteractionStart id x y ->
350 ( { model | dragNode = Just id, startPos = Just { x = x, y = y } }, Cmd.none )
351
352 PanStart ->
353 ( { model | isPanning = True }, Cmd.none )
354
355 InteractionMove dx dy ->
356 case model.dragNode of
357 Just id ->
358 let
359 updatedNodes =
360 Dict.update id
361 (Maybe.map (\n -> { n | x = n.x + dx / model.zoom, y = n.y + dy / model.zoom, vx = 0, vy = 0 }))
362 model.nodes
363 in
364 ( { model | nodes = updatedNodes }, Cmd.none )
365
366 Nothing ->
367 if model.isPanning then
368 ( { model | pan = { x = model.pan.x + dx, y = model.pan.y + dy } }, Cmd.none )
369
370 else
371 ( model, Cmd.none )
372
373 InteractionEnd x y ->
374 let
375 isClick =
376 case model.startPos of
377 Just start ->
378 let
379 dx =
380 x - start.x
381
382 dy =
383 y - start.y
384
385 dist =
386 sqrt (dx * dx + dy * dy)
387 in
388 dist < 5
389
390 Nothing ->
391 False
392
393 newModel =
394 { model | dragNode = Nothing, startPos = Nothing, isPanning = False }
395 in
396 case ( isClick, model.dragNode ) of
397 ( True, Just id ) ->
398 update (NodeClicked id) newModel
399
400 _ ->
401 ( newModel, Cmd.none )
402
403 Zoom delta ->
404 let
405 newZoom =
406 clamp 0.1 5.0 (model.zoom - delta * 0.001)
407 in
408 ( { model | zoom = newZoom }, Cmd.none )
409
410 LinkClicked urlRequest ->
411 case urlRequest of
412 Browser.Internal url ->
413 ( model, Nav.pushUrl model.navKey (Url.toString url) )
414
415 Browser.External href ->
416 ( model, Nav.load href )
417
418 UrlChanged url ->
419 case parseUrl url of
420 CnpjRoute cnpj ->
421 let
422 cleanCnpj s =
423 String.filter (\c -> Char.isAlphaNum c || c == '*' || c == ',') s
424
425 isSameCnpj =
426 cleanCnpj cnpj == cleanCnpj model.input
427 in
428 if isSameCnpj && not (Dict.isEmpty model.nodes) then
429 ( model, Cmd.none )
430
431 else
432 let
433 reset =
434 { model
435 | input = Format.maskMultiple cnpj
436 , nodes = Dict.empty
437 , edges = Set.empty
438 , visited = Set.empty
439 , pending = Set.empty
440 , queryQueue = []
441 , currentQueries = []
442 , simulation = Graph.initSimulation model.width model.height []
443 , url = url
444 , activeTab = CnpjTab
445 , manyBody = -150
446 , collisionRadius = 50
447 , linkDistance = 80
448 , isInitialSearch = True
449 }
450 in
451 triggerNextQuery (enqueueQueries (queriesFor cnpj True 0) reset)
452
453 ConnectionRoute id1 id2 ->
454 let
455 isSame =
456 id1 == String.filter Char.isAlphaNum model.connectionInput1 && id2 == String.filter Char.isAlphaNum model.connectionInput2
457 in
458 if isSame && not (Dict.isEmpty model.nodes) then
459 ( model, Cmd.none )
460
461 else
462 let
463 reset =
464 { model
465 | connectionInput1 = Format.mask id1
466 , connectionInput2 = Format.mask id2
467 , nodes = Dict.empty
468 , edges = Set.empty
469 , visited = Set.empty
470 , pending = Set.empty
471 , queryQueue = []
472 , currentQueries = []
473 , simulation = Graph.initSimulation model.width model.height []
474 , url = url
475 , activeTab = ConnectionTab
476 , manyBody = -150
477 , collisionRadius = 50
478 , linkDistance = 80
479 , isInitialSearch = True
480 }
481 in
482 triggerNextQuery (enqueueQueries [ QueryRequest (id1 ++ ";" ++ id2) 0 (ConnectionQuery id1 id2) ] reset)
483
484 HomeRoute ->
485 ( { model | url = url }, Cmd.none )
486
487
488type Route
489 = CnpjRoute String
490 | ConnectionRoute String String
491 | HomeRoute
492
493
494parseUrl : Url -> Route
495parseUrl url =
496 case url.fragment of
497 Just frag ->
498 let
499 cleanFrag =
500 if String.startsWith "/" frag then
501 String.dropLeft 1 frag
502
503 else
504 frag
505
506 parts =
507 String.split "/" cleanFrag
508 in
509 case parts of
510 [ "conexao", id1, id2 ] ->
511 ConnectionRoute id1 id2
512
513 [ "grafo", id ] ->
514 CnpjRoute id
515
516 _ ->
517 let
518 semiParts =
519 String.split ";" frag
520 in
521 case semiParts of
522 [ id1, id2 ] ->
523 ConnectionRoute id1 id2
524
525 [ id ] ->
526 if String.isEmpty id then
527 HomeRoute
528
529 else
530 CnpjRoute id
531
532 _ ->
533 HomeRoute
534
535 Nothing ->
536 HomeRoute
537
538
539triggerNextQuery : Model -> ( Model, Cmd Msg )
540triggerNextQuery model =
541 let
542 concurrencyLimit =
543 6
544
545 activeCount =
546 List.length model.currentQueries
547
548 availableSlots =
549 concurrencyLimit - activeCount
550 in
551 if availableSlots > 0 && not (List.isEmpty model.queryQueue) then
552 let
553 toTrigger =
554 List.take availableSlots model.queryQueue
555
556 remaining =
557 List.drop availableSlots model.queryQueue
558
559 newModel =
560 { model
561 | queryQueue = remaining
562 , currentQueries = model.currentQueries ++ toTrigger
563 }
564
565 cmds =
566 List.map (\q -> Api.queryEntity model.graphApi q.queryType q.depth) toTrigger
567 in
568 ( newModel, Cmd.batch cmds )
569
570 else
571 ( model, Cmd.none )
572
573
574processResponse : String -> ApiResponse -> Int -> Model -> ( Model, List QueryRequest, Cmd Msg )
575processResponse queriedId relations depth model =
576 let
577 processRelation rel ( accModel, accCmds ) =
578 let
579 ( c_nx, c_ny ) =
580 if Dict.member rel.cnpj accModel.nodes then
581 ( 0, 0 )
582
583 else
584 spawnNear rel.partnerId rel.cnpj accModel
585
586 companyNode =
587 case Dict.get rel.cnpj accModel.nodes of
588 Just existing ->
589 { existing | entity = Company rel.razaoSocial rel.cnpj StatusLoading }
590
591 Nothing ->
592 { id = rel.cnpj, entity = Company rel.razaoSocial rel.cnpj StatusLoading, depth = depth + 1, x = c_nx, y = c_ny, vx = 0, vy = 0, error = Nothing }
593
594 ( p_nx, p_ny ) =
595 if Dict.member rel.partnerId accModel.nodes then
596 ( 0, 0 )
597
598 else
599 spawnNear rel.cnpj rel.partnerId accModel
600
601 partnerName =
602 Maybe.withDefault rel.partnerId rel.partnerName
603
604 partnerNode =
605 case Dict.get rel.partnerId accModel.nodes of
606 Just existing ->
607 case existing.entity of
608 Person _ _ ->
609 { existing | entity = Person partnerName rel.partnerCpf }
610
611 Company _ _ _ ->
612 existing
613
614 Nothing ->
615 let
616 entity =
617 if isCnpj rel.partnerId then
618 Company partnerName rel.partnerId StatusLoading
619
620 else
621 Person partnerName rel.partnerCpf
622 in
623 { id = rel.partnerId, entity = entity, depth = depth + 1, x = p_nx, y = p_ny, vx = 0, vy = 0, error = Nothing }
624
625 edge =
626 ( rel.cnpj, rel.partnerId )
627
628 updatedNodes =
629 accModel.nodes
630 |> Dict.insert rel.cnpj companyNode
631 |> Dict.insert rel.partnerId partnerNode
632
633 updatedEdges =
634 Set.insert edge accModel.edges
635
636 companyCmd =
637 if isCnpj rel.cnpj && not (Dict.member rel.cnpj accModel.nodes) then
638 Api.fetchCompanyName accModel.jsonApi rel.cnpj
639
640 else
641 Cmd.none
642
643 partnerCmd =
644 if isCnpj rel.partnerId && not (Dict.member rel.partnerId accModel.nodes) then
645 Api.fetchCompanyName accModel.jsonApi rel.partnerId
646
647 else
648 Cmd.none
649 in
650 ( { accModel | nodes = updatedNodes, edges = updatedEdges }
651 , Cmd.batch [ accCmds, companyCmd, partnerCmd ]
652 )
653
654 ( modelWithNodes, cmds ) =
655 List.foldl processRelation ( model, Cmd.none ) relations
656
657 finalNodes =
658 let
659 idsToUpdate =
660 if String.contains ";" queriedId then
661 String.split ";" queriedId
662
663 else
664 [ queriedId ]
665
666 updateDepth id dict =
667 Dict.update id
668 (Maybe.map
669 (\n ->
670 if n.depth > depth then
671 { n | depth = depth }
672
673 else
674 n
675 )
676 )
677 dict
678 in
679 List.foldl updateDepth modelWithNodes.nodes idsToUpdate
680 in
681 ( { modelWithNodes | nodes = finalNodes }
682 , enqueueChildren queriedId depth modelWithNodes
683 , cmds
684 )
685
686
687isCnpj : String -> Bool
688isCnpj s =
689 String.length (String.filter Char.isAlphaNum s) == 14
690
691
692codeToStatus : Maybe Int -> CompanyStatus
693codeToStatus code =
694 case code of
695 Just 2 ->
696 StatusActive
697
698 Just _ ->
699 StatusInactive
700
701 Nothing ->
702 StatusInactive
703
704
705maxDepth : Int
706maxDepth =
707 8
708
709
710softNodeLimit : Int
711softNodeLimit =
712 32
713
714
715queryKey : QueryType -> QueryKey
716queryKey qType =
717 case qType of
718 EntityQuery id ->
719 ( id, "relacoes" )
720
721 ConnectionQuery id1 id2 ->
722 ( id1 ++ ";" ++ id2, "conexao" )
723
724
725queriesFor : String -> Bool -> Int -> List QueryRequest
726queriesFor id _ depth =
727 if String.contains "," id then
728 String.split "," id
729 |> List.map (String.filter (\c -> Char.isAlphaNum c || c == '*'))
730 |> List.filter (not << String.isEmpty)
731 |> List.map (\singleId -> QueryRequest singleId depth (EntityQuery singleId))
732
733 else
734 [ QueryRequest id depth (EntityQuery id) ]
735
736
737enqueueQueries : List QueryRequest -> Model -> Model
738enqueueQueries queries model =
739 let
740 accept q ( accModel, accQueue ) =
741 let
742 key =
743 queryKey q.queryType
744 in
745 if Set.member key accModel.pending || Set.member key accModel.visited then
746 ( accModel, accQueue )
747
748 else
749 ( { accModel | pending = Set.insert key accModel.pending }
750 , accQueue ++ [ q ]
751 )
752
753 ( newModel, newQueue ) =
754 List.foldl accept ( model, [] ) queries
755 in
756 { newModel | queryQueue = newModel.queryQueue ++ newQueue }
757
758
759enqueueChildren : String -> Int -> Model -> List QueryRequest
760enqueueChildren parentId depth model =
761 if depth >= maxDepth || Dict.size model.nodes >= softNodeLimit then
762 []
763
764 else
765 let
766 childDepth =
767 depth + 1
768
769 childEdges =
770 Set.filter (\( s, t ) -> s == parentId || t == parentId) model.edges
771
772 childIds =
773 Set.foldl
774 (\( s, t ) acc ->
775 if s == parentId then
776 Set.insert t acc
777
778 else
779 Set.insert s acc
780 )
781 Set.empty
782 childEdges
783 in
784 Set.foldl
785 (\childId acc ->
786 case Dict.get childId model.nodes of
787 Just node ->
788 let
789 isCompany =
790 case node.entity of
791 Company _ _ _ ->
792 True
793
794 Person _ _ ->
795 False
796 in
797 queriesFor childId isCompany childDepth ++ acc
798
799 Nothing ->
800 acc
801 )
802 []
803 childIds
804
805
806spawnNear : String -> String -> Model -> ( Float, Float )
807spawnNear parentId childId model =
808 case Dict.get parentId model.nodes of
809 Just parent ->
810 let
811 hash =
812 String.foldl (\c acc -> acc * 31 + Char.toCode c) 0 childId
813
814 angle =
815 toFloat hash * 0.6180339887498949
816
817 radius =
818 50 + toFloat (modBy 30 (abs hash))
819 in
820 ( parent.x + cos angle * radius
821 , parent.y + sin angle * radius
822 )
823
824 Nothing ->
825 nodeOffset childId model.width model.height
826
827
828nodeOffset : String -> Float -> Float -> ( Float, Float )
829nodeOffset id w h =
830 let
831 hash =
832 String.foldl (\c acc -> acc * 31 + Char.toCode c) 0 id
833
834 angle =
835 toFloat hash * 0.6180339887498949
836
837 baseRadius =
838 min w h / 3
839
840 jitter =
841 toFloat (modBy 80 (abs hash)) - 40
842 in
843 ( w / 2 + cos angle * (baseRadius + jitter)
844 , h / 2 + sin angle * (baseRadius + jitter)
845 )
846
847
848httpErrorToString : String -> Http.Error -> String
849httpErrorToString url err =
850 case err of
851 Http.BadUrl s ->
852 "URL inválida: " ++ s
853
854 Http.Timeout ->
855 "Tempo esgotado ao buscar " ++ url
856
857 Http.NetworkError ->
858 "Erro de rede ao buscar " ++ url ++ " (o servidor está rodando?)"
859
860 Http.BadStatus i ->
861 "Erro do servidor (status " ++ String.fromInt i ++ ") ao buscar " ++ url
862
863 Http.BadBody s ->
864 "Resposta inválida de " ++ url ++ ": " ++ s
865
866
867markNodeError : String -> Maybe String -> Model -> Model
868markNodeError id maybeMsg model =
869 { model
870 | nodes =
871 Dict.update id
872 (Maybe.map (\n -> { n | error = maybeMsg }))
873 model.nodes
874 }
875
876
877handleApiError : String -> String -> Maybe QueryRequest -> ApiError -> Model -> Model
878handleApiError id url _ apiErr model =
879 case apiErr of
880 NotFound ->
881 markNodeError id (Just "Não encontrado") model
882
883 BadRequest msg ->
884 let
885 marked =
886 markNodeError id (Just msg) model
887 in
888 if Dict.member id model.nodes then
889 marked
890
891 else
892 { marked | error = Just msg }
893
894 HttpError httpErr ->
895 { model | error = Just (httpErrorToString url httpErr) }
896
897
898subscriptions : Model -> Sub Msg
899subscriptions model =
900 let
901 interaction =
902 if model.dragNode /= Nothing || model.isPanning then
903 [ Browser.Events.onMouseMove (Decode.map2 InteractionMove (Decode.field "movementX" Decode.float) (Decode.field "movementY" Decode.float)) ]
904
905 else
906 []
907
908 animation =
909 if Graph.isSimulating model then
910 [ Browser.Events.onAnimationFrame (\_ -> Tick) ]
911
912 else
913 []
914
915 always =
916 [ Browser.Events.onMouseUp (Decode.map2 InteractionEnd (Decode.field "clientX" Decode.float) (Decode.field "clientY" Decode.float))
917 , Browser.Events.onResize (\w h -> Resize (toFloat w) (toFloat h))
918 ]
919 in
920 Sub.batch (interaction ++ animation ++ always)