From d40df118aa13634d849e3f8845988ffeb34ca4b8 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Thu, 3 Jul 2025 15:23:55 -0700 Subject: [PATCH 01/23] Add Phoenix Playground demo file --- demo_live.exs | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 demo_live.exs diff --git a/demo_live.exs b/demo_live.exs new file mode 100644 index 0000000..b031e41 --- /dev/null +++ b/demo_live.exs @@ -0,0 +1,122 @@ +Mix.install([ + {:phoenix_playground, "~> 0.1.6"}, + {:plox, path: "."} +]) + +defmodule DemoLive do + @moduledoc """ + Example graph rendered within a Phoenix Playground application. + """ + 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 simple_graph={@simple_graph} clicked_point={@clicked_point} /> + """ + end + + @impl Phoenix.LiveView + def handle_event( + "toggle_tooltip", + %{"id" => point_id, "dataset_id" => dataset_id, "x_pixel" => x_pixel, "y_pixel" => y_pixel}, + socket + ) do + {:noreply, + assign(socket, + clicked_point: %{ + id: point_id, + dataset_id: String.to_existing_atom(dataset_id), + x_pixel: x_pixel, + y_pixel: y_pixel + } + )} + end + + def handle_event("toggle_tooltip", _params, socket), do: {:noreply, assign(socket, clicked_point: nil)} + + defp mount_simple_line_graph(socket) do + simple_data = [ + %{date: ~D[2023-08-01], value: 45.0}, + %{date: ~D[2023-08-02], value: 40.0}, + %{date: ~D[2023-08-03], value: 35.0}, + %{date: ~D[2023-08-04], value: 60.0}, + %{date: ~D[2023-08-04], value: 10.0}, + %{date: ~D[2023-08-05], value: 15.0}, + %{date: ~D[2023-08-06], value: 25.0}, + %{date: ~D[2023-08-07], value: 20.0}, + %{date: ~D[2023-08-08], value: 10.0} + ] + + date_scale = date_scale(Date.range(~D[2023-08-01], ~D[2023-08-08])) + number_scale = number_scale(0.0, 80.0) + + dataset = + dataset(simple_data, + x: {date_scale, & &1.date}, + y: {number_scale, & &1.value} + ) + + assign(socket, + simple_graph: + to_graph( + scales: [date_scale: date_scale, number_scale: number_scale], + datasets: [dataset: dataset] + ), + clicked_point: nil + ) + end + + defp simple_line_graph(assigns) do + ~H""" +

Simple line graph with legend and tooltips

+ + <.graph :let={graph} id="simple_graph" for={@simple_graph} width="800" height="250"> + <:legend> + <.legend_item color="orange" label="data" /> + <.legend_item color="green" label="more data" /> + + + <:tooltips :let={graph}> + <.tooltip + :let={%{value: value, date: date}} + :if={!is_nil(@clicked_point)} + dataset={graph[@clicked_point.dataset_id]} + point_id={@clicked_point.id} + x_pixel={@clicked_point.x_pixel} + y_pixel={@clicked_point.y_pixel} + phx-click-away="toggle_tooltip" + > +

date: {date}

+

value: {value}

+ + + + <.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]} /> + + <.points_plot dataset={graph[:dataset]} phx-click="toggle_tooltip" /> + + <.marker at={~D[2023-08-03]} scale={graph[:date_scale]}> + Important date! + + + """ + end +end + +PhoenixPlayground.start(live: DemoLive) From 49f6ff1e8e6385c7fd189757771baefc2a6a936c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Mon, 26 Aug 2024 09:45:35 -0700 Subject: [PATCH 02/23] Handle scale value conversions with Axes + Separate out Dimensions --- lib/plox.ex | 635 +++++++++++++++++++++------------------- lib/plox/axis.ex | 25 ++ lib/plox/color_axis.ex | 22 ++ lib/plox/data_point.ex | 40 +-- lib/plox/dataset.ex | 33 ++- lib/plox/dimensions.ex | 13 +- lib/plox/graph.ex | 40 --- lib/plox/graph_scale.ex | 26 +- lib/plox/linear_axis.ex | 26 ++ lib/plox/x_axis.ex | 29 ++ lib/plox/y_axis.ex | 29 ++ 11 files changed, 532 insertions(+), 386 deletions(-) create mode 100644 lib/plox/axis.ex create mode 100644 lib/plox/color_axis.ex delete mode 100644 lib/plox/graph.ex create mode 100644 lib/plox/linear_axis.ex create mode 100644 lib/plox/x_axis.ex create mode 100644 lib/plox/y_axis.ex diff --git a/lib/plox.ex b/lib/plox.ex index 447bbe6..38f0544 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -6,215 +6,356 @@ defmodule Plox do use Phoenix.Component alias Phoenix.LiveView.JS + alias Plox.Axis 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 :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. - """ + attr :dimensions, Dimensions, required: true + # FIXME: + attr :rest, :global 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)) - ) + # 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 %> + <%!-- <%= for tooltip <- @tooltips do %> + <%= render_slot(tooltip) %> + <% 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 :rest, :global, include: ~w(gap rotation position) ++ @svg_presentation_globals - attr :position, :atom, values: [:left, :right], default: :left + slot :inner_block, required: true - attr :label_color, :string, default: "#18191A" - attr :label_rotation, :integer, default: nil + def x_axis_labels(assigns) do + ~H""" + <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> + <.x_axis_label axis={@axis} value={value} {@rest}> + {render_slot(@inner_block, value)} + + <% end %> + """ + end - attr :grid_lines, :boolean, default: true - attr :line_width, :string, default: "1" - attr :line_color, :string, default: "#F2F4F5" + @doc """ + An X-axis label at the bottom or top of the graph. + """ + @doc type: :component + + attr :axis, XAxis, required: true + attr :value, :any, required: true + attr :position, :atom, values: [:top, :bottom], default: :bottom + 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 y_axis(assigns) do + def x_axis_label(%{position: :bottom} = 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 %> + + {render_slot(@inner_block)} + + """ + end + + def x_axis_label(%{position: :top} = assigns) do + ~H""" + + {render_slot(@inner_block)} + """ end @doc """ - X-axis labels along the bottom or top of the graph + 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 :scale, :any, required: true + attr :axis, YAxis, required: true attr :ticks, :any attr :step, :any + attr :rest, :global, include: ~w(gap rotation position) ++ @svg_presentation_globals - attr :position, :atom, values: [:top, :bottom], default: :bottom + slot :inner_block, required: true - attr :label_color, :string, default: "#18191A" - attr :label_rotation, :integer, default: nil + def y_axis_labels(assigns) do + ~H""" + <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> + <.y_axis_label axis={@axis} value={value} {@rest}> + {render_slot(@inner_block, value)} + + <% end %> + """ + end + + @doc """ + A Y-axis label at the left or right side of the graph. + """ + @doc type: :component - attr :grid_lines, :boolean, default: true - attr :line_width, :string, default: "1" - attr :line_color, :string, default: "#F2F4F5" + 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: nil + attr :"text-anchor", :any, default: nil + attr :rest, :global, include: @svg_presentation_globals slot :inner_block, required: true - def x_axis(assigns) do + def y_axis_label(%{position: :left} = 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 %> + + {render_slot(@inner_block)} + """ end - defp scale_opts(assigns), do: Map.take(assigns, [:ticks, :step]) + def y_axis_label(%{position: :right} = assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end @doc """ - A connected line plot + X-axis grid lines. """ @doc type: :component - attr :dataset, :any, required: true + attr :axis, XAxis, required: true + attr :ticks, :any + attr :step, :any + attr :rest, :global, include: @svg_presentation_globals - 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" + def x_axis_grid_lines(assigns) do + ~H""" + <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> + <.x_axis_grid_line axis={@axis} value={value} {@rest} /> + <% end %> + """ + end + + @doc """ + A single X-axis grid line. + """ + @doc type: :component - 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 + 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 line_plot(%{type: :line} = assigns) do + def x_axis_grid_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 + @doc """ + Y-axis grid lines. + """ + @doc type: :component + + attr :axis, YAxis, required: true + attr :ticks, :any + attr :step, :any + attr :rest, :global, include: @svg_presentation_globals + + def y_axis_grid_lines(assigns) do + ~H""" + <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> + <.y_axis_grid_line axis={@axis} value={value} {@rest} /> + <% end %> + """ + end + + @doc """ + A single Y-axis grid line. + """ + @doc type: :component + + attr :axis, YAxis, required: true + attr :value, :any, required: true + attr :rest, :global, include: @svg_presentation_globals + + def y_axis_grid_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) + @doc """ + A connected line plot. + """ + @doc type: :component + + attr :dataset, Dataset, 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 :fill, :any, default: "none" + attr :rest, :global, include: @svg_presentation_globals + + def line_plot(assigns) do + ~H""" + + """ + end + + defp line_points(dataset, x_key, y_key) do + dataset.data + |> Enum.map(fn data_point -> %{x: data_point.graph[x_key], y: data_point.graph[y_key]} end) + |> polyline_points() + end + + @doc """ + A connected step line plot. + """ + @doc type: :component + + attr :dataset, Dataset, 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 :fill, :any, default: "none" + attr :rest, :global, include: @svg_presentation_globals + + def step_line_plot(assigns) do + ~H""" + + """ + end + + defp step_line_points(dataset, x_key, y_key) do + dataset.data + |> Enum.map(fn data_point -> %{x: data_point.graph[x_key], y: data_point.graph[y_key]} end) |> 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 """ - Points plot + Points plot. """ @doc type: :component @@ -223,39 +364,30 @@ defmodule Plox do 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 + attr :r, :any, examples: ["8", "24.5", :radius_axis], default: "4" + attr :fill, :any, examples: ["red", "#FF9330", :color_axis], default: nil + attr :rest, :global, include: @svg_presentation_globals + # attr :"phx-click", :any, default: nil + # attr :"phx-target", :any, default: nil def points_plot(assigns) do ~H""" """ end + defp maybe_graph(assign, data_point) when is_atom(assign), do: data_point.graph[assign] + defp maybe_graph(assign, _data_point), do: assign + @doc """ - Bar plot + Bar plot. """ @doc type: :component @@ -311,7 +443,7 @@ defmodule Plox do defp bar_style(:square), do: "butt" @doc """ - Tooltip + Tooltip. """ @doc type: :component @@ -386,7 +518,7 @@ defmodule Plox do end @doc """ - One-dimensional shaded areas, either horizontal or vertical + One-dimensional shaded areas, either horizontal or vertical. """ @doc type: :component @@ -478,148 +610,8 @@ defmodule Plox do |> 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 :rotation, :integer, default: nil - - slot :inner_block, required: true - - defp y_label(%{position: :left} = assigns) do - ~H""" - - {render_slot(@inner_block)} - - """ - end - - defp y_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 - - slot :inner_block, required: true - - defp x_label(%{position: :bottom} = assigns) do - ~H""" - - {render_slot(@inner_block)} - - """ - end - - defp x_label(%{position: :top} = assigns) do - ~H""" - - {render_slot(@inner_block)} - - """ - end - - attr :dimensions, :map, required: true - attr :y_pixel, :float, required: true - attr :width, :string, required: true - - attr :line_style, :atom, values: [:solid, :dashed, :dotted], default: :solid - attr :color, :string, required: true - - defp horizontal_line(assigns) do - ~H""" - - """ - end - - attr :dimensions, :map, required: true - attr :x_pixel, :float, required: true - attr :width, :string, required: true - - attr :line_style, :atom, values: [:solid, :dashed, :dotted], default: :solid - attr :color, :string, required: true - - defp vertical_line(assigns) do - ~H""" - - """ - end - @doc """ - A horizontal or vertical marker line with a label + A horizontal or vertical marker line with a label. """ @doc type: :component @@ -699,7 +691,54 @@ defmodule Plox do end @doc """ - Legend row + A horizontal or vertical marker line with a label. + """ + @doc type: :component + + attr :axis, XAxis, required: true + attr :value, :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 + + slot :inner_block, required: true + + def x_marker(assigns) do + ~H""" + + + {render_slot(@inner_block)} + + """ + end + + @doc """ + Legend row. """ @doc type: :component @@ -714,7 +753,7 @@ defmodule Plox do end @doc """ - Legend item + Legend item. """ @doc type: :component @@ -731,7 +770,7 @@ defmodule Plox do end @doc """ - A colored circle for legends + A colored circle for legends. """ @doc type: :component @@ -747,11 +786,17 @@ defmodule Plox do 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 + # def date_scale(graph, range), do: GraphScale.new(graph, DateScale.new(range)) + + # def number_scale(graph, first, last), do: GraphScale.new(graph, NumberScale.new(first, last)) + + # def dataset(data, axes), do: Dataset.new(data, axes) + + # defdelegate graph(width, height, opts \\ []), 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 end diff --git a/lib/plox/axis.ex b/lib/plox/axis.ex new file mode 100644 index 0000000..70f528e --- /dev/null +++ b/lib/plox/axis.ex @@ -0,0 +1,25 @@ +defprotocol Plox.Axis do + @moduledoc """ + A protocol for graph axes. + + TODO: docs + """ + + @typedoc """ + Any struct that implements this protocol + + Built in implementations are: + + * `Plox.XAxis` + * `Plox.YAxis` + * `Plox.RadiusAxis` + * `Plox.ColorAxis` + """ + @type t :: any() + + @doc """ + Converts a specific scale value to a value usable by the graph components + """ + @spec to_graph(axis :: t(), any()) :: any() + def to_graph(axis, value) +end diff --git a/lib/plox/color_axis.ex b/lib/plox/color_axis.ex new file mode 100644 index 0000000..141b05c --- /dev/null +++ b/lib/plox/color_axis.ex @@ -0,0 +1,22 @@ +defmodule Plox.ColorAxis do + @moduledoc """ + TODO: this is a public module that graph component implementers will interact + with, so it should be documented + """ + + alias Plox.ColorScale + + defstruct [:scale] + + def new(scale) do + %__MODULE__{scale: scale} + end + + # def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) + + defimpl Plox.Axis do + 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..e3ec10b 100644 --- a/lib/plox/data_point.ex +++ b/lib/plox/data_point.ex @@ -6,31 +6,31 @@ defmodule Plox.DataPoint do alias Plox.GraphScalar alias Plox.GraphScale - defstruct [:id, :original, :mapped] + defstruct [:original, :graph] - def new(id, original, mapped) do - %__MODULE__{id: id, original: original, mapped: mapped} + def new(original, graph) do + %__MODULE__{original: original, graph: graph} 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] + # 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) + # 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 + # 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_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) - end + # def to_graph_y(%__MODULE__{} = data_point, scale, key) do + # scale + # |> GraphScale.to_graph_y(data_point.mapped[key]) + # |> GraphScalar.new(data_point) + # end end diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index 6170b7e..5e8d4bc 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -2,27 +2,34 @@ defmodule Plox.Dataset do @moduledoc """ A collection of data points and some metadata for a graph """ + alias Plox.Axis alias Plox.DataPoint - defstruct [:data, :scales] - - def new(original_data, axes) do - scales = Map.new(axes, fn {key, {scale, _fun}} -> {key, scale} end) + # defstruct [:data, :axes] + defstruct [:data] + def new(original_data, axis_fns) do data = original_data - |> Enum.with_index() - |> Enum.map(fn {original, idx} -> - id = Map.get(original, :id, idx) - - mapped = - Map.new(axes, fn {key, {_scale, fun}} -> - {key, fun.(original)} + |> Enum.map(fn original -> + graph = + Map.new(axis_fns, fn {key, {axis, fun}} -> + {key, Axis.to_graph(axis, fun.(original))} end) - DataPoint.new(id, original, mapped) + DataPoint.new(original, graph) end) - %__MODULE__{data: data, scales: scales} + # axes = Map.new(axis_fns, fn {key, {scale, _fun}} -> {key, scale} end) + + %__MODULE__{data: data} + end + + def get_graph_values(%__MODULE__{data: data}, key_mapping) do + Enum.map(data, fn data_point -> + Map.new(key_mapping, fn {requested_key, axis_key} -> + {requested_key, Map.get(data_point.graph, axis_key)} + end) + end) end end diff --git a/lib/plox/dimensions.ex b/lib/plox/dimensions.ex index 5ad7d78..ffa0176 100644 --- a/lib/plox/dimensions.ex +++ b/lib/plox/dimensions.ex @@ -8,12 +8,15 @@ defmodule Plox.Dimensions do defstruct [:width, :height, :margin, :padding] - def new(attrs) do + def new(width, height, opts \\ []) do + margin = Keyword.get(opts, :margin, {35, 70}) + padding = Keyword.get(opts, :margin, 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/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_scale.ex b/lib/plox/graph_scale.ex index 48bd01b..4b8f935 100644 --- a/lib/plox/graph_scale.ex +++ b/lib/plox/graph_scale.ex @@ -6,33 +6,33 @@ defmodule Plox.GraphScale do alias Plox.Scale - defstruct [:id, :scale, :dimensions] + defstruct [:scale, :graph] - def new(id, scale, dimensions) do - %__MODULE__{id: id, scale: scale, dimensions: dimensions} + def new(graph, scale) do + %__MODULE__{scale: scale, graph: graph} end def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - def to_graph_x(%__MODULE__{scale: scale, dimensions: dimensions}, value) do + def to_graph_x(%__MODULE__{scale: scale, graph: graph}, value) do Scale.convert_to_range( scale, value, - (dimensions.margin.left + - dimensions.padding.left)..(dimensions.width - - dimensions.margin.right - - dimensions.padding.right) + (graph.margin.left + + graph.padding.left)..(graph.width - + graph.margin.right - + graph.padding.right) ) end - def to_graph_y(%__MODULE__{scale: scale, dimensions: dimensions}, value) do + def to_graph_y(%__MODULE__{scale: scale, graph: graph}, value) do Scale.convert_to_range( scale, value, - (dimensions.height - - dimensions.margin.bottom - - dimensions.padding.bottom)..(dimensions.margin.top + - dimensions.padding.top) + (graph.height - + graph.margin.bottom - + graph.padding.bottom)..(graph.margin.top + + graph.padding.top) ) end end diff --git a/lib/plox/linear_axis.ex b/lib/plox/linear_axis.ex new file mode 100644 index 0000000..3e44ed3 --- /dev/null +++ b/lib/plox/linear_axis.ex @@ -0,0 +1,26 @@ +defmodule Plox.LinearAxis do + @moduledoc """ + TODO: this is a public module that graph component implementers will interact + with, so it should be documented + """ + + alias Plox.Scale + + defstruct [:scale, :min, :max] + + 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 + + def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) + + defimpl Plox.Axis do + 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/x_axis.ex b/lib/plox/x_axis.ex new file mode 100644 index 0000000..4889fe5 --- /dev/null +++ b/lib/plox/x_axis.ex @@ -0,0 +1,29 @@ +defmodule Plox.XAxis do + @moduledoc """ + TODO: this is a public module that graph component implementers will interact + with, so it should be documented + """ + + alias Plox.Scale + + defstruct [:scale, :dimensions] + + def new(scale, dimensions) do + %__MODULE__{scale: scale, dimensions: dimensions} + end + + def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) + + defimpl Plox.Axis do + def to_graph(%{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 + end +end diff --git a/lib/plox/y_axis.ex b/lib/plox/y_axis.ex new file mode 100644 index 0000000..8f2e7b9 --- /dev/null +++ b/lib/plox/y_axis.ex @@ -0,0 +1,29 @@ +defmodule Plox.YAxis do + @moduledoc """ + TODO: this is a public module that graph component implementers will interact + with, so it should be documented + """ + + alias Plox.Scale + + defstruct [:scale, :dimensions] + + def new(scale, dimensions) do + %__MODULE__{scale: scale, dimensions: dimensions} + end + + def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) + + defimpl Plox.Axis do + def to_graph(%{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 +end From 5248c28fc91e6067e497e73a6be706aa8bbebe9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Mon, 26 Aug 2024 22:33:06 -0700 Subject: [PATCH 03/23] Update DateTimeScale + Add basic tests --- lib/plox.ex | 48 ++++++++++++++++++--------- lib/plox/dataset.ex | 11 ------ lib/plox/date_time_scale.ex | 24 ++++++++++---- test/plox/box_test.exs | 19 +++++++++++ test/plox/color_scale_test.exs | 11 ++++++ test/plox/data_point_test.exs | 14 ++++++++ test/plox/dataset_test.exs | 22 ++++++++++++ test/plox/date_scale_test.exs | 17 ++++++++++ test/plox/date_time_scale_test.exs | 17 ++++++++++ test/plox/dimensions_test.exs | 17 ++++++++++ test/plox/fixed_colors_scale_test.exs | 9 +++++ test/plox/fixed_values_scale_test.exs | 9 +++++ test/plox/graph_dataset_test.exs | 9 +++++ test/plox/graph_point_test.exs | 9 +++++ test/plox/graph_scalar_test.exs | 9 +++++ test/plox/graph_scale_test.exs | 9 +++++ test/plox/graph_test.exs | 9 +++++ test/plox/number_scale_test.exs | 14 ++++++++ test/plox/scale_test.exs | 9 +++++ test/plox_test.exs | 6 ++++ 20 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 test/plox/box_test.exs create mode 100644 test/plox/color_scale_test.exs create mode 100644 test/plox/data_point_test.exs create mode 100644 test/plox/dataset_test.exs create mode 100644 test/plox/date_scale_test.exs create mode 100644 test/plox/date_time_scale_test.exs create mode 100644 test/plox/dimensions_test.exs create mode 100644 test/plox/fixed_colors_scale_test.exs create mode 100644 test/plox/fixed_values_scale_test.exs create mode 100644 test/plox/graph_dataset_test.exs create mode 100644 test/plox/graph_point_test.exs create mode 100644 test/plox/graph_scalar_test.exs create mode 100644 test/plox/graph_scale_test.exs create mode 100644 test/plox/graph_test.exs create mode 100644 test/plox/number_scale_test.exs create mode 100644 test/plox/scale_test.exs diff --git a/lib/plox.ex b/lib/plox.ex index 38f0544..93b65e7 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -75,17 +75,21 @@ defmodule Plox do attr :axis, XAxis, 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 x_axis_labels(assigns) do ~H""" - <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> - <.x_axis_label axis={@axis} value={value} {@rest}> - {render_slot(@inner_block, value)} - - <% 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 @@ -152,17 +156,21 @@ defmodule Plox do 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 y_axis_labels(assigns) do ~H""" - <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> - <.y_axis_label axis={@axis} value={value} {@rest}> - {render_slot(@inner_block, value)} - - <% end %> + <.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 @@ -227,13 +235,17 @@ defmodule Plox do attr :axis, XAxis, required: true attr :ticks, :any attr :step, :any + attr :start, :any attr :rest, :global, include: @svg_presentation_globals def x_axis_grid_lines(assigns) do ~H""" - <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> - <.x_axis_grid_line axis={@axis} value={value} {@rest} /> - <% end %> + <.x_axis_grid_line + :for={value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step, :start]))} + axis={@axis} + value={value} + {@rest} + /> """ end @@ -268,13 +280,17 @@ defmodule Plox do attr :axis, YAxis, required: true attr :ticks, :any attr :step, :any + attr :start, :any attr :rest, :global, include: @svg_presentation_globals def y_axis_grid_lines(assigns) do ~H""" - <%= for value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step])) do %> - <.y_axis_grid_line axis={@axis} value={value} {@rest} /> - <% end %> + <.y_axis_grid_line + :for={value <- Scale.values(@axis.scale, Map.take(assigns, [:ticks, :step, :start]))} + axis={@axis} + value={value} + {@rest} + /> """ end diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index 5e8d4bc..002491e 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -5,7 +5,6 @@ defmodule Plox.Dataset do alias Plox.Axis alias Plox.DataPoint - # defstruct [:data, :axes] defstruct [:data] def new(original_data, axis_fns) do @@ -20,16 +19,6 @@ defmodule Plox.Dataset do DataPoint.new(original, graph) end) - # axes = Map.new(axis_fns, fn {key, {scale, _fun}} -> {key, scale} end) - %__MODULE__{data: data} end - - def get_graph_values(%__MODULE__{data: data}, key_mapping) do - Enum.map(data, fn data_point -> - Map.new(key_mapping, fn {requested_key, axis_key} -> - {requested_key, Map.get(data_point.graph, axis_key)} - end) - end) - end end diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 9d0d35b..c9ca141 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -20,10 +20,12 @@ defmodule Plox.DateTimeScale do @spec new(first :: datetime(), last :: datetime()) :: t() def new(first, last) - def new(%date_time_module{} = first, %date_time_module{} = last) when date_time_module in [DateTime, NaiveDateTime] 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} @@ -35,7 +37,8 @@ defmodule Plox.DateTimeScale do end defimpl Plox.Scale do - def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do + def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) + when tz != "Etc/UTC" do scale.first |> Stream.unfold(fn current_dt -> if DateTime.after?(current_dt, scale.last) do @@ -66,17 +69,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 - def convert_to_range(%{first: %date_time_module{}} = scale, %date_time_module{} = value, to_range) + 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 date_time_module.compare(value, scale.last) == :gt do @@ -104,7 +113,8 @@ defmodule Plox.DateTimeScale do defp shift_by(%DateTime{} = datetime, 0, :days), do: datetime # Positive shifts - defp shift_by(%DateTime{year: year, month: month, day: day} = datetime, value, :days) when value > 0 do + defp shift_by(%DateTime{year: year, month: month, day: day} = datetime, value, :days) + when value > 0 do ldom = :calendar.last_day_of_the_month(year, month) cond do 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_scale_test.exs b/test/plox/color_scale_test.exs new file mode 100644 index 0000000..0036b30 --- /dev/null +++ b/test/plox/color_scale_test.exs @@ -0,0 +1,11 @@ +defmodule Plox.ColorScaleTest do + use ExUnit.Case + + alias Plox.ColorScale + + doctest ColorScale + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/data_point_test.exs b/test/plox/data_point_test.exs new file mode 100644 index 0000000..6a4505f --- /dev/null +++ b/test/plox/data_point_test.exs @@ -0,0 +1,14 @@ +defmodule Plox.DataPointTest do + use ExUnit.Case + + alias Plox.DataPoint + + doctest DataPoint + + test "new/3" do + data_point = DataPoint.new(1, %{foo: 1, bar: 2}, %{x: 1, y: 2}) + assert data_point.id == 1 + assert data_point.original == %{foo: 1, bar: 2} + assert data_point.mapped == %{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..d5947b2 --- /dev/null +++ b/test/plox/dataset_test.exs @@ -0,0 +1,22 @@ +defmodule Plox.DatasetTest do + use ExUnit.Case + + alias Plox.DataPoint + alias Plox.Dataset + + doctest Dataset + + test "new/2" do + scale = Plox.number_scale(0, 10) + axes = %{x: {scale, & &1.foo}, y: {scale, & &1.bar}} + data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] + dataset = Dataset.new(data, axes) + + assert dataset.scales == %{x: scale, y: scale} + + assert dataset.data == [ + %DataPoint{id: 0, original: %{foo: 1, bar: 2}, mapped: %{x: 1, y: 2}}, + %DataPoint{id: 1, original: %{foo: 2, bar: 3}, mapped: %{x: 2, y: 3}} + ] + end +end diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs new file mode 100644 index 0000000..eedc203 --- /dev/null +++ b/test/plox/date_scale_test.exs @@ -0,0 +1,17 @@ +defmodule Plox.DateScaleTest do + use ExUnit.Case + + alias Plox.DateScale + + doctest DateScale + + test "new/2" 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]) + end + + test "new/2 given a range with a step it reduces it 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]) + 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..71d234a --- /dev/null +++ b/test/plox/date_time_scale_test.exs @@ -0,0 +1,17 @@ +defmodule Plox.DateTimeScaleTest do + use ExUnit.Case + + alias Plox.DateTimeScale + + doctest DateTimeScale + + test "the truth" do + assert 1 + 1 == 2 + end + + test "new/2" 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 +end diff --git a/test/plox/dimensions_test.exs b/test/plox/dimensions_test.exs new file mode 100644 index 0000000..85d3b40 --- /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(%{width: 100, height: 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..c16e8c5 --- /dev/null +++ b/test/plox/fixed_colors_scale_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.FixedColorsScaleTest do + use ExUnit.Case + + doctest Plox.FixedColorsScale + + test "the truth" do + assert 1 + 1 == 2 + 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..b77871c --- /dev/null +++ b/test/plox/fixed_values_scale_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.FixedValuesScaleTest do + use ExUnit.Case + + doctest Plox.FixedValuesScale + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/graph_dataset_test.exs b/test/plox/graph_dataset_test.exs new file mode 100644 index 0000000..c1f96ab --- /dev/null +++ b/test/plox/graph_dataset_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.GraphDatasetTest do + use ExUnit.Case + + doctest Plox.GraphDataset + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/graph_point_test.exs b/test/plox/graph_point_test.exs new file mode 100644 index 0000000..e6ef63a --- /dev/null +++ b/test/plox/graph_point_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.GraphPointTest do + use ExUnit.Case + + doctest Plox.GraphPoint + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/graph_scalar_test.exs b/test/plox/graph_scalar_test.exs new file mode 100644 index 0000000..24877e6 --- /dev/null +++ b/test/plox/graph_scalar_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.GraphScalarTest do + use ExUnit.Case + + doctest Plox.GraphScalar + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/graph_scale_test.exs b/test/plox/graph_scale_test.exs new file mode 100644 index 0000000..f1a19b5 --- /dev/null +++ b/test/plox/graph_scale_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.GraphScaleTest do + use ExUnit.Case + + doctest Plox.GraphScale + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/graph_test.exs b/test/plox/graph_test.exs new file mode 100644 index 0000000..1092785 --- /dev/null +++ b/test/plox/graph_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.GraphTest do + use ExUnit.Case + + doctest Plox.Graph + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox/number_scale_test.exs b/test/plox/number_scale_test.exs new file mode 100644 index 0000000..ff35095 --- /dev/null +++ b/test/plox/number_scale_test.exs @@ -0,0 +1,14 @@ +defmodule Plox.NumberScaleTest do + use ExUnit.Case + + doctest Plox.NumberScale + + test "the truth" do + assert 1 + 1 == 2 + end + + test "scale" do + scale = Plox.NumberScale.new(0, 100) + Plox.Scale.convert_to_range(scale, 200, 0..1000) + end +end diff --git a/test/plox/scale_test.exs b/test/plox/scale_test.exs new file mode 100644 index 0000000..420e508 --- /dev/null +++ b/test/plox/scale_test.exs @@ -0,0 +1,9 @@ +defmodule Plox.ScaleTest do + use ExUnit.Case + + doctest Plox.Scale + + test "the truth" do + assert 1 + 1 == 2 + end +end diff --git a/test/plox_test.exs b/test/plox_test.exs index 0dd049c..cd25a17 100644 --- a/test/plox_test.exs +++ b/test/plox_test.exs @@ -1,3 +1,9 @@ defmodule PloxTest do use ExUnit.Case + + doctest Plox + + test "the truth" do + assert 1 + 1 == 2 + end end From 7018fd068f9634f01e712d69805f464ccb9d5dcc Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Thu, 17 Oct 2024 16:29:12 -0400 Subject: [PATCH 04/23] Change points_plot to circles + Update icon SVG --- README.md | 2 +- images/plox-icon.svg | 2 +- lib/plox.ex | 24 ++++++++++++++++++------ lib/plox/dataset.ex | 18 ++++++++++-------- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f230fd1..3453b21 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,6 @@ Finally, render the `Plox.Graph` directly within your HEEx template: <.line_plot dataset={graph[:dataset]} color="#EC7E16" /> - <.points_plot dataset={graph[:dataset]} color="#EC7E16" /> + <.circles dataset={graph[:dataset]} color="#EC7E16" /> ``` 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/lib/plox.ex b/lib/plox.ex index 93b65e7..4c6a382 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -58,7 +58,7 @@ defmodule Plox do {render_slot(@inner_block)} <%!-- <%= for tooltip <- @tooltips do %> - <%= render_slot(tooltip) %> + {render_slot(tooltip)} <% end %> --%>
@@ -377,8 +377,8 @@ defmodule Plox do 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 :cx, :atom, default: :x, doc: "The dataset axis key to use for x values" + attr :cy, :atom, default: :y, doc: "The dataset axis key to use for y values" attr :r, :any, examples: ["8", "24.5", :radius_axis], default: "4" attr :fill, :any, examples: ["red", "#FF9330", :color_axis], default: nil @@ -386,13 +386,22 @@ defmodule Plox do # attr :"phx-click", :any, default: nil # attr :"phx-target", :any, default: nil - def points_plot(assigns) do + def circles(assigns) do ~H""" + <%!-- --%> + @@ -402,6 +411,9 @@ defmodule Plox do defp maybe_graph(assign, data_point) when is_atom(assign), do: data_point.graph[assign] defp maybe_graph(assign, _data_point), do: assign + defp maybe_axis({axis_name, value}, dataset, _data_point), do: Axis.to_graph(dataset.axes[axis_name], value) + defp maybe_axis(axis_name, _dataset, data_point), do: data_point.graph[axis_name] + @doc """ Bar plot. """ diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index 002491e..42f0963 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -5,20 +5,22 @@ defmodule Plox.Dataset do alias Plox.Axis alias Plox.DataPoint - defstruct [:data] + defstruct [:data, :axes] def new(original_data, axis_fns) do data = - original_data - |> Enum.map(fn original -> - graph = - Map.new(axis_fns, fn {key, {axis, fun}} -> - {key, Axis.to_graph(axis, fun.(original))} - end) + Enum.map(original_data, fn original -> + graph = Map.new(axis_fns, fn {key, {axis, fun}} -> {key, Axis.to_graph(axis, fun.(original))} end) DataPoint.new(original, graph) end) - %__MODULE__{data: data} + # %{axis_name => {axis, function}, etc.} + axes = + Map.new(axis_fns, fn {key, {axis, _fun}} -> + {key, axis} + end) + + %__MODULE__{data: data, axes: axes} end end From 5a85816378ff361e5391418c6de3e932910c02cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Thu, 17 Oct 2024 17:06:53 -0700 Subject: [PATCH 05/23] Implement Access and Enumberable protocols for Datasets --- lib/plox.ex | 100 ++++++++++++++++++++++++++---------- lib/plox/dataset.ex | 63 ++++++++++++++++++++++- lib/plox/date_time_scale.ex | 18 ++----- 3 files changed, 141 insertions(+), 40 deletions(-) diff --git a/lib/plox.ex b/lib/plox.ex index 4c6a382..e733e30 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -371,48 +371,96 @@ defmodule Plox do defp polyline_points(points), do: Enum.map_join(points, " ", &"#{&1.x},#{&1.y}") @doc """ - Points plot. + Draws a single or set of SVG elements. """ @doc type: :component - attr :dataset, :any, required: true - - attr :cx, :atom, default: :x, doc: "The dataset axis key to use for x values" - attr :cy, :atom, default: :y, doc: "The dataset axis key to use for y values" - - attr :r, :any, examples: ["8", "24.5", :radius_axis], default: "4" - attr :fill, :any, examples: ["red", "#FF9330", :color_axis], default: nil + # 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 - # attr :"phx-click", :any, default: nil - # attr :"phx-target", :any, default: nil - def circles(assigns) do + def circle(assigns) do + assigns + |> assign(dataset: validate_zero_or_one_dataset(Map.take(assigns, [:cx, :cy, :r, :fill]))) + |> do_circle() + end + + defp do_circle(%{dataset: :none} = assigns) do ~H""" - <%!-- --%> + + """ + end + defp do_circle(%{dataset: _dataset} = assigns) do + ~H""" """ end - defp maybe_graph(assign, data_point) when is_atom(assign), do: data_point.graph[assign] - defp maybe_graph(assign, _data_point), do: assign + defp validate_zero_or_one_dataset(assigns) do + datasets = + assigns + |> Enum.flat_map(fn + {_key, %Plox.DatasetAxis{} = dataset_axis} -> [dataset_axis.dataset] + {_key, _} -> [] + end) + |> Enum.uniq() + + case datasets do + [] -> :none + [dataset] -> dataset + _ -> raise "all dynamic values must be from the same dataset" + end + end + + # TODO: can we make this work elegantly? + # would it be cool to allow mixed datasets? + # defp collect_values(values) do + # {dynamics, constants} = + # Enum.split_with(values, fn + # {_key, %Plox.DatasetAxis{}} -> true + # {_key, _value} -> false + # end) + + # grouped_dynamics = dynamics |> Enum.group_by(fn {_key, value} -> value.dataset end, fn {key, value} -> {value.key, key} end) |> Enum.to_list() + + # constants_map = Map.new(constants) + + # case length(grouped_dynamics) do + # 0 -> + # [constants_map] + + # 1 -> + # [{dataset, keys}] = grouped_dynamics + # for data_point <- dataset.data do + + # end + # _ -> + # raise "all dynamic values must be from the same dataset" + # end + # end + + # TODO: a huge "dataset axis" struct just to get the key... + # I think this can be made way more elegant + defp maybe_graph(%Plox.DatasetAxis{key: key}, data_point), do: data_point.graph[key] + defp maybe_graph(value, _data_point), do: value - defp maybe_axis({axis_name, value}, dataset, _data_point), do: Axis.to_graph(dataset.axes[axis_name], value) - defp maybe_axis(axis_name, _dataset, data_point), do: data_point.graph[axis_name] + # defp maybe_axis({axis_name, value}, dataset, _data_point), do: Axis.to_graph(dataset.axes[axis_name], value) + # defp maybe_axis(axis_name, _dataset, data_point), do: data_point.graph[axis_name] @doc """ Bar plot. diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index 42f0963..3e498f4 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -1,7 +1,52 @@ +defmodule Plox.DatasetAxis do + @moduledoc false + @behaviour Access + + defstruct [:dataset, :axis, :key] + + @impl Access + def fetch(%__MODULE__{axis: axis}, value) do + {:ok, Plox.Axis.to_graph(axis, value)} + end + + @impl Access + def pop(_axis, _key) do + raise "Not implemented" + end + + # TODO: not currently being used, but maybe we can? + @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 """ + @behaviour Access + alias Plox.Axis alias Plox.DataPoint @@ -15,7 +60,6 @@ defmodule Plox.Dataset do DataPoint.new(original, graph) end) - # %{axis_name => {axis, function}, etc.} axes = Map.new(axis_fns, fn {key, {axis, _fun}} -> {key, axis} @@ -23,4 +67,21 @@ defmodule Plox.Dataset do %__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_time_scale.ex b/lib/plox/date_time_scale.ex index c9ca141..d5fffb8 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -20,12 +20,10 @@ defmodule Plox.DateTimeScale do @spec new(first :: datetime(), last :: datetime()) :: t() def new(first, last) - def new(%date_time_module{} = first, %date_time_module{} = last) - when date_time_module in [DateTime, NaiveDateTime] 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} @@ -37,8 +35,7 @@ defmodule Plox.DateTimeScale do end defimpl Plox.Scale do - def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) - when tz != "Etc/UTC" do + def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do scale.first |> Stream.unfold(fn current_dt -> if DateTime.after?(current_dt, scale.last) do @@ -81,11 +78,7 @@ defmodule Plox.DateTimeScale do |> elem(0) end - def convert_to_range( - %{first: %date_time_module{}} = scale, - %date_time_module{} = value, - to_range - ) + 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 date_time_module.compare(value, scale.last) == :gt do @@ -113,8 +106,7 @@ defmodule Plox.DateTimeScale do defp shift_by(%DateTime{} = datetime, 0, :days), do: datetime # Positive shifts - defp shift_by(%DateTime{year: year, month: month, day: day} = datetime, value, :days) - when value > 0 do + defp shift_by(%DateTime{year: year, month: month, day: day} = datetime, value, :days) when value > 0 do ldom = :calendar.last_day_of_the_month(year, month) cond do From e765b3bfb28369b73170e6331aef5f6545b27d55 Mon Sep 17 00:00:00 2001 From: Kurtis Rainbolt-Greene Date: Wed, 29 Jan 2025 10:23:39 -0800 Subject: [PATCH 06/23] Fetch padding from dimensions options instead of margin (#14) --- lib/plox/dimensions.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plox/dimensions.ex b/lib/plox/dimensions.ex index ffa0176..2de6d30 100644 --- a/lib/plox/dimensions.ex +++ b/lib/plox/dimensions.ex @@ -10,7 +10,7 @@ defmodule Plox.Dimensions do def new(width, height, opts \\ []) do margin = Keyword.get(opts, :margin, {35, 70}) - padding = Keyword.get(opts, :margin, 0) + padding = Keyword.get(opts, :padding, 0) %__MODULE__{ width: number(width), From 5733d69d2520d6e3f61b0278cfcf29f778723380 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Thu, 3 Jul 2025 16:43:43 -0700 Subject: [PATCH 07/23] Update demo with new example graph --- demo_live.exs | 146 ++++++++++++++++++++++++-------------------------- lib/plox.ex | 10 ++-- 2 files changed, 75 insertions(+), 81 deletions(-) diff --git a/demo_live.exs b/demo_live.exs index b031e41..a58d220 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -19,101 +19,95 @@ defmodule DemoLive do @impl Phoenix.LiveView def render(assigns) do ~H""" - <.simple_line_graph simple_graph={@simple_graph} clicked_point={@clicked_point} /> + <.simple_line_graph {@graph} /> """ end - @impl Phoenix.LiveView - def handle_event( - "toggle_tooltip", - %{"id" => point_id, "dataset_id" => dataset_id, "x_pixel" => x_pixel, "y_pixel" => y_pixel}, - socket - ) do - {:noreply, - assign(socket, - clicked_point: %{ - id: point_id, - dataset_id: String.to_existing_atom(dataset_id), - x_pixel: x_pixel, - y_pixel: y_pixel - } - )} - 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} + ] - def handle_event("toggle_tooltip", _params, socket), do: {:noreply, assign(socket, clicked_point: nil)} + dimensions = Plox.Dimensions.new(670, 250) - defp mount_simple_line_graph(socket) do - simple_data = [ - %{date: ~D[2023-08-01], value: 45.0}, - %{date: ~D[2023-08-02], value: 40.0}, - %{date: ~D[2023-08-03], value: 35.0}, - %{date: ~D[2023-08-04], value: 60.0}, - %{date: ~D[2023-08-04], value: 10.0}, - %{date: ~D[2023-08-05], value: 15.0}, - %{date: ~D[2023-08-06], value: 25.0}, - %{date: ~D[2023-08-07], value: 20.0}, - %{date: ~D[2023-08-08], value: 10.0} - ] - - date_scale = date_scale(Date.range(~D[2023-08-01], ~D[2023-08-08])) - number_scale = number_scale(0.0, 80.0) + 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 = - dataset(simple_data, - x: {date_scale, & &1.date}, - y: {number_scale, & &1.value} + 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, - simple_graph: - to_graph( - scales: [date_scale: date_scale, number_scale: number_scale], - datasets: [dataset: dataset] - ), - clicked_point: nil + graph: %{ + x_axis: x_axis, + y_axis: y_axis, + dataset: dataset, + dimensions: dimensions + } ) end defp simple_line_graph(assigns) do ~H""" -

Simple line graph with legend and tooltips

- - <.graph :let={graph} id="simple_graph" for={@simple_graph} width="800" height="250"> - <:legend> - <.legend_item color="orange" label="data" /> - <.legend_item color="green" label="more data" /> - - - <:tooltips :let={graph}> - <.tooltip - :let={%{value: value, date: date}} - :if={!is_nil(@clicked_point)} - dataset={graph[@clicked_point.dataset_id]} - point_id={@clicked_point.id} - x_pixel={@clicked_point.x_pixel} - y_pixel={@clicked_point.y_pixel} - phx-click-away="toggle_tooltip" - > -

date: {date}

-

value: {value}

- - - - <.x_axis :let={date} scale={graph[:date_scale]}> - {Calendar.strftime(date, "%-m/%-d")} - +

Example graph

- <.y_axis :let={value} scale={graph[:number_scale]} ticks={5}> - {value} - + <.graph dimensions={@dimensions}> + <.x_axis_labels :let={date} axis={@x_axis} class="text-sm"> + {Calendar.strftime(date, "%-m/%-d")} + - <.line_plot dataset={graph[:dataset]} /> + <%!-- 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} + class="text-sm fill-red-600 dark:fill-red-500" + > + {"Important Day"} + - <.points_plot dataset={graph[:dataset]} phx-click="toggle_tooltip" /> + <.x_axis_grid_lines axis={@x_axis} stroke="grey" /> - <.marker at={~D[2023-08-03]} scale={graph[:date_scale]}> - Important date! - + <.y_axis_labels :let={value} axis={@y_axis} ticks={5} class="text-sm"> + {value} + + + <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="grey" /> + + <.line_plot dataset={@dataset} stroke="orange" stroke-width={2} /> + + <%!-- Access behavior with axes --%> + <%!-- <.polyline points={{@dataset[:x], @dataset[:y]}} class="stroke-orange-500 dark:stroke-orange-400 stroke-2" /> --%> + <%!-- constant y = 40 --%> + <%!-- <.polyline points={{@dataset[:x], @dataset[:y][40]}} class="stroke-orange-500 dark:stroke-orange-400 stroke-2" /> --%> + <%!-- <.circles dataset={@dataset} cx={:x} cy={:y} fill={:color} r={:radius} /> --%> + <%!-- 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 diff --git a/lib/plox.ex b/lib/plox.ex index e733e30..370e957 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -8,14 +8,14 @@ defmodule Plox do alias Phoenix.LiveView.JS alias Plox.Axis alias Plox.Dataset - alias Plox.DateScale - alias Plox.DateTimeScale + # alias Plox.DateScale + # alias Plox.DateTimeScale alias Plox.Dimensions - alias Plox.FixedColorsScale - alias Plox.FixedValuesScale + # alias Plox.FixedColorsScale + # alias Plox.FixedValuesScale alias Plox.GraphDataset alias Plox.GraphScale - alias Plox.NumberScale + # alias Plox.NumberScale alias Plox.Scale alias Plox.XAxis alias Plox.YAxis From 8dd415e735b282c02287c481cfcedd97214765b9 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Mon, 7 Jul 2025 14:51:57 -0700 Subject: [PATCH 08/23] Add logo example graph + Add animated example graph --- animated_demo_live.exs | 221 +++++++++++++++++++++++++++++++++++++++++ demo_live.exs | 131 ++++++++++++++++++++++-- 2 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 animated_demo_live.exs diff --git a/animated_demo_live.exs b/animated_demo_live.exs new file mode 100644 index 0000000..23904fe --- /dev/null +++ b/animated_demo_live.exs @@ -0,0 +1,221 @@ +Mix.install([ + {:phoenix_playground, "~> 0.1.6"}, + {:plox, path: "."} +]) + +defmodule AnimatedDemoLive do + @moduledoc """ + Example animated graph rendered within a Phoenix Playground application. + """ + 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="grey" /> + + <.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="grey" /> + <.x_axis_grid_line axis={@x_axis} value={@x_axis.scale.first} stroke="grey" /> + <.x_axis_grid_line axis={@x_axis} value={@x_axis.scale.last} stroke="grey" /> + + <.x_axis_label axis={@x_axis} value={@now} position={:top} fill="red"> + Now ({Calendar.strftime(@now, "%-I:%M:%S")}) + + + <.x_axis_grid_line axis={@x_axis} value={@now} stroke="red" /> + + <.polyline dataset={@dataset1} stroke="orange" stroke-width="2" /> + <.polyline dataset={@dataset2} stroke="blue" stroke-width="2" /> + <.polyline dataset={@dataset3} stroke="green" stroke-width="2" /> + <%!-- <.circles dataset={@points_dataset} r={:r} fill={: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/demo_live.exs b/demo_live.exs index a58d220..e6afa86 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -13,13 +13,17 @@ defmodule DemoLive do @impl Phoenix.LiveView def mount(_params, _session, socket) do - {:ok, mount_simple_line_graph(socket)} + {:ok, socket |> mount_simple_line_graph() |> mount_logo_graph()} end @impl Phoenix.LiveView def render(assigns) do ~H""" <.simple_line_graph {@graph} /> + +
+ + <.logo_graph {@logo_graph} /> """ end @@ -67,23 +71,18 @@ defmodule DemoLive do

Example graph

<.graph dimensions={@dimensions}> - <.x_axis_labels :let={date} axis={@x_axis} class="text-sm"> + <.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} - class="text-sm fill-red-600 dark:fill-red-500" - > + <.x_axis_label axis={@x_axis} value={~D[2023-08-02]} position={:top} fill="red"> {"Important Day"} <.x_axis_grid_lines axis={@x_axis} stroke="grey" /> - <.y_axis_labels :let={value} axis={@y_axis} ticks={5} class="text-sm"> + <.y_axis_labels :let={value} axis={@y_axis} ticks={5}> {value} @@ -111,6 +110,120 @@ defmodule DemoLive do """ end + + defp mount_logo_graph(socket) do + dimensions = Plox.Dimensions.new(440, 250) + x_axis = Plox.XAxis.new(Plox.NumberScale.new(0.0, 10.0), dimensions) + y_axis = Plox.YAxis.new(Plox.NumberScale.new(0.0, 6.0), dimensions) + + # Letter "P" + p_data = [ + %{x: 1, y: 5}, + %{x: 2.5, y: 4}, + %{x: 1, y: 3}, + %{x: 1, y: 1} + ] + + p_dataset = + Plox.Dataset.new(p_data, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y} + ) + + # Letter "L" + l_data = [ + %{x: 3.5, y: 4.5}, + %{x: 3.5, y: 1} + ] + + l_dataset = + Plox.Dataset.new(l_data, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y} + ) + + # Letter "O" + o_data = [ + %{x: 4.5, y: 2}, + %{x: 5.5, y: 3}, + %{x: 6.5, y: 2}, + %{x: 5.5, y: 1}, + %{x: 4.5, y: 2} + ] + + o_dataset = + Plox.Dataset.new(o_data, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y} + ) + + # Letter "X" + x1_data = [ + %{x: 7, y: 3}, + %{x: 9, y: 1} + ] + + x1_dataset = + Plox.Dataset.new(x1_data, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y} + ) + + x2_data = [ + %{x: 7, y: 1}, + %{x: 9, y: 3} + ] + + x2_dataset = + Plox.Dataset.new(x2_data, + x: {x_axis, & &1.x}, + y: {y_axis, & &1.y} + ) + + assign(socket, + logo_graph: %{ + dimensions: dimensions, + x_axis: x_axis, + y_axis: y_axis, + p_dataset: p_dataset, + l_dataset: l_dataset, + o_dataset: o_dataset, + x1_dataset: x1_dataset, + x2_dataset: x2_dataset + } + ) + end + + defp logo_graph(assigns) do + ~H""" +

Logo graph

+ + <.graph dimensions={@dimensions}> + <.x_axis_labels :let={value} axis={@x_axis}> + {value} + + + <.y_axis_labels :let={value} axis={@y_axis} ticks={7}> + {value} + + + <.x_axis_grid_lines axis={@x_axis} stroke="grey" /> + + <.y_axis_grid_lines axis={@y_axis} ticks={7} stroke="grey" /> + + <.line_plot dataset={@p_dataset} stroke-width="5" stroke="#FF9330" /> + <%!-- <.circles dataset={@p_dataset} r="8" fill="#FF9330" /> --%> + <.line_plot dataset={@l_dataset} stroke-width="5" stroke="#78C348" /> + <%!-- <.circles dataset={@l_dataset} r="8" fill="#78C348" /> --%> + <.line_plot dataset={@o_dataset} stroke-width="5" stroke="#71AEFF" /> + <%!-- <.circles dataset={@o_dataset} r="8" fill="#71AEFF" /> --%> + <.line_plot dataset={@x1_dataset} stroke-width="5" stroke="#FF7167" /> + <%!-- <.circles dataset={@x1_dataset} r="8" fill="#FF7167" /> --%> + <.line_plot dataset={@x2_dataset} stroke-width="5" stroke="#FF7167" /> + <%!-- <.circles dataset={@x2_dataset} r="8" fill="#FF7167" /> --%> + + """ + end end PhoenixPlayground.start(live: DemoLive) From b56fc77cdc24630f4bf2d34a7bd794cdf34772e1 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Tue, 8 Jul 2025 16:21:03 -0700 Subject: [PATCH 09/23] Update README and add migration_guide --- README.md | 53 +++++++------- demo_live.exs | 4 +- images/readme-example-plot@2x.png | Bin 56884 -> 31206 bytes lib/plox.ex | 14 ---- migration_guide.md | 110 ++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 migration_guide.md diff --git a/README.md b/README.md index 3453b21..72d85d8 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 dataset={@dataset} stroke="#EC7E16" stroke-width={2} /> - <.circles dataset={graph[:dataset]} color="#EC7E16" /> + <.circle cx={@dataset[:x]} cy={@dataset[:y]} r={3} fill="#EC7E16" /> ``` diff --git a/demo_live.exs b/demo_live.exs index e6afa86..a27e66f 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -1,5 +1,5 @@ Mix.install([ - {:phoenix_playground, "~> 0.1.6"}, + {:phoenix_playground, "~> 0.1.7"}, {:plox, path: "."} ]) @@ -76,7 +76,7 @@ defmodule DemoLive do <%!-- 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} fill="red"> + <.x_axis_label axis={@x_axis} value={~D[2023-08-02]} position={:top} color="red"> {"Important Day"} diff --git a/images/readme-example-plot@2x.png b/images/readme-example-plot@2x.png index a5b028dfaf1d2d965660fcd0308fa4e6b8dc17ce..1f683b324e364b95bf83d3c7c34f870aa5063274 100644 GIT binary patch literal 31206 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 r%nZ=63z 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 LNizHT{b&CN(h|xjC>Sj$D5w%x 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%0Dd`y(8slSkaHlpRGa)yh=c0` za`1)bElcv}wC8X&K5dnR1MfGD4+{sNseFfg$t?&7+K%VNnvnKDomGn|n&+NjW@Ab9 zvmSNhqH_x1w%&_xPA)emVfx90!~7?Y=|gW<4%hNS>R3*N0tCl}9!F>O?fLoL6r*1) z9b(912mJ60U@Y%Wvi#V+5Qs)3=iWIx7#$Ifcn1;U);;??(;c$?pnjrSmRiB@o+Hg{;dN!Ted}W zq=as&N^IY~!m{&1I7V9M<;j-;mbCj0aJIX+yc)B{4Qxf|iAuFHmI)un-_G3Z^9s0Z zf)lTf*AJ8+wV>FbhabPvHChf5b7|+X)!UN*2*^{D4cWzU9C%VSM&uXl=$sGd+u&lXNXQ{c8UyZID28Ps=nC4>hHeXxKo~! z(#tC(k(-q8YBb;-bHKR%tr@`#wN+vBDZ(XP>vH8G*M0X94=zs5K!mtVw5ATHcM- zbC<&I*q7h=JxR9cxgWWfydh4^FvPI_G`f*DK;yW%s;`9~mf@8dI=SvX6|Mg`^7dc2~{;w|DZ#w!v&eEpKT8mW`+5>qgXzX>9A=6&b=-5}T0 z&xA|nZzb5@iZ7+N<-nF;1w=zZ%UZ>`u)GAl zic*{keMzG*tXzgVYh`W#{H0cn-kiOQK4a39!FD8WQNm{F!X1SQSIyL zQpFq2MZe67YZ!m(lk`xEC9Pz;<5!;)<$zXJFDQCQiO4y4FA>{;qhI7j#1yQoBp2G& zYUGILr07M`Vot%VSfEskr%ce5zmb{AZx+=Ua-YzzZ(5ZO*fwgWkGwK< z@8P~sjj$9w#n6*i|K*y<`oQg_hDQFsZtf-Niv}{Y zDGkYps_)K}1I6f<;kBS6=Mg5^hTk?*Yp2;(k8dr;`MF*?2@bi>F6sm2H#{m^lG^7dz#wSyZgntN1T-Ksv`Z+Iohd%qW1+?{O!;ozBzZEuV-^$;w6@V#N(;s8HCrONG~N9~d2Q1tbSkey~%t>DVjw;xH3 zesYDTZZ!|q*Xy#=I(JIuS@Dstg~jPm#k=n4Rz9sOdyw40zC}u{Hz;fFTqAdSmYMHQ z2FB$I<7lrWa}M(>iwbkZpL;@i_ErrQ>vOExtiLYQ*eGsWCOs%S;AiRB$#Xa<7MAW} z4Yw1Y-@J}#Lw|lt-tsfLW+>g?T;j!JVx7k*JyoBj6~UG>-k7}|)2RWptv~5#>H6&D zzO~u5o3jq38E$DRX|fIL3&VyZ_?tuSt}Irhq@*nUT0-}VEX~U{G+JaTtoJK2wJdej zm}dED_s2K;fbCR#S}W9ftSD38v0y4SMU^gv_sv@h*C+Y#|NdGB99W2dElqBzc{gmM z?-coh0+;p;xi$Hz8Aq6av{)cJhTHjV?1iY~AL_JzZ9O(axfT{pChr+2;8;y%b7jHk zo@!zge)wIcVc1y4TG#+XZTHJ81sSI#=g#b6%AA)UM_;p88^KSjKp0?5hbu-%hj{N5 zkVj{48E?6i=OQXTjxTrG@lFSGl7Ao{9+&C!yH+T(Xq8o<=YuMt%`X;T-3Y-Be)pBA zG#OaC@=vt|=E9^)h)vb&tWKuW0NKUj3%_tB(k309lBj*08n zrSdhcYr=BVTf)Wm;fL!moqBNt`7yj-*U+wuHvYWxXwbckJCfIP&T9;&DTJg3Wjc|L z%Dtt(+E)Z-hJ4fea*t}WD_hrO!H1)#sZTB32yaeTa`==|0Kf14C%qN7*Nc*CkLKWS z79!Mh{-oHeX^j5>I@}ej5J{`&Tx?a(ag*Y2sM#OX$E<)O;hDe1~EiBvqoE=r=~GN+8ULa?YNCAQi1?{o3x3KljF3|#Z`BQtCY(FN;|Z%OS1h=vUH51_I0jxU^C z4Ec;4BMTBZLkQX{5EYM_gqD7PtGs&48a?(17ZVHDtU5DOCi#cO?IzU%(rY1E%uu37k=W~8fPj?|H! zJD|C*yt6ENl34pqGEc8jxJZ7BJh@pQqN}?87?%ySeU0;v*a$?MR-ztSI~LUu%rU4e z?{OkmHeBDY2u@da-rcKVQLotK$vuS*3FSFyJpOwH@+U^>_{tH~G1Acau+~gknQQ!) zCBmBK1Am)8tm)c*yjXh9r&7=JUHI|G%DDpRm$FGmT@=Im7fQbUya<`!aKJI%sk}`U zZ~JKNvfQ>?g?w!7$mIa@!L1GaRn)WbP1K64Tmb#$zUlo8E^0r6;Y=-_-BAdxAk~tvgY%FJEU5pEPRxD6*Qj`6V>-Vqh2H()y5Vixa-y@qt2%h!yc9?ZWV_ z_x2Nf`u8$Gd^XChg|oqb*WFzuqTYm}xF4ic~fWl59n*3NNy#Q0hZ&qL^|R}+(SoHnhGEbt6@+@LuTVrw89gBTeJ4()3QW7&5WQ#hteOeS)_Vy*Jg9 zn?yzPxN=SVo9WLgyr4eVLRWwE)Q&)&A^NgK*KC6_?p-Iw(f-v$&@`hOmgP;`sGLwr zH})I|=|dtRekEe3UE)gm*1Nl^8EoDr;{BBiiC9&cCRRvmdQ;N?#fbs_qeiQlK>v0v zCD^f9Q$0u8_>feRFGJ|K4SO&uMY;?F{vPV`2 z@flNRXLLs>qfBM>N~KCfWd0)s508p9<`#bctRl0a8%SFTz!rin(^leb67aSG=b0v< znkU6_E=6ve$j!I$2FA0)p8K8+o_ko@AW`=bK>F?Jx0BNIW^yX8!_cbWh)a!(OnaGg zzPvlU7GPyYiV0cnIOHLw9+BhUU~Q)9At2yn_iAl?yQLt)>aibIYY$&PHlrzwrCH?} z1Vvi1E`#vEt_A1J8TK(?`6y7gUWd>mCc_X&U?L90#p;lr;GzGm*jpyY_Q~e&-`EIdl zeEyP5=0iY00@*x%Xf(P#(Y?v@Lgp<$WCK@PbwQ_XkfE4)u=wf+L0Y5tH(<#@)7XuGBse?p{$e?#5v zq|u5DjY5tVu{wC!%-Dje1cSmyCl{9&E^{@bg24Pj{@dwL!)8`R#t021k~)K@&X zWKG8bid2OLT9t^OIjgA;^U=07g9{{!D;0XKJ?$oaFTc)G$b;o;W93fYfeiEN@SlS#%qB`L$&~{Mc_= znRzw;QA<~Q){eIE{;2;eMi;dwm2*WQewe(E?Lt=)^N!xz3gs{0kRMlkIy_!tuoeA` z;e|Wc`)%yBTfn5}mvNi~xkC$)B7CQ7?e!OcFcrsAZXBT@d z3X#4OxE8uWKj*p3TH<=s?R2z$%$%!Fe!nwzUR>RBerKb$760!%f06g6S)amLWjFN( zTBudDV`w|I_Z@9hj9VOoh4!wu^)Eu54^cYd8vZX|%0<3-w!Omhio#S^B&vJoEbAGN zem&<3FK4HbCzS19+r-}t`C;gQW|5ak-Z z`2D_i@ont9Xq5GGuuala6@omSDqkA`zqDtGATT8)1Xqg*J+TmM{mm2`E2RJRx@U*H z62Z`DE;ULV)jN+0A-LhOnB#oYjmI$Jsm!KASgj)ip#TcblZ55yWTuJ1jpVVT~?9Q>Q z4V*W8d3GIf_K?8B!Dql2dH7rSB}rQAU=my2&YLmj&?(SEwnQrW%9xnG16j}!{CY|v zi-r8{Tim_|p*SSx*K>g6jBTjN4o*oB^})k?;DmHxo}I_d(`#YA*2``zX-@ZRg$nOc z6&Fu(eh28--0e}GQuufPH`Gf-C2ty|8R*9cof{ewa*q6`?Ysh`Ppd4VkTMH#<>q)? z+7|^>?QT97uGjZMY!O1ckdFW735GuNJ~~$q!9RuN$Ikv+{c7*T@j6FXNk{8Th18Mb zn!{cXJkcA4GB*2_g|t>r3^l9*(lVcQ?;|ZP`o>>Vne`{lyo2L9iCFcSI&B1|j$(1A zfbfRrLwsz^&YlJo8?p7mOf?V?sh-)25M)Abc@n%-;~9~#cR#hhcD$L|v9htowo95q zEjJ;dZiO)HHIY_VDl~Fc*qhAJ)SN4(H0`!6*g+)~x~b|5 zqEMIrK7Irx0gzk-nkvBd5pnX7a}V+lVNDBY#rQj9DYu`9MGs!cF`)G|t+Fp`R9Qq9 z-B@?_9w5nY^^#vA~f&2w3IluYTRjO1~#Hw&TzVp_6>@j^2J)2uQ zTcjio*@Dd>x~&>|b6bxq@3f~sndrn1^t1@tcq0Q$@0xbC#87xh8n5rEP7vy+>E~Cj z%^!6gcxZ+*{bVJ?s#H2=>^o?Tm|WpddIh_={jWGV`TYY2Fjn2>Nv!KJ8Ai4)M$?=K zCOSAN<8-770XN}SZ2xbx=0*40H`tDIlO}oeMLUwW1lD7xg|7frFhi8i$Z?mcxVc<2 zEAF|XgO6jc-#xNa=hV5?%8M??3=EeWFRjWf&MEn?aG`i>`aXViQf|>IY#m>EosUTv z0q*IT`NuF0cXzGxuy>>CGk)g&bu0b5xZ#;dW<<=7(8wQUHqaMb9@p%opy1Go71^?~ zM|ti2Ok2ZmZbfCM>UU~vem<_xBa&vAaNs3oCl9@(tK{wDPSW_b_qP@XY6}SGmd^Ii zSpQy)ag8q#i(h_N`c!R4?9Q1f4g6@o%w7Ai^QT&Gyv6Va1nd^bEVf}K?cWt~&S&0h z!VOa;JolRRRO3P20@WG99FRdfCW1PaV@&bRt^AbZ3OKr`RfLdmWteh_&qEQvzv9|l zdD9$)<8WgQW*Z64V}6b#n+=@D7&UNjU8As^n6ZbccBmw+&n4e?$H%jc=4+wmMM!j2 z;~!aPd9PZ~Xr)#X_T27~)6;LEjwq^X{>Ur4_z5x{2}%0i@j~xWJ9zaC)oCUb?rRV3 z_UQwoF;%T=+*7UKcZ*<9&45YsvtgN%>FfQ{%|4y4mq*&Vll98^fYq2Or26XraGL8= zj@PtJFd=egNcXk`kMq=7Vg|7@S~QMwEhibtYOGQx$Q_Fo*i?AaN=ldIbJ5Eq=Wmii z7C}(pb?ztJc^>%$1}eVdS1Y{W;BWk#lcvWNy`D~Rjn9`3U2cvV_4ibxR~D=3xb20w z3Bl$h_2`BrEzx#kb*WL&Mt2$iuY}>yZ@u;g$$TM3wQ_0>taS4R!pFTrcpB9wf zSD|U$ch}7Q%|(3N?Y7pnS4!eZsjo}7d(x2(EjFW`^0{8cU7=Z^X%QV zx^vXsihF%ltfuX~YnOn@*D9^IA!X*bQKgqYiL4R(Y&rC|efASB1(4ZicyO2CjfhTA*Kme z*5x#*D%Gs65x8QCuyihdB`OSYTg^5yP*~hY?XZ&b%pY^iwo)se6Y|F0SvinBrdfJd>(ZPk_0sd|f^Qid@00B$lEQoRaJ&I3)tZZrO$ZtC9KMehQJ z!@D(cpj9MIKfy%2RsHDl^(uV)WS!h9*6NwBL23MNx|6?eu@}2*K9mT#qhAkb1`ONgZN**^;d6q4zqo-PvLrgVDdiL zDfbtzS!#hhnQ7!I!eJ1rPzdVwCGjf?c0ULf8>Pt=;!D4X<-iZ8x{n$N#!wD&nNH--ltWqi z_rgSo_H}l6(HNG^haN1{43fdVFH~iCdnQ%GBPwj(I>{lVJJiGx=LO3HlB|U0gdFE74if+~R3FGdT`SqlDb;$VZq4oS;m2fc26er9XAPIt z?K?wpNaZdhW4?k9?^usPJ~gLiQb`Sq_v27ta&l6-)ezR8gjP8ZdO*b!9$kuF!gqPt z&zhjT05D~6ZoLAf+f|0_^1EV-C(7^YF}#ZG(s+l)&#YCTsiQ^hHvIvQPKn`wSVm*c0?y2d~^rv^g{C~>fVMpK6Sem3FY8-R9v{!=q==mV{ zjfZl$X;#-i4?*6LA@F`x>{C2RM{IfbQQyPpMwWSx2%og|Fy;Vy%r&YE}HGTi7XUjz-FdQS2S ziHCfOF+bjA7&8@85rv)a>WYh_-T%xl{WbAjlBL5dVb_3=0aUkN6NWW4&U+SDM&u!K zCMC}02($0S^3;^N#$Lu(?T^Dg?pC!F@uy;-7{pB!2eTw>o$si>{p$mGN7Y+Mzvp>- z8Z6-(jn{VAZS5c(Pa~i35I^5F$k^D5$Lj7DJ>QkYPaF zHP-r_)`*(TDM54pmZdGx2{hp+``b6s?BL6P6eVd7H5i@_bK-(XS{S zVCq~wU4kg?YiS(n2g13)4E(L_d2ZkMZtSlom&C?<1;#Jd%Ey zo;QA3+rWV8%4G)L=Lf4PjTXJPzjAWTVrvjuvd>u->nXG)?L0 z0y}1{sEj>W&@6Z~M)JCAR_-7~9wJR#=()@EF-AegGt=WJcY{hXr0xrlx^INYXoETP z!U-3Avb{}89EhyB5%Qod2O2=4m+i6)@V_L9RhN!}0r6dv7y_J9x?mEF*(UJ_`L6l; zh9E9caozdxC`{nLPUHhQpv*hB1fNMW%@BSa0xodHnfSy0jjxIK-@Jl=W!jP(EcW|Y zu5(m%hXyXh{w6?U^Ru>uuJ|5IP^&@XEu)GRx8hzt3xH$xj^4lx0FJq>#yTH(AhjzK z4Q7ORG4^v;cxbs9R`NdZd@+^V%y*O3anj1jTJQcn)eHEG1P8#iHu`~IH}t^n#YD_Q za61OwEy4Sgbg*rd6=wvR1}ZrTN4T`9VhUItBZVW4@2kYqqn@g9cX@JBH$GA-nN6R% zN^e!_W4fV_we0*>5O;&%F%RGu)*3}C(^0&$>4vAyhcUYN z3yI%KZ`D4w2CaCakl#$lF95d8fmZn9*K;=BWbK{w$L`?f1gXdY!(39Zqkt^R4d9qZ zinjO@T-il&5w7{OHUc$2^>T*rp@m;lxl+mp2He zSq`98nPmQx#D|)dc)ZdI+K?e}*n>_q_XKRUqhyEAKEuY*poVj($?C2LI@R@}_uH=U z;wJ{{t5=Mg6I*f%lveadhw{7L#-EW4d&+r6kOr!mX2uDn`Gj*dev~6R6FnzG^f>gLKHjTUbi z;#yoiVVn$4NC0_{jtW1V_P6z}Vwp^f!sJgfOhsQbjhX5jRT}NIeB~J~@Zf1eC+{+s zb8L?__g~5q8;e4&I_xS-ih|aG3!Z-Jkw_)uWW-J=PvvPj3>lrsn&mkv8-e~u1NTNw z0!$wBi-YAGtEmk6W3kL4M>!N!-V!rkdj*J^LcZwm-i?PZC`eYqy$3rBO8~lHs#bV% zFs=9qa7vS82Q#^90*Fafik)PKc<1LIj>LMq_yGaID|wx+uN)eH>IN$x0KN(^B;pJ} zGD1-$KHjHk>ax|%3-}q75W$0u*t8r;VE+C;!eE^+>aj)+ft$LdX%vCOhrj`f^*paI z8^IPYa`^y6$$ROtMnkIozPD`jB;WO_A346xuRT-GU>DLOu52{jH@@cl&n^Hi00o~R z9ykv+U9^8bJ>05864DGXOcvsw;K4Z)v8sXJB5tXKbwWs}ADH4$c{D$8@3HY05+F}B z+<1lqKYbi1uxM_6RwKA_$0nwI2Fy9-%H7ro|MP)on06ml{JU@wF!Y5F|@R2@8lhv-;s3+E|Cuc#gDA}zo1k^=c z1+_(G8my*2uLfgAIQ|&Mr{KdZ&oijJI_U?7A@TCZ1-Lv!EaVn@?EA}L!-?P^q#Usq z(lot7dAP<@8fspLC_HFt2zePWM`68qV$oX1Ow(Gf z@H74CZ`5LSR#%nyAR=?rg)~I(gg{tSUV2diQQEi^uC9OK$aip(MY!h0=jn1xm+==t zkiZ#`8cS(=xj9%34(EnQ{*3X3N+<2g3l(7>m3p+Fh&q@~e1WneOIXF+8kZ5c4-0hd zUwm1KZ!?FzuAcDchR4oy0TSTzOGrupuJ_@Sj;Zq9%uhxX!YqKw;+rzvfi(YOYq-0s%fa%}0Tl=n{I&l}02L1eWilIO9A2y(g>}?N;7lFnPV$^>ty=>3OXprhhM7Jh*^JrUdU0GE_q*k4Ws9yE+*YrjvJLCeV+6FxobVZEh*Uh8 z!>AR=c7GoY;x3-!x((K>^JasjhZ;1%rN2{1Qzm4vC@%mw{VE7Fnm;;7>lq+XqQ4e_JHx`Q5Po*f2oOs+ilAPYF)$Vb~D zxx z%cvsR4>GRaqL}*XIW-&DWdhU$fDGz)2QTCKryOA+XZhJLELUcG-cK2g1%=7zAowwb zk_VoRz~*;Fy$c5^h+???E+w~wai7UQDX4ImsGg(aLNle8O1x3*!8Y`M0(K_6nrdAcUiy6kt)Lexdl+`Q zXdn?=w5SdYWq`*G;I-qzx>*9S11K+g5FY@ZiTSa?^~fuevYs)p~K$3WyiJsyPHxpkE7k% z{Y%+_Vd=3sv&>W5l(5)?umrJ705BD+FR8RM>~RsI(>Uw3(wi|7wTkmq=c@lfE$LiU zr$-+*>n3+Yj6O|AbUG;sx64uo?Hw4n(Knw^yN=-Fz)anvL+A7z zE{90Vrv=+Y0jad51I~oJ5CwG!uC*|)%hD+r-1_|FodE)mK@WsMIkTpyB^2bBC$k!NyV8gEB z{lPR!GQx*^Ci!C3_TLGybWY_x$g{Oj)c|SB59Z?Ak_$nIvsrZ=iY`1Ty6{7!X`$%K z#uZ&lwl)Nm90_T9cUQC*KW;OR$3RTDkfDKRlc%B=Zcb0;lw!0xgVkLHc}XE+E}E%d#vE%MFtp!dP4y!#2=aho(MkpDrt!+8(lYNuqKjD9!F|9DM&BS|$Z`@5lp z7LaMooh_O@GkFGomHPMoC!I2-NAi=u`7jziI9|Lpx<*U5`oQMuRPf@MwB3fZ0Qvs! z5i(F51@m`H{2Q_1ci!tS<-}h%Wmmc9DS%TnI4+*194K!q$Y0wT4;I|GXA^(TAgeeM z$`U++Y5v2SXV2E!hXrB`FG`*RvU(C=eL9Kq6R?(t64z`v!kmVv`ifrxeHyWe+c;9R zya0`?7MwP)ag^ZCjEU8ONc6!+OVL}-@pbX!U0fM77tG6{J>dS4Ty>#QW|r^J7{48M zM1*!2*7aqsC89F-no## zt>hkt(9^C`$vc)14!Dkwc!Sf~gAIL%MjitPfIhuS7sZ=#qI7PYIqe87!nVU7M6*r( zq!6rimG;JAPD}B!-*rID^#0#D&l` z-yvAc$XB?3^(wB@Oc0E--GXX-bjw5tDPIiJOjowxquFNITc6*;$d`CuO>%QZBT{0U zEoo=T=QO;huTr*Yx}cUZM2^w9gL4L?GJ5W-TI~hq8gm&7g+U`LGE1AwIA{bhHQ%Cwju!xvJ^&_1 z!2sYpp*VU5`o-K}AgZb#0v(xv7Q`}w1Om7!XOSaZm~NOe zh<7ET!6ibR1=UoOnpQYA>QlNOh85A+Zt^H?#F1xAf%kA8AyKF|uB}@*ZIOg6m>(Pq zR^kgVxBEp%E8TGI$-tT3xXSyeT;Uwr0io53^ctIT%O8_%0%omD?ivOjW7H$2%KIXq z$uqXu>C%1T1KQuW7~1iLXwgpLngR5(wBi4JVXs#vK2(PCNj!0su6~n_j#K=Ii~BpO zIIJ2JXxRCy&H4qwjj>n$+jBiq)>rlEi$!9v^R~HH_BL-VuI$nIjgU0)>dRD3zd4>Y;Ug?lQoD*gq-m7bM$ zlfOq6gM3e)b6ubFr=q5j^>j73+m>gd^zgEw>P#Lea~q62{)@F?O33jVz~3M}WRZZr&k+It(LAws(S+H_+M ziiG-u)P`W9z#Z~ip}^w80c=?OlflngPZ}!9YiSEWzsJZW=9~oaCWzS`RhHUP;a*Ey zrdwe4V2jmtEcYJ)u2#_mZgKy=X#@C)n`jc=Il^12FvW37VX~@7K7t466^ESQvit@1 zavABD{#!(o=mj3%_=wgA%N>PlGz@Hx8w^;>?qbQbi8BE05>)WR4Vi{Z7KfKWvi`AJK7UpXVOM6+TBNCa%P6wvZvvr*rS_fGq-u~g+{0R&llS0< zlP3RlT}0`eYLUnk%eIvfF8<6DU**wxrsbn=J>_Tp`?WG2oJMUNwmRA%U6QUORWS(BM zZ#DgH{;gq$%RsCVb-*ap{N#2`r@8dnyG!jZqy%s0$rrR$74A0*U~GHDH-jFv_R8u82I}|1mn@Li;b;+Tfa4VNWeg$f zGe8cuOSe@9`qoSWVJYQ&>+A9!6LPY4{n-?kHgp8a>&%mCB!72(kVoH$`B1w%Ey4Wd zqp7deh+9a6_2ZV;VtSOK?q$L*`?uVh#TUb7&5?|nPcwPBT z*L!X?zSQ5n_I_{g2T`yXRfawJqkTP|#jp{?BFAihHN~jyT;b!+2D2Z(|tm|b09-Jk8+e;u{)XD=ZA+b4a~s)v}jhP%VS zE@<{-SgW_o1$HGbor|}|h;unxv60JC3HDr!XWjq0Z(=iWA`ti<`DiZ_ znmX-|1}qPo&+}we?DbW((th_`o-y7n#-^)I>zdx>bvx))X8$hTPZn(bLmeGSKMUL+=*# zCwo@zEqyzs`*6=u-gWAc$@J+ojComtZDbL7#Z9G@y!&5nSY01v?=M~mblhL%N4qvi z1SLvr)v>gHxSL(TDP~m zU0}-}=sF*pX|*8KH|@6$E4yFQN93H0?^4|j8F5I-BE`L(hx}6kK+tx~%?YqUTx){# z7QQ|LA%n;Jsvv7b&8A?Q8I@hb`~_i5^=pX*yFY(AS7;_df@|Qidd~DQn|8NxXuT?I zmdT|U)ss^4`Fm8UNqrqRXN|kituW+#>UENg_Hu|CZn+&!M^<3YpLA=xlzi*d8O~CN zxy;U!i4zq2kD8|Cb$4IHtq<=jIjp=;^ts#Z12umv0sv%E4dBUMx+S#@9naH))EpPKI}N z2$=mufHM}nIVZ$7?RGfD@mF9A%*J1nwE`~5#4jG#a(I7cWjA=xf^FY>*th(4f4Znc z2PM&VIaYr)ctd+%=_`tx-#nZC<(@8~MDyvEs_5#t?y|hlgF0l4@ZutY^XJe%LeQLw zj`hz#{b&r^*wW*ovo63(s}!?Wuq_Zec0alzO$Ufr<#0?1SN!tp=k7!4q&)+S^%4tG zxRj-dCK0=Pp5rE7hR!;FhR&Yv+EUY`BU-CDez^k=cc-&R6-!Z%*5 z-D@+ojak{3b2E|Ctx3d=Vks6mVBtdt)hYoHYLbV0$m3uJp(d+#-2_7s<3?q@0R z`Px3Y?Yr$C@n&4wPl{KJEUs18tM$S|2d<#UU5hnSApJQ+44}HNP3QMdjg9&Zg+PqhC)Cg=_=JXi=7sc6+dcC z>G}lIyr3`)p74?}9w#fv9?bG4xk2^-t%67z2R>iK9ny|>$gzvOV-0E+ zDSGt2}irLQMuORIagV4{`;77(C94oQ!A@g(0l=%#ajS9XMe zavcJScZh5R1e6pAD4|GOm`+cpfmbc-rr0?chyHRA92)SMJ(T<79IHY)Hju!H`36G5v#& zwmlXpQz%Tg7UAPqqlE3g`0cvqn>3t&;_Wh@)e(qG#q80p5^t}W@&UW7vYij z+GrbhZZ;S|P-@UC&G?zz221SaGvM4(2J}wg5nzdc%&*u;dd}03bT3tK%vS)cD>%_; zPE0))8&A0wF1x8UFZ{3W`gQ~JK?QY^MvYzNj-WbCT^8zgiVhI5)TUd|#-4`Kx-(YH z1^NXUz&)mZkIVw+nIpWBvYoOrGAwVlf9mpaw5uU2x)0x)TO&tDO66ruu}uJo|Ckbk zcP~G3L4nL>@kFIkOt#BmUxMh0bk&wk-gsLs!XW?cm<5j3J9X%D!5lA`p zU~;TSZC&8@23)@=>`dvk`8HP21HT1 zOJG1cq`UP)>F(}M85)L}dtUtN_kDWTx_8}w?phyzuo&iDC-!^J+57DM>}R9ZTQ@B zu5C0AP3vuWyzxX|H-T{H?Df6?cmCJPl1g>IHfT^p_U>58WwmO=q zDbS!N81+T!S!~eb0}ne;zQ8QDx90kH*jCBDS`Ga(tBj1I!WEX8Zz;e@aH4(ynvGb> zELP_8qy~ljspow1A3;Q3jz0%r^oIb(vNgcjC=4iAIM{v1YdpZE+D(~|MoM?I{S0sY z*ii--Y%(rl-*5BpGqS8iU8W5*UfmRm^hsfUls@ZjG5}>w{D84mo;b?gMKZCwiD-zp z`G^~djH4siGl0Vj$KMyIK{d$XWm-YX2pnEwz~M!}aS4k2S9?B7T3`~c0T(@?oN!fU*m}xoJPJs)j`)6sajDS!Y zotiI|j6vK{EY&m$Ku_h8L3MM?0mi_nj*(pwgxTH$J$1+B7AViML@WgmiYh?=eUY6$ zUIq{xJ&R`34lC+)(Sb+pCNmPh2SUJ;gi@-pGW~-NFXF*ruC| z7lGIqfY`F5UH}$jVq)mfq5_#sF~vA@hiTf<4zayQ8%!`Y)C6;9SRHFOmt#)ZklfUInKK!hGspydGdqXcCBp5{d|??{NeC4oC$9V>co<1sn46 zX~DTi`5>bO6zk2o#~|nBmNKow3Ib`bD5joLoE-*WkF0^W)1=mMe?~^mX-H6L+OVn4 z=z9pLc1u?TK!6nu9^iDOAOr`9B<)EKo(x!2sf@r@aZkiT-)2Zg6=H&|C7gTDne#e_ zxT15v_B~Tjakg-{LPjG7+k|aJ?5kC!?i98b;kR2F-(WfG4O{cXHE-KHPZ3lu{Ok%CmVDm_6ek2SNz9KCXVt6pt@Mx_B+d?pel zzx&D_;c?sp*p~#xpeN6WZ*<}UkLuYAabnOcw7`Zj`zB5a=J6aXGh%rmv4<3oAy{(C zYA7yM+1>;zrNdse3WyJBK8bPo+N(<3OCtv%NjAC{zG99atZ_^=D@(vYswPOHd$bwL zsrYr>C+x(d2%JmtJSHhN1tOLyL+KCofJR5FVb&~!FPUQM=yG(4zOqm90qj12`$K|Kxtjhs zwrCnCcTfHba$RxnZVEkb21#W3dBVh~>I4=KC7H?IH zsOe_T0-hsrEER7xjZd|WXps@f&*%J+pPxe;n8UtnKFw$G#_6mqv1<|+_0Cm@kr5!k z(R%<^+*iHlhkz`oUxx$oC=B*WddTqgOYyF!COfk=g5xsMxg2T&wogQk<&lZ-sS{O!8+hev4>yC~9j z{!)!Z>>94r!2&!euasl_GV9LA5I0ssa5b>OZz8n=j==cbwV@v|SFkT{pRTm8cp(2j z1JRJ{!J`)sB=AN`stZsCsKqVAyNC!9Mu2~heU;keRUiH^czJ8D7y|~#!zrQH){6%a zW6Ph)finHa&@^N|BL=i@S+oaD)-dg?M3-DzUw8F(I+VQxSZ z&W~|BLJ?<99_VKN46>LO+{ykiyuUssi@M_=ITBt<|BuF?w(|nqt0kUQ^Y;Ylued4x z-hI?5{8_xDS@C}i7Z|UJ?uC<|TpTO!H1r=O-U2r!u$Ea)1H~uV^gnXiPlhcb;SoC? zIZ!!rk+)==WKQHn8gC$Juso6j`r{wX)1M!_N%H=v70-UX0_F!}{NX0D$I+lrcU)iO z{paK7?nzPXt%Dhq>UT(Nm+g~PrTsXKp_Ahi z#b>PYYepVIf64myzuXP6w{gBVZ_`nw7)sXOSXj_Zh1-E^1z77gASFaPmdY3q!2WBP zCqq$?a>ucKOYM7t%>Rd!etf(q2VUq`O?@Q;Z6+VnUz2%h>d2CVq5aF1G7 zFD{Jb>%SA&8{MiuSVyo)eBj`fXcxwy$`+C!6Z@xAb?3wh)1}xuI!4Zh!nqAP_*P_` z_u?F>xCe-WYbIag8+0d|`AdKRI}EA|e-bliuHj)<+L?=GPV6omb*j7Aor^h@gf3r* zH}(*?`|VFY=YMpUfN<|-UW$bR+xyvt6aTxwcF;*gKJ%UIcKw;cSsypT@z#VS}M~l#lr>YsY9OicCVyCu+uuWDTF$ zY<4%xMhhIIHahhA$5>Xd7F53In*K?kJ7u zfMd{&B}4TPiPN{YZ{G&5W!_|ZQo^B&%7|PWsm(RL2C!`Z5X2uJ&*Meq(lfBRySWB3 zzu*QDtzj~5vwlV;5-X2ae-SIS5%aFkUaZ!|td6K}y*_Gc_)$9gk4FC9Ixv1xNE#X9 zDRQ;*xb!W$lTDI&#<32%C?EQlWIrhlxTRn6QCK1e{;Nprp9AeDR&epC;ws`4I7jxf zOn5SoF6mcVON4?QCzhTd>u+(-OEcf6>VhkpX=*8PXe?AxO^00X+P6se1&=kq1ofJ! zl_J*t0b!7xqsI!AYZn2Kc3Lhji@^V_0kU*VJfsuwfguNF0;i-wgw3b0c8C=Y@sR!5 zfy@fzJWhF9?vxJ$#FtGUeM{sv?aK`B+WqWt=E|jalnrrJ`9H#wfeUwq%Z0*^{I|B0 zoOyHpfhPY`c;a~JRgxLoM6(b*4pF}I)_gi%Mn$1pLqMbDC=N2BfuwQg%(le$5}I~$ zUXPNG%w>@g5jP|NLyqc7PkuJ?V$>z(9N@3|`&;<4@kOjh!lFXvgDm>QI~)vK3(PEW zUH{i1zdzs2$x4K+mnYp57M>uC-yP(@d;37~uqBc?5){29HB{Giemf}VqQ8)@-;g-G zBL61vHh({@>->=M+~k+V(RnH=D&)Q%`gt$GkI)>Uy#W7DhY$6*d=4!8^-ZVQVd>Bd zYl(@^0r_0ZECj;|6h@1q1uQ@m*oXxUN#P0m z=buggDNbPC2Du~gXDf;kS|F^6rh6R07(AWw7y>UOriqZUJWbGcSfc9AQqPT|i{{%A z(pNmDA);>dBkic~UCGcCI$mYhEHTgcq`Bn}>g+0?y?BdOzuqsxW~TmI(TOAVXQz~i z2$P2&@ZK^XfG~R^;#Tg4GY?ek@8{17dVJEQP`9dB1gRB&8~EQQBa(<629q{UB;{q=LHa-HwE3_RZ<@iPK?%)D53MvEsbo#)ZMF%cSYI6LZ zP)6uaC;8MP*7aB4L22I_6dlbb#+|?HfidVM!LNEhXOIGhr^&l^;a~9AKL!KL|Mg#t z3|vS*ffQ*0hO{_Q=*;}-5b{LY1%LG&MNl{vW03J&5&Mfjy8G8m!OI68o`3!O$MgSB zUjD}(Gyoz#!kj+ij|mS8Ggzc01;|GV1@6<&^~b7SK(1Pv?~`H#q;PoE`r*w2tM7gg zsQf);o?%NwV{IYHeeLUMoPA*SU^S=T06C}~ov!u}9@K2$zD>DycbU!1h1*nh&>d@; zTEzTQ3`sUHtd6k9T~;h+HiFj9pZ}{(1r-&8uTsYo!+`(PzvqJ~m?wBr#UD_U7%x^I z9+2o=r{s6+O}}va&W{G~g9a-}A3~jY(EJ{k!=QrW^n}ZGJ_86C^1kJRFz$v!766$^ZsL<7`0C9Z0bO@)WNAF+WPsbtQHL zlO7rA_XJVTyCWch0KDW|BvFvH^u_bV=w&cx_j314C&L6}|>Aca_7p9^8bcW`N1%Pz!=6V9V2Vp4yEFSssV`->+{kEjGTT`$V}~S z3T7r%>}WSww-(tfY}l8n8tmpaGxd%d_)qbXMyqOcgLU14YAbu`b$F9-uMx+y>g?`>*7tr4uqf<<2r-q*-K&g z%vB05dqOSSJSRa$JN#FBV~z#ZQ+H=3h7N1JV>m46kBe)?5YGM4Y_QQXt?JUOvi%0! zuCskpqD6J@2z=2n+~b#zLX~F$I<}|n3m|+r<#FA^ph4Pg6V(An5WmzZeBHtvPfIE3 z$CC9(ixhuWIw^a^`;3P>Y8`_jcitT$Rm5GUJ6*#QERUM@WsYb_mG!Mm5HxO&tmkbP zug!o4yF7sG_PmnFH;4OX~Ip((gWgsC$)o< zpHL{;*d7Jk<`g1zo^8BbzMPo_E<$<_FFJQ6N+e=7HfvE5My*b2Ft%q8R0N?{CsKhk z?zANv%{GUKvRs5W^a!V!5TsOZBOfagM1JGwKOb>b)tN9`47ZRv)esaD*F%mUZQQg! z6fOwVs3bitP`IIv6K?9w&CLe$zRW5NdHN0Qjlz~*1mt9Wmc5O^-4*r>nn!KJ zQSec5P6Md8c;5TZ5+pi|htJE<&~>ilyY0u_dyeXwdDb4pL>R*=t0};f%fJ;QYgs)P zVd|=*Y~D-IjpdTj>bBG)txN|x2%*GP3noI z8S6=kc4@t38%W9s^b6>3x%6HQU`S!CVgm z$^0`<>a*whe0H3GlY6TUle}jgLms@Eir2Nv?=o5~vtiv)#h?CGuWO7jj5VHZRGca( zKxV%4Du#1N9BxivCUTRO2O)P@H(gL8Pln*si?0;`IsNs5^ilYr)U7I{v2a#C8nEeI z`w_Gby6?fiUd4W8q!oSp7@V%+(=jzVW5$4GI@lq%8tr1=me`zyzO96DtU?PHUcYHi zc(NdUaFnjm6g!TIPp408$Zyil=-r1sSn16jG9J6OBOrXJ%zd~p|HP6D@kW638B&&8 z!beVylQ5%`3+y4kF=8gI#*8$CRvI5^?x{kD2ky)KiPRdATcpQUI2N>5x-Gg^~b4;JF2bI{@lc9asHQ|LF7kAO<_ zvhwb7UslT?YvAzCMlHr;k>$*$lA`_0!PbJsW4q(^)@XKpuYA*?#J9reecV)a0#H|y z&{`rvV5Lc?a;t%SV>sn;>yiX|Hr;xy4E z)~&%=0#-iLUP`3_KYp^f?w)ROS8KE8`!DX0RR_Y_Ik(<8OE2iq-ar0cM*!}~`VQP{ z_DNz`A&~kXz@{%i*tNYrRenZ&7SJk#tzRg45zcEn<#jVBH&=b*5FLE9h(xTSEcFCi z-yrTmt1}u0rkRV-Lz(tX%S1o(QrlEo(<-k17PXhST+eRYKDb^qoT5e}e(6!1vhtc~ zTu=by2`;;)tyCU6@n0`1kLUVZoyOHRlb}Or8&4E$*SSupi`TtTDY>efMAhY+ z^~)D4%JEvGAhsU&B&^^o?0vV} z*9in2hK6woTlap_>`7?*1ko7LizieqH;KKYtS6V3_U0w2cJ4kELL%-nE=uEoq~4HW z&t{c|I$^-#D+Og<=S!1ImILI#?%dh<*wZsJpjV0SG8C44XE*>5Q_fL6Ui~<4YzRo) zQ7}J;1$H1oN`=civ*8Ttvj-%)K_Bb9r0XR8Q$*_|Ln6lO+VLHhx>L@7xg(-`XEh%; zj^|mTPPy&eql2{~laRhj0KRoF(QKtD1l!gw-GkGtzZr#tpDZ6=gT{6`%qx)J z822`sbb_>yw#OY$+!4fUzFQM{Q*{#WQV*B2a)AZ#61kkIitMZtTXQV_A`Xy^SX&1` z$D$})Y%X@$=~=x97jkoH0qzH^EdwZOISDL>3nnger%qj|kro$KS&}c%@^dU{sR;S|lwp57dWV2?WWO@48sx@j#Hrc8c*I;LPV3py9(E-x1 zyHq}Yu@Ak_JwCdxX8_Q1gw`rgcRv}=a=k1}B)DYUhOp2JmgsxhUVm{VMK*>jI18UC zve+_qZ&g6J*3T~(%?3Oh?(X9VwecfM()wM41mVhnKt!BpsU;rNrwLoKOd?=FomN9~yn=3$HNC52_u$ixD%DXIBQnM0g%j15~Sd4QVqvBJU7%pBh0JQ)LtR{q< zkxgkJr;2xc9nU!p$N52&ukia4pICNB%V2&$ovB!ZLbjA0IfeR!ZO>C%N}5AAgj<(8 z6WZ|cN$Q#WOHK_JnH0Vt$#%+Mw4XuFF&K;;?r@o{eIc?s7va=k1aU?XmWXJsG~tun z-)Exaw_jFCoHn)kP}$lh1Gqj>#|z9(a=Yte3vi z*B$pxkaf@r!24uIpA*^EZPSDwN;OR zjM4MgqKve>FbCo4=A@}|(RX=q2^x9XOIsfr%f%pAa$BnRQU{L~b&Z_T*G5WlBkVz! z4sNBEwE-iKCErfIzfRA|- z(Hzf-GZIqBzp-U)aGLSwMkVZ-WvREkzg%~m)@HxbUtE`qN_iS0=T=Z?(9|7u3x8}h z`Rm5+wJixX==KtDo>8l4FyfPRD0KmBjPFQAlZHM=DaB`Twto1wT0EN>fQ2he%C>c6 z-z>&oAIzUY&D0h-*^C~Lugaky+eyV;#JM+goJB>emdk4VQ?Kzy`|Vg>IPe11PQV1h8?SDa`OM~tI>Uct4cxVl1-=x~{oJlAanSh0J zMOA*8Zdnk;!cLb~vpkSX?$m$dOrKZX2c(SWlsGlktaop~%TEvmw`G?l&f3TXSy`lz z$x>fd+E|1cb(*hSFgG{T2kyY}R%1w2E@9isbUx|vB-kZ679$Ni+*w)3?T_H^T3YD! z=f~`Fld@PRv^_c_nTtA*Pb8WO{;`dllMth!aTfAf#vMm9C4K_V$mCUSBIjjSg>IV$ z*PT{OT&BH^LLeexBOh`kol~CN-(0D^`0fJf6{mEqV$H)j!0W#(92#(14 zznZ*H)t`rLcRh3vURxV08MEjv(%gEGn--6juhennb{Vm?MZ#AOai2olQYud*ak;5G zHbO|d*KURxruZQ@+*uE6){vUf^{jfXPH}W^I_XF?1>4?mvDrwVdhYxNdAY?NLU30< zkW6rTd{lhB1JHd@OjfvazqD`rz&S&#`Xtnh@JWw64%5rKj`uCb_QBcCW^3Tp_Tb5> z>VtV=ld%eWg9t{*-_d?9tQdhhT*;L<^WcNejYN4G*&7@$^89Gi$vsaM3GFI%TB%yy z+<;U^B6g0>Q5rlQSXxIdUHHLcs9fDPtEIKIr0VmWOB-zwOj=G^_|*@|(mK7K3u%`t z@m$wyT{nlELd`l91?cmIh~C%C+WUzdF9xNwip;4NI^qcgBISjTd*$pt-2`uU)Sc(M z?_3`8ka@_q(T@>^5E;0yvr4iPXjO%|Hvv1!Uc5f{p}V_c#Bn3umxTF{n>&sV$MFcc zJ9irrB(cZ15u=ps$ixuYbyU9vI8?SU?hfm*nOP{64;6}@E4A=LlV~A9c&yEu>Ct>d zTX&}6V_{+9-`9bb{QSHwIb7hIOvF`Y=UV1s!&yG|Syj7k;u*f^s?^?I6}siK42PAk zpc(ykWpQv*NO(mM=VCMJ+GTtz-~td{a+>8Hchdh#9K2dRF6g8+>h%I>W|q3+xvK}= z6Yi~N8%l!CP}d? zjw`a5=2_V<^x3hCrk<^$49y|OXrP?}E70qe8v&z?RN!R5nQQdBpjlxTjXBOxPHlci zjwm3Y-kCC(WvIA?j?{p5{}o1sei!8DFPEs=)(t3w6) z4VNl2AG#1&g}Mz)yc#Ej={`;Cyo5t;C$LrTf8*K#vc5N4IW3?z{?f(+$l*kLl87Wn6v@?ZVoDkDd+eRbs3b%aVG-G<#PO7LPk7Q}hd>c&X z93eLuxwG7~)geqHFTkqFF&R2s?rX=A?R10N@?LyAX0&#yQyb&9+#5xqFnhf+TdSCU z?8^}38L?KOU}BPGV`n#D<%8{1!98Fz|T_keT1(01

!deM8zzq| zc11fB%rx42YfwWA>dGzPSRxD2<2(qLiQ-W#`nG?Eoj0zgDw)ZZxV0k$M$she7Kl{szRH5tf> zD{Y1|n~Z5R$e=%l-izeuV<+G|vBmMR3^QnocS^ZhJ7+I`RO;mghN?^eSyMO_h%~R{ zVoE1w6yZtBgz96mK|1dUJMs8`H{mh;HPuyHyIijE*C zWV`LRXsF!JYm%v!Wm;%-E>r3m(C43VJjDA$fjSK@Ev=?n8L$c}HX0OK^G+Abd0`cQ zKaT&g59xBIW3KMC-;m50j*JHZt)esMwacu(plEoXJ&^+0!h>gFH@M8Nmtj&KM<%PT zplqBuf|9eDpizk!5_Jlzf()1NnTD;|EoPxoBI6RyZ1^^Txk94l1*K=Vfu1$;=FVzO-{1RdPratU{rrQ__z-@beImONOk;&DtY zjY=)djsJciHeCj%gUQlFNAyk3irf+6-fln#v*Cr6 zAQ;VBq%2INt*h9M38u9P(@|K%GZG7*yb-0;i><}^ZwFdZ(>EJDYg$g+C7L+&i3qzp zJUIit(tM#ia0}7$>Sr3;!ZwM|iO6Sy)MI68Xo;9D}N zw4z{%)$2beDJ=>9#KWn#zUT#s8TszGGajxrsVfI_#bTKhhF%;yDkVzW!fudA6_s diff --git a/lib/plox.ex b/lib/plox.ex index 370e957..9dcfef5 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -37,19 +37,8 @@ defmodule Plox do 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)} - <%!-- <%= for tooltip <- @tooltips do %> - {render_slot(tooltip)} - <% end %> --%>
""" diff --git a/migration_guide.md b/migration_guide.md new file mode 100644 index 0000000..2f4f8c0 --- /dev/null +++ b/migration_guide.md @@ -0,0 +1,110 @@ +# Plox Migration Guide (0.2.0 to 0.3.0) + +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} + + + <.polyline dataset={graph[:dataset]} color="#EC7E16" /> + + <.circles dataset={graph[:dataset]} color="#EC7E16" /> + +``` + +## Example of 0.3.0 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 dataset={@dataset} stroke="#EC7E16" stroke-width={2} /> + + <.circle cx={@dataset[:x]} cy={@dataset[:y]} r={3} fill="#EC7E16" /> + +``` \ No newline at end of file From 26b9a9ac8d871ad8d298842a66b39afab03c1968 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Tue, 8 Jul 2025 16:33:16 -0700 Subject: [PATCH 10/23] Rename line_points to polyline and pass list of tuples for points --- demo_live.exs | 34 +++++++++++++++++++--------------- lib/plox.ex | 30 +++++++++++++++++------------- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/demo_live.exs b/demo_live.exs index a27e66f..3f2e335 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -80,15 +80,16 @@ defmodule DemoLive do {"Important Day"} - <.x_axis_grid_lines axis={@x_axis} stroke="grey" /> + <.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="grey" /> + <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> - <.line_plot dataset={@dataset} stroke="orange" stroke-width={2} /> + <%!-- <.polyline dataset={@dataset} stroke="orange" stroke-width={2} /> --%> + <.polyline points={Enum.zip(@dataset[:x], @dataset[:y])} stroke="orange" stroke-width={2} /> <%!-- Access behavior with axes --%> <%!-- <.polyline points={{@dataset[:x], @dataset[:y]}} class="stroke-orange-500 dark:stroke-orange-400 stroke-2" /> --%> @@ -207,20 +208,23 @@ defmodule DemoLive do {value} - <.x_axis_grid_lines axis={@x_axis} stroke="grey" /> + <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> - <.y_axis_grid_lines axis={@y_axis} ticks={7} stroke="grey" /> + <.y_axis_grid_lines axis={@y_axis} ticks={7} stroke="#D3D3D3" /> - <.line_plot dataset={@p_dataset} stroke-width="5" stroke="#FF9330" /> - <%!-- <.circles dataset={@p_dataset} r="8" fill="#FF9330" /> --%> - <.line_plot dataset={@l_dataset} stroke-width="5" stroke="#78C348" /> - <%!-- <.circles dataset={@l_dataset} r="8" fill="#78C348" /> --%> - <.line_plot dataset={@o_dataset} stroke-width="5" stroke="#71AEFF" /> - <%!-- <.circles dataset={@o_dataset} r="8" fill="#71AEFF" /> --%> - <.line_plot dataset={@x1_dataset} stroke-width="5" stroke="#FF7167" /> - <%!-- <.circles dataset={@x1_dataset} r="8" fill="#FF7167" /> --%> - <.line_plot dataset={@x2_dataset} stroke-width="5" stroke="#FF7167" /> - <%!-- <.circles dataset={@x2_dataset} r="8" fill="#FF7167" /> --%> + <.polyline points={Enum.zip(@p_dataset[:x], @p_dataset[:y])} stroke-width="5" stroke="#FF9330" /> + <.polyline points={Enum.zip(@l_dataset[:x], @l_dataset[:y])} stroke-width="5" stroke="#78C348" /> + <.polyline points={Enum.zip(@o_dataset[:x], @o_dataset[:y])} stroke-width="5" stroke="#71AEFF" /> + <.polyline + points={Enum.zip(@x1_dataset[:x], @x1_dataset[:y])} + stroke-width="5" + stroke="#FF7167" + /> + <.polyline + points={Enum.zip(@x2_dataset[:x], @x2_dataset[:y])} + stroke-width="5" + stroke="#FF7167" + /> """ end diff --git a/lib/plox.ex b/lib/plox.ex index 9dcfef5..71939d0 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -306,23 +306,27 @@ defmodule Plox do """ @doc type: :component - attr :dataset, Dataset, 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 :points, :any, required: true, doc: "String of coordinates or list of {x, y} tuples" attr :fill, :any, default: "none" attr :rest, :global, include: @svg_presentation_globals - def line_plot(assigns) do - ~H""" - - """ + def polyline(%{points: points} = assigns) when is_binary(points), do: do_polyline(assigns) + + def polyline(assigns) do + points = + assigns.points + |> Enum.map(fn {x, y} -> %{x: x, y: y} end) + |> polyline_points() + + assigns + |> assign(points: points) + |> do_polyline() end - defp line_points(dataset, x_key, y_key) do - dataset.data - |> Enum.map(fn data_point -> %{x: data_point.graph[x_key], y: data_point.graph[y_key]} end) - |> polyline_points() + defp do_polyline(assigns) do + ~H""" + + """ end @doc """ @@ -337,7 +341,7 @@ defmodule Plox do attr :fill, :any, default: "none" attr :rest, :global, include: @svg_presentation_globals - def step_line_plot(assigns) do + def step_polyline(assigns) do ~H""" """ From a35760b13eb23a4cc9b808da761cc8facc119ffa Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Tue, 8 Jul 2025 16:39:20 -0700 Subject: [PATCH 11/23] Update demo files with new polyline component --- README.md | 2 +- animated_demo_live.exs | 17 +++--- demo_live.exs | 123 +---------------------------------------- migration_guide.md | 4 +- 4 files changed, 13 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 72d85d8..451d1aa 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Once you have those, you can render a `graph` component within your HEEx templat <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> - <.polyline dataset={@dataset} stroke="#EC7E16" stroke-width={2} /> + <.polyline points={points(@dataset[:x], @dataset[:y])} stroke="#EC7E16" stroke-width={2} /> <.circle cx={@dataset[:x]} cy={@dataset[:y]} r={3} fill="#EC7E16" /> diff --git a/animated_demo_live.exs b/animated_demo_live.exs index 23904fe..7dc5e40 100644 --- a/animated_demo_live.exs +++ b/animated_demo_live.exs @@ -85,25 +85,24 @@ defmodule AnimatedDemoLive do {value} - <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="grey" /> + <.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="grey" /> - <.x_axis_grid_line axis={@x_axis} value={@x_axis.scale.first} stroke="grey" /> - <.x_axis_grid_line axis={@x_axis} value={@x_axis.scale.last} stroke="grey" /> - - <.x_axis_label axis={@x_axis} value={@now} position={:top} fill="red"> + <.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" /> + <.x_axis_label axis={@x_axis} value={@now} position={:top} color="red"> Now ({Calendar.strftime(@now, "%-I:%M:%S")}) <.x_axis_grid_line axis={@x_axis} value={@now} stroke="red" /> - <.polyline dataset={@dataset1} stroke="orange" stroke-width="2" /> - <.polyline dataset={@dataset2} stroke="blue" stroke-width="2" /> - <.polyline dataset={@dataset3} stroke="green" stroke-width="2" /> + <.polyline points={Enum.zip(@dataset1[:x], @dataset1[:y])} stroke="orange" stroke-width="2" /> + <.polyline points={Enum.zip(@dataset2[:x], @dataset2[:y])} stroke="blue" stroke-width="2" /> + <.polyline points={Enum.zip(@dataset3[:x], @dataset3[:y])} stroke="green" stroke-width="2" /> <%!-- <.circles dataset={@points_dataset} r={:r} fill={:color} /> --%> """ diff --git a/demo_live.exs b/demo_live.exs index 3f2e335..7877793 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -13,17 +13,13 @@ defmodule DemoLive do @impl Phoenix.LiveView def mount(_params, _session, socket) do - {:ok, socket |> mount_simple_line_graph() |> mount_logo_graph()} + {:ok, mount_simple_line_graph(socket)} end @impl Phoenix.LiveView def render(assigns) do ~H""" <.simple_line_graph {@graph} /> - -
- - <.logo_graph {@logo_graph} /> """ end @@ -111,123 +107,6 @@ defmodule DemoLive do """ end - - defp mount_logo_graph(socket) do - dimensions = Plox.Dimensions.new(440, 250) - x_axis = Plox.XAxis.new(Plox.NumberScale.new(0.0, 10.0), dimensions) - y_axis = Plox.YAxis.new(Plox.NumberScale.new(0.0, 6.0), dimensions) - - # Letter "P" - p_data = [ - %{x: 1, y: 5}, - %{x: 2.5, y: 4}, - %{x: 1, y: 3}, - %{x: 1, y: 1} - ] - - p_dataset = - Plox.Dataset.new(p_data, - x: {x_axis, & &1.x}, - y: {y_axis, & &1.y} - ) - - # Letter "L" - l_data = [ - %{x: 3.5, y: 4.5}, - %{x: 3.5, y: 1} - ] - - l_dataset = - Plox.Dataset.new(l_data, - x: {x_axis, & &1.x}, - y: {y_axis, & &1.y} - ) - - # Letter "O" - o_data = [ - %{x: 4.5, y: 2}, - %{x: 5.5, y: 3}, - %{x: 6.5, y: 2}, - %{x: 5.5, y: 1}, - %{x: 4.5, y: 2} - ] - - o_dataset = - Plox.Dataset.new(o_data, - x: {x_axis, & &1.x}, - y: {y_axis, & &1.y} - ) - - # Letter "X" - x1_data = [ - %{x: 7, y: 3}, - %{x: 9, y: 1} - ] - - x1_dataset = - Plox.Dataset.new(x1_data, - x: {x_axis, & &1.x}, - y: {y_axis, & &1.y} - ) - - x2_data = [ - %{x: 7, y: 1}, - %{x: 9, y: 3} - ] - - x2_dataset = - Plox.Dataset.new(x2_data, - x: {x_axis, & &1.x}, - y: {y_axis, & &1.y} - ) - - assign(socket, - logo_graph: %{ - dimensions: dimensions, - x_axis: x_axis, - y_axis: y_axis, - p_dataset: p_dataset, - l_dataset: l_dataset, - o_dataset: o_dataset, - x1_dataset: x1_dataset, - x2_dataset: x2_dataset - } - ) - end - - defp logo_graph(assigns) do - ~H""" -

Logo graph

- - <.graph dimensions={@dimensions}> - <.x_axis_labels :let={value} axis={@x_axis}> - {value} - - - <.y_axis_labels :let={value} axis={@y_axis} ticks={7}> - {value} - - - <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> - - <.y_axis_grid_lines axis={@y_axis} ticks={7} stroke="#D3D3D3" /> - - <.polyline points={Enum.zip(@p_dataset[:x], @p_dataset[:y])} stroke-width="5" stroke="#FF9330" /> - <.polyline points={Enum.zip(@l_dataset[:x], @l_dataset[:y])} stroke-width="5" stroke="#78C348" /> - <.polyline points={Enum.zip(@o_dataset[:x], @o_dataset[:y])} stroke-width="5" stroke="#71AEFF" /> - <.polyline - points={Enum.zip(@x1_dataset[:x], @x1_dataset[:y])} - stroke-width="5" - stroke="#FF7167" - /> - <.polyline - points={Enum.zip(@x2_dataset[:x], @x2_dataset[:y])} - stroke-width="5" - stroke="#FF7167" - /> - - """ - end end PhoenixPlayground.start(live: DemoLive) diff --git a/migration_guide.md b/migration_guide.md index 2f4f8c0..74a17a7 100644 --- a/migration_guide.md +++ b/migration_guide.md @@ -103,8 +103,10 @@ dataset = <.x_axis_grid_lines axis={@x_axis} stroke="#D3D3D3" /> <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> - <.polyline dataset={@dataset} stroke="#EC7E16" stroke-width={2} /> + + <.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 From 211078bdeb7d71a13ba6db771a816fe853ab3be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Wed, 9 Jul 2025 11:13:43 -0700 Subject: [PATCH 12/23] Simplify the way we generate multiple values from datasets (#18) --- animated_demo_live.exs | 6 +-- lib/plox.ex | 94 +++++++++++++++--------------------------- 2 files changed, 36 insertions(+), 64 deletions(-) diff --git a/animated_demo_live.exs b/animated_demo_live.exs index 7dc5e40..bdd3e37 100644 --- a/animated_demo_live.exs +++ b/animated_demo_live.exs @@ -100,9 +100,9 @@ defmodule AnimatedDemoLive do <.x_axis_grid_line axis={@x_axis} value={@now} stroke="red" /> - <.polyline points={Enum.zip(@dataset1[:x], @dataset1[:y])} stroke="orange" stroke-width="2" /> - <.polyline points={Enum.zip(@dataset2[:x], @dataset2[:y])} stroke="blue" stroke-width="2" /> - <.polyline points={Enum.zip(@dataset3[:x], @dataset3[:y])} stroke="green" stroke-width="2" /> + <.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" /> <%!-- <.circles dataset={@points_dataset} r={:r} fill={:color} /> --%> """ diff --git a/lib/plox.ex b/lib/plox.ex index 71939d0..bd517cc 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -375,82 +375,54 @@ defmodule Plox do attr :rest, :global, include: @svg_presentation_globals def circle(assigns) do - assigns - |> assign(dataset: validate_zero_or_one_dataset(Map.take(assigns, [:cx, :cy, :r, :fill]))) - |> do_circle() - end - - defp do_circle(%{dataset: :none} = assigns) do - ~H""" - - """ - end - - defp do_circle(%{dataset: _dataset} = assigns) do ~H""" """ end - defp validate_zero_or_one_dataset(assigns) do - datasets = - assigns - |> Enum.flat_map(fn - {_key, %Plox.DatasetAxis{} = dataset_axis} -> [dataset_axis.dataset] - {_key, _} -> [] - end) - |> Enum.uniq() - - case datasets do + defp validate_zero_or_one_dataset(data) do + data + |> Enum.flat_map(fn + %Plox.DatasetAxis{} = dataset_axis -> [dataset_axis.dataset] + _ -> [] + end) + |> Enum.uniq() + |> case do [] -> :none - [dataset] -> dataset + [_dataset] -> :ok _ -> raise "all dynamic values must be from the same dataset" end end - # TODO: can we make this work elegantly? - # would it be cool to allow mixed datasets? - # defp collect_values(values) do - # {dynamics, constants} = - # Enum.split_with(values, fn - # {_key, %Plox.DatasetAxis{}} -> true - # {_key, _value} -> false - # end) - - # grouped_dynamics = dynamics |> Enum.group_by(fn {_key, value} -> value.dataset end, fn {key, value} -> {value.key, key} end) |> Enum.to_list() - - # constants_map = Map.new(constants) - - # case length(grouped_dynamics) do - # 0 -> - # [constants_map] - - # 1 -> - # [{dataset, keys}] = grouped_dynamics - # for data_point <- dataset.data do + def points(x, y) do + values([x, y]) + end - # end - # _ -> - # raise "all dynamic values must be from the same dataset" - # end - # end + def values(data) do + case validate_zero_or_one_dataset(data) do + :none -> + [List.to_tuple(data)] - # TODO: a huge "dataset axis" struct just to get the key... - # I think this can be made way more elegant - defp maybe_graph(%Plox.DatasetAxis{key: key}, data_point), do: data_point.graph[key] - defp maybe_graph(value, _data_point), do: value + :ok -> + enumerables = + data + |> Enum.map(fn + %Plox.DatasetAxis{} = axis -> axis + constant -> Stream.repeatedly(fn -> constant end) + end) - # defp maybe_axis({axis_name, value}, dataset, _data_point), do: Axis.to_graph(dataset.axes[axis_name], value) - # defp maybe_axis(axis_name, _dataset, data_point), do: data_point.graph[axis_name] + Enum.zip(enumerables) + end + end @doc """ Bar plot. From 076be7655454d8b216526caa114ee57d7e857561 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Wed, 9 Jul 2025 11:36:20 -0700 Subject: [PATCH 13/23] Add docs to points and values + Update demo_live --- demo_live.exs | 10 +++---- lib/plox.ex | 79 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/demo_live.exs b/demo_live.exs index 7877793..a3514ef 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -84,14 +84,12 @@ defmodule DemoLive do <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> - <%!-- <.polyline dataset={@dataset} stroke="orange" stroke-width={2} /> --%> - <.polyline points={Enum.zip(@dataset[:x], @dataset[:y])} stroke="orange" stroke-width={2} /> + <%!-- <.polyline points={Enum.zip(@dataset[:x], @dataset[:y])} stroke="orange" stroke-width={2} /> --%> + <.polyline points={points(@dataset[:x], @dataset[:y])} stroke="orange" stroke-width={2} /> - <%!-- Access behavior with axes --%> - <%!-- <.polyline points={{@dataset[:x], @dataset[:y]}} class="stroke-orange-500 dark:stroke-orange-400 stroke-2" /> --%> <%!-- constant y = 40 --%> - <%!-- <.polyline points={{@dataset[:x], @dataset[:y][40]}} class="stroke-orange-500 dark:stroke-orange-400 stroke-2" /> --%> - <%!-- <.circles dataset={@dataset} cx={:x} cy={:y} fill={:color} r={:radius} /> --%> + <.polyline points={points(@dataset[:x], @dataset[:y][40])} stroke="purple" stroke-width="2" /> + <%!-- use the Access behavior --%> <.circle cx={@dataset[:x]} diff --git a/lib/plox.ex b/lib/plox.ex index bd517cc..805afab 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -377,7 +377,10 @@ defmodule Plox do def circle(assigns) do ~H""" Enum.flat_map(fn - %Plox.DatasetAxis{} = dataset_axis -> [dataset_axis.dataset] - _ -> [] - end) - |> Enum.uniq() - |> case do - [] -> :none - [_dataset] -> :ok - _ -> raise "all dynamic values must be from the same dataset" - end - end + @doc """ + Returns a list of points as 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> Plox.points([1, 2], %Plox.DatasetAxis{values: [3, 4]}) + [{1, 3}, {2, 4}] + + iex> Plox.points(%Plox.DatasetAxis{values: [1, 2]}, %Plox.DatasetAxis{values: [3, 4]}) + [{1, 3}, {2, 4}] + """ def points(x, y) do values([x, y]) end + @doc """ + Returns a list of tuples for use in polyline or other SVG elements. + + ## Example + + iex> Plox.values([1, 2]) + [{1, 2}] + + iex> Plox.values([1, 2], [3, 4]) + [{1, 3}, {2, 4}] + + iex> Plox.values([1, 2], %Plox.DatasetAxis{values: [3, 4]}) + [{1, 3}, {2, 4}] + + iex> Plox.values(%Plox.DatasetAxis{values: [1, 2]}, %Plox.DatasetAxis{values: [3, 4]}) + [{1, 3}, {2, 4}] + """ def values(data) do case validate_zero_or_one_dataset(data) do :none -> [List.to_tuple(data)] :ok -> - enumerables = - data - |> Enum.map(fn - %Plox.DatasetAxis{} = axis -> axis - constant -> Stream.repeatedly(fn -> constant end) - end) - - Enum.zip(enumerables) + data + |> Enum.map(fn + %Plox.DatasetAxis{} = axis -> axis + constant -> Stream.repeatedly(fn -> constant end) + end) + |> Enum.zip() + end + end + + defp validate_zero_or_one_dataset(data) do + data + |> Enum.flat_map(fn + %Plox.DatasetAxis{} = dataset_axis -> [dataset_axis.dataset] + _ -> [] + end) + |> Enum.uniq() + |> case do + [] -> :none + [_dataset] -> :ok + _ -> raise "all dynamic values must be from the same dataset" end end From a2fc0efa237924e31a121994ab7700d0aa86a5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Wed, 9 Jul 2025 15:44:50 -0700 Subject: [PATCH 14/23] Refactor Axis protocol and add Axis using macro so that axes can be accessed too (#19) --- lib/plox.ex | 21 +++++++------------- lib/plox/axis.ex | 40 +++++++++++++++++++-------------------- lib/plox/axis/protocol.ex | 25 ++++++++++++++++++++++++ lib/plox/color_axis.ex | 4 +++- lib/plox/dataset.ex | 5 ++--- lib/plox/linear_axis.ex | 4 +++- lib/plox/x_axis.ex | 4 +++- lib/plox/y_axis.ex | 4 +++- 8 files changed, 65 insertions(+), 42 deletions(-) create mode 100644 lib/plox/axis/protocol.ex diff --git a/lib/plox.ex b/lib/plox.ex index 805afab..3b766de 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -6,16 +6,9 @@ defmodule Plox do use Phoenix.Component alias Phoenix.LiveView.JS - alias Plox.Axis - alias Plox.Dataset - # alias Plox.DateScale - # alias Plox.DateTimeScale alias Plox.Dimensions - # alias Plox.FixedColorsScale - # alias Plox.FixedValuesScale alias Plox.GraphDataset alias Plox.GraphScale - # alias Plox.NumberScale alias Plox.Scale alias Plox.XAxis alias Plox.YAxis @@ -98,7 +91,7 @@ defmodule Plox do def x_axis_label(%{position: :bottom} = assigns) do ~H""" - graph = Map.new(axis_fns, fn {key, {axis, fun}} -> {key, Axis.to_graph(axis, fun.(original))} end) + graph = Map.new(axis_fns, fn {key, {axis, fun}} -> {key, axis[fun.(original)]} end) DataPoint.new(original, graph) end) diff --git a/lib/plox/linear_axis.ex b/lib/plox/linear_axis.ex index 3e44ed3..66c7721 100644 --- a/lib/plox/linear_axis.ex +++ b/lib/plox/linear_axis.ex @@ -4,6 +4,8 @@ defmodule Plox.LinearAxis do with, so it should be documented """ + use Plox.Axis + alias Plox.Scale defstruct [:scale, :min, :max] @@ -18,7 +20,7 @@ defmodule Plox.LinearAxis do def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - defimpl Plox.Axis do + defimpl Plox.Axis.Protocol do def to_graph(%{scale: scale, min: min, max: max}, value) do Scale.convert_to_range(scale, value, min..max) end diff --git a/lib/plox/x_axis.ex b/lib/plox/x_axis.ex index 4889fe5..582f347 100644 --- a/lib/plox/x_axis.ex +++ b/lib/plox/x_axis.ex @@ -4,6 +4,8 @@ defmodule Plox.XAxis do with, so it should be documented """ + use Plox.Axis + alias Plox.Scale defstruct [:scale, :dimensions] @@ -14,7 +16,7 @@ defmodule Plox.XAxis do def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - defimpl Plox.Axis do + defimpl Plox.Axis.Protocol do def to_graph(%{scale: scale, dimensions: dimensions}, value) do Scale.convert_to_range( scale, diff --git a/lib/plox/y_axis.ex b/lib/plox/y_axis.ex index 8f2e7b9..150c0b4 100644 --- a/lib/plox/y_axis.ex +++ b/lib/plox/y_axis.ex @@ -4,6 +4,8 @@ defmodule Plox.YAxis do with, so it should be documented """ + use Plox.Axis + alias Plox.Scale defstruct [:scale, :dimensions] @@ -14,7 +16,7 @@ defmodule Plox.YAxis do def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - defimpl Plox.Axis do + defimpl Plox.Axis.Protocol do def to_graph(%{scale: scale, dimensions: dimensions}, value) do Scale.convert_to_range( scale, From 19ca435fa04368263f9e9fbdf57267c4be7486d1 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Wed, 9 Jul 2025 17:08:00 -0700 Subject: [PATCH 15/23] Update step_polyline to follow polyline component --- demo_live.exs | 9 +++++++++ lib/plox.ex | 38 +++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/demo_live.exs b/demo_live.exs index a3514ef..a5c174a 100644 --- a/demo_live.exs +++ b/demo_live.exs @@ -90,6 +90,15 @@ defmodule DemoLive do <%!-- constant y = 40 --%> <.polyline points={points(@dataset[:x], @dataset[:y][40])} stroke="purple" stroke-width="2" /> + <%!-- <.step_polyline dataset={@dataset} x={:x} y={:y} stroke="pink" 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]} diff --git a/lib/plox.ex b/lib/plox.ex index 3b766de..0aeb2a4 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -299,7 +299,7 @@ defmodule Plox do """ @doc type: :component - attr :points, :any, required: true, doc: "String of coordinates or list of {x, y} tuples" + 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 @@ -327,22 +327,38 @@ defmodule Plox do """ @doc type: :component - attr :dataset, Dataset, 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 :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 step_polyline(assigns) do - ~H""" - - """ + 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(dataset, x_key, y_key) do - dataset.data - |> Enum.map(fn data_point -> %{x: data_point.graph[x_key], y: data_point.graph[y_key]} end) + defp step_line_points(points) do + points |> Enum.chunk_every(2, 1) |> Enum.flat_map(fn [point1, point2] -> [point1, %{point2 | y: point1.y}] From 4c0bdd594ef90c718bab8fcdfd3ddd414e01cbf4 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Wed, 9 Jul 2025 17:38:13 -0700 Subject: [PATCH 16/23] Remove unneeded marker components + Update docs and animated demo --- animated_demo_live.exs | 11 ++- lib/plox.ex | 155 ++--------------------------------------- 2 files changed, 14 insertions(+), 152 deletions(-) diff --git a/animated_demo_live.exs b/animated_demo_live.exs index bdd3e37..226c848 100644 --- a/animated_demo_live.exs +++ b/animated_demo_live.exs @@ -94,7 +94,9 @@ defmodule AnimatedDemoLive do <.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" /> - <.x_axis_label axis={@x_axis} value={@now} position={:top} color="red"> + + <%!-- 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")}) @@ -103,7 +105,12 @@ defmodule AnimatedDemoLive do <.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" /> - <%!-- <.circles dataset={@points_dataset} r={:r} fill={:color} /> --%> + <.circle + cx={@points_dataset[:x]} + cy={@points_dataset[:y]} + r={@points_dataset[:r]} + fill={@points_dataset[:color]} + /> """ end diff --git a/lib/plox.ex b/lib/plox.ex index 0aeb2a4..3b6c776 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -1,6 +1,7 @@ defmodule Plox do @moduledoc """ - TODO: + Composable, customizable, and flexible SVG graphing components rendered server-side + for Phoenix and LiveView. """ use Phoenix.Component @@ -8,7 +9,6 @@ defmodule Plox do alias Phoenix.LiveView.JS alias Plox.Dimensions alias Plox.GraphDataset - alias Plox.GraphScale alias Plox.Scale alias Plox.XAxis alias Plox.YAxis @@ -295,7 +295,7 @@ defmodule Plox do end @doc """ - A connected line plot. + Draws a SVG `` element connecting a series of points. """ @doc type: :component @@ -323,7 +323,7 @@ defmodule Plox do end @doc """ - A connected step line plot. + Draws a SVG `` element connecting a series of points in the form of a stepped line. """ @doc type: :component @@ -370,7 +370,7 @@ defmodule Plox do defp polyline_points(points), do: Enum.map_join(points, " ", &"#{&1.x},#{&1.y}") @doc """ - Draws a single or set of SVG elements. + Draws a single or set of SVG `` elements. """ @doc type: :component @@ -692,133 +692,6 @@ defmodule Plox do |> Enum.chunk_every(2, 1, :discard) end - @doc """ - A horizontal or vertical marker line with a label. - """ - @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 - - slot :inner_block, required: true - - 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) - - ~H""" - - - {render_slot(@inner_block)} - - """ - 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) - - ~H""" - - - {render_slot(@inner_block)} - - """ - end - - @doc """ - A horizontal or vertical marker line with a label. - """ - @doc type: :component - - attr :axis, XAxis, required: true - attr :value, :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 - - slot :inner_block, required: true - - def x_marker(assigns) do - ~H""" - - - {render_slot(@inner_block)} - - """ - end - @doc """ Legend row. """ @@ -863,22 +736,4 @@ defmodule Plox do
""" end - - defp stroke_dasharray(:solid), do: false - defp stroke_dasharray(:dotted), do: "2" - defp stroke_dasharray(:dashed), do: "6" - - # def date_scale(graph, range), do: GraphScale.new(graph, DateScale.new(range)) - - # def number_scale(graph, first, last), do: GraphScale.new(graph, NumberScale.new(first, last)) - - # def dataset(data, axes), do: Dataset.new(data, axes) - - # defdelegate graph(width, height, opts \\ []), 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 end From 7772660012dca246d9c6311293c95d8ff561d7a3 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Thu, 10 Jul 2025 09:50:39 -0700 Subject: [PATCH 17/23] Organize demos and migration guide into subfolders --- migration_guide.md => docs/migration_guide.md | 0 animated_demo_live.exs => examples/animated_demo_live.exs | 0 demo_live.exs => examples/demo_live.exs | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename migration_guide.md => docs/migration_guide.md (100%) rename animated_demo_live.exs => examples/animated_demo_live.exs (100%) rename demo_live.exs => examples/demo_live.exs (100%) diff --git a/migration_guide.md b/docs/migration_guide.md similarity index 100% rename from migration_guide.md rename to docs/migration_guide.md diff --git a/animated_demo_live.exs b/examples/animated_demo_live.exs similarity index 100% rename from animated_demo_live.exs rename to examples/animated_demo_live.exs diff --git a/demo_live.exs b/examples/demo_live.exs similarity index 100% rename from demo_live.exs rename to examples/demo_live.exs From 28902b5dd00bfbbd2007f22d99845a3199b883fd Mon Sep 17 00:00:00 2001 From: "Nikki (she/her)" Date: Thu, 17 Jul 2025 13:04:45 -0700 Subject: [PATCH 18/23] Update tests and docs (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add DatasetAxis and Dataset tests * Fix values/2 bug + Add Plox docs and tests for points/2 and values/1 * Add XAxis and YAxis docs and tests * Update docs and tests for Dimensions, Box, and DataPoint * Update Axis Protocol and ColorAxis docs + Add ColorAxis tests * Remove old unused graph_ files and tests * Add LinearAxis docs and tests * Update Scale + Remove Protocol test files * Adds docs and tests for all Scales * Add DateTimeScale docs and tests * Explicitly document which fns raise errors * Add DateScale docs and tests * Move protocol fn docs into moduledocs for doctests and visibility --------- Co-authored-by: Chris Dosé --- examples/animated_demo_live.exs | 4 +- examples/demo_live.exs | 2 + lib/plox.ex | 523 +++++++++++++------------- lib/plox/axis/protocol.ex | 11 +- lib/plox/box.ex | 26 +- lib/plox/color_axis.ex | 42 ++- lib/plox/data_point.ex | 31 +- lib/plox/dataset.ex | 43 ++- lib/plox/date_scale.ex | 55 ++- lib/plox/date_time_scale.ex | 51 ++- lib/plox/dimensions.ex | 22 +- lib/plox/fixed_colors_scale.ex | 32 +- lib/plox/fixed_values_scale.ex | 40 +- lib/plox/graph_dataset.ex | 77 ---- lib/plox/graph_point.ex | 12 - lib/plox/graph_scalar.ex | 12 - lib/plox/graph_scale.ex | 38 -- lib/plox/linear_axis.ex | 46 ++- lib/plox/number_scale.ex | 80 +++- lib/plox/scale.ex | 6 +- lib/plox/x_axis.ex | 63 +++- lib/plox/y_axis.ex | 65 +++- mix.exs | 16 +- test/plox/color_axis_test.exs | 32 ++ test/plox/color_scale_test.exs | 11 - test/plox/data_point_test.exs | 5 +- test/plox/dataset_test.exs | 97 ++++- test/plox/date_scale_test.exs | 77 +++- test/plox/date_time_scale_test.exs | 121 +++++- test/plox/dimensions_test.exs | 2 +- test/plox/fixed_colors_scale_test.exs | 36 +- test/plox/fixed_values_scale_test.exs | 45 ++- test/plox/graph_dataset_test.exs | 9 - test/plox/graph_point_test.exs | 9 - test/plox/graph_scalar_test.exs | 9 - test/plox/graph_scale_test.exs | 9 - test/plox/graph_test.exs | 9 - test/plox/linear_axis_test.exs | 34 ++ test/plox/number_scale_test.exs | 79 +++- test/plox/scale_test.exs | 9 - test/plox/x_axis_test.exs | 36 ++ test/plox/y_axis_test.exs | 38 ++ test/plox_test.exs | 50 ++- 43 files changed, 1396 insertions(+), 618 deletions(-) delete mode 100644 lib/plox/graph_dataset.ex delete mode 100644 lib/plox/graph_point.ex delete mode 100644 lib/plox/graph_scalar.ex delete mode 100644 lib/plox/graph_scale.ex create mode 100644 test/plox/color_axis_test.exs delete mode 100644 test/plox/color_scale_test.exs delete mode 100644 test/plox/graph_dataset_test.exs delete mode 100644 test/plox/graph_point_test.exs delete mode 100644 test/plox/graph_scalar_test.exs delete mode 100644 test/plox/graph_scale_test.exs delete mode 100644 test/plox/graph_test.exs create mode 100644 test/plox/linear_axis_test.exs delete mode 100644 test/plox/scale_test.exs create mode 100644 test/plox/x_axis_test.exs create mode 100644 test/plox/y_axis_test.exs diff --git a/examples/animated_demo_live.exs b/examples/animated_demo_live.exs index 226c848..d073039 100644 --- a/examples/animated_demo_live.exs +++ b/examples/animated_demo_live.exs @@ -1,11 +1,13 @@ Mix.install([ - {:phoenix_playground, "~> 0.1.6"}, + {: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 diff --git a/examples/demo_live.exs b/examples/demo_live.exs index a5c174a..6fc618e 100644 --- a/examples/demo_live.exs +++ b/examples/demo_live.exs @@ -6,6 +6,8 @@ Mix.install([ defmodule DemoLive do @moduledoc """ Example graph rendered within a Phoenix Playground application. + + $ iex demo_live.exs """ use Phoenix.LiveView diff --git a/lib/plox.ex b/lib/plox.ex index 3b6c776..6b55a5b 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -6,9 +6,7 @@ defmodule Plox do use Phoenix.Component - alias Phoenix.LiveView.JS alias Plox.Dimensions - alias Plox.GraphDataset alias Plox.Scale alias Plox.XAxis alias Plox.YAxis @@ -402,7 +400,7 @@ defmodule Plox do end @doc """ - Returns a list of points as tuples for use in polyline or other SVG elements. + Returns a list of tuples for use in polyline or other SVG elements. ## Example @@ -412,11 +410,19 @@ defmodule Plox do iex> Plox.points([1, 2], [3, 4]) [{1, 3}, {2, 4}] - iex> Plox.points([1, 2], %Plox.DatasetAxis{values: [3, 4]}) - [{1, 3}, {2, 4}] - - iex> Plox.points(%Plox.DatasetAxis{values: [1, 2]}, %Plox.DatasetAxis{values: [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}] """ def points(x, y) do values([x, y]) @@ -433,264 +439,259 @@ defmodule Plox do iex> Plox.values([1, 2], [3, 4]) [{1, 3}, {2, 4}] - iex> Plox.values([1, 2], %Plox.DatasetAxis{values: [3, 4]}) - [{1, 3}, {2, 4}] - - iex> Plox.values(%Plox.DatasetAxis{values: [1, 2]}, %Plox.DatasetAxis{values: [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 - case validate_zero_or_one_dataset(data) do - :none -> - [List.to_tuple(data)] - - :ok -> - data - |> Enum.map(fn - %Plox.DatasetAxis{} = axis -> axis - constant -> Stream.repeatedly(fn -> constant end) - end) - |> Enum.zip() - end - end - - defp validate_zero_or_one_dataset(data) do - data - |> Enum.flat_map(fn - %Plox.DatasetAxis{} = dataset_axis -> [dataset_axis.dataset] - _ -> [] - end) - |> Enum.uniq() - |> case do - [] -> :none - [_dataset] -> :ok - _ -> raise "all dynamic values must be from the same dataset" + 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 - @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 """ + # 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. diff --git a/lib/plox/axis/protocol.ex b/lib/plox/axis/protocol.ex index 27bfd70..7309d17 100644 --- a/lib/plox/axis/protocol.ex +++ b/lib/plox/axis/protocol.ex @@ -1,8 +1,7 @@ defprotocol Plox.Axis.Protocol do @moduledoc """ - A protocol for graph axes. - - TODO: docs + A protocol for graph axes. Requires axes to implement a method to convert scale values + to graphable values. """ @typedoc """ @@ -10,15 +9,15 @@ defprotocol Plox.Axis.Protocol do Built in implementations are: + * `Plox.ColorAxis` + * `Plox.LinearAxis` * `Plox.XAxis` * `Plox.YAxis` - * `Plox.RadiusAxis` - * `Plox.ColorAxis` """ @type t :: any() @doc """ - Converts a specific scale value to a value usable by the graph components + Converts a specific scale value to a graphable value. """ @spec to_graph(axis :: t(), any()) :: any() def to_graph(axis, value) diff --git a/lib/plox/box.ex b/lib/plox/box.ex index 8a30513..fe61d5a 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 index b948b7f..a0b33e6 100644 --- a/lib/plox/color_axis.ex +++ b/lib/plox/color_axis.ex @@ -1,7 +1,29 @@ defmodule Plox.ColorAxis do @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented + ColorAxis converts `Plox.ColorScale` values to graphable colors. + + This module implements the `Access` behaviour, allowing access to graphable + values using the `[]` syntax. + + ## Example + + 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 @@ -10,13 +32,25 @@ defmodule Plox.ColorAxis do 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 - # def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - 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 diff --git a/lib/plox/data_point.ex b/lib/plox/data_point.ex index e3ec10b..ff0bd7b 100644 --- a/lib/plox/data_point.ex +++ b/lib/plox/data_point.ex @@ -1,36 +1,11 @@ defmodule Plox.DataPoint do - @moduledoc false - # TODO: I dunno about docs yet - - alias Plox.GraphPoint - alias Plox.GraphScalar - alias Plox.GraphScale + @moduledoc """ + Data structure for containing raw data and its mapped values for graphing. + """ defstruct [:original, :graph] def new(original, graph) do %__MODULE__{original: original, graph: graph} 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) - # end end diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index f2edc1b..e0e7698 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -43,7 +43,29 @@ 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. + + ## Example + + 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: + + ## Example + + + <.circle cx={@dataset[:x]} cy={@dataset[:y][40]} fill="red" r="3" /> """ @behaviour Access @@ -51,6 +73,25 @@ defmodule Plox.Dataset do defstruct [:data, :axes] + @doc """ + Creates a new `Plox.Dataset` struct. + + 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 = Enum.map(original_data, fn original -> diff --git a/lib/plox/date_scale.ex b/lib/plox/date_scale.ex index 4b901b4..72c8928 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 d5fffb8..336c5d4 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 @@ -78,6 +108,11 @@ defmodule Plox.DateTimeScale do |> 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 2de6d30..f65c1d1 100644 --- a/lib/plox/dimensions.ex +++ b/lib/plox/dimensions.ex @@ -1,13 +1,31 @@ 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] + @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) diff --git a/lib/plox/fixed_colors_scale.ex b/lib/plox/fixed_colors_scale.ex index dd46e4c..9108237 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 is_non_struct_map(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_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 4b8f935..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 [:scale, :graph] - - def new(graph, scale) do - %__MODULE__{scale: scale, graph: graph} - end - - def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - - def to_graph_x(%__MODULE__{scale: scale, graph: graph}, value) do - Scale.convert_to_range( - scale, - value, - (graph.margin.left + - graph.padding.left)..(graph.width - - graph.margin.right - - graph.padding.right) - ) - end - - def to_graph_y(%__MODULE__{scale: scale, graph: graph}, value) do - Scale.convert_to_range( - scale, - value, - (graph.height - - graph.margin.bottom - - graph.padding.bottom)..(graph.margin.top + - graph.padding.top) - ) - end -end diff --git a/lib/plox/linear_axis.ex b/lib/plox/linear_axis.ex index 66c7721..a173238 100644 --- a/lib/plox/linear_axis.ex +++ b/lib/plox/linear_axis.ex @@ -1,7 +1,32 @@ defmodule Plox.LinearAxis do @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented + 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. + + ## Example + + 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 @@ -10,6 +35,17 @@ defmodule Plox.LinearAxis do 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) @@ -18,9 +54,11 @@ defmodule Plox.LinearAxis do %__MODULE__{scale: scale, min: min, max: max} end - def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - 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 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 index 582f347..6173a9a 100644 --- a/lib/plox/x_axis.ex +++ b/lib/plox/x_axis.ex @@ -1,7 +1,34 @@ defmodule Plox.XAxis do @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented + 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. + + ## Example + + 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 @@ -10,22 +37,34 @@ defmodule Plox.XAxis do 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 - def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - defimpl Plox.Axis.Protocol do + @doc """ + Converts the given `value` to a graphable x-coordinate. + """ def to_graph(%{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) - ) + 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 index 150c0b4..0b965a5 100644 --- a/lib/plox/y_axis.ex +++ b/lib/plox/y_axis.ex @@ -1,7 +1,35 @@ defmodule Plox.YAxis do @moduledoc """ - TODO: this is a public module that graph component implementers will interact - with, so it should be documented + 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. + + ## Example + + 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 @@ -10,22 +38,35 @@ defmodule Plox.YAxis do 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 - def values(%__MODULE__{scale: scale}, opts \\ %{}), do: Scale.values(scale, opts) - defimpl Plox.Axis.Protocol do + @doc """ + Converts the given `value` to a graphable y-coordinate. + """ def to_graph(%{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) - ) + 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..ed88ea9 100644 --- a/mix.exs +++ b/mix.exs @@ -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/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/color_scale_test.exs b/test/plox/color_scale_test.exs deleted file mode 100644 index 0036b30..0000000 --- a/test/plox/color_scale_test.exs +++ /dev/null @@ -1,11 +0,0 @@ -defmodule Plox.ColorScaleTest do - use ExUnit.Case - - alias Plox.ColorScale - - doctest ColorScale - - test "the truth" do - assert 1 + 1 == 2 - end -end diff --git a/test/plox/data_point_test.exs b/test/plox/data_point_test.exs index 6a4505f..24a850c 100644 --- a/test/plox/data_point_test.exs +++ b/test/plox/data_point_test.exs @@ -6,9 +6,8 @@ defmodule Plox.DataPointTest do doctest DataPoint test "new/3" do - data_point = DataPoint.new(1, %{foo: 1, bar: 2}, %{x: 1, y: 2}) - assert data_point.id == 1 + data_point = DataPoint.new(%{foo: 1, bar: 2}, %{x: 1, y: 2}) assert data_point.original == %{foo: 1, bar: 2} - assert data_point.mapped == %{x: 1, y: 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 index d5947b2..03504d2 100644 --- a/test/plox/dataset_test.exs +++ b/test/plox/dataset_test.exs @@ -3,20 +3,95 @@ defmodule Plox.DatasetTest do alias Plox.DataPoint alias Plox.Dataset + alias Plox.DatasetAxis - doctest Dataset + 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) - test "new/2" do - scale = Plox.number_scale(0, 10) - axes = %{x: {scale, & &1.foo}, y: {scale, & &1.bar}} - data = [%{foo: 1, bar: 2}, %{foo: 2, bar: 3}] - dataset = Dataset.new(data, axes) + 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.scales == %{x: scale, y: scale} + assert {:ok, dataset_axis_x} = Dataset.fetch(dataset, :x) + assert {:ok, dataset_axis_y} = Dataset.fetch(dataset, :y) - assert dataset.data == [ - %DataPoint{id: 0, original: %{foo: 1, bar: 2}, mapped: %{x: 1, y: 2}}, - %DataPoint{id: 1, original: %{foo: 2, bar: 3}, mapped: %{x: 2, y: 3}} - ] + # 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 index eedc203..708b19b 100644 --- a/test/plox/date_scale_test.exs +++ b/test/plox/date_scale_test.exs @@ -2,16 +2,81 @@ defmodule Plox.DateScaleTest do use ExUnit.Case alias Plox.DateScale + alias Plox.Scale doctest DateScale - test "new/2" 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]) + 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 - test "new/2 given a range with a step it reduces it 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]) + 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 index 71d234a..81c33f1 100644 --- a/test/plox/date_time_scale_test.exs +++ b/test/plox/date_time_scale_test.exs @@ -2,16 +2,125 @@ defmodule Plox.DateTimeScaleTest do use ExUnit.Case alias Plox.DateTimeScale + alias Plox.Scale doctest DateTimeScale - test "the truth" do - assert 1 + 1 == 2 + 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 - test "new/2" 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] + 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 index 85d3b40..a5b3930 100644 --- a/test/plox/dimensions_test.exs +++ b/test/plox/dimensions_test.exs @@ -7,7 +7,7 @@ defmodule Plox.DimensionsTest do doctest Dimensions test "new/1" do - assert Dimensions.new(%{width: 100, height: 100, margin: 0, padding: 0}) == %Dimensions{ + assert Dimensions.new(100, 100, margin: 0, padding: 0) == %Dimensions{ width: 100, height: 100, margin: %Box{top: 0, right: 0, bottom: 0, left: 0}, diff --git a/test/plox/fixed_colors_scale_test.exs b/test/plox/fixed_colors_scale_test.exs index c16e8c5..ccd8667 100644 --- a/test/plox/fixed_colors_scale_test.exs +++ b/test/plox/fixed_colors_scale_test.exs @@ -1,9 +1,41 @@ defmodule Plox.FixedColorsScaleTest do use ExUnit.Case + alias Plox.ColorScale + alias Plox.FixedColorsScale + doctest Plox.FixedColorsScale - test "the truth" do - assert 1 + 1 == 2 + 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 index b77871c..7290a89 100644 --- a/test/plox/fixed_values_scale_test.exs +++ b/test/plox/fixed_values_scale_test.exs @@ -1,9 +1,50 @@ defmodule Plox.FixedValuesScaleTest do use ExUnit.Case + alias Plox.FixedValuesScale + alias Plox.Scale + doctest Plox.FixedValuesScale - test "the truth" do - assert 1 + 1 == 2 + 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/graph_dataset_test.exs b/test/plox/graph_dataset_test.exs deleted file mode 100644 index c1f96ab..0000000 --- a/test/plox/graph_dataset_test.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plox.GraphDatasetTest do - use ExUnit.Case - - doctest Plox.GraphDataset - - test "the truth" do - assert 1 + 1 == 2 - end -end diff --git a/test/plox/graph_point_test.exs b/test/plox/graph_point_test.exs deleted file mode 100644 index e6ef63a..0000000 --- a/test/plox/graph_point_test.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plox.GraphPointTest do - use ExUnit.Case - - doctest Plox.GraphPoint - - test "the truth" do - assert 1 + 1 == 2 - end -end diff --git a/test/plox/graph_scalar_test.exs b/test/plox/graph_scalar_test.exs deleted file mode 100644 index 24877e6..0000000 --- a/test/plox/graph_scalar_test.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plox.GraphScalarTest do - use ExUnit.Case - - doctest Plox.GraphScalar - - test "the truth" do - assert 1 + 1 == 2 - end -end diff --git a/test/plox/graph_scale_test.exs b/test/plox/graph_scale_test.exs deleted file mode 100644 index f1a19b5..0000000 --- a/test/plox/graph_scale_test.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plox.GraphScaleTest do - use ExUnit.Case - - doctest Plox.GraphScale - - test "the truth" do - assert 1 + 1 == 2 - end -end diff --git a/test/plox/graph_test.exs b/test/plox/graph_test.exs deleted file mode 100644 index 1092785..0000000 --- a/test/plox/graph_test.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plox.GraphTest do - use ExUnit.Case - - doctest Plox.Graph - - test "the truth" do - assert 1 + 1 == 2 - 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 index ff35095..9b08f72 100644 --- a/test/plox/number_scale_test.exs +++ b/test/plox/number_scale_test.exs @@ -1,14 +1,83 @@ defmodule Plox.NumberScaleTest do use ExUnit.Case + alias Plox.NumberScale + alias Plox.Scale + doctest Plox.NumberScale - test "the truth" do - assert 1 + 1 == 2 + 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 - test "scale" do - scale = Plox.NumberScale.new(0, 100) - Plox.Scale.convert_to_range(scale, 200, 0..1000) + 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/scale_test.exs b/test/plox/scale_test.exs deleted file mode 100644 index 420e508..0000000 --- a/test/plox/scale_test.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plox.ScaleTest do - use ExUnit.Case - - doctest Plox.Scale - - test "the truth" do - assert 1 + 1 == 2 - 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 cd25a17..94eebac 100644 --- a/test/plox_test.exs +++ b/test/plox_test.exs @@ -1,9 +1,53 @@ defmodule PloxTest do use ExUnit.Case - doctest Plox + 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 "the truth" do - assert 1 + 1 == 2 + 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 From eb5a79fab472f76b4802537e2ca75947f10f1156 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Fri, 18 Jul 2025 11:37:24 -0700 Subject: [PATCH 19/23] Bump Erlang and Elixir versions for CI --- .github/workflows/ci.yml | 4 ++-- mix.exs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 276790f..f0d596c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,8 @@ on: env: MIX_ENV: test - OTP_VERSION_SPEC: "26.x" - ELIXIR_VERSION_SPEC: "1.16.x" + OTP_VERSION_SPEC: "27.x" + ELIXIR_VERSION_SPEC: "1.18.x" jobs: compile: diff --git a/mix.exs b/mix.exs index ed88ea9..73b2fee 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Plox.MixProject do [ app: :plox, version: @version, - elixir: "~> 1.15", + elixir: "~> 1.18", start_permanent: Mix.env() == :prod, deps: deps(), From fa122b318746af21a24882b30eab35a1346489cc Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Fri, 18 Jul 2025 14:50:51 -0700 Subject: [PATCH 20/23] Update docs to present better in HexDocs --- docs/migration_guide.md | 4 +- lib/plox.ex | 153 ++++++++++++++++++------------------ lib/plox/box.ex | 20 ++--- lib/plox/color_axis.ex | 4 +- lib/plox/data_point.ex | 37 +++++++++ lib/plox/dataset.ex | 6 +- lib/plox/date_scale.ex | 24 +++--- lib/plox/date_time_scale.ex | 6 +- lib/plox/linear_axis.ex | 4 +- lib/plox/x_axis.ex | 4 +- lib/plox/y_axis.ex | 4 +- 11 files changed, 144 insertions(+), 122 deletions(-) diff --git a/docs/migration_guide.md b/docs/migration_guide.md index 74a17a7..0d9997b 100644 --- a/docs/migration_guide.md +++ b/docs/migration_guide.md @@ -1,4 +1,4 @@ -# Plox Migration Guide (0.2.0 to 0.3.0) +# 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. @@ -54,7 +54,7 @@ example_graph = ``` -## Example of 0.3.0 usage +## Example of X.X.X usage 1. Set up data, dimensions, axes, and dataset: diff --git a/lib/plox.ex b/lib/plox.ex index 6b55a5b..460f176 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -1,7 +1,6 @@ defmodule Plox do @moduledoc """ - Composable, customizable, and flexible SVG graphing components rendered server-side - for Phoenix and LiveView. + Server-side rendered SVG graphing components for Phoenix and LiveView. """ use Phoenix.Component @@ -23,8 +22,6 @@ defmodule Plox do # FIXME: attr :rest, :global - slot :legend - slot :tooltips slot :inner_block, required: true def graph(assigns) do @@ -404,25 +401,25 @@ defmodule Plox do ## 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}] + 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}] """ def points(x, y) do values([x, y]) @@ -433,25 +430,25 @@ defmodule Plox do ## 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}] + 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 @@ -693,48 +690,48 @@ defmodule Plox do # |> Enum.chunk_every(2, 1, :discard) # end - @doc """ - Legend row. - """ - @doc type: :component + # @doc """ + # Legend row. + # """ + # @doc type: :component - slot :inner_block, required: true + # slot :inner_block, required: true - def legend(assigns) do - ~H""" -
- {render_slot(@inner_block)} -
- """ - end + # def legend(assigns) do + # ~H""" + #
+ # {render_slot(@inner_block)} + #
+ # """ + # end - @doc """ - Legend item. - """ - @doc type: :component + # @doc """ + # Legend item. + # """ + # @doc type: :component - attr :color, :string, required: true - attr :label, :string, required: true + # attr :color, :string, required: true + # attr :label, :string, required: true - def legend_item(assigns) do - ~H""" -
- <.color_bubble color={@color} /> -

{@label}

-
- """ - end + # def legend_item(assigns) do + # ~H""" + #
+ # <.color_bubble color={@color} /> + #

{@label}

+ #
+ # """ + # end - @doc """ - A colored circle for legends. - """ - @doc type: :component + # @doc """ + # A colored circle for legends. + # """ + # @doc type: :component - attr :color, :string, required: true + # attr :color, :string, required: true - def color_bubble(assigns) do - ~H""" -
- """ - end + # def color_bubble(assigns) do + # ~H""" + #
+ # """ + # end end diff --git a/lib/plox/box.ex b/lib/plox/box.ex index fe61d5a..4d5d6a3 100644 --- a/lib/plox/box.ex +++ b/lib/plox/box.ex @@ -13,20 +13,20 @@ defmodule Plox.Box do ## Examples - iex> Plox.Box.new(10) - %Plox.Box{top: 10, right: 10, bottom: 10, left: 10} + 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}) + %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}) + %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} - 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 diff --git a/lib/plox/color_axis.ex b/lib/plox/color_axis.ex index a0b33e6..c92b5df 100644 --- a/lib/plox/color_axis.ex +++ b/lib/plox/color_axis.ex @@ -3,9 +3,7 @@ defmodule Plox.ColorAxis do ColorAxis converts `Plox.ColorScale` values to graphable colors. This module implements the `Access` behaviour, allowing access to graphable - values using the `[]` syntax. - - ## Example + values using the `[]` syntax: iex> color_scale = Plox.FixedColorsScale.new(%{red: "#ff0000", green: "#00ff00", blue: "#0000ff"}) iex> color_axis = Plox.ColorAxis.new(color_scale) diff --git a/lib/plox/data_point.ex b/lib/plox/data_point.ex index ff0bd7b..7807bec 100644 --- a/lib/plox/data_point.ex +++ b/lib/plox/data_point.ex @@ -1,10 +1,47 @@ defmodule Plox.DataPoint do @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 diff --git a/lib/plox/dataset.ex b/lib/plox/dataset.ex index e0e7698..b2f6393 100644 --- a/lib/plox/dataset.ex +++ b/lib/plox/dataset.ex @@ -47,9 +47,7 @@ defmodule Plox.Dataset do the `Plox.DataPoint`s to graphable values. This module implements the `Access` behaviour, allowing access to each axis - using the `[]` syntax. - - ## Example + using the `[]` syntax: iex> dataset = %Plox.Dataset{data: [], axes: %{x: %Plox.XAxis{}, y: %Plox.YAxis{}}} iex> dataset[:x] @@ -62,8 +60,6 @@ defmodule Plox.Dataset do Since `Plox.Axis` also implements the `Access` behaviour, you can access the graphable values more ergonomically when rendering elements in a graph: - ## Example - <.circle cx={@dataset[:x]} cy={@dataset[:y][40]} fill="red" r="3" /> """ diff --git a/lib/plox/date_scale.ex b/lib/plox/date_scale.ex index 72c8928..e1334d3 100644 --- a/lib/plox/date_scale.ex +++ b/lib/plox/date_scale.ex @@ -6,23 +6,23 @@ defmodule Plox.DateScale do `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-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]] + 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-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 + 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] diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 336c5d4..5ca70d4 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -16,9 +16,9 @@ defmodule Plox.DateTimeScale do `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 + 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 diff --git a/lib/plox/linear_axis.ex b/lib/plox/linear_axis.ex index a173238..7489de6 100644 --- a/lib/plox/linear_axis.ex +++ b/lib/plox/linear_axis.ex @@ -4,9 +4,7 @@ defmodule Plox.LinearAxis do 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. - - ## Example + values using the `[]` syntax: iex> scale = Plox.NumberScale.new(0, 10) iex> linear_axis = %Plox.LinearAxis{scale: scale, min: 0, max: 100} diff --git a/lib/plox/x_axis.ex b/lib/plox/x_axis.ex index 6173a9a..2809201 100644 --- a/lib/plox/x_axis.ex +++ b/lib/plox/x_axis.ex @@ -4,9 +4,7 @@ defmodule Plox.XAxis do to graphable x-coordinates. This module implements the `Access` behaviour, allowing access to graphable - values using the `[]` syntax. - - ## Example + values using the `[]` syntax: iex> scale = Plox.NumberScale.new(0, 10) iex> dimensions = Plox.Dimensions.new(100, 100, margin: 0) diff --git a/lib/plox/y_axis.ex b/lib/plox/y_axis.ex index 0b965a5..edc00d2 100644 --- a/lib/plox/y_axis.ex +++ b/lib/plox/y_axis.ex @@ -5,9 +5,7 @@ defmodule Plox.YAxis do 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. - - ## Example + 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) From c75dd9f49fd123cdbd52dee8f51614344aa790fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Wed, 23 Jul 2025 13:43:20 -0700 Subject: [PATCH 21/23] Run CI against more elixir/OTP versions (#22) * Run CI against more elixir/OTP versions * Run CI on all pushes and PRs, not just against main * Remove fancy new guard so we can support older Elixir --- .github/workflows/ci.yml | 131 ++++++++++----------------------- lib/plox/fixed_colors_scale.ex | 2 +- mix.exs | 2 +- 3 files changed, 42 insertions(+), 93 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0d596c..61f190f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,52 +1,46 @@ name: CI -on: - push: - branches: [main] - pull_request: - branches: [main] +on: [push, pull_request] env: MIX_ENV: test - OTP_VERSION_SPEC: "27.x" - ELIXIR_VERSION_SPEC: "1.18.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 +48,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/lib/plox/fixed_colors_scale.ex b/lib/plox/fixed_colors_scale.ex index 9108237..953d2f5 100644 --- a/lib/plox/fixed_colors_scale.ex +++ b/lib/plox/fixed_colors_scale.ex @@ -26,7 +26,7 @@ defmodule Plox.FixedColorsScale do mapping: %{red: "#ff0000", green: "#00ff00", blue: "#0000ff"} } """ - def new(mapping) when is_non_struct_map(mapping) and map_size(mapping) >= 2 do + def new(mapping) when not is_struct(mapping) and map_size(mapping) >= 2 do %__MODULE__{mapping: mapping} end diff --git a/mix.exs b/mix.exs index 73b2fee..890786d 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Plox.MixProject do [ app: :plox, version: @version, - elixir: "~> 1.18", + elixir: "~> 1.16", start_permanent: Mix.env() == :prod, deps: deps(), From bc62321b57383bad52ec5096318f48e415a124b0 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Wed, 23 Jul 2025 14:36:55 -0700 Subject: [PATCH 22/23] Update CI to only push to main --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f190f..a408c38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ name: CI -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: env: MIX_ENV: test From 010a6d563ebb799977f791e0052af165c1581e8b Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Wed, 14 Jan 2026 16:31:57 -0800 Subject: [PATCH 23/23] Small cleanups + Fix incorrect migration guide example --- docs/migration_guide.md | 4 ++-- examples/demo_live.exs | 2 -- lib/plox.ex | 9 ++++----- lib/plox/dataset.ex | 1 - 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/migration_guide.md b/docs/migration_guide.md index 0d9997b..92debdb 100644 --- a/docs/migration_guide.md +++ b/docs/migration_guide.md @@ -48,9 +48,9 @@ example_graph = {value} - <.polyline dataset={graph[:dataset]} color="#EC7E16" /> + <.line_plot dataset={graph[:dataset]} color="#EC7E16" /> - <.circles dataset={graph[:dataset]} color="#EC7E16" /> + <.points_plot dataset={graph[:dataset]} color="#EC7E16" /> ``` diff --git a/examples/demo_live.exs b/examples/demo_live.exs index 6fc618e..b33448c 100644 --- a/examples/demo_live.exs +++ b/examples/demo_live.exs @@ -86,13 +86,11 @@ defmodule DemoLive do <.y_axis_grid_lines axis={@y_axis} ticks={5} stroke="#D3D3D3" /> - <%!-- <.polyline points={Enum.zip(@dataset[:x], @dataset[:y])} stroke="orange" stroke-width={2} /> --%> <.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 dataset={@dataset} x={:x} y={:y} stroke="pink" 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 --%> diff --git a/lib/plox.ex b/lib/plox.ex index 460f176..a2b6f8d 100644 --- a/lib/plox.ex +++ b/lib/plox.ex @@ -19,7 +19,6 @@ defmodule Plox do @doc type: :component attr :dimensions, Dimensions, required: true - # FIXME: attr :rest, :global slot :inner_block, required: true @@ -27,7 +26,7 @@ defmodule Plox do def graph(assigns) do ~H"""
-
+