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 |
<%= live_flash(@flash, :info) %>
5 |
6 |
<%= live_flash(@flash, :error) %>
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 |
10 | <%= render_slot(@inner_block) %>
11 |
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 |