🍴 Meu Garfo é uma visualização em grafo dos CNPJs cuducos.tngl.io/meu-garfo
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 31 kB View raw
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)