├── .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 | ![Example Charts](./samples/mashup.png "Example Charts") 6 | 7 | ... and it works nicely in Phoenix LiveView 8 | 9 | ![Animated Barchart](./samples/rolling.gif "Animated Barchart") 10 | 11 | ![CI badge](https://github.com/mindok/contex/workflows/CI/badge.svg) 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, " fancy SVG chart rendering stuff representing your plot"} 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 | 61 | #{generate_slices(chart)} 62 | 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 | 104 | 105 | 106 | 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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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|