├── .formatter.exs
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .vscode
└── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets
├── contex.css
└── logo.png
├── lib
├── chart
│ ├── axis.ex
│ ├── barchart.ex
│ ├── dataset.ex
│ ├── gallery
│ │ ├── 00_aliases.sample
│ │ ├── bar_charts.ex
│ │ ├── bar_charts_log_stacked.sample
│ │ ├── bar_charts_log_stacked_auto_domain.sample
│ │ ├── bar_charts_plain.sample
│ │ ├── bar_charts_plain_horizontal.sample
│ │ ├── bar_charts_plain_stacked.sample
│ │ ├── ohlc_candle.sample
│ │ ├── ohlc_charts.ex
│ │ ├── ohlc_tick.sample
│ │ ├── pie_charts.ex
│ │ ├── pie_charts_plain.sample
│ │ ├── point_plots.ex
│ │ ├── point_plots_log_masked.sample
│ │ ├── point_plots_log_masked_autorange.sample
│ │ ├── point_plots_log_masked_linear.sample
│ │ ├── point_plots_log_symmetric.sample
│ │ └── sample.ex
│ ├── gantt.ex
│ ├── legend.ex
│ ├── lineplot.ex
│ ├── mapping.ex
│ ├── ohlc.ex
│ ├── pie_chart.ex
│ ├── plot.ex
│ ├── pointplot.ex
│ ├── scale.ex
│ ├── scale
│ │ ├── category_colour_scale.ex
│ │ ├── continuous_linear_scale.ex
│ │ ├── continuous_log_scale.ex
│ │ ├── ordinal_scale.ex
│ │ ├── scale_utils.ex
│ │ └── time_scale.ex
│ ├── simple_pie.ex
│ ├── sparkline.ex
│ ├── svg.ex
│ ├── svg_sanitize.ex
│ └── utils.ex
└── contex.ex
├── mix.exs
├── mix.lock
├── samples
├── barchart.png
├── mashup.png
├── rolling.gif
└── test.svg
└── test
├── category_colour_scale_test.exs
├── contex_axis_test.exs
├── contex_bar_chart_test.exs
├── contex_continuous_linear_scale_test.exs
├── contex_continuous_log_scale_test.exs
├── contex_dataset_test.exs
├── contex_gantt_chart_test.exs
├── contex_legend_test.exs
├── contex_line_chart_test.exs
├── contex_linear_scale_test.exs
├── contex_mapping_test.exs
├── contex_plot_test.exs
├── contex_point_plot_test.exs
├── contex_scale_utils.exs
├── contex_scale_utils_test.exs
├── contex_test.exs
├── contex_timescale_test.exs
├── gallery
├── bar_charts_test.exs
├── gallery_tester.exs
├── pie_charts_test.exs
└── point_plots_test.exs
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
3 | ]
4 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [pull_request, push]
3 | jobs:
4 | mix_test:
5 | name: mix test (Elixir ${{ matrix.elixir }} OTP ${{ matrix.otp }})
6 | strategy:
7 | matrix:
8 | elixir:
9 | - "1.13"
10 | - "1.14"
11 | otp:
12 | - "24"
13 | - "25"
14 | include:
15 | - elixir: "1.14"
16 | otp: "25"
17 | format: true
18 |
19 | runs-on: ubuntu-20.04
20 | steps:
21 | - uses: actions/checkout@v2
22 |
23 | - name: Set up Elixir
24 | uses: erlef/setup-beam@v1
25 | with:
26 | elixir-version: ${{ matrix.elixir }}
27 | otp-version: ${{ matrix.otp }}
28 |
29 | - name: Install Dependencies
30 | run: |
31 | mix local.rebar --force
32 | mix local.hex --force
33 | mix deps.get
34 |
35 | - name: Run Tests
36 | run: mix test
37 |
38 | - name: Format
39 | run: mix format --check-formatted
40 | if: ${{ matrix.format }}
41 |
42 | - name: Dialyzer
43 | run: mix dialyzer
44 | if: ${{ matrix.format }}
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 | /cover
3 | /deps
4 | /doc
5 | /.fetch
6 | erl_crash.dump
7 | *.ez
8 | *.beam
9 | /config/*.secret.exs
10 | .elixir_ls/
11 | .tool-versions
12 | .DS_Store
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 2,
3 | "editor.formatOnSave": true
4 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # ContEx Change Log
2 |
3 | ## v0.5.0 : 2023-05-31
4 |
5 | - NOTE: behaviour change. If a `Dataset` is made up of rows of maps, `column_names` now returns headers if it is supplied, otherwise returns
6 | map keys from the first row. Previously, it just returned the map keys from the first row.
7 | - NOTE: `PlotContent.get_svg_legend` has been replaced with `PlotContent.get_legend_scales`. This moves the responsibility for legend
8 | rendering to `Plot`, which allows multiple scales to be combined in future. This change was needed to allow plot content to
9 | be resized based on the number of entries in the legend.
10 | - Change smoothed line to round coordinates to 2 decimal places (reducing SVG size for large plots).
11 | - Enable plot_options (show x axis, y axis) in `LinePlot` and `PointPlot`. Thanks @shadowRR
12 | - Added `meta` to `Dataset` to allow carrying of handy information with the dataset.
13 | - Added `:legend_top` and `:legend_bottom` options to `:legend_settings` - note that if you have too many legend entries it
14 | may push the plot content beyond the bottom of the plot.
15 | - Added `ContinuousLogScale`. Thanks @l3nz
16 | - Added a gallery into the documentation. Thanks @l3nz
17 | - Added additional examples into the gallery provided by @travelmassive. Thanks @l3nz & @travelmassive
18 | - Fixes to tests & documentation. Thanks @kianmeng, @axelson
19 | - Fix to `ContinuousLinearScale`. Thanks @ruimfernanded
20 |
21 |
22 | ## v0.4.0 : 2021-08-13
23 |
24 | - Add `SimplePie` - a sparkline-like Pie Chart. Thanks @zdenal.
25 | - Add `PieChart` - a Pie Chart with more control over labels, colour palette, legend generation etc. Thanks @zdenal
26 | - Add target option when setting `BarChart` event handler.
27 | - Refactor `BarChart` to honour all options passed in `new`.
28 | - Refactor `PointPlot` to honour all options passed in `new`.
29 | - Refactor `GanttChart` to honour all options passed in `new`.
30 | - Provide minimal default style so labels don't disappear unexpectedly if no CSS is set. Thanks @srowley.
31 | - XML declaration added to generated SVG so the output can be served as an image. Thanks @srowley.
32 | - Custom tick formatting enabled for PointPlot
33 | - Added `:custom_value_scale` to `BarChart` to allow overriding of the automatically generated scale
34 | - Added `:custom_x_scale` and `:custom_y_scale` to `PointPlot` to allow overriding of the automatically generated scales
35 | - Added `LinePlot` (finally)
36 | - Make stroke width for `LinePlot` adjustable through options. Thanks @littleStudent.
37 | - Handle nil values in `LinePlot` by creating gaps in line.
38 | - Stop crash on timescales when interval clashes with days in month resulting in invalid date. Thanks @imsoulfly.
39 | - Fix colour palette option in `GanttChart`.
40 |
41 | ### Deprecated
42 | - Most of the options set via functions, e.g. `BarChart.colours/2`. Use the options in the relevant `new` functions instead.
43 |
44 | ## v0.3.0 : 2020-06-08
45 | - Allow Dataset to be created from a list of Maps (previously lists of lists and list of tuples were supported)
46 | - Implement a data mapping mechanism to provide a consistent way of mapping data into required plot elements. Thanks
47 | @srowley for breaking the back of this.
48 | - Added simplified chart creation API. Thanks @srowley. The existing API remains as-is
49 | - Added test coverage for some components. Thanks @srowley
50 | - Refactored SVG generation to minimise, or at least isolate, messy string interpolation, as per @elcritch suggestion. More to do on code tidy up.
51 | - Improved Timescale code. Thanks for suggestions from [Eiji](https://elixirforum.com/u/eiji/)
52 | - Replaced Enum with Stream in (as yet to be activated) line generation code in PointPlot as per @elcritch suggestion.
53 | - Fixed up some typespecs to stop Elixir 1.10 complaining. More to do to make the module struct specs right.
54 | - Removed Timex dependency.
55 | - A number of bug fixes in TimeScales, plus additional type guards
56 | - Added basic sanitization of possible user inputs (titles, axis labels, category names from data). Sanitization approach is somewhat naive - basically any text is run through a copy of the `Plug.HTML.html_escape`.
57 | - Fixed scaling bug in sparkline when data range was small
58 | - Added axis_label_rotation option to PointPlot and BarChart. Thanks @srowley.
59 | - Allow plot margins to be optionally specified and override default calcs. Thanks @axelson.
60 |
61 | ## v0.2.0 : 2020-01-28
62 | - Documentation for all modules
63 | - Created type specs for the public API
64 | - Renamed :data to :dataset in various plot types to avoid ambiguity
65 | - ** BREAKING ** Renamed BarPlot to BarChart
66 | - ** POTENTIALLY BREAKING ** Renamed ContinuousScale to ContinuousLinearScale, renamed constructor from new_linear() to new(). Used internally, so shouldn't cause any issues.
67 | - ** POTENTIALLY BREAKING ** Made a number of margin calculation functions in Plot private. Shouldn't have been used externally anyway.
68 | - ** POTENTIALLY BREAKING ** Removed set_x_range and set_y_range from PointPlot. No longer used as range is set by Plot size calcs.
69 | - Fixed incorrect closed path in sparkline
70 | - Allowed forcing of value range in `BarChart` (`BarChart.force_value_range\2`)
71 | - Changed `BarChart` colour handling to pass through to `ColourCategoryScale`
72 | - Changed `new\3` to `new\1` on various plots as width & height are now set by `Plot`
73 | - Prevented infinite loop in `ColourCategoryScale` when setting palette to `nil` (turns out `nil` is an atom)
74 | - Fixed divide by zero error in `ContinuousLinearScale` (needed to test for float as well as integer)
75 | - Enabled legend for point plot
76 | - Added multiple series for point plot (note - must share a common x value at this stage)
77 |
78 |
79 | ## v0.1.0 : 2020-01-15
80 | Initial version extracted from bigger project
81 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 John Jessop (mindOk)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ContEx
2 |
3 | ContEx is a simple server side charting package for elixir. See these demos on the live site:
4 |
5 | 
6 |
7 | ... and it works nicely in Phoenix LiveView
8 |
9 | 
10 |
11 | 
12 |
13 | ## Core concepts
14 |
15 | ### Dataset
16 |
17 | ContEx uses a simple `Dataset` structure - a list of lists or a list of tuples together with a list of column names.
18 |
19 | For example:
20 |
21 | ```elixir
22 | data = [{1, 1}, {2, 2}]
23 | ds = Dataset.new(data, ["x", "y"])
24 | ```
25 |
26 | ### Charts
27 |
28 | Data can be represented within different chart types. Currently supported charts are `BarChart`, `PointPlot`, `LinePlot`, `GanttChart` and `Sparkline`. Generally speaking, you can create a chart structure by calling `new()` on the relevant module and Contex will take a reasonable guess at what you want. For example:
29 |
30 | ```elixir
31 | point_plot = PointPlot.new(ds)
32 | ```
33 |
34 | Will make a new point plot with the first column used for the x-axis, the second for the y-axis, and the scales set to look ok.
35 |
36 | Each module has different option. For example, `BarChart` allows you to set the `:padding` between the bar groups, specify whether you want `:type` to be `:grouped` or `:stacked`. The options are described in each module's documentation and are set in `new/2`.
37 |
38 | `DataSet` columns are mapped to the attributes each different chart type expects. For example, a `PointPlot` expects an x column and at
39 | least one y column. These are set up by passing a `:mapping` option in the options when creating a new chart. For example,
40 |
41 | ```elixir
42 | chart = PointPlot.new(
43 | dataset,
44 | mapping: %{x_col: :column_a, y_cols: [:column_b, column_c]}
45 | )
46 | ```
47 |
48 | It isn't necessary to supply a mapping unless the `DataSet` is a list of maps. If no mapping is provided, columns will be allocated
49 | automatically. For a `PointPlot`, the first column will be used for x, and the second for y.
50 |
51 | Each chart type implements the `PlotContent` protocol which requires it to scale to a defined height and width, emit SVG and optionally emit SVG for a legend. Generally, you won't directly access this protocol however, because...
52 |
53 | ### Plots
54 |
55 | ... Charts live within a `Plot`. `Plot`s manage things like titles, margins, axis titles, legend placement etc.
56 |
57 | So to generate SVG ready for your web-page you would do something like:
58 |
59 | ```elixir
60 | plot = Plot.new(600, 400, point_plot)
61 | |> Plot.plot_options(%{legend_setting: :legend_right})
62 | |> Plot.titles("My first plot", "With a fancy subtitle")
63 |
64 | Plot.to_svg(plot)
65 | #^ This generates something like {:safe, ""}
66 | ```
67 |
68 | There is a short-cut API which creates the `PlotContent` and plot in a single pass by providing the chart type module to `Plot.new/5`.
69 |
70 | For example:
71 |
72 | ```elixir
73 | plot = Plot.new(dataset, Contex.PointPlot, 600, 400, mapping: %{x_col: :column_a, y_cols: [:column_b, :column_c]})
74 |
75 | Plot.to_svg(plot)
76 | ```
77 |
78 | ### Scales
79 |
80 | Scales are all about mapping attributes to plotting geometry. They handle transformation of data to screen coordinates (and other plotting attributes). They also handle calculation of tick intervals and the like where appropriate. Scales currently implemented are:
81 |
82 | - `ContinuousLinearScale` : A linear continuous scale
83 | - `ContinuousLogScale` : A log version of continuous scale
84 | - `OrdinalScale` : For categories / discrete attributes. Used for plotting the category axis in a `BarChart`.
85 | - `CategoryColourScale` : Maps unique attributes into colours
86 | - `TimeScale` : A continuous timescale for `DateTime` and `NaiveDateTime` data types
87 |
88 | Others under consideration:
89 |
90 | - `ContinuousColourScale` : Generate colour gradients
91 |
92 | ### Legends
93 |
94 | `Legend`s are generated for scales. Currently legend generation is only supported for a `CategoryColourScale`
95 |
96 | ### WARNING
97 |
98 | There are quite a few things to tidy up to make this ready for the real world, and the API is likely to be unstable for a little while yet...
99 |
100 | - [x] Reasonable docs - the best resource currently is the accompanying [demo project](https://github.com/mindok/contex-samples)
101 | - [x] Default styling
102 | - [ ] Upgrade Elixir required version to 1.10 and fix up some of the data comparison operators to use the new sort capabilities. Holding off on this for a while so we don't force an unwanted Elixir upgrade.
103 | - [x] Multiple series in point plot
104 | - [x] Line plot
105 | - [x] Some test coverage - it has been built interactively using a liveview page for testing / refinement. Thanks to @srowley for getting some test coverage in place.
106 | - [ ] More test coverage... An approach for comparing "blessed" output SVG would make sense, including handling minor difference in spacing or element attribute order.
107 | - [ ] Options handling - needs to be better structured and use keyword lists rather than maps
108 | - [x] Options for BarChart, PointPlot and GanttChart
109 | - [ ] Plot options
110 | - [ ] Colour handling
111 | - [ ] Plot overlays (e.g. line chart on bar chart)
112 | - [x] SVG generation is poorly structured - lots of string interpolation.
113 | - [ ] Benchmarks - particularly for the situation where large datasets are getting updated frequently and served via LiveViews.
114 | - [x] Pie Charts
115 |
116 | ## Installation
117 |
118 | The package can be installed
119 | by adding `contex` to your list of dependencies in `mix.exs`:
120 |
121 | ```elixir
122 | def deps do
123 | [
124 | {:contex, "~> 0.5.0"}
125 | ]
126 | end
127 | ```
128 |
129 | ## Prior Art, Related Material & Alternatives
130 |
131 | Various details relating to scales, axes and SVG layout have been learnt from the excellent [D3](https://d3js.org/) library by [Mike Bostock](https://github.com/mbostock).
132 |
133 | The theory of translating data into graphics is also very well handled by [ggplot2](https://ggplot2.tidyverse.org/) and various papers by Hadley Wickham, such as [A Layered Grammar of Graphics](http://vita.had.co.nz/papers/layered-grammar.pdf)
134 |
135 | ### Pure Elixir Alternatives
136 |
137 | - [GGity](https://github.com/srowley/ggity) - modelled on [ggplot2](https://ggplot2.tidyverse.org/)
138 | - [PlotEx](https://github.com/elcritch/plotex) - has good line & time-series support and more optimised for certain situations.
139 | - [Sasa Juric Homebrew](https://github.com/sasa1977/demo_system/) - graph.html.leex has examples of injecting data into SVGs for very specific use cases.
140 |
--------------------------------------------------------------------------------
/assets/contex.css:
--------------------------------------------------------------------------------
1 | /* Styling for tick line */
2 | .exc-tick line {
3 | stroke: rgb(207, 207, 207);
4 | ;
5 | }
6 |
7 | /* Styling for tick text */
8 | .exc-tick text {
9 | fill: grey;
10 | stroke: none;
11 | font-size: 0.5rem;
12 | font-family: Helvetica, Arial, sans-serif;
13 | }
14 |
15 | /* Styling for axis line */
16 | .exc-domain {
17 | stroke: rgb(207, 207, 207);
18 | }
19 |
20 | /* Styling for grid line */
21 | .exc-grid {
22 | stroke: lightgrey;
23 | }
24 |
25 | /* Styling for outline of colours in legend */
26 | .exc-legend {
27 | stroke: black;
28 | }
29 |
30 | /* Styling for text of colours in legend */
31 | .exc-legend text {
32 | fill: grey;
33 | font-size: 0.8rem;
34 | stroke: none;
35 | font-family: Helvetica, Arial, sans-serif;
36 | }
37 |
38 | /* Styling for title & subtitle of any plot */
39 | .exc-title {
40 | fill: darkslategray;
41 | font-size: 1.3rem;
42 | stroke: none;
43 | font-family: Helvetica, Arial, sans-serif;
44 | }
45 |
46 | .exc-subtitle {
47 | fill: darkgrey;
48 | font-size: 0.7rem;
49 | stroke: none;
50 | font-family: Helvetica, Arial, sans-serif;
51 | }
52 |
53 | /* Styling for label printed inside a bar on a barchart */
54 | .exc-barlabel-in {
55 | fill: white;
56 | font-size: 0.8rem;
57 | font-family: Helvetica, Arial, sans-serif;
58 | }
59 |
60 | /* Styling for label printed outside of a bar (e.g. if bar is too small) */
61 | .exc-barlabel-out {
62 | fill: grey;
63 | font-size: 0.7rem;
64 | font-family: Helvetica, Arial, sans-serif;
65 | }
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mindok/contex/997a0f0932a3f63f3dae6afa6acfb219d0dee8db/assets/logo.png
--------------------------------------------------------------------------------
/lib/chart/axis.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Axis do
2 | @moduledoc """
3 | `Contex.Axis` represents the visual appearance of a `Contex.Scale`
4 |
5 | In general terms, an Axis is responsible for rendering a `Contex.Scale` where the scale is used to position
6 | a graphical element.
7 |
8 | As an end-user of the Contex you won't need to worry too much about Axes - the specific
9 | plot types take care of them. Things like styling and scales are handled elsewhere. However,
10 | if you are building a new plot type you will need to understand how they work.
11 |
12 | Axes can be drawn with ticks in different locations relative to the Axis based on the orientation.
13 | For example, when `:orientation` is `:top`, the axis is drawn as a horizontal line with the ticks
14 | above and the tick text above that.
15 |
16 | `:rotation` is used to optionally rotate the labels and can either by 45 or 90 (anything else is considered to be 0).
17 |
18 | `:tick_size_inner` and `:tick_size_outer` control the line lengths of the ticks.
19 |
20 | `:tick_padding` controls the gap between the end of the tick mark and the tick text.
21 |
22 | `:flip_factor` is for internal use. Whatever you set it to will be ignored.
23 |
24 | An offset relative to the containing SVG element's origin is used to position the axis line.
25 | For example, an x-axis drawn at the bottom of the plot will typically be offset by the height
26 | of the plot content. The different plot types look after this internally.
27 |
28 | There are some layout heuristics to calculate text sizes and offsets based on axis orientation and whether the
29 | tick labels are rotated.
30 | """
31 |
32 | alias __MODULE__
33 | alias Contex.Scale
34 |
35 | defstruct [
36 | :scale,
37 | :orientation,
38 | rotation: 0,
39 | tick_size_inner: 6,
40 | tick_size_outer: 6,
41 | tick_padding: 3,
42 | flip_factor: 1,
43 | offset: 0
44 | ]
45 |
46 | @orientations [:top, :left, :right, :bottom]
47 |
48 | @type t() :: %__MODULE__{}
49 | @type orientations() :: :top | :left | :right | :bottom
50 |
51 | @doc """
52 | Create a new axis struct with orientation being one of :top, :left, :right, :bottom
53 | """
54 | @spec new(Contex.Scale.t(), orientations()) :: __MODULE__.t()
55 | def new(scale, orientation) when orientation in @orientations do
56 | if is_nil(Contex.Scale.impl_for(scale)) do
57 | raise ArgumentError, message: "scale must implement Contex.Scale protocol"
58 | end
59 |
60 | %Axis{scale: scale, orientation: orientation}
61 | end
62 |
63 | @doc """
64 | Create a new axis struct with orientation set to `:top`.
65 |
66 | Equivalent to `Axis.new(scale, :top)`
67 | """
68 | @spec new_top_axis(Contex.Scale.t()) :: __MODULE__.t()
69 | def new_top_axis(scale), do: new(scale, :top)
70 |
71 | @doc """
72 | Create a new axis struct with orientation set to `:bottom`.
73 |
74 | Equivalent to `Axis.new(scale, :bottom)`
75 | """
76 | @spec new_bottom_axis(Contex.Scale.t()) :: __MODULE__.t()
77 | def new_bottom_axis(scale), do: new(scale, :bottom)
78 |
79 | @doc """
80 | Create a new axis struct with orientation set to `:left`.
81 |
82 | Equivalent to `Axis.new(scale, :left)`
83 | """
84 | @spec new_left_axis(Contex.Scale.t()) :: __MODULE__.t()
85 | def new_left_axis(scale), do: new(scale, :left)
86 |
87 | @doc """
88 | Create a new axis struct with orientation set to `:right`.
89 |
90 | Equivalent to `Axis.new(scale, :right)`
91 | """
92 | @spec new_right_axis(Contex.Scale.t()) :: __MODULE__.t()
93 | def new_right_axis(scale), do: new(scale, :right)
94 |
95 | @doc """
96 | Sets the offset for where the axis will be drawn. The offset will either be horizontal
97 | or vertical depending on the orientation of the axis.
98 | """
99 | @spec set_offset(__MODULE__.t(), number()) :: __MODULE__.t()
100 | def set_offset(%Axis{} = axis, offset) do
101 | %{axis | offset: offset}
102 | end
103 |
104 | @doc """
105 | Generates the SVG content for the axis (axis line, tick mark, tick labels). The coordinate system
106 | will be in the coordinate system of the containing plot (i.e. the range of the `Contex.Scale` specified for the axis)
107 | """
108 | def to_svg(%Axis{scale: scale} = axis) do
109 | # Returns IO List for axis. Assumes the containing group handles the transform to the correct location
110 | axis = %{axis | flip_factor: get_flip_factor(axis.orientation)}
111 | {range0, range1} = get_adjusted_range(scale)
112 |
113 | [
114 | "|,
117 | ~s||,
118 | get_svg_tickmarks(axis),
119 | ""
120 | ]
121 | end
122 |
123 | @doc """
124 | Generates grid-lines for each tick in the `Contex.Scale` specified for the axis.
125 | """
126 | def gridlines_to_svg(%Axis{} = axis) do
127 | [
128 | " ",
129 | get_svg_gridlines(axis),
130 | ""
131 | ]
132 | end
133 |
134 | defp get_svg_gridlines(%Axis{scale: scale} = axis) do
135 | domain_ticks = Scale.ticks_domain(scale)
136 | domain_to_range_fn = Scale.domain_to_range_fn(scale)
137 |
138 | domain_ticks
139 | # Don't render first tick as it should be on the axis
140 | |> Enum.drop(1)
141 | |> Enum.map(fn tick -> get_svg_gridline(axis, domain_to_range_fn.(tick)) end)
142 | end
143 |
144 | defp get_svg_gridline(%Axis{offset: offset} = axis, location) do
145 | dim_length = get_tick_dimension(axis)
146 |
147 | dim_constant =
148 | case dim_length do
149 | "x" -> "y"
150 | "y" -> "x"
151 | end
152 |
153 | # Nudge to render better
154 | location = location + 0.5
155 |
156 | [
157 | ~s||
160 | ]
161 | end
162 |
163 | defp get_svg_axis_location(%Axis{orientation: orientation}) when orientation in [:top, :left] do
164 | " "
165 | end
166 |
167 | defp get_svg_axis_location(%Axis{:orientation => :bottom, offset: offset}) do
168 | ~s|transform="translate(0, #{offset})"|
169 | end
170 |
171 | defp get_svg_axis_location(%Axis{:orientation => :right, offset: offset}) do
172 | ~s|transform="translate(#{offset}, 0)"|
173 | end
174 |
175 | defp get_text_anchor(%Axis{orientation: orientation}) do
176 | case orientation do
177 | :right -> "start"
178 | :left -> "end"
179 | _ -> "middle"
180 | end
181 | end
182 |
183 | defp get_svg_axis_line(%Axis{orientation: orientation} = axis, range0, range1)
184 | when orientation in [:right, :left] do
185 | %Axis{tick_size_outer: tick_size_outer, flip_factor: k} = axis
186 | ~s|M#{k * tick_size_outer},#{range0}H0.5V#{range1}H#{k * tick_size_outer}|
187 | end
188 |
189 | defp get_svg_axis_line(%Axis{orientation: orientation} = axis, range0, range1)
190 | when orientation in [:top, :bottom] do
191 | %Axis{tick_size_outer: tick_size_outer, flip_factor: k} = axis
192 | ~s|M#{range0}, #{k * tick_size_outer}V0.5H#{range1}V#{k * tick_size_outer}|
193 | end
194 |
195 | defp get_svg_tickmarks(%Axis{scale: scale} = axis) do
196 | domain_ticks = Scale.ticks_domain(scale)
197 | domain_to_range_fn = Scale.domain_to_range_fn(scale)
198 |
199 | domain_ticks
200 | |> Enum.map(fn tick -> get_svg_tick(axis, tick, domain_to_range_fn.(tick)) end)
201 | end
202 |
203 | defp get_svg_tick(%Axis{orientation: orientation} = axis, tick, range_tick) do
204 | # Approach is to calculate transform for the tick and render tick mark with text in one go
205 | [
206 | ~s|",
209 | get_svg_tick_line(axis),
210 | get_svg_tick_label(axis, tick),
211 | ""
212 | ]
213 | end
214 |
215 | defp get_svg_tick_transform(orientation, range_tick) when orientation in [:top, :bottom] do
216 | ~s|"translate(#{range_tick + 0.5},0)"|
217 | end
218 |
219 | defp get_svg_tick_transform(orientation, range_tick) when orientation in [:left, :right] do
220 | ~s|"translate(0, #{range_tick + 0.5})"|
221 | end
222 |
223 | defp get_svg_tick_line(%Axis{flip_factor: k, tick_size_inner: size} = axis) do
224 | dim = get_tick_dimension(axis)
225 | ~s||
226 | end
227 |
228 | defp get_svg_tick_label(%Axis{flip_factor: k, scale: scale} = axis, tick) do
229 | offset = axis.tick_size_inner + axis.tick_padding
230 | dim = get_tick_dimension(axis)
231 | text_adjust = get_svg_tick_text_adjust(axis)
232 |
233 | tick =
234 | Scale.get_formatted_tick(scale, tick)
235 | |> Contex.SVG.Sanitize.basic_sanitize()
236 |
237 | ~s|#{tick}|
238 | end
239 |
240 | defp get_tick_dimension(%Axis{orientation: orientation}) when orientation in [:top, :bottom],
241 | do: "y"
242 |
243 | defp get_tick_dimension(%Axis{orientation: orientation}) when orientation in [:left, :right],
244 | do: "x"
245 |
246 | defp get_svg_tick_text_adjust(%Axis{orientation: orientation})
247 | when orientation in [:left, :right],
248 | do: ~s|dy="0.32em"|
249 |
250 | defp get_svg_tick_text_adjust(%Axis{orientation: :top}), do: ""
251 |
252 | defp get_svg_tick_text_adjust(%Axis{orientation: :bottom, rotation: 45}) do
253 | ~s|dy="-0.1em" dx="-0.9em" text-anchor="end" transform="rotate(-45)"|
254 | end
255 |
256 | defp get_svg_tick_text_adjust(%Axis{orientation: :bottom, rotation: 90}) do
257 | ~s|dy="-0.51em" dx="-0.9em" text-anchor="end" transform="rotate(-90)"|
258 | end
259 |
260 | defp get_svg_tick_text_adjust(%Axis{orientation: :bottom}) do
261 | ~s|dy="0.71em" dx="0" text-anchor="middle"|
262 | end
263 |
264 | # NOTE: Recipes for rotates labels on bottom axis:
265 | # -90 dy="-0.51em" dx="-0.91em" text-anchor="end"
266 | # -45 dy="-0.1em" dx="-0.91em" text-anchor="end"
267 | # 0 dy="-0.71em" dx="0" text-anchor="middle"
268 |
269 | defp get_flip_factor(orientation) when orientation in [:top, :left], do: -1
270 |
271 | defp get_flip_factor(orientation) when orientation in [:right, :bottom], do: 1
272 |
273 | # TODO: We should only nudge things half a pixel for odd line widths. This is to stop fuzzy lines
274 | defp get_adjusted_range(scale) do
275 | {min_r, max_r} = Scale.get_range(scale)
276 | {min_r + 0.5, max_r + 0.5}
277 | end
278 | end
279 |
--------------------------------------------------------------------------------
/lib/chart/gallery/00_aliases.sample:
--------------------------------------------------------------------------------
1 |
2 | alias Contex.Plot
3 | alias Contex.PointPlot
4 | alias Contex.BarChart
5 | alias Contex.Dataset
6 | alias Contex.ContinuousLogScale
7 |
--------------------------------------------------------------------------------
/lib/chart/gallery/bar_charts.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Gallery.BarCharts do
2 | import Contex.Gallery.Sample, only: [graph: 1]
3 |
4 | @moduledoc """
5 | A gallery of Bar Charts.
6 |
7 |
8 | - `plain/0` - An introductory example
9 |
10 |
11 | > #### Have one to share? {: .warning}
12 | >
13 | > Do you have an interesting plot you want to
14 | > share? Something you learned the hard way that
15 | > should be here, or that's just great to see?
16 | > Just open a ticket on GitHub and we'll post it here.
17 |
18 | """
19 |
20 | @doc """
21 | Bar charts using a log scale.
22 |
23 | See `Contex.ContinuousLogScale` for details.
24 |
25 |
26 | #{graph(title: "A stacked sample",
27 | file: "bar_charts_log_stacked.sample",
28 | info: """
29 | This graph represents a distribution of values,
30 | rendered as a stacked sample.
31 |
32 | Notice how the large value difference (data is in minutes)
33 | makes a log scale mandatory, but the axis is not
34 | really readable on the far end.
35 |
36 | """)}
37 |
38 | #{graph(title: "A stacked sample with automatic domain and custom ticks",
39 | file: "bar_charts_log_stacked_auto_domain.sample",
40 | info: """
41 | This is the same data as above, but using a custom
42 | set of ticks that makes the values readable, and
43 | we get the axis domain out of the data-set.
44 | """)}
45 |
46 | """
47 | def with_log_scale(), do: 0
48 |
49 | @doc """
50 | Some plain charts.
51 |
52 |
53 | #{graph(title: "A simple vertical bar chart",
54 | file: "bar_charts_plain.sample",
55 | info: """
56 | Originally taken from https://github.com/mindok/contex/issues/74
57 | """)}
58 |
59 |
60 |
61 | #{graph(title: "A simple horizontal bar chart",
62 | file: "bar_charts_plain_horizontal.sample",
63 | info: """
64 | Originally taken from https://github.com/mindok/contex/issues/74
65 | """)}
66 |
67 |
68 | #{graph(title: "A simple stacked bar chart",
69 | file: "bar_charts_plain_stacked.sample",
70 | info: """
71 | Originally taken from https://github.com/mindok/contex/issues/74
72 | """)}
73 |
74 |
75 | """
76 | def plain(), do: 0
77 | end
78 |
--------------------------------------------------------------------------------
/lib/chart/gallery/bar_charts_log_stacked.sample:
--------------------------------------------------------------------------------
1 | palette = ["fff", "eee", "ff9838", "fdae53", "ddd", "fff"]
2 | series_cols = ["b0", "b1", "b2", "b3", "b4"]
3 | data = [
4 | %{
5 | "b0" => 10.4333,
6 | "b1" => 1.4834000000000014,
7 | "b2" => 2.0332999999999988,
8 | "b3" => 16.7833,
9 | "b4" => 265.40000000000003,
10 | "lbl" => "2023-03-09"
11 | },
12 | %{
13 | "b0" => 9.8667,
14 | "b1" => 1.5665999999999993,
15 | "b2" => 4.58340000000000,
16 | "b3" => 83.0333,
17 | "b4" => 359.15,
18 | "lbl" => "2023-03-08"
19 | },
20 | %{
21 | "b0" => 7.8333,
22 | "b1" => 2.9166999999999996,
23 | "b2" => 1.4666999999999994,
24 | "b3" => 9.600000000000001,
25 | "b4" => 379.2833,
26 | "lbl" => "2023-03-07"
27 | }
28 | ]
29 | test_data = Dataset.new(data, ["lbl" | series_cols])
30 |
31 | options = [
32 | mapping: %{category_col: "lbl", value_cols: series_cols},
33 | type: :stacked,
34 | data_labels: true,
35 | orientation: :horizontal,
36 | custom_value_scale:
37 | ContinuousLogScale.new(
38 | domain: {0, 1000},
39 | log_base: :base_10,
40 | negative_numbers: :mask,
41 | linear_range: 1
42 | ),
43 | colour_palette: palette
44 | ]
45 |
46 | Plot.new(test_data, BarChart, 500, 400, options)
47 | |> Plot.titles("Stacked bars", "Log axis")
48 | |> Plot.axis_labels("", "")
49 |
--------------------------------------------------------------------------------
/lib/chart/gallery/bar_charts_log_stacked_auto_domain.sample:
--------------------------------------------------------------------------------
1 | palette = ["fff", "eee", "ff9838", "fdae53", "ddd", "fff"]
2 | series_cols = ["b0", "b1", "b2", "b3", "b4"]
3 | data = [
4 | %{
5 | "b0" => 10.4333,
6 | "b1" => 1.4834000000000014,
7 | "b2" => 2.0332999999999988,
8 | "b3" => 16.7833,
9 | "b4" => 265.40000000000003,
10 | "lbl" => "2023-03-09"
11 | },
12 | %{
13 | "b0" => 9.8667,
14 | "b1" => 1.5665999999999993,
15 | "b2" => 4.58340000000000,
16 | "b3" => 83.0333,
17 | "b4" => 359.15,
18 | "lbl" => "2023-03-08"
19 | },
20 | %{
21 | "b0" => 7.8333,
22 | "b1" => 2.9166999999999996,
23 | "b2" => 1.4666999999999994,
24 | "b3" => 9.600000000000001,
25 | "b4" => 379.2833,
26 | "lbl" => "2023-03-07"
27 | }
28 | ]
29 | test_dataset = Dataset.new(data, ["lbl" | series_cols])
30 |
31 | options = [
32 | mapping: %{category_col: "lbl", value_cols: series_cols},
33 | type: :stacked,
34 | data_labels: true,
35 | orientation: :horizontal,
36 | custom_value_scale:
37 | ContinuousLogScale.new(
38 | dataset: test_dataset, axis: series_cols,
39 | tick_positions: [0, 5, 10, 15, 30, 60, 120, 240, 480, 960],
40 | log_base: :base_10,
41 | negative_numbers: :mask,
42 | linear_range: 1
43 | ),
44 | colour_palette: palette
45 | ]
46 |
47 | Plot.new(test_dataset, BarChart, 500, 400, options)
48 | |> Plot.titles("Stacked bars", "Log axis")
49 | |> Plot.axis_labels("", "")
50 |
--------------------------------------------------------------------------------
/lib/chart/gallery/bar_charts_plain.sample:
--------------------------------------------------------------------------------
1 |
2 | data = [
3 | ["Tiktok", 7.7],
4 | ["Twitter", 8.7],
5 | ["YouTube", 10.2],
6 | ["Blog/Website", 17],
7 | ["Instagram", 17.5]
8 | ]
9 |
10 | series_cols = ["Series 1"]
11 | test_data = Contex.Dataset.new(data, ["Category" | series_cols])
12 |
13 | options = [
14 | mapping: %{category_col: "Category", value_cols: ["Series 1"]},
15 | type: :stacked,
16 | data_labels: true,
17 | orientation: :vertical,
18 | colour_palette: ["4c4bdc"],
19 | series_columns: series_cols
20 | ]
21 |
22 | Contex.Plot.new(test_data, Contex.BarChart, 500, 400, options)
23 | |> Contex.Plot.titles("Combined Reach (M)", "")
24 | |> Contex.Plot.axis_labels("", "")
25 | |> Contex.Plot.plot_options(%{})
26 |
--------------------------------------------------------------------------------
/lib/chart/gallery/bar_charts_plain_horizontal.sample:
--------------------------------------------------------------------------------
1 | data = [
2 | ["Writing", 248],
3 | ["Adventure", 166],
4 | ["Food", 145],
5 | ["Travel Guide", 109],
6 | ["Photography", 94],
7 | ["Lifestyle", 78],
8 | ["Family", 75],
9 | ["Video", 71],
10 | ["Sustainability", 55],
11 | ["Luxury", 55],
12 | ["Womens Travel", 48],
13 | ["Vanlife", 46],
14 | ["Journalist", 39],
15 | ["Solo Travel", 29],
16 | ["Podcast", 25],
17 | ["Accommodation", 24],
18 | ["Outdoors", 24],
19 | ["Nomad", 20],
20 | ["Fashion", 20],
21 | ["Hiking", 18],
22 | ["Flying", 17],
23 | ["Cruise", 16],
24 | ["Points", 13],
25 | ["Wellness", 12],
26 | ["Slow Travel", 11],
27 | ] |> Enum.reverse()
28 |
29 | series_cols = ["Series 1"]
30 | test_data = Contex.Dataset.new(data, ["Category" | series_cols])
31 |
32 | options = [
33 | mapping: %{category_col: "Category", value_cols: ["Series 1"]},
34 | type: :stacked,
35 | data_labels: true,
36 | orientation: :horizontal,
37 | colour_palette: ["1e293b"],
38 | series_columns: series_cols
39 | ]
40 |
41 | Contex.Plot.new(test_data, Contex.BarChart, 500, 400, options)
42 | |> Contex.Plot.titles("", "")
43 | |> Contex.Plot.axis_labels("", "")
44 | |> Contex.Plot.plot_options(%{})
--------------------------------------------------------------------------------
/lib/chart/gallery/bar_charts_plain_stacked.sample:
--------------------------------------------------------------------------------
1 | data = [
2 | ["Tiktok", 4.7, 3],
3 | ["Twitter", 6.7, 2],
4 | ["YouTube", 5.2, 5],
5 | ["Blog/Website", 7, 8],
6 | ["Instagram", 10.5, 7]
7 | ]
8 |
9 | series_cols = ["Series 1", "Series 2"]
10 | test_data = Contex.Dataset.new(data, ["Category" | series_cols])
11 |
12 | options = [
13 | mapping: %{category_col: "Category", value_cols: ["Series 1", "Series 2"]},
14 | type: :stacked,
15 | data_labels: true,
16 | orientation: :vertical,
17 | colour_palette: ["4c4bdc", "c13584"],
18 | series_columns: series_cols
19 | ]
20 |
21 | Contex.Plot.new(test_data, Contex.BarChart, 500, 400, options)
22 | |> Contex.Plot.titles("Combined Reach of Brand + Individuals (M)", "")
23 | |> Contex.Plot.axis_labels("", "")
24 | |> Contex.Plot.plot_options(%{})
--------------------------------------------------------------------------------
/lib/chart/gallery/ohlc_candle.sample:
--------------------------------------------------------------------------------
1 | data = [
2 | [~N[2023-12-28 00:00:00], "AAPL", 34049900, 194.14, 194.66, 193.17, 193.58],
3 | [~N[2023-12-27 00:00:00], "AAPL", 48087680, 192.49, 193.50, 191.09, 193.15],
4 | [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.61, 193.89, 192.83, 193.05],
5 | [~N[2023-12-25 00:00:00], "AAPL", 37149570, 195.18, 195.41, 192.97, 193.60],
6 | [~N[2023-12-24 00:00:00], "AAPL", 46482550, 196.10, 197.08, 193.50, 194.68],
7 | [~N[2023-12-23 00:00:00], "AAPL", 52242820, 196.90, 197.68, 194.83, 194.83],
8 | [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.16, 196.95, 195.89, 196.94],
9 | [~N[2023-12-21 00:00:00], "AAPL", 55751860, 196.09, 196.63, 194.39, 195.89]
10 | ]
11 |
12 | test_data = Dataset.new(data, ["Date", "Ticker", "Volume", "Open", "High", "Low", "Close"])
13 |
14 | options = [
15 | mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"},
16 | style: :candle,
17 | title: "AAPL"
18 | ]
19 |
20 | Contex.Plot.new(test_data, Contex.OHLC, 500, 400, options)
21 | |> Contex.Plot.titles("Apple Stock Price", "")
22 | |> Contex.Plot.axis_labels("", "")
23 | |> Contex.Plot.plot_options(%{})
24 |
--------------------------------------------------------------------------------
/lib/chart/gallery/ohlc_charts.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Gallery.OHLCCharts do
2 | import Contex.Gallery.Sample, only: [graph: 1]
3 |
4 | @moduledoc """
5 | A gallery of OHLC Charts.
6 |
7 | > #### Have one to share? {: .warning}
8 | >
9 | > Do you have an interesting plot you want to
10 | > share? Something you learned the hard way that
11 | > should be here, or that's just great to see?
12 | > Just open a ticket on GitHub and we'll post it here.
13 |
14 |
15 | """
16 |
17 | @doc """
18 | Some OHLC charts.
19 |
20 |
21 | #{graph(title: "A simple candle OHLC chart",
22 | file: "ohlc_candle.sample")}
23 |
24 | #{graph(title: "A simple tick OHLC chart",
25 | file: "ohlc_tick.sample")}
26 |
27 |
28 | """
29 | def plain(), do: 0
30 | end
31 |
--------------------------------------------------------------------------------
/lib/chart/gallery/ohlc_tick.sample:
--------------------------------------------------------------------------------
1 | data = [
2 | [~N[2023-12-28 00:00:00], "AAPL", 34049900, 193.58, 194.14, 194.66, 193.17],
3 | [~N[2023-12-27 00:00:00], "AAPL", 48087680, 193.15, 192.49, 193.50, 191.09],
4 | [~N[2023-12-26 00:00:00], "AAPL", 28919310, 193.05, 193.61, 193.89, 192.83],
5 | [~N[2023-12-25 00:00:00], "AAPL", 37149570, 193.60, 195.18, 195.41, 192.97],
6 | [~N[2023-12-24 00:00:00], "AAPL", 46482550, 194.68, 196.10, 197.08, 193.50],
7 | [~N[2023-12-23 00:00:00], "AAPL", 52242820, 194.83, 196.90, 197.68, 194.83],
8 | [~N[2023-12-22 00:00:00], "AAPL", 40714050, 196.94, 196.16, 196.95, 195.89],
9 | [~N[2023-12-21 00:00:00], "AAPL", 55751860, 195.89, 196.09, 196.63, 194.39]
10 | ]
11 |
12 | test_data = Dataset.new(data, ["Date", "Ticker", "Volume", "Close", "Open", "High", "Low"])
13 |
14 | options = [
15 | mapping: %{datetime: "Date", open: "Open", high: "High", low: "Low", close: "Close"},
16 | style: :tick,
17 | title: "AAPL"
18 | ]
19 |
20 | Contex.Plot.new(test_data, Contex.OHLC, 500, 400, options)
21 | |> Contex.Plot.titles("Apple Stock Price", "")
22 | |> Contex.Plot.axis_labels("", "")
23 | |> Contex.Plot.plot_options(%{})
24 |
--------------------------------------------------------------------------------
/lib/chart/gallery/pie_charts.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Gallery.PieCharts do
2 | import Contex.Gallery.Sample, only: [graph: 1]
3 |
4 | @moduledoc """
5 | A gallery of Pie Charts.
6 |
7 | > #### Have one to share? {: .warning}
8 | >
9 | > Do you have an interesting plot you want to
10 | > share? Something you learned the hard way that
11 | > should be here, or that's just great to see?
12 | > Just open a ticket on GitHub and we'll post it here.
13 |
14 |
15 | """
16 |
17 | @doc """
18 | Some plain pie charts.
19 |
20 |
21 | #{graph(title: "A simple pie chart",
22 | file: "pie_charts_plain.sample",
23 | info: """
24 | Originally taken from https://github.com/mindok/contex/issues/74
25 | """)}
26 |
27 | """
28 | def plain(), do: 0
29 | end
30 |
--------------------------------------------------------------------------------
/lib/chart/gallery/pie_charts_plain.sample:
--------------------------------------------------------------------------------
1 | data = [
2 | ["Blog (400)", 400],
3 | ["Instagram (399)", 399],
4 | ["Twitter (348)", 348],
5 | ["YouTube (200)", 200],
6 | ["Tiktok (72)", 72]
7 | ]
8 |
9 | dataset = Contex.Dataset.new(data, ["Channel", "Count"])
10 |
11 | opts = [
12 | mapping: %{category_col: "Channel", value_col: "Count"},
13 | colour_palette: ["16a34a", "c13584", "499be4", "FF0000", "00f2ea"],
14 | legend_setting: :legend_right,
15 | data_labels: true,
16 | title: "Social Media Accounts"
17 | ]
18 |
19 | Contex.Plot.new(dataset, Contex.PieChart, 600, 400, opts)
20 |
--------------------------------------------------------------------------------
/lib/chart/gallery/point_plots.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Gallery.PointPlots do
2 | import Contex.Gallery.Sample, only: [graph: 1]
3 |
4 | @moduledoc """
5 | A gallery of Line Charts.
6 |
7 | > #### Have one to share? {: .warning}
8 | >
9 | > Do you have an interesting plot you want to
10 | > share? Something you learned the hard way that
11 | > should be here, or that's just great to see?
12 | > Just open a ticket on GitHub and we'll post it here.
13 |
14 |
15 | """
16 |
17 | @doc """
18 | PointPlots using a log scale.
19 |
20 | #{graph(title: "Masked mode",
21 | file: "point_plots_log_masked.sample",
22 | info: """
23 | As negative numbers cannot be plotted with logarithms,
24 | as a default we just replace them with zeros (maked mode).
25 | """)}
26 |
27 |
28 | #{graph(title: "Symmetric mode",
29 | file: "point_plots_log_symmetric.sample",
30 | info: """
31 | As negative numbers cannot be plotted with logarithms,
32 | we can "make do" and use the symmetric mode.
33 | """)}
34 |
35 | #{graph(title: "Linear mode",
36 | file: "point_plots_log_masked_linear.sample",
37 | info: """
38 | As numbers below zero are negative as logarithms,
39 | and may get really big fast, you may want to
40 | "linearize" them.
41 |
42 | This works in masked mode (as shown) but also in
43 | symmetric mode.
44 | """)}
45 |
46 | #{graph(title: "Automatic range",
47 | file: "point_plots_log_masked_autorange.sample",
48 | info: """
49 | You can have the logscale "infer" the domain from
50 | data, so you don't have to think twice about it.
51 | """)}
52 |
53 | """
54 | def with_log_scale(), do: 0
55 | end
56 |
--------------------------------------------------------------------------------
/lib/chart/gallery/point_plots_log_masked.sample:
--------------------------------------------------------------------------------
1 | data =
2 | -200..200
3 | |> Enum.map(fn v -> {v / 10.0, v / 10.0} end)
4 |
5 | ds = Dataset.new(data, ["x", "y"])
6 |
7 | options = [
8 | mapping: %{x_col: "x", y_cols: ["y"]},
9 | data_labels: true,
10 | orientation: :vertical,
11 | custom_y_scale:
12 | ContinuousLogScale.new(domain: {-20, 20}, negative_numbers: :mask),
13 | colour_palette: ["ff9838", "fdae53", "fbc26f", "fad48e", "fbe5af", "fff5d1"]
14 | ]
15 |
16 | Plot.new(ds, PointPlot, 500, 400, options)
17 | |> Plot.titles("Masked log scale", "")
18 | |> Plot.axis_labels("x", "y (log)")
19 |
--------------------------------------------------------------------------------
/lib/chart/gallery/point_plots_log_masked_autorange.sample:
--------------------------------------------------------------------------------
1 | data =
2 | -200..200
3 | |> Enum.map(fn v -> {v / 10.0, v / 10.0} end)
4 |
5 | ds = Dataset.new(data, ["x", "y"])
6 |
7 | options = [
8 | mapping: %{x_col: "x", y_cols: ["y"]},
9 | data_labels: true,
10 | orientation: :vertical,
11 | custom_y_scale:
12 | ContinuousLogScale.new(dataset: ds, axis: "y", negative_numbers: :mask),
13 | colour_palette: ["ff9838", "fdae53", "fbc26f", "fad48e", "fbe5af", "fff5d1"]
14 | ]
15 |
16 | Plot.new(ds, PointPlot, 500, 400, options)
17 | |> Plot.titles("Masked log scale", "")
18 | |> Plot.axis_labels("x", "y (log)")
19 |
--------------------------------------------------------------------------------
/lib/chart/gallery/point_plots_log_masked_linear.sample:
--------------------------------------------------------------------------------
1 | data =
2 | -200..200
3 | |> Enum.map(fn v -> {v / 10.0, v / 10.0} end)
4 |
5 | ds = Dataset.new(data, ["x", "y"])
6 |
7 | options = [
8 | mapping: %{x_col: "x", y_cols: ["y"]},
9 | data_labels: true,
10 | orientation: :vertical,
11 | custom_y_scale:
12 | ContinuousLogScale.new(domain: {-20, 20}, negative_numbers: :mask, linear_range: 1),
13 | colour_palette: ["ff9838", "fdae53", "fbc26f", "fad48e", "fbe5af", "fff5d1"]
14 | ]
15 |
16 | Plot.new(ds, PointPlot, 500, 400, options)
17 | |> Plot.titles("A masked log scale", "With linear range")
18 | |> Plot.axis_labels("x", "y")
19 |
--------------------------------------------------------------------------------
/lib/chart/gallery/point_plots_log_symmetric.sample:
--------------------------------------------------------------------------------
1 | data =
2 | -200..200
3 | |> Enum.map(fn v -> {v / 10.0, v / 10.0} end)
4 |
5 | ds = Dataset.new(data, ["x", "y"])
6 |
7 | options = [
8 | mapping: %{x_col: "x", y_cols: ["y"]},
9 | data_labels: true,
10 | orientation: :vertical,
11 | custom_y_scale:
12 | ContinuousLogScale.new(domain: {-20, 20}, negative_numbers: :sym),
13 | colour_palette: ["ff9838", "fdae53", "fbc26f", "fad48e", "fbe5af", "fff5d1"]
14 | ]
15 |
16 | Plot.new(ds, PointPlot, 500, 400, options)
17 | |> Plot.titles("A symmetric log scale", "")
18 | |> Plot.axis_labels("x", "y (log)")
19 |
--------------------------------------------------------------------------------
/lib/chart/gallery/sample.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Gallery.Sample do
2 | alias Contex.Plot
3 |
4 | @moduledoc """
5 | Renders plots to be used in ExDocs and tests.
6 |
7 | The idea is that each graph is displayed along its
8 | source code, so it's very easy to spot what is what.
9 | To shortern the source code displayed, though, aliases
10 | are imported but not shown.
11 | """
12 |
13 | @doc """
14 | Adds a graph to a documentation block.
15 |
16 | Usage:
17 |
18 | import Contex.Gallery.Sample, only: [graph: 1]
19 |
20 | graph(title: "A stacked sample",
21 | file: "bar_charts_log_stacked.sample",
22 | info: "Some Markdown description")
23 |
24 | If there are rendering errors, a message is printed
25 | on stdout and the full text of the error is shown
26 | on the docs. We don't want this to break, as docs
27 | are generated at compile time, so if they break, your
28 | code does not compile!
29 |
30 | """
31 |
32 | def graph(options \\ []) do
33 | path = Keyword.get(options, :path, "lib/chart/gallery")
34 | file = Keyword.get(options, :file, nil)
35 | aliases = Keyword.get(options, :aliases, "00_aliases.sample")
36 | title = Keyword.get(options, :title, "")
37 | bgcolor = Keyword.get(options, :bgcolor, "#fff")
38 | extra_info_text = Keyword.get(options, :info, "")
39 |
40 | case safely_evaluate_svg(["#{path}/#{aliases}", "#{path}/#{file}"]) do
41 | {:ok, source_code, svg, time} ->
42 | """
43 | # #{title}
44 |
45 | #{extra_info_text}
46 |
47 | #{encode_svg(svg, bgcolor)}
48 | __Rendering took #{time} ms - Size: #{String.length(svg) / 1000} Kb__
49 |
50 |
51 | ```
52 | #{source_code}
53 | ```
54 |
55 |
56 | """
57 |
58 | {:error, error_text, filename, code_run, time} ->
59 | with IO.puts("Error processing #{filename} - see generated docs") do
60 | """
61 | # Error: #{title}
62 |
63 | ```
64 | #{code_run}
65 | ```
66 |
67 | Raised error:
68 |
69 | ```
70 | #{error_text}
71 | ```
72 |
73 |
74 | __Rendering took #{time} ms__
75 | """
76 | end
77 | end
78 | end
79 |
80 | defp uid(), do: make_ref() |> inspect() |> String.slice(11, 99) |> String.replace(">", "")
81 |
82 | @doc """
83 | Will try and evaluate a set of files, by sticking them in order
84 | one after the other.
85 |
86 | If all goes well, it will return the SVG generated and
87 | how long execution took.
88 |
89 | If there are any errors, it will return the error, the complete
90 | source code that was evaluated (with includes) and how long
91 | execution took in ms.
92 | """
93 | def safely_evaluate_svg(files) do
94 | code_to_evaluate =
95 | files
96 | |> Enum.map(fn f ->
97 | {:ok, code} = File.read(f)
98 |
99 | """
100 | ## Source: #{f}
101 |
102 | #{code}
103 |
104 | """
105 | end)
106 | |> Enum.join()
107 |
108 | filename =
109 | files
110 | |> List.last()
111 |
112 | {:ok, source} =
113 | filename
114 | |> File.read()
115 |
116 | timer = mkTimer()
117 |
118 | try do
119 | {plot, _} = Code.eval_string(code_to_evaluate)
120 | {:safe, svg_list} = Plot.to_svg(plot)
121 | {:ok, source, List.to_string(svg_list), timer.()}
122 | rescue
123 | e ->
124 | {:error, Exception.format(:error, e, __STACKTRACE__), filename, code_to_evaluate,
125 | timer.()}
126 | end
127 | end
128 |
129 | @doc """
130 | Encodes a div for our SVG.
131 |
132 | Unfortunately, we need to split it into multiple
133 | lines, as ExDoc is veeeeery slow with long
134 | text lines.
135 |
136 | It also complains of SVG code being improperly formatted,
137 | and breaks the page.
138 |
139 | So we encode the SVG as one long JS line, and then
140 | stick it into the container DIV we just created.
141 |
142 | """
143 | def encode_svg(svg, bgcolor) do
144 | encoded_svg = URI.encode(svg)
145 |
146 | chunked_svg =
147 | encoded_svg
148 | |> String.codepoints()
149 | |> Enum.chunk_every(50)
150 | |> Enum.map(&Enum.join/1)
151 | |> Enum.map(fn c -> "\"#{c}\"" end)
152 | |> Enum.join(" + \n")
153 |
154 | block_id = uid()
155 |
156 | """
157 |
158 |
159 |
160 |
161 |
162 |
166 | """
167 | end
168 |
169 | @doc """
170 | Returns a timer function.
171 |
172 | By calling it, we get the number of elapsed milliseconds
173 | since the function was created.
174 |
175 | """
176 |
177 | def mkTimer() do
178 | t0 = :erlang.monotonic_time(:millisecond)
179 | fn -> :erlang.monotonic_time(:millisecond) - t0 end
180 | end
181 | end
182 |
--------------------------------------------------------------------------------
/lib/chart/legend.ex:
--------------------------------------------------------------------------------
1 | defprotocol Contex.Legend do
2 | @moduledoc """
3 | Protocol for generating a legend.
4 |
5 | Implemented by specific scale modules
6 | """
7 | def to_svg(scale)
8 | def height(scale)
9 | end
10 |
11 | defimpl Contex.Legend, for: Contex.CategoryColourScale do
12 | import Contex.SVG
13 |
14 | alias Contex.CategoryColourScale
15 |
16 | @item_spacing 21
17 | @item_height 18
18 | def to_svg(scale) do
19 | values = scale.values
20 |
21 | legend_items =
22 | Enum.with_index(values)
23 | |> Enum.map(fn {val, index} ->
24 | fill = CategoryColourScale.colour_for_value(scale, val)
25 | y = index * @item_spacing
26 |
27 | [
28 | rect({0, 18}, {y, y + @item_height}, "", fill: fill),
29 | text(23, y + @item_height / 2, val, text_anchor: "start", dominant_baseline: "central")
30 | ]
31 | end)
32 |
33 | [~s||, legend_items, ""]
34 | end
35 |
36 | def height(scale) do
37 | value_count = length(scale.values)
38 |
39 | value_count * @item_spacing + @item_height
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/chart/mapping.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Mapping do
2 | @moduledoc """
3 | Mappings generalize the process of associating columns in the dataset to the
4 | elements of a plot. As part of creating a mapping, these associations are
5 | validated to confirm that a column has been assigned to each of the graphical
6 | elements that are necessary to draw the plot, and that all of the assigned columns
7 | exist in the dataset.
8 |
9 | The Mapping struct stores accessor functions for the assigned columns, which
10 | are used to retrieve values for those columns from the dataset to support
11 | drawing the plot. The accessor functions have the same name as the associated
12 | plot element; this allows plot-drawing functions to access data based that plot's
13 | required elements without knowing anything about the dataset.
14 | """
15 |
16 | alias Contex.{Dataset}
17 |
18 | defstruct [:column_map, :accessors, :expected_mappings, :dataset]
19 |
20 | @type t() :: %__MODULE__{}
21 |
22 | @doc """
23 | Given expected mappings for a plot and a map associating plot elements with dataset
24 | columns, creates a Mapping struct for the plot that stores accessor functions for
25 | each element and returns a mapping. Raises if the map does not include all
26 | required elements of the specified plot type or if the dataset columns are not
27 | present in the dataset.
28 |
29 | Expected mappings are passed as a keyword list where each plot element is one
30 | of the following:
31 |
32 | * `:exactly_one` - indicates that the plot needs exactly one of these elements, for
33 | example a column representing categories in a barchart.
34 | * `:one_more_more` - indicates that the plot needs at least one of these elements,
35 | for example y columns in a point plot
36 | * `:zero_or_one` - indicates that the plot will use one of these elements if it
37 | is available, for example a fill colour column in a point plot
38 | * `:zero_or_more` - indicates that plot will use one or more of these elements if it
39 | is available
40 |
41 | For example, the expected mappings for a barchart are represented as follows:
42 | `[category_col: :exactly_one, value_cols: :one_or_more]`
43 |
44 | and for a point point:
45 | `[ x_col: :exactly_one, y_cols: :one_or_more, fill_col: :zero_or_one]`
46 |
47 | Provided mappings are passed as a map with the map key matching the expected mapping
48 | and the map value representing the columns in the underlying dataset. So for a barchart
49 | the column mappings may be:
50 | `%{category_col: "Quarter", value_cols: ["Australian Sales", "Kiwi Sales", "South African Sales"]}`
51 |
52 | If columns are not specified for optional plot elements, an accessor function
53 | that returns `nil` is created for those elements.
54 | """
55 | @spec new(keyword(), map(), Contex.Dataset.t()) :: Contex.Mapping.t()
56 | def new(expected_mappings, provided_mappings, %Dataset{} = dataset) do
57 | column_map = check_mappings(provided_mappings, expected_mappings, dataset)
58 | mapped_accessors = accessors(dataset, column_map)
59 |
60 | %__MODULE__{
61 | column_map: column_map,
62 | expected_mappings: expected_mappings,
63 | dataset: dataset,
64 | accessors: mapped_accessors
65 | }
66 | end
67 |
68 | @doc """
69 | Given a plot that already has a mapping and a new map of elements to columns,
70 | updates the mapping accordingly and returns the plot.
71 | """
72 | @spec update(Contex.Mapping.t(), map()) :: Contex.Mapping.t()
73 | def update(
74 | %__MODULE__{expected_mappings: expected_mappings, dataset: dataset} = mapping,
75 | updated_mappings
76 | ) do
77 | column_map =
78 | Map.merge(mapping.column_map, updated_mappings)
79 | |> check_mappings(expected_mappings, dataset)
80 |
81 | mapped_accessors = accessors(dataset, column_map)
82 |
83 | %{mapping | column_map: column_map, accessors: mapped_accessors}
84 | end
85 |
86 | defp check_mappings(nil, expected_mappings, %Dataset{} = dataset) do
87 | check_mappings(default_mapping(expected_mappings, dataset), expected_mappings, dataset)
88 | end
89 |
90 | defp check_mappings(mappings, expected_mappings, %Dataset{} = dataset) do
91 | add_nil_for_optional_mappings(mappings, expected_mappings)
92 | |> validate_mappings(expected_mappings, dataset)
93 | end
94 |
95 | defp default_mapping(_expected_mappings, %Dataset{data: [first | _rest]} = _dataset)
96 | when is_map(first) do
97 | raise(ArgumentError, "Can not create default data mappings with Map data.")
98 | end
99 |
100 | defp default_mapping(expected_mappings, %Dataset{} = dataset) do
101 | Enum.with_index(expected_mappings)
102 | |> Enum.reduce(%{}, fn {{expected_mapping, expected_count}, index}, mapping ->
103 | column_name = Dataset.column_name(dataset, index)
104 |
105 | column_names =
106 | case expected_count do
107 | :exactly_one -> column_name
108 | :one_or_more -> [column_name]
109 | :zero_or_one -> nil
110 | :zero_or_more -> [nil]
111 | end
112 |
113 | Map.put(mapping, expected_mapping, column_names)
114 | end)
115 | end
116 |
117 | defp add_nil_for_optional_mappings(mappings, expected_mappings) do
118 | Enum.reduce(expected_mappings, mappings, fn {expected_mapping, expected_count}, mapping ->
119 | case expected_count do
120 | :zero_or_one ->
121 | if mapping[expected_mapping] == nil,
122 | do: Map.put(mapping, expected_mapping, nil),
123 | else: mapping
124 |
125 | :zero_or_more ->
126 | if mapping[expected_mapping] == nil,
127 | do: Map.put(mapping, expected_mapping, [nil]),
128 | else: mapping
129 |
130 | _ ->
131 | mapping
132 | end
133 | end)
134 | end
135 |
136 | defp validate_mappings(provided_mappings, expected_mappings, %Dataset{} = dataset) do
137 | # TODO: Could get more precise by looking at how many mapped dataset columns are expected
138 | check_required_columns!(expected_mappings, provided_mappings)
139 | confirm_columns_in_dataset!(dataset, provided_mappings)
140 |
141 | provided_mappings
142 | end
143 |
144 | defp check_required_columns!(expected_mappings, column_map) do
145 | required_mappings = Enum.map(expected_mappings, fn {k, _v} -> k end)
146 |
147 | provided_mappings = Map.keys(column_map)
148 | missing_mappings = missing_columns(required_mappings, provided_mappings)
149 |
150 | case missing_mappings do
151 | [] ->
152 | :ok
153 |
154 | mappings ->
155 | mapping_string = Enum.map_join(mappings, ", ", &"\"#{&1}\"")
156 | raise "Required mapping(s) #{mapping_string} not included in column map."
157 | end
158 | end
159 |
160 | defp confirm_columns_in_dataset!(dataset, column_map) do
161 | available_columns = [nil | Dataset.column_names(dataset)]
162 |
163 | missing_columns =
164 | Map.values(column_map)
165 | |> List.flatten()
166 | |> missing_columns(available_columns)
167 |
168 | case missing_columns do
169 | [] ->
170 | :ok
171 |
172 | columns ->
173 | column_string = Enum.map_join(columns, ", ", &"\"#{&1}\"")
174 | raise "Column(s) #{column_string} in the column mapping not in the dataset."
175 | end
176 | end
177 |
178 | defp missing_columns(required_columns, provided_columns) do
179 | MapSet.new(required_columns)
180 | |> MapSet.difference(MapSet.new(provided_columns))
181 | |> MapSet.to_list()
182 | end
183 |
184 | defp accessors(dataset, column_map) do
185 | Enum.map(column_map, fn {mapping, columns} ->
186 | {mapping, accessor(dataset, columns)}
187 | end)
188 | |> Enum.into(%{})
189 | end
190 |
191 | defp accessor(dataset, columns) when is_list(columns) do
192 | Enum.map(columns, &accessor(dataset, &1))
193 | end
194 |
195 | defp accessor(_dataset, nil) do
196 | fn _row -> nil end
197 | end
198 |
199 | defp accessor(dataset, column) do
200 | Dataset.value_fn(dataset, column)
201 | end
202 | end
203 |
--------------------------------------------------------------------------------
/lib/chart/pie_chart.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.PieChart do
2 | @moduledoc """
3 | A Pie Chart that displays data in a circular graph.
4 |
5 | The pieces of the graph are proportional to the fraction of the whole in each category.
6 | Each slice of the pie is relative to the size of that category in the group as a whole.
7 | The entire “pie” represents 100 percent of a whole, while the pie “slices” represent portions of the whole.
8 |
9 | Fill colours for each slice can be specified with `colour_palette` parameter in chart options, or can be
10 | applied from a `CategoryColourScale` suppled in the `colour_scale` parameter. If neither option is supplied
11 | a default colour palette is used.
12 | """
13 |
14 | alias __MODULE__
15 | alias Contex.{Dataset, Mapping, CategoryColourScale}
16 |
17 | defstruct [
18 | :dataset,
19 | :mapping,
20 | :options,
21 | :colour_scale
22 | ]
23 |
24 | @type t() :: %__MODULE__{}
25 |
26 | @required_mappings [
27 | category_col: :zero_or_one,
28 | value_col: :zero_or_one
29 | ]
30 |
31 | @default_options [
32 | width: 600,
33 | height: 400,
34 | colour_palette: :default,
35 | colour_scale: nil,
36 | data_labels: true
37 | ]
38 |
39 | @doc """
40 | Create a new PieChart struct from Dataset.
41 |
42 | Options may be passed to control the settings for the chart. Options available are:
43 |
44 | - `:data_labels` : `true` (default) or false - display labels for each slice value
45 | - `:colour_palette` : `:default` (default) or colour palette - see `colours/2`
46 |
47 | An example:
48 | data = [
49 | ["Cat", 10.0],
50 | ["Dog", 20.0],
51 | ["Hamster", 5.0]
52 | ]
53 |
54 | dataset = Dataset.new(data, ["Pet", "Preference"])
55 |
56 | opts = [
57 | mapping: %{category_col: "Pet", value_col: "Preference"},
58 | colour_palette: ["fbb4ae", "b3cde3", "ccebc5"],
59 | legend_setting: :legend_right,
60 | data_labels: false,
61 | title: "Why dogs are better than cats"
62 | ]
63 |
64 | Contex.Plot.new(dataset, Contex.PieChart, 600, 400, opts)
65 | """
66 | def new(%Dataset{} = dataset, options \\ []) when is_list(options) do
67 | options = check_options(options)
68 | options = Keyword.merge(@default_options, options)
69 | mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset)
70 |
71 | %PieChart{
72 | dataset: dataset,
73 | mapping: mapping,
74 | options: options,
75 | colour_scale: Keyword.get(options, :colour_scale)
76 | }
77 | end
78 |
79 | defp check_options(options) do
80 | colour_scale = check_colour_scale(Keyword.get(options, :colour_scale))
81 | Keyword.put(options, :colour_scale, colour_scale)
82 | end
83 |
84 | defp check_colour_scale(%CategoryColourScale{} = scale), do: scale
85 | defp check_colour_scale(_), do: nil
86 |
87 | @doc false
88 | def set_size(%PieChart{} = chart, width, height) do
89 | chart
90 | |> set_option(:width, width)
91 | |> set_option(:height, height)
92 | end
93 |
94 | @doc false
95 | def get_legend_scales(%PieChart{} = chart) do
96 | [get_colour_palette(chart)]
97 | end
98 |
99 | @doc """
100 | Overrides the default colours.
101 |
102 | Colours can either be a named palette defined in `Contex.CategoryColourScale` or a list of strings representing hex code
103 | of the colour as per CSS colour hex codes, but without the #. For example:
104 |
105 | ```
106 | barchart = BarChart.colours(barchart, ["fbb4ae", "b3cde3", "ccebc5"])
107 | ```
108 |
109 | The colours will be applied to the data series in the same order as the columns are specified in `set_val_col_names/2`
110 | """
111 | @deprecated "Set in new/2 options"
112 | @spec colours(PieChart.t(), Contex.CategoryColourScale.colour_palette()) ::
113 | PieChart.t()
114 | def colours(%PieChart{} = chart, colour_palette) when is_list(colour_palette) do
115 | set_option(chart, :colour_palette, colour_palette)
116 | end
117 |
118 | def colours(%PieChart{} = chart, colour_palette) when is_atom(colour_palette) do
119 | set_option(chart, :colour_palette, colour_palette)
120 | end
121 |
122 | def colours(%PieChart{} = chart, _) do
123 | set_option(chart, :colour_palette, :default)
124 | end
125 |
126 | @doc """
127 | Renders the PieChart to svg, including the svg wrapper, as a string or improper string list that
128 | is marked safe.
129 | """
130 | def to_svg(%PieChart{} = chart) do
131 | [
132 | "",
133 | generate_slices(chart),
134 | ""
135 | ]
136 | end
137 |
138 | def get_categories(%PieChart{dataset: dataset, mapping: mapping}) do
139 | cat_accessor = dataset |> Dataset.value_fn(mapping.column_map[:category_col])
140 |
141 | dataset.data
142 | |> Enum.map(&cat_accessor.(&1))
143 | end
144 |
145 | defp set_option(%PieChart{options: options} = plot, key, value) do
146 | options = Keyword.put(options, key, value)
147 |
148 | %{plot | options: options}
149 | end
150 |
151 | defp get_option(%PieChart{options: options}, key) do
152 | Keyword.get(options, key)
153 | end
154 |
155 | defp get_colour_palette(%PieChart{colour_scale: colour_scale}) when not is_nil(colour_scale) do
156 | colour_scale
157 | end
158 |
159 | defp get_colour_palette(%PieChart{} = chart) do
160 | get_categories(chart)
161 | |> CategoryColourScale.new()
162 | |> CategoryColourScale.set_palette(get_option(chart, :colour_palette))
163 | end
164 |
165 | defp generate_slices(%PieChart{} = chart) do
166 | height = get_option(chart, :height)
167 | with_labels? = get_option(chart, :data_labels)
168 | colour_palette = get_colour_palette(chart)
169 |
170 | r = height / 2
171 | stroke_circumference = 2 * :math.pi() * r / 2
172 |
173 | scale_values(chart)
174 | |> Enum.map_reduce({0, 0}, fn {value, category}, {idx, offset} ->
175 | text_rotation = rotate_for(value, offset)
176 |
177 | label =
178 | if with_labels? do
179 | ~s"""
180 |
189 | #{Float.round(value, 2)}%
190 |
191 | """
192 | else
193 | ""
194 | end
195 |
196 | {
197 | ~s"""
198 |
203 |
204 | #{label}
205 | """,
206 | {idx + 1, offset + value}
207 | }
208 | end)
209 | |> elem(0)
210 | |> Enum.join()
211 | end
212 |
213 | defp slice_value(value, stroke_circumference) do
214 | value * stroke_circumference / 100
215 | end
216 |
217 | defp rotate_for(n, offset) do
218 | n / 2 * 3.6 + offset * 3.6
219 | end
220 |
221 | defp need_flip?(rotation) do
222 | 90 < rotation and rotation < 270
223 | end
224 |
225 | defp negate_if_flipped(number, rotation) do
226 | if need_flip?(rotation),
227 | do: -number,
228 | else: number
229 | end
230 |
231 | @spec scale_values(PieChart.t()) :: [{value :: number(), label :: any()}]
232 | defp scale_values(%PieChart{dataset: dataset, mapping: mapping}) do
233 | val_accessor = dataset |> Dataset.value_fn(mapping.column_map[:value_col])
234 | cat_accessor = dataset |> Dataset.value_fn(mapping.column_map[:category_col])
235 |
236 | sum = dataset.data |> Enum.reduce(0, fn col, acc -> val_accessor.(col) + acc end)
237 |
238 | dataset.data
239 | |> Enum.map_reduce(sum, &{{val_accessor.(&1) / &2 * 100, cat_accessor.(&1)}, &2})
240 | |> elem(0)
241 | end
242 | end
243 |
--------------------------------------------------------------------------------
/lib/chart/scale.ex:
--------------------------------------------------------------------------------
1 | defprotocol Contex.Scale do
2 | @moduledoc """
3 | Provides a common interface for scales generating plotting coordinates.
4 |
5 | This enables Log & Linear scales, for example, to be handled exactly
6 | the same way in plot generation code.
7 |
8 | Example:
9 | ```
10 | # It doesn't matter if x & y scales are log, linear or discretizing scale
11 | x_tx_fn = Scale.domain_to_range_fn(x_scale)
12 | y_tx_fn = Scale.domain_to_range_fn(y_scale)
13 |
14 | points_to_plot = Enum.map(big_load_of_data, fn %{x: x, y: y}=_row ->
15 | {x_tx_fn.(x), y_tx_fn.(y)}
16 | end)
17 | ```
18 | """
19 |
20 | @doc """
21 | Returns a list of tick values in the domain of the scale
22 |
23 | Typically these are used to label the tick
24 | """
25 | @spec ticks_domain(t()) :: list(any())
26 | def ticks_domain(scale)
27 |
28 | @doc """
29 | Returns a list of tick locations in the range of the scale
30 |
31 | Typically these are used to plot the location of the tick
32 | """
33 | @spec ticks_range(t()) :: list(number())
34 | def ticks_range(scale)
35 |
36 | @doc """
37 | Returns a transform function to convert values within the domain to the
38 | range.
39 |
40 | Typically this function is used to calculate plotting coordinates for input data.
41 | """
42 | @spec domain_to_range_fn(t()) :: fun()
43 | def domain_to_range_fn(scale)
44 |
45 | @doc """
46 | Transforms a value in the domain to a plotting coordinate within the range
47 | """
48 | @spec domain_to_range(t(), any()) :: number()
49 | def domain_to_range(scale, domain_val)
50 |
51 | @doc """
52 | Returns the plotting range set for the scale
53 |
54 | Note that there is not an equivalent for the domain, as the domain is specific to
55 | the type of scale.
56 | """
57 | @spec get_range(t()) :: {number(), number()}
58 | def get_range(scale)
59 |
60 | @doc """
61 | Applies a plotting range set for the scale
62 | """
63 | @spec set_range(t(), number(), number()) :: t()
64 | def set_range(scale, start, finish)
65 |
66 | @doc """
67 | Formats a domain value according to formatting rules calculated for the scale.
68 |
69 | For example, timescales will have formatting rules calculated based on the
70 | overall time period being plotted. Numeric scales may calculate number of
71 | decimal places to show based on the range of data being plotted.
72 | """
73 | @spec get_formatted_tick(t(), number()) :: String.t()
74 | def get_formatted_tick(scale, tick_val)
75 | end
76 |
--------------------------------------------------------------------------------
/lib/chart/scale/category_colour_scale.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.CategoryColourScale do
2 | @moduledoc """
3 | Maps categories to colours.
4 |
5 | The `Contex.CategoryColourScale` maps categories to a colour palette. It is used, for example, to calculate
6 | the fill colours for `Contex.BarChart`, or to calculate the colours for series in `Contex.PointPlot`.
7 |
8 | Internally it is a very simple map with some convenience methods to handle duplicated data inputs,
9 | cycle through colours etc.
10 |
11 | The mapping is done on a first identified, first matched basis from the provided dataset. So, for example,
12 | if you have a colour palette of `["ff0000", "00ff00", "0000ff"]` (aka red, green, blue), the mapping
13 | for a dataset would be as follows:
14 |
15 | X | Y | Category | Mapped Colour
16 | -- | - | -------- | -------------
17 | 0 | 0 | Turtle | red
18 | 1 | 1 | Turtle | red
19 | 0 | 1 | Camel | green
20 | 2 | 1 | Brontosaurus | blue
21 | 3 | 4 | Turtle | red
22 | 5 | 5 | Brontosaurus | blue
23 | 6 | 7 | Hippopotamus | red ← *NOTE* - if you run out of colours, they will cycle
24 |
25 | Tn use, the `CategoryColourScale` is created with a list of values to map to colours and optionally a colour
26 | palette. If using with a `Contex.Dataset`, it would be initialised like this:
27 |
28 | ```
29 | dataset = Dataset.new(data, ["X", "Y", "Category"])
30 | colour_scale
31 | = dataset
32 | |> Dataset.unique_values("Category")
33 | |> CategoryColourScale(["ff0000", "00ff00", "0000ff"])
34 | ```
35 | Then it can be used to look up colours for values as needed:
36 |
37 | ```
38 | fill_colour = CategoryColourScale.colour_for_value(colour_scale, "Brontosaurus") // returns "0000ff"
39 | ```
40 |
41 | There are a number of built-in colour palettes - see `colour_palette()`, but you can supply your own by
42 | providing a list of strings representing hex code of the colour as per CSS colour hex codes, but without the #. For example:
43 |
44 | ```
45 | scale = CategoryColourScale.set_palette(scale, ["fbb4ae", "b3cde3", "ccebc5"])
46 | ```
47 | """
48 | alias __MODULE__
49 |
50 | defstruct [:values, :colour_palette, :colour_map, :default_colour]
51 |
52 | @type t() :: %__MODULE__{}
53 | @type colour_palette() :: nil | :default | :pastel1 | :warm | list()
54 |
55 | @default_colour "fa8866"
56 |
57 | @doc """
58 | Create a new CategoryColourScale from a list of values.
59 |
60 | Optionally attach a colour palette.
61 | Pretty well any value list can be used so long as it can be a key in a map.
62 | """
63 | @spec new(list(), colour_palette()) :: Contex.CategoryColourScale.t()
64 | def new(raw_values, palette \\ :default) when is_list(raw_values) do
65 | values = Enum.uniq(raw_values)
66 |
67 | %CategoryColourScale{values: values}
68 | |> set_palette(palette)
69 | end
70 |
71 | @doc """
72 | Update the colour palette used for the scale
73 | """
74 | @spec set_palette(Contex.CategoryColourScale.t(), colour_palette()) ::
75 | Contex.CategoryColourScale.t()
76 | def set_palette(%CategoryColourScale{} = colour_scale, nil),
77 | do: set_palette(colour_scale, :default)
78 |
79 | def set_palette(%CategoryColourScale{} = colour_scale, palette) when is_atom(palette) do
80 | set_palette(colour_scale, get_palette(palette))
81 | end
82 |
83 | def set_palette(%CategoryColourScale{} = colour_scale, palette) when is_list(palette) do
84 | %{colour_scale | colour_palette: palette}
85 | |> map_values_to_palette()
86 | end
87 |
88 | @doc """
89 | Sets the default colour for the scale when it isn't possible to look one up for a value
90 | """
91 | def set_default_colour(%CategoryColourScale{} = colour_scale, colour) do
92 | %{colour_scale | default_colour: colour}
93 | end
94 |
95 | @doc """
96 | Inverts the order of values. Note, the palette is generated from the existing
97 | colour map so reapplying a palette will result in reversed colours
98 | """
99 | def invert(%CategoryColourScale{values: values} = scale) do
100 | values = Enum.reverse(values)
101 | palette = Enum.map(values, fn val -> colour_for_value(scale, val) end)
102 |
103 | new(values, palette)
104 | end
105 |
106 | @doc """
107 | Look up a colour for a value from the palette.
108 | """
109 | @spec colour_for_value(Contex.CategoryColourScale.t() | nil, any()) :: String.t()
110 | def colour_for_value(nil, _value), do: @default_colour
111 |
112 | def colour_for_value(%CategoryColourScale{colour_map: colour_map} = colour_scale, value) do
113 | case Map.fetch(colour_map, value) do
114 | {:ok, result} -> result
115 | _ -> get_default_colour(colour_scale)
116 | end
117 | end
118 |
119 | @doc """
120 | Get the default colour. Surprise.
121 | """
122 | @spec get_default_colour(Contex.CategoryColourScale.t() | nil) :: String.t()
123 | def get_default_colour(%CategoryColourScale{default_colour: default} = _colour_scale)
124 | when is_binary(default),
125 | do: default
126 |
127 | def get_default_colour(_), do: @default_colour
128 |
129 | @doc """
130 | Create a function to lookup a value from the palette.
131 | """
132 | def domain_to_range_fn(%CategoryColourScale{} = scale) do
133 | # Note, we basically carry a copy of the scale definition - we could
134 | # probably get smarter than this by pre-mapping values to colours
135 | # TODO: We could probably implement the Contex.Scale protocol
136 |
137 | fn range_val ->
138 | CategoryColourScale.colour_for_value(scale, range_val)
139 | end
140 | end
141 |
142 | defp map_values_to_palette(
143 | %CategoryColourScale{values: values, colour_palette: palette} = colour_scale
144 | ) do
145 | {_, colour_map} =
146 | Enum.reduce(values, {0, Map.new()}, fn value, {index, current_result} ->
147 | colour = get_colour(palette, index)
148 | {index + 1, Map.put(current_result, value, colour)}
149 | end)
150 |
151 | %{colour_scale | colour_map: colour_map}
152 | end
153 |
154 | # "Inspired by" https://github.com/d3/d3-scale-chromatic/blob/master/src/categorical/category10.js
155 | @default_palette [
156 | "1f77b4",
157 | "ff7f0e",
158 | "2ca02c",
159 | "d62728",
160 | "9467bd",
161 | "8c564b",
162 | "e377c2",
163 | "7f7f7f",
164 | "bcbd22",
165 | "17becf"
166 | ]
167 | defp get_palette(:default), do: @default_palette
168 |
169 | # "Inspired by" https://github.com/d3/d3-scale-chromatic/blob/master/src/categorical/Pastel1.js
170 | @pastel1_palette [
171 | "fbb4ae",
172 | "b3cde3",
173 | "ccebc5",
174 | "decbe4",
175 | "fed9a6",
176 | "ffffcc",
177 | "e5d8bd",
178 | "fddaec",
179 | "f2f2f2"
180 | ]
181 | defp get_palette(:pastel1), do: @pastel1_palette
182 |
183 | # Warm colours - see https://learnui.design/tools/data-color-picker.html#single
184 | @warm_palette ["d40810", "e76241", "f69877", "ffcab4", "ffeac4", "fffae4"]
185 | defp get_palette(:warm), do: @warm_palette
186 |
187 | defp get_palette(_), do: nil
188 |
189 | # TODO: We currently cycle the palette when we run out of colours. Probably should fade them (or similar)
190 | defp get_colour(colour_palette, index) when is_list(colour_palette) do
191 | palette_length = length(colour_palette)
192 | adjusted_index = rem(index, palette_length)
193 | Enum.at(colour_palette, adjusted_index)
194 | end
195 | end
196 |
--------------------------------------------------------------------------------
/lib/chart/scale/continuous_linear_scale.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.ContinuousLinearScale do
2 | @moduledoc """
3 | A linear scale to map continuous numeric data to a plotting coordinate system.
4 |
5 | Implements the general aspects of scale setup and use defined in the `Contex.Scale` protocol
6 |
7 | The `ContinuousLinearScale` is responsible for mapping to and from values in the data
8 | to a visual scale. The two key concepts are "domain" and "range".
9 |
10 | The "domain" represents values in the dataset to be plotted.
11 |
12 | The "range" represents the plotting coordinate system use to plot values in the "domain".
13 |
14 | *Important Note* - When you set domain and range, the scale code makes a few adjustments
15 | based on the desired number of tick intervals so that the ticks look "nice" - i.e. on
16 | round numbers. So if you have a data range of 0.0 → 8.7 and you want 10 intervals the scale
17 | won't display ticks at 0.0, 0.87, 1.74 etc, it will round up the domain to 10 so you have nice
18 | tick intervals of 0, 1, 2, 3 etc.
19 |
20 | By default the scale creates 10 tick intervals.
21 |
22 | When domain and range are both set, the scale makes transform functions available to map each way
23 | between the domain and range that are then available to the various plots to map data
24 | to plotting coordinate systems, and potentially vice-versa.
25 |
26 | The typical setup of the scale looks like this:
27 |
28 | ```
29 | y_scale
30 | = ContinuousLinearScale.new()
31 | |> ContinuousLinearScale.domain(min_value, max_value)
32 | |> Scale.set_range(start_of_y_plotting_coord, end_of_y_plotting_coord)
33 | ```
34 |
35 | Translating a value to plotting coordinates would then look like this:
36 |
37 | ```
38 | plot_y = Scale.domain_to_range(y_scale, y_value)
39 | ```
40 |
41 | `ContinuousLinearScale` implements the `Contex.Scale` protocol that provides a nicer way to access the
42 | transform functions. Calculation of plotting coordinates is typically done in tight loops
43 | so you are more likely to do something like than translating a single value as per the above example:
44 |
45 | ```
46 | x_tx_fn = Scale.domain_to_range_fn(x_scale)
47 | y_tx_fn = Scale.domain_to_range_fn(y_scale)
48 |
49 | points_to_plot = Enum.map(big_load_of_data, fn %{x: x, y: y}=_row ->
50 | {x_tx_fn.(x), y_tx_fn.(y)}
51 | end)
52 | ```
53 |
54 | """
55 |
56 | alias __MODULE__
57 | alias Contex.Utils
58 |
59 | defstruct [
60 | :domain,
61 | :nice_domain,
62 | :range,
63 | :interval_count,
64 | :interval_size,
65 | :display_decimals,
66 | :custom_tick_formatter
67 | ]
68 |
69 | @type t() :: %__MODULE__{}
70 |
71 | @doc """
72 | Creates a new scale with defaults
73 | """
74 | @spec new :: Contex.ContinuousLinearScale.t()
75 | def new() do
76 | %ContinuousLinearScale{range: {0.0, 1.0}, interval_count: 10, display_decimals: nil}
77 | end
78 |
79 | @doc """
80 | Defines the number of intervals between ticks.
81 |
82 | Defaults to 10.
83 |
84 | Tick-rendering is the responsibility of `Contex.Axis`, but calculating tick intervals is the responsibility
85 | of the scale.
86 | """
87 | @spec interval_count(Contex.ContinuousLinearScale.t(), integer()) ::
88 | Contex.ContinuousLinearScale.t()
89 | def interval_count(%ContinuousLinearScale{} = scale, interval_count)
90 | when is_integer(interval_count) and interval_count > 1 do
91 | scale
92 | |> struct(interval_count: interval_count)
93 | |> nice()
94 | end
95 |
96 | def interval_count(%ContinuousLinearScale{} = scale, _), do: scale
97 |
98 | @doc """
99 | Sets the extents of the value domain for the scale.
100 | """
101 | @spec domain(Contex.ContinuousLinearScale.t(), number, number) ::
102 | Contex.ContinuousLinearScale.t()
103 | def domain(%ContinuousLinearScale{} = scale, min, max) when is_number(min) and is_number(max) do
104 | # We can be flexible with the range start > end, but the domain needs to start from the min
105 | {d_min, d_max} =
106 | case min < max do
107 | true -> {min, max}
108 | _ -> {max, min}
109 | end
110 |
111 | scale
112 | |> struct(domain: {d_min, d_max})
113 | |> nice()
114 | end
115 |
116 | @doc """
117 | Sets the extents of the value domain for the scale by specifying a list of values to be displayed.
118 |
119 | The scale will determine the extents of the data.
120 | """
121 | @spec domain(Contex.ContinuousLinearScale.t(), list(number())) ::
122 | Contex.ContinuousLinearScale.t()
123 | def domain(%ContinuousLinearScale{} = scale, data) when is_list(data) do
124 | {min, max} = extents(data)
125 | domain(scale, min, max)
126 | end
127 |
128 | # NOTE: interval count will likely get adjusted down here to keep things looking nice
129 | defp nice(
130 | %ContinuousLinearScale{domain: {min_d, max_d}, interval_count: interval_count} = scale
131 | )
132 | when is_number(min_d) and is_number(max_d) and is_number(interval_count) and
133 | interval_count > 1 do
134 | width = max_d - min_d
135 | width = if width == 0.0, do: 1.0, else: width
136 | unrounded_interval_size = width / interval_count
137 | order_of_magnitude = :math.ceil(:math.log10(unrounded_interval_size) - 1)
138 | power_of_ten = :math.pow(10, order_of_magnitude)
139 |
140 | rounded_interval_size =
141 | lookup_axis_interval(unrounded_interval_size / power_of_ten) * power_of_ten
142 |
143 | min_nice = rounded_interval_size * Float.floor(min_d / rounded_interval_size)
144 | max_nice = rounded_interval_size * Float.ceil(max_d / rounded_interval_size)
145 | adjusted_interval_count = round(1.0001 * (max_nice - min_nice) / rounded_interval_size)
146 |
147 | display_decimals = guess_display_decimals(order_of_magnitude)
148 |
149 | %{
150 | scale
151 | | nice_domain: {min_nice, max_nice},
152 | interval_size: rounded_interval_size,
153 | interval_count: adjusted_interval_count,
154 | display_decimals: display_decimals
155 | }
156 | end
157 |
158 | defp nice(%ContinuousLinearScale{} = scale), do: scale
159 |
160 | @axis_interval_breaks [0.05, 0.1, 0.2, 0.25, 0.4, 0.5, 1.0, 2.0, 2.5, 4.0, 5.0, 10.0, 20.0]
161 | defp lookup_axis_interval(raw_interval) when is_float(raw_interval) do
162 | Enum.find(@axis_interval_breaks, 10.0, fn x -> x >= raw_interval end)
163 | end
164 |
165 | defp guess_display_decimals(power_of_ten) when power_of_ten > 0 do
166 | 0
167 | end
168 |
169 | defp guess_display_decimals(power_of_ten) do
170 | 1 + -1 * round(power_of_ten)
171 | end
172 |
173 | @doc false
174 | def get_domain_to_range_function(%ContinuousLinearScale{
175 | nice_domain: {min_d, max_d},
176 | range: {min_r, max_r}
177 | })
178 | when is_number(min_d) and is_number(max_d) and is_number(min_r) and is_number(max_r) do
179 | domain_width = max_d - min_d
180 | range_width = max_r - min_r
181 |
182 | case domain_width do
183 | 0 ->
184 | fn x -> x end
185 |
186 | 0.0 ->
187 | fn x -> x end
188 |
189 | _ ->
190 | fn domain_val ->
191 | case domain_val do
192 | nil ->
193 | nil
194 |
195 | _ ->
196 | ratio = (domain_val - min_d) / domain_width
197 | min_r + ratio * range_width
198 | end
199 | end
200 | end
201 | end
202 |
203 | def get_domain_to_range_function(_), do: fn x -> x end
204 |
205 | @doc false
206 | def get_range_to_domain_function(%ContinuousLinearScale{
207 | nice_domain: {min_d, max_d},
208 | range: {min_r, max_r}
209 | })
210 | when is_number(min_d) and is_number(max_d) and is_number(min_r) and is_number(max_r) do
211 | domain_width = max_d - min_d
212 | range_width = max_r - min_r
213 |
214 | case range_width do
215 | 0 ->
216 | fn x -> x end
217 |
218 | 0.0 ->
219 | fn x -> x end
220 |
221 | _ ->
222 | fn range_val ->
223 | ratio = (range_val - min_r) / range_width
224 | min_d + ratio * domain_width
225 | end
226 | end
227 | end
228 |
229 | def get_range_to_domain_function(_), do: fn x -> x end
230 |
231 | @doc false
232 | def extents(data) do
233 | Enum.reduce(data, {nil, nil}, fn x, {min, max} ->
234 | {Utils.safe_min(x, min), Utils.safe_max(x, max)}
235 | end)
236 | end
237 |
238 | defimpl Contex.Scale do
239 | def domain_to_range_fn(%ContinuousLinearScale{} = scale),
240 | do: ContinuousLinearScale.get_domain_to_range_function(scale)
241 |
242 | def ticks_domain(%ContinuousLinearScale{
243 | nice_domain: {min_d, _},
244 | interval_count: interval_count,
245 | interval_size: interval_size
246 | })
247 | when is_number(min_d) and is_number(interval_count) and is_number(interval_size) do
248 | 0..interval_count
249 | |> Enum.map(fn i -> min_d + i * interval_size end)
250 | end
251 |
252 | def ticks_domain(_), do: []
253 |
254 | def ticks_range(%ContinuousLinearScale{} = scale) do
255 | transform_func = ContinuousLinearScale.get_domain_to_range_function(scale)
256 |
257 | ticks_domain(scale)
258 | |> Enum.map(transform_func)
259 | end
260 |
261 | def domain_to_range(%ContinuousLinearScale{} = scale, range_val) do
262 | transform_func = ContinuousLinearScale.get_domain_to_range_function(scale)
263 | transform_func.(range_val)
264 | end
265 |
266 | def get_range(%ContinuousLinearScale{range: {min_r, max_r}}), do: {min_r, max_r}
267 |
268 | def set_range(%ContinuousLinearScale{} = scale, start, finish)
269 | when is_number(start) and is_number(finish) do
270 | %{scale | range: {start, finish}}
271 | end
272 |
273 | def set_range(%ContinuousLinearScale{} = scale, {start, finish})
274 | when is_number(start) and is_number(finish),
275 | do: set_range(scale, start, finish)
276 |
277 | def get_formatted_tick(
278 | %ContinuousLinearScale{
279 | display_decimals: display_decimals,
280 | custom_tick_formatter: custom_tick_formatter
281 | },
282 | tick_val
283 | ) do
284 | format_tick_text(tick_val, display_decimals, custom_tick_formatter)
285 | end
286 |
287 | defp format_tick_text(tick, _, custom_tick_formatter) when is_function(custom_tick_formatter),
288 | do: custom_tick_formatter.(tick)
289 |
290 | defp format_tick_text(tick, _, _) when is_integer(tick), do: to_string(tick)
291 |
292 | defp format_tick_text(tick, display_decimals, _) when display_decimals > 0 do
293 | :erlang.float_to_binary(tick, decimals: display_decimals)
294 | end
295 |
296 | defp format_tick_text(tick, _, _), do: :erlang.float_to_binary(tick, [:compact, decimals: 0])
297 | end
298 | end
299 |
--------------------------------------------------------------------------------
/lib/chart/scale/continuous_log_scale.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.ContinuousLogScale do
2 | @moduledoc """
3 | A logarithmic scale to map continuous numeric data to a plotting coordinate system.
4 |
5 | This works like `Contex.ContinuousLinearScale`, and the
6 | settings are given as keywords.
7 |
8 | ContinuousLogScale.new(
9 | domain: {0, 100},
10 | tick_positions: [0, 5, 10, 15, 30, 60, 120, 240, 480, 960],
11 | log_base: :base_10,
12 | negative_numbers: :mask,
13 | linear_range: 1
14 | )
15 |
16 | **Logarithm**
17 |
18 | - `log_base` is the logarithm base. Defaults to 2. Can be
19 | set to `:base_2`, `:base_e` or `:base_10`.
20 | - `negative_numbers` controls how negative numbers are represented.
21 | It can be:
22 | * `:mask`: always return 0
23 | * `:clip`: always returns 0
24 | * `:sym`: logarithms are drawn symmetrically, that is, the log of a
25 | number *n* when n < 0 is -log(abs(n))
26 | - `linear_range` is a range -if any- where results are not logarithmical
27 |
28 | **Data domain**
29 |
30 | Unfortunately, a domain must be given for all custom scales.
31 | To make your life easier, you can either:
32 |
33 | - `domain: {0, 27}` will set an explicit domain, or
34 | - `dataset` and `axis` let you specify a Dataset and one or a list of axes,
35 | and the domain will be computed out of them all.
36 |
37 | **Ticks**
38 |
39 | - `interval_count` divides the interval in `n` linear slices, or
40 | - `tick_positions` can receive a list of explicit possible
41 | ticks, that will be displayed ony if they are within the domain
42 | area.
43 | - `custom_tick_formatter` is a function to be applied to the
44 | ticks.
45 |
46 | """
47 |
48 | alias __MODULE__
49 | alias Contex.ScaleUtils
50 | alias Contex.Dataset
51 |
52 | defstruct [
53 | :domain,
54 | :range,
55 | :log_base_fn,
56 | :negative_numbers,
57 | :linear_range,
58 | :custom_tick_formatter,
59 | :tick_positions,
60 | :interval_count,
61 |
62 | # These are compouted automagically
63 | :nice_domain,
64 | :display_decimals
65 | ]
66 |
67 | @type t() :: %__MODULE__{}
68 |
69 | @doc """
70 | Creates a new scale with defaults.
71 |
72 |
73 |
74 | """
75 | @spec new :: Contex.ContinuousLogScale.t()
76 | def new(options \\ []) do
77 | dom =
78 | get_domain(
79 | Keyword.get(options, :domain, :notfound),
80 | Keyword.get(options, :dataset, :notfound),
81 | Keyword.get(options, :axis, :notfound)
82 | )
83 | |> ScaleUtils.validate_range(":domain")
84 |
85 | rng =
86 | Keyword.get(options, :range, nil)
87 | |> ScaleUtils.validate_range_nil(":range")
88 |
89 | ic = Keyword.get(options, :interval_count, 10)
90 |
91 | is = Keyword.get(options, :tick_positions, nil)
92 |
93 | lb =
94 | Keyword.get(options, :log_base, :base_2)
95 | |> ScaleUtils.validate_option(":log_base", [:base_2, :base_e, :base_10])
96 |
97 | neg_num =
98 | Keyword.get(options, :negative_numbers, :clip)
99 | |> ScaleUtils.validate_option(":negative_numbers", [:clip, :mask, :sym])
100 |
101 | lin_rng = Keyword.get(options, :linear_range, nil)
102 |
103 | ctf = Keyword.get(options, :custom_tick_formatter, nil)
104 |
105 | log_base_fn =
106 | case lb do
107 | :base_2 -> &:math.log2/1
108 | :base_e -> &:math.log/1
109 | :base_10 -> &:math.log10/1
110 | end
111 |
112 | %ContinuousLogScale{
113 | domain: dom,
114 | nice_domain: nil,
115 | range: rng,
116 | tick_positions: is,
117 | interval_count: ic,
118 | display_decimals: nil,
119 | custom_tick_formatter: ctf,
120 | log_base_fn: log_base_fn,
121 | negative_numbers: neg_num,
122 | linear_range: lin_rng
123 | }
124 | |> nice()
125 | end
126 |
127 | @doc """
128 | Fixes inconsistencies and scales.
129 | """
130 | @spec nice(Contex.ContinuousLogScale.t()) :: Contex.ContinuousLogScale.t()
131 | def nice(
132 | %ContinuousLogScale{
133 | domain: {min_d, max_d},
134 | interval_count: interval_count,
135 | tick_positions: tick_positions
136 | } = c
137 | ) do
138 | %{
139 | nice_domain: nice_domain,
140 | ticks: computed_ticks,
141 | display_decimals: display_decimals
142 | } =
143 | ScaleUtils.compute_nice_settings(
144 | min_d,
145 | max_d,
146 | tick_positions,
147 | interval_count
148 | )
149 |
150 | %{
151 | c
152 | | nice_domain: nice_domain,
153 | tick_positions: computed_ticks,
154 | display_decimals: display_decimals
155 | }
156 | end
157 |
158 | @spec get_domain(:notfound | {any, any}, any, any) :: {number(), number()}
159 | @doc """
160 | Computes the correct domain {a, b}.
161 |
162 | - If it is explicitly passed, we use it.
163 | - If there is a dataset and a column or a list of columns, we use that
164 | - If all else fails, we use {0, 1}
165 | """
166 | def get_domain(:notfound, %Dataset{} = requested_dataset, requested_columns)
167 | when is_list(requested_columns) do
168 | all_ranges =
169 | requested_columns
170 | |> Enum.map(fn c -> Dataset.column_extents(requested_dataset, c) end)
171 |
172 | minimum =
173 | all_ranges
174 | |> Enum.map(fn {min, _} -> min end)
175 | |> Enum.min()
176 |
177 | maximum =
178 | all_ranges
179 | |> Enum.map(fn {_, max} -> max end)
180 | |> Enum.max()
181 |
182 | {minimum, maximum}
183 | end
184 |
185 | def get_domain(:notfound, %Dataset{} = requested_dataset, requested_column),
186 | do: get_domain(:notfound, requested_dataset, [requested_column])
187 |
188 | def get_domain({_a, _b} = requested_domain, _requested_dataset, _requested_column),
189 | do: requested_domain
190 |
191 | def get_domain(_, _, _), do: {0, 1}
192 |
193 | @doc """
194 | Translates a value into its logarithm,
195 | given the mode and an optional linear part.
196 | """
197 | @spec log_value(number(), function(), :clip | :mask | :sym, float()) :: any
198 | def log_value(v, fn_exp, mode, lin) when is_number(v) or is_float(lin) or is_nil(lin) do
199 | is_lin_area =
200 | case lin do
201 | nil -> false
202 | _ -> abs(v) < lin
203 | end
204 |
205 | # IO.puts("#{inspect({v, mode, is_lin_area, v > 0})}")
206 |
207 | case {mode, is_lin_area, v > 0} do
208 | {:mask, _, false} ->
209 | 0
210 |
211 | {:mask, true, true} ->
212 | v
213 |
214 | {:mask, false, true} ->
215 | fn_exp.(v)
216 |
217 | {:clip, _, false} ->
218 | 0
219 |
220 | {:clip, true, true} ->
221 | v
222 |
223 | {:clip, false, true} ->
224 | fn_exp.(v)
225 |
226 | {:sym, true, _} ->
227 | v
228 |
229 | {:sym, false, false} ->
230 | if v < 0 do
231 | 0 - fn_exp.(-v)
232 | else
233 | 0
234 | end
235 |
236 | {:sym, false, true} ->
237 | fn_exp.(v)
238 | end
239 | end
240 |
241 | @spec get_domain_to_range_function(Contex.ContinuousLogScale.t()) :: (number -> float)
242 | def get_domain_to_range_function(
243 | %ContinuousLogScale{
244 | domain: {min_d, max_d},
245 | range: {min_r, max_r},
246 | log_base_fn: log_base_fn,
247 | negative_numbers: neg_num,
248 | linear_range: lin_rng
249 | } = _scale
250 | ) do
251 | log_fn = fn v -> log_value(v, log_base_fn, neg_num, lin_rng) end
252 |
253 | min_log_d = log_fn.(min_d)
254 | max_log_d = log_fn.(max_d)
255 | width_d = max_log_d - min_log_d
256 | width_r = max_r - min_r
257 |
258 | fn x ->
259 | log_x = log_fn.(x)
260 | v = ScaleUtils.rescale_value(log_x, min_log_d, width_d, min_r, width_r)
261 | # IO.puts("Domain: #{x} -> #{log_x} -> #{v}")
262 | v
263 | end
264 | end
265 |
266 | # ===============================================================
267 | # Implementation of Contex.Scale
268 |
269 | defimpl Contex.Scale do
270 | @spec domain_to_range_fn(Contex.ContinuousLogScale.t()) :: (number -> float)
271 | def domain_to_range_fn(%ContinuousLogScale{} = scale),
272 | do: ContinuousLogScale.get_domain_to_range_function(scale)
273 |
274 | @spec ticks_domain(Contex.ContinuousLogScale.t()) :: list(number)
275 | def ticks_domain(%ContinuousLogScale{
276 | tick_positions: tick_positions
277 | }) do
278 | tick_positions
279 | end
280 |
281 | def ticks_domain(_), do: []
282 |
283 | @spec ticks_range(Contex.ContinuousLogScale.t()) :: list(number)
284 | def ticks_range(%ContinuousLogScale{} = scale) do
285 | transform_func = ContinuousLogScale.get_domain_to_range_function(scale)
286 |
287 | ticks_domain(scale)
288 | |> Enum.map(transform_func)
289 | end
290 |
291 | @spec domain_to_range(Contex.ContinuousLogScale.t(), number) :: float
292 | def domain_to_range(%ContinuousLogScale{} = scale, range_val) do
293 | transform_func = ContinuousLogScale.get_domain_to_range_function(scale)
294 | transform_func.(range_val)
295 | end
296 |
297 | @spec get_range(Contex.ContinuousLogScale.t()) :: {number, number}
298 | def get_range(%ContinuousLogScale{range: {min_r, max_r}}), do: {min_r, max_r}
299 |
300 | @spec set_range(Contex.ContinuousLogScale.t(), number, number) ::
301 | Contex.ContinuousLogScale.t()
302 | def set_range(%ContinuousLogScale{} = scale, start, finish)
303 | when is_number(start) and is_number(finish) do
304 | %{scale | range: {start, finish}}
305 | end
306 |
307 | @spec set_range(Contex.ContinuousLogScale.t(), {number, number}) ::
308 | Contex.ContinuousLogScale.t()
309 | def set_range(%ContinuousLogScale{} = scale, {start, finish})
310 | when is_number(start) and is_number(finish),
311 | do: set_range(scale, start, finish)
312 |
313 | @spec get_formatted_tick(Contex.ContinuousLogScale.t(), any) :: binary
314 | def get_formatted_tick(
315 | %ContinuousLogScale{
316 | display_decimals: display_decimals,
317 | custom_tick_formatter: custom_tick_formatter
318 | },
319 | tick_val
320 | ) do
321 | ScaleUtils.format_tick_text(tick_val, display_decimals, custom_tick_formatter)
322 | end
323 | end
324 | end
325 |
--------------------------------------------------------------------------------
/lib/chart/scale/ordinal_scale.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.OrdinalScale do
2 | @moduledoc """
3 | An ordinal scale to map discrete values (text or numeric) to a plotting coordinate system.
4 |
5 | An ordinal scale is commonly used for the category axis in barcharts. It has to be able
6 | to generate the centre-point of the bar (e.g. for tick annotations) as well as the
7 | available width the bar or bar-group has to fill.
8 |
9 | In order to do that the ordinal scale requires a 'padding' option to be set (defaults to 0.5 in the scale)
10 | that defines the gaps between the bars / categories. The ordinal scale has two mapping functions for
11 | the data domain to the plotting range. One returns the centre point (`range_to_domain_fn`) and one
12 | returns the "band" the category can occupy (`domain_to_range_band_fn`).
13 |
14 | An `OrdinalScale` is initialised with a list of values which represent the categories. The scale generates
15 | a tick for each value in that list.
16 |
17 | Typical usage of this scale would be as follows:
18 |
19 | iex> category_scale
20 | ...> = Contex.OrdinalScale.new(["Hippo", "Turtle", "Rabbit"])
21 | ...> |> Contex.Scale.set_range(0.0, 9.0)
22 | ...> |> Contex.OrdinalScale.padding(2)
23 | ...> category_scale.domain_to_range_fn.("Turtle")
24 | 4.5
25 | iex> category_scale.domain_to_range_band_fn.("Hippo")
26 | {1.0, 2.0}
27 | iex> category_scale.domain_to_range_band_fn.("Turtle")
28 | {4.0, 5.0}
29 | """
30 | alias __MODULE__
31 |
32 | defstruct [
33 | :domain,
34 | :range,
35 | :padding,
36 | :domain_to_range_fn,
37 | :range_to_domain_fn,
38 | :domain_to_range_band_fn
39 | ]
40 |
41 | @type t() :: %__MODULE__{}
42 |
43 | @doc """
44 | Creates a new ordinal scale.
45 | """
46 | @spec new(list()) :: Contex.OrdinalScale.t()
47 | def new(domain) when is_list(domain) do
48 | %OrdinalScale{domain: domain, padding: 0.5}
49 | end
50 |
51 | @doc """
52 | Updates the domain data for the scale.
53 | """
54 | @spec domain(Contex.OrdinalScale.t(), list()) :: Contex.OrdinalScale.t()
55 | def domain(%OrdinalScale{} = ordinal_scale, data) when is_list(data) do
56 | %{ordinal_scale | domain: data}
57 | |> update_transform_funcs()
58 | end
59 |
60 | @doc """
61 | Sets the padding between the categories for the scale.
62 |
63 | Defaults to 0.5.
64 |
65 | Defined in terms of plotting coordinates.
66 |
67 | *Note* that if the padding is greater than the calculated width of each category
68 | you might get strange effects (e.g. the end of a band being before the beginning)
69 | """
70 | def padding(%OrdinalScale{} = scale, padding) when is_number(padding) do
71 | # We need to update the transform functions if we change the padding as the band calculations need it
72 | %{scale | padding: padding}
73 | |> update_transform_funcs()
74 | end
75 |
76 | @doc false
77 | def update_transform_funcs(
78 | %OrdinalScale{domain: domain, range: {start_r, end_r}, padding: padding} = scale
79 | )
80 | when is_list(domain) and is_number(start_r) and is_number(end_r) and is_number(padding) do
81 | domain_count = Kernel.length(domain)
82 | range_width = end_r - start_r
83 |
84 | item_width =
85 | case domain_count do
86 | 0 -> 0.0
87 | _ -> range_width / domain_count
88 | end
89 |
90 | flip_padding =
91 | case start_r < end_r do
92 | true -> 1.0
93 | _ -> -1.0
94 | end
95 |
96 | # Returns centre point of bucket
97 | domain_to_range_fn = fn domain_val ->
98 | case Enum.find_index(domain, fn x -> x == domain_val end) do
99 | nil ->
100 | start_r
101 |
102 | index ->
103 | start_r + item_width / 2.0 + index * item_width
104 | end
105 | end
106 |
107 | domain_to_range_band_fn = fn domain_val ->
108 | case Enum.find_index(domain, fn x -> x == domain_val end) do
109 | nil ->
110 | {start_r, start_r}
111 |
112 | index ->
113 | band_start = start_r + flip_padding * padding / 2.0 + index * item_width
114 | band_end = start_r + (index + 1) * item_width - flip_padding * padding / 2.0
115 | {band_start, band_end}
116 | end
117 | end
118 |
119 | range_to_domain_fn =
120 | case range_width do
121 | 0 ->
122 | fn -> "" end
123 |
124 | _ ->
125 | fn range_val ->
126 | case domain_count do
127 | 0 ->
128 | ""
129 |
130 | _ ->
131 | bucket_index = Kernel.trunc((range_val - start_r) / item_width)
132 | Enum.at(domain, bucket_index)
133 | end
134 | end
135 | end
136 |
137 | %{
138 | scale
139 | | domain_to_range_fn: domain_to_range_fn,
140 | range_to_domain_fn: range_to_domain_fn,
141 | domain_to_range_band_fn: domain_to_range_band_fn
142 | }
143 | end
144 |
145 | def update_transform_funcs(%OrdinalScale{} = scale), do: scale
146 |
147 | @doc """
148 | Returns the band for the nominated category in terms of plotting coordinate system.
149 |
150 | If the category isn't found, the start of the plotting range is returned.
151 | """
152 | @spec get_band(Contex.OrdinalScale.t(), any) :: {number(), number()}
153 | def get_band(%OrdinalScale{domain_to_range_band_fn: domain_to_range_band_fn}, domain_value)
154 | when is_function(domain_to_range_band_fn) do
155 | domain_to_range_band_fn.(domain_value)
156 | end
157 |
158 | defimpl Contex.Scale do
159 | def ticks_domain(%OrdinalScale{domain: domain}), do: domain
160 |
161 | def ticks_range(%OrdinalScale{domain_to_range_fn: transform_func} = scale)
162 | when is_function(transform_func) do
163 | ticks_domain(scale)
164 | |> Enum.map(transform_func)
165 | end
166 |
167 | def domain_to_range_fn(%OrdinalScale{domain_to_range_fn: domain_to_range_fn}),
168 | do: domain_to_range_fn
169 |
170 | def domain_to_range(%OrdinalScale{domain_to_range_fn: transform_func}, range_val)
171 | when is_function(transform_func) do
172 | transform_func.(range_val)
173 | end
174 |
175 | def get_range(%OrdinalScale{range: {min_r, max_r}}), do: {min_r, max_r}
176 |
177 | def set_range(%OrdinalScale{} = scale, start, finish)
178 | when is_number(start) and is_number(finish) do
179 | %{scale | range: {start, finish}}
180 | |> OrdinalScale.update_transform_funcs()
181 | end
182 |
183 | def set_range(%OrdinalScale{} = scale, {start, finish})
184 | when is_number(start) and is_number(finish),
185 | do: set_range(scale, start, finish)
186 |
187 | def get_formatted_tick(_, tick_val), do: tick_val
188 | end
189 | end
190 |
--------------------------------------------------------------------------------
/lib/chart/scale/scale_utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.ScaleUtils do
2 | @moduledoc """
3 | Here are common functions that can be shared between multiple scales.
4 | """
5 | alias Contex.Utils
6 |
7 | @doc """
8 | Makes sure that a range of numerics is
9 | a tuple of floats, in the right order.
10 |
11 | """
12 | def validate_range({f, t}, _label) when is_number(f) and is_number(t) do
13 | ff = as_float(f)
14 | tt = as_float(t)
15 |
16 | if tt < ff do
17 | {tt, ff}
18 | else
19 | {ff, tt}
20 | end
21 | end
22 |
23 | def validate_range(v, label),
24 | do:
25 | throw("#{label} - a range should be in the form {0.0, 1.0} but you supplied #{inspect(v)}")
26 |
27 | def as_float(n) when is_number(n) do
28 | case(n) do
29 | i when is_integer(i) -> i * 1.0
30 | f -> f
31 | end
32 | end
33 |
34 | @doc """
35 | Validates a range, that could be nil.
36 | """
37 | def validate_range_nil(nil, _label), do: nil
38 | def validate_range_nil(r, label), do: validate_range(r, label)
39 |
40 | def validate_option(o, option_name, possible_options)
41 | when is_binary(option_name) and is_list(possible_options) do
42 | if o in possible_options do
43 | o
44 | else
45 | throw(
46 | "Option #{option_name} cannot be set to #{o} - valid values are #{inspect(possible_options)} "
47 | )
48 | end
49 | end
50 |
51 | @doc """
52 | Rescales a value from domain to range.
53 |
54 | Expects
55 |
56 | (can be refactored in Lin)
57 | """
58 | def rescale_value(v, domain_min, domain_width, range_min, range_width) do
59 | if domain_width > 0.0 do
60 | ratio = (v - domain_min) / domain_width
61 | ratio * range_width + range_min
62 | else
63 | 0.0
64 | end
65 | end
66 |
67 | @doc """
68 | Finds the area where a data-set is defined,
69 | as to properly place minimums and maximums.
70 |
71 | Returns a domain, e.g. {-3, 22}
72 |
73 | """
74 | def extents(data) do
75 | Enum.reduce(data, {nil, nil}, fn x, {min, max} ->
76 | {Utils.safe_min(x, min), Utils.safe_max(x, max)}
77 | end)
78 | end
79 |
80 | @doc """
81 | Formats ticks.
82 |
83 | (can be refactored in Lin)
84 | """
85 |
86 | def format_tick_text(tick, _, custom_tick_formatter) when is_function(custom_tick_formatter),
87 | do: custom_tick_formatter.(tick)
88 |
89 | def format_tick_text(tick, _, _) when is_integer(tick), do: to_string(tick)
90 |
91 | def format_tick_text(tick, display_decimals, _) when display_decimals > 0 do
92 | :erlang.float_to_binary(tick, decimals: display_decimals)
93 | end
94 |
95 | def format_tick_text(tick, _, _), do: :erlang.float_to_binary(tick, [:compact, decimals: 0])
96 |
97 | @doc """
98 | Computes settings to display values.
99 |
100 | %{
101 | nice_domain: {min_nice, max_nice},
102 | interval_size: rounded_interval_size,
103 | interval_count: adjusted_interval_count,
104 | display_decimals: display_decimals
105 | }
106 |
107 |
108 | (can be refactored in Lin)
109 | """
110 |
111 | def compute_nice_settings(
112 | min_d,
113 | max_d,
114 | explicit_ticks,
115 | interval_count
116 | )
117 | when is_number(min_d) and is_number(max_d) and is_number(interval_count) and
118 | interval_count > 1 do
119 | width = max_d - min_d
120 | width = if width == 0.0, do: 1.0, else: width
121 | unrounded_interval_size = width / interval_count
122 | order_of_magnitude = :math.ceil(:math.log10(unrounded_interval_size) - 1)
123 | power_of_ten = :math.pow(10, order_of_magnitude)
124 |
125 | rounded_interval_size =
126 | lookup_axis_interval(unrounded_interval_size / power_of_ten) * power_of_ten
127 |
128 | min_nice = rounded_interval_size * Float.floor(min_d / rounded_interval_size)
129 | max_nice = rounded_interval_size * Float.ceil(max_d / rounded_interval_size)
130 | adjusted_interval_count = round(1.0001 * (max_nice - min_nice) / rounded_interval_size)
131 |
132 | display_decimals = guess_display_decimals(order_of_magnitude)
133 |
134 | # If I have a list of explicit ticks
135 | computed_ticks =
136 | case explicit_ticks do
137 | ei when is_list(ei) ->
138 | ei
139 | |> Enum.filter(fn v -> v >= min_d && v <= max_d end)
140 |
141 | _ ->
142 | 0..adjusted_interval_count
143 | |> Enum.map(fn i -> min_d + i * rounded_interval_size end)
144 | end
145 |
146 | %{
147 | nice_domain: {min_nice, max_nice},
148 | ticks: computed_ticks,
149 | display_decimals: display_decimals
150 | }
151 | end
152 |
153 | @axis_interval_breaks [0.05, 0.1, 0.2, 0.25, 0.4, 0.5, 1.0, 2.0, 2.5, 4.0, 5.0, 10.0, 20.0]
154 | defp lookup_axis_interval(raw_interval) when is_float(raw_interval) do
155 | Enum.find(@axis_interval_breaks, 10.0, fn x -> x >= raw_interval end)
156 | end
157 |
158 | defp guess_display_decimals(power_of_ten) when power_of_ten > 0 do
159 | 0
160 | end
161 |
162 | defp guess_display_decimals(power_of_ten) do
163 | 1 + -1 * round(power_of_ten)
164 | end
165 | end
166 |
--------------------------------------------------------------------------------
/lib/chart/simple_pie.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.SimplePie do
2 | @moduledoc """
3 | Generates a simple pie chart from an array of tuples like `{"Cat", 10.0}`.
4 |
5 | Usage:
6 |
7 | ```
8 | SimplePie.new([{"Cat", 10.0}, {"Dog", 20.0}, {"Hamster", 5.0}])
9 | |> SimplePie.colours(["aa0000", "00aa00", "0000aa"]) # Optional - only if you don't like the defaults
10 | |> SimplePie.draw() # Emits svg pie chart
11 | ```
12 |
13 | The colours are using default from `Contex.CategoryColourScale.new/1` by names in tuples.
14 |
15 | The size defaults to 50 pixels high and wide. You can override by updating
16 | `:height` directly in the `SimplePie` struct before call `draw/1`.
17 | The height and width of pie chart is always same, therefore set only height is enough.
18 | """
19 | alias __MODULE__
20 | alias Contex.CategoryColourScale
21 |
22 | defstruct [
23 | :data,
24 | :scaled_values,
25 | :fill_colours,
26 | height: 50
27 | ]
28 |
29 | @type t() :: %__MODULE__{}
30 |
31 | @doc """
32 | Create a new SimplePie struct from list of tuples.
33 | """
34 | @spec new([{String.t(), number()}]) :: t()
35 | def new(data)
36 | when is_list(data) do
37 | %SimplePie{
38 | data: data,
39 | scaled_values: data |> Enum.map(&elem(&1, 1)) |> scale_values(),
40 | fill_colours: data |> Enum.map(&elem(&1, 0)) |> CategoryColourScale.new()
41 | }
42 | end
43 |
44 | @doc """
45 | Update the colour palette used for the slices.
46 | """
47 | @spec colours(t(), CategoryColourScale.colour_palette()) :: t()
48 | def colours(%SimplePie{fill_colours: fill_colours} = pie, colours) do
49 | custom_fill_colours = CategoryColourScale.set_palette(fill_colours, colours)
50 | %SimplePie{pie | fill_colours: custom_fill_colours}
51 | end
52 |
53 | @doc """
54 | Renders the SimplePie to svg, including the svg wrapper, as a string or improper string list that
55 | is marked safe.
56 | """
57 | @spec draw(t()) :: {:safe, [String.t()]}
58 | def draw(%SimplePie{height: height} = chart) do
59 | output = ~s"""
60 |
63 | """
64 |
65 | {:safe, [output]}
66 | end
67 |
68 | defp generate_slices(%SimplePie{
69 | data: data,
70 | scaled_values: scaled_values,
71 | height: height,
72 | fill_colours: fill_colours
73 | }) do
74 | r = height / 2
75 | stroke_circumference = 2 * :math.pi() * r / 2
76 | categories = data |> Enum.map(&elem(&1, 0))
77 |
78 | scaled_values
79 | |> Enum.zip(categories)
80 | |> Enum.map_reduce({0, 0}, fn {value, category}, {idx, offset} ->
81 | {
82 | ~s"""
83 |
88 |
89 | """,
90 | {idx + 1, offset + value}
91 | }
92 | end)
93 | |> elem(0)
94 | |> Enum.join()
95 | end
96 |
97 | defp slice_value(value, stroke_circumference) do
98 | value * stroke_circumference / 100
99 | end
100 |
101 | defp scale_values(values) do
102 | values
103 | |> Enum.map_reduce(Enum.sum(values), &{&1 / &2 * 100, &2})
104 | |> elem(0)
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/lib/chart/sparkline.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Sparkline do
2 | @moduledoc """
3 | Generates a simple sparkline from an array of numbers.
4 |
5 | Note that this does not follow the pattern for other types of plot. It is not designed
6 | to be embedded within a `Contex.Plot` and, because it only relies on a single list
7 | of numbers, does not use data wrapped in a `Contex.Dataset`.
8 |
9 | Usage is exceptionally simple:
10 |
11 | ```
12 | data = [0, 5, 10, 15, 12, 12, 15, 14, 20, 14, 10, 15, 15]
13 | Sparkline.new(data) |> Sparkline.draw() # Emits svg sparkline
14 | ```
15 |
16 | The colour defaults to a green line with a faded green fill, but can be overridden
17 | with `colours/3`. Unlike other colours in Contex, these colours are how you would
18 | specify them in CSS - e.g.
19 | ```
20 | Sparkline.new(data)
21 | |> Sparkline.colours("#fad48e", "#ff9838")
22 | |> Sparkline.draw()
23 | ```
24 |
25 | The size defaults to 20 pixels high and 100 wide. You can override by updating
26 | `:height` and `:width` directly in the `Sparkline` struct before call `draw/1`.
27 | """
28 | alias __MODULE__
29 | alias Contex.{ContinuousLinearScale, Scale}
30 |
31 | defstruct [
32 | :data,
33 | :extents,
34 | :length,
35 | :spot_radius,
36 | :spot_colour,
37 | :line_width,
38 | :line_colour,
39 | :fill_colour,
40 | :y_transform,
41 | :height,
42 | :width
43 | ]
44 |
45 | @type t() :: %__MODULE__{}
46 |
47 | @doc """
48 | Create a new sparkline struct from some data.
49 | """
50 | @spec new([number()]) :: Contex.Sparkline.t()
51 | def new(data) when is_list(data) do
52 | %Sparkline{data: data, extents: ContinuousLinearScale.extents(data), length: length(data)}
53 | |> set_default_style
54 | end
55 |
56 | @doc """
57 | Override line and fill colours for the sparkline.
58 |
59 | Note that colours should be specified as you would in CSS - they are passed through
60 | directly into the SVG. For example:
61 |
62 | ```
63 | Sparkline.new(data)
64 | |> Sparkline.colours("#fad48e", "#ff9838")
65 | |> Sparkline.draw()
66 | ```
67 | """
68 | @spec colours(Contex.Sparkline.t(), String.t(), String.t()) :: Contex.Sparkline.t()
69 | def colours(%Sparkline{} = sparkline, fill, line) do
70 | # TODO: Really need some validation...
71 | %{sparkline | fill_colour: fill, line_colour: line}
72 | end
73 |
74 | defp set_default_style(%Sparkline{} = sparkline) do
75 | %{
76 | sparkline
77 | | spot_radius: 2,
78 | spot_colour: "red",
79 | line_width: 1,
80 | line_colour: "rgba(0, 200, 50, 0.7)",
81 | fill_colour: "rgba(0, 200, 50, 0.2)",
82 | height: 20,
83 | width: 100
84 | }
85 | end
86 |
87 | @doc """
88 | Renders the sparkline to svg, including the svg wrapper, as a string or improper string list that
89 | is marked safe.
90 | """
91 | def draw(%Sparkline{height: height, width: width, line_width: line_width} = sparkline) do
92 | vb_width = sparkline.length + 1
93 | vb_height = height - 2 * line_width
94 |
95 | scale =
96 | ContinuousLinearScale.new()
97 | |> ContinuousLinearScale.domain(sparkline.data)
98 | |> Scale.set_range(vb_height, 0)
99 |
100 | sparkline = %{sparkline | y_transform: Scale.domain_to_range_fn(scale)}
101 |
102 | output = ~s"""
103 |
107 | """
108 |
109 | {:safe, [output]}
110 | end
111 |
112 | defp get_line_style(%Sparkline{line_colour: line_colour, line_width: line_width}) do
113 | ~s|stroke="#{line_colour}" stroke-width="#{line_width}" fill="none" vector-effect="non-scaling-stroke"|
114 | end
115 |
116 | defp get_fill_style(%Sparkline{fill_colour: fill_colour}) do
117 | ~s|stroke="none" fill="#{fill_colour}"|
118 | end
119 |
120 | defp get_closed_path(%Sparkline{} = sparkline, vb_height) do
121 | # Same as the open path, except we drop down, run back to height,height (aka 0,0) and close it...
122 | open_path = get_path(sparkline)
123 | [open_path, "V #{vb_height} L 0 #{vb_height} Z"]
124 | end
125 |
126 | # This is the IO List approach
127 | defp get_path(%Sparkline{y_transform: transform_func} = sparkline) do
128 | last_item = Enum.count(sparkline.data) - 1
129 |
130 | [
131 | "M",
132 | sparkline.data
133 | |> Enum.map(transform_func)
134 | |> Enum.with_index()
135 | |> Enum.map(fn {value, i} ->
136 | case i < last_item do
137 | true -> "#{i} #{value} L "
138 | _ -> "#{i} #{value}"
139 | end
140 | end)
141 | ]
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/lib/chart/svg.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.SVG do
2 | @moduledoc """
3 | Convenience functions for generating SVG output
4 | """
5 |
6 | def text(x, y, content, opts \\ []) do
7 | attrs = opts_to_attrs(opts)
8 |
9 | [
10 | "",
14 | clean(content),
15 | ""
16 | ]
17 | end
18 |
19 | def text(content, opts \\ []) do
20 | attrs = opts_to_attrs(opts)
21 |
22 | [
23 | "",
26 | clean(content),
27 | ""
28 | ]
29 | end
30 |
31 | def title(content, opts \\ []) do
32 | attrs = opts_to_attrs(opts)
33 |
34 | [
35 | "",
38 | clean(content),
39 | ""
40 | ]
41 | end
42 |
43 | def rect({_x1, _x2} = x_extents, {_y1, _y2} = y_extents, inner_content, opts \\ []) do
44 | width = width(x_extents)
45 | height = width(y_extents)
46 | y = min(y_extents)
47 | x = min(x_extents)
48 |
49 | attrs = opts_to_attrs(opts)
50 |
51 | [
52 | "",
56 | inner_content,
57 | ""
58 | ]
59 | end
60 |
61 | def circle(x, y, radius, opts \\ []) do
62 | attrs = opts_to_attrs(opts)
63 |
64 | [
65 | ""
69 | ]
70 | end
71 |
72 | def line(points, smoothed, opts \\ []) do
73 | attrs = opts_to_attrs(opts)
74 |
75 | path = path(points, smoothed)
76 |
77 | [
78 | ""
83 | ]
84 | end
85 |
86 | defp path([], _), do: ""
87 |
88 | defp path(points, false) do
89 | Enum.reduce(points, :first, fn {x, y}, acc ->
90 | coord = ~s|#{x} #{y}|
91 |
92 | case acc do
93 | :first -> ["M ", coord]
94 | _ -> [acc, [" L ", coord]]
95 | end
96 | end)
97 | end
98 |
99 | defp path(points, true) do
100 | # Use Catmull-Rom curve - see http://schepers.cc/getting-to-the-point
101 | # First point stays as-is. Subsequent points are draw using SVG cubic-spline
102 | # where control points are calculated as follows:
103 | # - Take the immediately prior data point, the data point itself and the next two into
104 | # an array of 4 points. Where this isn't possible (first & last) duplicate
105 | # Apply Cardinal Spline to Cubic Bezier conversion matrix (this is with tension = 0.0)
106 | # 0 1 0 0
107 | # -1/6 1 1/6 0
108 | # 0 1/6 1 -1/6
109 | # 0 0 1 0
110 | # First control point is second result, second control point is third result, end point is last result
111 |
112 | initial_window = {nil, nil, nil, nil}
113 |
114 | {_, window, last_p, result} =
115 | Enum.reduce(points, {:first, initial_window, nil, ""}, fn p,
116 | {step, window, last_p, result} ->
117 | case step do
118 | :first ->
119 | {:second, {p, p, p, p}, p, []}
120 |
121 | :second ->
122 | {:rest, bump_window(window, p), p, ["M ", coord(last_p)]}
123 |
124 | :rest ->
125 | window = bump_window(window, p)
126 | {cp1, cp2} = cardinal_spline_control_points(window)
127 | {:rest, window, p, [result, " C " | [coord(cp1), coord(cp2), coord(last_p)]]}
128 | end
129 | end)
130 |
131 | window = bump_window(window, last_p)
132 | {cp1, cp2} = cardinal_spline_control_points(window)
133 |
134 | [result, " C " | [coord(cp1), coord(cp2), coord(last_p)]]
135 | end
136 |
137 | defp bump_window({_p1, p2, p3, p4}, new_p), do: {p2, p3, p4, new_p}
138 |
139 | @spline_tension 0.3
140 | @factor (1.0 - @spline_tension) / 6.0
141 | defp cardinal_spline_control_points({{x1, y1}, {x2, y2}, {x3, y3}, {x4, y4}}) do
142 | cp1 = {x2 + @factor * (x3 - x1), y2 + @factor * (y3 - y1)}
143 | cp2 = {x3 + @factor * (x2 - x4), y3 + @factor * (y2 - y4)}
144 |
145 | {cp1, cp2}
146 | end
147 |
148 | defp coord({x, y}) do
149 | x = if is_float(x), do: :erlang.float_to_binary(x, decimals: 2), else: x
150 | y = if is_float(y), do: :erlang.float_to_binary(y, decimals: 2), else: y
151 |
152 | ~s| #{x} #{y}|
153 | end
154 |
155 | def opts_to_attrs(opts), do: opts_to_attrs(opts, [])
156 |
157 | defp opts_to_attrs([{_, nil} | t], attrs), do: opts_to_attrs(t, attrs)
158 | defp opts_to_attrs([{_, ""} | t], attrs), do: opts_to_attrs(t, attrs)
159 |
160 | defp opts_to_attrs([{:phx_click, val} | t], attrs),
161 | do: opts_to_attrs(t, [[" phx-click=\"", val, "\""] | attrs])
162 |
163 | defp opts_to_attrs([{:phx_target, val} | t], attrs),
164 | do: opts_to_attrs(t, [[" phx-target=\"", val, "\""] | attrs])
165 |
166 | defp opts_to_attrs([{:series, val} | t], attrs),
167 | do: opts_to_attrs(t, [[" phx-value-series=\"", "#{clean(val)}", "\""] | attrs])
168 |
169 | defp opts_to_attrs([{:category, val} | t], attrs),
170 | do: opts_to_attrs(t, [[" phx-value-category=\"", "#{clean(val)}", "\""] | attrs])
171 |
172 | defp opts_to_attrs([{:value, val} | t], attrs),
173 | do: opts_to_attrs(t, [[" phx-value-value=\"", "#{clean(val)}", "\""] | attrs])
174 |
175 | defp opts_to_attrs([{:id, val} | t], attrs),
176 | do: opts_to_attrs(t, [[" phx-value-id=\"", "#{val}", "\""] | attrs])
177 |
178 | defp opts_to_attrs([{:task, val} | t], attrs),
179 | do: opts_to_attrs(t, [[" phx-value-task=\"", "#{clean(val)}", "\""] | attrs])
180 |
181 | # TODO: This is going to break down with more complex styles
182 | defp opts_to_attrs([{:fill, val} | t], attrs),
183 | do: opts_to_attrs(t, [[" style=\"fill:#", val, ";\""] | attrs])
184 |
185 | defp opts_to_attrs([{:transparent, true} | t], attrs),
186 | do: opts_to_attrs(t, [[" fill=\"transparent\""] | attrs])
187 |
188 | defp opts_to_attrs([{:stroke, val} | t], attrs),
189 | do: opts_to_attrs(t, [[" stroke=\"#", val, "\""] | attrs])
190 |
191 | defp opts_to_attrs([{:stroke_width, val} | t], attrs),
192 | do: opts_to_attrs(t, [[" stroke-width=\"", val, "\""] | attrs])
193 |
194 | defp opts_to_attrs([{:stroke_linejoin, val} | t], attrs),
195 | do: opts_to_attrs(t, [[" stroke-linejoin=\"", val, "\""] | attrs])
196 |
197 | defp opts_to_attrs([{:opacity, val} | t], attrs),
198 | do: opts_to_attrs(t, [[" fill-opacity=\"", val, "\""] | attrs])
199 |
200 | defp opts_to_attrs([{:class, val} | t], attrs),
201 | do: opts_to_attrs(t, [[" class=\"", val, "\""] | attrs])
202 |
203 | defp opts_to_attrs([{:transform, val} | t], attrs),
204 | do: opts_to_attrs(t, [[" transform=\"", val, "\""] | attrs])
205 |
206 | defp opts_to_attrs([{:text_anchor, val} | t], attrs),
207 | do: opts_to_attrs(t, [[" text-anchor=\"", val, "\""] | attrs])
208 |
209 | defp opts_to_attrs([{:dominant_baseline, val} | t], attrs),
210 | do: opts_to_attrs(t, [[" dominant-baseline=\"", val, "\""] | attrs])
211 |
212 | defp opts_to_attrs([{:alignment_baseline, val} | t], attrs),
213 | do: opts_to_attrs(t, [[" alignment-baseline=\"", val, "\""] | attrs])
214 |
215 | defp opts_to_attrs([{:marker_start, val} | t], attrs),
216 | do: opts_to_attrs(t, [[" marker-start=\"", val, "\""] | attrs])
217 |
218 | defp opts_to_attrs([{:marker_mid, val} | t], attrs),
219 | do: opts_to_attrs(t, [[" marker-mid=\"", val, "\""] | attrs])
220 |
221 | defp opts_to_attrs([{:marker_end, val} | t], attrs),
222 | do: opts_to_attrs(t, [[" marker-end=\"", val, "\""] | attrs])
223 |
224 | defp opts_to_attrs([{:shape_rendering, val} | t], attrs),
225 | do: opts_to_attrs(t, [[" shape-rendering=\"", val, "\""] | attrs])
226 |
227 | defp opts_to_attrs([{key, val} | t], attrs) when is_atom(key),
228 | do: opts_to_attrs(t, [[" ", Atom.to_string(key), "=\"", clean(val), "\""] | attrs])
229 |
230 | defp opts_to_attrs([{key, val} | t], attrs) when is_binary(key),
231 | do: opts_to_attrs(t, [[" ", key, "=\"", clean(val), "\""] | attrs])
232 |
233 | defp opts_to_attrs([], attrs), do: attrs
234 |
235 | defp width({a, b}), do: abs(a - b)
236 | defp min({a, b}), do: min(a, b)
237 |
238 | defp clean(s), do: Contex.SVG.Sanitize.basic_sanitize(s)
239 | end
240 |
--------------------------------------------------------------------------------
/lib/chart/svg_sanitize.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.SVG.Sanitize do
2 | @moduledoc false
3 |
4 | # Basically a copy/paste of Plug.HTML. Copied here to avoid a substantial dependency
5 |
6 | # License:
7 | # Copyright (c) 2013 Plataformatec.
8 |
9 | # Licensed under the Apache License, Version 2.0 (the "License");
10 | # you may not use this file except in compliance with the License.
11 | # You may obtain a copy of the License at
12 |
13 | # http://www.apache.org/licenses/LICENSE-2.0
14 |
15 | # Unless required by applicable law or agreed to in writing, software
16 | # distributed under the License is distributed on an "AS IS" BASIS,
17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18 | # See the License for the specific language governing permissions and
19 | # limitations under the License.
20 |
21 | @doc """
22 | Very basic approach to sanitizing strings for titles etc - it is effectively run
23 | through Plug.HTML.html_escape
24 | """
25 | @dialyzer [
26 | {:no_improper_lists, to_iodata: 4},
27 | {:no_improper_lists, to_iodata: 5}
28 | ]
29 |
30 | @spec basic_sanitize(any()) :: any()
31 | def basic_sanitize(data) when is_binary(data), do: html_escape(data)
32 | def basic_sanitize(data) when is_number(data), do: data
33 |
34 | @spec html_escape(String.t()) :: String.t()
35 | def html_escape(data) when is_binary(data) do
36 | IO.iodata_to_binary(to_iodata(data, 0, data, []))
37 | end
38 |
39 | @spec html_escape_to_iodata(String.t()) :: iodata
40 | def html_escape_to_iodata(data) when is_binary(data) do
41 | to_iodata(data, 0, data, [])
42 | end
43 |
44 | escapes = [
45 | {?<, "<"},
46 | {?>, ">"},
47 | {?&, "&"},
48 | {?", """},
49 | {?', "'"}
50 | ]
51 |
52 | for {match, insert} <- escapes do
53 | defp to_iodata(<>, skip, original, acc) do
54 | to_iodata(rest, skip + 1, original, [acc | unquote(insert)])
55 | end
56 | end
57 |
58 | defp to_iodata(<<_char, rest::bits>>, skip, original, acc) do
59 | to_iodata(rest, skip, original, acc, 1)
60 | end
61 |
62 | defp to_iodata(<<>>, _skip, _original, acc) do
63 | acc
64 | end
65 |
66 | for {match, insert} <- escapes do
67 | defp to_iodata(<>, skip, original, acc, len) do
68 | part = binary_part(original, skip, len)
69 | to_iodata(rest, skip + len + 1, original, [acc, part | unquote(insert)])
70 | end
71 | end
72 |
73 | defp to_iodata(<<_char, rest::bits>>, skip, original, acc, len) do
74 | to_iodata(rest, skip, original, acc, len + 1)
75 | end
76 |
77 | defp to_iodata(<<>>, 0, original, _acc, _len) do
78 | original
79 | end
80 |
81 | defp to_iodata(<<>>, skip, original, acc, len) do
82 | [acc | binary_part(original, skip, len)]
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/lib/chart/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex.Utils do
2 | @moduledoc false
3 |
4 | def date_compare(%DateTime{} = a, %DateTime{} = b) do
5 | DateTime.compare(a, b)
6 | end
7 |
8 | def date_compare(%NaiveDateTime{} = a, %NaiveDateTime{} = b) do
9 | NaiveDateTime.compare(a, b)
10 | end
11 |
12 | def date_diff(%DateTime{} = a, %DateTime{} = b, unit), do: DateTime.diff(a, b, unit)
13 |
14 | def date_diff(%NaiveDateTime{} = a, %NaiveDateTime{} = b, unit),
15 | do: NaiveDateTime.diff(a, b, unit)
16 |
17 | @doc """
18 | Adds intervals to dates. Note that only system time units (nanosecond, microsecond, millisecond, second) are
19 | supported by default by DateTime and NaiveDateTime. Minutes, Hours, Days & Weeks need to be converted to one
20 | of the supported units. Special cases have been introduced for :months & :years due to variable days in month
21 | and leap year behaviour. This has been copied from Timex.
22 |
23 | iex> {:ok, d1, 0} = DateTime.from_iso8601("2016-01-31T03:00:00Z")
24 | {:ok, ~U[2016-01-31 03:00:00Z], 0}
25 | iex> d1 = Contex.Utils.date_add(d1, 1, :months)
26 | ~U[2016-02-29 03:00:00Z]
27 | iex> _d1 = Contex.Utils.date_add(d1, 1, :years)
28 | ~U[2017-02-28 03:00:00Z]
29 |
30 | iex> {:ok, d1, 0} = DateTime.from_iso8601("2016-03-31T03:00:00Z")
31 | {:ok, ~U[2016-03-31 03:00:00Z], 0}
32 | iex> d1 = Contex.Utils.date_add(d1, -1, :months)
33 | ~U[2016-02-29 03:00:00Z]
34 | iex> _d1 = Contex.Utils.date_add(d1, -1, :years)
35 | ~U[2015-02-28 03:00:00Z]
36 | """
37 | def date_add(dt, amount_to_add, :years), do: shift_by(dt, amount_to_add, :years)
38 |
39 | def date_add(dt, amount_to_add, :months) do
40 | new_date = shift_by(dt, amount_to_add, :months)
41 |
42 | if is_last_day_of_month(dt) do
43 | ldom_new = :calendar.last_day_of_the_month(new_date.year, new_date.month)
44 | %{new_date | day: ldom_new}
45 | else
46 | new_date
47 | end
48 | end
49 |
50 | def date_add(%DateTime{} = dt, amount_to_add, unit), do: DateTime.add(dt, amount_to_add, unit)
51 |
52 | def date_add(%NaiveDateTime{} = dt, amount_to_add, unit),
53 | do: NaiveDateTime.add(dt, amount_to_add, unit)
54 |
55 | defp is_last_day_of_month(%{year: year, month: month, day: day}) do
56 | :calendar.last_day_of_the_month(year, month) == day
57 | end
58 |
59 | defp date_min(a, b), do: if(date_compare(a, b) == :lt, do: a, else: b)
60 | defp date_max(a, b), do: if(date_compare(a, b) != :lt, do: a, else: b)
61 |
62 | def safe_min(nil, nil), do: nil
63 | def safe_min(nil, b), do: b
64 | def safe_min(a, ""), do: a
65 | def safe_min("", b), do: b
66 | def safe_min(a, nil), do: a
67 | def safe_min(%DateTime{} = a, %DateTime{} = b), do: date_min(a, b)
68 | def safe_min(%NaiveDateTime{} = a, %NaiveDateTime{} = b), do: date_min(a, b)
69 | def safe_min(a, b) when is_number(a) and is_number(b), do: min(a, b)
70 | def safe_min(_, _), do: nil
71 |
72 | def safe_max(nil, nil), do: nil
73 | def safe_max(nil, b), do: b
74 | def safe_max(a, nil), do: a
75 | def safe_max("", b), do: b
76 | def safe_max(a, ""), do: a
77 | def safe_max(%DateTime{} = a, %DateTime{} = b), do: date_max(a, b)
78 | def safe_max(%NaiveDateTime{} = a, %NaiveDateTime{} = b), do: date_max(a, b)
79 | def safe_max(a, b) when is_number(a) and is_number(b), do: max(a, b)
80 | def safe_max(_, _), do: nil
81 |
82 | # def safe_min(x, y), do: safe_combine(x, y, fn x, y -> min(x, y) end)
83 | # def safe_max(x, y), do: safe_combine(x, y, fn x, y -> max(x, y) end)
84 |
85 | def safe_add(x, y), do: safe_combine(x, y, fn x, y -> x + y end)
86 |
87 | defp safe_combine(x, y, combiner) when is_number(x) and is_number(y), do: combiner.(x, y)
88 | defp safe_combine(x, _, _) when is_number(x), do: x
89 | defp safe_combine(_, y, _) when is_number(y), do: y
90 | defp safe_combine(_, _, _), do: nil
91 |
92 | def fixup_value_range({min, max}) when min == max and max > 0, do: {0, max}
93 | def fixup_value_range({min, max}) when min == max and max < 0, do: {max, 0}
94 | def fixup_value_range({0, 0}), do: {0, 1}
95 | def fixup_value_range({0.0, 0.0}), do: {0.0, 1.0}
96 | def fixup_value_range({min, max}), do: {min, max}
97 |
98 | # DateTime shifting methods copied from `defimpl Timex.Protocol, for: DateTime`
99 | # License Details:
100 | # The MIT License (MIT)
101 |
102 | ## Copyright (c) 2016 Paul Schoenfelder
103 |
104 | # Permission is hereby granted, free of charge, to any person obtaining a copy
105 | # of this software and associated documentation files (the "Software"), to deal
106 | # in the Software without restriction, including without limitation the rights
107 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
108 | # copies of the Software, and to permit persons to whom the Software is
109 | # furnished to do so, subject to the following conditions:
110 |
111 | # The above copyright notice and this permission notice shall be included in
112 | # all copies or substantial portions of the Software.
113 |
114 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
115 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
116 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
117 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
118 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
119 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
120 | # THE SOFTWARE.
121 | defp shift_by(%{year: y} = datetime, value, :years) do
122 | shifted = %{datetime | year: y + value}
123 | # If a plain shift of the year fails, then it likely falls on a leap day,
124 | # so set the day to the last day of that month
125 | case :calendar.valid_date({shifted.year, shifted.month, shifted.day}) do
126 | false ->
127 | last_day = :calendar.last_day_of_the_month(shifted.year, shifted.month)
128 | %{shifted | day: last_day}
129 |
130 | true ->
131 | shifted
132 | end
133 | end
134 |
135 | defp shift_by(%{} = datetime, 0, :months), do: datetime
136 | # Positive shifts
137 | defp shift_by(%{year: year, month: month, day: day} = datetime, value, :months)
138 | when value > 0 do
139 | if month + value <= 12 do
140 | ldom = :calendar.last_day_of_the_month(year, month + value)
141 |
142 | if day > ldom do
143 | %{datetime | month: month + value, day: ldom}
144 | else
145 | %{datetime | month: month + value}
146 | end
147 | else
148 | diff = 12 - month + 1
149 | shift_by(%{datetime | year: year + 1, month: 1}, value - diff, :months)
150 | end
151 | end
152 |
153 | # Negative shifts
154 | defp shift_by(%{year: year, month: month, day: day} = datetime, value, :months) do
155 | cond do
156 | month + value >= 1 ->
157 | ldom = :calendar.last_day_of_the_month(year, month + value)
158 |
159 | if day > ldom do
160 | %{datetime | month: month + value, day: ldom}
161 | else
162 | %{datetime | month: month + value}
163 | end
164 |
165 | :else ->
166 | shift_by(%{datetime | year: year - 1, month: 12}, value + month, :months)
167 | end
168 | end
169 | end
170 |
--------------------------------------------------------------------------------
/lib/contex.ex:
--------------------------------------------------------------------------------
1 | defmodule Contex do
2 | @moduledoc """
3 | Contex is a pure Elixir server-side data-plotting / charting system that generates SVG output.
4 |
5 | Contex is designed to be simple to use and extensible, relying on common core components, such
6 | as `Contex.Axis` and `Contex.Scale`, to create new plot types.
7 |
8 | The typical usage pattern is to wrap your data in a `Contex.Dataset`, pass that into a
9 | specific chart type (e.g. `Contex.BarChart`) to build the `Contex.PlotContent`, and then
10 | to lay that out using `Contex.Plot`, finally calling `Contex.Plot.to_svg(plot)` to create
11 | the SVG output.
12 |
13 | A minimal example might look like:
14 | ```
15 | data = [["Apples", 10], ["Bananas", 12], ["Pears", 2]]
16 | output =
17 | data
18 | |> Contex.Dataset.new()
19 | |> Contex.Plot.new(Contex.BarChart, 600, 400)
20 | |> Contex.Plot.to_svg()
21 | ```
22 |
23 | ## CSS Styling
24 | A minimal stylesheet is embedded in the SVG by default, for the purpose of making lines and text
25 | visible if no stylesheet is supplied. It is expected that these styles will be overridden using
26 | provided Contex-specific classes. The default style can also be removed by setting the
27 | `:default_style` Plot attribute to `false`.
28 |
29 | Sample CSS is shown below:
30 | ```css
31 | /* Styling for tick line */
32 | .exc-tick {
33 | stroke: grey;
34 | }
35 |
36 | /* Styling for tick text */
37 | .exc-tick text {
38 | fill: grey;
39 | stroke: none;
40 | }
41 |
42 | /* Styling for axis line */
43 | .exc-domain {
44 | stroke: rgb(207, 207, 207);
45 | }
46 |
47 | /* Styling for grid line */
48 | .exc-grid {
49 | stroke: lightgrey;
50 | }
51 |
52 | /* Styling for outline of colours in legend */
53 | .exc-legend {
54 | stroke: black;
55 | }
56 |
57 | /* Styling for text of colours in legend */
58 | .exc-legend text {
59 | fill: grey;
60 | font-size: 0.8rem;
61 | stroke: none;
62 | }
63 |
64 | /* Styling for title & subtitle of any plot */
65 | .exc-title {
66 | fill: darkslategray;
67 | font-size: 2.3rem;
68 | stroke: none;
69 | }
70 | .exc-subtitle {
71 | fill: darkgrey;
72 | font-size: 1.0rem;
73 | stroke: none;
74 | }
75 |
76 | /* Styling for label printed inside a bar on a barchart */
77 | .exc-barlabel-in {
78 | fill: white;
79 | font-size: 0.7rem;
80 | }
81 |
82 | /* Styling for label printed outside of a bar (e.g. if bar is too small) */
83 | .exc-barlabel-out {
84 | fill: grey;
85 | font-size: 0.7rem;
86 | }
87 | ```
88 |
89 | """
90 | end
91 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Contex.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :contex,
7 | version: "0.5.0",
8 | elixir: "~> 1.9",
9 | build_embedded: Mix.env() == :prod,
10 | start_permanent: Mix.env() == :prod,
11 | description: description(),
12 | package: package(),
13 | name: "ContEx",
14 | source_url: "https://github.com/mindok/contex",
15 | homepage_url: "https://contex-charts.org/",
16 | deps: deps(),
17 | docs: docs()
18 | ]
19 | end
20 |
21 | def application do
22 | [
23 | extra_applications: [:eex]
24 | ]
25 | end
26 |
27 | defp description() do
28 | "Contex - a server-side charting library for Elixir."
29 | end
30 |
31 | defp deps do
32 | [
33 | {:nimble_strftime, "~> 0.1.0"},
34 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
35 | {:sweet_xml, "~> 0.7.3", only: :test},
36 | {:floki, "~> 0.34.2", only: :test},
37 | {:extructure, "~> 1.0"},
38 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}
39 | ]
40 | end
41 |
42 | defp docs do
43 | [
44 | main: "Contex",
45 | logo: "assets/logo.png",
46 | assets: "assets",
47 | before_closing_head_tag: &docs_before_closing_head_tag/1
48 | ]
49 | end
50 |
51 | # Injects reference to contex.css into documentation output
52 | # See https://medium.com/@takanori.ishikawa/customize-how-your-exdoc-documentation-looks-a10234dbb4c9
53 | defp docs_before_closing_head_tag(:html) do
54 | ~s{}
55 | end
56 |
57 | defp docs_before_closing_head_tag(_), do: ""
58 |
59 | defp package() do
60 | [
61 | name: "contex",
62 | # These are the default files included in the package
63 | files: ~w(lib mix.exs README* LICENSE*),
64 | licenses: ["MIT"],
65 | links: %{
66 | "GitHub" => "https://github.com/mindok/contex",
67 | "Website" => "https://contex-charts.org/"
68 | }
69 | ]
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
3 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
4 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"},
5 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
6 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"},
7 | "extructure": {:hex, :extructure, "1.0.0", "7fb05a7d05094bb381ae753226f8ceca6adbbaa5bd0c90ebe3d286f20d87fc1e", [:mix], [], "hexpm", "5f67c55786867a92c549aaaace29c898c2cc02cc01b69f2192de7b7bdb5c8078"},
8 | "floki": {:hex, :floki, "0.34.2", "5fad07ef153b3b8ec110b6b155ec3780c4b2c4906297d0b4be1a7162d04a7e02", [:mix], [], "hexpm", "26b9d50f0f01796bc6be611ca815c5e0de034d2128e39cc9702eee6b66a4d1c8"},
9 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
10 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
12 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
13 | "nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"},
14 | "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"},
15 | }
16 |
--------------------------------------------------------------------------------
/samples/barchart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mindok/contex/997a0f0932a3f63f3dae6afa6acfb219d0dee8db/samples/barchart.png
--------------------------------------------------------------------------------
/samples/mashup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mindok/contex/997a0f0932a3f63f3dae6afa6acfb219d0dee8db/samples/mashup.png
--------------------------------------------------------------------------------
/samples/rolling.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mindok/contex/997a0f0932a3f63f3dae6afa6acfb219d0dee8db/samples/rolling.gif
--------------------------------------------------------------------------------
/samples/test.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/category_colour_scale_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexCategoryColourScaleTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.CategoryColourScale
5 |
6 | test "Cardinal Colour Scale" do
7 | values = ["Fred", "Bob", "Fred", "Bill"]
8 | palette = ["Red", "Green", "Blue"]
9 |
10 | scale = CategoryColourScale.new(values) |> CategoryColourScale.set_palette(palette)
11 |
12 | default_colour = CategoryColourScale.get_default_colour(scale)
13 |
14 | assert CategoryColourScale.colour_for_value(scale, "Fred") == "Red"
15 | assert CategoryColourScale.colour_for_value(scale, "Bill") == "Blue"
16 | assert CategoryColourScale.colour_for_value(scale, "Barney") == default_colour
17 |
18 | scale = CategoryColourScale.set_palette(scale, :pastel1)
19 | assert CategoryColourScale.colour_for_value(scale, "Fred") == "fbb4ae"
20 | assert CategoryColourScale.colour_for_value(scale, "Bill") == "ccebc5"
21 | assert CategoryColourScale.colour_for_value(scale, "Barney") == default_colour
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/contex_axis_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexAxisTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Axis, ContinuousLinearScale}
5 | import SweetXml
6 |
7 | # TODO: This is a bit brittle - most of the axis calculations are in float which is imprecise
8 | # String representations in the output may not line up depending on how the floats
9 | # are calculated on the day
10 |
11 | defp axis_map(axis) do
12 | Axis.to_svg(axis)
13 | |> IO.chardata_to_string()
14 | |> xpath(~x"/g",
15 | transform: ~x"./@transform"s,
16 | text_anchor: ~x"./@text-anchor"s,
17 | path: [
18 | ~x"./path",
19 | d: ~x"./@d"s
20 | ],
21 | ticks: [
22 | ~x"./g[@class='exc-tick']"l,
23 | transform: ~x"./@transform"s,
24 | line: [
25 | ~x"./line",
26 | x2: ~x"./@x2"s,
27 | y2: ~x"./@y2"s
28 | ],
29 | text: [
30 | ~x"./text",
31 | x: ~x"./@x"s,
32 | y: ~x"./@y"s,
33 | dy: ~x"./@dy"s,
34 | text: ~x"./text()"s
35 | ]
36 | ]
37 | )
38 | end
39 |
40 | setup do
41 | scale = ContinuousLinearScale.new()
42 | axis = Axis.new(scale, :top)
43 | %{axis: axis}
44 | end
45 |
46 | describe "new/1" do
47 | test "returns an axis struct given scale and a valid orientation" do
48 | scale = ContinuousLinearScale.new()
49 | axis = Axis.new(scale, :top)
50 | assert axis.orientation == :top
51 | assert axis.scale == scale
52 | end
53 |
54 | test "raises when given an invalid orientation" do
55 | assert_raise FunctionClauseError, fn -> Axis.new("foo", :cattywompus) end
56 | end
57 |
58 | test "raises for not a scale" do
59 | assert_raise ArgumentError, fn -> Axis.new("foo", :top) end
60 | end
61 |
62 | test "raises for scale that does not implement required protocol" do
63 | # A scale, but not one that can be plotted on an Axis...
64 | scale = Contex.CategoryColourScale.new([5, 10, 15], :default)
65 | assert_raise ArgumentError, fn -> Axis.new(scale, :top) end
66 | end
67 | end
68 |
69 | describe "new_top_axis/1" do
70 | test "creates axis with orientation set to :top", %{axis: axis} do
71 | axis = %{axis | orientation: :bottom}
72 | top_axis = Axis.new_top_axis(axis.scale)
73 | assert top_axis.orientation == :top
74 | end
75 | end
76 |
77 | describe "new_left_axis/1" do
78 | test "creates axis with orientation set to :left", %{axis: axis} do
79 | left_axis = Axis.new_left_axis(axis.scale)
80 | assert left_axis.orientation == :left
81 | end
82 | end
83 |
84 | describe "new_bottom_axis/1" do
85 | test "creates axis with orientation set to :bottom", %{axis: axis} do
86 | bottom_axis = Axis.new_bottom_axis(axis.scale)
87 | assert bottom_axis.orientation == :bottom
88 | end
89 | end
90 |
91 | describe "new_right_axis/1" do
92 | test "creates axis with orientation set to :right", %{axis: axis} do
93 | right_axis = Axis.new_right_axis(axis.scale)
94 | assert right_axis.orientation == :right
95 | end
96 | end
97 |
98 | describe "set_offset/1" do
99 | test "updates the offset", %{axis: axis} do
100 | axis = Axis.set_offset(axis, 5)
101 | assert axis.offset == 5
102 | end
103 | end
104 |
105 | describe "to_svg(%Axis{orientation: :right})" do
106 | setup do
107 | axis =
108 | ContinuousLinearScale.new()
109 | |> ContinuousLinearScale.domain(0, 1)
110 | |> Axis.new(:right)
111 |
112 | %{axis_map: axis_map(axis)}
113 | end
114 |
115 | test "shifts axis properly", %{axis_map: axis_map} do
116 | assert axis_map.transform == "translate(0, 0)"
117 | end
118 |
119 | test "sets text-anchor 'start'", %{axis_map: axis_map} do
120 | assert axis_map.text_anchor == "start"
121 | end
122 |
123 | test "positions axis line properly", %{axis_map: axis_map} do
124 | assert axis_map.path.d == "M6,0.5H0.5V1.5H6"
125 | end
126 |
127 | test "positions tick marks properly", %{axis_map: axis_map} do
128 | assert [
129 | "(0, 0.5)",
130 | "(0, 0.6)",
131 | "(0, 0.7)",
132 | "(0, 0.8)",
133 | "(0, 0.9)",
134 | "(0, 1.0)",
135 | "(0, 1.1)",
136 | "(0, 1.2000000000000002)",
137 | "(0, 1.3)",
138 | "(0, 1.4)",
139 | "(0, 1.5)"
140 | ] ==
141 | Enum.map(axis_map.ticks, fn tick -> Map.get(tick, :transform) end)
142 | |> Enum.map(fn tick -> String.trim_leading(tick, "translate") end)
143 |
144 | assert ["6"] ==
145 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:line, :x2]) end)
146 | |> Enum.uniq()
147 |
148 | assert ["0.32em"] ==
149 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :dy]) end)
150 | |> Enum.uniq()
151 |
152 | assert [
153 | "0.000",
154 | "0.100",
155 | "0.200",
156 | "0.300",
157 | "0.400",
158 | "0.500",
159 | "0.600",
160 | "0.700",
161 | "0.800",
162 | "0.900",
163 | "1.000"
164 | ] ==
165 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :text]) end)
166 | end
167 | end
168 |
169 | describe "to_svg(%Axis{orientation: :bottom})" do
170 | setup do
171 | axis =
172 | ContinuousLinearScale.new()
173 | |> ContinuousLinearScale.domain(0, 1)
174 | |> Axis.new(:bottom)
175 |
176 | %{axis_map: axis_map(axis)}
177 | end
178 |
179 | test "shifts axis properly", %{axis_map: axis_map} do
180 | assert axis_map.transform == "translate(0, 0)"
181 | end
182 |
183 | test "sets text-anchor for to 'middle'", %{axis_map: axis_map} do
184 | assert axis_map.text_anchor == "middle"
185 | end
186 |
187 | test "positions axis line properly", %{axis_map: axis_map} do
188 | assert axis_map.path.d == "M0.5, 6V0.5H1.5V6"
189 | end
190 |
191 | test "positions tick marks properly", %{axis_map: axis_map} do
192 | assert [
193 | "(0.5,0)",
194 | "(0.6,0)",
195 | "(0.7,0)",
196 | "(0.8,0)",
197 | "(0.9,0)",
198 | "(1.0,0)",
199 | "(1.1,0)",
200 | "(1.2000000000000002,0)",
201 | "(1.3,0)",
202 | "(1.4,0)",
203 | "(1.5,0)"
204 | ] ==
205 | Enum.map(axis_map.ticks, fn tick -> Map.get(tick, :transform) end)
206 | |> Enum.map(fn tick -> String.trim_leading(tick, "translate") end)
207 |
208 | assert ["6"] ==
209 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:line, :y2]) end)
210 | |> Enum.uniq()
211 |
212 | assert ["0.71em"] ==
213 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :dy]) end)
214 | |> Enum.uniq()
215 |
216 | assert [
217 | "0.000",
218 | "0.100",
219 | "0.200",
220 | "0.300",
221 | "0.400",
222 | "0.500",
223 | "0.600",
224 | "0.700",
225 | "0.800",
226 | "0.900",
227 | "1.000"
228 | ] ==
229 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :text]) end)
230 | end
231 | end
232 |
233 | describe "to_svg(%Axis{orientation: :top})" do
234 | setup do
235 | axis =
236 | ContinuousLinearScale.new()
237 | |> ContinuousLinearScale.domain(0, 1)
238 | |> Axis.new(:top)
239 |
240 | %{axis_map: axis_map(axis)}
241 | end
242 |
243 | test "does not shift axis", %{axis_map: axis_map} do
244 | assert axis_map.transform == ""
245 | end
246 |
247 | test "sets text-anchor for to 'middle'", %{axis_map: axis_map} do
248 | assert axis_map.text_anchor == "middle"
249 | end
250 |
251 | test "positions axis line properly", %{axis_map: axis_map} do
252 | assert axis_map.path.d == "M0.5, -6V0.5H1.5V-6"
253 | end
254 |
255 | # TODO
256 | # For top/bottom there's not space between the values;
257 | test "positions tick marks properly", %{axis_map: axis_map} do
258 | assert [
259 | "(0.5,0)",
260 | "(0.6,0)",
261 | "(0.7,0)",
262 | "(0.8,0)",
263 | "(0.9,0)",
264 | "(1.0,0)",
265 | "(1.1,0)",
266 | "(1.2000000000000002,0)",
267 | "(1.3,0)",
268 | "(1.4,0)",
269 | "(1.5,0)"
270 | ] ==
271 | Enum.map(axis_map.ticks, fn tick -> Map.get(tick, :transform) end)
272 | |> Enum.map(fn tick -> String.trim_leading(tick, "translate") end)
273 |
274 | assert ["-6"] ==
275 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:line, :y2]) end)
276 | |> Enum.uniq()
277 |
278 | assert [""] ==
279 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :dy]) end)
280 | |> Enum.uniq()
281 |
282 | assert [
283 | "0.000",
284 | "0.100",
285 | "0.200",
286 | "0.300",
287 | "0.400",
288 | "0.500",
289 | "0.600",
290 | "0.700",
291 | "0.800",
292 | "0.900",
293 | "1.000"
294 | ] ==
295 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :text]) end)
296 | end
297 | end
298 |
299 | describe "to_svg(%Axis{orientation: :left})" do
300 | setup do
301 | axis =
302 | ContinuousLinearScale.new()
303 | |> ContinuousLinearScale.domain(0, 1)
304 | |> Axis.new(:left)
305 |
306 | %{axis_map: axis_map(axis)}
307 | end
308 |
309 | test "does not shift axis", %{axis_map: axis_map} do
310 | assert axis_map.transform == ""
311 | end
312 |
313 | test "sets text-anchor to 'end'", %{axis_map: axis_map} do
314 | assert axis_map.text_anchor == "end"
315 | end
316 |
317 | test "positions axis line properly", %{axis_map: axis_map} do
318 | assert axis_map.path.d == "M-6,0.5H0.5V1.5H-6"
319 | end
320 |
321 | test "positions tick marks properly", %{axis_map: axis_map} do
322 | assert [
323 | "(0, 0.5)",
324 | "(0, 0.6)",
325 | "(0, 0.7)",
326 | "(0, 0.8)",
327 | "(0, 0.9)",
328 | "(0, 1.0)",
329 | "(0, 1.1)",
330 | "(0, 1.2000000000000002)",
331 | "(0, 1.3)",
332 | "(0, 1.4)",
333 | "(0, 1.5)"
334 | ] ==
335 | Enum.map(axis_map.ticks, fn tick -> Map.get(tick, :transform) end)
336 | |> Enum.map(fn tick -> String.trim_leading(tick, "translate") end)
337 |
338 | assert ["-6"] ==
339 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:line, :x2]) end)
340 | |> Enum.uniq()
341 |
342 | assert ["0.32em"] ==
343 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :dy]) end)
344 | |> Enum.uniq()
345 |
346 | assert [
347 | "0.000",
348 | "0.100",
349 | "0.200",
350 | "0.300",
351 | "0.400",
352 | "0.500",
353 | "0.600",
354 | "0.700",
355 | "0.800",
356 | "0.900",
357 | "1.000"
358 | ] ==
359 | Enum.map(axis_map.ticks, fn tick -> get_in(tick, [:text, :text]) end)
360 | end
361 | end
362 |
363 | # Not tested yet because it's not clear how it's used
364 | # @tag :skip
365 | # test "gridlines_to_svg/1" do
366 | # end
367 | end
368 |
--------------------------------------------------------------------------------
/test/contex_bar_chart_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexBarChartTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Dataset, BarChart, Plot}
5 | import SweetXml
6 |
7 | setup do
8 | plot =
9 | Dataset.new([{"Category 1", 10, 20}, {"Category 2", 30, 40}], [
10 | "Category",
11 | "Series 1",
12 | "Series 2"
13 | ])
14 | |> BarChart.new()
15 |
16 | %{plot: plot}
17 | end
18 |
19 | def get_option(plot_content, key) do
20 | Keyword.get(plot_content.options, key)
21 | end
22 |
23 | describe "new/2" do
24 | test "given data from tuples or lists, returns a BarChart struct with defaults", %{plot: plot} do
25 | assert get_option(plot, :width) == 100
26 | assert get_option(plot, :height) == 100
27 | end
28 |
29 | test "given data from a map and a valid column map, returns a BarChart struct accordingly" do
30 | plot =
31 | Dataset.new([%{"bb" => 2, "aa" => 2}, %{"bb" => 3, "aa" => 4}])
32 | |> BarChart.new(mapping: %{category_col: "bb", value_cols: ["aa"]})
33 |
34 | assert get_option(plot, :width) == 100
35 | assert get_option(plot, :height) == 100
36 | assert plot.mapping.column_map.category_col == "bb"
37 | assert plot.mapping.column_map.value_cols == ["aa"]
38 | end
39 |
40 | test "Raises if no mapping is passed with map data" do
41 | assert_raise(
42 | ArgumentError,
43 | "Can not create default data mappings with Map data.",
44 | fn ->
45 | Dataset.new([%{"bb" => 2, "aa" => 2}, %{"bb" => 3, "aa" => 4}])
46 | |> BarChart.new()
47 | end
48 | )
49 | end
50 |
51 | test "Raises if invalid column map is passed with map data" do
52 | assert_raise(
53 | RuntimeError,
54 | "Required mapping(s) \"category_col\" not included in column map.",
55 | fn ->
56 | Dataset.new([%{"bb" => 2, "aa" => 2}, %{"bb" => 3, "aa" => 4}])
57 | |> BarChart.new(mapping: %{x_col: "bb", value_cols: ["aa"]})
58 | end
59 | )
60 | end
61 |
62 | test "Check data labels value can be passed with option" do
63 | plot =
64 | Dataset.new([%{"bb" => 2, "aa" => 2}, %{"bb" => 3, "aa" => 4}])
65 | |> BarChart.new(mapping: %{category_col: "bb", value_cols: ["aa"]}, data_labels: false)
66 |
67 | assert get_option(plot, :data_labels) == false
68 |
69 | plot =
70 | Dataset.new([%{"bb" => 2, "aa" => 2}, %{"bb" => 3, "aa" => 4}])
71 | |> BarChart.new(mapping: %{category_col: "bb", value_cols: ["aa"]}, data_labels: true)
72 |
73 | assert get_option(plot, :data_labels) == true
74 | end
75 |
76 | test "Check colour scheme can be passed with option" do
77 | plot =
78 | Dataset.new([%{"bb" => 2, "aa" => 2}, %{"bb" => 3, "aa" => 4}])
79 | |> BarChart.new(mapping: %{category_col: "bb", value_cols: ["aa"]}, colour_palette: :warm)
80 |
81 | assert get_option(plot, :colour_palette) == :warm
82 | end
83 | end
84 |
85 | describe "data_labels/2" do
86 | test "sets the data labels value", %{plot: plot} do
87 | plot = BarChart.data_labels(plot, false)
88 | assert get_option(plot, :data_labels) == false
89 | end
90 | end
91 |
92 | describe "type/2" do
93 | test "sets the plot type", %{plot: plot} do
94 | plot = BarChart.type(plot, :grouped)
95 | assert get_option(plot, :type) == :grouped
96 | end
97 | end
98 |
99 | describe "orientation/2" do
100 | test "sets the orientation", %{plot: plot} do
101 | plot = BarChart.orientation(plot, :horizontal)
102 | assert get_option(plot, :orientation) == :horizontal
103 | end
104 | end
105 |
106 | describe "force_value_range/2" do
107 | test "sets the value range", %{plot: plot} do
108 | plot = BarChart.force_value_range(plot, {100, 200})
109 | assert plot.value_range == {100, 200}
110 | end
111 | end
112 |
113 | describe "axis_label_rotation/2" do
114 | test "sets the axis label rotation", %{plot: plot} do
115 | plot = BarChart.axis_label_rotation(plot, 45)
116 | assert get_option(plot, :axis_label_rotation) == 45
117 | end
118 |
119 | test "rotates the labels when set", %{plot: plot} do
120 | plot = BarChart.axis_label_rotation(plot, 45)
121 |
122 | assert ["rotate(-45)"] ==
123 | Plot.new(200, 200, plot)
124 | |> Plot.to_svg()
125 | |> elem(1)
126 | |> IO.chardata_to_string()
127 | |> xpath(~x"/svg/g/g/g/text/@transform"sl)
128 | |> Enum.uniq()
129 | end
130 | end
131 |
132 | describe "padding/2" do
133 | test "sets padding and updates scale padding", %{plot: plot} do
134 | plot = BarChart.padding(plot, 4)
135 | assert get_option(plot, :padding) == 4
136 | end
137 |
138 | # Not testing clause where category scale is not ordinal, since there is
139 | # presently no way to set it to anything other than ordinal
140 | # test "if category scale is not ordinal, just sets padding", %{plot: plot} do
141 | # plot = BarChart.padding(plot, 4)
142 | # assert plot.padding == 4
143 | # end
144 | end
145 |
146 | # Should be able to validate atom is a valid palette. If colors
147 | # not limited to hex values validating those is harder.
148 | describe "colours/2" do
149 | test "accepts a list of (whatever)", %{plot: plot} do
150 | colours = ["blah", "blurgh", "blee"]
151 | plot = BarChart.colours(plot, colours)
152 | assert get_option(plot, :colour_palette) == colours
153 | end
154 |
155 | test "accepts an atom (any atom)", %{plot: plot} do
156 | plot = BarChart.colours(plot, :meat)
157 | assert get_option(plot, :colour_palette) == :meat
158 | end
159 |
160 | test "sets the palette to :default without an atom or list", %{plot: plot} do
161 | plot = BarChart.colours(plot, 12345)
162 | assert get_option(plot, :colour_palette) == :default
163 | end
164 | end
165 |
166 | describe "event_handler/2" do
167 | test "sets the Phoenix event handler", %{plot: plot} do
168 | plot = BarChart.event_handler(plot, "clicked")
169 | assert get_option(plot, :phx_event_handler) == "clicked"
170 | end
171 | end
172 |
173 | describe "select_item/2" do
174 | # TODO
175 | # This shouldn't work since select item is supposed to be a map
176 | # with certain keys
177 | test "sets the selected item", %{plot: plot} do
178 | plot = BarChart.select_item(plot, :meat)
179 | assert get_option(plot, :select_item) == :meat
180 | end
181 | end
182 |
183 | describe "custom_value_formatter/2" do
184 | test "sets the custom value formatter when passed nil", %{plot: plot} do
185 | plot = BarChart.custom_value_formatter(plot, nil)
186 | assert get_option(plot, :custom_value_formatter) == nil
187 | end
188 |
189 | test "sets the custom value formatter when passed a function", %{plot: plot} do
190 | format_function = fn x -> x end
191 | plot = BarChart.custom_value_formatter(plot, format_function)
192 | assert get_option(plot, :custom_value_formatter) == format_function
193 | end
194 |
195 | test "raises when not passed a function or nil", %{plot: plot} do
196 | assert_raise FunctionClauseError, fn -> BarChart.custom_value_formatter(plot, :meat) end
197 | end
198 | end
199 |
200 | describe "to_svg/1" do
201 | defp plot_iodata_to_map(plot_iodata) do
202 | IO.chardata_to_string(plot_iodata)
203 | |> xpath(~x"//g/rect"l,
204 | x: ~x"./@x"s,
205 | y: ~x"./@y"s,
206 | width: ~x"./@width"s,
207 | height: ~x"./@height"s,
208 | title: ~x"./title/text()"s
209 | )
210 | end
211 |
212 | # Axis and legend svg not tested as they are for practical purposes handled
213 | # by Contex.Axis and Context.Legend, tested separately
214 | test "returns properly constructed chart", %{plot: plot} do
215 | plot = BarChart.set_val_col_names(plot, ["Series 1", "Series 2"])
216 |
217 | rects_map =
218 | Plot.new(200, 200, plot)
219 | |> Plot.to_svg()
220 | |> elem(1)
221 | |> plot_iodata_to_map()
222 |
223 | string_to_rounded_float = fn value ->
224 | Float.parse(value)
225 | |> elem(0)
226 | |> Float.round(3)
227 | end
228 |
229 | assert [
230 | [17.143, 58.0, 1.0, 102.857],
231 | [34.286, 58.0, 1.0, 68.571],
232 | [51.429, 58.0, 61.0, 68.571],
233 | [68.571, 58.0, 61.0, 0.0]
234 | ] ==
235 | Stream.map(rects_map, &Map.delete(&1, :title))
236 | |> Stream.map(&Enum.unzip/1)
237 | |> Stream.map(fn value ->
238 | elem(value, 1)
239 | end)
240 | |> Enum.map(fn value ->
241 | Enum.map(value, string_to_rounded_float)
242 | end)
243 |
244 | assert ["10", "20", "30", "40"] ==
245 | Enum.map(rects_map, &Map.get(&1, :title))
246 | end
247 |
248 | test "generates equivalent output when passed map data", %{plot: plot} do
249 | map_plot_svg =
250 | Dataset.new([
251 | %{"Category" => "Category 1", "Series 1" => 10, "Series_2" => 20},
252 | %{"Category" => "Category 2", "Series 1" => 30, "Series_2" => 40}
253 | ])
254 | |> Plot.new(BarChart, 200, 200,
255 | mapping: %{category_col: "Category", value_cols: ["Series 1"]}
256 | )
257 | |> Plot.to_svg()
258 |
259 | assert map_plot_svg ==
260 | plot.dataset
261 | |> Plot.new(BarChart, 200, 200)
262 | |> Plot.to_svg()
263 | end
264 | end
265 |
266 | # TODO
267 | # Need to test reset of scale
268 | describe "set_cat_col_name/2" do
269 | test "sets category column to specified dataset column", %{plot: plot} do
270 | plot = BarChart.set_cat_col_name(plot, "Series 2")
271 | assert plot.mapping.column_map.category_col == "Series 2"
272 | end
273 | end
274 |
275 | # TODO
276 | # Need to test reset of scale
277 | describe "set_val_col_names/2" do
278 | test "sets value column(s) to specified dataset column(s)", %{plot: plot} do
279 | plot = BarChart.set_val_col_names(plot, ["Series 1", "Series 2"])
280 | assert plot.mapping.column_map.value_cols == ["Series 1", "Series 2"]
281 | end
282 | end
283 | end
284 |
--------------------------------------------------------------------------------
/test/contex_continuous_linear_scale_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContinuousLinearScaleTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.ContinuousLinearScale
5 |
6 | describe "new/0" do
7 | test "returns a ContinuousLinearScale struct with default values" do
8 | scale = ContinuousLinearScale.new()
9 |
10 | assert scale.range == {0.0, 1.0}
11 | assert scale.interval_count == 10
12 | assert scale.display_decimals == nil
13 | end
14 | end
15 |
16 | describe "domain/2" do
17 | test "returns a ContinuousLinearScale" do
18 | scale =
19 | ContinuousLinearScale.new()
20 | |> ContinuousLinearScale.domain([1.2, 2.4, 0.5, 0.2, 2.8])
21 |
22 | assert scale.domain == {0.2, 2.8}
23 | end
24 |
25 | test "returns a ContinuousLinearScale for data with small values (largest_value <= 0.0001)" do
26 | scale =
27 | ContinuousLinearScale.new()
28 | |> ContinuousLinearScale.domain([0.0, 0.0001, 0.0, 0.0001, 0.0])
29 |
30 | assert scale.domain == {0.0, 0.0001}
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/test/contex_continuous_log_scale_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContinuousLogScaleTest do
2 | # mix test test/contex_continuous_log_scale_test.exs
3 | use ExUnit.Case
4 |
5 | alias Contex.ContinuousLogScale
6 | alias Contex.Dataset
7 |
8 | describe "Build ContinuousLogScale" do
9 | test "defaults" do
10 | assert %Contex.ContinuousLogScale{
11 | custom_tick_formatter: nil,
12 | domain: {0.0, 1.0},
13 | tick_positions: [
14 | 0.0,
15 | 0.1,
16 | 0.2,
17 | _,
18 | _,
19 | _,
20 | _,
21 | _,
22 | 0.8,
23 | 0.9,
24 | 1.0
25 | ],
26 | interval_count: 10,
27 | linear_range: nil,
28 | log_base_fn: _,
29 | negative_numbers: :clip,
30 | range: nil,
31 | #
32 | nice_domain: {0.0, 1.0},
33 | display_decimals: 3
34 | } = ContinuousLogScale.new()
35 | end
36 | end
37 |
38 | describe "Compute log_value" do
39 | test "mode :mask, no linear" do
40 | f = fn v -> ContinuousLogScale.log_value(v, &:math.log2/1, :mask, nil) end
41 |
42 | [
43 | {"Negative", -3, 0},
44 | {"Zero", 0, 0},
45 | {"Positive A", 2, 1},
46 | {"Positive B", 8, 3}
47 | ]
48 | |> Enum.map(fn c -> test_case(f, c) end)
49 | end
50 |
51 | test "mode :mask, linear part" do
52 | f = fn v -> ContinuousLogScale.log_value(v, &:math.log2/1, :mask, 1.0) end
53 |
54 | [
55 | {"Negative, outside linear", -3, 0},
56 | {"Negative, within linear", -0.5, 0},
57 | {"Zero", 0, 0},
58 | {"Positive, within linear", 0.3, 0.3},
59 | {"Positive, outside linear", 2, 1}
60 | ]
61 | |> Enum.map(fn c -> test_case(f, c) end)
62 | end
63 |
64 | test "mode :sym, no linear" do
65 | f = fn v -> ContinuousLogScale.log_value(v, &:math.log2/1, :sym, nil) end
66 |
67 | [
68 | {"Negative A", -8, -3},
69 | {"Negative B", -2, -1},
70 | {"Zero", 0, 0},
71 | {"Positive A", 2, 1},
72 | {"Positive B", 8, 3}
73 | ]
74 | |> Enum.map(fn c -> test_case(f, c) end)
75 | end
76 |
77 | test "mode :sym, linear part" do
78 | f = fn v -> ContinuousLogScale.log_value(v, &:math.log2/1, :sym, 1.0) end
79 |
80 | [
81 | {"Negative, outside linear", -8, -3},
82 | {"Negative, within linear", -0.5, -0.5},
83 | {"Zero", 0, 0},
84 | {"Positive, within linear", 0.3, 0.3},
85 | {"Positive, outside linear", 2, 1}
86 | ]
87 | |> Enum.map(fn c -> test_case(f, c) end)
88 | end
89 | end
90 |
91 | describe "Get extents out of a dataset" do
92 | def ds(),
93 | do:
94 | Dataset.new(
95 | [{"a", 10, 5}, {"b", 20, 10}, {"c", 3, 7}],
96 | ["x", "y1", "y2"]
97 | )
98 |
99 | test "Explicit domain" do
100 | assert {7, 9} = ContinuousLogScale.get_domain({7, 9}, ds(), "x")
101 | end
102 |
103 | test "Use dataset" do
104 | assert {3, 20} = ContinuousLogScale.get_domain(:notfound, ds(), "y1")
105 |
106 | assert {5, 10} = ContinuousLogScale.get_domain(:notfound, ds(), "y2")
107 | end
108 |
109 | test "specific dataset 1" do
110 | series_cols = ["b0", "b1", "b2", "b3", "b4"]
111 |
112 | data = [
113 | %{
114 | "b0" => 10.4333,
115 | "b1" => 1.4834000000000014,
116 | "b2" => 2.0332999999999988,
117 | "b3" => 16.7833,
118 | "b4" => 265.40000000000003,
119 | "lbl" => "2023-03-09"
120 | },
121 | %{
122 | "b0" => 9.8667,
123 | "b1" => 1.5665999999999993,
124 | "b2" => 4.58340000000000,
125 | "b3" => 83.0333,
126 | "b4" => 359.15,
127 | "lbl" => "2023-03-08"
128 | },
129 | %{
130 | "b0" => 7.8333,
131 | "b1" => 2.9166999999999996,
132 | "b2" => 1.4666999999999994,
133 | "b3" => 9.600000000000001,
134 | "b4" => 379.2833,
135 | "lbl" => "2023-03-07"
136 | }
137 | ]
138 |
139 | test_dataset = Dataset.new(data, ["lbl" | series_cols])
140 |
141 | assert {1.4666999999999994, 379.2833} =
142 | ContinuousLogScale.get_domain(:notfound, test_dataset, series_cols)
143 | end
144 | end
145 |
146 | def test_case(function, {case_name, input_val, expected_output}) do
147 | result = function.(input_val)
148 | error = abs(result - expected_output)
149 |
150 | assert error < 0.001,
151 | "Case #{case_name} - For #{input_val} expected #{expected_output} but got #{result} "
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/test/contex_dataset_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexDatasetTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.Dataset
5 |
6 | doctest Contex.Dataset
7 |
8 | setup do
9 | dataset_maps = Dataset.new([%{y: 1, x: 2, z: 5}, %{x: 3, y: 4, z: 6}])
10 | dataset_nocols = Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}])
11 | dataset = Dataset.new(dataset_nocols.data, ["aa", "bb", "cccc", "d"])
12 | %{dataset_maps: dataset_maps, dataset_nocols: dataset_nocols, dataset: dataset}
13 | end
14 |
15 | describe "new/1" do
16 | test "returns a Dataset struct with no headers when passed a list" do
17 | dataset = Dataset.new([{1, 2}, {1, 2}])
18 | assert %Dataset{} = dataset
19 | assert dataset.headers == nil
20 | end
21 |
22 | test "raises when not passed a list" do
23 | data = {{1, 2}, {1, 2}}
24 | assert_raise FunctionClauseError, fn -> Dataset.new(data) end
25 | end
26 | end
27 |
28 | describe "new/2" do
29 | test "returns a Dataset struct with headers when passed two lists" do
30 | dataset = Dataset.new([{1, 2}, {1, 2}], ["x", "y"])
31 | assert %Dataset{} = dataset
32 | assert dataset.headers == ["x", "y"]
33 | end
34 |
35 | test "raises when not passed two lists" do
36 | data = {{1, 2}, {1, 2}}
37 | headers = {"x", "y"}
38 | assert_raise FunctionClauseError, fn -> Dataset.new(data, headers) end
39 |
40 | data = [{1, 2}, {1, 2}]
41 | headers = {"x", "y"}
42 | assert_raise FunctionClauseError, fn -> Dataset.new(data, headers) end
43 |
44 | data = {{1, 2}, {1, 2}}
45 | headers = ["x", "y"]
46 | assert_raise FunctionClauseError, fn -> Dataset.new(data, headers) end
47 | end
48 |
49 | test "returns a dataset struct with data in a map when passed a list of maps" do
50 | list_of_maps = [%{y: 1, x: 2, z: 5}, %{x: 3, y: 4, z: 6}]
51 | dataset = Dataset.new(list_of_maps)
52 | assert %Dataset{} = dataset
53 | assert dataset.data == list_of_maps
54 | assert dataset.headers == nil
55 | end
56 | end
57 |
58 | describe "column_names/1" do
59 | test "returns names if data is a map", %{dataset_maps: dataset_maps} do
60 | assert [:x, :y, :z] ==
61 | Dataset.column_names(dataset_maps)
62 | |> Enum.sort()
63 | end
64 |
65 | test "returns names if data has headers", %{dataset: dataset} do
66 | assert ["aa", "bb", "cccc", "d"] == Dataset.column_names(dataset)
67 | end
68 |
69 | test "returns names if tuple data does not have headers", %{dataset_nocols: dataset_nocols} do
70 | assert [0, 1, 2, 3] == Dataset.column_names(dataset_nocols)
71 | end
72 |
73 | test "returns names if list data does not have headers" do
74 | assert [0, 1, 2, 3] ==
75 | Dataset.new([[1, 2, 3, 4], [4, 5, 6, 4], [-3, -2, -1, 0]])
76 | |> Dataset.column_names()
77 | end
78 | end
79 |
80 | describe "column_index/2" do
81 | test "returns map key if data is a map", %{dataset_maps: dataset_maps} do
82 | assert Dataset.column_index(dataset_maps, :x) == :x
83 | end
84 |
85 | test "returns nil if column name is not a map key", %{dataset_maps: dataset_maps} do
86 | assert Dataset.column_index(dataset_maps, :not_a_key) == nil
87 | # assert_raise(
88 | # ArgumentError,
89 | # "Column name provided is not a key in the data map.",
90 | # fn -> Dataset.column_index(dataset_maps, :not_a_key) end
91 | # )
92 | end
93 |
94 | test "returns nil if dataset has no headers", %{dataset_nocols: dataset_nocols} do
95 | assert Dataset.column_index(dataset_nocols, "bb") == nil
96 | end
97 |
98 | test "returns index of header value in headers list if it exists", %{dataset: dataset} do
99 | assert Dataset.column_index(dataset, "bb") == 1
100 | end
101 |
102 | test "returns nil if header not in list", %{dataset: dataset} do
103 | assert Dataset.column_index(dataset, "bbb") == nil
104 | end
105 | end
106 |
107 | describe "value_fn/2" do
108 | test "returns accessor function for map data", %{dataset_maps: dataset_maps} do
109 | accessor = Dataset.value_fn(dataset_maps, :x)
110 | assert accessor.(hd(dataset_maps.data)) == 2
111 | end
112 |
113 | test "returns accessor function for tuple data with headers", %{dataset: dataset} do
114 | accessor = Dataset.value_fn(dataset, "aa")
115 | assert accessor.(hd(dataset.data)) == 1
116 | end
117 |
118 | test "returns accessor function for tuple data with no headers", %{
119 | dataset_nocols: dataset_nocols
120 | } do
121 | accessor = Dataset.value_fn(dataset_nocols, 0)
122 | assert accessor.(hd(dataset_nocols.data)) == 1
123 | end
124 |
125 | test "returns accessor function for list data with no headers" do
126 | dataset = Dataset.new([[1, 2, 3, 4], [4, 5, 6, 4], [-3, -2, -1, 0]])
127 | accessor = Dataset.value_fn(dataset, 0)
128 | assert accessor.(hd(dataset.data)) == 1
129 | end
130 | end
131 |
132 | describe "column_extents/2" do
133 | test "returns appropriate boundary values for given column header", %{dataset: dataset} do
134 | assert Dataset.column_extents(dataset, "bb") == {-2, 5}
135 | end
136 | end
137 |
138 | describe "column_name/2" do
139 | test "returns the map key when given the key for a column in map data", %{
140 | dataset_maps: dataset_maps
141 | } do
142 | assert Dataset.column_name(dataset_maps, :x) == :x
143 | end
144 |
145 | test "looks up the column name for a given index", %{dataset: dataset} do
146 | assert Dataset.column_name(dataset, 0) == "aa"
147 | end
148 |
149 | test "returns the index if it is out of bounds", %{dataset: dataset} do
150 | assert Dataset.column_name(dataset, 10) == 10
151 | end
152 |
153 | test "returns the index if the dataset has no headers", %{dataset_nocols: dataset_nocols} do
154 | assert Dataset.column_name(dataset_nocols, 0) == 0
155 | end
156 | end
157 |
158 | describe "guess_column_type/2" do
159 | setup do
160 | date_time_1 = DateTime.from_unix!(1)
161 | naive_date_time_1 = DateTime.to_naive(date_time_1)
162 | date_time_2 = DateTime.from_unix!(2)
163 | naive_date_time_2 = DateTime.to_naive(date_time_2)
164 |
165 | %{
166 | dataset:
167 | Dataset.new(
168 | [
169 | {1, "foo", date_time_1, naive_date_time_1},
170 | {2, "bar", date_time_2, naive_date_time_2}
171 | ],
172 | ["number", "string", "date_time", "naive_date_time"]
173 | )
174 | }
175 | end
176 |
177 | test "guesses numbers", %{dataset: dataset} do
178 | assert Dataset.guess_column_type(dataset, "number") == :number
179 | end
180 |
181 | test "guesses strings", %{dataset: dataset} do
182 | assert Dataset.guess_column_type(dataset, "string") == :string
183 | end
184 |
185 | test "guesses %DateTime{}s", %{dataset: dataset} do
186 | assert Dataset.guess_column_type(dataset, "date_time") == :datetime
187 | end
188 |
189 | test "guesses %NaiveDateTime{}s", %{dataset: dataset} do
190 | assert Dataset.guess_column_type(dataset, "naive_date_time") == :datetime
191 | end
192 | end
193 |
194 | describe "combined_column_extents/2" do
195 | test "calculates boundary values of row sums of given columns", %{dataset: dataset} do
196 | assert Dataset.combined_column_extents(dataset, ["aa", "cccc"]) == {-4, 10}
197 | end
198 | end
199 |
200 | describe "unique_values/2" do
201 | test "returns a list of unique values for a given column", %{dataset: dataset} do
202 | assert Dataset.unique_values(dataset, "d") == [4, 0]
203 | end
204 | end
205 | end
206 |
--------------------------------------------------------------------------------
/test/contex_gantt_chart_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexGanttChartTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Dataset, GanttChart, Plot}
5 | import SweetXml
6 |
7 | setup do
8 | plot =
9 | Dataset.new(
10 | [
11 | {"Category 1", "Task 1", ~N{2019-10-01 10:00:00}, ~N{2019-10-02 10:00:00}, "1_1"},
12 | {"Category 1", "Task 2", ~N{2019-10-02 10:00:00}, ~N{2019-10-04 10:00:00}, "1_2"},
13 | {"Category 2", "Task 3", ~N{2019-10-04 10:00:00}, ~N{2019-10-05 10:00:00}, "2_3"},
14 | {"Category 2", "Task 4", ~N{2019-10-06 10:00:00}, ~N{2019-10-08 10:00:00}, "2_4"}
15 | ],
16 | ["Category", "Task", "Start", "End", "Task ID"]
17 | )
18 | |> GanttChart.new()
19 |
20 | dataset_maps =
21 | Dataset.new([
22 | %{
23 | category: "Category 1",
24 | task: "Task 1",
25 | start: ~N{2019-10-01 10:00:00},
26 | finish: ~N{2019-10-02 10:00:00}
27 | },
28 | %{
29 | category: "Category 1",
30 | task: "Task 2",
31 | start: ~N{2019-10-02 10:00:00},
32 | finish: ~N{2019-10-04 10:00:00}
33 | },
34 | %{
35 | category: "Category 2",
36 | task: "Task 3",
37 | start: ~N{2019-10-04 10:00:00},
38 | finish: ~N{2019-10-05 10:00:00}
39 | },
40 | %{
41 | category: "Category 2",
42 | task: "Task 4",
43 | start: ~N{2019-10-06 10:00:00},
44 | finish: ~N{2019-10-08 10:00:00}
45 | }
46 | ])
47 |
48 | %{plot: plot, dataset_maps: dataset_maps}
49 | end
50 |
51 | def get_option(plot_content, key) do
52 | Keyword.get(plot_content.options, key)
53 | end
54 |
55 | describe "new/2" do
56 | test "returns a GanttChart struct with defaults", %{plot: plot} do
57 | assert get_option(plot, :width) == 100
58 | assert get_option(plot, :height) == 100
59 | end
60 |
61 | test "given data from a map and a valid column map, returns GanttChart struct accordingly", %{
62 | dataset_maps: dataset_maps
63 | } do
64 | plot =
65 | dataset_maps
66 | |> GanttChart.new(
67 | mapping: %{
68 | category_col: :category,
69 | task_col: :task,
70 | start_col: :start,
71 | finish_col: :finish
72 | }
73 | )
74 |
75 | assert get_option(plot, :padding) == 2
76 | assert get_option(plot, :show_task_labels) == true
77 | assert plot.mapping.column_map.category_col == :category
78 | assert plot.mapping.column_map.task_col == :task
79 | assert plot.mapping.column_map.start_col == :start
80 | assert plot.mapping.column_map.finish_col == :finish
81 | assert plot.mapping.column_map.id_col == nil
82 | end
83 |
84 | test "Raises if invalid column map is passed with map data", %{dataset_maps: dataset_maps} do
85 | assert_raise(
86 | RuntimeError,
87 | "Required mapping(s) \"category_col\", \"finish_col\", \"start_col\", \"task_col\" not included in column map.",
88 | fn -> GanttChart.new(dataset_maps, mapping: %{x_col: :category}) end
89 | )
90 | end
91 |
92 | test "Raises if no series is passed with map data", %{dataset_maps: dataset_maps} do
93 | assert_raise(
94 | ArgumentError,
95 | "Can not create default data mappings with Map data.",
96 | fn -> GanttChart.new(dataset_maps) end
97 | )
98 | end
99 | end
100 |
101 | describe "show_task_labels/2" do
102 | test "sets the show task label switch", %{plot: plot} do
103 | plot = GanttChart.show_task_labels(plot, false)
104 | assert get_option(plot, :show_task_labels) == false
105 | end
106 | end
107 |
108 | describe "set_category_task_cols/3" do
109 | test "sets the category and task columns", %{plot: plot} do
110 | plot = GanttChart.set_category_task_cols(plot, "Task", "Category")
111 | assert plot.mapping.column_map.category_col == "Task"
112 | assert plot.mapping.column_map.task_col == "Category"
113 | end
114 |
115 | test "raises when given column is not in the dataset", %{plot: plot} do
116 | assert_raise(
117 | RuntimeError,
118 | "Column(s) \"Wrong Series\" in the column mapping not in the dataset.",
119 | fn ->
120 | GanttChart.set_category_task_cols(plot, "Wrong Series", "Task")
121 | end
122 | )
123 | end
124 | end
125 |
126 | describe "set_task_interval_cols/2" do
127 | test "sets the interval columns' values", %{plot: plot} do
128 | plot = GanttChart.set_task_interval_cols(plot, {"End", "Start"})
129 | assert plot.mapping.column_map.start_col == "End"
130 | assert plot.mapping.column_map.finish_col == "Start"
131 | end
132 |
133 | test "raises when given column is not in the dataset", %{plot: plot} do
134 | assert_raise(
135 | RuntimeError,
136 | "Column(s) \"Wrong Series\" in the column mapping not in the dataset.",
137 | fn ->
138 | GanttChart.set_task_interval_cols(plot, {"End", "Wrong Series"})
139 | end
140 | )
141 | end
142 | end
143 |
144 | describe "event_handler/2" do
145 | test "sets the Phoenix event handler", %{plot: plot} do
146 | plot = GanttChart.event_handler(plot, "clicked")
147 | assert get_option(plot, :phx_event_handler) == "clicked"
148 | end
149 | end
150 |
151 | describe "set_id_col/2" do
152 | test "sets the id column", %{plot: plot} do
153 | plot = GanttChart.set_id_col(plot, "Task ID")
154 | assert plot.mapping.column_map.id_col == "Task ID"
155 | end
156 |
157 | test "raises when given column is not in the dataset", %{plot: plot} do
158 | assert_raise(
159 | RuntimeError,
160 | "Column(s) \"Wrong Series\" in the column mapping not in the dataset.",
161 | fn ->
162 | GanttChart.set_id_col(plot, "Wrong Series")
163 | end
164 | )
165 | end
166 | end
167 |
168 | describe "to_svg/1" do
169 | defp plot_iodata_to_map(plot_iodata) do
170 | IO.chardata_to_string(plot_iodata)
171 | |> xpath(~x"/svg/g/g/rect"l,
172 | x: ~x"./@x"s,
173 | y: ~x"./@y"s,
174 | width: ~x"./@width"s,
175 | height: ~x"./@height"s
176 | )
177 | end
178 |
179 | defp label_iodata_to_map(plot_iodata) do
180 | IO.chardata_to_string(plot_iodata)
181 | |> xpath(~x"/svg/g/g[not(@class)]/text"l,
182 | label: ~x"./text()"s
183 | )
184 | end
185 |
186 | # Axis and legend svg not tested as they are for practical purposes handled
187 | # by Contex.Axis and Context.Legend, tested separately
188 | test "returns properly constructed chart", %{plot: plot} do
189 | rects_map =
190 | Plot.new(200, 200, plot)
191 | |> Plot.to_svg()
192 | |> elem(1)
193 | |> plot_iodata_to_map()
194 |
195 | string_to_rounded_float = fn value ->
196 | Float.parse(value)
197 | |> elem(0)
198 | |> Float.round(3)
199 | end
200 |
201 | assert [
202 | [28.0, 15.0, 6.25, 1.0],
203 | [28.0, 30.0, 21.25, 31.0],
204 | [28.0, 15.0, 51.25, 61.0],
205 | [28.0, 30.0, 81.25, 91.0]
206 | ] ==
207 | Stream.map(rects_map, &Enum.unzip/1)
208 | |> Stream.map(fn value ->
209 | elem(value, 1)
210 | end)
211 | |> Enum.map(fn value ->
212 | Enum.map(value, string_to_rounded_float)
213 | end)
214 |
215 | labels =
216 | Plot.new(200, 200, plot)
217 | |> Plot.to_svg()
218 | |> elem(1)
219 | |> label_iodata_to_map()
220 | |> Enum.map(&Map.get(&1, :label))
221 |
222 | assert labels == ["Task 1", "Task 2", "Task 3", "Task 4"]
223 | end
224 |
225 | test "generates equivalent output with map data", %{plot: plot, dataset_maps: dataset_maps} do
226 | map_plot_svg =
227 | dataset_maps
228 | |> Plot.new(GanttChart, 200, 200,
229 | mapping: %{
230 | category_col: :category,
231 | task_col: :task,
232 | start_col: :start,
233 | finish_col: :finish
234 | }
235 | )
236 | |> Plot.to_svg()
237 |
238 | assert map_plot_svg ==
239 | plot.dataset
240 | |> Plot.new(GanttChart, 200, 200)
241 | |> Plot.to_svg()
242 | end
243 | end
244 | end
245 |
--------------------------------------------------------------------------------
/test/contex_legend_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexLegendTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Dataset, PointPlot}
5 | import SweetXml
6 |
7 | describe "to_svg/2" do
8 | test "returns properly formatted legend" do
9 | plot =
10 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
11 | |> PointPlot.new()
12 |
13 | {:safe, svg} =
14 | Contex.Plot.new(150, 150, plot)
15 | |> Contex.Plot.plot_options(%{legend_setting: :legend_right})
16 | |> Contex.Plot.to_svg()
17 |
18 | legend =
19 | IO.chardata_to_string(svg)
20 | |> xpath(~x"//g[@class='exc-legend']",
21 | box: [
22 | ~x"./rect",
23 | x: ~x"./@x"s,
24 | y: ~x"./@y"s,
25 | height: ~x"./@height"s,
26 | width: ~x"./@width"s,
27 | style: ~x"./@style"s
28 | ],
29 | text: [
30 | ~x"./text",
31 | x: ~x"./@x"s,
32 | y: ~x"./@y"s,
33 | text_anchor: ~x"./@text-anchor"s,
34 | dominant_baseline: ~x"./@dominant-baseline"s,
35 | text: ~x"./text()"s
36 | ]
37 | )
38 |
39 | # The other attributes are not tested because they are hard-coded.
40 | assert %{y: "0", style: "fill:#1f77b4;"} = legend.box
41 | assert %{y: "9.0", text: "bb"} = legend.text
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/contex_line_chart_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexLineChartTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Dataset, LinePlot}
5 |
6 | setup do
7 | plot =
8 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
9 | |> LinePlot.new()
10 |
11 | %{plot: plot}
12 | end
13 |
14 | describe "smooth line plotting" do
15 | test "points to smoothed points" do
16 | points = [
17 | {20, 380},
18 | {58, 342},
19 | {100, 342},
20 | {100, 300},
21 | {140, 250},
22 | {190, 210},
23 | {220, 197},
24 | {250, 184},
25 | {280, 155},
26 | {310, 260},
27 | {404, 20}
28 | ]
29 |
30 | _expected_output = """
31 | d=M20,380
32 | C26.333333333333332,373.6666666666667 44.666666666666664,348.3333333333333 58,342
33 | C71.33333333333333,335.6666666666667 93,349 100,342
34 | C107,335 93.33333333333333,315.3333333333333 100,300
35 | C106.66666666666667,284.6666666666667 125,265 140,250
36 | C155,235 176.66666666666666,218.83333333333334 190,210
37 | C203.33333333333334,201.16666666666666 210,201.33333333333334 220,197
38 | C230,192.66666666666666 240,191 250,184
39 | C260,177 270,142.33333333333334 280,155
40 | C290,167.66666666666666 289.3333333333333,282.5 310,260
41 | C330.6666666666667,237.5 388.3333333333333,60 404,20 "
42 | """
43 |
44 | output = Contex.SVG.line(points, true) |> IO.iodata_to_binary()
45 | IO.inspect(output)
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/test/contex_linear_scale_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexLinearScaleTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.ContinuousLinearScale
5 | alias Contex.Scale
6 |
7 | describe "Basic scale tests" do
8 | test "Crashing bug with round domain range" do
9 | scale =
10 | ContinuousLinearScale.new()
11 | |> ContinuousLinearScale.domain(1.2, 2.2)
12 | |> Scale.set_range(0.0, 1.0)
13 |
14 | assert scale.interval_size == 0.2
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/contex_mapping_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexMappingTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Mapping, Dataset, PointPlot}
5 |
6 | setup do
7 | maps_data = Dataset.new([%{y: 1, x: 2, z: 5}, %{x: 3, y: 4, z: 6}])
8 |
9 | headers_data =
10 | Dataset.new([[1, 2, 3, 4], [4, 5, 6, 4], [-3, -2, -1, 0]], ["aa", "bb", "cccc", "d"])
11 |
12 | nocols_data = Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}])
13 |
14 | %{
15 | maps: struct(PointPlot, dataset: maps_data),
16 | headers: struct(PointPlot, dataset: headers_data),
17 | nocols: struct(PointPlot, dataset: nocols_data),
18 | maps_data: maps_data,
19 | headers_data: headers_data,
20 | nocols_data: nocols_data,
21 | required_columns: [x_col: :exactly_one, y_cols: :one_or_more, fill_col: :zero_or_one]
22 | }
23 | end
24 |
25 | describe "new!/3" do
26 | test "returns a mapping given valid inputs", plot_with do
27 | # Map data
28 | mapping =
29 | Mapping.new(
30 | plot_with.required_columns,
31 | %{x_col: :x, y_cols: [:y, :z]},
32 | plot_with.maps_data
33 | )
34 |
35 | assert mapping.column_map.x_col == :x
36 | assert mapping.column_map.y_cols == [:y, :z]
37 |
38 | row = hd(plot_with.maps.dataset.data)
39 | assert mapping.accessors.x_col.(row) == 2
40 | assert Enum.map(mapping.accessors.y_cols, & &1.(row)) == [1, 5]
41 |
42 | # List data with headers
43 | mapping =
44 | Mapping.new(
45 | plot_with.required_columns,
46 | %{x_col: "aa", y_cols: ["bb", "d"]},
47 | plot_with.headers_data
48 | )
49 |
50 | assert mapping.column_map.x_col == "aa"
51 | assert mapping.column_map.y_cols == ["bb", "d"]
52 |
53 | row = hd(plot_with.headers.dataset.data)
54 | assert mapping.accessors.x_col.(row) == 1
55 | assert Enum.map(mapping.accessors.y_cols, & &1.(row)) == [2, 4]
56 |
57 | # Tuple data with no headers
58 | mapping =
59 | Mapping.new(
60 | plot_with.required_columns,
61 | %{x_col: 0, y_cols: [1, 3]},
62 | plot_with.nocols_data
63 | )
64 |
65 | assert mapping.column_map.x_col == 0
66 | assert mapping.column_map.y_cols == [1, 3]
67 |
68 | row = hd(plot_with.nocols.dataset.data)
69 | assert mapping.accessors.x_col.(row) == 1
70 | assert Enum.map(mapping.accessors.y_cols, & &1.(row)) == [2, 4]
71 | end
72 |
73 | test "Maps default accessor for mappings not provided", plot_with do
74 | mapping =
75 | Mapping.new(
76 | plot_with.required_columns,
77 | %{x_col: :x, y_cols: [:y, :z]},
78 | plot_with.maps_data
79 | )
80 |
81 | # A mapping should pick up all the expected columns...
82 | assert Map.has_key?(mapping.column_map, :fill_col)
83 |
84 | row = hd(plot_with.maps.dataset.data)
85 | # ... but return nil for an unmapped one
86 | assert mapping.accessors.fill_col.(row) == nil
87 | end
88 |
89 | test "Raises if required column not provided", plot_with do
90 | assert_raise(
91 | RuntimeError,
92 | "Required mapping(s) \"y_cols\" not included in column map.",
93 | fn -> Mapping.new(plot_with.required_columns, %{x_col: :x}, plot_with.maps_data) end
94 | )
95 | end
96 |
97 | test "Raises if column in map is not in dataset", plot_with do
98 | assert_raise(
99 | RuntimeError,
100 | "Column(s) \"a\" in the column mapping not in the dataset.",
101 | fn ->
102 | Mapping.new(
103 | plot_with.required_columns,
104 | %{x_col: :a, y_cols: [:y, :z]},
105 | plot_with.maps_data
106 | )
107 | end
108 | )
109 | end
110 | end
111 |
112 | test "updates the column map and accessors", plot_with do
113 | mapping =
114 | Mapping.new(plot_with.required_columns, %{x_col: :x, y_cols: [:y]}, plot_with.maps_data)
115 | |> Mapping.update(%{x_col: :z, fill_col: :x})
116 |
117 | assert mapping.column_map == %{x_col: :z, y_cols: [:y], fill_col: :x}
118 | end
119 |
120 | test "Raises if the updated columns are not in the dataset", plot_with do
121 | mapping =
122 | Mapping.new(plot_with.required_columns, %{x_col: :x, y_cols: [:y, :z]}, plot_with.maps_data)
123 |
124 | assert_raise(
125 | RuntimeError,
126 | "Column(s) \"a\" in the column mapping not in the dataset.",
127 | fn -> Mapping.update(mapping, %{x_col: :a, fill_col: :x}) end
128 | )
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/test/contex_plot_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ContexPlotTest do
2 | use ExUnit.Case
3 |
4 | alias Contex.{Plot, Dataset, PointPlot, BarChart}
5 | import SweetXml
6 |
7 | setup do
8 | plot =
9 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
10 | |> PointPlot.new()
11 |
12 | plot = Plot.new(150, 200, plot)
13 | %{plot: plot}
14 | end
15 |
16 | def get_option(plot_content, key) do
17 | Keyword.get(plot_content.options, key)
18 | end
19 |
20 | describe "new/5" do
21 | test "returns a Plot struct with default options and margins" do
22 | plot =
23 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
24 | |> Plot.new(PointPlot, 150, 200)
25 |
26 | assert plot.width == 150
27 | assert plot.height == 200
28 |
29 | assert plot.plot_options == %{
30 | show_x_axis: true,
31 | show_y_axis: true,
32 | legend_setting: :legend_none
33 | }
34 |
35 | assert plot.margins == %{
36 | left: 70,
37 | top: 10,
38 | right: 10,
39 | bottom: 70
40 | }
41 | end
42 |
43 | test "Sets orientation on BarChart" do
44 | plot =
45 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
46 | |> Plot.new(BarChart, 150, 200, orientation: :horizontal)
47 |
48 | assert get_option(plot.plot_content, :orientation) == :horizontal
49 | end
50 |
51 | test "can override margins" do
52 | plot_options = %{top_margin: 5, right_margin: 6, bottom_margin: 7, left_margin: 8}
53 |
54 | plot =
55 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
56 | |> Plot.new(BarChart, 150, 200, plot_options: plot_options)
57 | |> Contex.Plot.plot_options(plot_options)
58 |
59 | assert plot.margins == %{
60 | top: 5,
61 | right: 6,
62 | bottom: 7,
63 | left: 8
64 | }
65 | end
66 |
67 | test "returns a Plot struct using assigned attributes" do
68 | plot =
69 | Dataset.new([{1, 2, 3, 4}, {4, 5, 6, 4}, {-3, -2, -1, 0}], ["aa", "bb", "cccc", "d"])
70 | |> Plot.new(
71 | PointPlot,
72 | 150,
73 | 200,
74 | title: "Title",
75 | x_label: "X Label",
76 | legend_setting: :legend_right
77 | )
78 |
79 | assert plot.title == "Title"
80 | assert plot.x_label == "X Label"
81 | assert plot.plot_options.legend_setting == :legend_right
82 |
83 | assert plot.margins == %{
84 | left: 70,
85 | top: 40,
86 | right: 110,
87 | bottom: 90
88 | }
89 | end
90 | end
91 |
92 | describe "new/3" do
93 | test "returns a Plot struct with default options and margins", %{plot: plot} do
94 | assert plot.width == 150
95 | assert plot.height == 200
96 |
97 | assert plot.plot_options == %{
98 | show_x_axis: true,
99 | show_y_axis: true,
100 | legend_setting: :legend_none
101 | }
102 |
103 | assert plot.margins == %{
104 | left: 70,
105 | top: 10,
106 | right: 10,
107 | bottom: 70
108 | }
109 | end
110 | end
111 |
112 | describe "dataset/3" do
113 | test "given two lists updates the dataset and headers", %{plot: plot} do
114 | plot = Plot.dataset(plot, [{1, 2}, {3, 4}], ["x", "y"])
115 | assert plot.plot_content.dataset.data == [{1, 2}, {3, 4}]
116 | assert plot.plot_content.dataset.headers == ["x", "y"]
117 | end
118 | end
119 |
120 | describe "dataset/2" do
121 | test "given a Dataset updates the dataset", %{plot: plot} do
122 | dataset = Dataset.new([{1, 2}, {3, 4}], ["first", "second"])
123 | plot = Plot.dataset(plot, dataset)
124 | assert plot.plot_content.dataset.headers == ["first", "second"]
125 | assert plot.plot_content.dataset.data == [{1, 2}, {3, 4}]
126 | end
127 |
128 | test "given one list updates the dataset, preserving headers", %{plot: plot} do
129 | headers = plot.plot_content.dataset.headers
130 | plot = Plot.dataset(plot, [{1, 2}, {3, 4}])
131 | assert plot.plot_content.dataset.data == [{1, 2}, {3, 4}]
132 | assert plot.plot_content.dataset.headers == headers
133 | end
134 | end
135 |
136 | describe "attributes/2" do
137 | test "updates provided attributes", %{plot: plot} do
138 | plot =
139 | Plot.attributes(plot, title: "Title", x_label: "X Label", legend_setting: :legend_right)
140 |
141 | assert plot.title == "Title"
142 | assert plot.x_label == "X Label"
143 | assert plot.plot_options.legend_setting == :legend_right
144 | end
145 |
146 | test "recalculates margins", %{plot: plot} do
147 | plot =
148 | Plot.attributes(plot, title: "Title", x_label: "X Label", legend_setting: :legend_right)
149 |
150 | assert plot.margins == %{
151 | left: 70,
152 | top: 40,
153 | right: 110,
154 | bottom: 90
155 | }
156 | end
157 | end
158 |
159 | describe "plot_options/2" do
160 | setup context do
161 | %{
162 | plot:
163 | Plot.plot_options(context.plot, %{show_y_axis: false, legend_setting: :legend_right})
164 | }
165 | end
166 |
167 | test "sets plot options", %{plot: plot} do
168 | assert plot.plot_options == %{
169 | show_x_axis: true,
170 | show_y_axis: false,
171 | legend_setting: :legend_right
172 | }
173 | end
174 |
175 | test "recalculates margins", %{plot: plot} do
176 | assert plot.margins == %{
177 | left: 0,
178 | top: 10,
179 | right: 110,
180 | bottom: 70
181 | }
182 | end
183 | end
184 |
185 | describe "titles/3" do
186 | setup context do
187 | %{plot: Plot.titles(context.plot, "The Title", "The Sub")}
188 | end
189 |
190 | test "sets title and subtitle", %{plot: plot} do
191 | assert plot.title == "The Title"
192 | assert plot.subtitle == "The Sub"
193 | end
194 |
195 | test "recalculates margins", %{plot: plot} do
196 | assert plot.margins == %{
197 | left: 70,
198 | top: 55,
199 | right: 10,
200 | bottom: 70
201 | }
202 | end
203 | end
204 |
205 | describe "axis_labels/3" do
206 | setup context do
207 | %{plot: Plot.axis_labels(context.plot, "X Side", "Y Side")}
208 | end
209 |
210 | test "sets x- and y-axis labels", %{plot: plot} do
211 | assert plot.x_label == "X Side"
212 | assert plot.y_label == "Y Side"
213 | end
214 |
215 | test "recalculates margins", %{plot: plot} do
216 | assert plot.margins == %{
217 | left: 90,
218 | top: 10,
219 | right: 10,
220 | bottom: 90
221 | }
222 | end
223 | end
224 |
225 | describe "size/3" do
226 | setup context do
227 | %{plot: Plot.size(context.plot, 200, 300)}
228 | end
229 |
230 | test "sets width and height", %{plot: plot} do
231 | assert plot.width == 200
232 | assert plot.height == 300
233 | end
234 |
235 | # TODO
236 | # Plot.size/3 calls calculate_margins/1 internally but the plot
237 | # dimensions are not an input to the margin calculation so it's
238 | # not clear why.
239 | test "doesn't affect margins", %{plot: plot} do
240 | assert plot.margins == %{
241 | left: 70,
242 | top: 10,
243 | right: 10,
244 | bottom: 70
245 | }
246 | end
247 | end
248 |
249 | describe "to_svg/1" do
250 | test "renders plot svg", %{plot: plot} do
251 | {:safe, svg} =
252 | Plot.titles(plot, "The Title", "The Sub")
253 | |> Plot.axis_labels("X Side", "Y Side")
254 | |> Plot.plot_options(%{legend_setting: :legend_right})
255 | |> Plot.to_svg()
256 |
257 | svg =
258 | IO.chardata_to_string(svg)
259 | |> xpath(~x"/svg",
260 | viewbox: ~x"./@viewBox"s,
261 | title: [
262 | ~x"./text[@class='exc-title']",
263 | text: ~x"./text()"s,
264 | x: ~x"./@x"s,
265 | y: ~x"./@y"s
266 | ],
267 | subtitle: [
268 | ~x".//text[@class='exc-subtitle'][1]",
269 | text: ~x"./text()"s,
270 | x: ~x"./@x"s,
271 | y: ~x"./@y"s
272 | ],
273 | x_axis_label: [
274 | ~x".//text[@class='exc-subtitle'][2]",
275 | text: ~x"./text()"s,
276 | x: ~x"./@x"s,
277 | y: ~x"./@y"s
278 | ],
279 | y_axis_label: [
280 | ~x".//text[@class='exc-subtitle'][3]",
281 | text: ~x"./text()"s,
282 | x: ~x"./@x"s,
283 | y: ~x"./@y"s
284 | ],
285 | legend_transform: ~x"./g[last()]/@transform"s
286 | )
287 |
288 | # Only test elements that are not rendered ultimately rendered
289 | # by PlotContent.to_svg/1 or PlotContent.get_svg_legend/1
290 | assert svg.viewbox == "0 0 150 200"
291 | assert svg.title == %{text: "The Title", x: "65.0", y: "20"}
292 | assert svg.subtitle == %{text: "The Sub", x: "65.0", y: "35"}
293 | assert svg.x_axis_label == %{text: "X Side", x: "65.0", y: "180"}
294 | assert svg.y_axis_label == %{text: "Y Side", x: "-82.5", y: "20"}
295 | assert svg.y_axis_label == %{text: "Y Side", x: "-82.5", y: "20"}
296 | assert svg.legend_transform == "translate(50, 65)"
297 | end
298 |
299 | test "includes default styles by default", %{plot: plot} do
300 | assert plot
301 | |> Plot.to_svg()
302 | |> elem(1)
303 | |> IO.chardata_to_string()
304 | |> String.contains?(~s|