├── test ├── test_helper.exs ├── flight_simulator_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ └── page_controller_test.exs └── support │ ├── channel_case.ex │ └── conn_case.ex ├── lib ├── flight_simulator_web │ ├── templates │ │ ├── page │ │ │ └── index.html.heex │ │ └── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ ├── views │ │ ├── page_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── controllers │ │ └── page_controller.ex │ ├── gettext.ex │ ├── flight_simulator │ │ ├── flight_simulator_live.html.heex │ │ ├── flight_simulator_live.ex │ │ └── instrument.ex │ ├── router.ex │ ├── telemetry.ex │ └── endpoint.ex ├── flight_simulator │ └── application.ex ├── flight_simulator_web.ex └── flight_simulator.ex ├── .formatter.exs ├── priv ├── static │ ├── favicon.ico │ ├── images │ │ └── phoenix.png │ └── robots.txt └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── assets ├── package.json ├── tailwind.config.js ├── css │ └── app.css ├── js │ ├── view.js │ ├── app.js │ └── map.js ├── package-lock.json └── vendor │ └── topbar.js ├── config ├── test.exs ├── config.exs ├── prod.exs ├── runtime.exs └── dev.exs ├── .gitignore ├── mix.exs ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/templates/page/index.html.heex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/team-alembic/liveview-flight-simulator/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /lib/flight_simulator_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.PageView do 2 | use FlightSimulatorWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/team-alembic/liveview-flight-simulator/HEAD/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /test/flight_simulator_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.PageViewTest do 2 | use FlightSimulatorWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@googlemaps/js-api-loader": "^1.13.10", 4 | "arcgis-js-api": "^4.15.2", 5 | "esri-loader": "^3.4.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.PageController do 2 | use FlightSimulatorWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /test/flight_simulator_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.PageControllerTest do 2 | use FlightSimulatorWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.LayoutView do 2 | use FlightSimulatorWeb, :view 3 | 4 | # Phoenix LiveDashboard is available only in development by default, 5 | # so we instruct Elixir to not warn if the dashboard route is missing. 6 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 7 | end 8 | -------------------------------------------------------------------------------- /test/flight_simulator_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.LayoutViewTest do 2 | use FlightSimulatorWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | module.exports = { 4 | content: [ 5 | './js/**/*.js', 6 | '../lib/*_web.ex', 7 | '../lib/*_web/**/*.*ex' 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [ 13 | require('@tailwindcss/forms') 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /test/flight_simulator_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.ErrorViewTest do 2 | use FlightSimulatorWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(FlightSimulatorWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(FlightSimulatorWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :flight_simulator, FlightSimulatorWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "Cw7/rJpmPZK+eeyPJIMPDOrrRcOyuLHB7BDChD1WUBeNL2EtAQM9fvd4ngiYUeoL", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warn 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.ErrorView do 2 | use FlightSimulatorWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag assigns[:page_title] || "FlightSimulator", suffix: " · Phoenix Framework" %> 9 | 10 | 11 | 12 | 13 |
14 |
15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import FlightSimulatorWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :flight_simulator 24 | end 25 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/flight_simulator/flight_simulator_live.html.heex: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | flight_simulator-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use FlightSimulatorWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import FlightSimulatorWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint FlightSimulatorWeb.Endpoint 28 | end 29 | end 30 | 31 | setup _tags do 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/flight_simulator/application.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulator.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | FlightSimulatorWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: FlightSimulator.PubSub}, 15 | # Start the Endpoint (http/https) 16 | FlightSimulatorWeb.Endpoint 17 | # Start a worker by calling: FlightSimulator.Worker.start_link(arg) 18 | # {FlightSimulator.Worker, arg} 19 | ] 20 | 21 | # See https://hexdocs.pm/elixir/Supervisor.html 22 | # for other strategies and supported options 23 | opts = [strategy: :one_for_one, name: FlightSimulator.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | 27 | # Tell Phoenix to update the endpoint configuration 28 | # whenever the application is updated. 29 | @impl true 30 | def config_change(changed, _new, removed) do 31 | FlightSimulatorWeb.Endpoint.config_change(changed, removed) 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use FlightSimulatorWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import FlightSimulatorWeb.ConnCase 26 | 27 | alias FlightSimulatorWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint FlightSimulatorWeb.Endpoint 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.Router do 2 | use FlightSimulatorWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {FlightSimulatorWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", FlightSimulatorWeb do 18 | pipe_through :browser 19 | 20 | live "/", FlightSimulatorLive 21 | end 22 | 23 | # Other scopes may use custom stacks. 24 | # scope "/api", FlightSimulatorWeb do 25 | # pipe_through :api 26 | # end 27 | 28 | # Enables LiveDashboard only for development 29 | # 30 | # If you want to use the LiveDashboard in production, you should put 31 | # it behind authentication and allow only admins to access it. 32 | # If your application does not have an admins-only section yet, 33 | # you can use Plug.BasicAuth to set up some basic authentication 34 | # as long as you are also using SSL (which you should anyway). 35 | if Mix.env() in [:dev, :test] do 36 | import Phoenix.LiveDashboard.Router 37 | 38 | scope "/" do 39 | pipe_through :browser 40 | 41 | live_dashboard "/dashboard", metrics: FlightSimulatorWeb.Telemetry 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # VM Metrics 34 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 35 | summary("vm.total_run_queue_lengths.total"), 36 | summary("vm.total_run_queue_lengths.cpu"), 37 | summary("vm.total_run_queue_lengths.io") 38 | ] 39 | end 40 | 41 | defp periodic_measurements do 42 | [ 43 | # A module, function and arguments to be invoked periodically. 44 | # This function must call :telemetry.execute/3 and a metric must be added above. 45 | # {FlightSimulatorWeb, :count_users, []} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :flight_simulator, FlightSimulatorWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [view: FlightSimulatorWeb.ErrorView, accepts: ~w(html json), layout: false], 14 | pubsub_server: FlightSimulator.PubSub, 15 | live_view: [signing_salt: "FsqXITOa"] 16 | 17 | # Configure esbuild (the version is required) 18 | config :esbuild, 19 | version: "0.14.23", 20 | default: [ 21 | args: 22 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 23 | cd: Path.expand("../assets", __DIR__), 24 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 25 | ] 26 | 27 | # Configures Elixir's Logger 28 | config :logger, :console, 29 | format: "$time $metadata[$level] $message\n", 30 | metadata: [:request_id] 31 | 32 | # Use Jason for JSON parsing in Phoenix 33 | config :phoenix, :json_library, Jason 34 | 35 | config :tailwind, 36 | version: "3.0.23", 37 | default: [ 38 | args: ~w( 39 | --config=tailwind.config.js 40 | --input=css/app.css 41 | --output=../priv/static/assets/app.css 42 | ), 43 | cd: Path.expand("../assets", __DIR__) 44 | ] 45 | 46 | # Import environment specific config. This must remain at the bottom 47 | # of this file so it overrides the configuration defined above. 48 | import_config "#{config_env()}.exs" 49 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :flight_simulator 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_flight_simulator_key", 10 | signing_salt: "2G3LDM30" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :flight_simulator, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | end 32 | 33 | plug Phoenix.LiveDashboard.RequestLogger, 34 | param_key: "request_logger", 35 | cookie_key: "request_logger" 36 | 37 | plug Plug.RequestId 38 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 39 | 40 | plug Plug.Parsers, 41 | parsers: [:urlencoded, :multipart, :json], 42 | pass: ["*/*"], 43 | json_decoder: Phoenix.json_library() 44 | 45 | plug Plug.MethodOverride 46 | plug Plug.Head 47 | plug Plug.Session, @session_options 48 | plug FlightSimulatorWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_name(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(FlightSimulatorWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(FlightSimulatorWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :flight_simulator, FlightSimulatorWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :flight_simulator, FlightSimulatorWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :flight_simulator, FlightSimulatorWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulator.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :flight_simulator, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {FlightSimulator.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.6.6"}, 37 | {:phoenix_html, "~> 3.0"}, 38 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 39 | {:phoenix_live_view, "~> 0.17.5"}, 40 | {:floki, ">= 0.30.0", only: :test}, 41 | {:phoenix_live_dashboard, "~> 0.6"}, 42 | {:esbuild, "~> 0.3", runtime: Mix.env() == :dev}, 43 | {:telemetry_metrics, "~> 0.6"}, 44 | {:telemetry_poller, "~> 1.0"}, 45 | {:gettext, "~> 0.18"}, 46 | {:jason, "~> 1.2"}, 47 | {:plug_cowboy, "~> 2.5"}, 48 | {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, 49 | {:geocalc, "~> 0.8"} 50 | ] 51 | end 52 | 53 | # Aliases are shortcuts or tasks specific to the current project. 54 | # For example, to install project dependencies and perform other setup tasks, run: 55 | # 56 | # $ mix setup 57 | # 58 | # See the documentation for `Mix` for more info on aliases. 59 | defp aliases do 60 | [ 61 | setup: ["deps.get"], 62 | "assets.deploy": ["esbuild default --minify", "phx.digest"] 63 | ] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | 7 | /* LiveView specific classes for your customization */ 8 | .phx-no-feedback.invalid-feedback, 9 | .phx-no-feedback .invalid-feedback { 10 | display: none; 11 | } 12 | 13 | .phx-click-loading { 14 | opacity: 0.5; 15 | transition: opacity 1s ease-out; 16 | } 17 | 18 | .phx-loading { 19 | cursor: wait; 20 | } 21 | 22 | .phx-modal { 23 | opacity: 1 !important; 24 | position: fixed; 25 | z-index: 1; 26 | left: 0; 27 | top: 0; 28 | width: 100%; 29 | height: 100%; 30 | overflow: auto; 31 | background-color: rgba(0, 0, 0, 0.4); 32 | } 33 | 34 | .phx-modal-content { 35 | background-color: #fefefe; 36 | margin: 15vh auto; 37 | padding: 20px; 38 | border: 1px solid #888; 39 | width: 80%; 40 | } 41 | 42 | .phx-modal-close { 43 | color: #aaa; 44 | float: right; 45 | font-size: 28px; 46 | font-weight: bold; 47 | } 48 | 49 | .phx-modal-close:hover, 50 | .phx-modal-close:focus { 51 | color: black; 52 | text-decoration: none; 53 | cursor: pointer; 54 | } 55 | 56 | .fade-in-scale { 57 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 58 | } 59 | 60 | .fade-out-scale { 61 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 62 | } 63 | 64 | .fade-in { 65 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 66 | } 67 | .fade-out { 68 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 69 | } 70 | 71 | @keyframes fade-in-scale-keys { 72 | 0% { 73 | scale: 0.95; 74 | opacity: 0; 75 | } 76 | 100% { 77 | scale: 1; 78 | opacity: 1; 79 | } 80 | } 81 | 82 | @keyframes fade-out-scale-keys { 83 | 0% { 84 | scale: 1; 85 | opacity: 1; 86 | } 87 | 100% { 88 | scale: 0.95; 89 | opacity: 0; 90 | } 91 | } 92 | 93 | @keyframes fade-in-keys { 94 | 0% { 95 | opacity: 0; 96 | } 97 | 100% { 98 | opacity: 1; 99 | } 100 | } 101 | 102 | @keyframes fade-out-keys { 103 | 0% { 104 | opacity: 1; 105 | } 106 | 100% { 107 | opacity: 0; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # Start the phoenix server if environment is set and running in a release 11 | if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do 12 | config :flight_simulator, FlightSimulatorWeb.Endpoint, server: true 13 | end 14 | 15 | if config_env() == :prod do 16 | # The secret key base is used to sign/encrypt cookies and other secrets. 17 | # A default value is used in config/dev.exs and config/test.exs but you 18 | # want to use a different value for prod and you most likely don't want 19 | # to check this value into version control, so we use an environment 20 | # variable instead. 21 | secret_key_base = 22 | System.get_env("SECRET_KEY_BASE") || 23 | raise """ 24 | environment variable SECRET_KEY_BASE is missing. 25 | You can generate one by calling: mix phx.gen.secret 26 | """ 27 | 28 | host = System.get_env("PHX_HOST") || "example.com" 29 | port = String.to_integer(System.get_env("PORT") || "4000") 30 | 31 | config :flight_simulator, FlightSimulatorWeb.Endpoint, 32 | url: [host: host, port: 443], 33 | http: [ 34 | # Enable IPv6 and bind on all interfaces. 35 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 36 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 37 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 38 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 39 | port: port 40 | ], 41 | secret_key_base: secret_key_base 42 | 43 | # ## Using releases 44 | # 45 | # If you are doing OTP releases, you need to instruct Phoenix 46 | # to start each relevant endpoint: 47 | # 48 | # config :flight_simulator, FlightSimulatorWeb.Endpoint, server: true 49 | # 50 | # Then you can assemble a release by calling `mix release`. 51 | # See `mix help release` for more information. 52 | end 53 | -------------------------------------------------------------------------------- /assets/js/view.js: -------------------------------------------------------------------------------- 1 | import { loadModules } from 'esri-loader'; 2 | 3 | export function mountView(state, location) { 4 | loadModules(["esri/Map", "esri/views/SceneView"]) 5 | .then(([Map, SceneView]) => { 6 | const view = state.view = new SceneView({ 7 | map: new Map({ 8 | basemap: "satellite", 9 | ground: "world-elevation", 10 | ui: { 11 | components: [] 12 | } 13 | }), 14 | ui: { 15 | components: [] 16 | }, 17 | environment: { 18 | starsEnabled: false, 19 | atmosphereEnabled: true 20 | }, 21 | qualityProfile: "low", 22 | container: "view", 23 | zoom: 12, 24 | tilt: 90, 25 | rotation: 0, 26 | center: { 27 | latitude: parseFloat(location.lat), 28 | longitude: parseFloat(location.lng), 29 | z: parseFloat(location.alt), 30 | } 31 | }) 32 | 33 | view.on("focus", function (event) { 34 | event.stopPropagation(); 35 | }); 36 | view.on("key-down", function (event) { 37 | event.stopPropagation(); 38 | }); 39 | view.on("mouse-wheel", function (event) { 40 | event.stopPropagation(); 41 | }); 42 | view.on("double-click", function (event) { 43 | event.stopPropagation(); 44 | }); 45 | view.on("double-click", ["Control"], function (event) { 46 | event.stopPropagation(); 47 | }); 48 | view.on("drag", function (event) { 49 | event.stopPropagation(); 50 | }); 51 | view.on("drag", ["Shift"], function (event) { 52 | event.stopPropagation(); 53 | }); 54 | view.on("drag", ["Shift", "Control"], function (event) { 55 | event.stopPropagation(); 56 | }); 57 | }); 58 | } 59 | 60 | export function updateView(state, location) { 61 | if (state.view) { 62 | const value = { 63 | position: { 64 | latitude: parseFloat(location.lat), 65 | longitude: parseFloat(location.lng), 66 | z: parseFloat(location.alt), 67 | }, 68 | zoom: 12, 69 | heading: parseFloat(location.bearing), 70 | tilt: 90 71 | } 72 | state.view.goTo(value, { 73 | animate: false 74 | }) 75 | .catch(function(error) { 76 | if (error.name != "AbortError") { 77 | console.error(error); 78 | } 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :flight_simulator, FlightSimulatorWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "Eto/fQOrn4o+TEZCnG9+1nxsRQXaIU9WRLgflhpRRzbfVs5ApSS4KRfa7vMDYGXq", 17 | watchers: [ 18 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 19 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 20 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 21 | ] 22 | 23 | # ## SSL Support 24 | # 25 | # In order to use HTTPS in development, a self-signed 26 | # certificate can be generated by running the following 27 | # Mix task: 28 | # 29 | # mix phx.gen.cert 30 | # 31 | # Note that this task requires Erlang/OTP 20 or later. 32 | # Run `mix help phx.gen.cert` for more information. 33 | # 34 | # The `http:` config above can be replaced with: 35 | # 36 | # https: [ 37 | # port: 4001, 38 | # cipher_suite: :strong, 39 | # keyfile: "priv/cert/selfsigned_key.pem", 40 | # certfile: "priv/cert/selfsigned.pem" 41 | # ], 42 | # 43 | # If desired, both `http:` and `https:` keys can be 44 | # configured to run both http and https servers on 45 | # different ports. 46 | 47 | # Watch static and templates for browser reloading. 48 | config :flight_simulator, FlightSimulatorWeb.Endpoint, 49 | live_reload: [ 50 | patterns: [ 51 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 52 | ~r"priv/gettext/.*(po)$", 53 | ~r"lib/flight_simulator_web/(live|views)/.*(ex)$", 54 | ~r"lib/flight_simulator_web/templates/.*(eex)$" 55 | ] 56 | ] 57 | 58 | # Do not include metadata nor timestamps in development logs 59 | config :logger, :console, format: "[$level] $message\n" 60 | 61 | # Set a higher stacktrace during development. Avoid configuring such 62 | # in production as building large stacktraces may be expensive. 63 | config :phoenix, :stacktrace_depth, 20 64 | 65 | # Initialize plugs at runtime for faster development compilation 66 | config :phoenix, :plug_init_mode, :runtime 67 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | 4 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 5 | // to get started and then uncomment the line below. 6 | // import "./user_socket.js" 7 | 8 | // You can include dependencies in two ways. 9 | // 10 | // The simplest option is to put them in assets/vendor and 11 | // import them using relative paths: 12 | // 13 | // import "../vendor/some-package.js" 14 | // 15 | // Alternatively, you can `npm install some-package --prefix assets` and import 16 | // them using a path starting with the package name: 17 | // 18 | // import "some-package" 19 | // 20 | 21 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 22 | import "phoenix_html" 23 | // Establish Phoenix Socket and LiveView configuration. 24 | import { Socket } from "phoenix" 25 | import { LiveSocket } from "phoenix_live_view" 26 | import topbar from "../vendor/topbar" 27 | import { mountMap, updateMap } from "./map" 28 | import { mountView, updateView } from "./view" 29 | 30 | function extractLocation(element) { 31 | return { 32 | lat: element.getAttribute("data-lat"), 33 | lng: element.getAttribute("data-lng"), 34 | alt: element.getAttribute("data-alt"), 35 | bearing: element.getAttribute("data-bearing"), 36 | pitch: element.getAttribute("data-pitch"), 37 | } 38 | } 39 | 40 | const mapState = {} 41 | const viewState = {} 42 | 43 | let Hooks = {} 44 | Hooks.Map = { 45 | mounted() { 46 | const location = extractLocation(this.el) 47 | mountMap(mapState, location) 48 | mountView(viewState, location) 49 | }, 50 | updated() { 51 | const location = extractLocation(this.el) 52 | updateMap(mapState, location) 53 | updateView(viewState, location) 54 | }, 55 | } 56 | 57 | let csrfToken = document 58 | .querySelector("meta[name='csrf-token']") 59 | .getAttribute("content") 60 | let liveSocket = new LiveSocket("/live", Socket, { 61 | params: { _csrf_token: csrfToken }, 62 | hooks: Hooks, 63 | }) 64 | 65 | // Show progress bar on live navigation and form submits 66 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) 67 | window.addEventListener("phx:page-loading-start", (info) => topbar.show()) 68 | window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()) 69 | 70 | // connect if there are any LiveViews on the page 71 | liveSocket.connect() 72 | 73 | // expose liveSocket on window for web console debug logs and latency simulation: 74 | // >> liveSocket.enableDebug() 75 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 76 | // >> liveSocket.disableLatencySim() 77 | window.liveSocket = liveSocket 78 | -------------------------------------------------------------------------------- /assets/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@googlemaps/js-api-loader": "^1.13.10", 9 | "arcgis-js-api": "^4.15.2", 10 | "esri-loader": "^3.4.0" 11 | } 12 | }, 13 | "node_modules/@googlemaps/js-api-loader": { 14 | "version": "1.13.10", 15 | "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.13.10.tgz", 16 | "integrity": "sha512-11AF8SdXca1+trN7kDYiPqwxcjQjet/e/A20PPR2hMHQIZ0ktxd4VsqbsSDLj1BZW1bke0FnZ4zY0KYmgWZ9cg==", 17 | "dependencies": { 18 | "fast-deep-equal": "^3.1.3" 19 | } 20 | }, 21 | "node_modules/arcgis-js-api": { 22 | "version": "4.23.0", 23 | "resolved": "https://registry.npmjs.org/arcgis-js-api/-/arcgis-js-api-4.23.0.tgz", 24 | "integrity": "sha512-+mLcXsFRycXvLdNA9XbnFLyb5NeLK/oeIG9uQALhgorxxaasGjYMWq5CMRaLbmQUnrevrzZS0OJOp9A4UVUF7w==" 25 | }, 26 | "node_modules/esri-loader": { 27 | "version": "3.4.0", 28 | "resolved": "https://registry.npmjs.org/esri-loader/-/esri-loader-3.4.0.tgz", 29 | "integrity": "sha512-iS3SbBmrnr4TlUdAjyyVZD2yCud8AMZkyJcmYEVzTiE5wVUtdN8d9USp7XYtilgwkJe/eWR2f2G1+S58ESqLuQ==" 30 | }, 31 | "node_modules/fast-deep-equal": { 32 | "version": "3.1.3", 33 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 34 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 35 | } 36 | }, 37 | "dependencies": { 38 | "@googlemaps/js-api-loader": { 39 | "version": "1.13.10", 40 | "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.13.10.tgz", 41 | "integrity": "sha512-11AF8SdXca1+trN7kDYiPqwxcjQjet/e/A20PPR2hMHQIZ0ktxd4VsqbsSDLj1BZW1bke0FnZ4zY0KYmgWZ9cg==", 42 | "requires": { 43 | "fast-deep-equal": "^3.1.3" 44 | } 45 | }, 46 | "arcgis-js-api": { 47 | "version": "4.23.0", 48 | "resolved": "https://registry.npmjs.org/arcgis-js-api/-/arcgis-js-api-4.23.0.tgz", 49 | "integrity": "sha512-+mLcXsFRycXvLdNA9XbnFLyb5NeLK/oeIG9uQALhgorxxaasGjYMWq5CMRaLbmQUnrevrzZS0OJOp9A4UVUF7w==" 50 | }, 51 | "esri-loader": { 52 | "version": "3.4.0", 53 | "resolved": "https://registry.npmjs.org/esri-loader/-/esri-loader-3.4.0.tgz", 54 | "integrity": "sha512-iS3SbBmrnr4TlUdAjyyVZD2yCud8AMZkyJcmYEVzTiE5wVUtdN8d9USp7XYtilgwkJe/eWR2f2G1+S58ESqLuQ==" 55 | }, 56 | "fast-deep-equal": { 57 | "version": "3.1.3", 58 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 59 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/flight_simulator_web.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use FlightSimulatorWeb, :controller 9 | use FlightSimulatorWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: FlightSimulatorWeb 23 | 24 | import Plug.Conn 25 | import FlightSimulatorWeb.Gettext 26 | alias FlightSimulatorWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/flight_simulator_web/templates", 34 | namespace: FlightSimulatorWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {FlightSimulatorWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def component do 63 | quote do 64 | use Phoenix.Component 65 | 66 | unquote(view_helpers()) 67 | end 68 | end 69 | 70 | def router do 71 | quote do 72 | use Phoenix.Router 73 | 74 | import Plug.Conn 75 | import Phoenix.Controller 76 | import Phoenix.LiveView.Router 77 | end 78 | end 79 | 80 | def channel do 81 | quote do 82 | use Phoenix.Channel 83 | import FlightSimulatorWeb.Gettext 84 | end 85 | end 86 | 87 | defp view_helpers do 88 | quote do 89 | # Use all HTML functionality (forms, tags, etc) 90 | use Phoenix.HTML 91 | 92 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 93 | import Phoenix.LiveView.Helpers 94 | 95 | # Import basic rendering functionality (render, render_layout, etc) 96 | import Phoenix.View 97 | 98 | import FlightSimulatorWeb.ErrorHelpers 99 | import FlightSimulatorWeb.Gettext 100 | alias FlightSimulatorWeb.Router.Helpers, as: Routes 101 | end 102 | end 103 | 104 | @doc """ 105 | When used, dispatch to the appropriate controller/view/etc. 106 | """ 107 | defmacro __using__(which) when is_atom(which) do 108 | apply(__MODULE__, which, []) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/flight_simulator/flight_simulator_live.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.FlightSimulatorLive do 2 | use FlightSimulatorWeb, :live_view 3 | 4 | alias FlightSimulatorWeb.Instrument 5 | 6 | @initial_state %FlightSimulator{ 7 | location: %{lat: -33.964592291602244, lng: 151.18069727924058}, 8 | bearing: 347.0 9 | } 10 | 11 | @tick 30 12 | @tick_seconds @tick / 1000 13 | 14 | @impl true 15 | def mount(_params, _session, socket) do 16 | if connected?(socket), do: :timer.send_interval(@tick, self(), :tick) 17 | 18 | {:ok, assign(socket, simulator: @initial_state)} 19 | end 20 | 21 | @impl true 22 | def handle_info(:tick, socket) do 23 | socket.assigns.simulator 24 | |> FlightSimulator.update(@tick_seconds) 25 | |> update_simulator(socket) 26 | end 27 | 28 | @impl true 29 | def handle_event("control_input", %{"key" => code}, socket) 30 | when code in ["ArrowLeft", "a"] do 31 | socket.assigns.simulator 32 | |> FlightSimulator.roll_left() 33 | |> update_simulator(socket) 34 | end 35 | 36 | def handle_event("control_input", %{"key" => code}, socket) 37 | when code in ["ArrowRight", "d"] do 38 | socket.assigns.simulator 39 | |> FlightSimulator.roll_right() 40 | |> update_simulator(socket) 41 | end 42 | 43 | def handle_event("control_input", %{"key" => code}, socket) 44 | when code in ["ArrowUp", "w"] do 45 | socket.assigns.simulator 46 | |> FlightSimulator.pitch_down() 47 | |> update_simulator(socket) 48 | end 49 | 50 | def handle_event("control_input", %{"key" => code}, socket) 51 | when code in ["ArrowDown", "s"] do 52 | socket.assigns.simulator 53 | |> FlightSimulator.pitch_up() 54 | |> update_simulator(socket) 55 | end 56 | 57 | def handle_event("control_input", %{"key" => code}, socket) 58 | when code in ["-", "_"] do 59 | socket.assigns.simulator 60 | |> FlightSimulator.speed_down() 61 | |> update_simulator(socket) 62 | end 63 | 64 | def handle_event("control_input", %{"key" => code}, socket) 65 | when code in ["=", "+"] do 66 | socket.assigns.simulator 67 | |> FlightSimulator.speed_up() 68 | |> update_simulator(socket) 69 | end 70 | 71 | def handle_event("control_input", %{"key" => code}, socket) when code in [",", "<"] do 72 | socket.assigns.simulator 73 | |> FlightSimulator.yaw_left() 74 | |> update_simulator(socket) 75 | end 76 | 77 | def handle_event("control_input", %{"key" => code}, socket) when code in [".", ">"] do 78 | socket.assigns.simulator 79 | |> FlightSimulator.yaw_right() 80 | |> update_simulator(socket) 81 | end 82 | 83 | def handle_event("control_input", %{"key" => " "}, socket) do 84 | socket.assigns.simulator 85 | |> FlightSimulator.reset_attitude() 86 | |> update_simulator(socket) 87 | end 88 | 89 | def handle_event("control_input", %{"key" => "Escape"}, socket) do 90 | update_simulator(@initial_state, socket) 91 | end 92 | 93 | def handle_event("control_input", key, socket) do 94 | {:noreply, socket} 95 | end 96 | 97 | def update_simulator(state, socket) do 98 | {:noreply, assign(socket, :simulator, state)} 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /assets/js/map.js: -------------------------------------------------------------------------------- 1 | import { Loader } from "@googlemaps/js-api-loader" 2 | 3 | export function mountMap(state, location) { 4 | const loader = new Loader({ 5 | apiKey: "", 6 | version: "weekly", 7 | }) 8 | 9 | let pos = { lat: parseFloat(location.lat), lng: parseFloat(location.lng) } 10 | 11 | loader.load().then(() => { 12 | state.map = new google.maps.Map(document.getElementById("map"), { 13 | center: pos, 14 | zoom: 12, 15 | disableDefaultUI: true, 16 | }) 17 | 18 | state.marker = new google.maps.Marker({ 19 | position: pos, 20 | icon: plane(location.bearing), 21 | map: state.map, 22 | }) 23 | }) 24 | } 25 | 26 | export function updateMap(state, location) { 27 | if (state.map) { 28 | let pos = { 29 | lat: parseFloat(location.lat), 30 | lng: parseFloat(location.lng), 31 | } 32 | state.map.setCenter(pos) 33 | state.marker.setPosition(pos) 34 | state.marker.setIcon(plane(location.bearing)) 35 | } 36 | } 37 | 38 | export function plane(bearing) { 39 | return { 40 | path: "M 4.7932777,4.7812376 C 4.7259064,4.8486085 4.7323964,4.9347702 4.9650313,5.4373336 C 4.9924894,5.4966515 5.1453716,5.7138571 5.1453716,5.7138571 C 5.1453723,5.7138561 5.0426847,5.8268489 5.0148394,5.8546943 C 4.9610053,5.9085284 4.9361984,6.0293335 4.958161,6.1243469 C 5.026297,6.4191302 5.8480608,7.5947712 6.3081405,8.0548517 C 6.593652,8.3403629 6.7408456,8.5354068 6.730653,8.6147666 C 6.7220521,8.6817367 6.6138788,8.9698607 6.4901987,9.2536889 C 6.2719706,9.7544933 6.1902268,9.8530093 3.7284084,12.592571 C 1.7788774,14.76205 1.1823532,15.462131 1.1469587,15.620578 C 1.0488216,16.059908 1.4289737,16.468046 2.4110617,16.977428 L 2.9177343,17.24021 C 2.9177343,17.24021 10.306553,11.950215 10.306553,11.950215 L 14.736066,15.314858 L 14.634732,15.495198 C 14.578751,15.594046 14.11587,16.171307 13.60593,16.778194 C 13.095992,17.385083 12.673006,17.939029 12.666441,18.009665 C 12.640049,18.293626 13.777085,19.186772 13.947719,19.016137 C 14.217037,18.74682 15.346696,17.884968 15.441971,17.875697 C 15.509995,17.869079 16.481025,17.128624 16.810843,16.798805 C 17.140662,16.468987 17.881117,15.497956 17.887735,15.429933 C 17.897006,15.334658 18.758859,14.204999 19.028176,13.93568 C 19.198811,13.765045 18.305664,12.62801 18.021702,12.654403 C 17.951067,12.660967 17.397123,13.083953 16.790233,13.593891 C 16.183346,14.103831 15.606085,14.566712 15.507236,14.622693 L 15.326897,14.724027 L 11.962253,10.294514 C 11.962253,10.294514 17.252249,2.9056938 17.25225,2.9056938 L 16.989466,2.3990218 C 16.480084,1.416933 16.071947,1.0367811 15.632617,1.1349189 C 15.474169,1.1703136 14.774089,1.7668355 12.60461,3.7163677 C 9.8650471,6.1781859 9.7665321,6.2599294 9.2657298,6.4781579 C 8.9819013,6.601838 8.6937782,6.7100098 8.6268071,6.7186131 C 8.5474478,6.7288044 8.352405,6.5816098 8.0668925,6.2960996 C 7.6068129,5.8360191 6.4311712,5.0142561 6.1363875,4.9461203 C 6.0413739,4.9241577 5.92057,4.9489642 5.8667352,5.0027982 C 5.8388891,5.0306446 5.7276147,5.1316136 5.7276147,5.1316136 C 5.7276147,5.1316136 5.5104099,4.9787304 5.4510923,4.9512732 C 4.9485278,4.7186391 4.8606505,4.7138647 4.7932777,4.7812376 z", 41 | fillColor: "black", 42 | fillOpacity: 0.8, 43 | strokeWeight: 0, 44 | rotation: 45 + parseInt(bearing), 45 | scale: 3, 46 | anchor: new google.maps.Point(10, 10), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix LiveView Flight Simulator 2 | 3 | It's a simple flight simulator written in Phoenix LiveView. The flight simulation takes place completely on the server side, where Phoenix Live View sends DOM updates over a websocket at appoximately 30fps (1 tick every ~30ms). 4 | 5 | Every client browser will get their own world with your very own plane in it, so enjoy flying around Sydney (you'll start on the main runway of Sydney International airport). 6 | 7 | 8 | There's no fancy linear algebra here (yet) it was conceived in 2019 mostly to test out what LiveView was capable of. LiveView in 2019 was perfectly capable of building this, but there were a few rough edges I needed to work around. 9 | 10 | In 2022 the LiveView story is massively improved with the introduction of function components. So I've rewritten the original repo to take advantage of Tailwind and function components. Leaflet stopped working properly, so I've replaced it with Google Maps which works well. 11 | 12 | https://github.com/joshprice/groundstation 13 | 14 | Have fun! 15 | 16 | 17 | ## Getting started 18 | 19 | You'll need to add your Google Maps API key here: 20 | 21 | https://github.com/team-alembic/liveview-flight-simulator/blob/main/assets/js/map.js#L5 22 | 23 | To start your Phoenix server: 24 | 25 | - Install dependencies with `mix deps.get` 26 | - Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 27 | 28 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 29 | 30 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 31 | 32 | ## Flight Manual 33 | 34 | ### Keyboard Commands 35 | 36 | - `wasd` or `Arrow keys` controls the planes' roll and pitch 37 | - `up` or `w` points the plane down toward the ground 38 | - `down` or `s` points the plane up into the air 39 | - `left`/`right` or `a`/`d` banks left and right 40 | - `,`/`.` turns the rudder left / right (yaw control - useful for taxiing on a runway) 41 | - `-`/`=` increase / decrease speed 42 | - `Esc` reset the simulation 43 | - `Space` partially restore the plane to level flight 44 | 45 | ## Implementation Highlights 46 | 47 | - The **Artificial Horizon** and **Compass** are rendered using hand-written SVG which is updated directly by Live View. 48 | - The **Map** is implemented using Google Maps 49 | - The **3D Cockpit View** is implemented using the ESRI library and the ArcGIS SDK https://developers.arcgis.com/javascript/latest/api-reference/esri-views-SceneView.html 50 | - Both the Map and Cockpit view are updated using Live View Hooks which runs JS functions on DOM updates from the server 51 | 52 | ## Limitations 53 | 54 | - It requires keyboard input to control the plane, so this probably won't work well on mobile devices (at least I haven't tested it) 55 | - This is running on a tiny free tier Gigalixir deployment so if lots of people are using this, then it's going to not work that well. That said it's Elixir and it'll probably handle quite a lot of planes before crashing and burning ;) 56 | - If you want to experience it super smooth then please run it locally to spare some bandwidth 57 | - I tried getting the 3d camera to tilt but it wouldn't point up reliably, and often flipped upside down and be generally janky so our view stays level 58 | - Don't fly too close to the ground as the 3d view doesn't react that well when you run into hills and mountains, but it's kind of fun. 59 | 60 | ## Contributing 61 | 62 | Bug reports and PRs welcome! 63 | 64 | ## Background 65 | 66 | This app is the starting point of building the Ground Station application for the UAV Challenge 2020 (https://uavchallenge.org/medical-rescue/). For this challenge, the BEAM UAV team will be attempting to complete the challenge using 2 drones and controlling them with as much Elixir and the BEAM for the software components as possible. 67 | 68 | I wrote the Flight Simulator so this would be a bit more interactive and fun for a more general audience, as well as showing off the power and simplicity of Phoenix Live View. 69 | 70 | ## Thanks 71 | 72 | Special thanks to Robin Hilliard for the motivation to build this and some guidance around simplifying the maths of the simulator. Also thanks to David Parry and Rufus Post for moral support. 73 | 74 | Lastly I really appreciate all the hard work that's gone into Live View, it's an amazing technology that we'll hopefully see a lot more of in the coming years. So thanks Chris McCord and Jose Valim! 75 | 76 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /lib/flight_simulator_web/flight_simulator/instrument.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulatorWeb.Instrument do 2 | use Phoenix.Component 3 | 4 | @doc """ 5 | Instrument panel 6 | """ 7 | def panel(assigns) do 8 | ~H""" 9 | 12 | """ 13 | end 14 | 15 | @doc """ 16 | Instrument is just a placeholder for an instrument in the panel 17 | """ 18 | def instrument(assigns) do 19 | ~H""" 20 |
  • 21 | <%= if @inner_block do %> 22 | <%= render_slot(@inner_block) %> 23 | <% end %> 24 |
  • 25 | """ 26 | end 27 | 28 | @doc """ 29 | Map creates an HTML element that LeafletJS can target 30 | """ 31 | def map(assigns) do 32 | ~H""" 33 |
    34 | """ 35 | end 36 | 37 | @doc """ 38 | Map creates an HTML element that LeafletJS can target 39 | """ 40 | def cockpit_view(assigns) do 41 | ~H""" 42 |
    43 | """ 44 | end 45 | 46 | @doc """ 47 | An SVG compass. Takes a @bearing as a float. 48 | """ 49 | def compass(assigns) do 50 | ~H""" 51 | 52 | 53 | <%= Float.round(@bearing, 1) %>º 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | N 63 | E 64 | S 65 | W 66 | 67 | 68 | NE 69 | SE 70 | SW 71 | NW 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | """ 81 | end 82 | 83 | @doc """ 84 | An SVG artificial horizon. Takes a @roll_angle and a @pitch_angle as floats. 85 | """ 86 | def horizon(assigns) do 87 | ~H""" 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 20 115 | 20 116 | 10 117 | 10 118 | 10 119 | 10 120 | 20 121 | 20 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | Altitude (m) 155 | <%= Float.round(@altitude) %> 156 | Speed (m/s) 157 | <%= Float.round(@speed) %> 158 | 159 | 160 | """ 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/flight_simulator.ex: -------------------------------------------------------------------------------- 1 | defmodule FlightSimulator do 2 | import Geocalc 3 | 4 | @moduledoc """ 5 | The state of a simulated aircraft with ability to control basic parameters and 6 | update them over time. 7 | 8 | ## Units 9 | 10 | - All angles are expressed in degrees (and are converted to radians internally when needed) 11 | - All distances are expressed in metres 12 | - All speeds are expressed in metres per second 13 | """ 14 | 15 | @pitch_delta 1.0 16 | @max_pitch_angle 20.0 17 | @min_pitch_angle -20.0 18 | 19 | @roll_delta 1.0 20 | @max_roll_angle 50.0 21 | @min_roll_angle -50.0 22 | 23 | @yaw_delta 1.0 24 | 25 | @speed_delta 5.0 26 | @min_speed 5.0 27 | @max_speed 120.0 28 | 29 | @min_altitude 10.0 30 | 31 | @reset_factor 1.1 32 | 33 | defstruct bearing: 0.0, 34 | altitude: @min_altitude, 35 | pitch_angle: 0.0, 36 | roll_angle: 0.0, 37 | speed: @min_speed, 38 | location: %{lat: 0.0, lng: 0.0} 39 | 40 | def reset_attitude(state), 41 | do: 42 | struct(state, 43 | pitch_angle: state.pitch_angle / @reset_factor, 44 | roll_angle: state.roll_angle / @reset_factor 45 | ) 46 | 47 | def speed_down(state), 48 | do: struct(state, speed: max(state.speed - @speed_delta, @min_speed)) 49 | 50 | def speed_up(state), 51 | do: struct(state, speed: min(state.speed + @speed_delta, @max_speed)) 52 | 53 | def pitch_down(state), 54 | do: struct(state, pitch_angle: max(state.pitch_angle - @pitch_delta, @min_pitch_angle)) 55 | 56 | def pitch_up(state), 57 | do: struct(state, pitch_angle: min(state.pitch_angle + @pitch_delta, @max_pitch_angle)) 58 | 59 | def roll_left(state), 60 | do: struct(state, roll_angle: max(state.roll_angle - @roll_delta, @min_roll_angle)) 61 | 62 | def roll_right(state), 63 | do: struct(state, roll_angle: min(state.roll_angle + @roll_delta, @max_roll_angle)) 64 | 65 | def yaw_left(state), 66 | do: struct(state, bearing: update_bearing(state.bearing, -@yaw_delta)) 67 | 68 | def yaw_right(state), 69 | do: struct(state, bearing: update_bearing(state.bearing, @yaw_delta)) 70 | 71 | @doc """ 72 | Calculate the changes in the simulator state over the time given in seconds. 73 | 74 | ## Example 75 | 76 | iex> update(%FlightSimulator{}, 10) 77 | %FlightSimulator{ 78 | altitude: 500.0, 79 | pitch_angle: 0.0, 80 | roll_angle: 0.0, 81 | speed: 50.0, 82 | bearing: 0.0, 83 | location: %{lat: 0.004496608029593652, lng: 0.0} 84 | } 85 | 86 | """ 87 | def update(state, time) do 88 | distance = ground_distance(state.speed, time, state.pitch_angle) 89 | 90 | struct(state, 91 | bearing: 92 | update_bearing(state.bearing, bearing_delta_for_roll(state.roll_angle, state.speed, time)), 93 | altitude: update_altitude(state.altitude, altitude_delta(distance, state.pitch_angle)), 94 | location: update_location(state.location, state.bearing, distance) 95 | ) 96 | end 97 | 98 | @doc """ 99 | Calculate new bearing given the current bearing (in degrees) and a delta (in degrees). 100 | 101 | ## Example 102 | 103 | iex> update_bearing(0, 0) 104 | 0.0 105 | iex> update_bearing(0, 1) 106 | 1.0 107 | iex> update_bearing(0, 180) 108 | 180.0 109 | iex> update_bearing(360, 270) 110 | 270.0 111 | iex> update_bearing(0, -1) 112 | 359.0 113 | iex> update_bearing(0, -180) 114 | 180.0 115 | iex> update_bearing(0, -360) 116 | 0.0 117 | 118 | """ 119 | def update_bearing(bearing, delta) do 120 | new_bearing = 121 | (bearing + delta) 122 | |> degrees_to_radians() 123 | |> radians_to_degrees() 124 | 125 | if new_bearing >= 0 do 126 | new_bearing 127 | else 128 | 360 + new_bearing 129 | end 130 | end 131 | 132 | @doc """ 133 | Calculate new altitude given the current altitude (in metres) and a delta (in metres). 134 | 135 | ## Example 136 | 137 | iex> update_altitude(0, 0) 138 | 0.0 139 | iex> update_altitude(0, 1) 140 | 1.0 141 | iex> update_altitude(0, -1) 142 | 0.0 143 | iex> update_altitude(500, 1) 144 | 501.0 145 | iex> update_altitude(500, -501) 146 | 0.0 147 | 148 | """ 149 | def update_altitude(altitude, delta) do 150 | max(@min_altitude, altitude + delta) / 1 151 | end 152 | 153 | @doc """ 154 | Calculate ground distance given speed (metres/second) and time (seconds). 155 | 156 | Account for the pitch angle to calculate the actual distance travelled across the ground. 157 | 158 | ## Example 159 | 160 | iex> ground_distance(10, 1, 0) 161 | 10.0 162 | iex> ground_distance(10, 10, 0) 163 | 100.0 164 | 165 | """ 166 | def ground_distance(speed, time, pitch_angle) do 167 | speed * time * cos(pitch_angle) 168 | end 169 | 170 | @doc """ 171 | Calculate the change in altitude given the actual distance travelled (not ground distance). 172 | 173 | ## Example 174 | 175 | iex> altitude_delta(10, 0) 176 | 0.0 177 | iex> altitude_delta(10, 30) 178 | 4.999999999999999 179 | iex> altitude_delta(10, 90) 180 | 10.0 181 | 182 | """ 183 | def altitude_delta(distance, pitch_angle) do 184 | distance * sin(pitch_angle) 185 | end 186 | 187 | @doc """ 188 | Calculate the change in bearing (degrees) given the roll angle (degrees), speed (in m/s) and time (in seconds). 189 | 190 | ## Example 191 | 192 | iex> bearing_delta_for_roll(0, 100, 100) 193 | 0.0 194 | iex> bearing_delta_for_roll(10, 100, 0) 195 | 0.0 196 | iex> bearing_delta_for_roll(10, 50, 1) 197 | 1.979301705471317 198 | iex> bearing_delta_for_roll(-10, 50, 1) 199 | -1.979301705471317 200 | 201 | """ 202 | def bearing_delta_for_roll(roll_angle, speed, time) do 203 | time * rate_of_turn(roll_angle, speed) 204 | end 205 | 206 | @doc """ 207 | Calculate rate of turn (in degrees / second) given roll angle (in degrees) and current speed (in m/s). 208 | 209 | See http://www.flightlearnings.com/2009/08/26/rate-of-turn/ for formula. 210 | 211 | ## Example 212 | 213 | iex> rate_of_turn(30, 60) 214 | 5.400716176417849 215 | iex> rate_of_turn(-30, 60) 216 | -5.400716176417849 217 | iex> rate_of_turn(10, 60) 218 | 1.6494180878927642 219 | iex> rate_of_turn(-10, 60) 220 | -1.6494180878927642 221 | iex> rate_of_turn(10, 30) 222 | 3.2988361757855285 223 | iex> rate_of_turn(-10, 30) 224 | -3.2988361757855285 225 | 226 | """ 227 | @knots_per_metre_per_second 1.9438444924406 228 | @rot_constant 1_091 229 | def rate_of_turn(roll_angle, speed) do 230 | @rot_constant * tan(roll_angle) / (speed * @knots_per_metre_per_second) 231 | end 232 | 233 | @doc """ 234 | Calculate new location for distance (in metres) and bearing (in degrees) from current location 235 | 236 | Need this for lat/lng point given distance and bearing 237 | http://www.movable-type.co.uk/scripts/latlong.html#dest-point 238 | """ 239 | def update_location(%{lat: lat, lng: lng}, bearing, distance) do 240 | {:ok, [lat_new, lng_new]} = 241 | destination_point({lat, lng}, degrees_to_radians(bearing), distance) 242 | 243 | %{lat: lat_new, lng: lng_new} 244 | end 245 | 246 | defp sin(a), do: :math.sin(degrees_to_radians(a)) 247 | defp cos(a), do: :math.cos(degrees_to_radians(a)) 248 | defp tan(a), do: :math.tan(degrees_to_radians(a)) 249 | end 250 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.16", "2675f717adc700475345c5512c381ef9273eb5df26bdd3f8c13e2636cf4cc175", [:mix], [], "hexpm", "28ed2c43d83b5c25d35c51bc0abf229ac51359c170cba76171a462ced2e4b651"}, 3 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 5 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 6 | "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, 7 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 8 | "floki": {:hex, :floki, "0.32.1", "dfe3b8db3b793939c264e6f785bca01753d17318d144bd44b407fb3493acaa87", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "d4b91c713e4a784a3f7b1e3cc016eefc619f6b1c3898464222867cafd3c681a3"}, 9 | "geocalc": {:hex, :geocalc, "0.8.4", "ee0526cccccbcd52498670e20852b0b763edbfc67c5225c33dfebd3be7bd5393", [:mix], [], "hexpm", "d58cbfc54fee549340c790efc54321e68fbc92d9dc334e24cef4afc120fefc8c"}, 10 | "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, 11 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 12 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 13 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 14 | "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, 15 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 16 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, 17 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, 18 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.7", "05a42377075868a678d446361effba80cefef19ab98941c01a7a4c7560b29121", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25eaf41028eb351b90d4f69671874643a09944098fefd0d01d442f40a6091b6f"}, 19 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 20 | "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, 21 | "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, 22 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 23 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 24 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 25 | "tailwind": {:hex, :tailwind, "0.1.5", "5561bed6c114434415077972f6d291e7d43b258ef0ee756bda1ead7293811f61", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "3be21a0ddec7fc29b323ee72bed7516078a2787f7b142e455698a2209296e2a5"}, 26 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 27 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 28 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 29 | } 30 | --------------------------------------------------------------------------------