diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 276790f..a408c38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,51 +2,49 @@ name: CI on: push: - branches: [main] + branches: + - main pull_request: - branches: [main] env: MIX_ENV: test - OTP_VERSION_SPEC: "26.x" - ELIXIR_VERSION_SPEC: "1.16.x" - -jobs: - compile: - name: Compile - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - otp-version: ${{ env.OTP_VERSION_SPEC }} - elixir-version: ${{ env.ELIXIR_VERSION_SPEC }} - - name: Install dependencies - run: mix deps.get - - name: Compile dependencies - run: mix deps.compile - - name: Compile - run: mix compile --warnings-as-errors +jobs: test: name: Test runs-on: ubuntu-latest + # Test on the 3 latest Elixir versions with the latest OTP version that each supports + strategy: + matrix: + include: + - elixir: 1.16.x + otp: 26.x + + - elixir: 1.17.x + otp: 27.x + + - elixir: 1.18.x + otp: 28.x + lint: true + steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: - otp-version: ${{ env.OTP_VERSION_SPEC }} - elixir-version: ${{ env.ELIXIR_VERSION_SPEC }} + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Install dependencies run: mix deps.get - - name: Compile dependencies - run: mix deps.compile + + # We only check for warnings on latest Elixir version + - name: Compile & lint + run: mix compile --warnings-as-errors + if: ${{ matrix.lint }} + - name: Run tests run: mix test @@ -54,73 +52,28 @@ jobs: name: Check Formatted runs-on: ubuntu-latest + # Check formatting on the latest Elixir version only + strategy: + matrix: + include: + - elixir: 1.18.x + otp: 28.x + steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Elixir uses: erlef/setup-beam@v1 with: - otp-version: ${{ env.OTP_VERSION_SPEC }} - elixir-version: ${{ env.ELIXIR_VERSION_SPEC }} + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Install dependencies run: mix deps.get - - name: Compile dependencies - run: mix deps.compile - - name: Check formatted - run: mix format --check-formatted - - # credo: - # name: Credo - # runs-on: ubuntu-latest - - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # - name: Set up Elixir - # uses: erlef/setup-beam@v1 - # with: - # otp-version: ${{ env.OTP_VERSION_SPEC }} - # elixir-version: ${{ env.ELIXIR_VERSION_SPEC }} - # - name: Install dependencies - # run: mix deps.get - # - name: Compile dependencies - # run: mix deps.compile - # - name: Run credo - # run: mix credo --strict - - # dialyzer: - # name: Dialyzer - # runs-on: ubuntu-latest - # env: - # MIX_ENV: dev + - name: Compile + run: mix compile - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # - name: Set mix file hash - # id: set_vars - # run: | - # mix_hash="${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}" - # echo "::set-output name=mix_hash::$mix_hash" - # - name: Cache PLT files - # id: cache-plt - # uses: actions/cache@v4 - # with: - # path: | - # _build/dev/*.plt - # _build/dev/*.plt.hash - # key: plt-cache-${{ steps.set_vars.outputs.mix_hash }} - # restore-keys: | - # plt-cache- - # - name: Set up Elixir - # uses: erlef/setup-beam@v1 - # with: - # otp-version: ${{ env.OTP_VERSION_SPEC }} - # elixir-version: ${{ env.ELIXIR_VERSION_SPEC }} - # - name: Install dependencies - # run: mix deps.get - # - name: Compile dependencies - # run: mix deps.compile - # - name: Run dialyzer - # run: mix dialyzer + - name: Check formatted + run: mix format --check-formatted diff --git a/README.md b/README.md index f230fd1..451d1aa 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ installed by adding `plox` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:plox, "~> 0.1.0"} + {:plox, "~> 0.2.0"} ] end ``` @@ -27,7 +27,7 @@ Documentation is published on [HexDocs](https://hexdocs.pm) and can be found at Example screenshot -Start by setting up your data, scales, and dataset: +Start by setting up your data, dimensions, axes, and dataset: ```elixir data = [ @@ -38,44 +38,39 @@ data = [ %{date: ~D[2023-08-05], value: 50.0} ] -date_scale = date_scale(Date.range(~D[2023-08-01], ~D[2023-08-05])) -number_scale = number_scale(0.0, 80.0) +dimensions = Plox.Dimensions.new(800, 250) -dataset = - dataset(data, - x: {date_scale, & &1.date}, - y: {number_scale, & &1.value} - ) -``` +x_axis = Plox.XAxis.new( + Plox.DateScale.new(Date.range(~D[2023-08-01], ~D[2023-08-05])), + dimensions +) -Once you have those, you can build a `Plox.Graph` struct: +y_axis = Plox.YAxis.new(Plox.NumberScale.new(0.0, 80.0), dimensions) -```elixir -example_graph = - to_graph( - scales: [date_scale: date_scale, number_scale: number_scale], - datasets: [dataset: dataset] +dataset = + Plox.Dataset.new(data, + x: {x_axis, & &1.date}, + y: {y_axis, & &1.value} ) ``` -Finally, render the `Plox.Graph` directly within your HEEx template: +Once you have those, you can render a `graph` component within your HEEx template: ```html -<.graph :let={graph} id="example_graph" for={@example_graph} width="800" height="250"> - <:legend> - <.legend_item color="#EC7E16" label="Data" /> - +<.graph id="example_graph" dimensions={@dimensions}> + <.x_axis_labels :let={date} axis={@x_axis}> + {Calendar.strftime(date, "%-m/%-d")} + - <.x_axis :let={date} scale={graph[:date_scale]}> - <%= Calendar.strftime(date, "%-m/%-d") %> - + <.y_axis_labels :let={value} axis={@y_axis} ticks={5}> + {value} + - <.y_axis :let={value} scale={graph[:number_scale]} ticks={5}> - <%= value %> - + <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> + <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> - <.line_plot dataset={graph[:dataset]} color="#EC7E16" /> + <.polyline points={points(@dataset[:x], @dataset[:y])} stroke="#EC7E16" stroke-width={2} /> - <.points_plot dataset={graph[:dataset]} color="#EC7E16" /> + <.circle cx={@dataset[:x]} cy={@dataset[:y]} r={3} fill="#EC7E16" /> ``` diff --git a/docs/migration_guide.md b/docs/migration_guide.md new file mode 100644 index 0000000..92debdb --- /dev/null +++ b/docs/migration_guide.md @@ -0,0 +1,112 @@ +# Plox Migration Guide (0.2.0 to X.X.X) + +Plox has gone through a major philosophical rewrite since its initial publication. This +guide will help users convert their current Plox graphs over to the new approach. + +## Example of 0.2.0 usage + +1. Set up data, scales, and dataset: + +```elixir +data = [ + %{date: ~D[2023-08-01], value: 35.0}, + %{date: ~D[2023-08-02], value: 60.0}, + %{date: ~D[2023-08-03], value: 65.0}, + %{date: ~D[2023-08-04], value: 10.0}, + %{date: ~D[2023-08-05], value: 50.0} +] + +date_scale = date_scale(Date.range(~D[2023-08-01], ~D[2023-08-05])) +number_scale = number_scale(0.0, 80.0) + +dataset = + dataset(data, + x: {date_scale, & &1.date}, + y: {number_scale, & &1.value} + ) +``` + +2. Build a `Plox.Graph` struct: + +```elixir +example_graph = + to_graph( + scales: [date_scale: date_scale, number_scale: number_scale], + datasets: [dataset: dataset] + ) +``` + +3. Render the `Plox.Graph` within your HEEx template: + +```html +<.graph :let={graph} id="example_graph" for={@example_graph} width="800" height="250"> + <.x_axis :let={date} scale={graph[:date_scale]}> + {Calendar.strftime(date, "%-m/%-d")} + + + <.y_axis :let={value} scale={graph[:number_scale]} ticks={5}> + {value} + + + <.line_plot dataset={graph[:dataset]} color="#EC7E16" /> + + <.points_plot dataset={graph[:dataset]} color="#EC7E16" /> + +``` + +## Example of X.X.X usage + +1. Set up data, dimensions, axes, and dataset: + +```elixir +data = [ + %{date: ~D[2023-08-01], value: 35.0}, + %{date: ~D[2023-08-02], value: 60.0}, + %{date: ~D[2023-08-03], value: 65.0}, + %{date: ~D[2023-08-04], value: 10.0}, + %{date: ~D[2023-08-05], value: 50.0} +] + +# Instead of passing the height and width via the `graph` component, +# create Dimensions and pass them to each Axis +dimensions = Plox.Dimensions.new(800, 250) + +# Instead of creating separate Scales, we need them when creating our Axes +x_axis = Plox.XAxis.new( + Plox.DateScale.new(Date.range(~D[2023-08-01], ~D[2023-08-05])), + dimensions +) + +y_axis = Plox.YAxis.new(Plox.NumberScale.new(0.0, 80.0), dimensions) + +# Create a Dataset with our Axes +dataset = + Plox.Dataset.new(data, + x: {x_axis, & &1.date}, + y: {y_axis, & &1.value} + ) +``` + +2. Render the `graph` component within your HEEx template: + +```html +<.graph id="example_graph" dimensions={@dimensions}> + + <.x_axis_labels :let={date} axis={@x_axis}> + {Calendar.strftime(date, "%-m/%-d")} + + + <.y_axis_labels :let={value} axis={@y_axis} ticks={5}> + {value} + + + <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> + <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> + + + <.polyline points={points(@dataset[:x], @dataset[:y])} stroke="#EC7E16" stroke-width={2} /> + + + <.circle cx={@dataset[:x]} cy={@dataset[:y]} r={3} fill="#EC7E16" /> + +``` \ No newline at end of file diff --git a/examples/animated_demo_live.exs b/examples/animated_demo_live.exs new file mode 100644 index 0000000..d073039 --- /dev/null +++ b/examples/animated_demo_live.exs @@ -0,0 +1,229 @@ +Mix.install([ + {:phoenix_playground, "~> 0.1.7"}, + {:plox, path: "."} +]) + +defmodule AnimatedDemoLive do + @moduledoc """ + Example animated graph rendered within a Phoenix Playground application. + + $ iex animated_demo_live.exs + """ + use Phoenix.LiveView + + import Plox + + @interval 1000 + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + dimensions = Plox.Dimensions.new(1_000, 250) + + if connected?(socket) do + Process.send_after(self(), :tick, @interval) + Process.send_after(self(), :add_point, :rand.uniform(10) * 1_000) + end + + now = DateTime.truncate(DateTime.utc_now(), :second) + soon = DateTime.add(now, 5, :second) + start = DateTime.add(soon, -60, :second) + nearest_5_second = get_nearest_5_second(start) + + x_axis = + Plox.XAxis.new( + Plox.DateTimeScale.new( + start, + soon + ), + dimensions + ) + + y_axis = Plox.YAxis.new(Plox.NumberScale.new(0, 100), dimensions) + + radius_axis = Plox.LinearAxis.new(Plox.NumberScale.new(1, 100), min: 5, max: 15) + + color_axis = + Plox.ColorAxis.new(Plox.FixedColorsScale.new(%{cold: "#1E88E5AA", normal: "#43A047AA", warm: "#FFC107AA"})) + + {data1, dataset1} = init_line_data(now, x_axis, y_axis) + {data2, dataset2} = init_line_data(now, x_axis, y_axis) + {data3, dataset3} = init_line_data(now, x_axis, y_axis) + + points = [] + + points_dataset = + Plox.Dataset.new(points, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y}, + r: {radius_axis, & &1.size}, + color: {color_axis, & &1.temperature} + ) + + {:ok, + assign(socket, + now: now, + data1: data1, + dataset1: dataset1, + data2: data2, + dataset2: dataset2, + data3: data3, + dataset3: dataset3, + points: points, + points_dataset: points_dataset, + x_axis: x_axis, + y_axis: y_axis, + radius_axis: radius_axis, + color_axis: color_axis, + dimensions: dimensions, + nearest_5_second: nearest_5_second + )} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + <.graph dimensions={@dimensions}> + <.y_axis_labels :let={value} axis={@y_axis} ticks={5}> + {value} + + + <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> + + <.x_axis_labels :let={datetime} axis={@x_axis} step={5} start={@nearest_5_second}> + {Calendar.strftime(datetime, "%-I:%M:%S")} + + + <.x_axis_grid_lines axis={@x_axis} step={5} start={@nearest_5_second} stroke="#D3D3D3" /> + <.x_axis_grid_line axis={@x_axis} value={@x_axis.scale.first} stroke="#D3D3D3" /> + <.x_axis_grid_line axis={@x_axis} value={@x_axis.scale.last} stroke="#D3D3D3" /> + + <%!-- vertical marker for "now" with a label --%> + <.x_axis_label axis={@x_axis} value={@now} position={:top} stroke="red"> + Now ({Calendar.strftime(@now, "%-I:%M:%S")}) + + + <.x_axis_grid_line axis={@x_axis} value={@now} stroke="red" /> + + <.polyline points={points(@dataset1[:x], @dataset1[:y])} stroke="orange" stroke-width="2" /> + <.polyline points={points(@dataset2[:x], @dataset2[:y])} stroke="blue" stroke-width="2" /> + <.polyline points={points(@dataset3[:x], @dataset3[:y])} stroke="green" stroke-width="2" /> + <.circle + cx={@points_dataset[:x]} + cy={@points_dataset[:y]} + r={@points_dataset[:r]} + fill={@points_dataset[:color]} + /> + + """ + end + + @impl Phoenix.LiveView + def handle_info(:tick, socket) do + Process.send_after(self(), :tick, @interval) + + now = DateTime.truncate(DateTime.utc_now(), :second) + soon = DateTime.add(now, 5, :second) + start = DateTime.add(soon, -60, :second) + nearest_5_second = get_nearest_5_second(start) + + x_axis = + Plox.XAxis.new( + Plox.DateTimeScale.new( + start, + soon + ), + socket.assigns.dimensions + ) + + {data1, dataset1} = update_line_data(socket.assigns.data1, now, x_axis, socket.assigns.y_axis) + {data2, dataset2} = update_line_data(socket.assigns.data2, now, x_axis, socket.assigns.y_axis) + {data3, dataset3} = update_line_data(socket.assigns.data3, now, x_axis, socket.assigns.y_axis) + + points = + Enum.take_while(socket.assigns.points, fn %{x: time} -> + DateTime.compare(time, x_axis.scale.first) != :lt + end) + + points_dataset = + Plox.Dataset.new(points, + x: {socket.assigns.x_axis, & &1.x}, + y: {socket.assigns.y_axis, & &1.y}, + r: {socket.assigns.radius_axis, & &1.size}, + color: {socket.assigns.color_axis, & &1.temperature} + ) + + {:noreply, + assign(socket, + now: now, + data1: data1, + dataset1: dataset1, + data2: data2, + dataset2: dataset2, + data3: data3, + dataset3: dataset3, + points: points, + points_dataset: points_dataset, + x_axis: x_axis, + nearest_5_second: nearest_5_second + )} + end + + def handle_info(:add_point, socket) do + Process.send_after(self(), :add_point, :rand.uniform(10) * 1_000) + + new_point = %{ + x: socket.assigns.now, + y: :rand.uniform(100), + size: :rand.uniform(100), + temperature: Enum.random(~w(cold normal warm)a) + } + + points = [new_point | socket.assigns.points] + + {:noreply, assign(socket, points: points)} + end + + defp get_nearest_5_second(datetime) do + seconds = datetime.second + + diff = ceil(seconds / 5) * 5 - seconds + + DateTime.add(datetime, diff) + end + + defp init_line_data(now, x_axis, y_axis) do + data = [%{x: now, y: :rand.uniform(100)}] + + dataset = Plox.Dataset.new(data, x: {x_axis, & &1.x}, y: {y_axis, & &1.y}) + + {data, dataset} + end + + defp update_line_data(data, now, x_axis, y_axis) do + [previous | _] = data + + diff = + case previous.y do + 100 -> :rand.uniform(10) - 10 + 0 -> :rand.uniform(10) + _ -> :rand.uniform(20) - 10 + end + + new = min(max(previous.y + diff, 0), 100) + + data = + Enum.take_while([%{x: now, y: new} | data], fn %{x: time} -> + DateTime.compare(time, x_axis.scale.first) != :lt + end) + + dataset = + Plox.Dataset.new(data, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y} + ) + + {data, dataset} + end +end + +PhoenixPlayground.start(live: AnimatedDemoLive) diff --git a/examples/demo_live.exs b/examples/demo_live.exs new file mode 100644 index 0000000..b33448c --- /dev/null +++ b/examples/demo_live.exs @@ -0,0 +1,119 @@ +Mix.install([ + {:phoenix_playground, "~> 0.1.7"}, + {:plox, path: "."} +]) + +defmodule DemoLive do + @moduledoc """ + Example graph rendered within a Phoenix Playground application. + + $ iex demo_live.exs + """ + use Phoenix.LiveView + + import Plox + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, mount_simple_line_graph(socket)} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + <.simple_line_graph {@graph} /> + """ + end + + defp mount_simple_line_graph(socket) do + data = + [ + %{date: ~D[2023-08-01], value: 35.0, intensity: 10, temperature: :cold}, + %{date: ~D[2023-08-02], value: 60.0, intensity: 20, temperature: :cold}, + %{date: ~D[2023-08-03], value: 65.0, intensity: 25, temperature: :normal}, + %{date: ~D[2023-08-04], value: 10.0, intensity: 45, temperature: :warm}, + %{date: ~D[2023-08-05], value: 50.0, intensity: 15, temperature: :warm} + ] + + dimensions = Plox.Dimensions.new(670, 250) + + x_axis = + Plox.XAxis.new(Plox.DateScale.new(Date.range(~D[2023-08-01], ~D[2023-08-05])), dimensions) + + y_axis = Plox.YAxis.new(Plox.NumberScale.new(0.0, 80.0), dimensions) + radius_axis = Plox.LinearAxis.new(Plox.NumberScale.new(10, 45), min: 4, max: 10) + + color_axis = + Plox.ColorAxis.new(Plox.FixedColorsScale.new(%{cold: "#1E88E5", normal: "#43A047", warm: "#FFC107"})) + + dataset = + Plox.Dataset.new(data, + x: {x_axis, & &1.date}, + y: {y_axis, & &1.value}, + radius: {radius_axis, & &1.intensity}, + color: {color_axis, & &1.temperature} + ) + + assign(socket, + graph: %{ + x_axis: x_axis, + y_axis: y_axis, + dataset: dataset, + dimensions: dimensions + } + ) + end + + defp simple_line_graph(assigns) do + ~H""" +

Example graph

+ + <.graph dimensions={@dimensions}> + <.x_axis_labels :let={date} axis={@x_axis}> + {Calendar.strftime(date, "%-m/%-d")} + + + <%!-- this wraps text... why does it take in `axis`?? if we want to follow the SVG, we need to pass in `x` --%> + <.x_axis_label axis={@x_axis} value={~D[2023-08-02]} position={:top} color="red"> + {"Important Day"} + + + <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> + + <.y_axis_labels :let={value} axis={@y_axis} ticks={5}> + {value} + + + <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> + + <.polyline points={points(@dataset[:x], @dataset[:y])} stroke="orange" stroke-width={2} /> + + <%!-- constant y = 40 --%> + <.polyline points={points(@dataset[:x], @dataset[:y][40])} stroke="purple" stroke-width="2" /> + + <.step_polyline points={points(@dataset[:x], @dataset[:y])} stroke="pink" stroke-width="2" /> + + <%!-- you can manually add points like how the polyline SVG accepts them, but it'll turn them into a step line --%> + <%!-- <.step_polyline points="50,60 100,20 150,40" stroke="pink" stroke-width="2" /> --%> + + <%!-- you can also manually build a list of {x, y} tuples and pass that in --%> + <%!-- <.step_polyline points={@step_points} stroke="green" stroke-width="2" /> --%> + + <%!-- use the Access behavior --%> + <.circle + cx={@dataset[:x]} + cy={@dataset[:y]} + stroke={@dataset[:color]} + fill="none" + stroke-width="2px" + r={@dataset[:radius]} + /> + + <%!-- pass in constant y-axis and color value --%> + <.circle cx={@dataset[:x]} cy={@dataset[:y][40]} fill="red" r={@dataset[:radius]} /> + + """ + end +end + +PhoenixPlayground.start(live: DemoLive) diff --git a/images/plox-icon.svg b/images/plox-icon.svg index 9c4dc53..a693c38 100644 --- a/images/plox-icon.svg +++ b/images/plox-icon.svg @@ -76,6 +76,6 @@ - + diff --git a/images/readme-example-plot@2x.png b/images/readme-example-plot@2x.png index a5b028d..1f683b3 100644 Binary files a/images/readme-example-plot@2x.png and b/images/readme-example-plot@2x.png differ diff --git a/lib/plox.ex b/lib/plox.ex index 447bbe6..a2b6f8d 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -1,757 +1,736 @@ defmodule Plox do @moduledoc """ - TODO: + Server-side rendered SVG graphing components for Phoenix and LiveView. """ use Phoenix.Component - alias Phoenix.LiveView.JS - alias Plox.Dataset - alias Plox.DateScale - alias Plox.DateTimeScale alias Plox.Dimensions - alias Plox.FixedColorsScale - alias Plox.FixedValuesScale - alias Plox.Graph - alias Plox.GraphDataset - alias Plox.GraphScale - alias Plox.NumberScale + alias Plox.Scale + alias Plox.XAxis + alias Plox.YAxis + + # copied from SVG spec: https://svgwg.org/svg2-draft/styling.html#TermPresentationAttribute + @svg_presentation_globals ~w(alignment-baseline baseline-shift clip-path clip-rule color color-interpolation color-interpolation-filters cursor direction display dominant-baseline fill-opacity fill-rule filter flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-orientation-horizontal glyph-orientation-vertical image-rendering letter-spacing lighting-color marker-end marker-mid marker-start mask mask-type opacity overflow paint-order pointer-events shape-rendering stop-color stop-opacity stroke stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-overflow text-rendering transform-origin unicode-bidi vector-effect visibility white-space word-spacing writing-mode) @doc """ - Entrypoint component for rendering graphs and plots + Entrypoint component for rendering graphs and plots. """ @doc type: :component - attr :for, Graph, required: true - - attr :id, :string, required: true - attr :width, :any, required: true, doc: "The total width of the rendered graph in pixels" - attr :height, :any, required: true, doc: "The total height of the rendered graph in pixels" - - attr :margin, :any, - default: {35, 70}, - doc: """ - The amount of space around the plotting area of the graph in which the axis labels are - rendered. Accepts one, two, three or four values and interprets them the same was as in - CSS. - """ + attr :dimensions, Dimensions, required: true + attr :rest, :global - attr :padding, :any, - default: 0, - doc: """ - The amount of space inside the plotting area of the graph from the edges to where plotting - begins. Accepts one, two, three or four values and interprets them the same was as in CSS. - """ - - slot :legend - slot :tooltips slot :inner_block, required: true def graph(assigns) do - assigns = - assign(assigns, - for: nil, - graph: Graph.put_dimensions(assigns.for, Dimensions.new(assigns)) - ) - ~H""" -
-
- <.legend :for={legend <- @legend}> - {render_slot(legend)} - -
-
- - {render_slot(@inner_block, @graph)} +
+
+ + {render_slot(@inner_block)} - - <%= for tooltip <- @tooltips do %> - {render_slot(tooltip, @graph)} - <% end %>
""" end @doc """ - Y-axis labels along the left or right side of the graph + X-axis labels along the bottom or top of the graph. + + See `x_axis_label/1` for more details on the accepted attributes. """ @doc type: :component - attr :scale, :any, required: true + attr :axis, XAxis, required: true attr :ticks, :any attr :step, :any - - attr :position, :atom, values: [:left, :right], default: :left - - attr :label_color, :string, default: "#18191A" - attr :label_rotation, :integer, default: nil - - attr :grid_lines, :boolean, default: true - attr :line_width, :string, default: "1" - attr :line_color, :string, default: "#F2F4F5" + attr :start, :any + attr :rest, :global, include: ~w(gap rotation position) ++ @svg_presentation_globals slot :inner_block, required: true - def y_axis(assigns) do + def x_axis_labels(assigns) do ~H""" - <%= for y_value <- GraphScale.values(@scale, scale_opts(assigns)), y_pixel = GraphScale.to_graph_y(@scale, y_value) do %> - <.y_label - dimensions={@scale.dimensions} - y_pixel={y_pixel} - position={@position} - color={@label_color} - rotation={@label_rotation} - > - {render_slot(@inner_block, y_value)} - - <.horizontal_line - :if={@grid_lines} - dimensions={@scale.dimensions} - y_pixel={y_pixel} - width={@line_width} - color={@line_color} - /> - <% end %> + <.x_axis_label + :for={value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step, :start]))} + axis={@axis} + value={value} + {@rest} + > + {render_slot(@inner_block, value)} + """ end @doc """ - X-axis labels along the bottom or top of the graph + An X-axis label at the bottom or top of the graph. """ @doc type: :component - attr :scale, :any, required: true - attr :ticks, :any - attr :step, :any - + attr :axis, XAxis, required: true + attr :value, :any, required: true attr :position, :atom, values: [:top, :bottom], default: :bottom - - attr :label_color, :string, default: "#18191A" - attr :label_rotation, :integer, default: nil - - attr :grid_lines, :boolean, default: true - attr :line_width, :string, default: "1" - attr :line_color, :string, default: "#F2F4F5" + attr :gap, :integer, default: 16 + attr :rotation, :integer, default: nil + attr :"dominant-baseline", :any, default: nil + attr :"text-anchor", :any, default: nil + attr :rest, :global, include: @svg_presentation_globals slot :inner_block, required: true - def x_axis(assigns) do - ~H""" - <%= for x_value <- GraphScale.values(@scale, scale_opts(assigns)), x_pixel = GraphScale.to_graph_x(@scale, x_value) do %> - <.x_label - dimensions={@scale.dimensions} - x_pixel={x_pixel} - position={@position} - color={@label_color} - rotation={@label_rotation} - > - {render_slot(@inner_block, x_value)} - - <.vertical_line - :if={@grid_lines} - dimensions={@scale.dimensions} - x_pixel={x_pixel} - width={@line_width} - color={@line_color} - /> - <% end %> - """ - end - - defp scale_opts(assigns), do: Map.take(assigns, [:ticks, :step]) - - @doc """ - A connected line plot - """ - @doc type: :component - - attr :dataset, :any, required: true - - attr :x, :atom, default: :x, doc: "The dataset axis key to use for x values" - attr :y, :atom, default: :y, doc: "The dataset axis key to use for y values" - - attr :width, :string, examples: ["1.5", "4"], default: "2" - attr :line_style, :atom, values: [:solid, :dashed, :dotted], default: :solid - attr :color, :string, default: "#FF9330" - attr :type, :atom, values: [:line, :step_line], default: :line - - def line_plot(%{type: :line} = assigns) do - ~H""" - GraphDataset.to_graph_points(@x, @y) |> polyline_points()} - fill="none" - stroke={@color} - stroke-width={@width} - stroke-dasharray={stroke_dasharray(@line_style)} - /> - """ - end - - def line_plot(%{type: :step_line} = assigns) do - ~H""" - step_points(@x, @y) |> polyline_points()} - fill="none" - stroke={@color} - stroke-width={@width} - stroke-dasharray={stroke_dasharray(@line_style)} - /> - """ - end - - defp step_points(%GraphDataset{} = graph_dataset, x_key, y_key) do - graph_dataset - |> GraphDataset.to_graph_points(x_key, y_key) - |> Enum.chunk_every(2, 1) - |> Enum.flat_map(fn - [point1, point2] -> [point1, %{point2 | y: point1.y}] - [point] -> [point] - end) - end - - defp polyline_points(points), do: Enum.map_join(points, " ", &"#{&1.x},#{&1.y}") - - @doc """ - Points plot - """ - @doc type: :component - - attr :dataset, :any, required: true - - attr :x, :atom, default: :x, doc: "The dataset axis key to use for x values" - attr :y, :atom, default: :y, doc: "The dataset axis key to use for y values" - - attr :radius, :string, examples: ["8", "24.5"], default: "4" - attr :color, :any, examples: ["red", "#FF9330", :color_axis], default: "#FF9330" - attr :"phx-click", :any, default: nil - attr :"phx-target", :any, default: nil - - def points_plot(assigns) do + def x_axis_label(%{position: :bottom} = assigns) do ~H""" - + {@rest} + > + {render_slot(@inner_block)} + """ end - @doc """ - Bar plot - """ - @doc type: :component - - attr :dataset, :any, required: true - - attr :x, :atom, default: :x, doc: "The dataset axis key to use for x values" - attr :y, :atom, default: :y, doc: "The dataset axis key to use for y values" - - attr :width, :string, examples: ["1.5", "4"], default: "16" - attr :bar_style, :atom, values: [:round, :square], default: :round - attr :color, :any, examples: ["red", "#FF9330", :color_axis], default: "#FF9330" - - attr :"phx-click", :any, default: nil - attr :"phx-target", :any, default: nil - - # TODO: - # support for several groups of bars - - def bar_plot(assigns) do + def x_axis_label(%{position: :top} = assigns) do ~H""" - <%= for point <- GraphDataset.to_graph_points(@dataset, @x, @y) do %> - - <% end %> + + {render_slot(@inner_block)} + """ end - defp bar_style(:round), do: "round" - defp bar_style(:square), do: "butt" - @doc """ - Tooltip + Y-axis labels along the left or right side of the graph. + + See `y_axis_label/1` for more details on the accepeted attributes. """ @doc type: :component - attr :dataset, :any, required: true - attr :point_id, :any, required: true - attr :x, :atom, default: :x, doc: "The dataset axis key to use for x values" - attr :y, :atom, default: :y, doc: "The dataset axis key to use for y values" - - attr :x_pixel, :any, required: true - attr :y_pixel, :any, required: true - - attr :"phx-click-away", :any - attr :"phx-target", :any, default: nil + attr :axis, YAxis, required: true + attr :ticks, :any + attr :step, :any + attr :start, :any + attr :rest, :global, include: ~w(gap rotation position) ++ @svg_presentation_globals slot :inner_block, required: true - def tooltip(%{x_pixel: x_pixel, y_pixel: y_pixel} = assigns) do - height = assigns.dataset.dimensions.height - width = assigns.dataset.dimensions.width - - {bubble_classes_lr, caret_classes_lr} = - if x_pixel < width / 2 do - # left half of the graph, move caret and bubble right of point - {"left: #{x_pixel + 10}px;", "left: #{x_pixel + 4}px;"} - else - # right half of the graph, move caret and bubble left of point - {"right: #{width - x_pixel + 10}px;", "right: #{width - x_pixel + 4}px;"} - end - - bubble_classes_tb = - if y_pixel < height / 2 do - # top half of the graph, move bubble up 20px - "top: #{y_pixel - 20}px;" - else - # bottom half of the graph, move bubble below 20px - "bottom: #{height - y_pixel - 20}px;" - end - - assigns = - assign(assigns, - data_point: GraphDataset.get_point(assigns.dataset, assigns.point_id), - bubble_classes_lr: bubble_classes_lr, - caret_classes_lr: caret_classes_lr, - bubble_classes_tb: bubble_classes_tb - ) - + def y_axis_labels(assigns) do ~H""" -
- <%!-- caret --%> -
- - <%!-- bubble --%> -
- {render_slot(@inner_block, @data_point.original)} -
-
+ <.y_axis_label + :for={value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step, :start]))} + axis={@axis} + value={value} + {@rest} + > + {render_slot(@inner_block, value)} + """ end @doc """ - One-dimensional shaded areas, either horizontal or vertical + A Y-axis label at the left or right side of the graph. """ @doc type: :component - attr :dataset, :any, required: true - - attr :area, :atom, required: true, doc: "The dataset axis key to use for the area plots" - attr :color, :atom, required: true, doc: "The dataset axis key to use for colors" - - attr :orientation, :atom, values: [:vertical, :horizontal], default: :horizontal - - attr :"phx-click", :any, default: nil - attr :"phx-target", :any, default: nil - - def area_plot(%{orientation: :horizontal} = assigns) do - ~H""" - <%= for [scalar1, scalar2] <- area_points(@dataset, @area, @orientation), rect_color = GraphDataset.to_color(@dataset, @color, scalar1.data_point) do %> - - <% end %> - """ - end - - def area_plot(%{orientation: :vertical} = assigns) do - ~H""" - <%= for [scalar1, scalar2] <- area_points(@dataset, @area, @orientation), rect_color = GraphDataset.to_color(@dataset, @color, scalar1.data_point) do %> - - <% end %> - """ - end - - defp area_points(%GraphDataset{} = graph_dataset, key, :horizontal) do - graph_dataset - |> GraphDataset.to_graph_xs(key) - |> Enum.chunk_every(2, 1, :discard) - end - - defp area_points(%GraphDataset{} = graph_dataset, key, :vertical) do - graph_dataset - |> GraphDataset.to_graph_ys(key) - |> Enum.chunk_every(2, 1, :discard) - end - - attr :dimensions, :map, required: true - attr :y_pixel, :float, required: true, doc: "Y pixel value for rendering this label" - attr :position, :atom, required: true, values: [:left, :right] - - attr :color, :string, required: true - attr :style, :string, default: "font-size: 0.75rem; line-height: 1rem" + attr :axis, YAxis, required: true + attr :value, :any, required: true + attr :position, :atom, values: [:left, :right], default: :left + attr :gap, :integer, default: 16 attr :rotation, :integer, default: nil + attr :"dominant-baseline", :any, default: "middle" + attr :"text-anchor", :any, default: nil + attr :rest, :global, include: @svg_presentation_globals slot :inner_block, required: true - defp y_label(%{position: :left} = assigns) do + def y_axis_label(%{position: :left} = assigns) do ~H""" {render_slot(@inner_block)} """ end - defp y_label(%{position: :right} = assigns) do + def y_axis_label(%{position: :right} = assigns) do ~H""" {render_slot(@inner_block)} """ end - attr :dimensions, :map, required: true - attr :x_pixel, :float, required: true, doc: "X pixel value for rendering this label" - attr :position, :atom, required: true, values: [:top, :bottom] - - attr :color, :string, required: true - attr :style, :string, default: "font-size: 0.75rem; line-height: 1rem" - attr :rotation, :integer, default: nil + @doc """ + X-axis grid lines. + """ + @doc type: :component - slot :inner_block, required: true + attr :axis, XAxis, required: true + attr :ticks, :any + attr :step, :any + attr :start, :any + attr :rest, :global, include: @svg_presentation_globals - defp x_label(%{position: :bottom} = assigns) do + def x_axis_grid_lines(assigns) do ~H""" - - {render_slot(@inner_block)} - + <.x_axis_grid_line + :for={value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step, :start]))} + axis={@axis} + value={value} + {@rest} + /> """ end - defp x_label(%{position: :top} = assigns) do + @doc """ + A single X-axis grid line. + """ + @doc type: :component + + attr :axis, XAxis, required: true + attr :value, :any, required: true + attr :top_overdraw, :integer, default: 0 + attr :bottom_overdraw, :integer, default: 0 + attr :rest, :global, include: @svg_presentation_globals + + def x_axis_grid_line(assigns) do ~H""" - - {render_slot(@inner_block)} - + """ end - attr :dimensions, :map, required: true - attr :y_pixel, :float, required: true - attr :width, :string, required: true + @doc """ + Y-axis grid lines. + """ + @doc type: :component - attr :line_style, :atom, values: [:solid, :dashed, :dotted], default: :solid - attr :color, :string, required: true + attr :axis, YAxis, required: true + attr :ticks, :any + attr :step, :any + attr :start, :any + attr :rest, :global, include: @svg_presentation_globals - defp horizontal_line(assigns) do + def y_axis_grid_lines(assigns) do ~H""" - """ end - attr :dimensions, :map, required: true - attr :x_pixel, :float, required: true - attr :width, :string, required: true + @doc """ + A single Y-axis grid line. + """ + @doc type: :component - attr :line_style, :atom, values: [:solid, :dashed, :dotted], default: :solid - attr :color, :string, required: true + attr :axis, YAxis, required: true + attr :value, :any, required: true + attr :rest, :global, include: @svg_presentation_globals - defp vertical_line(assigns) do + def y_axis_grid_line(assigns) do ~H""" """ end @doc """ - A horizontal or vertical marker line with a label + Draws a SVG `` element connecting a series of points. """ @doc type: :component - attr :at, :any, required: true - attr :scale, :any, required: true - attr :width, :string, default: "1.5" - attr :orientation, :atom, values: [:vertical, :horizontal], default: :vertical - - attr :line_style, :atom, values: [:solid, :dashed, :dotted], default: :dotted - attr :line_color, :string, default: "#18191A" - attr :label_color, :string, default: "#18191A" - attr :label_style, :string, default: "font-size: 0.75rem; line-height: 1rem" - attr :label_rotation, :integer, default: nil + attr :points, :any, required: true, doc: "String of coordinates (x1,y1 x2,y2...) or list of {x, y} tuples" + attr :fill, :any, default: "none" + attr :rest, :global, include: @svg_presentation_globals - slot :inner_block, required: true + def polyline(%{points: points} = assigns) when is_binary(points), do: do_polyline(assigns) - def marker(%{orientation: :vertical} = assigns) do - x_pixel = GraphScale.to_graph_x(assigns.scale, assigns.at) - assigns = assign(assigns, dimensions: assigns.scale.dimensions, x_pixel: x_pixel) + def polyline(assigns) do + points = + assigns.points + |> Enum.map(fn {x, y} -> %{x: x, y: y} end) + |> polyline_points() - ~H""" - - - {render_slot(@inner_block)} - - """ + assigns + |> assign(points: points) + |> do_polyline() end - def marker(%{orientation: :horizontal} = assigns) do - y_pixel = GraphScale.to_graph_y(assigns.scale, assigns.at) - assigns = assign(assigns, dimensions: assigns.scale.dimensions, y_pixel: y_pixel) - + defp do_polyline(assigns) do ~H""" - - - {render_slot(@inner_block)} - + """ end @doc """ - Legend row + Draws a SVG `` element connecting a series of points in the form of a stepped line. """ @doc type: :component - slot :inner_block, required: true + attr :points, :any, required: true, doc: "String of coordinates (x1,y1 x2,y2...) or list of {x, y} tuples" + attr :fill, :any, default: "none" + attr :rest, :global, include: @svg_presentation_globals + + def step_polyline(%{points: points} = assigns) when is_binary(points) do + points = + points + |> String.split(" ") + |> Enum.map(fn point -> + [x, y] = String.split(point, ",") + %{x: String.to_integer(x), y: String.to_integer(y)} + end) + |> step_line_points() + + assigns + |> assign(points: points) + |> do_polyline() + end - def legend(assigns) do - ~H""" -
- {render_slot(@inner_block)} -
- """ + def step_polyline(assigns) do + points = + assigns.points + |> Enum.map(fn {x, y} -> %{x: x, y: y} end) + |> step_line_points() + + assigns + |> assign(points: points) + |> do_polyline() end + defp step_line_points(points) do + points + |> Enum.chunk_every(2, 1) + |> Enum.flat_map(fn + [point1, point2] -> [point1, %{point2 | y: point1.y}] + [point] -> [point] + end) + |> polyline_points() + end + + defp polyline_points(points), do: Enum.map_join(points, " ", &"#{&1.x},#{&1.y}") + @doc """ - Legend item + Draws a single or set of SVG `` elements. """ @doc type: :component - attr :color, :string, required: true - attr :label, :string, required: true + # TODO: I wonder if we can more dynamically determine all "dynamic"-possible attributes + attr :cx, :any, required: true + attr :cy, :any, required: true + attr :r, :any, required: true + attr :fill, :any, default: nil + attr :stroke, :any, default: nil + attr :"stroke-width", :any, default: nil + attr :rest, :global, include: @svg_presentation_globals - def legend_item(assigns) do + def circle(assigns) do ~H""" -
- <.color_bubble color={@color} /> -

{@label}

-
+ """ end @doc """ - A colored circle for legends + Returns a list of tuples for use in polyline or other SVG elements. + + ## Example + + iex> Plox.points(1, 2) + [{1, 2}] + + iex> Plox.points([1, 2], [3, 4]) + [{1, 3}, {2, 4}] + + iex> dataset = %Plox.Dataset{ + ...> data: [%{x: 10, y: 20}, %{x: 30, y: 40}], + ...> axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}} + ...>} + iex> Plox.points([1, 2], dataset[:x]) + [{1, 10}, {2, 30}] + + iex> dataset = %Plox.Dataset{ + ...> data: [%{x: 10, y: 20}, %{x: 30, y: 40}], + ...> axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}} + ...>} + iex> Plox.points(dataset[:x], dataset[:y]) + [{10, 20}, {30, 40}] """ - @doc type: :component + def points(x, y) do + values([x, y]) + end - attr :color, :string, required: true + @doc """ + Returns a list of tuples for use in polyline or other SVG elements. - def color_bubble(assigns) do - ~H""" -
- """ + ## Example + + iex> Plox.values([1, 2]) + [{1, 2}] + + iex> Plox.values([1, 2], [3, 4]) + [{1, 3}, {2, 4}] + + iex> dataset = %Plox.Dataset{ + ...> data: [%{x: 10, y: 20}, %{x: 30, y: 40}], + ...> axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}} + ...>} + iex> Plox.values([[1, 2], dataset[:x]]) + [{1, 10}, {2, 30}] + + iex> dataset = %Plox.Dataset{ + ...> data: [%{x: 10, y: 20}, %{x: 30, y: 40}], + ...> axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}} + ...>} + iex> Plox.values([dataset[:x], dataset[:y]]) + [{10, 20}, {30, 40}] + """ + def values(data) do + if Enum.any?(data, &Enumerable.impl_for/1) do + data + |> Enum.map(fn value -> + if Enumerable.impl_for(value) do + value + else + Stream.repeatedly(fn -> value end) + end + end) + |> Enum.zip() + else + [List.to_tuple(data)] + end end - defp stroke_dasharray(:solid), do: false - defp stroke_dasharray(:dotted), do: "2" - defp stroke_dasharray(:dashed), do: "6" - - defdelegate to_graph(scales_and_datasets), to: Graph, as: :new - defdelegate date_scale(range), to: DateScale, as: :new - defdelegate datetime_scale(first, last), to: DateTimeScale, as: :new - defdelegate number_scale(first, last), to: NumberScale, as: :new - defdelegate fixed_colors_scale(color_mapping), to: FixedColorsScale, as: :new - defdelegate fixed_values_scale(values), to: FixedValuesScale, as: :new - defdelegate dataset(data, aces), to: Dataset, as: :new + # @doc """ + # Bar plot. + # """ + # @doc type: :component + + # attr :dataset, :any, required: true + + # attr :x, :atom, default: :x, doc: "The dataset axis key to use for x values" + # attr :y, :atom, default: :y, doc: "The dataset axis key to use for y values" + + # attr :width, :string, examples: ["1.5", "4"], default: "16" + # attr :bar_style, :atom, values: [:round, :square], default: :round + # attr :color, :any, examples: ["red", "#FF9330", :color_axis], default: "#FF9330" + + # attr :"phx-click", :any, default: nil + # attr :"phx-target", :any, default: nil + + # # TODO: + # # support for several groups of bars + + # def bar_plot(assigns) do + # ~H""" + # <%= for point <- GraphDataset.to_graph_points(@dataset, @x, @y) do %> + # + # <% end %> + # """ + # end + + # defp bar_style(:round), do: "round" + # defp bar_style(:square), do: "butt" + + # @doc """ + # Tooltip. + # """ + # @doc type: :component + + # attr :dataset, :any, required: true + # attr :point_id, :any, required: true + # attr :x, :atom, default: :x, doc: "The dataset axis key to use for x values" + # attr :y, :atom, default: :y, doc: "The dataset axis key to use for y values" + + # attr :x_pixel, :any, required: true + # attr :y_pixel, :any, required: true + + # attr :"phx-click-away", :any + # attr :"phx-target", :any, default: nil + + # slot :inner_block, required: true + + # def tooltip(%{x_pixel: x_pixel, y_pixel: y_pixel} = assigns) do + # height = assigns.dataset.dimensions.height + # width = assigns.dataset.dimensions.width + + # {bubble_classes_lr, caret_classes_lr} = + # if x_pixel < width / 2 do + # # left half of the graph, move caret and bubble right of point + # {"left: #{x_pixel + 10}px;", "left: #{x_pixel + 4}px;"} + # else + # # right half of the graph, move caret and bubble left of point + # {"right: #{width - x_pixel + 10}px;", "right: #{width - x_pixel + 4}px;"} + # end + + # bubble_classes_tb = + # if y_pixel < height / 2 do + # # top half of the graph, move bubble up 20px + # "top: #{y_pixel - 20}px;" + # else + # # bottom half of the graph, move bubble below 20px + # "bottom: #{height - y_pixel - 20}px;" + # end + + # assigns = + # assign(assigns, + # data_point: GraphDataset.get_point(assigns.dataset, assigns.point_id), + # bubble_classes_lr: bubble_classes_lr, + # caret_classes_lr: caret_classes_lr, + # bubble_classes_tb: bubble_classes_tb + # ) + + # ~H""" + #
+ # <%!-- caret --%> + #
+ + # <%!-- bubble --%> + #
+ # {render_slot(@inner_block, @data_point.original)} + #
+ #
+ # """ + # end + + # @doc """ + # One-dimensional shaded areas, either horizontal or vertical. + # """ + # @doc type: :component + + # attr :dataset, :any, required: true + + # attr :area, :atom, required: true, doc: "The dataset axis key to use for the area plots" + # attr :color, :atom, required: true, doc: "The dataset axis key to use for colors" + + # attr :orientation, :atom, values: [:vertical, :horizontal], default: :horizontal + + # attr :"phx-click", :any, default: nil + # attr :"phx-target", :any, default: nil + + # def area_plot(%{orientation: :horizontal} = assigns) do + # ~H""" + # <%= for [scalar1, scalar2] <- area_points(@dataset, @area, @orientation), rect_color = GraphDataset.to_color(@dataset, @color, scalar1.data_point) do %> + # + # <% end %> + # """ + # end + + # def area_plot(%{orientation: :vertical} = assigns) do + # ~H""" + # <%= for [scalar1, scalar2] <- area_points(@dataset, @area, @orientation), rect_color = GraphDataset.to_color(@dataset, @color, scalar1.data_point) do %> + # + # <% end %> + # """ + # end + + # defp area_points(%GraphDataset{} = graph_dataset, key, :horizontal) do + # graph_dataset + # |> GraphDataset.to_graph_xs(key) + # |> Enum.chunk_every(2, 1, :discard) + # end + + # defp area_points(%GraphDataset{} = graph_dataset, key, :vertical) do + # graph_dataset + # |> GraphDataset.to_graph_ys(key) + # |> Enum.chunk_every(2, 1, :discard) + # end + + # @doc """ + # Legend row. + # """ + # @doc type: :component + + # slot :inner_block, required: true + + # def legend(assigns) do + # ~H""" + #
+ # {render_slot(@inner_block)} + #
+ # """ + # end + + # @doc """ + # Legend item. + # """ + # @doc type: :component + + # attr :color, :string, required: true + # attr :label, :string, required: true + + # def legend_item(assigns) do + # ~H""" + #
+ # <.color_bubble color={@color} /> + #

{@label}

+ #
+ # """ + # end + + # @doc """ + # A colored circle for legends. + # """ + # @doc type: :component + + # attr :color, :string, required: true + + # def color_bubble(assigns) do + # ~H""" + #
+ # """ + # end end diff --git a/lib/plox/axis.ex b/lib/plox/axis.ex new file mode 100644 index 0000000..791bb54 --- /dev/null +++ b/lib/plox/axis.ex @@ -0,0 +1,23 @@ +defmodule Plox.Axis do + @moduledoc false + defmacro __using__(_opts) do + quote do + @behaviour Access + + @impl Access + def fetch(axis, value) do + {:ok, Plox.Axis.Protocol.to_graph(axis, value)} + end + + @impl Access + def pop(_axis, _key) do + raise "Not implemented" + end + + @impl Access + def get_and_update(_axis, _key, _function) do + raise "Not implemented" + end + end + end +end diff --git a/lib/plox/axis/protocol.ex b/lib/plox/axis/protocol.ex new file mode 100644 index 0000000..7309d17 --- /dev/null +++ b/lib/plox/axis/protocol.ex @@ -0,0 +1,24 @@ +defprotocol Plox.Axis.Protocol do + @moduledoc """ + A protocol for graph axes. Requires axes to implement a method to convert scale values + to graphable values. + """ + + @typedoc """ + Any struct that implements this protocol + + Built in implementations are: + + * `Plox.ColorAxis` + * `Plox.LinearAxis` + * `Plox.XAxis` + * `Plox.YAxis` + """ + @type t :: any() + + @doc """ + Converts a specific scale value to a graphable value. + """ + @spec to_graph(axis :: t(), any()) :: any() + def to_graph(axis, value) +end diff --git a/lib/plox/box.ex b/lib/plox/box.ex index 8a30513..4d5d6a3 100644 --- a/lib/plox/box.ex +++ b/lib/plox/box.ex @@ -1,11 +1,33 @@ defmodule Plox.Box do @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented + Data structure for defining a rectangular box with top, right, bottom, and + left sides. """ defstruct [:top, :right, :bottom, :left] + @doc """ + Creates a new `Plox.Box` struct from a string, number, or tuple. This is + useful for specifying margins and paddings in a format similar to CSS margin + and padding properties. + + ## Examples + + iex> Plox.Box.new(10) + %Plox.Box{top: 10, right: 10, bottom: 10, left: 10} + + iex> Plox.Box.new({5, 15}) + %Plox.Box{top: 5, right: 15, bottom: 5, left: 15} + + iex> Plox.Box.new({5, 15, 10}) + %Plox.Box{top: 5, right: 15, bottom: 10, left: 15} + + iex> Plox.Box.new({5, 15, 10, 20}) + %Plox.Box{top: 5, right: 15, bottom: 10, left: 20} + + iex> Plox.Box.new("5 15 10 20") + %Plox.Box{top: 5, right: 15, bottom: 10, left: 20} + """ def new(string) when is_binary(string) do string |> String.split(" ", trim: true) diff --git a/lib/plox/color_axis.ex b/lib/plox/color_axis.ex new file mode 100644 index 0000000..c92b5df --- /dev/null +++ b/lib/plox/color_axis.ex @@ -0,0 +1,56 @@ +defmodule Plox.ColorAxis do + @moduledoc """ + ColorAxis converts `Plox.ColorScale` values to graphable colors. + + This module implements the `Access` behaviour, allowing access to graphable + values using the `[]` syntax: + + iex> color_scale = Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + iex> color_axis = Plox.ColorAxis.new(color_scale) + iex> color_axis[:green] + "#00ff00" + + This is useful when rendering graph elements in a more intuitive way: + + + <.circle cx={50.0} cy={75.0} fill={color_axis[:red]} r="3" /> + + This module implements the `Plox.Axis.Protocol` and defines the `Plox.Axis.Protocol.to_graph/2` + function. This function is called by the `Access` behaviour: + + iex> color_scale = Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + iex> color_axis = Plox.ColorAxis.new(color_scale) + iex> color_axis[:blue] == Plox.Axis.Protocol.to_graph(color_axis, :blue) + true + """ + + use Plox.Axis + + alias Plox.ColorScale + + defstruct [:scale] + + @doc """ + Creates a new `Plox.ColorAxis` struct. + + Accepts a `Plox.ColorScale` struct. + + ## Example + + iex> color_scale = Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + iex> Plox.ColorAxis.new(color_scale) + %Plox.ColorAxis{scale: color_scale} + """ + def new(scale) do + %__MODULE__{scale: scale} + end + + defimpl Plox.Axis.Protocol do + @doc """ + Converts the given `value` to a graphable color. + """ + def to_graph(%{scale: scale}, value) do + ColorScale.convert_to_color(scale, value) + end + end +end diff --git a/lib/plox/data_point.ex b/lib/plox/data_point.ex index 169d4df..7807bec 100644 --- a/lib/plox/data_point.ex +++ b/lib/plox/data_point.ex @@ -1,36 +1,48 @@ defmodule Plox.DataPoint do - @moduledoc false - # TODO: I dunno about docs yet - - alias Plox.GraphPoint - alias Plox.GraphScalar - alias Plox.GraphScale - - defstruct [:id, :original, :mapped] - - def new(id, original, mapped) do - %__MODULE__{id: id, original: original, mapped: mapped} - end - - def to_graph_point(%__MODULE__{} = data_point, x_scale, x_key, y_scale, y_key) do - x_value = data_point.mapped[x_key] - y_value = data_point.mapped[y_key] - - x = GraphScale.to_graph_x(x_scale, x_value) - y = GraphScale.to_graph_y(y_scale, y_value) - - GraphPoint.new(x, y, data_point) - end - - def to_graph_x(%__MODULE__{} = data_point, scale, key) do - scale - |> GraphScale.to_graph_x(data_point.mapped[key]) - |> GraphScalar.new(data_point) - end - - def to_graph_y(%__MODULE__{} = data_point, scale, key) do - scale - |> GraphScale.to_graph_y(data_point.mapped[key]) - |> GraphScalar.new(data_point) + @moduledoc """ + Data structure for containing raw data and its mapped values for graphing. + + Calculated by `Plox.Dataset.new/2` when processing the raw data: + + iex> data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + iex> dimensions = Plox.Dimensions.new(100, 100, margin: 0) + iex> scale = Plox.NumberScale.new(0, 10) + iex> x_axis = Plox.XAxis.new(scale, dimensions) + iex> Plox.Dataset.new(data, %{x: {x_axis, & &1.foo}}) + %Plox.Dataset{ + data: [ + %Plox.DataPoint{original: %{foo: 1, bar: 2}, graph: %{x: 10.0}}, + %Plox.DataPoint{original: %{foo: 2, bar: 3}, graph: %{x: 20.0}} + ], + axes: %{ + x: %Plox.XAxis{ + scale: Plox.NumberScale.new(0.0, 10.0), + dimensions: %Plox.Dimensions{ + width: 100, + height: 100, + margin: %Plox.Box{top: 0, right: 0, bottom: 0, left: 0}, + padding: %Plox.Box{top: 0, right: 0, bottom: 0, left: 0} + } + } + } + } + """ + + defstruct [:original, :graph] + + @doc """ + Creates a new `Plox.DataPoint` struct. + + Accepts the original data and a map of graphable values for each axis. + + ## Example + + iex> original = %{foo: 1, bar: 2} + iex> graph = %{x: 10.0, y: 20.0} + iex> Plox.DataPoint.new(original, graph) + %Plox.DataPoint{original: %{foo: 1, bar: 2}, graph: %{x: 10.0, y: 20.0}} + """ + def new(original, graph) do + %__MODULE__{original: original, graph: graph} end end diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index 6170b7e..6859718 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -1,28 +1,122 @@ +defmodule Plox.DatasetAxis do + @moduledoc false + @behaviour Access + + defstruct [:dataset, :axis, :key] + + @impl Access + def fetch(%__MODULE__{axis: axis}, value) do + {:ok, axis[value]} + end + + @impl Access + def pop(_axis, _key) do + raise "Not implemented" + end + + @impl Access + def get_and_update(_axis, _key, _function) do + raise "Not implemented" + end + + defimpl Enumerable do + def slice(_) do + {:error, Plox.DatasetAxis} + end + + def member?(_, _) do + {:error, Plox.DatasetAxis} + end + + def count(_) do + {:error, Plox.DatasetAxis} + end + + def reduce(dataset_axis, acc, fun) do + dataset_axis.dataset.data + |> Enum.map(& &1.graph[dataset_axis.key]) + |> Enumerable.List.reduce(acc, fun) + end + end +end + defmodule Plox.Dataset do @moduledoc """ - A collection of data points and some metadata for a graph + A collection of `Plox.DataPoint`s and `Plox.Axis` implementations to convert + the `Plox.DataPoint`s to graphable values. + + This module implements the `Access` behaviour, allowing access to each axis + using the `[]` syntax: + + iex> dataset = %Plox.Dataset{data: [], axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}}} + iex> dataset[:x] + %Plox.DatasetAxis{axis: %Plox.XAxis{}, key: :x} + + iex> dataset = %Plox.Dataset{data: [], axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}}} + iex> dataset[:y] + %Plox.DatasetAxis{axis: %Plox.YAxis{}, key: :y} + + Since `Plox.Axis` also implements the `Access` behaviour, you can access + the graphable values more ergonomically when rendering elements in a graph: + + + <.circle cx={@dataset[:x]} cy={@dataset[:y][40]} fill="red" r="3" /> """ + @behaviour Access + alias Plox.DataPoint - defstruct [:data, :scales] + defstruct [:data, :axes] + + @doc """ + Creates a new `Plox.Dataset` struct. - def new(original_data, axes) do - scales = Map.new(axes, fn {key, {scale, _fun}} -> {key, scale} end) + Accepts an enumerable of raw data and a mapping of axis keys to tuples + containing the axis and a function to extract the value from the raw data. + ## Example + + iex> data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + iex> axis_fns = %{x: {%Plox.XAxis{}, & &1.foo}, y: {%Plox.YAxis{}, & &1.bar}} + iex> dataset = Plox.Dataset.new(data, axis_fns) + %Plox.Dataset{ + data: [ + %Plox.DataPoint{original: %{foo: 1, bar: 2}, graph: %{x: ..., y: ...}}, + %Plox.DataPoint{original: %{foo: 2, bar: 3}, graph: %{x: ..., y: ...}} + ], + axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}} + } + """ + def new(original_data, axis_fns) do data = - original_data - |> Enum.with_index() - |> Enum.map(fn {original, idx} -> - id = Map.get(original, :id, idx) + Enum.map(original_data, fn original -> + graph = Map.new(axis_fns, fn {key, {axis, fun}} -> {key, axis[fun.(original)]} end) - mapped = - Map.new(axes, fn {key, {_scale, fun}} -> - {key, fun.(original)} - end) + DataPoint.new(original, graph) + end) - DataPoint.new(id, original, mapped) + axes = + Map.new(axis_fns, fn {key, {axis, _fun}} -> + {key, axis} end) - %__MODULE__{data: data, scales: scales} + %__MODULE__{data: data, axes: axes} + end + + @impl Access + def fetch(%__MODULE__{} = dataset, key) do + with {:ok, axis} <- Map.fetch(dataset.axes, key) do + {:ok, %Plox.DatasetAxis{dataset: dataset, axis: axis, key: key}} + end + end + + @impl Access + def pop(_dataset, _key) do + raise "Not implemented" + end + + @impl Access + def get_and_update(_dataset, _key, _function) do + raise "Not implemented" end end diff --git a/lib/plox/date_scale.ex b/lib/plox/date_scale.ex index 4b901b4..e1334d3 100644 --- a/lib/plox/date_scale.ex +++ b/lib/plox/date_scale.ex @@ -1,18 +1,46 @@ defmodule Plox.DateScale do @moduledoc """ - A scale for elixir `Date` values + A scale of date values (`t:Date.t/0`). This struct implements the `Plox.Scale` protocol. + + `Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable: + + iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1)) + iex> scale |> Plox.Scale.values(%{step: 2}) |> Enum.to_list() + [~D[2020-01-01], ~D[2020-01-03], ~D[2020-01-05], ~D[2020-01-07], ~D[2020-01-09]] + + iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-10], ~D[2020-01-01], -1)) + iex> scale |> Plox.Scale.values(%{step: 3}) |> Enum.to_list() + [~D[2020-01-10], ~D[2020-01-07], ~D[2020-01-04], ~D[2020-01-01]] + + `Plox.Scale.convert_to_range/3` returns a number in the given range: + + iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-09], 1)) + iex> Plox.Scale.convert_to_range(scale, ~D[2020-01-05], 0..100) + 50.0 + + iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-09], ~D[2020-01-01], -1)) + iex> Plox.Scale.convert_to_range(scale, ~D[2020-01-07], 0..100) + 25.0 """ defstruct [:range] @type t :: %__MODULE__{} @doc """ - Creates a new `Plox.DateScale` struct + Creates a new `Plox.DateScale` struct. + + Raises if `range` is not a `t:Date.Range.t/0` struct or if it does not contain at least + two dates. The step is ignored. Supports forward and backward ranges. - Accepts an elixir `Date.Range` struct. The range must contain at least two - dates. The step is ignored. Supports forward and backward ranges. + ## Example + + iex> Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1)) + %Plox.DateScale{range: Date.range(~D[2020-01-01], ~D[2020-01-10], 1)} + + iex> Plox.DateScale.new(Date.range(~D[2020-01-10], ~D[2020-01-01], -1)) + %Plox.DateScale{range: Date.range(~D[2020-01-10], ~D[2020-01-01], -1)} """ @spec new(range :: Date.Range.t()) :: t() def new(%Date.Range{} = range) do @@ -26,19 +54,36 @@ defmodule Plox.DateScale do %__MODULE__{range: range} end - defp reduce_step(%Date.Range{step: step} = range) when step > 0, do: Date.range(range.first, range.last, 1) + def new(_range) do + raise ArgumentError, + message: "Invalid DateScale: must be a Date.Range struct with at least two dates" + end + defp reduce_step(%Date.Range{step: step} = range) when step > 0, do: Date.range(range.first, range.last, 1) defp reduce_step(%Date.Range{step: step} = range) when step < 0, do: Date.range(range.first, range.last, -1) defimpl Plox.Scale do + @doc """ + Returns a `t:Date.Range.t/0` of all `t:Date.t/0` values in the scale, + stepping by the given interval. + + Accepts a `step` option as a number of days. The default step is 1 day. + Raises if given `step` is not a positive integer greater than 1. + """ def values(scale, opts) do case Map.fetch(opts, :step) do :error -> scale.range + {:ok, step} when step < 1 -> raise ArgumentError, message: "Step must be a positive integer" {:ok, step} when scale.range.step > 0 -> %{scale.range | step: step} {:ok, step} when scale.range.step < 0 -> %{scale.range | step: -step} end end + @doc """ + Converts a date `value` from the scale to a number in the given `to_range`. + + Raises if `value` is not a valid date included in the scale. + """ def convert_to_range(scale, %Date{} = value, to_range) do range = scale.range diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 9d0d35b..5ca70d4 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -1,8 +1,24 @@ defmodule Plox.DateTimeScale do @moduledoc """ - A scale made of elixir `DateTime` or `NaiveDateTime` values + A scale of datetime values (`t:DateTime.t/0` or `t:NaiveDateTime.t/0`). This struct implements the `Plox.Scale` protocol. + + `Plox.Scale.values/2` returns a list of all datetime values: + + iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00]) + iex> Plox.Scale.values(scale) + [~N[2019-01-01 00:00:00], ~N[2019-01-01 00:01:00], ~N[2019-01-01 00:02:00], ~N[2019-01-01 00:03:00]] + + iex> scale = Plox.DateTimeScale.new(~U[2019-01-01 00:00:00Z], ~U[2019-01-03 00:00:00Z]) + iex> Plox.Scale.values(scale, %{step: {1, :day}}) + [~U[2019-01-01 00:00:00Z], ~U[2019-01-02 00:00:00Z], ~U[2019-01-03 00:00:00Z]] + + `Plox.Scale.convert_to_range/3` returns a number in the given range: + + iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + iex> Plox.Scale.convert_to_range(scale, ~N[2019-01-02 00:00:00], 0..100) + 50.0 """ require Logger @@ -12,10 +28,19 @@ defmodule Plox.DateTimeScale do @type datetime :: DateTime.t() | NaiveDateTime.t() @doc """ - Creates a new `Plox.DateTimeScale` struct + Creates a new `Plox.DateTimeScale` struct. - Accepts 2 elixir `DateTime` or `NaiveDateTime` structs as `first` and `last`. + Accepts 2 datetime structs as `first` and `last` (`t:DateTime.t/0` or `t:NaiveDateTime.t/0`). + Raises if `first` and `last` are not the same struct or if `first` is not before `last`. Negative ranges are not currently supported. + + ## Example + + iex> Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + %Plox.DateTimeScale{first: ~N[2019-01-01 00:00:00], last: ~N[2019-01-03 00:00:00]} + + iex> Plox.DateTimeScale.new(~U[2019-01-01 00:00:00Z], ~U[2019-01-03 00:00:00Z]) + %Plox.DateTimeScale{first: ~U[2019-01-01 00:00:00Z], last: ~U[2019-01-03 00:00:00Z]} """ @spec new(first :: datetime(), last :: datetime()) :: t() def new(first, last) @@ -23,7 +48,7 @@ defmodule Plox.DateTimeScale do def new(%date_time_module{} = first, %date_time_module{} = last) when date_time_module in [DateTime, NaiveDateTime] do if date_time_module.diff(last, first) <= 0 do raise ArgumentError, - message: "Invalid DateTimeScale: The range must be at least 1 second long and `first` must come before `last`." + message: "Invalid DateTimeScale: The range must be at least 1 second long and `first` must come before `last`" end %__MODULE__{first: first, last: last} @@ -31,10 +56,17 @@ defmodule Plox.DateTimeScale do def new(_first, _last) do raise ArgumentError, - message: "Invalid DateTimeScale: First and last must be DateTime or NaiveDateTime structs." + message: "Invalid DateTimeScale: First and last must both be DateTime or NaiveDateTime structs" end defimpl Plox.Scale do + @doc """ + Returns a list of all `DateTime` or `NaiveDateTime` values in the scale, + stepping by the given interval. + + Accepts a `:step` option, which can be a number of seconds, minutes, hours, + or days. The default step is 60 seconds. + """ def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do scale.first |> Stream.unfold(fn current_dt -> @@ -47,9 +79,7 @@ defmodule Plox.DateTimeScale do |> Enum.to_list() end - def values(scale, opts) do - %{first: %date_time_module{}} = scale - + def values(%{first: %date_time_module{}} = scale, opts) do step_seconds = case Map.get(opts, :step, {60, :second}) do seconds when is_integer(seconds) -> seconds @@ -66,16 +96,23 @@ defmodule Plox.DateTimeScale do end) end - total_seconds = date_time_module.diff(scale.last, scale.first) + first_value = Map.get(opts, :start, scale.first) + + total_seconds = date_time_module.diff(scale.last, first_value) ticks = trunc(total_seconds / step_seconds) 0..ticks - |> Enum.map_reduce(scale.first, fn _i, acc -> + |> Enum.map_reduce(first_value, fn _i, acc -> {acc, date_time_module.add(acc, step_seconds)} end) |> elem(0) end + @doc """ + Converts a datetime `value` from the scale to a number in the given `to_range`. + + Raises if `value` is not a valid datetime included in the scale. + """ def convert_to_range(%{first: %date_time_module{}} = scale, %date_time_module{} = value, to_range) when date_time_module in [DateTime, NaiveDateTime] do if date_time_module.compare(value, scale.first) == :lt or diff --git a/lib/plox/dimensions.ex b/lib/plox/dimensions.ex index 5ad7d78..f65c1d1 100644 --- a/lib/plox/dimensions.ex +++ b/lib/plox/dimensions.ex @@ -1,19 +1,40 @@ defmodule Plox.Dimensions do @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented + Data structure for defining the dimensions of a graph, including width, + height, margin, and padding. """ alias Plox.Box defstruct [:width, :height, :margin, :padding] - def new(attrs) do + @doc """ + Creates a new `Plox.Dimensions` struct. + + Accepts width and height as numbers or strings, and optional keyword + arguments for margin and padding. Default margin is `{35, 70}` and + default padding is `0`. See `Plox.Box` for more information on how + margins and padding may be input. + + ## Example + + iex> Plox.Dimensions.new(800, 600, margin: {20, 30}, padding: 10) + %Plox.Dimensions{ + width: 800, + height: 600, + margin: %Plox.Box{top: 20, right: 30, bottom: 20, left: 30}, + padding: %Plox.Box{top: 10, right: 10, bottom: 10, left: 10} + } + """ + def new(width, height, opts \\ []) do + margin = Keyword.get(opts, :margin, {35, 70}) + padding = Keyword.get(opts, :padding, 0) + %__MODULE__{ - width: number(attrs.width), - height: number(attrs.height), - margin: Box.new(attrs.margin), - padding: Box.new(attrs.padding) + width: number(width), + height: number(height), + margin: Box.new(margin), + padding: Box.new(padding) } end diff --git a/lib/plox/fixed_colors_scale.ex b/lib/plox/fixed_colors_scale.ex index dd46e4c..953d2f5 100644 --- a/lib/plox/fixed_colors_scale.ex +++ b/lib/plox/fixed_colors_scale.ex @@ -2,15 +2,45 @@ defmodule Plox.FixedColorsScale do @moduledoc """ A color scale for mapping a set of known fixed values to a set of known fixed colors. + + This struct implements the `Plox.ColorScale` protocol. + + `Plox.ColorScale.convert_to_color/2` returns the color for a given value: + + iex> scale = Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + iex> Plox.ColorScale.convert_to_color(scale, :red) + "#ff0000" """ defstruct [:mapping] - def new(mapping) do + @doc """ + Creates a new `Plox.FixedColorsScale` struct. + + Raises if the given `mapping` is not a map or contains fewer than two entries. + + ## Example + + iex> Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + %Plox.FixedColorsScale{ + mapping: %{red: "#ff0000", green: "#00ff00", blue: "#0000ff"} + } + """ + def new(mapping) when not is_struct(mapping) and map_size(mapping) >= 2 do %__MODULE__{mapping: mapping} end + def new(_mapping) do + raise ArgumentError, + message: "Invalid FixedColorsScale: must be a map with at least two entries" + end + defimpl Plox.ColorScale do + @doc """ + Converts a given `value` from the scale to its corresponding color. + + Raises if `value` is not a key within the scale. + """ def convert_to_color(scale, value) do case Map.fetch(scale.mapping, value) do {:ok, color} -> diff --git a/lib/plox/fixed_values_scale.ex b/lib/plox/fixed_values_scale.ex index 75b9e03..8682670 100644 --- a/lib/plox/fixed_values_scale.ex +++ b/lib/plox/fixed_values_scale.ex @@ -5,15 +5,43 @@ defmodule Plox.FixedValuesScale do It places the values in the given order with equal distance between them. This struct implements the `Plox.Scale` protocol. + + `Plox.Scale.values/2` returns an enumerable of the `values` in the scale: + + iex> scale = Plox.FixedValuesScale.new([1, 2, 3, 4]) + iex> Plox.Scale.values(scale) + [1, 2, 3, 4] + + `Plox.Scale.convert_to_range/3` returns a number in the given range: + + iex> scale = Plox.FixedValuesScale.new([:a, :b, :c]) + iex> Plox.Scale.convert_to_range(scale, :b, 0..100) + 50.0 """ defstruct [:values, :index_map, :max_index] @type t :: %__MODULE__{} @doc """ - Creates a new `Plox.FixedValuesScale` struct + Creates a new `Plox.FixedValuesScale` struct. - Accepts any enumerable. + Raises if given an enumerable with less than two values. + + ## Example + + iex> Plox.FixedValuesScale.new([1, 2, 3, 4]) + %Plox.FixedValuesScale{ + values: [1, 2, 3, 4], + index_map: %{1 => 0, 2 => 1, 3 => 2, 4 => 3}, + max_index: 3 + } + + iex> Plox.FixedValuesScale.new(["a", "b", "c"]) + %Plox.FixedValuesScale{ + values: ["a", "b", "c"], + index_map: %{"a" => 0, "b" => 1, "c" => 2}, + max_index: 2 + } """ @spec new(values :: Enumerable.t()) :: t() def new(values) do @@ -35,8 +63,16 @@ defmodule Plox.FixedValuesScale do end defimpl Plox.Scale do + @doc """ + Returns an enumerable of the `values` in the scale. + """ def values(scale, _opts), do: scale.values + @doc """ + Converts a given `value` from the scale to a number in the given `to_range`. + + Raises if `value` is not within the scale. + """ def convert_to_range(scale, value, to_range) do case Map.fetch(scale.index_map, value) do {:ok, value_index} -> diff --git a/lib/plox/graph.ex b/lib/plox/graph.ex deleted file mode 100644 index 29915eb..0000000 --- a/lib/plox/graph.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Plox.Graph do - @moduledoc """ - TODO: docs - """ - - alias Plox.Dataset - alias Plox.Dimensions - alias Plox.GraphDataset - alias Plox.GraphScale - - defstruct datasets: %{}, scales: %{}, color_scales: %{}, dimensions: nil - - def new(scales_and_datasets) do - scales = scales_and_datasets |> Keyword.get(:scales, []) |> Map.new() - color_scales = scales_and_datasets |> Keyword.get(:color_scales, []) |> Map.new() - datasets = scales_and_datasets |> Keyword.get(:datasets, []) |> Map.new() - - # TODO: you could theoretically check that all scales that appear in the - # datasets, were also given in the `scales` input - - %__MODULE__{scales: scales, color_scales: color_scales, datasets: datasets} - end - - def put_dimensions(%__MODULE__{} = graph, %Dimensions{} = dimensions) do - %{graph | dimensions: dimensions} - end - - # Access behaviour - - def fetch(%__MODULE__{} = graph, key) do - with :error <- Map.fetch(graph.datasets, key), - :error <- Map.fetch(graph.scales, key) do - raise ArgumentError, - "accessing a graph with graph[key] requires the key to be the ID of a dataset or scale got: #{inspect(key)}" - else - {:ok, %Dataset{} = dataset} -> {:ok, GraphDataset.new(key, dataset, graph.dimensions)} - {:ok, scale} -> {:ok, GraphScale.new(key, scale, graph.dimensions)} - end - end -end diff --git a/lib/plox/graph_dataset.ex b/lib/plox/graph_dataset.ex deleted file mode 100644 index b7043b3..0000000 --- a/lib/plox/graph_dataset.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule Plox.GraphDataset do - @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented - """ - - alias Plox.ColorScale - alias Plox.DataPoint - alias Plox.GraphScale - - defstruct [:id, :dataset, :dimensions] - - def new(id, dataset, dimensions) do - %__MODULE__{id: id, dataset: dataset, dimensions: dimensions} - end - - def get_point(dataset, point_id) do - Enum.find_value(dataset.dataset.data, fn data_point -> - if data_point.id == point_id, do: data_point - end) - end - - def get_scale!(%__MODULE__{dataset: dataset, dimensions: dimensions}, key) do - case Map.fetch(dataset.scales, key) do - {:ok, scale} -> - GraphScale.new(key, scale, dimensions) - - :error -> - raise ArgumentError, - message: "No such scale #{inspect(key)} in dataset #{inspect(dataset)}" - end - end - - def to_graph_points(%__MODULE__{} = dataset, x_key, y_key) do - x_scale = get_scale!(dataset, x_key) - y_scale = get_scale!(dataset, y_key) - - Enum.map(dataset.dataset.data, fn data_point -> - DataPoint.to_graph_point(data_point, x_scale, x_key, y_scale, y_key) - end) - end - - def to_graph_point(%__MODULE__{} = dataset, x_key, y_key, id) do - x_scale = get_scale!(dataset, x_key) - y_scale = get_scale!(dataset, y_key) - - Enum.find_value(dataset.dataset.data, fn data_point -> - if data_point.id == id do - DataPoint.to_graph_point(data_point, x_scale, x_key, y_scale, y_key) - end - end) - end - - def to_graph_xs(%__MODULE__{} = dataset, x_key) do - x_scale = get_scale!(dataset, x_key) - - Enum.map(dataset.dataset.data, fn data_point -> - DataPoint.to_graph_x(data_point, x_scale, x_key) - end) - end - - def to_graph_ys(%__MODULE__{} = dataset, y_key) do - y_scale = get_scale!(dataset, y_key) - - Enum.map(dataset.dataset.data, fn data_point -> - DataPoint.to_graph_y(data_point, y_scale, y_key) - end) - end - - def to_color(%__MODULE__{} = _dataset, color, _data_point) when is_binary(color), do: color - - def to_color(%__MODULE__{} = dataset, key, data_point) when is_atom(key) do - graph_scale = get_scale!(dataset, key) - - ColorScale.convert_to_color(graph_scale.scale, data_point.mapped[key]) - end -end diff --git a/lib/plox/graph_point.ex b/lib/plox/graph_point.ex deleted file mode 100644 index d5e953e..0000000 --- a/lib/plox/graph_point.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Plox.GraphPoint do - @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented - """ - - defstruct [:x, :y, :data_point] - - def new(x, y, data_point) do - %__MODULE__{x: x, y: y, data_point: data_point} - end -end diff --git a/lib/plox/graph_scalar.ex b/lib/plox/graph_scalar.ex deleted file mode 100644 index ace4f09..0000000 --- a/lib/plox/graph_scalar.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Plox.GraphScalar do - @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented - """ - - defstruct [:value, :data_point] - - def new(value, data_point) do - %__MODULE__{value: value, data_point: data_point} - end -end diff --git a/lib/plox/graph_scale.ex b/lib/plox/graph_scale.ex deleted file mode 100644 index 48bd01b..0000000 --- a/lib/plox/graph_scale.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Plox.GraphScale do - @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented - """ - - alias Plox.Scale - - defstruct [:id, :scale, :dimensions] - - def new(id, scale, dimensions) do - %__MODULE__{id: id, scale: scale, dimensions: dimensions} - end - - def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - - def to_graph_x(%__MODULE__{scale: scale, dimensions: dimensions}, value) do - Scale.convert_to_range( - scale, - value, - (dimensions.margin.left + - dimensions.padding.left)..(dimensions.width - - dimensions.margin.right - - dimensions.padding.right) - ) - end - - def to_graph_y(%__MODULE__{scale: scale, dimensions: dimensions}, value) do - Scale.convert_to_range( - scale, - value, - (dimensions.height - - dimensions.margin.bottom - - dimensions.padding.bottom)..(dimensions.margin.top + - dimensions.padding.top) - ) - end -end diff --git a/lib/plox/linear_axis.ex b/lib/plox/linear_axis.ex new file mode 100644 index 0000000..7489de6 --- /dev/null +++ b/lib/plox/linear_axis.ex @@ -0,0 +1,64 @@ +defmodule Plox.LinearAxis do + @moduledoc """ + LinearAxis implements the `Plox.Axis.Protocol` and is used to convert scale values + to graphable values in a linear range between a minimum and maximum. + + This module implements the `Access` behaviour, allowing access to graphable + values using the `[]` syntax: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> linear_axis = %Plox.LinearAxis{scale: scale, min: 0, max: 100} + iex> linear_axis[1] + 10.0 + iex> linear_axis[2] + 20.0 + + This is useful when rendering graph elements in a more intuitive way: + + + <.circle cx={25.0} cy={50.0} fill="red" r={linear_axis[1]} /> + + This module implements the `Plox.Axis.Protocol` and defines the `Plox.Axis.Protocol.to_graph/2` + function. This function is called by the `Access` behaviour: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> linear_axis = Plox.LinearAxis.new(scale, min: 0, max: 100) + iex> linear_axis[1] == Plox.Axis.Protocol.to_graph(linear_axis, 1) + true + """ + + use Plox.Axis + + alias Plox.Scale + + defstruct [:scale, :min, :max] + + @doc """ + Creates a new `Plox.LinearAxis` struct. + + Accepts a `Plox.Scale` struct and `min` and `max` values. + + ## Example + + iex> scale = Plox.NumberScale.new(0, 10) + iex> Plox.LinearAxis.new(scale, min: 0, max: 100) + %Plox.LinearAxis{scale: scale, min: 0, max: 100} + """ + def new(scale, opts \\ []) do + Keyword.validate!(opts, [:min, :max]) + min = Keyword.fetch!(opts, :min) + max = Keyword.fetch!(opts, :max) + + %__MODULE__{scale: scale, min: min, max: max} + end + + defimpl Plox.Axis.Protocol do + @doc """ + Converts the given `value` to a graphable value linearly interpolated + between the `min` and `max` of the axis. + """ + def to_graph(%{scale: scale, min: min, max: max}, value) do + Scale.convert_to_range(scale, value, min..max) + end + end +end diff --git a/lib/plox/number_scale.ex b/lib/plox/number_scale.ex index c587242..6f20202 100644 --- a/lib/plox/number_scale.ex +++ b/lib/plox/number_scale.ex @@ -1,25 +1,52 @@ defmodule Plox.NumberScale do @moduledoc """ - An arbitrary precision number scale + An arbitrary precision number scale. + + Although internally we use `Decimal` for arbitrary precision and accurate + math, this scale expects floats as input and produces floats as output, + which may lead to floating point imprecision. This struct implements the `Plox.Scale` protocol. - Although internally we use `Decimal` for arbitrary precision and accurate - math, this scale expects floats as input and produces floats as output, so - there's still some room for floating point imprecision. + `Plox.Scale.values/2` returns an enumerable of the numerical values in the scale: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> Plox.Scale.values(scale) + [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] + + iex> scale = Plox.NumberScale.new(10, 0) + iex> Plox.Scale.values(scale, %{ticks: 6}) + [10.0, 8.0, 6.0, 4.0, 2.0, 0.0] + + `Plox.Scale.convert_to_range/3` returns a number in the given range: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> Plox.Scale.convert_to_range(scale, 5, 0..100) + 50.0 + + iex> scale = Plox.NumberScale.new(10, 0) + iex> Plox.Scale.convert_to_range(scale, 2, 0..100) + 80.0 """ defstruct [:first, :last, :backwards?] @type t :: %__MODULE__{} @doc """ - Creates a new `Plox.NumberScale` struct + Creates a new `Plox.NumberScale` struct. - Accepts 2 numbers as `first` and `last` as well as the number of `ticks` that - should comprise the scale. The in-between values for these ticks are - dynamically calculated based on `first`, `last`, and `ticks`. + Accepts 2 numbers as `first` and `last` values. Dynamically determines + if the scale is backwards (i.e. `first` is greater than `last`). - `ticks` must be at least `2`. + Raises if given equivalent numbers or if either `first` or `last` is not a number. + + ## Example + + iex> Plox.NumberScale.new(0, 10) + %Plox.NumberScale{first: Decimal.new("0.0"), last: Decimal.new("10.0"), backwards?: false} + + iex> Plox.NumberScale.new(10, 0) + %Plox.NumberScale{first: Decimal.new("10.0"), last: Decimal.new("0.0"), backwards?: true} """ @spec new(first :: number(), last :: number()) :: t() def new(first, last) when is_number(first) and is_number(last) and first != last do @@ -27,21 +54,29 @@ defmodule Plox.NumberScale do last = Decimal.from_float(last / 1.0) backwards? = Decimal.compare(first, last) == :gt - %__MODULE__{ - first: first, - last: last, - backwards?: backwards? - } + %__MODULE__{first: first, last: last, backwards?: backwards?} end def new(_first, _last) do raise ArgumentError, - message: "Invalid NumberScale: First and last must be numbers and cannot be equivalent." + message: "Invalid NumberScale: First and last must be numbers and cannot be equivalent" end defimpl Plox.Scale do - def values(scale, opts \\ %{}) do + @doc """ + Returns an enumerable of the numerical values in the scale. Optionally accepts a + number of `ticks` to specify how many values to return. The in-between values are + dynamically calculated based on `first`, `last`, and `ticks`. + + Raises if `ticks` is less than `2` (default is `11`). + """ + def values(scale, opts) do ticks = Map.get(opts, :ticks, 11) + + if ticks < 2 do + raise ArgumentError, message: "Invalid ticks count `#{ticks}`, must be at least 2" + end + step = scale.last |> Decimal.sub(scale.first) |> Decimal.div(ticks - 1) # we don't compute the last value because it could include rounding errors @@ -53,8 +88,21 @@ defmodule Plox.NumberScale do |> Enum.map(&Decimal.to_float/1) end + @doc """ + Converts a number from the scale to a number in the given `to_range`. The given + `input_value` must be a number inclusively within the `scale` bounds. + + Raises if `input_value` is out of bounds or not a number. + """ def convert_to_range(scale, input_value, to_range) when is_number(input_value) do value = Decimal.from_float(input_value / 1.0) + lower = if scale.backwards?, do: scale.last, else: scale.first + upper = if scale.backwards?, do: scale.first, else: scale.last + + if Decimal.gt?(value, upper) || Decimal.lt?(value, lower) do + raise ArgumentError, + message: "Input value `#{inspect(input_value)}` is out of bounds for `#{inspect(scale)}`" + end value |> Decimal.sub(scale.first) diff --git a/lib/plox/scale.ex b/lib/plox/scale.ex index 2cc1ce0..de254de 100644 --- a/lib/plox/scale.ex +++ b/lib/plox/scale.ex @@ -19,17 +19,17 @@ defprotocol Plox.Scale do @type t :: any() @doc """ - Returns an enumerable of the "labeled values" in a scale + Returns an enumerable of the "labeled values" in a scale. Note: the returned values don't necessarily represent all the values in the scale, just the values meant to be labeled and rendered on the corresponding axis. e.g. the final value might not be equal to the scale's configured max. """ @spec values(scale :: t(), opts :: map()) :: Enumerable.t() - def values(scale, opts) + def values(scale, opts \\ %{}) @doc """ - Converts a specific scale value to a number within the requested range + Converts a specific scale value to a number within the requested range. The destination range must be a valid integer range. """ diff --git a/lib/plox/x_axis.ex b/lib/plox/x_axis.ex new file mode 100644 index 0000000..2809201 --- /dev/null +++ b/lib/plox/x_axis.ex @@ -0,0 +1,68 @@ +defmodule Plox.XAxis do + @moduledoc """ + XAxis implements the `Plox.Axis.Protocol` and is used to convert scale values + to graphable x-coordinates. + + This module implements the `Access` behaviour, allowing access to graphable + values using the `[]` syntax: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> dimensions = Plox.Dimensions.new(100, 100, margin: 0) + iex> x_axis = %Plox.XAxis{scale: scale, dimensions: dimensions} + iex> x_axis[1] + 10.0 + iex> x_axis[2] + 20.0 + + This is useful when rendering graph elements in a more intuitive way: + + + <.circle cx={x_axis[1]} cy={50.0} fill="red" r="3" /> + + This module implements the `Plox.Axis.Protocol` and defines the `Plox.Axis.Protocol.to_graph/2` + function. This function is called by the `Access` behaviour: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> dimensions = Plox.Dimensions.new(100, 100, margin: 0) + iex> x_axis = Plox.XAxis.new(scale, dimensions) + iex> x_axis[1] == Plox.Axis.Protocol.to_graph(x_axis, 1) + true + """ + + use Plox.Axis + + alias Plox.Scale + + defstruct [:scale, :dimensions] + + @doc """ + Creates a new `Plox.XAxis` struct. + + Accepts a `Plox.Scale` struct and `Plox.Dimensions` struct. + + ## Example + + iex> scale = Plox.NumberScale.new(0, 10) + iex> dimensions = Plox.Dimensions.new(100, 100) + iex> Plox.XAxis.new(scale, dimensions) + %Plox.XAxis{scale: scale, dimensions: dimensions} + """ + def new(scale, dimensions) do + %__MODULE__{scale: scale, dimensions: dimensions} + end + + defimpl Plox.Axis.Protocol do + @doc """ + Converts the given `value` to a graphable x-coordinate. + """ + def to_graph(%{scale: scale, dimensions: dimensions}, value) do + range = + Range.new( + dimensions.margin.left + dimensions.padding.left, + dimensions.width - dimensions.margin.right - dimensions.padding.right + ) + + Scale.convert_to_range(scale, value, range) + end + end +end diff --git a/lib/plox/y_axis.ex b/lib/plox/y_axis.ex new file mode 100644 index 0000000..edc00d2 --- /dev/null +++ b/lib/plox/y_axis.ex @@ -0,0 +1,70 @@ +defmodule Plox.YAxis do + @moduledoc """ + YAxis implements the `Plox.Axis.Protocol` and is used to convert scale values + to graphable y-coordinates. + + This module implements the `Access` behaviour, allowing access to graphable + values using the `[]` syntax. Note that y-coordinates go from top to bottom, + meaning that lower y values correspond to higher y-coordinates and vice versa: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> dimensions = Plox.Dimensions.new(100, 100, margin: 0) + iex> y_axis = %Plox.YAxis{scale: scale, dimensions: dimensions} + iex> y_axis[1] + 90.0 + iex> y_axis[8] + 20.0 + + This is useful when rendering graph elements in a more intuitive way: + + + <.circle cx={50.0} cy={y_axis[1]} fill="red" r="3" /> + + This module implements the `Plox.Axis.Protocol` and defines the `Plox.Axis.Protocol.to_graph/2` + function. This function is called by the `Access` behaviour: + + iex> scale = Plox.NumberScale.new(0, 10) + iex> dimensions = Plox.Dimensions.new(100, 100, margin: 0) + iex> y_axis = Plox.YAxis.new(scale, dimensions) + iex> y_axis[1] == Plox.Axis.Protocol.to_graph(y_axis, 1) + true + """ + + use Plox.Axis + + alias Plox.Scale + + defstruct [:scale, :dimensions] + + @doc """ + Creates a new `Plox.YAxis` struct. + + Accepts a `Plox.Scale` struct and `Plox.Dimensions` struct. + + ## Example + + iex> scale = Plox.NumberScale.new(0, 10) + iex> dimensions = Plox.Dimensions.new(100, 100) + iex> Plox.YAxis.new(scale, dimensions) + %Plox.YAxis{scale: scale, dimensions: dimensions} + """ + def new(scale, dimensions) do + %__MODULE__{scale: scale, dimensions: dimensions} + end + + defimpl Plox.Axis.Protocol do + @doc """ + Converts the given `value` to a graphable y-coordinate. + """ + def to_graph(%{scale: scale, dimensions: dimensions}, value) do + range = + Range.new( + dimensions.height - dimensions.margin.bottom - dimensions.padding.bottom, + dimensions.margin.top + dimensions.padding.top, + -1 + ) + + Scale.convert_to_range(scale, value, range) + end + end +end diff --git a/mix.exs b/mix.exs index 60250a7..890786d 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Plox.MixProject do [ app: :plox, version: @version, - elixir: "~> 1.15", + elixir: "~> 1.16", start_permanent: Mix.env() == :prod, deps: deps(), @@ -48,7 +48,7 @@ defmodule Plox.MixProject do groups_for_functions: [ Components: &(&1[:type] == :component) ], - extras: ["README.md", "LICENSE", "CODE_OF_CONDUCT.md"] + extras: ["README.md", "LICENSE", "CODE_OF_CONDUCT.md", "docs/migration_guide.md"] ] end @@ -58,17 +58,19 @@ defmodule Plox.MixProject do Plox.Box, Plox.DataPoint, Plox.Dataset, - Plox.Dimensions, - Plox.Graph, - Plox.GraphDataset, - Plox.GraphPoint, - Plox.GraphScalar, - Plox.GraphScale + Plox.Dimensions ], Protocols: [ + Plox.Axis.Protocol, Plox.ColorScale, Plox.Scale ], + Axes: [ + Plox.ColorAxis, + Plox.LinearAxis, + Plox.XAxis, + Plox.YAxis + ], Scales: [ Plox.DateScale, Plox.DateTimeScale, diff --git a/test/plox/box_test.exs b/test/plox/box_test.exs new file mode 100644 index 0000000..95dc902 --- /dev/null +++ b/test/plox/box_test.exs @@ -0,0 +1,19 @@ +defmodule Plox.BoxTest do + use ExUnit.Case + + alias Plox.Box + + doctest Box + + test "new/1" do + assert Box.new(1) == %Box{top: 1, right: 1, bottom: 1, left: 1} + assert Box.new({1}) == %Box{top: 1, right: 1, bottom: 1, left: 1} + assert Box.new({1, 2}) == %Box{top: 1, right: 2, bottom: 1, left: 2} + assert Box.new({1, 2, 3}) == %Box{top: 1, right: 2, bottom: 3, left: 2} + assert Box.new({1, 2, 3, 4}) == %Box{top: 1, right: 2, bottom: 3, left: 4} + assert Box.new("1") == %Box{top: 1, right: 1, bottom: 1, left: 1} + assert Box.new("1 2") == %Box{top: 1, right: 2, bottom: 1, left: 2} + assert Box.new("1 2 3") == %Box{top: 1, right: 2, bottom: 3, left: 2} + assert Box.new("1 2 3 4") == %Box{top: 1, right: 2, bottom: 3, left: 4} + end +end diff --git a/test/plox/color_axis_test.exs b/test/plox/color_axis_test.exs new file mode 100644 index 0000000..81e10a3 --- /dev/null +++ b/test/plox/color_axis_test.exs @@ -0,0 +1,32 @@ +defmodule Plox.ColorAxisTest do + use ExUnit.Case + + alias Plox.Axis + alias Plox.ColorAxis + + doctest ColorAxis + + setup do + %{scale: Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"})} + end + + test "new/2", %{scale: scale} do + assert ColorAxis.new(scale) == %ColorAxis{scale: scale} + end + + test "implements to_graph/2", %{scale: scale} do + color_axis = ColorAxis.new(scale) + + assert Axis.Protocol.to_graph(color_axis, :red) == "#ff0000" + assert Axis.Protocol.to_graph(color_axis, :green) == "#00ff00" + assert Axis.Protocol.to_graph(color_axis, :blue) == "#0000ff" + end + + test "fetches graphable values using Access syntax", %{scale: scale} do + color_axis = ColorAxis.new(scale) + + assert color_axis[:red] == "#ff0000" + assert color_axis[:green] == "#00ff00" + assert color_axis[:blue] == "#0000ff" + end +end diff --git a/test/plox/data_point_test.exs b/test/plox/data_point_test.exs new file mode 100644 index 0000000..24a850c --- /dev/null +++ b/test/plox/data_point_test.exs @@ -0,0 +1,13 @@ +defmodule Plox.DataPointTest do + use ExUnit.Case + + alias Plox.DataPoint + + doctest DataPoint + + test "new/3" do + data_point = DataPoint.new(%{foo: 1, bar: 2}, %{x: 1, y: 2}) + assert data_point.original == %{foo: 1, bar: 2} + assert data_point.graph == %{x: 1, y: 2} + end +end diff --git a/test/plox/dataset_test.exs b/test/plox/dataset_test.exs new file mode 100644 index 0000000..03504d2 --- /dev/null +++ b/test/plox/dataset_test.exs @@ -0,0 +1,97 @@ +defmodule Plox.DatasetTest do + use ExUnit.Case + + alias Plox.DataPoint + alias Plox.Dataset + alias Plox.DatasetAxis + + describe "DatasetAxis" do + test "fetch/2 returns graphable values" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Dataset.new(data, axis_fns) + + assert {:ok, dataset_axis_x} = Dataset.fetch(dataset, :x) + assert {:ok, dataset_axis_y} = Dataset.fetch(dataset, :y) + + # y coordinates are height - the expected value (100 - 20 = 80, 100 - 30 = 70) + assert {:ok, 10.0} = DatasetAxis.fetch(dataset_axis_x, 1) + assert {:ok, 20.0} = DatasetAxis.fetch(dataset_axis_x, 2) + assert {:ok, 80.0} = DatasetAxis.fetch(dataset_axis_y, 2) + assert {:ok, 70.0} = DatasetAxis.fetch(dataset_axis_y, 3) + end + + test "fetches graphable values using Access syntax" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Dataset.new(data, axis_fns) + + # y coordinates are height - the expected value (100 - 20 = 80, 100 - 30 = 70) + assert 10.0 = dataset[:x][1] + assert 20.0 = dataset[:x][2] + assert 80.0 = dataset[:y][2] + assert 70.0 = dataset[:y][3] + end + end + + describe "Dataset" do + test "new/2" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Dataset.new(data, axis_fns) + + assert dataset.axes == %{x: x_axis, y: y_axis} + + # y coordinates are height - the expected value (100 - 20 = 80, 100 - 30 = 70) + assert dataset.data == [ + %DataPoint{original: %{foo: 1, bar: 2}, graph: %{x: 10.0, y: 80.0}}, + %DataPoint{original: %{foo: 2, bar: 3}, graph: %{x: 20.0, y: 70.0}} + ] + end + + test "fetch/2 returns DatasetAxes" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Dataset.new(data, axis_fns) + + assert {:ok, %DatasetAxis{axis: ^x_axis}} = Dataset.fetch(dataset, :x) + assert {:ok, %DatasetAxis{axis: ^y_axis}} = Dataset.fetch(dataset, :y) + assert Dataset.fetch(dataset, :z) == :error + end + + test "fetches DatasetAxes using Access syntax" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Dataset.new(data, axis_fns) + + assert %DatasetAxis{axis: ^x_axis} = dataset[:x] + assert %DatasetAxis{axis: ^y_axis} = dataset[:y] + assert dataset[:z] == nil + end + end +end diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs new file mode 100644 index 0000000..708b19b --- /dev/null +++ b/test/plox/date_scale_test.exs @@ -0,0 +1,82 @@ +defmodule Plox.DateScaleTest do + use ExUnit.Case + + alias Plox.DateScale + alias Plox.Scale + + doctest DateScale + + describe "new/2" do + test "with a valid Date range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03])) + assert scale.range == Date.range(~D[2019-01-01], ~D[2019-01-03], 1) + end + + test "with a valid Date range reduces step to 1" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03], 2)) + assert scale.range == Date.range(~D[2019-01-01], ~D[2019-01-03], 1) + end + + test "with a valid reverse Date range" do + scale = DateScale.new(Date.range(~D[2019-01-03], ~D[2019-01-01], -1)) + assert scale.range == Date.range(~D[2019-01-03], ~D[2019-01-01], -1) + end + + test "raises an error with identical dates" do + assert_raise ArgumentError, fn -> + DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-01])) + end + end + + test "raises an error with invalid Date range" do + assert_raise ArgumentError, fn -> + DateScale.new(0..1) + end + end + end + + describe "values/2" do + test "with default step (1d)" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03])) + assert %Date.Range{first: ~D[2019-01-01], last: ~D[2019-01-03], step: 1} = Scale.values(scale) + end + + test "with positive custom step in days" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + assert Scale.values(scale, %{step: 2}) == Date.range(~D[2019-01-01], ~D[2019-01-10], 2) + end + + test "with negative custom step in days" do + scale = DateScale.new(Date.range(~D[2019-01-10], ~D[2019-01-01], -1)) + assert Scale.values(scale, %{step: 2}) == Date.range(~D[2019-01-10], ~D[2019-01-01], -2) + end + + test "raises an error with invalid step" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: 0}) + end + end + end + + describe "convert_to_range/3" do + test "with a valid Date in the range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-09])) + assert Scale.convert_to_range(scale, ~D[2019-01-05], 0..100) == 50.0 + end + + test "with a valid Date in the reverse range" do + scale = DateScale.new(Date.range(~D[2019-01-09], ~D[2019-01-01], -1)) + assert Scale.convert_to_range(scale, ~D[2019-01-07], 0..100) == 25.0 + end + + test "raises an error with Date outside the range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + + assert_raise ArgumentError, fn -> + Scale.convert_to_range(scale, ~D[2019-01-12], 0..100) + end + end + end +end diff --git a/test/plox/date_time_scale_test.exs b/test/plox/date_time_scale_test.exs new file mode 100644 index 0000000..81c33f1 --- /dev/null +++ b/test/plox/date_time_scale_test.exs @@ -0,0 +1,126 @@ +defmodule Plox.DateTimeScaleTest do + use ExUnit.Case + + alias Plox.DateTimeScale + alias Plox.Scale + + doctest DateTimeScale + + describe "new/2" do + test "with valid NaiveDateTimes" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + assert scale.first == ~N[2019-01-01 00:00:00] + assert scale.last == ~N[2019-01-03 00:00:00] + end + + test "with valid DateTimes" do + scale = DateTimeScale.new(~U[2019-01-01 00:00:00Z], ~U[2019-01-03 00:00:00Z]) + assert scale.first == ~U[2019-01-01 00:00:00Z] + assert scale.last == ~U[2019-01-03 00:00:00Z] + end + + test "raises an error with identical DateTimes" do + assert_raise ArgumentError, fn -> + DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:00:00]) + end + end + + test "raises an error with identical NaiveDateTimes" do + assert_raise ArgumentError, fn -> + DateTimeScale.new(~U[2019-01-01 00:00:00Z], ~U[2019-01-01 00:00:00Z]) + end + end + + test "raises an error with mixed structs" do + assert_raise ArgumentError, fn -> + DateTimeScale.new(~N[2019-01-01 00:00:00], ~U[2019-01-03 00:00:00Z]) + end + end + + test "raises an error with negative ranges" do + assert_raise ArgumentError, fn -> + DateTimeScale.new(~N[2019-01-03 00:00:00], ~N[2019-01-01 00:00:00]) + end + end + end + + describe "values/2" do + test "with default step (60s)" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + + test "with valid step in days" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + + assert Scale.values(scale, %{step: {1, :day}}) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-02 00:00:00], + ~N[2019-01-03 00:00:00] + ] + end + + test "with valid step in hours" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 01:00:00]) + + assert Scale.values(scale, %{step: {1, :hour}}) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 01:00:00] + ] + end + + test "with valid step in minutes" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale, %{step: {1, :minute}}) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + + test "with valid step in seconds" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:00:03]) + + assert Scale.values(scale, %{step: {1, :second}}) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 00:00:01], + ~N[2019-01-01 00:00:02], + ~N[2019-01-01 00:00:03] + ] + end + end + + describe "convert_to_range/3" do + test "with valid NaiveDateTime" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + assert Scale.convert_to_range(scale, ~N[2019-01-02 00:00:00], 0..100) == 50.0 + end + + test "with valid DateTime" do + scale = DateTimeScale.new(~U[2019-01-01 00:00:00Z], ~U[2019-01-03 00:00:00Z]) + assert Scale.convert_to_range(scale, ~U[2019-01-02 00:00:00Z], 0..100) == 50.0 + end + + test "raises an error with values outside the scale" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + + assert_raise ArgumentError, fn -> + Scale.convert_to_range(scale, ~N[2019-01-04 00:00:00], 0..100) + end + end + + test "raises an error with invalid datetime" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) + + assert_raise ArgumentError, fn -> + Scale.convert_to_range(scale, ~U[2019-01-02 12:00:00Z], 0..100) + end + end + end +end diff --git a/test/plox/dimensions_test.exs b/test/plox/dimensions_test.exs new file mode 100644 index 0000000..a5b3930 --- /dev/null +++ b/test/plox/dimensions_test.exs @@ -0,0 +1,17 @@ +defmodule Plox.DimensionsTest do + use ExUnit.Case + + alias Plox.Box + alias Plox.Dimensions + + doctest Dimensions + + test "new/1" do + assert Dimensions.new(100, 100, margin: 0, padding: 0) == %Dimensions{ + width: 100, + height: 100, + margin: %Box{top: 0, right: 0, bottom: 0, left: 0}, + padding: %Box{top: 0, right: 0, bottom: 0, left: 0} + } + end +end diff --git a/test/plox/fixed_colors_scale_test.exs b/test/plox/fixed_colors_scale_test.exs new file mode 100644 index 0000000..ccd8667 --- /dev/null +++ b/test/plox/fixed_colors_scale_test.exs @@ -0,0 +1,41 @@ +defmodule Plox.FixedColorsScaleTest do + use ExUnit.Case + + alias Plox.ColorScale + alias Plox.FixedColorsScale + + doctest Plox.FixedColorsScale + + describe "new/1" do + test "creates a new FixedColorsScale" do + scale = FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + + assert %FixedColorsScale{ + mapping: %{red: "#ff0000", green: "#00ff00", blue: "#0000ff"} + } = scale + end + + test "raises an error for invalid input" do + assert_raise ArgumentError, fn -> + FixedColorsScale.new(%{red: "#ff0000"}) + end + end + end + + describe "convert_to_color/2" do + test "with valid input" do + scale = FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + assert ColorScale.convert_to_color(scale, :red) == "#ff0000" + assert ColorScale.convert_to_color(scale, :green) == "#00ff00" + assert ColorScale.convert_to_color(scale, :blue) == "#0000ff" + end + + test "raises an error for invalid values" do + scale = FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) + + assert_raise ArgumentError, fn -> + ColorScale.convert_to_color(scale, :yellow) + end + end + end +end diff --git a/test/plox/fixed_values_scale_test.exs b/test/plox/fixed_values_scale_test.exs new file mode 100644 index 0000000..7290a89 --- /dev/null +++ b/test/plox/fixed_values_scale_test.exs @@ -0,0 +1,50 @@ +defmodule Plox.FixedValuesScaleTest do + use ExUnit.Case + + alias Plox.FixedValuesScale + alias Plox.Scale + + doctest Plox.FixedValuesScale + + describe "new/1" do + test "creates a new FixedValuesScale" do + scale = FixedValuesScale.new([:a, :b, :c, :d]) + + assert %FixedValuesScale{ + values: [:a, :b, :c, :d], + index_map: %{a: 0, b: 1, c: 2, d: 3}, + max_index: 3 + } = scale + end + + test "raises an error for invalid input" do + assert_raise ArgumentError, fn -> + FixedValuesScale.new([1]) + end + end + end + + describe "values/1" do + test "returns the values in the scale" do + scale = FixedValuesScale.new([:a, :b, :c]) + assert Scale.values(scale) == [:a, :b, :c] + end + end + + describe "convert_to_range/3" do + test "with valid input" do + scale = FixedValuesScale.new([:a, :b, :c]) + assert Scale.convert_to_range(scale, :a, 0..100) == 0.0 + assert Scale.convert_to_range(scale, :b, 0..100) == 50.0 + assert Scale.convert_to_range(scale, :c, 0..100) == 100.0 + end + + test "raises an error for invalid values" do + scale = FixedValuesScale.new([:a, :b, :c]) + + assert_raise ArgumentError, fn -> + Scale.convert_to_range(scale, :d, 0..100) + end + end + end +end diff --git a/test/plox/linear_axis_test.exs b/test/plox/linear_axis_test.exs new file mode 100644 index 0000000..0e77fdc --- /dev/null +++ b/test/plox/linear_axis_test.exs @@ -0,0 +1,34 @@ +defmodule Plox.LinearAxisTest do + use ExUnit.Case + + alias Plox.Axis + alias Plox.LinearAxis + + doctest LinearAxis + + setup do + %{scale: Plox.NumberScale.new(0, 10)} + end + + test "new/2", %{scale: scale} do + assert LinearAxis.new(scale, min: 0, max: 100) == %LinearAxis{ + scale: scale, + min: 0, + max: 100 + } + end + + test "implements to_graph/2", %{scale: scale} do + linear_axis = LinearAxis.new(scale, min: 0, max: 100) + + assert Axis.Protocol.to_graph(linear_axis, 1) == 10.0 + assert Axis.Protocol.to_graph(linear_axis, 2) == 20.0 + end + + test "fetches graphable values using Access syntax", %{scale: scale} do + linear_axis = LinearAxis.new(scale, min: 0, max: 100) + + assert linear_axis[1] == 10.0 + assert linear_axis[2] == 20.0 + end +end diff --git a/test/plox/number_scale_test.exs b/test/plox/number_scale_test.exs new file mode 100644 index 0000000..9b08f72 --- /dev/null +++ b/test/plox/number_scale_test.exs @@ -0,0 +1,83 @@ +defmodule Plox.NumberScaleTest do + use ExUnit.Case + + alias Plox.NumberScale + alias Plox.Scale + + doctest Plox.NumberScale + + describe "new/2" do + test "with valid numbers" do + scale = NumberScale.new(0, 10) + assert scale.first == Decimal.new("0.0") + assert scale.last == Decimal.new("10.0") + refute scale.backwards? + end + + test "with reversed numbers" do + scale = NumberScale.new(10, 0) + assert scale.first == Decimal.new("10.0") + assert scale.last == Decimal.new("0.0") + assert scale.backwards? + end + + test "raises an error with identical numbers" do + assert_raise ArgumentError, fn -> + NumberScale.new(5, 5) + end + end + end + + describe "values/2" do + test "with default ticks" do + scale = NumberScale.new(0, 10) + assert Scale.values(scale) == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] + end + + test "with custom ticks" do + scale = NumberScale.new(0, 10) + assert Scale.values(scale, %{ticks: 5}) == [0.0, 2.5, 5.0, 7.5, 10.0] + end + + test "with reversed scale" do + scale = NumberScale.new(10, 0) + assert Scale.values(scale) == [10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0] + end + + test "raises an error with invalid ticks" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{ticks: 1}) + end + end + end + + describe "convert_to_range/3" do + test "with valid input" do + scale = NumberScale.new(0, 10) + assert Scale.convert_to_range(scale, 5, 0..100) == 50.0 + end + + test "with reversed scale" do + scale = NumberScale.new(10, 0) + assert Scale.convert_to_range(scale, 2, 0..100) == 80.0 + end + + test "raises an error with out of bounds input" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.convert_to_range(scale, 11, 0..100) + end + end + + test "raises an error with invalid input" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.convert_to_range(scale, "invalid", 0..100) + end + end + end +end diff --git a/test/plox/x_axis_test.exs b/test/plox/x_axis_test.exs new file mode 100644 index 0000000..5d009dc --- /dev/null +++ b/test/plox/x_axis_test.exs @@ -0,0 +1,36 @@ +defmodule Plox.XAxisTest do + use ExUnit.Case + + alias Plox.Axis + alias Plox.XAxis + + doctest XAxis + + setup do + %{ + scale: Plox.NumberScale.new(0, 10), + dimensions: Plox.Dimensions.new(100, 100, margin: 0) + } + end + + test "new/2", %{scale: scale, dimensions: dimensions} do + assert XAxis.new(scale, dimensions) == %XAxis{ + scale: scale, + dimensions: dimensions + } + end + + test "implements to_graph/2", %{scale: scale, dimensions: dimensions} do + x_axis = XAxis.new(scale, dimensions) + + assert Axis.Protocol.to_graph(x_axis, 1) == 10.0 + assert Axis.Protocol.to_graph(x_axis, 2) == 20.0 + end + + test "fetches graphable values using Access syntax", %{scale: scale, dimensions: dimensions} do + x_axis = XAxis.new(scale, dimensions) + + assert x_axis[1] == 10.0 + assert x_axis[2] == 20.0 + end +end diff --git a/test/plox/y_axis_test.exs b/test/plox/y_axis_test.exs new file mode 100644 index 0000000..ef74460 --- /dev/null +++ b/test/plox/y_axis_test.exs @@ -0,0 +1,38 @@ +defmodule Plox.YAxisTest do + use ExUnit.Case + + alias Plox.Axis + alias Plox.YAxis + + doctest YAxis + + setup do + %{ + scale: Plox.NumberScale.new(0, 10), + dimensions: Plox.Dimensions.new(100, 100, margin: 0) + } + end + + test "new/2", %{scale: scale, dimensions: dimensions} do + assert YAxis.new(scale, dimensions) == %YAxis{ + scale: scale, + dimensions: dimensions + } + end + + test "implements to_graph/2", %{scale: scale, dimensions: dimensions} do + y_axis = YAxis.new(scale, dimensions) + + # y coordinates are height - the expected value (100 - 10 = 90, 100 - 20 = 80) + assert Axis.Protocol.to_graph(y_axis, 1) == 90.0 + assert Axis.Protocol.to_graph(y_axis, 2) == 80.0 + end + + test "fetches graphable values using Access syntax", %{scale: scale, dimensions: dimensions} do + y_axis = YAxis.new(scale, dimensions) + + # y coordinates are height - the expected value (100 - 10 = 90, 100 - 20 = 80) + assert y_axis[1] == 90.0 + assert y_axis[2] == 80.0 + end +end diff --git a/test/plox_test.exs b/test/plox_test.exs index 0dd049c..94eebac 100644 --- a/test/plox_test.exs +++ b/test/plox_test.exs @@ -1,3 +1,53 @@ defmodule PloxTest do use ExUnit.Case + + describe "points/2" do + test "handles constant values" do + assert Plox.points(1, 2) == [{1, 2}] + assert Plox.points([1, 2], [3, 4]) == [{1, 3}, {2, 4}] + end + + test "handles DatasetAxes" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Plox.Dataset.new(data, axis_fns) + + # y coordinates are height - the expected value (100 - 20 = 80, 100 - 30 = 70) + assert Plox.points(dataset[:x], dataset[:y]) == [{10.0, 80.0}, {20.0, 70.0}] + assert Plox.points(dataset[:x], 1) == [{10.0, 1.0}, {20.0, 1.0}] + assert Plox.points(1, dataset[:y]) == [{1.0, 80.0}, {1.0, 70.0}] + assert Plox.points(dataset[:x], [3, 4]) == [{10.0, 3.0}, {20.0, 4.0}] + assert Plox.points([1, 2], dataset[:y]) == [{1.0, 80.0}, {2.0, 70.0}] + end + end + + describe "values/1" do + test "handles constant values" do + assert Plox.values([1, 2]) == [{1, 2}] + assert Plox.values([[1, 2], [3, 4]]) == [{1, 3}, {2, 4}] + end + + test "handles DatasetAxes" do + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dimensions = Plox.Dimensions.new(100, 100, margin: 0) + scale = Plox.NumberScale.new(0, 10) + + x_axis = Plox.XAxis.new(scale, dimensions) + y_axis = Plox.YAxis.new(scale, dimensions) + axis_fns = %{x: {x_axis, & &1.foo}, y: {y_axis, & &1.bar}} + dataset = Plox.Dataset.new(data, axis_fns) + + # y coordinates are height - the expected value (100 - 20 = 80, 100 - 30 = 70) + assert Plox.values([dataset[:x], dataset[:y]]) == [{10.0, 80.0}, {20.0, 70.0}] + assert Plox.values([dataset[:x], 1]) == [{10.0, 1.0}, {20.0, 1.0}] + assert Plox.values([1, dataset[:y]]) == [{1.0, 80.0}, {1.0, 70.0}] + assert Plox.values([dataset[:x], [3, 4]]) == [{10.0, 3.0}, {20.0, 4.0}] + assert Plox.values([[1, 2], dataset[:y]]) == [{1.0, 80.0}, {2.0, 70.0}] + end + end end