├── .credo.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── ROADMAP.md ├── guides ├── annotate.md ├── assets │ ├── annotate_1.svg │ ├── annotate_2.svg │ ├── annotate_3.svg │ ├── geom_bar_1.svg │ ├── geom_bar_2.svg │ ├── geom_boxplot_1.svg │ ├── geom_boxplot_2.svg │ ├── geom_boxplot_3.svg │ ├── geom_boxplot_4.svg │ ├── geom_line_1.svg │ ├── geom_line_2.svg │ ├── geom_line_3.svg │ ├── geom_point_1.svg │ ├── geom_point_2.svg │ ├── geom_point_3.svg │ ├── geom_point_4.svg │ ├── geom_point_5.svg │ ├── geom_point_6.svg │ ├── geom_point_custom.svg │ ├── geom_text_1.svg │ ├── geom_text_2.svg │ ├── geom_text_3.svg │ ├── geom_text_4.svg │ ├── geom_text_5.svg │ ├── geom_text_6.svg │ ├── geom_text_7.svg │ ├── geom_text_8.svg │ ├── geom_text_9.svg │ ├── scale_color_viridis_1.svg │ ├── scale_color_viridis_2.svg │ ├── scale_color_viridis_3.svg │ ├── theme_1.svg │ ├── theme_10.svg │ ├── theme_11.svg │ ├── theme_12.svg │ ├── theme_13.svg │ ├── theme_2.svg │ ├── theme_3.svg │ ├── theme_4.svg │ ├── theme_5.svg │ ├── theme_6.svg │ ├── theme_7.svg │ ├── theme_8.svg │ └── theme_9.svg ├── geom_bar.md ├── geom_boxplot.md ├── geom_line.md ├── geom_point.md ├── geom_text.md ├── scale_color_viridis.md └── theme.md ├── lib ├── ggity.ex ├── ggity │ ├── annotate.ex │ ├── axis.ex │ ├── color.ex │ ├── draw.ex │ ├── element.ex │ ├── element │ │ ├── line.ex │ │ ├── rect.ex │ │ └── text.ex │ ├── geom │ │ ├── bar.ex │ │ ├── blank.ex │ │ ├── boxplot.ex │ │ ├── line.ex │ │ ├── point.ex │ │ ├── rect.ex │ │ ├── ribbon.ex │ │ ├── segment.ex │ │ └── text.ex │ ├── html.ex │ ├── labels.ex │ ├── layer.ex │ ├── legend.ex │ ├── plot.ex │ ├── scale.ex │ ├── scale │ │ ├── alpha_continuous.ex │ │ ├── alpha_discrete.ex │ │ ├── alpha_manual.ex │ │ ├── color_manual.ex │ │ ├── color_viridis.ex │ │ ├── continuous.ex │ │ ├── discrete.ex │ │ ├── fill_viridis.ex │ │ ├── identity.ex │ │ ├── linetype_discrete.ex │ │ ├── linetype_manual.ex │ │ ├── shape.ex │ │ ├── shape_manual.ex │ │ ├── size.ex │ │ ├── size_manual.ex │ │ ├── x_continuous.ex │ │ ├── x_date.ex │ │ ├── x_date_time.ex │ │ ├── x_discrete.ex │ │ └── y_continuous.ex │ ├── shapes.ex │ ├── stat.ex │ └── theme.ex └── mix │ └── tasks │ ├── doc_examples │ ├── annotate.ex │ ├── geom_bar.ex │ ├── geom_boxplot.ex │ ├── geom_line.ex │ ├── geom_point.ex │ ├── geom_text.ex │ ├── scale_color_viridis.ex │ └── theme.ex │ ├── docs.ex │ └── examples.ex ├── livebooks └── geom_point.livemd ├── mix.exs ├── mix.lock ├── priv ├── diamonds.csv ├── economics.csv ├── economics_long.csv ├── mpg.csv └── tx_housing.csv └── test ├── ggity_draw_test.exs ├── ggity_geom_line_test.exs ├── ggity_geom_point_test.exs ├── ggity_labels_test.exs ├── ggity_plot_test.exs ├── ggity_theme_test.exs ├── scale ├── ggity_scale_alpha_continuous_test.exs ├── ggity_scale_alpha_discrete_test.exs ├── ggity_scale_alpha_manual_test.exs ├── ggity_scale_color_manual_test.exs ├── ggity_scale_color_viridis_test.exs ├── ggity_scale_identity_test.exs ├── ggity_scale_linetype_discrete_test.exs ├── ggity_scale_linetype_manual_test.exs ├── ggity_scale_shape_manual_test.exs ├── ggity_scale_shape_test.exs ├── ggity_scale_size_manual_test.exs ├── ggity_scale_size_test.exs ├── ggity_scale_x_continuous_test.exs ├── ggity_scale_x_date_test.exs ├── ggity_scale_x_datetime_test.exs └── ggity_scale_y_continuous_test.exs ├── test_helper.exs └── visual ├── ggity_geom_bar_test.livemd ├── ggity_geom_boxplot_test.livemd ├── ggity_geom_line_test.livemd ├── ggity_geom_point_test.livemd ├── ggity_geom_ribbon_test.livemd ├── ggity_geom_text_test.livemd ├── ggity_labels_test.livemd ├── ggity_layers_test.livemd └── ggity_scale_color_viridis_test.livemd /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | test: 9 | runs-on: ubuntu-20.04 10 | env: 11 | MIX_ENV: test 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - pair: 17 | elixir: '1.14' 18 | otp: 24.3 19 | - pair: 20 | elixir: '1.14' 21 | otp: 25 22 | - pair: 23 | elixir: '1.15' 24 | otp: 24.3 25 | - pair: 26 | elixir: '1.15' 27 | otp: 25 28 | lint: lint 29 | steps: 30 | - uses: actions/checkout@v2 31 | 32 | - uses: erlef/setup-beam@v1 33 | with: 34 | otp-version: ${{matrix.pair.otp}} 35 | elixir-version: ${{matrix.pair.elixir}} 36 | 37 | - uses: actions/cache@v2 38 | with: 39 | path: deps 40 | key: mix-deps-${{ hashFiles('**/mix.lock') }} 41 | 42 | - run: mix deps.get 43 | 44 | - run: mix format --check-formatted 45 | if: ${{ matrix.lint }} 46 | 47 | - run: mix deps.unlock --check-unused 48 | if: ${{ matrix.lint }} 49 | 50 | - run: mix deps.compile 51 | 52 | - run: mix compile --warnings-as-errors 53 | if: ${{ matrix.lint }} 54 | 55 | - run: mix test 56 | if: ${{ ! matrix.lint }} 57 | 58 | - run: mix test --warnings-as-errors 59 | if: ${{ matrix.lint }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ggity-*.tar 24 | 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Steve Rowley 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 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## v0.6 4 | * Histograms 5 | * Faceting 6 | 7 | ## Later 8 | * Continuous color scale (including legends) 9 | * `coord_flip` (support horizontal bar charts) 10 | * Implement a method for rendering new points and updating scales only (LiveView optimization) 11 | * Draw legend on any side of the plot (top/left/bottom/right) 12 | * Implement x_lim/y_lim 13 | * ggplot2-ify gridlines approach (breaks, minor breaks) 14 | * `stat_smooth` 15 | * Density plots/stats -------------------------------------------------------------------------------- /guides/annotate.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.mtcars() 3 | |> Plot.new(%{x: :wt, y: :mpg}) 4 | |> Plot.geom_point() 5 | |> Plot.annotate(:text, x: 4, y: 25, label: "Some text") 6 | |> Plot.plot() 7 | 8 | ``` 9 | ![](assets/annotate_1.svg) 10 | ``` 11 | Examples.mtcars() 12 | |> Plot.new(%{x: :wt, y: :mpg}) 13 | |> Plot.geom_point() 14 | |> Plot.annotate(:rect, xmin: 3, xmax: 4.2, ymin: 12, ymax: 21, alpha: 0.2) 15 | |> Plot.plot() 16 | 17 | ``` 18 | ![](assets/annotate_2.svg) 19 | ``` 20 | Examples.mtcars() 21 | |> Plot.new(%{x: :wt, y: :mpg}) 22 | |> Plot.geom_point() 23 | |> Plot.annotate(:segment, x: 2.5, xend: 4, y: 15, yend: 25, color: "blue") 24 | |> Plot.plot() 25 | 26 | ``` 27 | ![](assets/annotate_3.svg) 28 | -------------------------------------------------------------------------------- /guides/assets/geom_text_7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 2 10 | 11 | 12 | 1 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | x 32 | 33 | 34 | 35 | 36 | 37 | 3.0 38 | 39 | 40 | 2.25 41 | 42 | 43 | 1.5 44 | 45 | 46 | 0.75 47 | 48 | 49 | 0.0 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | y 97 | 98 | 99 | 100 | 101 | 3 102 | 1 103 | 1 104 | 2 105 | 106 | 107 | 108 | grp 109 | 110 | a 111 | 112 | b 113 | 114 | 115 | -------------------------------------------------------------------------------- /guides/assets/geom_text_8.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 2 10 | 11 | 12 | 1 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | x 32 | 33 | 34 | 35 | 36 | 37 | 3.0 38 | 39 | 40 | 2.25 41 | 42 | 43 | 1.5 44 | 45 | 46 | 0.75 47 | 48 | 49 | 0.0 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | y 97 | 98 | 99 | 100 | 101 | 3 102 | 1 103 | 1 104 | 2 105 | 106 | 107 | 108 | grp 109 | 110 | a 111 | 112 | b 113 | 114 | 115 | -------------------------------------------------------------------------------- /guides/assets/geom_text_9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 2 10 | 11 | 12 | 1 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | x 32 | 33 | 34 | 35 | 36 | 37 | 4.0 38 | 39 | 40 | 3.0 41 | 42 | 43 | 2.0 44 | 45 | 46 | 1.0 47 | 48 | 49 | 0.0 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | y 97 | 98 | 99 | 100 | 101 | 1 102 | 3 103 | 2 104 | 1 105 | 106 | 107 | 108 | grp 109 | 110 | a 111 | 112 | b 113 | 114 | 115 | -------------------------------------------------------------------------------- /guides/geom_bar.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.mpg() 3 | |> Plot.new(%{x: "class"}) 4 | |> Plot.geom_bar() 5 | |> Plot.plot() 6 | 7 | ``` 8 | ![](assets/geom_bar_1.svg) 9 | ``` 10 | Examples.mpg() 11 | |> Plot.new(%{x: "class"}) 12 | |> Plot.geom_bar(%{fill: "drv"}) 13 | |> Plot.plot() 14 | 15 | ``` 16 | ![](assets/geom_bar_2.svg) 17 | -------------------------------------------------------------------------------- /guides/geom_boxplot.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.mpg() 3 | |> Plot.new(%{x: "class", y: "hwy"}) 4 | |> Plot.geom_boxplot() 5 | |> Plot.plot() 6 | 7 | ``` 8 | ![](assets/geom_boxplot_1.svg) 9 | ``` 10 | Examples.mpg() 11 | |> Plot.new(%{x: "class", y: "hwy"}) 12 | |> Plot.geom_boxplot(fill: "white", color: "#3366FF") 13 | |> Plot.plot() 14 | 15 | ``` 16 | ![](assets/geom_boxplot_2.svg) 17 | ``` 18 | Examples.mpg() 19 | |> Plot.new(%{x: "class", y: "hwy"}) 20 | |> Plot.geom_boxplot(outlier_color: "red") 21 | |> Plot.plot() 22 | 23 | ``` 24 | ![](assets/geom_boxplot_3.svg) 25 | ``` 26 | Examples.mpg() 27 | |> Plot.new(%{x: "class", y: "hwy"}) 28 | |> Plot.geom_boxplot(%{color: "drv"}) 29 | |> Plot.plot() 30 | 31 | ``` 32 | ![](assets/geom_boxplot_4.svg) 33 | -------------------------------------------------------------------------------- /guides/geom_line.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.economics() 3 | |> Plot.new(%{x: "date", y: "unemploy"}) 4 | |> Plot.geom_line() 5 | |> Plot.plot() 6 | 7 | ``` 8 | ![](assets/geom_line_1.svg) 9 | ``` 10 | Examples.economics_long() 11 | |> Plot.new(%{x: "date", y: "value01", color: "variable"}) 12 | |> Plot.geom_line() 13 | |> Plot.plot() 14 | 15 | ``` 16 | ![](assets/geom_line_2.svg) 17 | ``` 18 | Examples.economics() 19 | |> Plot.new(%{x: "date", y: "unemploy"}) 20 | |> Plot.geom_line(color: "red") 21 | |> Plot.plot() 22 | 23 | ``` 24 | ![](assets/geom_line_3.svg) 25 | -------------------------------------------------------------------------------- /guides/geom_point.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.mtcars() 3 | |> Plot.new(%{x: :wt, y: :mpg}) 4 | |> Plot.geom_point() 5 | |> Plot.plot() 6 | 7 | ``` 8 | ![](assets/geom_point_1.svg) 9 | ``` 10 | # Add aesthetic mapping to color 11 | Examples.mtcars() 12 | |> Plot.new(%{x: :wt, y: :mpg}) 13 | |> Plot.geom_point(%{color: :cyl}) 14 | |> Plot.plot() 15 | 16 | ``` 17 | ![](assets/geom_point_2.svg) 18 | ``` 19 | # Add aesthetic mapping to shape 20 | Examples.mtcars() 21 | |> Plot.new(%{x: :wt, y: :mpg}) 22 | |> Plot.geom_point(%{shape: :cyl}) 23 | |> Plot.plot() 24 | 25 | ``` 26 | ![](assets/geom_point_3.svg) 27 | ``` 28 | # Add aesthetic mapping to size (for circles, a bubble chart) 29 | Examples.mtcars() 30 | |> Plot.new(%{x: :wt, y: :mpg}) 31 | |> Plot.geom_point(%{size: :qsec}) 32 | |> Plot.plot() 33 | 34 | ``` 35 | ![](assets/geom_point_4.svg) 36 | ``` 37 | # Set aesthetics to fixed value 38 | Examples.mtcars() 39 | |> Plot.new(%{x: :wt, y: :mpg}) 40 | |> Plot.geom_point(color: "red", size: 5) 41 | |> Plot.plot() 42 | 43 | ``` 44 | ![](assets/geom_point_5.svg) 45 | -------------------------------------------------------------------------------- /guides/geom_text.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.mtcars() 3 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 4 | |> Plot.geom_text() 5 | |> Plot.plot() 6 | 7 | ``` 8 | ![](assets/geom_text_1.svg) 9 | ``` 10 | # Set the font size for the label 11 | Examples.mtcars() 12 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 13 | |> Plot.geom_text(size: 10) 14 | |> Plot.plot() 15 | 16 | ``` 17 | ![](assets/geom_text_2.svg) 18 | ``` 19 | # Shift positioning 20 | Examples.mtcars() 21 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 22 | |> Plot.geom_point(size: 2) 23 | |> Plot.geom_text(size: 5, hjust: :left, nudge_x: 3) 24 | |> Plot.plot() 25 | 26 | ``` 27 | ![](assets/geom_text_3.svg) 28 | ``` 29 | Examples.mtcars() 30 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 31 | |> Plot.geom_point(size: 2) 32 | |> Plot.geom_text(size: 5, vjust: :top, nudge_y: 3) 33 | |> Plot.plot() 34 | 35 | ``` 36 | ![](assets/geom_text_4.svg) 37 | ``` 38 | # Map other aesthetics 39 | Examples.mtcars() 40 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 41 | |> Plot.geom_text(%{color: :cyl}, size: 5) 42 | |> Plot.plot() 43 | 44 | ``` 45 | ![](assets/geom_text_5.svg) 46 | ``` 47 | # Add a text annotation 48 | Examples.mtcars() 49 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 50 | |> Plot.geom_text(size: 5) 51 | |> Plot.annotate(:text, label: "plot mpg vs. wt", x: 1.5, y: 15, size: 8, color: "red") 52 | |> Plot.plot() 53 | 54 | ``` 55 | ![](assets/geom_text_6.svg) 56 | ``` 57 | # Bar chart labelling 58 | [%{x: "1", y: 1, grp: "a"}, 59 | %{x: "1", y: 3, grp: "b"}, 60 | %{x: "2", y: 2, grp: "a"}, 61 | %{x: "2", y: 1, grp: "b"},] 62 | |> Plot.new(%{x: "x", y: "y", group: "grp"}) 63 | |> Plot.geom_col(%{fill: "grp"}, position: :dodge) 64 | |> Plot.geom_text(%{label: "y"}, position: :dodge, size: 6) 65 | |> Plot.plot() 66 | 67 | ``` 68 | ![](assets/geom_text_7.svg) 69 | ``` 70 | # Nudge the label up a bit 71 | [%{x: "1", y: 1, grp: "a"}, 72 | %{x: "1", y: 3, grp: "b"}, 73 | %{x: "2", y: 2, grp: "a"}, 74 | %{x: "2", y: 1, grp: "b"},] 75 | |> Plot.new(%{x: "x", y: "y", group: "grp"}) 76 | |> Plot.geom_col(%{fill: "grp"}, position: :dodge) 77 | |> Plot.geom_text(%{label: "y"}, position: :dodge, size: 6, nudge_y: 4) 78 | |> Plot.plot() 79 | 80 | ``` 81 | ![](assets/geom_text_8.svg) 82 | ``` 83 | # Position label in the middle of stacked bars 84 | [%{x: "1", y: 1, grp: "a"}, 85 | %{x: "1", y: 3, grp: "b"}, 86 | %{x: "2", y: 2, grp: "a"}, 87 | %{x: "2", y: 1, grp: "b"},] 88 | |> Plot.new(%{x: "x", y: "y", group: "grp"}) 89 | |> Plot.geom_col(%{fill: "grp"}) 90 | |> Plot.geom_text(%{label: "y"}, position: :stack, position_vjust: 0.5, size: 6) 91 | |> Plot.plot() 92 | 93 | ``` 94 | ![](assets/geom_text_9.svg) 95 | -------------------------------------------------------------------------------- /guides/scale_color_viridis.md: -------------------------------------------------------------------------------- 1 | ``` 2 | # The viridis scale is the default color scale 3 | Examples.diamonds() 4 | |> Explorer.DataFrame.sample(1000, seed: 100) 5 | |> Plot.new(%{x: "carat", y: "price"}) 6 | |> Plot.geom_point(%{color: "clarity"}) 7 | |> Plot.plot() 8 | 9 | ``` 10 | ![](assets/scale_color_viridis_1.svg) 11 | ``` 12 | # Use the :option option to select a palette 13 | cities = Explorer.Series.from_list([ 14 | "Houston", 15 | "Fort Worth", 16 | "San Antonio", 17 | "Dallas", 18 | "Austin" 19 | ]) 20 | 21 | Examples.tx_housing() 22 | |> Explorer.DataFrame.filter_with(&Explorer.Series.in(&1["city"], cities)) 23 | |> Plot.new(%{x: "sales", y: "median"}) 24 | |> Plot.geom_point(%{color: "city"}) 25 | |> Plot.scale_color_viridis(option: :plasma) 26 | |> Plot.plot() 27 | 28 | ``` 29 | ![](assets/scale_color_viridis_2.svg) 30 | ``` 31 | cities = Explorer.Series.from_list([ 32 | "Houston", 33 | "Fort Worth", 34 | "San Antonio", 35 | "Dallas", 36 | "Austin" 37 | ]) 38 | 39 | Examples.tx_housing() 40 | |> Explorer.DataFrame.filter_with(&Explorer.Series.in(&1["city"], cities)) 41 | |> Plot.new(%{x: "sales", y: "median"}) 42 | |> Plot.geom_point(%{color: "city"}) 43 | |> Plot.scale_color_viridis(option: :inferno) 44 | |> Plot.plot() 45 | 46 | ``` 47 | ![](assets/scale_color_viridis_3.svg) 48 | -------------------------------------------------------------------------------- /guides/theme.md: -------------------------------------------------------------------------------- 1 | ``` 2 | Examples.mtcars() 3 | |> Plot.new(%{x: :wt, y: :mpg}) 4 | |> Plot.geom_point() 5 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 6 | |> Plot.plot() 7 | 8 | ``` 9 | ![](assets/theme_1.svg) 10 | ``` 11 | # Examples below assume that element constructors are imported 12 | # e.g. `import GGity.Element.{Line, Rect, Text} 13 | 14 | # Plot formatting 15 | Examples.mtcars() 16 | |> Plot.new(%{x: :wt, y: :mpg}) 17 | |> Plot.geom_point() 18 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 19 | |> Plot.theme(plot_title: element_text(size: 10)) 20 | |> Plot.plot() 21 | 22 | ``` 23 | ![](assets/theme_2.svg) 24 | ``` 25 | Examples.mtcars() 26 | |> Plot.new(%{x: :wt, y: :mpg}) 27 | |> Plot.geom_point() 28 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 29 | |> Plot.theme(plot_background: element_rect(fill: "green")) 30 | |> Plot.plot() 31 | 32 | ``` 33 | ![](assets/theme_3.svg) 34 | ``` 35 | # Panel formatting 36 | Examples.mtcars() 37 | |> Plot.new(%{x: :wt, y: :mpg}) 38 | |> Plot.geom_point() 39 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 40 | |> Plot.theme(panel_background: element_rect(fill: "white", color: "grey")) 41 | |> Plot.plot() 42 | 43 | ``` 44 | ![](assets/theme_4.svg) 45 | ``` 46 | Examples.mtcars() 47 | |> Plot.new(%{x: :wt, y: :mpg}) 48 | |> Plot.geom_point() 49 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 50 | |> Plot.theme(panel_grid_major: element_line(color: "black")) 51 | |> Plot.plot() 52 | 53 | ``` 54 | ![](assets/theme_5.svg) 55 | ``` 56 | # Axis formatting 57 | Examples.mtcars() 58 | |> Plot.new(%{x: :wt, y: :mpg}) 59 | |> Plot.geom_point() 60 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 61 | |> Plot.theme(axis_line: element_line(size: 6, color: "grey")) 62 | |> Plot.plot() 63 | 64 | ``` 65 | ![](assets/theme_6.svg) 66 | ``` 67 | Examples.mtcars() 68 | |> Plot.new(%{x: :wt, y: :mpg}) 69 | |> Plot.geom_point() 70 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 71 | |> Plot.theme(axis_text: element_text(color: "blue")) 72 | |> Plot.plot() 73 | 74 | ``` 75 | ![](assets/theme_7.svg) 76 | ``` 77 | Examples.mtcars() 78 | |> Plot.new(%{x: :wt, y: :mpg}) 79 | |> Plot.geom_point() 80 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 81 | |> Plot.theme(axis_ticks: element_line(size: 4)) 82 | |> Plot.plot() 83 | 84 | ``` 85 | ![](assets/theme_8.svg) 86 | ``` 87 | # Turn the x-axis ticks inward 88 | Examples.mtcars() 89 | |> Plot.new(%{x: :wt, y: :mpg}) 90 | |> Plot.geom_point() 91 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 92 | |> Plot.theme(axis_ticks_length_x: -2) 93 | |> Plot.plot() 94 | 95 | ``` 96 | ![](assets/theme_9.svg) 97 | ``` 98 | # GGity does not support legend position, but legend key boxes 99 | # and text can be styled as you would expect 100 | 101 | # Default styling 102 | Examples.mtcars() 103 | |> Plot.new(%{x: :wt, y: :mpg}) 104 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 105 | |> Plot.labs( 106 | x: "Weight (1000 lbs)", 107 | y: "Fuel economy (mpg)", 108 | color: "Cylinders", 109 | shape: "Transmission" 110 | ) 111 | |> Plot.plot() 112 | 113 | ``` 114 | ![](assets/theme_10.svg) 115 | ``` 116 | # Style legend keys 117 | Examples.mtcars() 118 | |> Plot.new(%{x: :wt, y: :mpg}) 119 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 120 | |> Plot.labs( 121 | x: "Weight (1000 lbs)", 122 | y: "Fuel economy (mpg)", 123 | color: "Cylinders", 124 | shape: "Transmission" 125 | ) 126 | |> Plot.theme(legend_key: element_rect(fill: "white", color: "black")) 127 | |> Plot.plot() 128 | 129 | ``` 130 | ![](assets/theme_11.svg) 131 | ``` 132 | # Style legend text 133 | Examples.mtcars() 134 | |> Plot.new(%{x: :wt, y: :mpg}) 135 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 136 | |> Plot.labs( 137 | x: "Weight (1000 lbs)", 138 | y: "Fuel economy (mpg)", 139 | color: "Cylinders", 140 | shape: "Transmission" 141 | ) 142 | |> Plot.theme(legend_text: element_text(size: 4, color: "red")) 143 | |> Plot.plot() 144 | 145 | ``` 146 | ![](assets/theme_12.svg) 147 | ``` 148 | # Style legend title 149 | Examples.mtcars() 150 | |> Plot.new(%{x: :wt, y: :mpg}) 151 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 152 | |> Plot.labs( 153 | x: "Weight (1000 lbs)", 154 | y: "Fuel economy (mpg)", 155 | color: "Cylinders", 156 | shape: "Transmission" 157 | ) 158 | |> Plot.theme(legend_title: element_text(face: "bold")) 159 | |> Plot.plot() 160 | 161 | ``` 162 | ![](assets/theme_13.svg) 163 | -------------------------------------------------------------------------------- /lib/ggity.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity do 2 | @moduledoc false 3 | end 4 | -------------------------------------------------------------------------------- /lib/ggity/annotate.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Annotate do 2 | @moduledoc false 3 | 4 | alias GGity.{Geom, Layer} 5 | 6 | @type geom :: Geom.Rect.t() | Geom.Segment.t() | Geom.Text.t() 7 | 8 | @supported_geoms [:rect, :segment, :text] 9 | 10 | @required_parameters [ 11 | rect: [:xmin, :xmax, :ymin, :ymax], 12 | segment: [:x, :xend, :y, :yend], 13 | text: [:x, :y, :label] 14 | ] 15 | 16 | @geom_structs [ 17 | rect: %Geom.Rect{}, 18 | segment: %Geom.Segment{}, 19 | text: %Geom.Text{} 20 | ] 21 | 22 | @doc false 23 | @spec annotate(atom(), keyword()) :: geom() 24 | def annotate(geom_type, params) when geom_type in @supported_geoms do 25 | mapping = required_params_mapping(geom_type, params) 26 | options = construct_options(geom_type, params) 27 | Layer.new(@geom_structs[geom_type], mapping, options) 28 | end 29 | 30 | defp required_params_mapping(type, params) do 31 | for {key, _} <- params, key in required_parameters(type), do: {key, to_string(key)}, into: %{} 32 | end 33 | 34 | defp construct_options(type, params) do 35 | options = for {key, param} <- params, key not in required_parameters(type), do: {key, param} 36 | Keyword.put(options, :data, format_data(type, params)) 37 | end 38 | 39 | defp format_data(type, params) do 40 | row = 41 | for {key, _} <- params, 42 | key in required_parameters(type), 43 | do: {to_string(key), params[key]}, 44 | into: %{} 45 | 46 | Explorer.DataFrame.new([row]) 47 | end 48 | 49 | defp required_parameters(type), do: @required_parameters[type] 50 | end 51 | -------------------------------------------------------------------------------- /lib/ggity/color.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Color do 2 | @moduledoc false 3 | 4 | @valid_css_color_names [ 5 | "aliceblue", 6 | "antiquewhite", 7 | "aqua", 8 | "aquamarine", 9 | "azure", 10 | "beige", 11 | "bisque", 12 | "black", 13 | "blanchedalmond", 14 | "blue", 15 | "blueviolet", 16 | "brown", 17 | "burlywood", 18 | "cadetblue", 19 | "chartreuse", 20 | "chocolate", 21 | "coral", 22 | "cornflowerblue", 23 | "cornsilk", 24 | "crimson", 25 | "cyan", 26 | "darkblue", 27 | "darkcyan", 28 | "darkgoldenrod", 29 | "darkgray", 30 | "darkgrey", 31 | "darkgreen", 32 | "darkkhaki", 33 | "darkmagenta", 34 | "darkolivegreen", 35 | "darkorange", 36 | "darkorchid", 37 | "darkred", 38 | "darksalmon", 39 | "darkseagreen", 40 | "darkslateblue", 41 | "darkslategray", 42 | "darkslategrey", 43 | "darkturquoise", 44 | "darkviolet", 45 | "deeppink", 46 | "deepskyblue", 47 | "dimgray", 48 | "dimgrey", 49 | "dodgerblue", 50 | "firebrick", 51 | "floralwhite", 52 | "forestgreen", 53 | "fuchsia", 54 | "gainsboro", 55 | "ghostwhite", 56 | "gold", 57 | "goldenrod", 58 | "gray", 59 | "grey", 60 | "green", 61 | "greenyellow", 62 | "honeydew", 63 | "hotpink", 64 | "indianred", 65 | "indigo", 66 | "ivory", 67 | "khaki", 68 | "lavender", 69 | "lavenderblush", 70 | "lawngreen", 71 | "lemonchiffon", 72 | "lightblue", 73 | "lightcoral", 74 | "lightcyan", 75 | "lightgoldenrodyellow", 76 | "lightgray", 77 | "lightgrey", 78 | "lightgreen", 79 | "lightpink", 80 | "lightsalmon", 81 | "lightseagreen", 82 | "lightskyblue", 83 | "lightslategray", 84 | "lightslategrey", 85 | "lightsteelblue", 86 | "lightyellow", 87 | "lime", 88 | "limegreen", 89 | "linen", 90 | "magenta", 91 | "maroon", 92 | "mediumaquamarine", 93 | "mediumblue", 94 | "mediumorchid", 95 | "mediumpurple", 96 | "mediumseagreen", 97 | "mediumslateblue", 98 | "mediumspringgreen", 99 | "mediumturquoise", 100 | "mediumvioletred", 101 | "midnightblue", 102 | "mintcream", 103 | "mistyrose", 104 | "moccasin", 105 | "navajowhite", 106 | "navy", 107 | "oldlace", 108 | "olive", 109 | "olivedrab", 110 | "orange", 111 | "orangered", 112 | "orchid", 113 | "palegoldenrod", 114 | "palegreen", 115 | "paleturquoise", 116 | "palevioletred", 117 | "papayawhip", 118 | "peachpuff", 119 | "peru", 120 | "pink", 121 | "plum", 122 | "powderblue", 123 | "purple", 124 | "rebeccapurple", 125 | "red", 126 | "rosybrown", 127 | "royalblue", 128 | "saddlebrown", 129 | "salmon", 130 | "sandybrown", 131 | "seagreen", 132 | "seashell", 133 | "sienna", 134 | "silver", 135 | "skyblue", 136 | "slateblue", 137 | "slategray", 138 | "slategrey", 139 | "snow", 140 | "springgreen", 141 | "steelblue", 142 | "tan", 143 | "teal", 144 | "thistle", 145 | "tomato", 146 | "turquoise", 147 | "violet", 148 | "wheat", 149 | "white", 150 | "whitesmoke", 151 | "yellow", 152 | "yellowgreen" 153 | ] 154 | 155 | # TODO: write some tests for this 156 | @doc false 157 | @spec valid_color?(any()) :: boolean() 158 | def valid_color?(<>) when code not in @valid_css_color_names do 159 | String.match?(code, ~r/^[[:xdigit:]]{3}$/) 160 | end 161 | 162 | def valid_color?(<>) when code not in @valid_css_color_names do 163 | String.match?(code, ~r/^[[:xdigit:]]{6}$/) 164 | end 165 | 166 | def valid_color?("#" <> code) do 167 | valid_color?(code) 168 | end 169 | 170 | def valid_color?(color) when is_binary(color) do 171 | String.downcase(color) in @valid_css_color_names 172 | end 173 | 174 | def valid_color?(_color), do: false 175 | end 176 | -------------------------------------------------------------------------------- /lib/ggity/draw.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Draw do 2 | @moduledoc false 3 | 4 | alias GGity.HTML 5 | 6 | @type options() :: keyword() 7 | 8 | @spec svg(iolist(), options()) :: iolist() 9 | def svg(elements, options \\ []) do 10 | [ 11 | ~s|", 14 | "\n", 15 | elements, 16 | "" 17 | ] 18 | end 19 | 20 | @spec g(iolist(), options()) :: iolist() 21 | def g(elements, options) do 22 | attributes = options_to_attributes(options) 23 | 24 | [ 25 | "", 28 | "\n", 29 | elements, 30 | "", 31 | "\n" 32 | ] 33 | end 34 | 35 | @spec rect(options()) :: iolist() 36 | def rect(options) do 37 | [ 38 | "", 41 | "", 42 | "\n" 43 | ] 44 | end 45 | 46 | @spec line(options()) :: iolist() 47 | def line(coord_list) do 48 | [ 49 | "", 52 | "", 53 | "\n" 54 | ] 55 | end 56 | 57 | @spec text(binary(), options()) :: iolist() 58 | def text(text_element, options) do 59 | attributes = options_to_attributes(options) 60 | 61 | [ 62 | "", 65 | HTML.escape_to_iodata(text_element), 66 | "", 67 | "\n" 68 | ] 69 | end 70 | 71 | @spec circle({number(), number()}, number(), keyword()) :: iolist() 72 | def circle({x, y}, radius, options) do 73 | [ 74 | "", 86 | "", 87 | "\n" 88 | ] 89 | end 90 | 91 | @spec polygon(binary(), keyword()) :: iolist() 92 | def polygon(points, options) do 93 | [ 94 | "", 98 | "\n" 99 | ] 100 | end 101 | 102 | @spec polyline(list({number(), number()}), binary(), number(), number(), binary()) :: iolist() 103 | def polyline(coords, color, size, alpha, linetype) do 104 | coord_list = Enum.map(coords, fn {x, y} -> [to_string(x), ",", to_string(y), " "] end) 105 | 106 | [ 107 | "", 124 | "", 125 | "\n" 126 | ] 127 | end 128 | 129 | defp options_to_attributes(options) do 130 | Enum.map_join(options, " ", &option_to_attribute/1) 131 | end 132 | 133 | defp option_to_attribute({name, value}) do 134 | name = 135 | name 136 | |> Atom.to_string() 137 | |> String.replace("_", "-") 138 | 139 | [ 140 | name, 141 | "=\"", 142 | HTML.escape_to_iodata(to_string(value)), 143 | "\"" 144 | ] 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/ggity/element.ex: -------------------------------------------------------------------------------- 1 | defprotocol GGity.Element do 2 | @moduledoc false 3 | 4 | @spec to_css(GGity.Element.t(), binary()) :: iolist() 5 | def to_css(element, class) 6 | end 7 | 8 | defimpl GGity.Element, for: Atom do 9 | def to_css(nil, _class), do: [] 10 | end 11 | 12 | defimpl GGity.Element, for: Integer do 13 | def to_css(_element, _class), do: [] 14 | end 15 | 16 | defimpl GGity.Element, for: Any do 17 | def to_css(%element_type{} = element, class) do 18 | [".", class, " {", apply(element_type, :attributes_for, [element]), "}"] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/ggity/element/line.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Element.Line do 2 | @moduledoc """ 3 | Defines the data and functions used to style non-geom line elements. 4 | 5 | CSS presentation attributes: 6 | * `:color` sets value of CSS `stroke` 7 | * `:size` sets value of CSS `stroke-width` 8 | """ 9 | 10 | import GGity.Color, only: [valid_color?: 1] 11 | alias GGity.Element 12 | 13 | @derive [Element] 14 | defstruct [ 15 | :color, 16 | :size 17 | # :linetype, 18 | # :lineend, 19 | # :arrow 20 | ] 21 | 22 | @type t() :: %__MODULE__{} 23 | 24 | @doc """ 25 | Constructor for a Line element. 26 | 27 | Setting the value of an attributed to `nil` will remove that property 28 | from the generated stylesheet altogether. 29 | 30 | Calling `element_line(attributes)` is equivalent to `struct(GGity.Element.Line, attributes)`. 31 | """ 32 | @spec element_line(keyword()) :: Element.Line.t() 33 | def element_line(attributes) do 34 | struct(Element.Line, attributes) 35 | end 36 | 37 | @doc false 38 | @spec attributes_for(Element.Line.t()) :: iolist() 39 | def attributes_for(element) do 40 | element 41 | |> Map.from_struct() 42 | |> Enum.map(&attribute_for/1) 43 | end 44 | 45 | defp attribute_for({_attribute, nil}), do: [] 46 | 47 | defp attribute_for({:color, value}) do 48 | if valid_color?(value), do: ["stroke: ", value, ";"], else: [] 49 | end 50 | 51 | defp attribute_for({:size, value}) when is_number(value) do 52 | ["stroke-width: ", to_string(value), ";"] 53 | end 54 | 55 | defp attribute_for({:size, _value}), do: [] 56 | 57 | defp attribute_for(_element), do: [] 58 | end 59 | -------------------------------------------------------------------------------- /lib/ggity/element/rect.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Element.Rect do 2 | @moduledoc """ 3 | Defines the data and functions used to style non-geom rect elements. 4 | 5 | CSS presentation attributes: 6 | * `:fill` - string: sets value of CSS `fill` 7 | 8 | Values must be valid CSS color names or hex values. 9 | 10 | * `:color` - string: sets value of CSS `stroke` 11 | 12 | Values must be valid CSS color names or hex values. 13 | 14 | * `:size` - number: sets value of CSS `stroke-width` 15 | 16 | 17 | Other attributes: 18 | * `:height` - number: sets value of SVG `height` (height of the key glyph box) 19 | """ 20 | 21 | import GGity.Color, only: [valid_color?: 1] 22 | alias GGity.Element 23 | 24 | @derive [Element] 25 | defstruct [ 26 | :fill, 27 | :color, 28 | :size, 29 | # :linetype, 30 | :height 31 | ] 32 | 33 | @type t() :: %__MODULE__{} 34 | 35 | @doc """ 36 | Constructor for a Rect element. 37 | 38 | Setting the value of an attributed to `nil` will remove that property 39 | from the generated stylesheet altogether. 40 | 41 | Calling `element_rect(attributes)` is equivalent to `struct(GGity.Element.Line, attributes)`. 42 | """ 43 | @spec element_rect(keyword()) :: Element.Line.t() 44 | def element_rect(attributes) do 45 | struct(Element.Rect, attributes) 46 | end 47 | 48 | @doc false 49 | @spec attributes_for(Element.Rect.t()) :: iolist() 50 | def attributes_for(element) do 51 | element 52 | |> Map.from_struct() 53 | |> Enum.map(&attribute_for/1) 54 | end 55 | 56 | defp attribute_for({_attribute, nil}), do: [] 57 | 58 | defp attribute_for({:fill, value}) do 59 | if valid_color?(value), do: ["fill: ", value, ";"], else: [] 60 | end 61 | 62 | defp attribute_for({:color, value}) do 63 | if valid_color?(value), do: ["stroke: ", value, ";"], else: [] 64 | end 65 | 66 | defp attribute_for({:size, value}) when is_number(value) do 67 | ["stroke-width: ", to_string(value), ";"] 68 | end 69 | 70 | defp attribute_for(_element), do: [] 71 | end 72 | -------------------------------------------------------------------------------- /lib/ggity/element/text.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Element.Text do 2 | @moduledoc """ 3 | Defines the data and functions used to style non-geom text elements. 4 | 5 | CSS presentation attributes: 6 | * `:family` - string: sets value of CSS `font-family` 7 | 8 | * `:face` - string or integer: sets value of CSS `font-weight` 9 | 10 | Valid values: 11 | * `"normal"` 12 | * `"bold"` 13 | * `"bolder"` 14 | * `"lighter"` 15 | * `"initial"` 16 | * `"inherit"` 17 | * A multiple of 100 between 100 and 900 18 | 19 | * `:color` - string: sets value of CSS `fill` 20 | 21 | Values must be valid CSS color names or hex values. 22 | 23 | * `:size` - number: sets value of CSS `font-size` in pixels 24 | 25 | Other attributes: 26 | * `:angle` - number (between 0 and 90): sets the value passed to 27 | `transform: rotate()` for the purpose of rotating x axis tick 28 | labels (has no effect when set for other theme elements) 29 | """ 30 | 31 | import GGity.Color, only: [valid_color?: 1] 32 | alias GGity.{Element, HTML} 33 | 34 | @valid_font_weights List.flatten([ 35 | "normal", 36 | "bold", 37 | "bolder", 38 | "lighter", 39 | "initial", 40 | "inherit", 41 | Enum.map(1..9, fn number -> [number * 100, to_string(number * 100)] end) 42 | ]) 43 | 44 | @derive [Element] 45 | defstruct [ 46 | :family, 47 | :face, 48 | :color, 49 | :size, 50 | :angle 51 | # :hjust, 52 | # :vjust, 53 | # :lineheight, 54 | # :margin 55 | ] 56 | 57 | @type t() :: %__MODULE__{} 58 | 59 | @doc """ 60 | Constructor for a Text element. 61 | 62 | Setting the value of an attributed to `nil` will remove that property 63 | from the generated stylesheet altogether. 64 | 65 | Calling `element_text(attributes)` is equivalent to `struct(GGity.Element.Text, attributes)`. 66 | """ 67 | @spec element_text(keyword()) :: Element.Text.t() 68 | def element_text(attributes) do 69 | struct(Element.Text, attributes) 70 | end 71 | 72 | @doc false 73 | @spec attributes_for(Element.Text.t()) :: iolist() 74 | def attributes_for(element) do 75 | element 76 | |> Map.from_struct() 77 | |> Enum.map(&attribute_for/1) 78 | end 79 | 80 | defp attribute_for({_attribute, nil}), do: [] 81 | 82 | defp attribute_for({:family, value}) do 83 | ["font-family: ", HTML.escape_to_iodata(value), ";"] 84 | end 85 | 86 | defp attribute_for({:face, value}) when value in @valid_font_weights do 87 | ["font-weight: ", value, ";"] 88 | end 89 | 90 | defp attribute_for({:color, value}) when is_binary(value) do 91 | if valid_color?(value), do: ["fill: ", value, ";"] 92 | end 93 | 94 | defp attribute_for({:size, value}) when is_number(value) do 95 | ["font-size: ", to_string(value), "px;"] 96 | end 97 | 98 | defp attribute_for(_element), do: [] 99 | end 100 | -------------------------------------------------------------------------------- /lib/ggity/geom/bar.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Bar do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Plot} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type record() :: map() 8 | @type mapping() :: map() 9 | 10 | defstruct data: nil, 11 | mapping: nil, 12 | stat: :count, 13 | position: :stack, 14 | key_glyph: :rect, 15 | fill: "black", 16 | alpha: 1, 17 | bar_group_width: nil, 18 | custom_attributes: nil 19 | 20 | @spec new(mapping(), keyword()) :: Geom.Bar.t() 21 | def new(mapping, options \\ []) do 22 | struct(Geom.Bar, [{:mapping, mapping} | options]) 23 | end 24 | 25 | @spec draw(Geom.Bar.t(), list(map()), Plot.t()) :: iolist() 26 | def draw(%Geom.Bar{} = geom_bar, data, plot) do 27 | number_of_levels = length(plot.scales.x.levels) 28 | group_width = (plot.width - number_of_levels * (plot.scales.x.padding - 1)) / number_of_levels 29 | geom_bar = struct(geom_bar, bar_group_width: group_width) 30 | bars(geom_bar, data, plot) 31 | end 32 | 33 | defp bars(%Geom.Bar{} = geom_bar, data, plot) do 34 | data 35 | |> Enum.reject(fn row -> row[geom_bar.mapping[:y]] == 0 end) 36 | |> Enum.group_by(fn row -> row[geom_bar.mapping[:x]] end) 37 | |> Enum.with_index(fn {_x_value, group}, group_index -> 38 | bar_group(geom_bar, group, group_index, plot) 39 | end) 40 | end 41 | 42 | defp bar_group(geom_bar, group_values, group_index, %Plot{scales: scales} = plot) do 43 | transforms = transforms(geom_bar, scales) 44 | count_rows = length(group_values) 45 | 46 | sort_order = 47 | case geom_bar.position do 48 | :stack -> :desc 49 | :dodge -> :asc 50 | _unknown_adjustment -> :asc 51 | end 52 | 53 | group_values 54 | |> Enum.sort_by( 55 | fn row -> {row[geom_bar.mapping[:fill]], row[geom_bar.mapping[:alpha]]} end, 56 | sort_order 57 | ) 58 | |> Enum.reduce({0, 0, []}, fn row, {total_width, total_height, rects} -> 59 | custom_attributes = GGity.Layer.custom_attributes(geom_bar, plot, row) 60 | 61 | { 62 | total_width + geom_bar.bar_group_width / count_rows, 63 | total_height + 64 | transforms.y.(row[geom_bar.mapping[:y]]) / plot.aspect_ratio, 65 | [ 66 | Draw.rect( 67 | [ 68 | x: position_adjust_x(geom_bar, row, group_index, total_width, plot), 69 | y: 70 | plot.area_padding + plot.width / plot.aspect_ratio - 71 | position_adjust_y(geom_bar, row, total_height, plot), 72 | width: position_adjust_bar_width(geom_bar, count_rows), 73 | height: transforms.y.(row[geom_bar.mapping[:y]]) / plot.aspect_ratio, 74 | fill: transforms.fill.(row[geom_bar.mapping[:fill]]), 75 | fill_opacity: transforms.alpha.(row[geom_bar.mapping[:alpha]]) 76 | ] ++ 77 | custom_attributes 78 | ) 79 | | rects 80 | ] 81 | } 82 | end) 83 | |> elem(2) 84 | end 85 | 86 | defp transforms(geom_bar, scales) do 87 | scale_transforms = 88 | geom_bar.mapping 89 | |> Map.keys() 90 | |> Enum.reduce(%{}, fn aesthetic, mapped -> 91 | Map.put(mapped, aesthetic, Map.get(scales[aesthetic], :transform)) 92 | end) 93 | 94 | geom_bar 95 | |> Map.take([:alpha, :fill]) 96 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 97 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 98 | end) 99 | |> Map.merge(scale_transforms) 100 | end 101 | 102 | defp position_adjust_x( 103 | %Geom.Bar{position: :stack} = geom_bar, 104 | _row, 105 | group_index, 106 | _total_width, 107 | plot 108 | ) do 109 | plot.area_padding + group_index * (geom_bar.bar_group_width + plot.scales.x.padding) 110 | end 111 | 112 | defp position_adjust_x( 113 | %Geom.Bar{position: :dodge} = geom_bar, 114 | _row, 115 | group_index, 116 | total_width, 117 | plot 118 | ) do 119 | plot.area_padding + group_index * (geom_bar.bar_group_width + plot.scales.x.padding) + 120 | total_width 121 | end 122 | 123 | defp position_adjust_y(%Geom.Bar{position: :stack} = geom_bar, row, total_height, plot) do 124 | total_height + plot.scales.y.transform.(row[geom_bar.mapping[:y]]) / plot.aspect_ratio 125 | end 126 | 127 | defp position_adjust_y(%Geom.Bar{position: :dodge} = geom_bar, row, _total_height, plot) do 128 | plot.scales.y.transform.(row[geom_bar.mapping[:y]]) / plot.aspect_ratio 129 | end 130 | 131 | defp position_adjust_bar_width(%Geom.Bar{position: :stack} = geom_bar, _count_rows) do 132 | geom_bar.bar_group_width 133 | end 134 | 135 | defp position_adjust_bar_width(%Geom.Bar{position: :dodge} = geom_bar, count_rows) do 136 | geom_bar.bar_group_width / count_rows 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/ggity/geom/blank.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Blank do 2 | @moduledoc false 3 | 4 | defstruct data: nil, 5 | mapping: nil, 6 | position: :identity, 7 | stat: :identity, 8 | labels: %{} 9 | 10 | @spec draw() :: iolist() 11 | def draw, do: [] 12 | end 13 | -------------------------------------------------------------------------------- /lib/ggity/geom/boxplot.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Boxplot do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Plot, Shapes} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type record() :: map() 8 | @type mapping() :: map() 9 | 10 | defstruct data: nil, 11 | mapping: nil, 12 | stat: :boxplot, 13 | position: :dodge, 14 | key_glyph: :boxplot, 15 | outlier_color: nil, 16 | outlier_fill: nil, 17 | outlier_shape: :circle, 18 | outlier_size: 5, 19 | color: "black", 20 | fill: "white", 21 | alpha: 1, 22 | box_group_width: nil, 23 | custom_attributes: nil 24 | 25 | @spec new(mapping(), keyword()) :: Geom.Boxplot.t() 26 | def new(mapping, options \\ []) do 27 | struct(Geom.Boxplot, [{:mapping, mapping} | options]) 28 | end 29 | 30 | @spec draw(Geom.Boxplot.t(), list(map()), Plot.t()) :: iolist 31 | def draw(%Geom.Boxplot{} = geom_boxplot, data, plot) do 32 | number_of_levels = length(plot.scales.x.levels) 33 | group_width = (plot.width - number_of_levels * (plot.scales.x.padding - 1)) / number_of_levels 34 | geom_boxplot = struct(geom_boxplot, box_group_width: group_width) 35 | boxplots(geom_boxplot, data, plot) 36 | end 37 | 38 | defp boxplots(%Geom.Boxplot{} = geom_boxplot, data, plot) do 39 | data 40 | |> Enum.group_by(fn row -> row[geom_boxplot.mapping[:x]] end) 41 | |> Enum.with_index(fn {_x_value, group_values}, group_index -> 42 | boxplot_group(geom_boxplot, group_values, group_index, plot) 43 | end) 44 | end 45 | 46 | defp boxplot_group(geom_boxplot, group_values, group_index, %Plot{scales: scales} = plot) do 47 | mapping = geom_boxplot.mapping 48 | scale_transforms = fetch_scale_transforms(mapping, scales) 49 | fixed_aesthetics = fetch_fixed_aesthetics(geom_boxplot) 50 | transforms = Map.merge(fixed_aesthetics, scale_transforms) 51 | 52 | count_rows = length(group_values) 53 | 54 | group_values 55 | |> Enum.sort_by( 56 | fn row -> 57 | {row[mapping[:fill]], row[mapping[:color]], row[mapping[:alpha]]} 58 | end, 59 | :asc 60 | ) 61 | |> Enum.reduce({0, []}, fn row, {total_width, rects} -> 62 | box_left = position_adjust_x(geom_boxplot, row, group_index, total_width, plot) 63 | box_width = position_adjust_bar_width(geom_boxplot, count_rows) 64 | box_right = box_left + box_width 65 | box_middle = box_left + box_width / 2 66 | 67 | { 68 | total_width + geom_boxplot.box_group_width / count_rows, 69 | [ 70 | Draw.rect( 71 | [ 72 | x: box_left, 73 | y: 74 | plot.area_padding + plot.width / plot.aspect_ratio - 75 | position_adjust_y(row, plot), 76 | width: box_width, 77 | height: 78 | (transforms.y.(row["upper"]) - transforms.y.(row["lower"])) / plot.aspect_ratio, 79 | fill: transforms.fill.(row[mapping[:fill]]), 80 | fill_opacity: transforms.alpha.(row[mapping[:alpha]]), 81 | stroke: transforms.color.(row[mapping[:color]]), 82 | stroke_width: 0.5 83 | ] ++ GGity.Layer.custom_attributes(geom_boxplot, plot, row) 84 | ), 85 | draw_median(box_left, box_right, row, transforms, mapping, plot), 86 | draw_top_whisker(box_middle, row, transforms, mapping, plot), 87 | draw_bottom_whisker(box_middle, row, transforms, mapping, plot), 88 | for outlier <- :erlang.binary_to_term(row["outliers"]) do 89 | draw_outlier(outlier, box_middle, row, geom_boxplot, transforms, plot) 90 | end 91 | | rects 92 | ] 93 | } 94 | end) 95 | |> elem(1) 96 | end 97 | 98 | defp fetch_scale_transforms(mapping, scales) do 99 | for aes <- Map.keys(mapping), reduce: %{} do 100 | scale_transforms -> Map.put(scale_transforms, aes, scales[aes].transform) 101 | end 102 | end 103 | 104 | defp fetch_fixed_aesthetics(geom_boxplot) do 105 | geom_boxplot 106 | |> Map.take([:alpha, :color, :fill]) 107 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 108 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 109 | end) 110 | end 111 | 112 | defp draw_median(box_left, box_right, row, transforms, mapping, plot) do 113 | Draw.line( 114 | x1: box_left, 115 | x2: box_right, 116 | y1: transform_and_adjust_y(row, "middle", plot), 117 | y2: transform_and_adjust_y(row, "middle", plot), 118 | stroke: transforms.color.(row[mapping[:color]]) 119 | ) 120 | end 121 | 122 | defp draw_top_whisker(box_middle, row, transforms, mapping, plot) do 123 | Draw.line( 124 | x1: box_middle, 125 | x2: box_middle, 126 | y1: transform_and_adjust_y(row, "upper", plot), 127 | y2: transform_and_adjust_y(row, "ymax", plot), 128 | stroke: transforms.color.(row[mapping[:color]]), 129 | stroke_width: 0.5 130 | ) 131 | end 132 | 133 | defp draw_bottom_whisker(box_middle, row, transforms, mapping, plot) do 134 | Draw.line( 135 | x1: box_middle, 136 | x2: box_middle, 137 | y1: transform_and_adjust_y(row, "lower", plot), 138 | y2: transform_and_adjust_y(row, "ymin", plot), 139 | stroke: transforms.color.(row[mapping[:color]]), 140 | stroke_width: 0.5 141 | ) 142 | end 143 | 144 | defp transform_and_adjust_y(row, aes, plot) do 145 | plot.area_padding + plot.width / plot.aspect_ratio - 146 | plot.scales.y.transform.(row[aes]) / plot.aspect_ratio 147 | end 148 | 149 | defp draw_outlier(value, box_middle, row, geom_boxplot, transforms, plot) do 150 | y_coord = plot.area_padding + (200 - plot.scales.y.transform.(value)) / plot.aspect_ratio 151 | fill = geom_boxplot.outlier_fill || transforms.color.(row[geom_boxplot.mapping[:color]]) 152 | color = geom_boxplot.outlier_color || transforms.color.(row[geom_boxplot.mapping[:color]]) 153 | 154 | # This will break when we have fillable shapes 155 | case geom_boxplot.outlier_shape do 156 | :na -> 157 | [] 158 | 159 | :circle -> 160 | Shapes.draw(:circle, {box_middle, y_coord}, :math.pow(geom_boxplot.outlier_size, 2), 161 | fill: fill, 162 | color: color 163 | ) 164 | 165 | shape -> 166 | Shapes.draw(shape, {box_middle, y_coord}, :math.pow(geom_boxplot.outlier_size, 2), 167 | fill: fill, 168 | color: color 169 | ) 170 | end 171 | end 172 | 173 | defp position_adjust_x( 174 | %Geom.Boxplot{position: :dodge} = geom_boxplot, 175 | _row, 176 | group_index, 177 | total_width, 178 | plot 179 | ) do 180 | plot.area_padding + group_index * (geom_boxplot.box_group_width + plot.scales.x.padding) + 181 | total_width 182 | end 183 | 184 | defp position_adjust_y(row, plot) do 185 | plot.scales.y.transform.(row["upper"]) / plot.aspect_ratio 186 | end 187 | 188 | defp position_adjust_bar_width(%Geom.Boxplot{position: :dodge} = geom_box, count_rows) do 189 | geom_box.box_group_width / count_rows 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/ggity/geom/line.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Line do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Plot} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type plot() :: %GGity.Plot{} 8 | @type record() :: map() 9 | @type mapping() :: map() 10 | 11 | @linetype_specs %{ 12 | solid: "", 13 | dashed: "4", 14 | dotted: "1", 15 | longdash: "6 2", 16 | dotdash: "1 2 3 2", 17 | twodash: "2 2 6 2" 18 | } 19 | 20 | defstruct data: nil, 21 | mapping: nil, 22 | stat: :identity, 23 | position: :identity, 24 | key_glyph: :path, 25 | alpha: 1, 26 | color: "black", 27 | linetype: "", 28 | size: 1 29 | 30 | @spec new(mapping(), keyword()) :: Geom.Line.t() 31 | def new(mapping, options \\ []) do 32 | linetype_name = Keyword.get(options, :linetype, :solid) 33 | 34 | options = 35 | options 36 | |> Keyword.drop([:linetype]) 37 | |> Keyword.merge(mapping: mapping, linetype: @linetype_specs[linetype_name]) 38 | 39 | struct(Geom.Line, options) 40 | end 41 | 42 | @spec draw(Geom.Line.t(), list(map()), plot()) :: iolist() 43 | def draw(%Geom.Line{} = geom_line, _data, plot), do: lines(geom_line, plot) 44 | 45 | defp lines(%Geom.Line{} = geom_line, %Plot{} = plot) do 46 | scale_transforms = scale_transforms_for(geom_line, plot.scales) 47 | 48 | transforms = 49 | scale_transforms 50 | |> Map.put(:x, fn row -> 51 | transform_and_pad_x(row, scale_transforms.x, geom_line.mapping[:x], plot.area_padding) 52 | end) 53 | |> Map.put(:y, fn row -> 54 | transform_and_pad_y( 55 | row, 56 | scale_transforms.y, 57 | geom_line.mapping[:y], 58 | plot.area_padding, 59 | plot.aspect_ratio, 60 | plot.width 61 | ) 62 | end) 63 | 64 | (geom_line.data || plot.data) 65 | |> Enum.group_by(fn row -> 66 | %{ 67 | alpha: row[geom_line.mapping[:alpha]], 68 | color: row[geom_line.mapping[:color]], 69 | linetype: row[geom_line.mapping[:linetype]], 70 | size: row[geom_line.mapping[:size]] 71 | } 72 | end) 73 | |> Enum.map(fn {values, group} -> line(values, group, transforms) end) 74 | end 75 | 76 | defp scale_transforms_for(geom_line, scales) do 77 | scale_transforms = fetch_scale_transforms(geom_line.mapping, scales) 78 | fixed_aesthetics = fetch_fixed_aesthetics(geom_line) 79 | Map.merge(fixed_aesthetics, scale_transforms) 80 | end 81 | 82 | defp fetch_scale_transforms(mapping, scales) do 83 | for aes <- Map.keys(mapping), aes in [:x, :y, :color, :linetype, :size], reduce: %{} do 84 | scale_transforms -> Map.put(scale_transforms, aes, scales[aes].transform) 85 | end 86 | end 87 | 88 | defp fetch_fixed_aesthetics(geom_line) do 89 | geom_line 90 | |> Map.take([:alpha, :color, :linetype, :size]) 91 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 92 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 93 | end) 94 | end 95 | 96 | defp transform_and_pad_x(row, x_transform, x_mapping, area_padding) do 97 | x_mapping = to_string(x_mapping) 98 | x_transform.(row[x_mapping]) + area_padding 99 | end 100 | 101 | defp transform_and_pad_y(row, y_transform, y_mapping, area_padding, aspect_ratio, width) do 102 | y_mapping = to_string(y_mapping) 103 | (width - y_transform.(row[y_mapping])) / aspect_ratio + area_padding 104 | end 105 | 106 | defp line(values, data, transforms) do 107 | coords = 108 | data 109 | |> Enum.map(fn row -> {transforms.x.(row), transforms.y.(row)} end) 110 | |> Enum.sort_by(fn {x, _y} -> x end) 111 | 112 | [alpha, color, linetype, size] = [ 113 | transforms.alpha.(values.alpha), 114 | transforms.color.(values.color), 115 | transforms.linetype.(values.linetype), 116 | transforms.size.(values.size) 117 | ] 118 | 119 | Draw.polyline(coords, color, size, alpha, linetype) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/ggity/geom/point.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Point do 2 | @moduledoc false 3 | 4 | alias GGity.{Geom, Plot} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type plot() :: %Plot{} 8 | @type record() :: map() 9 | @type mapping() :: map() 10 | 11 | defstruct data: nil, 12 | mapping: nil, 13 | stat: :identity, 14 | position: :identity, 15 | key_glyph: :point, 16 | alpha: 1, 17 | color: "black", 18 | shape: :circle, 19 | size: 6, 20 | custom_attributes: nil 21 | 22 | @spec new(mapping(), keyword()) :: Geom.Point.t() 23 | def new(mapping, options) do 24 | struct(Geom.Point, [{:mapping, mapping} | options]) 25 | end 26 | 27 | @spec draw(Geom.Point.t(), list(map()), plot()) :: iolist() 28 | def draw(%Geom.Point{} = geom_point, data, plot), do: points(geom_point, data, plot) 29 | 30 | defp points(%Geom.Point{} = geom_point, data, %Plot{scales: scales} = plot) do 31 | scale_transforms = fetch_scale_transforms(geom_point.mapping, scales) 32 | fixed_aesthetics = fetch_fixed_aesthetics(geom_point) 33 | all_transforms = Map.merge(fixed_aesthetics, scale_transforms) 34 | 35 | Enum.map(data, fn row -> point(row, all_transforms, geom_point, plot) end) 36 | end 37 | 38 | defp point(row, transforms, geom_point, plot) do 39 | mapping = 40 | Map.new( 41 | geom_point.mapping, 42 | fn {k, v} -> 43 | if is_atom(v) do 44 | {k, to_string(v)} 45 | else 46 | {k, v} 47 | end 48 | end 49 | ) 50 | 51 | [x, y, alpha, color, shape, size] = [ 52 | transforms.x.(row[mapping.x]), 53 | transforms.y.(row[mapping.y]), 54 | transforms.alpha.(row[mapping[:alpha]]), 55 | transforms.color.(row[mapping[:color]]), 56 | transforms.shape.(row[mapping[:shape]]), 57 | transforms.size.(row[mapping[:size]]) 58 | ] 59 | 60 | adjusted_x = x + plot.area_padding 61 | adjusted_y = (plot.width - y) / plot.aspect_ratio + plot.area_padding 62 | 63 | custom_attributes = GGity.Layer.custom_attributes(geom_point, plot, row) 64 | 65 | options = [{:color, color}, {:fill_opacity, alpha} | custom_attributes] 66 | 67 | GGity.Shapes.draw(shape, {adjusted_x, adjusted_y}, size, options) 68 | end 69 | 70 | defp fetch_scale_transforms(mapping, scales) do 71 | mapping 72 | |> Map.keys() 73 | |> Map.new(fn aesthetic -> {aesthetic, scales[aesthetic].transform} end) 74 | end 75 | 76 | defp fetch_fixed_aesthetics(geom_point) do 77 | geom_point 78 | |> Map.take([:alpha, :color, :shape, :size]) 79 | |> Enum.reduce(%{}, fn 80 | {:size, fixed_value}, fixed -> 81 | Map.put(fixed, :size, fn _value -> :math.pow(fixed_value, 2) end) 82 | 83 | {aesthetic, fixed_value}, fixed -> 84 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 85 | end) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/ggity/geom/rect.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Rect do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Plot} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type plot() :: %Plot{} 8 | @type record() :: map() 9 | @type mapping() :: map() 10 | 11 | defstruct data: nil, 12 | mapping: nil, 13 | stat: :identity, 14 | position: :identity, 15 | key_glyph: :rect, 16 | alpha: 1, 17 | fill: "black", 18 | color: "black", 19 | size: 0, 20 | custom_attributes: nil 21 | 22 | @spec new(mapping(), keyword()) :: Geom.Rect.t() 23 | def new(mapping, options) do 24 | struct(Geom.Rect, [{:mapping, mapping} | options]) 25 | end 26 | 27 | @spec draw(Geom.Rect.t(), list(map()), plot()) :: iolist() 28 | def draw(%Geom.Rect{} = geom_rect, data, plot), do: rects(geom_rect, data, plot) 29 | 30 | defp rects(%Geom.Rect{} = geom_rect, data, %Plot{scales: scales} = plot) do 31 | scale_transforms = fetch_scale_transforms(geom_rect.mapping, scales) 32 | fixed_aesthetics = fetch_fixed_aesthetics(geom_rect) 33 | transforms = Map.merge(fixed_aesthetics, scale_transforms) 34 | 35 | Enum.map(data, fn row -> rect(row, transforms, geom_rect, plot) end) 36 | end 37 | 38 | defp fetch_scale_transforms(mapping, scales) do 39 | for aes <- Map.keys(mapping), reduce: %{} do 40 | scale_transforms -> Map.put(scale_transforms, aes, scales[aes].transform) 41 | end 42 | end 43 | 44 | defp fetch_fixed_aesthetics(geom_rect) do 45 | geom_rect 46 | |> Map.take([:alpha, :color, :fill, :size]) 47 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 48 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 49 | end) 50 | end 51 | 52 | defp rect(row, transforms, geom_rect, plot) do 53 | mapping = geom_rect.mapping 54 | 55 | attributes = [ 56 | x: transforms.x.(row[mapping.xmin]) + plot.area_padding, 57 | y: (plot.width - transforms.y.(row[mapping.ymax])) / plot.aspect_ratio + plot.area_padding, 58 | height: 59 | (transforms.y.(row[mapping.ymax]) - transforms.y.(row[geom_rect.mapping.ymin])) / 60 | plot.aspect_ratio, 61 | width: transforms.x.(row[mapping.xmax]) - transforms.x.(row[mapping.xmin]), 62 | fill_opacity: transforms.alpha.(row[geom_rect.mapping[:alpha]]), 63 | stroke: transforms.color.(row[geom_rect.mapping[:color]]), 64 | fill: transforms.fill.(row[geom_rect.mapping[:fill]]), 65 | stroke_width: transforms.size.(row[geom_rect.mapping[:size]]) 66 | ] 67 | 68 | geom_rect 69 | |> GGity.Layer.custom_attributes(plot, row) 70 | |> Keyword.merge(attributes) 71 | |> Draw.rect() 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/ggity/geom/ribbon.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Ribbon do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Plot} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type record() :: map() 8 | @type mapping() :: map() 9 | 10 | defstruct data: nil, 11 | mapping: nil, 12 | stat: :identity, 13 | position: :identity, 14 | key_glyph: :rect, 15 | fill: "black", 16 | alpha: 1, 17 | color: nil, 18 | size: nil 19 | 20 | @spec new(mapping(), keyword()) :: Geom.Ribbon.t() 21 | def new(mapping, options \\ []) do 22 | struct(Geom.Ribbon, [{:mapping, mapping} | options]) 23 | end 24 | 25 | @spec draw(Geom.Ribbon.t(), list(map()), Plot.t()) :: iolist() 26 | def draw(%Geom.Ribbon{} = geom_ribbon, _data, plot) do 27 | ribbons(geom_ribbon, plot) 28 | end 29 | 30 | defp ribbons(%Geom.Ribbon{position: :stack} = geom_ribbon, plot) do 31 | ribbons = 32 | geom_ribbon 33 | |> group_by_aesthetics(plot) 34 | |> Enum.sort_by(fn {value, _group} -> value end, :desc) 35 | |> Enum.map(fn {_value, group} -> ribbon(geom_ribbon, group, plot) end) 36 | 37 | plot_height = plot.width / plot.aspect_ratio 38 | 39 | ribbons 40 | |> Enum.map(fn group -> group.coords end) 41 | |> stack_coordinates(plot_height) 42 | |> Enum.zip_with(ribbons, fn stacked_coords, ribbon -> 43 | draw_ribbon(Map.put(ribbon, :coords, stacked_coords), plot.area_padding) 44 | end) 45 | end 46 | 47 | defp ribbons(%Geom.Ribbon{} = geom_ribbon, plot) do 48 | geom_ribbon 49 | |> group_by_aesthetics(plot) 50 | |> Enum.map(fn {_value, group} -> 51 | geom_ribbon 52 | |> ribbon(group, plot) 53 | |> draw_ribbon(plot.area_padding) 54 | end) 55 | end 56 | 57 | defp ribbon(%Geom.Ribbon{} = geom_ribbon, data, plot) do 58 | scale_transforms = fetch_scale_transforms(geom_ribbon.mapping, plot.scales) 59 | fixed_aesthetics = fetch_fixed_aesthetics(geom_ribbon) 60 | transforms = Map.merge(fixed_aesthetics, scale_transforms) 61 | 62 | row = hd(data) 63 | mapping = geom_ribbon.mapping 64 | 65 | [alpha, color, fill, size] = [ 66 | transforms.alpha.(row[mapping[:alpha]]), 67 | transforms.color.(row[mapping[:color]]), 68 | transforms.fill.(row[mapping[:fill]]), 69 | transforms.size.(row[mapping[:size]]) 70 | ] 71 | 72 | plot_height = plot.width / plot.aspect_ratio 73 | sorted_data = Enum.sort_by(data, fn row -> plot.scales.x.transform.(row[mapping[:x]]) end) 74 | y_max_coords = format_coordinates(:y_max, mapping, sorted_data, plot) 75 | 76 | all_coords = 77 | if geom_ribbon.mapping[:y_min] do 78 | y_min_coords = 79 | :y_min 80 | |> format_coordinates(mapping, sorted_data, plot) 81 | |> Enum.reverse() 82 | 83 | [y_max_coords, y_min_coords] 84 | else 85 | first_x = List.first(y_max_coords)[:x] 86 | last_x = List.last(y_max_coords)[:x] 87 | [y_max_coords, %{x: last_x, y_max: plot_height}, %{x: first_x, y_max: plot_height}] 88 | end 89 | 90 | %{fill: fill, alpha: alpha, color: color, size: size, coords: List.flatten(all_coords)} 91 | end 92 | 93 | defp fetch_scale_transforms(mapping, scales) do 94 | for aes <- [:y | Map.keys(mapping)], aes != :y_max, reduce: %{} do 95 | scale_transforms -> Map.put(scale_transforms, aes, scales[aes].transform) 96 | end 97 | end 98 | 99 | defp fetch_fixed_aesthetics(geom_ribbon) do 100 | geom_ribbon 101 | |> Map.take([:alpha, :color, :fill, :size]) 102 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 103 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 104 | end) 105 | end 106 | 107 | defp group_by_aesthetics(geom, plot) do 108 | data = geom.data || plot.data 109 | 110 | Enum.group_by(data, fn row -> 111 | { 112 | row[geom.mapping[:alpha]], 113 | row[geom.mapping[:fill]] 114 | } 115 | end) 116 | end 117 | 118 | defp draw_ribbon(ribbon, area_padding) do 119 | ribbon.coords 120 | |> Enum.map_join(" ", fn row -> 121 | "#{row.x + area_padding},#{row.y_max + area_padding}" 122 | end) 123 | |> Draw.polygon( 124 | stroke: ribbon.color, 125 | stroke_width: ribbon.size, 126 | fill: ribbon.fill, 127 | fill_opacity: ribbon.alpha 128 | ) 129 | end 130 | 131 | defp format_coordinates(y_aesthetic, mapping, data, plot) do 132 | Enum.map(data, fn row -> 133 | %{ 134 | x: plot.scales.x.transform.(row[mapping[:x]]), 135 | y_max: transform_and_pad_y(row[mapping[y_aesthetic]], plot) 136 | } 137 | end) 138 | end 139 | 140 | defp transform_and_pad_y(y_value, plot) do 141 | (plot.width - plot.scales.y.transform.(y_value)) / plot.aspect_ratio 142 | end 143 | 144 | defp stack_coordinates([only_group | []], _plot_height), do: only_group 145 | 146 | defp stack_coordinates([first_group, next_group | rest_of_groups], plot_height) do 147 | stack_coordinates( 148 | rest_of_groups, 149 | sum_ymax_coordinates(first_group, next_group, plot_height), 150 | [first_group], 151 | plot_height 152 | ) 153 | end 154 | 155 | defp stack_coordinates([], updated_group, stacked_coords, _plot_height) do 156 | [updated_group | stacked_coords] 157 | end 158 | 159 | defp stack_coordinates( 160 | [next_group | rest_of_groups], 161 | updated_group, 162 | stacked_coords, 163 | plot_height 164 | ) do 165 | stack_coordinates( 166 | rest_of_groups, 167 | sum_ymax_coordinates(next_group, updated_group, plot_height), 168 | [updated_group | stacked_coords], 169 | plot_height 170 | ) 171 | end 172 | 173 | defp sum_ymax_coordinates(first_list, second_list, plot_height) do 174 | Enum.zip_with(first_list, second_list, fn first_row, second_row -> 175 | %{x: first_row.x, y_max: first_row.y_max + second_row.y_max - plot_height} 176 | end) 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/ggity/geom/segment.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Segment do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Plot} 5 | 6 | @type t() :: %__MODULE__{} 7 | @type plot() :: %Plot{} 8 | @type record() :: map() 9 | @type mapping() :: map() 10 | 11 | defstruct data: nil, 12 | mapping: nil, 13 | stat: :identity, 14 | position: :identity, 15 | key_glyph: :line, 16 | alpha: 1, 17 | color: "black", 18 | size: 1, 19 | custom_attributes: nil 20 | 21 | @spec new(mapping(), keyword()) :: Geom.Segment.t() 22 | def new(mapping, options) do 23 | struct(Geom.Segment, [{:mapping, mapping} | options]) 24 | end 25 | 26 | @spec draw(Geom.Segment.t(), list(map()), plot()) :: iolist() 27 | def draw(%Geom.Segment{} = geom_segment, data, plot), do: segments(geom_segment, data, plot) 28 | 29 | defp segments(%Geom.Segment{} = geom_segment, data, %Plot{scales: scales} = plot) do 30 | scale_transforms = fetch_scale_transforms(geom_segment.mapping, scales) 31 | fixed_aesthetics = fetch_fixed_aesthetics(geom_segment) 32 | all_transforms = Map.merge(fixed_aesthetics, scale_transforms) 33 | 34 | Enum.map(data, fn row -> segment(row, all_transforms, geom_segment, plot) end) 35 | end 36 | 37 | defp segment(row, transforms, geom_segment, plot) do 38 | [x, xend, y, yend, stroke_opacity, stroke, stroke_width] = [ 39 | transforms.x.(row[geom_segment.mapping.x]), 40 | transforms.x.(row[geom_segment.mapping.xend]), 41 | transforms.y.(row[geom_segment.mapping.y]), 42 | transforms.y.(row[geom_segment.mapping.yend]), 43 | transforms.alpha.(row[geom_segment.mapping[:alpha]]), 44 | transforms.color.(row[geom_segment.mapping[:color]]), 45 | transforms.size.(row[geom_segment.mapping[:size]]) 46 | ] 47 | 48 | x1 = x + plot.area_padding 49 | x2 = xend + plot.area_padding 50 | y1 = (plot.width - y) / plot.aspect_ratio + plot.area_padding 51 | y2 = (plot.width - yend) / plot.aspect_ratio + plot.area_padding 52 | 53 | custom_attributes = GGity.Layer.custom_attributes(geom_segment, plot, row) 54 | 55 | [ 56 | x1: x1, 57 | x2: x2, 58 | y1: y1, 59 | y2: y2, 60 | stroke_opacity: stroke_opacity, 61 | stroke: stroke, 62 | stroke_width: stroke_width 63 | ] 64 | |> Keyword.merge(custom_attributes) 65 | |> Draw.line() 66 | end 67 | 68 | defp fetch_scale_transforms(mapping, scales) do 69 | for aes <- Map.keys(mapping), reduce: %{} do 70 | scale_transforms -> Map.put(scale_transforms, aes, scales[aes].transform) 71 | end 72 | end 73 | 74 | defp fetch_fixed_aesthetics(geom_segment) do 75 | geom_segment 76 | |> Map.take([:alpha, :color, :size]) 77 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 78 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 79 | end) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/ggity/geom/text.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Geom.Text do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Geom, Layer, Plot, Scale} 5 | 6 | @hjust_anchor_map %{left: "start", center: "middle", right: "end"} 7 | @vjust_anchor_map %{top: "baseline", middle: "middle", bottom: "hanging"} 8 | 9 | @type t() :: %__MODULE__{} 10 | @type plot() :: %Plot{} 11 | @type record() :: map() 12 | @type mapping() :: map() 13 | 14 | defstruct data: nil, 15 | mapping: nil, 16 | stat: :identity, 17 | position: :identity, 18 | position_vjust: 1, 19 | group_width: nil, 20 | group_padding: 5, 21 | key_glyph: :a, 22 | alpha: 1, 23 | color: "black", 24 | size: 8, 25 | family: "Helvetica, Arial, sans-serif", 26 | fontface: "normal", 27 | hjust: :center, 28 | vjust: :middle, 29 | nudge_x: 0, 30 | nudge_y: 0, 31 | custom_attributes: nil 32 | 33 | @spec new(mapping(), keyword()) :: Geom.Text.t() 34 | def new(mapping, options) do 35 | struct(Geom.Text, [{:mapping, mapping} | options]) 36 | end 37 | 38 | @spec draw(Geom.Text.t(), list(map()), Plot.t()) :: iolist() 39 | def draw(%Geom.Text{} = geom_text, data, %Plot{scales: %{x: %Scale.X.Discrete{}}} = plot) do 40 | number_of_levels = length(plot.scales.x.levels) 41 | group_width = (plot.width - number_of_levels * (plot.scales.x.padding - 1)) / number_of_levels 42 | mapping = Map.new(geom_text.mapping, fn {k, v} -> {k, to_string(v)} end) 43 | geom_text = struct(geom_text, mapping: mapping, group_width: group_width) 44 | words(geom_text, data, plot) 45 | end 46 | 47 | def draw(%Geom.Text{} = geom_text, data, plot) do 48 | mapping = Map.new(geom_text.mapping, fn {k, v} -> {k, to_string(v)} end) 49 | geom_text = struct(geom_text, mapping: mapping) 50 | words(geom_text, data, plot) 51 | end 52 | 53 | defp words(%Geom.Text{} = geom_text, data, %Plot{scales: %{x: %Scale.X.Discrete{}}} = plot) do 54 | data 55 | |> Enum.reject(fn row -> row[geom_text.mapping[:y]] == 0 end) 56 | |> Enum.group_by(fn row -> row[geom_text.mapping[:x]] end) 57 | |> Enum.with_index(fn {_x_value, group}, group_index -> 58 | group(geom_text, group, group_index, plot) 59 | end) 60 | end 61 | 62 | defp words(%Geom.Text{} = geom_text, data, %Plot{scales: scales} = plot) do 63 | transforms = transforms(geom_text, scales) 64 | 65 | Enum.map(data, fn row -> 66 | row 67 | |> apply_scale_transform(transforms, geom_text.mapping) 68 | |> map_to_svg_attributes() 69 | |> draw_word(geom_text, plot) 70 | end) 71 | end 72 | 73 | defp apply_scale_transform(row, transforms, mapping) do 74 | [ 75 | transforms.x.(row[mapping.x]), 76 | transforms.y.(row[mapping.y]), 77 | transforms.label.(row[mapping[:label]]), 78 | transforms.alpha.(row[mapping[:alpha]]), 79 | transforms.color.(row[mapping[:color]]), 80 | transforms.size.(row[mapping[:size]]) 81 | ] 82 | end 83 | 84 | defp map_to_svg_attributes(row) do 85 | Enum.zip([:x, :y, :label, :fill_opacity, :fill, :size], row) 86 | end 87 | 88 | defp draw_word(row, geom_text, plot) do 89 | Draw.text( 90 | to_string(row[:label]), 91 | [ 92 | x: row[:x] + plot.area_padding, 93 | y: (plot.width - row[:y]) / plot.aspect_ratio + plot.area_padding, 94 | fill: row[:fill], 95 | fill_opacity: row[:fill_opacity], 96 | font_size: "#{row[:size]}px", 97 | text_anchor: @hjust_anchor_map[geom_text.hjust], 98 | dominant_baseline: @vjust_anchor_map[geom_text.vjust], 99 | dx: geom_text.nudge_x, 100 | dy: -1 * geom_text.nudge_y, 101 | font_family: geom_text.family, 102 | font_weight: geom_text.fontface 103 | ] ++ Layer.custom_attributes(geom_text, plot, row) 104 | ) 105 | end 106 | 107 | defp group(geom_text, group_values, group_index, %Plot{scales: scales} = plot) do 108 | transforms = transforms(geom_text, scales) 109 | count_rows = length(group_values) 110 | 111 | sort_order = 112 | case geom_text.position do 113 | :stack -> :desc 114 | :dodge -> :asc 115 | _unknown_adjustment -> :asc 116 | end 117 | 118 | group_values 119 | |> Enum.sort_by(fn row -> row[plot.mapping[:group]] end, sort_order) 120 | |> Enum.reduce({count_rows, 0, 0, []}, fn row, 121 | {number_of_groups, total_width, total_height, text} -> 122 | { 123 | number_of_groups, 124 | total_width + geom_text.group_width / count_rows, 125 | total_height + 126 | transforms.y.(row[geom_text.mapping[:y]]) / plot.aspect_ratio, 127 | [ 128 | Draw.text( 129 | to_string(row[geom_text.mapping[:label]]), 130 | [ 131 | x: 132 | position_adjust_x( 133 | geom_text, 134 | row, 135 | group_index, 136 | total_width, 137 | plot, 138 | number_of_groups 139 | ), 140 | y: 141 | plot.area_padding + plot.width / plot.aspect_ratio - 142 | position_adjust_y(geom_text, row, total_height, plot), 143 | fill: geom_text.color, 144 | fill_opacity: geom_text.alpha, 145 | font_size: "#{transforms.size.(row[geom_text.mapping[:size]])}pt", 146 | text_anchor: @hjust_anchor_map[geom_text.hjust], 147 | dominant_baseline: @vjust_anchor_map[geom_text.vjust], 148 | dx: geom_text.nudge_x, 149 | dy: -1 * geom_text.nudge_y, 150 | font_family: geom_text.family, 151 | font_weight: geom_text.fontface 152 | ] ++ Layer.custom_attributes(geom_text, plot, row) 153 | ) 154 | | text 155 | ] 156 | } 157 | end) 158 | |> elem(3) 159 | end 160 | 161 | defp transforms(geom, scales) do 162 | scale_transforms = 163 | geom.mapping 164 | |> Map.keys() 165 | |> Enum.reduce(%{}, fn aesthetic, mapped -> 166 | Map.put(mapped, aesthetic, Map.get(scales[aesthetic], :transform)) 167 | end) 168 | 169 | geom 170 | |> Map.take([:alpha, :color, :shape, :size]) 171 | |> Enum.reduce(%{}, fn {aesthetic, fixed_value}, fixed -> 172 | Map.put(fixed, aesthetic, fn _value -> fixed_value end) 173 | end) 174 | |> Map.merge(scale_transforms) 175 | end 176 | 177 | defp position_adjust_x( 178 | %Geom.Text{position: :identity}, 179 | row, 180 | _group_index, 181 | _total_width, 182 | plot, 183 | _number_of_groups 184 | ) do 185 | plot.scales.x.transform.(row[plot.mapping[:x]]) 186 | end 187 | 188 | defp position_adjust_x( 189 | %Geom.Text{position: :stack} = geom_text, 190 | _row, 191 | group_index, 192 | _total_width, 193 | plot, 194 | _number_of_groups 195 | ) do 196 | plot.area_padding + geom_text.group_width / 2 + 197 | group_index * (geom_text.group_width + plot.scales.x.padding) 198 | end 199 | 200 | defp position_adjust_x( 201 | %Geom.Text{position: :dodge} = geom_text, 202 | _row, 203 | group_index, 204 | total_width, 205 | plot, 206 | number_of_groups 207 | ) do 208 | plot.area_padding + geom_text.group_width / 2 / number_of_groups + 209 | group_index * (geom_text.group_width + plot.scales.x.padding) + 210 | total_width 211 | end 212 | 213 | defp position_adjust_y(%Geom.Text{position: :identity} = geom_text, row, _total_height, plot) do 214 | plot.scales.y.transform.(row[geom_text.mapping[:y]]) / plot.aspect_ratio 215 | end 216 | 217 | defp position_adjust_y(%Geom.Text{position: :stack} = geom_text, row, total_height, plot) do 218 | total_height + 219 | plot.scales.y.transform.(row[geom_text.mapping[:y]]) / plot.aspect_ratio * 220 | geom_text.position_vjust 221 | end 222 | 223 | defp position_adjust_y(%Geom.Text{position: :dodge} = geom_text, row, _total_height, plot) do 224 | plot.scales.y.transform.(row[geom_text.mapping[:y]]) / plot.aspect_ratio * 225 | geom_text.position_vjust 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /lib/ggity/html.ex: -------------------------------------------------------------------------------- 1 | # Copied directly from the Plug.HTML module in the Plug library. 2 | 3 | # Copyright (c) 2013 Plataformatec. Used under the terms of the 4 | # applicable license - notice below. 5 | 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | defmodule GGity.HTML do 19 | @moduledoc false 20 | 21 | @doc false 22 | @spec escape(String.t()) :: String.t() 23 | def escape(data) when is_binary(data) do 24 | IO.iodata_to_binary(to_iodata(data, 0, data, [])) 25 | end 26 | 27 | @doc false 28 | @spec escape_to_iodata(String.t()) :: iodata 29 | def escape_to_iodata(data) when is_binary(data) do 30 | to_iodata(data, 0, data, []) 31 | end 32 | 33 | escapes = [ 34 | {?<, "<"}, 35 | {?>, ">"}, 36 | {?&, "&"}, 37 | {?", """}, 38 | {?', "'"} 39 | ] 40 | 41 | for {match, insert} <- escapes do 42 | defp to_iodata(<>, skip, original, acc) do 43 | to_iodata(rest, skip + 1, original, [acc | unquote(insert)]) 44 | end 45 | end 46 | 47 | defp to_iodata(<<_char, rest::bits>>, skip, original, acc) do 48 | to_iodata(rest, skip, original, acc, 1) 49 | end 50 | 51 | defp to_iodata(<<>>, _skip, _original, acc) do 52 | acc 53 | end 54 | 55 | for {match, insert} <- escapes do 56 | defp to_iodata(<>, skip, original, acc, len) do 57 | part = binary_part(original, skip, len) 58 | to_iodata(rest, skip + len + 1, original, [acc, part | unquote(insert)]) 59 | end 60 | end 61 | 62 | defp to_iodata(<<_char, rest::bits>>, skip, original, acc, len) do 63 | to_iodata(rest, skip, original, acc, len + 1) 64 | end 65 | 66 | defp to_iodata(<<>>, 0, original, _acc, _len) do 67 | original 68 | end 69 | 70 | defp to_iodata(<<>>, skip, original, acc, len) do 71 | [acc | binary_part(original, skip, len)] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/ggity/labels.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Labels do 2 | @moduledoc """ 3 | Common functions for transforming axis tick labels. 4 | 5 | Break (i.e. axis tick or legend item) labels are formatted based on a 6 | scale's `:labels` option. This option can be provided in several forms: 7 | 8 | - `nil` - No labels are drawn 9 | - `:waivers` - drawn using the default formatting (`Kernel.to_string/1`) 10 | - a function that takes a single argument representing the break value and returns a binary 11 | - an atom, representing the name of a built-in formatting function (e.g., `commas/1`) 12 | 13 | Note that the built-in formatting functions are not intended to be robust. One option for 14 | finely-tuned formatting would be to pass functions from the [Cldr family of packages](https://hexdocs.pm/ex_cldr) (must be 15 | added as a separate dependency). 16 | ## Examples 17 | 18 | ``` 19 | data 20 | |> Plot.new(%{x: "x", y: "y"}) 21 | |> Plot.geom_point() 22 | |> Plot.scale_x_continuous(labels: nil) 23 | # value 1000 is printed as an empty string 24 | ``` 25 | 26 | ``` 27 | data 28 | |> Plot.new(%{x: "x", y: "y"}) 29 | |> Plot.geom_point() 30 | |> Plot.scale_x_continuous() # This is equivalent to Plot.scale_x_continuous(labels: :waivers) 31 | # value 1000 (integer) is printed as "1000" 32 | # value 1000.0 (float) is printed as "1.0e3" 33 | 34 | ``` 35 | 36 | ``` 37 | data 38 | |> Plot.new(%{x: "x", y: "y", c: "color"}) 39 | |> Plot.geom_point() 40 | |> Plot.scale_color_viridis(labels: fn value -> value <> "!" end) 41 | # value "First Item" is printed as "First Item!" 42 | ``` 43 | 44 | ``` 45 | data 46 | |> Plot.new(%{x: "x", y: "y"}) 47 | |> Plot.geom_point() 48 | |> Plot.scale_x_continuous(labels: :commas) 49 | # value 1000 (integer) is printed as "1,000" 50 | # value 1000 (float) is printed as "1,000" 51 | ``` 52 | 53 | Date scales (e.g., `GGity.Scale.X.Date`) are a special case. 54 | For those scales, if a value for `:date_labels` has been specified, that 55 | pattern overrides any value for the `:labels` option. See 56 | `GGity.Plot.scale_x_date/2` for more information regarding 57 | date labels. 58 | """ 59 | 60 | alias GGity.{Labels, Scale} 61 | 62 | @type tick_value() :: %Date{} | %DateTime{} | number() 63 | 64 | @doc false 65 | @spec format(map(), tick_value()) :: String.t() 66 | def format(%scale_type{date_labels: {pattern, options}}, value) 67 | when scale_type in [Scale.X.Date, Scale.X.DateTime] do 68 | NimbleStrftime.format(value, pattern, options) 69 | end 70 | 71 | def format(%scale_type{date_labels: pattern}, value) 72 | when scale_type in [Scale.X.Date, Scale.X.DateTime] and is_binary(pattern) do 73 | NimbleStrftime.format(value, pattern) 74 | end 75 | 76 | def format(%{labels: :waivers}, value), do: to_string(value) 77 | 78 | def format(%{labels: nil}, _value), do: "" 79 | 80 | def format(%{labels: built_in_function}, value) when is_atom(built_in_function) do 81 | apply(Labels, built_in_function, [value]) 82 | end 83 | 84 | def format(%{labels: formatter}, value) when is_function(formatter) do 85 | formatter.(value) 86 | end 87 | 88 | @doc """ 89 | Applies a comma separator to a number and converts it to a string. 90 | 91 | If the number is a float, it is first rounded using `Kernel.to_string/1`. 92 | 93 | Note that simple floating point arithmetic is used; the various issues/errors 94 | associated with floating point values apply. 95 | 96 | iex> GGity.Labels.commas(5000.0) 97 | "5,000" 98 | 99 | iex> GGity.Labels.commas(1000.6) 100 | "1,001" 101 | 102 | iex> GGity.Labels.commas(100.0) 103 | "100" 104 | 105 | iex> GGity.Labels.commas(10_000_000) 106 | "10,000,000" 107 | """ 108 | @spec commas(number()) :: String.t() 109 | def commas(value) when is_number(value) do 110 | value 111 | |> round() 112 | |> to_charlist() 113 | |> Enum.reverse() 114 | |> comma_separate([]) 115 | |> to_string() 116 | end 117 | 118 | defp comma_separate([first, second, third | []], acc) do 119 | [third, second, first | acc] 120 | end 121 | 122 | defp comma_separate([first, second, third | tail], acc) do 123 | acc = [~c",", third, second, first | acc] 124 | comma_separate(tail, acc) 125 | end 126 | 127 | defp comma_separate([first | [second]], acc), do: [second, first | acc] 128 | defp comma_separate([first], acc), do: [first | acc] 129 | defp comma_separate([], acc), do: acc 130 | 131 | @doc """ 132 | Formats a number in U.S. dollars and cents. 133 | 134 | If the value is greater than or equal to 100,000 rounds to the nearest 135 | dollar and does not display cents. 136 | 137 | Note that simple floating point arithmetic is used; the various issues/errors 138 | associated with floating point values apply. 139 | 140 | iex> GGity.Labels.dollar(5000.0) 141 | "$5,000.00" 142 | 143 | iex> GGity.Labels.dollar(1000.6) 144 | "$1,000.60" 145 | 146 | iex> GGity.Labels.dollar(100.0) 147 | "$100.00" 148 | 149 | iex> GGity.Labels.dollar(10_000_000) 150 | "$10,000,000" 151 | """ 152 | @spec dollar(number()) :: String.t() 153 | def dollar(value) when is_float(value) do 154 | cents = 155 | ((Float.round(value, 2) - floor(value)) * 100) 156 | |> round() 157 | |> Integer.digits() 158 | |> Enum.take(2) 159 | |> Enum.join() 160 | |> String.pad_trailing(2, "0") 161 | 162 | case value do 163 | value when value >= 100_000 -> 164 | "$" <> commas(floor(value)) 165 | 166 | value -> 167 | "$#{commas(floor(value))}.#{cents}" 168 | end 169 | end 170 | 171 | def dollar(value) when is_integer(value) do 172 | dollar(value * 1.0) 173 | end 174 | 175 | @doc """ 176 | Formats a number as a percent. 177 | 178 | Accepts a `:precision` option specifying the number of decimal places 179 | to be displayed. 180 | 181 | Note that simple floating point arithmetic is used; the various issues/errors 182 | associated with floating point values apply. 183 | 184 | iex> GGity.Labels.percent(0.5) 185 | "50%" 186 | 187 | iex> GGity.Labels.percent(0.111) 188 | "11%" 189 | 190 | iex> GGity.Labels.percent(0.015, precision: 1) 191 | "1.5%" 192 | 193 | iex> GGity.Labels.percent(10) 194 | "1000%" 195 | """ 196 | @spec percent(number(), keyword()) :: String.t() 197 | def percent(value, options \\ [precision: 0]) 198 | 199 | def percent(value, precision: precision) when is_float(value) do 200 | percent_value = Float.round(value * 100, precision) 201 | 202 | rounded_value = 203 | case precision do 204 | 0 -> round(percent_value) 205 | _other -> percent_value 206 | end 207 | 208 | to_string(rounded_value) <> "%" 209 | end 210 | 211 | def percent(value, _options) when is_integer(value) do 212 | to_string(value * 100) <> "%" 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /lib/ggity/layer.ex: -------------------------------------------------------------------------------- 1 | defprotocol GGity.Layer do 2 | @moduledoc false 3 | 4 | @spec new(GGity.Layer.t(), map(), keyword()) :: GGity.Layer.t() 5 | def new(geom, mapping, options) 6 | 7 | @spec draw(GGity.Layer.t(), list(map()), GGity.Plot.t()) :: iolist() 8 | def draw(geom, data, plot) 9 | 10 | @spec custom_attributes(GGity.Layer.t(), GGity.Plot.t(), map()) :: keyword() 11 | def custom_attributes(geom, plot, row) 12 | end 13 | 14 | defimpl GGity.Layer, 15 | for: [ 16 | GGity.Geom.Bar, 17 | GGity.Geom.Boxplot, 18 | GGity.Geom.Line, 19 | GGity.Geom.Point, 20 | GGity.Geom.Rect, 21 | GGity.Geom.Ribbon, 22 | GGity.Geom.Segment, 23 | GGity.Geom.Text 24 | ] do 25 | def new(%geom_type{} = _geom, mapping, options) do 26 | apply(geom_type, :new, [mapping, options]) 27 | end 28 | 29 | def draw(%geom_type{} = geom, data, plot) do 30 | data = Explorer.DataFrame.to_rows(data) 31 | geom = %{geom | data: Explorer.DataFrame.to_rows(geom.data)} 32 | 33 | apply(geom_type, :draw, [geom, data, plot]) 34 | end 35 | 36 | def custom_attributes(%{custom_attributes: nil}, _plot, _row), do: [] 37 | 38 | def custom_attributes(geom, plot, row) do 39 | geom.custom_attributes.(plot, row) 40 | end 41 | end 42 | 43 | defimpl GGity.Layer, for: GGity.Geom.Blank do 44 | def new(_geom, _mapping, _options), do: struct(GGity.Geom.Blank) 45 | 46 | def draw(_geom, _data, _plot), do: GGity.Geom.Blank.draw() 47 | 48 | def custom_attributes(_geom, _data, _plot), do: [] 49 | end 50 | -------------------------------------------------------------------------------- /lib/ggity/legend.ex: -------------------------------------------------------------------------------- 1 | defprotocol GGity.Legend do 2 | @moduledoc false 3 | 4 | @fallback_to_any true 5 | 6 | @spec draw_legend(GGity.Legend.t(), binary(), atom(), number(), keyword()) :: iolist() 7 | def draw_legend(scale, label, key_glyph, key_height, fixed_aesthetics) 8 | end 9 | 10 | defimpl GGity.Legend, 11 | for: [ 12 | GGity.Scale.Alpha.Discrete, 13 | GGity.Scale.Color.Viridis, 14 | GGity.Scale.Fill.Viridis, 15 | GGity.Scale.Linetype.Discrete, 16 | GGity.Scale.Size 17 | ] do 18 | def draw_legend(%scale_type{} = scale, label, key_glyph, key_height, fixed_aesthetics) do 19 | apply(scale_type, :draw_legend, [scale, label, key_glyph, key_height, fixed_aesthetics]) 20 | end 21 | end 22 | 23 | defimpl GGity.Legend, for: [GGity.Scale.Shape, GGity.Scale.Shape.Manual] do 24 | def draw_legend(%scale_type{} = scale, label, _key_glyph, key_height, fixed_aesthetics) do 25 | apply(scale_type, :draw_legend, [scale, label, key_height, fixed_aesthetics]) 26 | end 27 | end 28 | 29 | defimpl GGity.Legend, for: Any do 30 | def draw_legend(_scale, _label, _key_glyph, _key_height, _fixed_aesthetics), do: [] 31 | end 32 | -------------------------------------------------------------------------------- /lib/ggity/scale.ex: -------------------------------------------------------------------------------- 1 | defprotocol GGity.Scale do 2 | @moduledoc false 3 | 4 | @type date() :: %Date{} 5 | @type datetime() :: %DateTime{} | %NaiveDateTime{} 6 | @type date_or_time() :: date() | datetime() 7 | @type continuous_value :: number() | date_or_time() 8 | 9 | @spec train(GGity.Scale.t(), {continuous_value(), continuous_value()} | list(binary())) :: 10 | GGity.Scale.t() 11 | def train(scale, parameters) 12 | end 13 | 14 | defimpl GGity.Scale, 15 | for: [ 16 | GGity.Scale.Alpha.Discrete, 17 | GGity.Scale.Color.Viridis, 18 | GGity.Scale.Fill.Viridis, 19 | GGity.Scale.Identity, 20 | GGity.Scale.Linetype.Discrete, 21 | GGity.Scale.Shape.Manual, 22 | GGity.Scale.Shape, 23 | GGity.Scale.X.Discrete 24 | ] do 25 | def train(%scale_type{} = scale, levels) do 26 | apply(scale_type, :train, [scale, levels]) 27 | end 28 | end 29 | 30 | defimpl GGity.Scale, 31 | for: [ 32 | GGity.Scale.Alpha.Continuous, 33 | GGity.Scale.Size, 34 | GGity.Scale.X.Continuous, 35 | GGity.Scale.X.Date, 36 | GGity.Scale.X.DateTime, 37 | GGity.Scale.Y.Continuous 38 | ] do 39 | def train(%scale_type{} = scale, {min, max}) do 40 | apply(scale_type, :train, [scale, {min, max}]) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ggity/scale/alpha_continuous.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Alpha.Continuous do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.Alpha 5 | 6 | @type t() :: %__MODULE__{} 7 | 8 | defstruct range: {0.1, 1}, 9 | transform: nil 10 | 11 | @spec new(keyword()) :: Alpha.Continuous.t() 12 | def new(options \\ []), do: struct(Alpha.Continuous, options) 13 | 14 | @spec train(Alpha.Continuous.t(), {number(), number()}) :: Alpha.Continuous.t() 15 | def train(scale, {value_min, value_max}) do 16 | domain = value_max - value_min 17 | transformations(scale, domain, value_min) 18 | end 19 | 20 | defp transformations(scale, 0, _value_min) do 21 | struct(scale, transform: fn _value -> 1 end) 22 | end 23 | 24 | defp transformations(scale, domain, value_min) do 25 | {range_min, range_max} = scale.range 26 | range = range_max - range_min 27 | 28 | struct(scale, 29 | transform: fn value -> range_min + (value - value_min) / domain * range end 30 | ) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ggity/scale/alpha_discrete.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Alpha.Discrete do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Labels} 5 | alias GGity.Scale.Alpha 6 | 7 | defstruct transform: nil, 8 | range: {0.1, 1}, 9 | levels: nil, 10 | labels: :waivers, 11 | guide: :legend 12 | 13 | @type t() :: %__MODULE__{} 14 | 15 | @spec new(keyword()) :: Alpha.Discrete.t() 16 | def new(options \\ []), do: struct(Alpha.Discrete, options) 17 | 18 | @spec train(Alpha.Discrete.t(), list(binary())) :: Alpha.Discrete.t() 19 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 20 | transform = GGity.Scale.Discrete.transform(levels, palette(scale, levels)) 21 | struct(scale, levels: levels, transform: transform) 22 | end 23 | 24 | defp palette(%Alpha.Discrete{range: {min, max}}, [_single_level]) do 25 | [min + (max - min) / 2] 26 | end 27 | 28 | defp palette(%Alpha.Discrete{range: {min, max}}, levels) do 29 | number_of_levels = length(levels) 30 | interval = (max - min) / (number_of_levels - 1) 31 | for index <- 1..number_of_levels, do: min + (index - 1) * interval 32 | end 33 | 34 | @spec draw_legend(Alpha.Discrete.t(), binary(), atom(), number(), keyword()) :: iolist() 35 | def draw_legend( 36 | %Alpha.Discrete{guide: :none}, 37 | _label, 38 | _key_glyph, 39 | _key_height, 40 | _fixed_aesthetics 41 | ), 42 | do: [] 43 | 44 | def draw_legend( 45 | %Alpha.Discrete{levels: [_]}, 46 | _label, 47 | _key_glyph, 48 | _key_height, 49 | _fixed_aethetics 50 | ), 51 | do: [] 52 | 53 | def draw_legend( 54 | %Alpha.Discrete{levels: levels} = scale, 55 | label, 56 | key_glyph, 57 | key_height, 58 | fixed_aethetics 59 | ) do 60 | [ 61 | Draw.text( 62 | "#{label}", 63 | x: "0", 64 | y: "-5", 65 | class: "gg-text gg-legend-title", 66 | text_anchor: "left" 67 | ), 68 | Enum.with_index(levels, fn level, index -> 69 | draw_legend_item(scale, {level, index}, key_glyph, key_height, fixed_aethetics) 70 | end) 71 | ] 72 | end 73 | 74 | defp draw_legend_item(scale, {level, index}, key_glyph, key_height, fixed_aesthetics) do 75 | [ 76 | Draw.rect( 77 | x: "0", 78 | y: "#{key_height * index}", 79 | height: key_height, 80 | width: key_height, 81 | class: "gg-legend-key" 82 | ), 83 | draw_key_glyph(scale, level, index, key_glyph, key_height, fixed_aesthetics), 84 | Draw.text( 85 | "#{Labels.format(scale, level)}", 86 | x: "#{key_height + 5}", 87 | y: "#{10 + key_height * index}", 88 | class: "gg-text gg-legend-text", 89 | text_anchor: "left" 90 | ) 91 | ] 92 | end 93 | 94 | defp draw_key_glyph(scale, level, index, :rect, key_height, fixed_aesthetics) do 95 | Draw.rect( 96 | x: "0", 97 | y: "#{key_height * index}", 98 | height: key_height, 99 | width: key_height, 100 | fill_opacity: "#{scale.transform.(level)}", 101 | fill: fixed_aesthetics[:fill] 102 | ) 103 | end 104 | 105 | defp draw_key_glyph(scale, level, index, :a, key_height, fixed_aesthetics) do 106 | Draw.text( 107 | "a", 108 | x: key_height / 2, 109 | y: key_height / 2 + key_height * index, 110 | text_anchor: "middle", 111 | dominant_baseline: "middle", 112 | font_size: 10, 113 | font_weight: "bold", 114 | fill: fixed_aesthetics[:color], 115 | fill_opacity: "#{scale.transform.(level)}" 116 | ) 117 | end 118 | 119 | defp draw_key_glyph(scale, level, index, :point, key_height, fixed_aesthetics) do 120 | GGity.Shapes.draw( 121 | :circle, 122 | {key_height / 2, key_height / 2 + key_height * index}, 123 | :math.pow(1 + key_height / 3, 2), 124 | color: fixed_aesthetics[:color], 125 | fill: fixed_aesthetics[:fill], 126 | fill_opacity: "#{scale.transform.(level)}" 127 | ) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/ggity/scale/alpha_manual.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Alpha.Manual do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.Alpha 5 | 6 | @default_alpha 1 7 | 8 | @type t() :: %__MODULE__{} 9 | 10 | defstruct transform: nil 11 | 12 | @doc false 13 | @spec new(any()) :: Alpha.Manual.t() 14 | def new(value \\ @default_alpha) when value >= 0 and value <= 1 do 15 | struct(Alpha.Manual, transform: fn _value -> value end) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/ggity/scale/color_manual.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Color.Manual do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.Color 5 | 6 | @default_color "black" 7 | 8 | @type t() :: %__MODULE__{} 9 | 10 | defstruct transform: nil, 11 | levels: nil 12 | 13 | @spec new(any()) :: Color.Manual.t() 14 | def new(value \\ @default_color) when is_binary(value) do 15 | struct(Color.Manual, levels: [value], transform: fn _value -> value end) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/ggity/scale/continuous.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Continuous do 2 | @moduledoc false 3 | 4 | @type extent() :: number() | %Date{} | %DateTime{} | %NaiveDateTime{} 5 | 6 | @doc false 7 | @spec transform({extent(), extent()}, {extent(), extent()}) :: (extent() -> number()) 8 | def transform({domain_min, domain_max}, {range_min, range_max}) do 9 | fn value -> 10 | diff(value, domain_min) / diff(domain_max, domain_min) * diff(range_max, range_min) 11 | end 12 | end 13 | 14 | defp diff(first, second) when is_number(first) and is_number(second) do 15 | first - second 16 | end 17 | 18 | defp diff(%date_type{} = first, %date_type{} = second) when date_type == Date do 19 | Date.diff(first, second) 20 | end 21 | 22 | defp diff(%date_type{} = first, %date_type{} = second) 23 | when date_type in [DateTime, NaiveDateTime] do 24 | apply(date_type, :diff, [first, second, :millisecond]) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/ggity/scale/discrete.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Discrete do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec transform(list(), list()) :: (binary() -> binary()) 6 | def transform(levels, palette) when is_list(levels) and is_list(palette) do 7 | levels_map = 8 | palette 9 | |> Enum.zip(levels) 10 | |> Map.new(fn {aesthetic, value} -> {value, aesthetic} end) 11 | 12 | fn value -> levels_map[to_string(value)] end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ggity/scale/fill_viridis.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Fill.Viridis do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Labels} 5 | alias GGity.Scale.{Color, Fill} 6 | 7 | defstruct transform: nil, 8 | levels: nil, 9 | labels: :waivers, 10 | guide: :legend, 11 | option: :viridis 12 | 13 | @type t() :: %__MODULE__{} 14 | 15 | @spec new(keyword()) :: Fill.Viridis.t() 16 | def new(options \\ []), do: struct(Fill.Viridis, options) 17 | 18 | @spec train(Fill.Viridis.t(), list(binary())) :: Fill.Viridis.t() 19 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 20 | color_struct = 21 | Color.Viridis 22 | |> struct(Map.from_struct(scale)) 23 | |> Color.Viridis.train(levels) 24 | 25 | struct(Fill.Viridis, Map.from_struct(color_struct)) 26 | end 27 | 28 | @spec draw_legend(Fill.Viridis.t(), binary(), atom(), number(), keyword()) :: iolist() 29 | def draw_legend( 30 | %Fill.Viridis{guide: :none}, 31 | _label, 32 | _key_glyph, 33 | _key_height, 34 | _fixed_aesthetics 35 | ), 36 | do: [] 37 | 38 | def draw_legend(%Fill.Viridis{levels: [_]}, _label, _key_glyp, _key_heighth, _fixed_aesthetics), 39 | do: [] 40 | 41 | def draw_legend( 42 | %Fill.Viridis{levels: levels} = scale, 43 | label, 44 | key_glyph, 45 | key_height, 46 | fixed_aesthetics 47 | ) do 48 | [ 49 | Draw.text( 50 | "#{label}", 51 | x: "0", 52 | y: "-5", 53 | class: "gg-text gg-legend-title", 54 | text_anchor: "left" 55 | ), 56 | Enum.with_index(levels, fn level, index -> 57 | draw_legend_item(scale, {level, index}, key_glyph, key_height, fixed_aesthetics) 58 | end) 59 | ] 60 | end 61 | 62 | defp draw_legend_item(scale, {level, index}, key_glyph, key_height, fixed_aesthetics) do 63 | [ 64 | draw_key_glyph(scale, level, index, key_glyph, key_height, fixed_aesthetics), 65 | Draw.text( 66 | "#{Labels.format(scale, level)}", 67 | x: "#{5 + key_height}", 68 | y: "#{10 + key_height * index}", 69 | class: "gg-text gg-legend-text", 70 | text_anchor: "left" 71 | ) 72 | ] 73 | end 74 | 75 | defp draw_key_glyph(scale, level, index, :rect, key_height, fixed_aesthetics) do 76 | Draw.rect( 77 | x: "0", 78 | y: "#{key_height * index}", 79 | height: key_height, 80 | width: key_height, 81 | style: "fill:#{scale.transform.(level)}; fill-opacity:#{fixed_aesthetics[:alpha]};", 82 | class: "gg-legend-key" 83 | ) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/ggity/scale/identity.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Identity do 2 | @moduledoc false 3 | 4 | alias GGity.Scale 5 | 6 | @type t() :: %__MODULE__{} 7 | 8 | defstruct transform: nil, 9 | levels: nil, 10 | labels: :waivers, 11 | guide: :none, 12 | aesthetic: nil 13 | 14 | @spec new(atom()) :: Scale.Identity.t() 15 | def new(aesthetic) do 16 | struct(Scale.Identity, aesthetic: aesthetic) 17 | end 18 | 19 | @spec train(Scale.Identity.t(), list(binary())) :: Scale.Identity.t() 20 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 21 | transform = fn value -> value end 22 | struct(scale, levels: levels, transform: transform) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ggity/scale/linetype_discrete.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Linetype.Discrete do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Labels} 5 | alias GGity.Scale.Linetype 6 | 7 | # solid: "", 8 | # dashed: "4", 9 | # dotted: "1", 10 | # longdash: "6 2", 11 | # dotdash: "1 2 3 2", 12 | # twodash: "2 2 6 2" 13 | 14 | @palette_values [ 15 | "", 16 | "4", 17 | "1", 18 | "6 2", 19 | "1 2 3 2", 20 | "2 2 6 2" 21 | ] 22 | 23 | defstruct transform: nil, 24 | levels: nil, 25 | labels: :waivers, 26 | guide: :legend 27 | 28 | @type t() :: %__MODULE__{} 29 | 30 | @spec new(keyword()) :: Linetype.Discrete.t() 31 | def new(options \\ []), do: struct(Linetype.Discrete, options) 32 | 33 | @spec train(Linetype.Discrete.t(), list(binary())) :: Linetype.Discrete.t() 34 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 35 | transform = GGity.Scale.Discrete.transform(levels, palette(levels)) 36 | struct(scale, levels: levels, transform: transform) 37 | end 38 | 39 | defp palette(levels) do 40 | @palette_values 41 | |> Stream.cycle() 42 | |> Enum.take(length(levels)) 43 | end 44 | 45 | @spec draw_legend(Linetype.Discrete.t(), binary(), atom(), number(), keyword()) :: iolist() 46 | def draw_legend( 47 | %Linetype.Discrete{guide: :none}, 48 | _label, 49 | _key_glyph, 50 | _key_height, 51 | _fixed_aesthetics 52 | ), 53 | do: [] 54 | 55 | def draw_legend( 56 | %Linetype.Discrete{levels: [_]}, 57 | _label, 58 | _key_glyph, 59 | _key_height, 60 | _fixed_aesthetics 61 | ), 62 | do: [] 63 | 64 | def draw_legend( 65 | %Linetype.Discrete{levels: levels} = scale, 66 | label, 67 | key_glyph, 68 | key_height, 69 | fixed_aesthetics 70 | ) do 71 | [ 72 | Draw.text( 73 | "#{label}", 74 | x: "0", 75 | y: "-5", 76 | class: "gg-text gg-legend-title", 77 | text_anchor: "left" 78 | ), 79 | Enum.with_index(levels, fn level, index -> 80 | draw_legend_item(scale, {level, index}, key_glyph, key_height, fixed_aesthetics) 81 | end) 82 | ] 83 | end 84 | 85 | defp draw_legend_item(scale, {level, index}, key_glyph, key_height, fixed_aesthetics) do 86 | [ 87 | Draw.rect( 88 | x: "0", 89 | y: "#{key_height * index}", 90 | height: key_height, 91 | width: key_height, 92 | class: "gg-legend-key" 93 | ), 94 | draw_key_glyph(scale, level, index, key_glyph, key_height, fixed_aesthetics), 95 | Draw.text( 96 | "#{Labels.format(scale, level)}", 97 | x: "#{key_height + 5}", 98 | y: "#{10 + key_height * index}", 99 | class: "gg-text gg-legend-text", 100 | text_anchor: "left" 101 | ) 102 | ] 103 | end 104 | 105 | defp draw_key_glyph(scale, level, index, :path, key_height, fixed_aesthetics) do 106 | Draw.line( 107 | x1: 1, 108 | y1: key_height / 2 + key_height * index, 109 | x2: key_height - 1, 110 | y2: key_height / 2 + key_height * index, 111 | stroke: fixed_aesthetics[:color], 112 | stroke_dasharray: "#{scale.transform.(level)}", 113 | stroke_opacity: fixed_aesthetics[:alpha] 114 | ) 115 | end 116 | 117 | defp draw_key_glyph(scale, level, index, :timeseries, key_height, fixed_aesthetics) do 118 | offset = key_height * index 119 | 120 | Draw.polyline( 121 | [ 122 | {1, key_height - 1 + offset}, 123 | {key_height / 5 * 2, key_height / 5 * 2 + offset}, 124 | {key_height / 5 * 3, key_height / 5 * 3 + offset}, 125 | {key_height - 1, 1 + offset} 126 | ], 127 | fixed_aesthetics[:color], 128 | fixed_aesthetics[:size], 129 | fixed_aesthetics[:linetype], 130 | scale.transform.(level) 131 | ) 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/ggity/scale/linetype_manual.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Linetype.Manual do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.Linetype 5 | 6 | @default_linetype :solid 7 | @linetype_specs %{ 8 | solid: "", 9 | dashed: "4", 10 | dotted: "1", 11 | longdash: "6 2", 12 | dotdash: "1 2 3 2", 13 | twodash: "2 2 6 2" 14 | } 15 | @valid_linetypes Map.keys(@linetype_specs) 16 | 17 | @type t() :: %__MODULE__{} 18 | 19 | defstruct transform: nil, 20 | levels: nil 21 | 22 | @spec new(atom()) :: Linetype.Manual.t() 23 | def new(value \\ @default_linetype) when value in @valid_linetypes do 24 | value = get_specs(value) 25 | struct(Linetype.Manual, levels: [value], transform: fn _value -> value end) 26 | end 27 | 28 | defp get_specs(value) do 29 | @linetype_specs[value] || "" 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ggity/scale/shape.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Shape do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Labels} 5 | alias GGity.Scale.Shape 6 | 7 | @palette_values [:circle, :triangle, :square, :plus, :square_cross] 8 | 9 | defstruct transform: nil, 10 | levels: nil, 11 | labels: :waivers, 12 | guide: :legend 13 | 14 | @type t() :: %__MODULE__{} 15 | 16 | @spec new(keyword()) :: Shape.t() 17 | def new(options \\ []), do: struct(Shape, options) 18 | 19 | @spec train(Shape.t(), list(binary())) :: Shape.t() 20 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 21 | transform = GGity.Scale.Discrete.transform(levels, palette(levels)) 22 | struct(scale, levels: levels, transform: transform) 23 | end 24 | 25 | defp palette(levels) do 26 | @palette_values 27 | |> Stream.cycle() 28 | |> Enum.take(length(levels)) 29 | end 30 | 31 | @spec draw_legend(Shape.t(), binary(), number(), keyword()) :: iolist() 32 | def draw_legend(%Shape{guide: :none}, _label, _key_height, _fixed_aesthetics), do: [] 33 | 34 | def draw_legend(%Shape{levels: [_]}, _label, _key_height, _fixed_aesthetics), do: [] 35 | 36 | def draw_legend(%Shape{levels: levels} = scale, label, key_height, fixed_aesthetics) do 37 | [ 38 | Draw.text( 39 | "#{label}", 40 | x: "0", 41 | y: "-5", 42 | class: "gg-text gg-legend-title", 43 | text_anchor: "left" 44 | ), 45 | Enum.with_index(levels, fn level, index -> 46 | draw_legend_item(scale, {level, index}, key_height, fixed_aesthetics) 47 | end) 48 | ] 49 | end 50 | 51 | defp draw_legend_item(scale, {level, index}, key_height, fixed_aesthetics) do 52 | transformed_value = scale.transform.(level) 53 | 54 | [ 55 | Draw.rect( 56 | x: "0", 57 | y: "#{key_height * index}", 58 | height: key_height, 59 | width: key_height, 60 | class: "gg-legend-key" 61 | ), 62 | GGity.Shapes.draw( 63 | transformed_value, 64 | {key_height / 2, key_height / 2 + key_height * index}, 65 | :math.pow(1 + key_height / 3, 2), 66 | fill: fixed_aesthetics[:fill], 67 | color: fixed_aesthetics[:color], 68 | fill_opacity: fixed_aesthetics[:alpha] 69 | ), 70 | Draw.text( 71 | "#{Labels.format(scale, level)}", 72 | x: "#{5 + key_height}", 73 | y: "#{10 + key_height * index}", 74 | class: "gg-text gg-legend-text", 75 | text_anchor: "left" 76 | ) 77 | ] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/ggity/scale/shape_manual.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Shape.Manual do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Labels} 5 | alias GGity.Scale.Shape 6 | 7 | @type t() :: %__MODULE__{} 8 | 9 | defstruct levels: nil, 10 | transform: nil, 11 | labels: :waivers, 12 | guide: :legend, 13 | values: [] 14 | 15 | @spec new(keyword()) :: Shape.Manual.t() 16 | def new(options) do 17 | values = 18 | options 19 | |> Keyword.get(:values) 20 | |> set_values() 21 | 22 | options = Keyword.put_new(options, :values, values) 23 | struct(Shape.Manual, options) 24 | end 25 | 26 | defp set_values(nil), 27 | do: raise(ArgumentError, "Manual scales must be passed a :values option with scale values.") 28 | 29 | defp set_values([value | _other_values] = values) when is_binary(value) do 30 | values 31 | end 32 | 33 | @spec train(Shape.Manual.t(), list(binary())) :: Shape.Manual.t() 34 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 35 | number_of_levels = length(levels) 36 | 37 | palette = 38 | scale.values 39 | |> Stream.cycle() 40 | |> Enum.take(number_of_levels) 41 | |> List.to_tuple() 42 | 43 | values_map = 44 | levels 45 | |> Enum.with_index(fn level, index -> 46 | {level, elem(palette, index)} 47 | end) 48 | |> Map.new() 49 | 50 | struct(scale, levels: levels, transform: fn value -> values_map[to_string(value)] end) 51 | end 52 | 53 | @spec draw_legend(Shape.Manual.t(), binary(), number(), keyword()) :: iolist() 54 | def draw_legend(%Shape.Manual{guide: :none}, _label, _key_height, _fixed_aesthetics), do: [] 55 | 56 | def draw_legend(%Shape.Manual{levels: []}, _label, _key_height, _fixed_aesthetics), do: [] 57 | 58 | def draw_legend(%Shape.Manual{levels: [_]}, _label, _key_height, _fixed_aesthetics), do: [] 59 | 60 | def draw_legend(%Shape.Manual{levels: levels} = scale, label, key_height, fixed_aesthetics) do 61 | [ 62 | Draw.text( 63 | "#{label}", 64 | x: "0", 65 | y: "-5", 66 | class: "gg-text gg-legend-title", 67 | text_anchor: "left" 68 | ), 69 | Enum.with_index(levels, fn level, index -> 70 | draw_legend_item(scale, {level, index}, key_height, fixed_aesthetics) 71 | end) 72 | ] 73 | end 74 | 75 | defp draw_legend_item(scale, {level, index}, key_height, fixed_aesthetics) do 76 | marker = scale.transform.(level) 77 | 78 | size = 79 | case marker do 80 | character when is_binary(character) -> 7 / 15 * key_height 81 | _otherwise -> key_height / 3 82 | end 83 | 84 | [ 85 | Draw.rect( 86 | x: "0", 87 | y: "#{key_height * index}", 88 | height: key_height, 89 | width: key_height, 90 | class: "gg-legend-key" 91 | ), 92 | GGity.Shapes.draw( 93 | marker, 94 | {key_height / 2, key_height / 2 + key_height * index}, 95 | :math.pow(1 + size, 2), 96 | fill: fixed_aesthetics[:fill], 97 | color: fixed_aesthetics[:color], 98 | fill_opacity: fixed_aesthetics[:alpha] 99 | ), 100 | Draw.text( 101 | "#{Labels.format(scale, level)}", 102 | x: "#{5 + key_height}", 103 | y: "#{10 + key_height * index}", 104 | class: "gg-text gg-legend-text", 105 | text_anchor: "left" 106 | ) 107 | ] 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/ggity/scale/size.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Size do 2 | @moduledoc false 3 | 4 | alias GGity.{Draw, Labels} 5 | alias GGity.Scale.{Continuous, Size} 6 | 7 | @base_intervals [0.1, 0.2, 0.25, 0.4, 0.5, 0.75, 1.0, 2.0, 2.5, 4.0, 5.0, 7.5, 10] 8 | 9 | defstruct range: {1, 6}, 10 | breaks: 4, 11 | labels: :waivers, 12 | tick_values: [], 13 | inverse: nil, 14 | transform: nil, 15 | guide: :legend 16 | 17 | @type t() :: %__MODULE__{} 18 | 19 | @spec new(keyword()) :: Size.t() 20 | def new(options \\ []), do: struct(Size, options) 21 | 22 | @spec train(Size.t(), {number(), number()}) :: Size.t() 23 | def train(scale, {min, max}) when is_number(min) and is_number(max) do 24 | range = max - min 25 | struct(scale, transformations(range, min, max, scale)) 26 | end 27 | 28 | defp transformations(range, min, max, %Size{} = scale) do 29 | raw_interval_size = range / (scale.breaks - 1) 30 | order_of_magnitude = :math.ceil(:math.log10(raw_interval_size) - 1) 31 | power_of_ten = :math.pow(10, order_of_magnitude) 32 | adjusted_interval_size = axis_interval_lookup(raw_interval_size / power_of_ten) * power_of_ten 33 | adjusted_min = adjusted_interval_size * Float.floor(min / adjusted_interval_size) 34 | adjusted_max = adjusted_interval_size * Float.ceil(max / adjusted_interval_size) 35 | 36 | adjusted_interval_count = 37 | round(1.0001 * (adjusted_max - adjusted_min) / adjusted_interval_size) 38 | 39 | # TODO - generalize/fix this so it is not different from other continuous scales 40 | tick_values = 41 | Enum.map( 42 | 1..(adjusted_interval_count + 1), 43 | &(adjusted_min + &1 * adjusted_interval_size) 44 | # &(adjusted_min + (&1 - 1) * adjusted_interval_size) 45 | ) 46 | 47 | # This does not seem like it is exactly right depending on the shape 48 | # But it appears to be what ggplot2 does: 49 | # https://github.com/tidyverse/ggplot2/blob/master/R/scale-size.r 50 | # https://github.com/r-lib/scales/blob/master/R/pal-area.r 51 | domain_min = :math.pow(elem(scale.range, 0), 2) 52 | domain_max = :math.pow(elem(scale.range, 1), 2) 53 | transform = Continuous.transform({adjusted_min, adjusted_max}, {domain_min, domain_max}) 54 | 55 | [ 56 | tick_values: tick_values, 57 | inverse: transform, 58 | transform: transform 59 | ] 60 | end 61 | 62 | defp axis_interval_lookup(value) do 63 | Enum.find(@base_intervals, &(&1 >= value)) 64 | end 65 | 66 | @spec draw_legend(Size.t(), binary(), atom(), number(), keyword()) :: iolist() 67 | def draw_legend(%Size{guide: :none}, _label, _key_glyph, _key_height, _fixed_aesthetics), do: [] 68 | 69 | def draw_legend(%Size{} = scale, label, key_glyph, key_height, fixed_aesthetics) do 70 | max_value = Enum.max(scale.tick_values) 71 | key_width = max(key_height, 2 * :math.sqrt(scale.transform.(max_value) / :math.pi())) 72 | 73 | [ 74 | Draw.text( 75 | "#{label}", 76 | x: "0", 77 | y: "-5", 78 | class: "gg-text gg-legend-title", 79 | text_anchor: "left" 80 | ), 81 | scale.tick_values 82 | |> Enum.map_reduce(0, fn value, y_position -> 83 | draw_legend_item( 84 | scale, 85 | value, 86 | key_glyph, 87 | key_height, 88 | key_width, 89 | y_position, 90 | fixed_aesthetics 91 | ) 92 | end) 93 | |> elem(0) 94 | ] 95 | end 96 | 97 | defp draw_legend_item( 98 | scale, 99 | value, 100 | key_glyph, 101 | key_height, 102 | key_width, 103 | y_position, 104 | fixed_aesthetics 105 | ) do 106 | key_height = max(key_height, 2 * :math.sqrt(scale.transform.(value) / :math.pi())) 107 | 108 | { 109 | [ 110 | Draw.rect( 111 | x: "0", 112 | y: "#{y_position}", 113 | height: key_height, 114 | width: key_width, 115 | class: "gg-legend-key" 116 | ), 117 | draw_key_glyph( 118 | scale, 119 | value, 120 | key_glyph, 121 | key_height, 122 | key_width, 123 | y_position, 124 | fixed_aesthetics 125 | ), 126 | Draw.text( 127 | "#{Labels.format(scale, value)}", 128 | x: "#{5 + key_width}", 129 | y: "#{key_height / 2 + y_position}", 130 | class: "gg-text gg-legend-text", 131 | text_anchor: "left", 132 | dominant_baseline: "middle" 133 | ) 134 | ], 135 | y_position + key_height 136 | } 137 | end 138 | 139 | defp draw_key_glyph(scale, value, :a, key_height, key_width, y_position, fixed_aesthetics) do 140 | Draw.text( 141 | "a", 142 | x: "#{key_width / 2}", 143 | y: "#{key_height / 2 + y_position}", 144 | font_size: "#{scale.transform.(value)}pt", 145 | fill: fixed_aesthetics[:color], 146 | text_anchor: "left" 147 | ) 148 | end 149 | 150 | defp draw_key_glyph( 151 | scale, 152 | value, 153 | _key_glyph, 154 | key_height, 155 | key_width, 156 | y_position, 157 | fixed_aesthetics 158 | ) do 159 | GGity.Shapes.draw( 160 | fixed_aesthetics[:shape], 161 | {key_width / 2, key_height / 2 + y_position}, 162 | scale.inverse.(value), 163 | color: fixed_aesthetics[:color], 164 | fill_opacity: fixed_aesthetics[:alpha] 165 | ) 166 | end 167 | end 168 | 169 | # The process here needs to be: 170 | 171 | # For each variable 172 | # 1) for each layer 173 | # 2) if that variable is mapped to one or more aesthetics with a legend as a guide 174 | # 3) get the breaks for the legend 175 | 176 | # You don't need to merge aesthetics across layers, you just draw glyphs on top of one another 177 | # What data do I need? 178 | # --A list of all the variables mapped to something with a legend 179 | # --Groups of layers, one for each variable 180 | # -- For each group, need the layers, the scale 181 | 182 | # For first layer - is there mapping to a variable with a legend as a guide? 183 | # If yes, note that and get the fixed aesthetics mapped for that layer 184 | # Go through the rest of the layers and do the same thing 185 | # With that list, for each break in the scale draw a glyph for each layer 186 | 187 | # So what have we learned 188 | # A legend displays specific values (breaks) and a glyph mapping that value to the applicable aesthetic 189 | # A colorbar is not a legend, it is a thing that draws a gradient over the range of the color aesthetic with tick 190 | # marks at the breaks 191 | # There is also a guide_bins, which displays the cutoffs for each bin on each side (top/bottom) of the glyph 192 | # for that bin 193 | # ggplot2 doesn't try to merge different kinds of guides 194 | -------------------------------------------------------------------------------- /lib/ggity/scale/size_manual.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Size.Manual do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.Size 5 | 6 | @default_size 4 7 | 8 | @type t() :: %__MODULE__{} 9 | 10 | defstruct transform: nil 11 | 12 | @spec new(number()) :: Size.Manual.t() 13 | def new(size \\ @default_size) when is_number(size) do 14 | struct(Size.Manual, transform: fn _size -> size end) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/ggity/scale/x_continuous.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.X.Continuous do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.{Continuous, X} 5 | 6 | @base_axis_intervals [0.1, 0.2, 0.25, 0.4, 0.5, 0.75, 1.0, 2.0, 2.5, 4.0, 5.0, 7.5, 10] 7 | 8 | @type t() :: %__MODULE__{} 9 | @type mapping() :: map() 10 | 11 | defstruct width: 200, 12 | breaks: 5, 13 | labels: :waivers, 14 | tick_values: nil, 15 | inverse: nil, 16 | transform: nil 17 | 18 | @spec new(keyword()) :: X.Continuous.t() 19 | def new(options \\ []), do: struct(X.Continuous, options) 20 | 21 | @spec train(X.Continuous.t(), {number(), number()}) :: X.Continuous.t() 22 | def train(scale, {min, max}) do 23 | range = max - min 24 | struct(scale, transformations(range, min, max, scale)) 25 | end 26 | 27 | defp transformations(0, min, _max, %X.Continuous{} = scale) do 28 | [ 29 | tick_values: min, 30 | inverse: fn _value -> min end, 31 | transform: fn _value -> scale.width / 2 end 32 | ] 33 | end 34 | 35 | # Many parts of this library are influenced by ContEx, but this part (which is itself copied 36 | # in GGity.Scale.Y.Continuous) is more or less flat-out copied - license acknowledgement below: 37 | 38 | # Copyright (c) 2020 John Jessop (mindOk) 39 | 40 | # Permission is hereby granted, free of charge, to any person obtaining a copy 41 | # of this software and associated documentation files (the "Software"), to deal 42 | # in the Software without restriction, including without limitation the rights 43 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 44 | # copies of the Software, and to permit persons to whom the Software is 45 | # furnished to do so, subject to the following conditions: 46 | 47 | # The above copyright notice and this permission notice shall be included in all 48 | # copies or substantial portions of the Software. 49 | 50 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 51 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 52 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 53 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 54 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 55 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 56 | # SOFTWARE. 57 | 58 | defp transformations(range, min, max, %X.Continuous{} = scale) do 59 | raw_interval_size = range / (scale.breaks - 1) 60 | order_of_magnitude = :math.ceil(:math.log10(raw_interval_size) - 1) 61 | power_of_ten = :math.pow(10, order_of_magnitude) 62 | adjusted_interval_size = axis_interval_lookup(raw_interval_size / power_of_ten) * power_of_ten 63 | adjusted_min = adjusted_interval_size * Float.floor(min / adjusted_interval_size) 64 | adjusted_max = adjusted_interval_size * Float.ceil(max / adjusted_interval_size) 65 | 66 | adjusted_interval_count = 67 | round(1.0001 * (adjusted_max - adjusted_min) / adjusted_interval_size) 68 | 69 | tick_values = 70 | Enum.map( 71 | 1..(adjusted_interval_count + 1), 72 | &(adjusted_min + (&1 - 1) * adjusted_interval_size) 73 | ) 74 | 75 | [ 76 | tick_values: tick_values, 77 | inverse: Continuous.transform({adjusted_min, adjusted_max}, {0, scale.width}), 78 | transform: Continuous.transform({adjusted_min, adjusted_max}, {0, scale.width}) 79 | ] 80 | end 81 | 82 | defp axis_interval_lookup(value) do 83 | Enum.find(@base_axis_intervals, &(&1 >= value)) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/ggity/scale/x_discrete.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.X.Discrete do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.X 5 | 6 | @type t() :: %__MODULE__{} 7 | @type record() :: map() 8 | @type mapping() :: map() 9 | 10 | defstruct width: 200, 11 | levels: nil, 12 | labels: :waivers, 13 | padding: 5, 14 | tick_values: nil, 15 | inverse: nil, 16 | transform: nil 17 | 18 | @spec new(keyword()) :: X.Discrete.t() 19 | def new(options \\ []), do: struct(X.Discrete, options) 20 | 21 | @spec train(X.Discrete.t(), list(binary())) :: X.Discrete.t() 22 | def train(scale, [level | _other_levels] = levels) when is_list(levels) and is_binary(level) do 23 | scale = struct(scale, levels: levels) 24 | struct(scale, transformations(scale)) 25 | end 26 | 27 | defp transformations(scale) do 28 | number_of_levels = length(scale.levels) 29 | 30 | values_map = 31 | scale.levels 32 | |> Stream.with_index() 33 | |> Stream.map(fn {level, index} -> 34 | group_width = (scale.width - number_of_levels * (scale.padding - 1)) / number_of_levels 35 | {level, group_width * (index + 0.5) + index * scale.padding} 36 | end) 37 | |> Enum.into(%{}) 38 | 39 | transform = fn value -> values_map[to_string(value)] end 40 | 41 | [ 42 | tick_values: scale.levels, 43 | inverse: transform, 44 | transform: transform 45 | ] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ggity/scale/y_continuous.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Scale.Y.Continuous do 2 | @moduledoc false 3 | 4 | alias GGity.Scale.{Continuous, Y} 5 | 6 | @base_axis_intervals [0.1, 0.2, 0.25, 0.4, 0.5, 0.75, 1.0, 2.0, 2.5, 4.0, 5.0, 7.5, 10] 7 | 8 | @type t() :: %__MODULE__{} 9 | @type mapping() :: map() 10 | 11 | defstruct width: 200, 12 | breaks: 5, 13 | labels: :waivers, 14 | tick_values: nil, 15 | inverse: nil, 16 | transform: nil 17 | 18 | @spec new(keyword()) :: Y.Continuous.t() 19 | def new(options \\ []), do: struct(Y.Continuous, options) 20 | 21 | @spec train(Y.Continuous.t(), {number(), number()}) :: Y.Continuous.t() 22 | def train(scale, {min, max}) do 23 | range = max - min 24 | struct(scale, transformations(range, min, max, scale)) 25 | end 26 | 27 | defp transformations(0, min, _max, %Y.Continuous{} = scale) do 28 | [ 29 | tick_values: [min], 30 | inverse: fn _value -> scale.width / 2 end, 31 | transform: fn _value -> scale.width / 2 end 32 | ] 33 | end 34 | 35 | defp transformations(range, min, max, %Y.Continuous{} = scale) do 36 | raw_interval_size = range / (scale.breaks - 1) 37 | order_of_magnitude = :math.ceil(:math.log10(raw_interval_size) - 1) 38 | power_of_ten = :math.pow(10, order_of_magnitude) 39 | adjusted_interval_size = axis_interval_lookup(raw_interval_size / power_of_ten) * power_of_ten 40 | adjusted_min = adjusted_interval_size * Float.floor(min / adjusted_interval_size) 41 | adjusted_max = adjusted_interval_size * Float.ceil(max / adjusted_interval_size) 42 | 43 | adjusted_interval_count = 44 | round(1.0001 * (adjusted_max - adjusted_min) / adjusted_interval_size) 45 | 46 | tick_values = 47 | Enum.map( 48 | 1..(adjusted_interval_count + 1), 49 | &(adjusted_min + (&1 - 1) * adjusted_interval_size) 50 | ) 51 | 52 | [ 53 | tick_values: tick_values, 54 | inverse: Continuous.transform({adjusted_min, adjusted_max}, {0, scale.width}), 55 | transform: Continuous.transform({adjusted_min, adjusted_max}, {0, scale.width}) 56 | ] 57 | end 58 | 59 | defp axis_interval_lookup(value) do 60 | Enum.find(@base_axis_intervals, &(&1 >= value)) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/ggity/stat.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Stat do 2 | @moduledoc false 3 | 4 | @type dataset :: Explorer.DataFrame.t() 5 | 6 | @doc false 7 | @spec identity(dataset(), map()) :: {dataset(), map()} 8 | def identity(data, mapping), do: {data, mapping} 9 | 10 | @spec count(dataset(), map()) :: {dataset(), map()} 11 | def count(data, mapping) do 12 | discrete_variables = discrete_variables(data, mapping) 13 | 14 | stat = 15 | data 16 | |> Explorer.DataFrame.group_by(discrete_variables) 17 | |> Explorer.DataFrame.summarise_with(&[count: Explorer.Series.count(&1[mapping[:x]])]) 18 | |> Explorer.DataFrame.arrange_with(& &1[mapping[:x]]) 19 | 20 | mapping = Map.put(mapping, :y, "count") 21 | {stat, mapping} 22 | end 23 | 24 | @spec boxplot(dataset(), map()) :: {dataset(), map()} 25 | def boxplot(data, mapping) do 26 | discrete_aesthetics = discrete_aesthetics(data, mapping) 27 | permutations = permutations(discrete_aesthetics, data, mapping) 28 | data = Explorer.DataFrame.to_rows(data) 29 | 30 | stat = 31 | permutations 32 | |> Enum.reduce([], fn permutation, stat -> 33 | [ 34 | discrete_aesthetics 35 | |> Map.new(fn aesthetic -> 36 | {mapping[aesthetic], permutation[aesthetic]} 37 | end) 38 | |> Map.merge(boxplot_stats_map(data, mapping, permutation)) 39 | | stat 40 | ] 41 | end) 42 | |> Enum.sort_by(fn row -> row[mapping[:x]] end) 43 | |> Explorer.DataFrame.new(dtypes: [{"outliers", :binary}]) 44 | 45 | {stat, mapping} 46 | end 47 | 48 | defp boxplot_stats_map(data, mapping, permutation) do 49 | permutation_data = 50 | data 51 | |> Enum.filter(fn row -> 52 | Enum.map(permutation, fn {k, _v} -> row[mapping[k]] end) == 53 | Enum.map(permutation, fn {_k, v} -> v end) 54 | end) 55 | |> Enum.map(fn row -> row[mapping[:y]] end) 56 | 57 | permutation_series = Explorer.Series.from_list(permutation_data) 58 | 59 | quantiles = 60 | for quantile <- [0.25, 0.5, 0.75], 61 | do: {quantile, Explorer.Series.quantile(permutation_series, quantile)}, 62 | into: %{} 63 | 64 | interquartile_range = quantiles[0.75] - quantiles[0.25] 65 | ymin_threshold = quantiles[0.25] - 1.5 * interquartile_range 66 | ymax_threshold = quantiles[0.75] + 1.5 * interquartile_range 67 | 68 | outliers = 69 | for record <- permutation_data, 70 | record > ymax_threshold or record < ymin_threshold, 71 | do: record 72 | 73 | %{ 74 | "ymin" => 75 | permutation_series 76 | |> Explorer.Series.mask(Explorer.Series.greater_equal(permutation_series, ymin_threshold)) 77 | |> Explorer.Series.min(), 78 | "lower" => quantiles[0.25], 79 | "middle" => quantiles[0.5], 80 | "upper" => quantiles[0.75], 81 | "ymax" => 82 | permutation_series 83 | |> Explorer.Series.mask(Explorer.Series.less_equal(permutation_series, ymax_threshold)) 84 | |> Explorer.Series.max(), 85 | "outliers" => :erlang.term_to_binary(outliers) 86 | } 87 | end 88 | 89 | defp discrete_variables(data, mapping) do 90 | for {name, series} <- Explorer.DataFrame.to_series(data), 91 | Explorer.Series.dtype(series) == :string or name == mapping[:x], 92 | name in Map.values(mapping), 93 | do: name 94 | end 95 | 96 | defp discrete_aesthetics(data, mapping) do 97 | discrete_variables = discrete_variables(data, mapping) 98 | for {aesthetic, variable} <- mapping, variable in discrete_variables, do: aesthetic 99 | end 100 | 101 | defp permutations(discrete_aesthetics, data, mapping) do 102 | data = Explorer.DataFrame.to_rows(data) 103 | 104 | for row <- data, 105 | uniq: true, 106 | do: 107 | discrete_aesthetics 108 | |> Enum.map(fn aesthetic -> {aesthetic, row[mapping[aesthetic]]} end) 109 | |> Map.new() 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/annotate.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Annotate do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.mtcars() 10 | |> Plot.new(%{x: :wt, y: :mpg}) 11 | |> Plot.geom_point() 12 | |> Plot.annotate(:text, x: 4, y: 25, label: "Some text") 13 | """, 14 | """ 15 | Examples.mtcars() 16 | |> Plot.new(%{x: :wt, y: :mpg}) 17 | |> Plot.geom_point() 18 | |> Plot.annotate(:rect, xmin: 3, xmax: 4.2, ymin: 12, ymax: 21, alpha: 0.2) 19 | """, 20 | """ 21 | Examples.mtcars() 22 | |> Plot.new(%{x: :wt, y: :mpg}) 23 | |> Plot.geom_point() 24 | |> Plot.annotate(:segment, x: 2.5, xend: 4, y: 15, yend: 25, color: "blue") 25 | """ 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/geom_bar.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Geom.Bar do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.mpg() 10 | |> Plot.new(%{x: "class"}) 11 | |> Plot.geom_bar() 12 | """, 13 | """ 14 | Examples.mpg() 15 | |> Plot.new(%{x: "class"}) 16 | |> Plot.geom_bar(%{fill: "drv"}) 17 | """ 18 | ] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/geom_boxplot.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Geom.Boxplot do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.mpg() 10 | |> Plot.new(%{x: "class", y: "hwy"}) 11 | |> Plot.geom_boxplot() 12 | """, 13 | """ 14 | Examples.mpg() 15 | |> Plot.new(%{x: "class", y: "hwy"}) 16 | |> Plot.geom_boxplot(fill: "white", color: "#3366FF") 17 | """, 18 | """ 19 | Examples.mpg() 20 | |> Plot.new(%{x: "class", y: "hwy"}) 21 | |> Plot.geom_boxplot(outlier_color: "red") 22 | """, 23 | """ 24 | Examples.mpg() 25 | |> Plot.new(%{x: "class", y: "hwy"}) 26 | |> Plot.geom_boxplot(%{color: "drv"}) 27 | """ 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/geom_line.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Geom.Line do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.economics() 10 | |> Plot.new(%{x: "date", y: "unemploy"}) 11 | |> Plot.geom_line() 12 | """, 13 | """ 14 | Examples.economics_long() 15 | |> Plot.new(%{x: "date", y: "value01", color: "variable"}) 16 | |> Plot.geom_line() 17 | """, 18 | """ 19 | Examples.economics() 20 | |> Plot.new(%{x: "date", y: "unemploy"}) 21 | |> Plot.geom_line(color: "red") 22 | """ 23 | ] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/geom_point.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Geom.Point do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.mtcars() 10 | |> Plot.new(%{x: :wt, y: :mpg}) 11 | |> Plot.geom_point() 12 | """, 13 | """ 14 | # Add aesthetic mapping to color 15 | Examples.mtcars() 16 | |> Plot.new(%{x: :wt, y: :mpg}) 17 | |> Plot.geom_point(%{color: :cyl}) 18 | """, 19 | """ 20 | # Add aesthetic mapping to shape 21 | Examples.mtcars() 22 | |> Plot.new(%{x: :wt, y: :mpg}) 23 | |> Plot.geom_point(%{shape: :cyl}) 24 | """, 25 | """ 26 | # Add aesthetic mapping to size (for circles, a bubble chart) 27 | Examples.mtcars() 28 | |> Plot.new(%{x: :wt, y: :mpg}) 29 | |> Plot.geom_point(%{size: :qsec}) 30 | """, 31 | """ 32 | # Set aesthetics to fixed value 33 | Examples.mtcars() 34 | |> Plot.new(%{x: :wt, y: :mpg}) 35 | |> Plot.geom_point(color: "red", size: 5) 36 | """ 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/geom_text.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Geom.Text do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.mtcars() 10 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 11 | |> Plot.geom_text() 12 | """, 13 | """ 14 | # Set the font size for the label 15 | Examples.mtcars() 16 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 17 | |> Plot.geom_text(size: 10) 18 | """, 19 | """ 20 | # Shift positioning 21 | Examples.mtcars() 22 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 23 | |> Plot.geom_point(size: 2) 24 | |> Plot.geom_text(size: 5, hjust: :left, nudge_x: 3) 25 | """, 26 | """ 27 | Examples.mtcars() 28 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 29 | |> Plot.geom_point(size: 2) 30 | |> Plot.geom_text(size: 5, vjust: :top, nudge_y: 3) 31 | """, 32 | """ 33 | # Map other aesthetics 34 | Examples.mtcars() 35 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 36 | |> Plot.geom_text(%{color: :cyl}, size: 5) 37 | """, 38 | """ 39 | # Add a text annotation 40 | Examples.mtcars() 41 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 42 | |> Plot.geom_text(size: 5) 43 | |> Plot.annotate(:text, label: "plot mpg vs. wt", x: 1.5, y: 15, size: 8, color: "red") 44 | """, 45 | """ 46 | # Bar chart labelling 47 | [%{x: "1", y: 1, grp: "a"}, 48 | %{x: "1", y: 3, grp: "b"}, 49 | %{x: "2", y: 2, grp: "a"}, 50 | %{x: "2", y: 1, grp: "b"},] 51 | |> Plot.new(%{x: "x", y: "y", group: "grp"}) 52 | |> Plot.geom_col(%{fill: "grp"}, position: :dodge) 53 | |> Plot.geom_text(%{label: "y"}, position: :dodge, size: 6) 54 | """, 55 | """ 56 | # Nudge the label up a bit 57 | [%{x: "1", y: 1, grp: "a"}, 58 | %{x: "1", y: 3, grp: "b"}, 59 | %{x: "2", y: 2, grp: "a"}, 60 | %{x: "2", y: 1, grp: "b"},] 61 | |> Plot.new(%{x: "x", y: "y", group: "grp"}) 62 | |> Plot.geom_col(%{fill: "grp"}, position: :dodge) 63 | |> Plot.geom_text(%{label: "y"}, position: :dodge, size: 6, nudge_y: 4) 64 | """, 65 | """ 66 | # Position label in the middle of stacked bars 67 | [%{x: "1", y: 1, grp: "a"}, 68 | %{x: "1", y: 3, grp: "b"}, 69 | %{x: "2", y: 2, grp: "a"}, 70 | %{x: "2", y: 1, grp: "b"},] 71 | |> Plot.new(%{x: "x", y: "y", group: "grp"}) 72 | |> Plot.geom_col(%{fill: "grp"}) 73 | |> Plot.geom_text(%{label: "y"}, position: :stack, position_vjust: 0.5, size: 6) 74 | """ 75 | ] 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/scale_color_viridis.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Scale.Color.Viridis do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | # The viridis scale is the default color scale 10 | Examples.diamonds() 11 | |> Explorer.DataFrame.sample(1000, seed: 100) 12 | |> Plot.new(%{x: "carat", y: "price"}) 13 | |> Plot.geom_point(%{color: "clarity"}) 14 | """, 15 | """ 16 | # Use the :option option to select a palette 17 | cities = Explorer.Series.from_list([ 18 | "Houston", 19 | "Fort Worth", 20 | "San Antonio", 21 | "Dallas", 22 | "Austin" 23 | ]) 24 | 25 | Examples.tx_housing() 26 | |> Explorer.DataFrame.filter_with(&Explorer.Series.in(&1["city"], cities)) 27 | |> Plot.new(%{x: "sales", y: "median"}) 28 | |> Plot.geom_point(%{color: "city"}) 29 | |> Plot.scale_color_viridis(option: :plasma) 30 | """, 31 | """ 32 | cities = Explorer.Series.from_list([ 33 | "Houston", 34 | "Fort Worth", 35 | "San Antonio", 36 | "Dallas", 37 | "Austin" 38 | ]) 39 | 40 | Examples.tx_housing() 41 | |> Explorer.DataFrame.filter_with(&Explorer.Series.in(&1["city"], cities)) 42 | |> Plot.new(%{x: "sales", y: "median"}) 43 | |> Plot.geom_point(%{color: "city"}) 44 | |> Plot.scale_color_viridis(option: :inferno) 45 | """ 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/mix/tasks/doc_examples/theme.ex: -------------------------------------------------------------------------------- 1 | defmodule GGity.Docs.Theme do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec examples() :: iolist() 6 | def examples do 7 | [ 8 | """ 9 | Examples.mtcars() 10 | |> Plot.new(%{x: :wt, y: :mpg}) 11 | |> Plot.geom_point() 12 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 13 | """, 14 | """ 15 | # Examples below assume that element constructors are imported 16 | # e.g. `import GGity.Element.{Line, Rect, Text} 17 | 18 | # Plot formatting 19 | Examples.mtcars() 20 | |> Plot.new(%{x: :wt, y: :mpg}) 21 | |> Plot.geom_point() 22 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 23 | |> Plot.theme(plot_title: element_text(size: 10)) 24 | """, 25 | """ 26 | Examples.mtcars() 27 | |> Plot.new(%{x: :wt, y: :mpg}) 28 | |> Plot.geom_point() 29 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 30 | |> Plot.theme(plot_background: element_rect(fill: "green")) 31 | """, 32 | """ 33 | # Panel formatting 34 | Examples.mtcars() 35 | |> Plot.new(%{x: :wt, y: :mpg}) 36 | |> Plot.geom_point() 37 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 38 | |> Plot.theme(panel_background: element_rect(fill: "white", color: "grey")) 39 | """, 40 | # TODO: Major gridlines should be drawn on top of minor gridlines. 41 | # Unfortunately we draw each axis and gridlines set as one SVG group, 42 | # so this will required material changes to the axis-drawing internals. 43 | """ 44 | Examples.mtcars() 45 | |> Plot.new(%{x: :wt, y: :mpg}) 46 | |> Plot.geom_point() 47 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 48 | |> Plot.theme(panel_grid_major: element_line(color: "black")) 49 | """, 50 | """ 51 | # Axis formatting 52 | Examples.mtcars() 53 | |> Plot.new(%{x: :wt, y: :mpg}) 54 | |> Plot.geom_point() 55 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 56 | |> Plot.theme(axis_line: element_line(size: 6, color: "grey")) 57 | """, 58 | """ 59 | Examples.mtcars() 60 | |> Plot.new(%{x: :wt, y: :mpg}) 61 | |> Plot.geom_point() 62 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 63 | |> Plot.theme(axis_text: element_text(color: "blue")) 64 | """, 65 | """ 66 | Examples.mtcars() 67 | |> Plot.new(%{x: :wt, y: :mpg}) 68 | |> Plot.geom_point() 69 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 70 | |> Plot.theme(axis_ticks: element_line(size: 4)) 71 | """, 72 | """ 73 | # Turn the x-axis ticks inward 74 | Examples.mtcars() 75 | |> Plot.new(%{x: :wt, y: :mpg}) 76 | |> Plot.geom_point() 77 | |> Plot.labs(title: "Fuel economy declines as weight decreases") 78 | |> Plot.theme(axis_ticks_length_x: -2) 79 | """, 80 | """ 81 | # GGity does not support legend position, but legend key boxes 82 | # and text can be styled as you would expect 83 | 84 | # Default styling 85 | Examples.mtcars() 86 | |> Plot.new(%{x: :wt, y: :mpg}) 87 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 88 | |> Plot.labs( 89 | x: "Weight (1000 lbs)", 90 | y: "Fuel economy (mpg)", 91 | color: "Cylinders", 92 | shape: "Transmission" 93 | ) 94 | """, 95 | """ 96 | # Style legend keys 97 | Examples.mtcars() 98 | |> Plot.new(%{x: :wt, y: :mpg}) 99 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 100 | |> Plot.labs( 101 | x: "Weight (1000 lbs)", 102 | y: "Fuel economy (mpg)", 103 | color: "Cylinders", 104 | shape: "Transmission" 105 | ) 106 | |> Plot.theme(legend_key: element_rect(fill: "white", color: "black")) 107 | """, 108 | """ 109 | # Style legend text 110 | Examples.mtcars() 111 | |> Plot.new(%{x: :wt, y: :mpg}) 112 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 113 | |> Plot.labs( 114 | x: "Weight (1000 lbs)", 115 | y: "Fuel economy (mpg)", 116 | color: "Cylinders", 117 | shape: "Transmission" 118 | ) 119 | |> Plot.theme(legend_text: element_text(size: 4, color: "red")) 120 | """, 121 | """ 122 | # Style legend title 123 | Examples.mtcars() 124 | |> Plot.new(%{x: :wt, y: :mpg}) 125 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 126 | |> Plot.labs( 127 | x: "Weight (1000 lbs)", 128 | y: "Fuel economy (mpg)", 129 | color: "Cylinders", 130 | shape: "Transmission" 131 | ) 132 | |> Plot.theme(legend_title: element_text(face: "bold")) 133 | """ 134 | ] 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/mix/tasks/docs.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ggity.Docs do 2 | @shortdoc "Generates guides documentation." 3 | @moduledoc @shortdoc 4 | 5 | use Mix.Task 6 | alias GGity.Docs 7 | 8 | @examples [ 9 | Docs.Geom.Point, 10 | Docs.Geom.Line, 11 | Docs.Geom.Bar, 12 | Docs.Geom.Boxplot, 13 | Docs.Geom.Text, 14 | Docs.Scale.Color.Viridis, 15 | Docs.Theme, 16 | Docs.Annotate 17 | ] 18 | 19 | @aliases "alias GGity.{Examples, Plot}\n" 20 | 21 | @element_import "import GGity.Element.{Line, Rect, Text}\n" 22 | 23 | @docs_code """ 24 | |> Plot.plot() 25 | """ 26 | 27 | @to_file_code """ 28 | |> Plot.to_xml(550) 29 | """ 30 | 31 | @doc false 32 | @spec run(list(any)) :: list(:ok) 33 | def run(_argv) do 34 | for example <- @examples, do: guides_for(example) 35 | end 36 | 37 | defp guides_for(example) do 38 | [_elixir, _ggity, _docs | example_module] = 39 | example 40 | |> Atom.to_string() 41 | |> String.downcase() 42 | |> String.split(".") 43 | 44 | name = Enum.join(example_module, "_") 45 | 46 | guide_content = 47 | example 48 | |> apply(:examples, []) 49 | |> Enum.with_index(1) 50 | |> Enum.map(fn example -> generate_example(name, example) end) 51 | 52 | File.write!("guides/#{name}.md", guide_content) 53 | end 54 | 55 | defp generate_example(name, {example, index}) do 56 | image_example(name, example, index) 57 | doc_example(name, example <> @docs_code, index) 58 | end 59 | 60 | defp image_example(name, example, index) do 61 | full_code = @aliases <> @element_import <> example <> @to_file_code 62 | {image, _mystery_list} = Code.eval_string(full_code) 63 | file = "guides/assets/#{name}_#{index}.svg" 64 | File.write!(file, image) 65 | end 66 | 67 | defp doc_example(name, example, index) do 68 | """ 69 | ``` 70 | #{example} 71 | ``` 72 | ![](assets/#{name}_#{index}.svg) 73 | """ 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/mix/tasks/examples.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Readability.Specs 2 | defmodule GGity.Examples do 3 | @moduledoc false 4 | 5 | def mtcars do 6 | headers = [:model, :mpg, :cyl, :disp, :hp, :drat, :wt, :qsec, :vs, :am, :gear, :carb] 7 | 8 | data = [ 9 | ["Mazda RX4", 21, 6, 160, 110, 3.9, 2.62, 16.46, 0, 1, 4, 4], 10 | ["Mazda RX4 Wag", 21, 6, 160, 110, 3.9, 2.875, 17.02, 0, 1, 4, 4], 11 | ["Datsun 710", 22.8, 4, 108, 93, 3.85, 2.32, 18.61, 1, 1, 4, 1], 12 | ["Hornet 4 Drive", 21.4, 6, 258, 110, 3.08, 3.215, 19.44, 1, 0, 3, 1], 13 | ["Hornet Sportabout", 18.7, 8, 360, 175, 3.15, 3.44, 17.02, 0, 0, 3, 2], 14 | ["Valiant", 18.1, 6, 225, 105, 2.76, 3.46, 20.22, 1, 0, 3, 1], 15 | ["Duster 360", 14.3, 8, 360, 245, 3.21, 3.57, 15.84, 0, 0, 3, 4], 16 | ["Merc 240D", 24.4, 4, 146.7, 62, 3.69, 3.19, 20, 1, 0, 4, 2], 17 | ["Merc 230", 22.8, 4, 140.8, 95, 3.92, 3.15, 22.9, 1, 0, 4, 2], 18 | ["Merc 280", 19.2, 6, 167.6, 123, 3.92, 3.44, 18.3, 1, 0, 4, 4], 19 | ["Merc 280C", 17.8, 6, 167.6, 123, 3.92, 3.44, 18.9, 1, 0, 4, 4], 20 | ["Merc 450SE", 16.4, 8, 275.8, 180, 3.07, 4.07, 17.4, 0, 0, 3, 3], 21 | ["Merc 450SL", 17.3, 8, 275.8, 180, 3.07, 3.73, 17.6, 0, 0, 3, 3], 22 | ["Merc 450SLC", 15.2, 8, 275.8, 180, 3.07, 3.78, 18, 0, 0, 3, 3], 23 | ["Cadillac Fleetwood", 10.4, 8, 472, 205, 2.93, 5.25, 17.98, 0, 0, 3, 4], 24 | ["Lincoln Continental", 10.4, 8, 460, 215, 3, 5.424, 17.82, 0, 0, 3, 4], 25 | ["Chrysler Imperial", 14.7, 8, 440, 230, 3.23, 5.345, 17.42, 0, 0, 3, 4], 26 | ["Fiat 128", 32.4, 4, 78.7, 66, 4.08, 2.2, 19.47, 1, 1, 4, 1], 27 | ["Honda Civic", 30.4, 4, 75.7, 52, 4.93, 1.615, 18.52, 1, 1, 4, 2], 28 | ["Toyota Corolla", 33.9, 4, 71.1, 65, 4.22, 1.835, 19.9, 1, 1, 4, 1], 29 | ["Toyota Corona", 21.5, 4, 120.1, 97, 3.7, 2.465, 20.01, 1, 0, 3, 1], 30 | ["Dodge Challenger", 15.5, 8, 318, 150, 2.76, 3.52, 16.87, 0, 0, 3, 2], 31 | ["AMC Javelin", 15.2, 8, 304, 150, 3.15, 3.435, 17.3, 0, 0, 3, 2], 32 | ["Camaro Z28", 13.3, 8, 350, 245, 3.73, 3.84, 15.41, 0, 0, 3, 4], 33 | ["Pontiac Firebird", 19.2, 8, 400, 175, 3.08, 3.845, 17.05, 0, 0, 3, 2], 34 | ["Fiat X1-9", 27.3, 4, 79, 66, 4.08, 1.935, 18.9, 1, 1, 4, 1], 35 | ["Porsche 914-2", 26, 4, 120.3, 91, 4.43, 2.14, 16.7, 0, 1, 5, 2], 36 | ["Lotus Europa", 30.4, 4, 95.1, 113, 3.77, 1.513, 16.9, 1, 1, 5, 2], 37 | ["Ford Pantera L", 15.8, 8, 351, 264, 4.22, 3.17, 14.5, 0, 1, 5, 4], 38 | ["Ferrari Dino", 19.7, 6, 145, 175, 3.62, 2.77, 15.5, 0, 1, 5, 6], 39 | ["Maserati Bora", 15, 8, 301, 335, 3.54, 3.57, 14.6, 0, 1, 5, 8], 40 | ["Volvo 142E", 21.4, 4, 121, 109, 4.11, 2.78, 18.6, 1, 1, 4, 2] 41 | ] 42 | 43 | data 44 | |> Enum.map(fn row -> Enum.zip([headers, row]) end) 45 | |> Explorer.DataFrame.new() 46 | end 47 | 48 | def diamonds, do: load_csv("diamonds.csv") 49 | def economics, do: load_csv("economics.csv", parse_dates: true) 50 | def economics_long, do: load_csv("economics_long.csv", parse_dates: true) 51 | def mpg, do: load_csv("mpg.csv") 52 | def tx_housing, do: load_csv("tx_housing.csv") 53 | 54 | defp load_csv(file_name, options \\ []) do 55 | file_path = Path.join([:code.priv_dir(:ggity), file_name]) 56 | Explorer.DataFrame.from_csv!(file_path, options) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule GGity.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/srowley/ggity" 5 | @version "0.5.0" 6 | 7 | def project do 8 | [ 9 | app: :ggity, 10 | version: @version, 11 | aliases: aliases(), 12 | elixir: "~> 1.14", 13 | start_permanent: Mix.env() == :prod, 14 | package: package(), 15 | deps: deps(), 16 | dialyzer: [plt_add_apps: [:mix]], 17 | name: "GGity", 18 | description: """ 19 | GGity brings the familiar interface of R's ggplot2 library to SVG 20 | charting in Elixir. 21 | """, 22 | docs: docs() 23 | ] 24 | end 25 | 26 | def application, do: [] 27 | 28 | defp deps do 29 | [ 30 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 31 | {:ex_doc, "~> 0.29", only: :dev, runtime: false}, 32 | {:sweet_xml, "~> 0.7", only: :test}, 33 | {:explorer, "~> 0.6.0"}, 34 | {:nimble_csv, "~> 1.2"}, 35 | {:nimble_strftime, "~> 0.1"} 36 | ] 37 | end 38 | 39 | defp aliases do 40 | [ 41 | checks: [ 42 | "compile", 43 | "credo", 44 | "format" 45 | ], 46 | build_docs: [ 47 | "ggity.docs", 48 | "docs" 49 | ] 50 | ] 51 | end 52 | 53 | defp package() do 54 | [ 55 | maintainers: "Steve Rowley", 56 | name: "ggity", 57 | files: ~w(lib priv mix.exs README* LICENSE* ROADMAP* CHANGELOG*), 58 | licenses: ["MIT"], 59 | links: %{ 60 | "Website" => "http://www.pocketbookvote.com/", 61 | "GitHub" => @source_url, 62 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", 63 | "Roadmap" => "#{@source_url}/blob/master/ROADMAP.md" 64 | } 65 | ] 66 | end 67 | 68 | defp docs do 69 | [ 70 | source_url: @source_url, 71 | source_ref: "v#{@version}", 72 | main: "readme", 73 | extra_section: "CONCEPTS & EXAMPLES", 74 | assets: "guides/assets", 75 | formatters: ["html", "epub"], 76 | groups_for_modules: groups_for_modules(), 77 | extras: extras(), 78 | groups_for_extras: groups_for_extras(), 79 | api_reference: false 80 | ] 81 | end 82 | 83 | defp extras() do 84 | [ 85 | "README.md", 86 | "guides/geom_point.md": [title: "Points"], 87 | "guides/geom_line.md": [title: "Lines"], 88 | "guides/geom_bar.md": [title: "Bars"], 89 | "guides/geom_boxplot.md": [title: "Boxplot"], 90 | "guides/geom_text.md": [title: "Text"], 91 | "guides/scale_color_viridis.md": [title: "Color/Fill Viridis"], 92 | "guides/theme.md": [title: "Theme"], 93 | "guides/annotate.md": [title: "Annotate"] 94 | ] 95 | end 96 | 97 | defp groups_for_extras do 98 | [ 99 | Geoms: [ 100 | "guides/geom_point.md", 101 | "guides/geom_line.md", 102 | "guides/geom_bar.md", 103 | "guides/geom_boxplot.md", 104 | "guides/geom_text.md" 105 | ], 106 | Scales: [ 107 | "guides/scale_color_viridis.md" 108 | ], 109 | Themes: [ 110 | "guides/theme.md" 111 | ], 112 | Annotations: [ 113 | "guides/annotate.md" 114 | ] 115 | ] 116 | end 117 | 118 | defp groups_for_modules do 119 | [ 120 | "Plot API": [GGity.Plot], 121 | Themes: [GGity.Theme, GGity.Element.Line, GGity.Element.Rect, GGity.Element.Text], 122 | Helpers: [GGity.Labels] 123 | ] 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, 4 | "credo": {:hex, :credo, "1.7.8", "9722ba1681e973025908d542ec3d95db5f9c549251ba5b028e251ad8c24ab8c5", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cb9e87cc64f152f3ed1c6e325e7b894dea8f5ef2e41123bd864e3cd5ceb44968"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"}, 6 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 7 | "explorer": {:hex, :explorer, "0.6.1", "04b599781ed75e5eee076e3477a22a2a848eb1f17b02b122af7569e2ac37e719", [:mix], [{:nx, "~> 0.4.0 or ~> 0.5.0", [hex: :nx, repo: "hexpm", optional: true]}, {:rustler, "~> 0.29.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.5", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}, {:table, "~> 0.1.2", [hex: :table, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "43db8bf4c503e241e456e758fd8ff229be88495ac1c92ac820d7c4d215b2faff"}, 8 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 9 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 10 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 13 | "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 15 | "nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"}, 16 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.6.2", "d2218ba08a43fa331957f30481d00b666664d7e3861431b02bd3f4f30eec8e5b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "b9048eaed8d7d14a53f758c91865cc616608a438d2595f621f6a4b32a5511709"}, 17 | "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, 18 | "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, 19 | "table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"}, 20 | } 21 | -------------------------------------------------------------------------------- /test/ggity_draw_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityDrawTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Draw 5 | 6 | describe "svg/2" do 7 | test "wraps given IO list in tags" do 8 | svg = 9 | ["foo", "bar"] 10 | |> Draw.svg(viewBox: "0 0 500 500") 11 | |> IO.chardata_to_string() 12 | 13 | assert svg == 14 | ~s|\nfoobar| 15 | end 16 | end 17 | 18 | describe "g/2" do 19 | test "wraps given elements in tags with given attributes" do 20 | g = 21 | Draw.text("meat", fill: "black", fill_opacity: 0.5) 22 | |> Draw.g(transform: "translate(0,0)") 23 | |> IO.chardata_to_string() 24 | 25 | assert g == 26 | ~s|\nmeat\n\n| 27 | end 28 | end 29 | 30 | describe "rect/1" do 31 | test "draws rect with coordinates, height, width and options" do 32 | rect = 33 | [x: "0", y: "0", height: "10", width: "10", fill: "grey"] 34 | |> Draw.rect() 35 | |> IO.chardata_to_string() 36 | 37 | assert rect == ~s|\n| 38 | end 39 | end 40 | 41 | describe "line/1" do 42 | test "draws line with provided coordinates" do 43 | line = 44 | [x1: "1", y1: "2", x2: "3", y2: "4"] 45 | |> Draw.line() 46 | |> IO.chardata_to_string() 47 | 48 | assert line == ~s|\n| 49 | end 50 | end 51 | 52 | describe "text/2" do 53 | test "draws text element with given value and attributes" do 54 | text = 55 | "foo" 56 | |> Draw.text(text_anchor: "middle") 57 | |> IO.chardata_to_string() 58 | 59 | assert text == ~s|foo\n| 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/ggity_geom_line_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityGeomLineTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Geom 5 | 6 | describe "new/3" do 7 | test "adds fixed aesthetics specified as options" do 8 | geom = Geom.Line.new(%{x: :wt, y: :mpg}, linetype: :dashed, color: "red") 9 | assert geom.mapping == %{x: :wt, y: :mpg} 10 | assert geom.color == "red" 11 | assert geom.linetype == "4" 12 | end 13 | 14 | test "sets default linetype" do 15 | geom = Geom.Line.new(%{x: :wt, y: :mpg}) 16 | assert geom.linetype == "" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/ggity_geom_point_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityGeomPointTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Geom 5 | 6 | describe "new/3" do 7 | test "adds mapping and fixed aesthetics specified as options" do 8 | geom = Geom.Point.new(%{x: :wt, y: :mpg}, alpha: 0.5, color: "red") 9 | assert geom.mapping == %{x: :wt, y: :mpg} 10 | assert geom.color == "red" 11 | assert geom.alpha == 0.5 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/ggity_labels_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityLabelsTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest GGity.Labels 5 | end 6 | -------------------------------------------------------------------------------- /test/ggity_theme_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityThemeTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GGity.Element.{Line, Rect, Text} 5 | alias GGity.{Plot, Theme} 6 | 7 | setup do 8 | data = [ 9 | %{a: 1, b: 2, c: 3, date: ~D[2001-01-01], datetime: ~N[2001-01-01 00:00:00]}, 10 | %{a: 2, b: 4, c: 6, date: ~D[2001-01-03], datetime: ~N[2001-01-03 00:00:00]} 11 | ] 12 | 13 | mapping = %{x: :a, y: :b} 14 | 15 | plot = 16 | data 17 | |> Plot.new(mapping) 18 | |> Plot.geom_point() 19 | 20 | %{plot: plot} 21 | end 22 | 23 | describe "to_stylesheet/2" do 24 | test "generates stylesheet from theme", %{plot: plot} do 25 | stylesheet = 26 | plot.theme 27 | |> Theme.to_stylesheet("gg-1") 28 | |> IO.chardata_to_string() 29 | 30 | assert String.contains?(stylesheet, "hackety-hack")) 39 | 40 | stylesheet = 41 | plot.theme 42 | |> Theme.to_stylesheet("gg-1") 43 | |> IO.chardata_to_string() 44 | 45 | refute String.contains?(stylesheet, "22") 46 | refute String.contains?(stylesheet, "script") 47 | refute String.contains?(stylesheet, "hack") 48 | end 49 | 50 | test "ignores invalid data for rect elements", %{plot: plot} do 51 | plot = 52 | Plot.theme(plot, 53 | panel_background: element_rect(fill: "'22'") 54 | ) 55 | 56 | stylesheet = 57 | plot.theme 58 | |> Theme.to_stylesheet("gg-1") 59 | |> IO.chardata_to_string() 60 | 61 | refute String.contains?(stylesheet, "22") 62 | refute String.contains?(stylesheet, "script") 63 | refute String.contains?(stylesheet, "hack") 64 | end 65 | 66 | test "ignores invalid data for text elements", %{plot: plot} do 67 | plot = Plot.theme(plot, text: element_text(face: "'22'")) 68 | 69 | stylesheet = 70 | plot.theme 71 | |> Theme.to_stylesheet("gg-1") 72 | |> IO.chardata_to_string() 73 | 74 | refute String.contains?(stylesheet, "22") 75 | refute String.contains?(stylesheet, "script") 76 | refute String.contains?(stylesheet, "hack") 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_alpha_continuous_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleAlphaContinuousTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Alpha 5 | 6 | setup do 7 | %{min_max: {0, 3}} 8 | end 9 | 10 | describe "draw/2" do 11 | test "returns a correct scale given default options", %{min_max: min_max} do 12 | scale = Alpha.Continuous.train(Alpha.Continuous.new(), min_max) 13 | 14 | assert_in_delta scale.transform.(0), 0.1, 0.0000001 15 | assert_in_delta scale.transform.(1), 0.4, 0.0000001 16 | assert_in_delta scale.transform.(2), 0.7, 0.0000001 17 | assert_in_delta scale.transform.(3), 1, 0.0000001 18 | end 19 | 20 | test "returns a correct scale given custom min and max", %{min_max: min_max} do 21 | scale = 22 | [range: {0.2, 0.8}] 23 | |> Alpha.Continuous.new() 24 | |> Alpha.Continuous.train(min_max) 25 | 26 | assert_in_delta scale.transform.(0), 0.2, 0.0000001 27 | assert_in_delta scale.transform.(1), 0.4, 0.0000001 28 | assert_in_delta scale.transform.(2), 0.6, 0.0000001 29 | assert_in_delta scale.transform.(3), 0.8, 0.0000001 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_alpha_discrete_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleAlphaDiscreteTest do 2 | use ExUnit.Case, async: true 3 | 4 | import SweetXml 5 | 6 | alias GGity.Scale.Alpha 7 | 8 | setup do 9 | %{scale: Alpha.Discrete.train(Alpha.Discrete.new(), ["beef", "chicken", "fish", "lamb"])} 10 | end 11 | 12 | describe "new/2, train/2" do 13 | test "returns a proper scale for discrete values", %{scale: scale} do 14 | assert_in_delta scale.transform.("beef"), 0.1, 0.000001 15 | assert_in_delta scale.transform.("chicken"), 0.4, 0.000001 16 | assert_in_delta scale.transform.("fish"), 0.7, 0.000001 17 | assert_in_delta scale.transform.("lamb"), 1, 0.000001 18 | end 19 | end 20 | 21 | describe "draw_legend/2" do 22 | test "returns an empty list if scale has one level" do 23 | assert [] == 24 | Alpha.Discrete.new() 25 | |> Alpha.Discrete.train(["fish"]) 26 | |> Alpha.Discrete.draw_legend("Nothing Here", :point, 15, []) 27 | end 28 | 29 | test "returns a legend if scale has two or more levels", %{scale: scale} do 30 | legend = 31 | scale 32 | |> Alpha.Discrete.draw_legend("Fine Meats", :point, 15, []) 33 | |> IO.chardata_to_string() 34 | |> String.replace_prefix("", "") 35 | |> String.replace_suffix("", "") 36 | 37 | assert xpath(legend, ~x"//text/text()"ls) == [ 38 | "Fine Meats", 39 | "beef", 40 | "chicken", 41 | "fish", 42 | "lamb" 43 | ] 44 | 45 | assert xpath(legend, ~x"//circle/@fill-opacity"lf) == 46 | Enum.map(scale.levels, fn value -> scale.transform.(value) end) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_alpha_manual_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleAlphaManualTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Alpha 5 | 6 | describe "new/1" do 7 | test "set transform function to single value" do 8 | assert Alpha.Manual.new(0.5).transform.("meat") == 0.5 9 | end 10 | 11 | test "raises with an invalid value" do 12 | assert_raise FunctionClauseError, fn -> Alpha.Manual.new(2).transform.("meat") == 2 end 13 | 14 | assert_raise FunctionClauseError, fn -> 15 | Alpha.Manual.new("dark").transform.("meat") == "dark" 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_color_manual_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleColorManualTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Color 5 | 6 | describe "new/1" do 7 | test "set transform function to single value" do 8 | assert Color.Manual.new("black").transform.("meat") == "black" 9 | end 10 | 11 | test "raises with an invalid value" do 12 | assert_raise FunctionClauseError, fn -> Color.Manual.new(4).transform.("meat") == 4 end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_color_viridis_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleColorViridisTest do 2 | use ExUnit.Case, async: true 3 | 4 | import SweetXml 5 | 6 | alias GGity.Scale.Color 7 | 8 | setup do 9 | %{scale: Color.Viridis.train(Color.Viridis.new(), ["0", "1", "2"])} 10 | end 11 | 12 | defp to_hex(rgb) do 13 | rgb 14 | |> Stream.map(fn element -> floor(element * 255) end) 15 | |> Stream.map(fn element -> Integer.to_string(element, 16) end) 16 | |> Stream.map(fn element -> String.pad_leading(element, 2, "0") end) 17 | |> Enum.join("") 18 | |> String.pad_leading(7, "#") 19 | end 20 | 21 | describe "train/2" do 22 | test "returns a correct scale given default options", %{scale: scale} do 23 | assert scale.transform.("0") == to_hex([0.267004, 0.004874, 0.329415]) 24 | assert scale.transform.("1") == to_hex([0.128729, 0.563265, 0.551229]) 25 | assert scale.transform.("2") == to_hex([0.983868, 0.904867, 0.136897]) 26 | end 27 | end 28 | 29 | describe "draw_legend/2" do 30 | test "returns an empty list if scale has one level" do 31 | assert [] == 32 | Color.Viridis.new() 33 | |> Color.Viridis.train(["fish"]) 34 | |> Color.Viridis.draw_legend("Nothing Here", :point, 15, []) 35 | end 36 | 37 | test "returns a legend if scale has two or more levels", %{scale: scale} do 38 | legend = 39 | Color.Viridis.draw_legend(scale, "Fine Meats", :point, 15, []) 40 | |> IO.chardata_to_string() 41 | |> String.replace_prefix("", "") 42 | |> String.replace_suffix("", "") 43 | 44 | assert xpath(legend, ~x"//text/text()"ls) == ["Fine Meats", "0", "1", "2"] 45 | 46 | assert xpath(legend, ~x"//circle/@fill"ls) == 47 | Enum.map(scale.levels, fn value -> scale.transform.(value) end) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_identity_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleIdentityTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale 5 | 6 | describe "train/2" do 7 | test "returns a proper scale for discrete values" do 8 | scale = 9 | :color 10 | |> Scale.Identity.new() 11 | |> Scale.train(["meat", "potatoes"]) 12 | 13 | assert scale.transform.("meat") == "meat" 14 | assert scale.transform.("potatoes") == "potatoes" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_linetype_discrete_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleLineTypeDiscreteTest do 2 | use ExUnit.Case, async: true 3 | 4 | import SweetXml 5 | 6 | alias GGity.Scale.Linetype 7 | 8 | setup do 9 | %{ 10 | scale: 11 | Linetype.Discrete.train(Linetype.Discrete.new(), [ 12 | "beef", 13 | "chicken", 14 | "deer", 15 | "fish", 16 | "gator", 17 | "lamb", 18 | "shrimp" 19 | ]) 20 | } 21 | end 22 | 23 | describe "new/2" do 24 | test "returns a proper scale for discrete values", %{scale: scale} do 25 | assert scale.transform.("beef") == "" 26 | assert scale.transform.("chicken") == "4" 27 | assert scale.transform.("deer") == "1" 28 | assert scale.transform.("fish") == "6 2" 29 | assert scale.transform.("gator") == "1 2 3 2" 30 | assert scale.transform.("lamb") == "2 2 6 2" 31 | assert scale.transform.("shrimp") == "" 32 | end 33 | end 34 | 35 | describe "draw_legend/2" do 36 | test "returns an empty list if scale has one level" do 37 | assert [] == 38 | Linetype.Discrete.new() 39 | |> Linetype.Discrete.train(["fish"]) 40 | |> Linetype.Discrete.draw_legend("Nothing Here", :path, 15, []) 41 | end 42 | 43 | test "returns a legend if scale has two or more levels", %{scale: scale} do 44 | legend = 45 | scale 46 | |> Linetype.Discrete.draw_legend("Fine Meats", :path, 15, []) 47 | |> IO.chardata_to_string() 48 | |> String.replace_prefix("", "") 49 | |> String.replace_suffix("", "") 50 | 51 | assert xpath(legend, ~x"//text/text()"ls) == [ 52 | "Fine Meats", 53 | "beef", 54 | "chicken", 55 | "deer", 56 | "fish", 57 | "gator", 58 | "lamb", 59 | "shrimp" 60 | ] 61 | 62 | assert length(xpath(legend, ~x"//line"l)) == 7 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_linetype_manual_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleLinetypeManual do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Linetype 5 | 6 | describe "new/1" do 7 | test "set transform function to single value" do 8 | assert Linetype.Manual.new(:solid).transform.("meat") == "" 9 | end 10 | 11 | test "raises with an invalid value" do 12 | assert_raise FunctionClauseError, fn -> Linetype.Manual.new(2).transform.("meat") == 2 end 13 | 14 | assert_raise FunctionClauseError, fn -> 15 | Linetype.Manual.new("dark").transform.("meat") == "dark" 16 | end 17 | 18 | assert_raise FunctionClauseError, fn -> 19 | Linetype.Manual.new(:squiggly).transform.("meat") == :squiggly 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_shape_manual_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleShapeManual do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Shape 5 | 6 | describe "train/2" do 7 | test "set transform function to single value given a binary" do 8 | scale = Shape.Manual.new(values: ["A"]) 9 | assert Shape.Manual.train(scale, ["meat"]).transform.("meat") == "A" 10 | end 11 | 12 | test "set transform function to a custom list" do 13 | scale = Shape.Manual.new(values: ["a", "b", "c"]) 14 | assert Shape.Manual.train(scale, ["1", "2", "3"]).transform.(2) == "b" 15 | end 16 | 17 | test "raises with an invalid value" do 18 | assert_raise FunctionClauseError, fn -> Shape.Manual.new(values: [2]) end 19 | assert_raise FunctionClauseError, fn -> Shape.Manual.new(values: [:rhombus]) end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_shape_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleShapeTest do 2 | use ExUnit.Case, async: true 3 | 4 | import SweetXml 5 | 6 | alias GGity.Scale.Shape 7 | 8 | setup do 9 | %{ 10 | scale: Shape.train(Shape.new(), ["beef", "chicken", "fish", "lamb", "scallops", "shrimp"]) 11 | } 12 | end 13 | 14 | describe "new/2" do 15 | test "returns a proper scale for discrete values", %{scale: scale} do 16 | assert scale.transform.("beef") == :circle 17 | assert scale.transform.("chicken") == :triangle 18 | assert scale.transform.("fish") == :square 19 | assert scale.transform.("lamb") == :plus 20 | assert scale.transform.("scallops") == :square_cross 21 | assert scale.transform.("shrimp") == :circle 22 | end 23 | end 24 | 25 | describe "draw_legend/2" do 26 | test "returns an empty list if scale has one level" do 27 | assert [] == 28 | Shape.new() 29 | |> Shape.train(["fish"]) 30 | |> Shape.draw_legend("Nothing Here", 15, []) 31 | end 32 | 33 | test "returns a legend if scale has two or more levels", %{scale: scale} do 34 | legend = 35 | scale 36 | |> Shape.draw_legend("Fine Meats", 15, []) 37 | |> IO.chardata_to_string() 38 | |> String.replace_prefix("", "") 39 | |> String.replace_suffix("", "") 40 | 41 | assert xpath(legend, ~x"//text/text()"ls) == [ 42 | "Fine Meats", 43 | "beef", 44 | "chicken", 45 | "fish", 46 | "lamb", 47 | "scallops", 48 | "shrimp" 49 | ] 50 | 51 | assert length(xpath(legend, ~x"//circle"l)) == 2 52 | assert length(xpath(legend, ~x"//polygon"l)) == 1 53 | assert length(xpath(legend, ~x"//rect"l)) == 8 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_size_manual_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleSizeManualTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Size 5 | 6 | describe "new/1" do 7 | test "set transform function to single value" do 8 | assert Size.Manual.new(4).transform.("meat") == 4 9 | end 10 | 11 | test "raises with an invalid value" do 12 | assert_raise FunctionClauseError, fn -> Size.Manual.new("big").transform.("meat") == 4 end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_size_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleSizeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Size 5 | 6 | setup do 7 | %{min_max: {0, 3}} 8 | end 9 | 10 | describe "new/2" do 11 | test "returns a correct scale given default options", %{min_max: min_max} do 12 | scale = Size.train(Size.new(), min_max) 13 | 14 | assert_in_delta scale.transform.(0), 0, 0.0000001 15 | assert_in_delta scale.transform.(1), 11.666666, 0.000001 16 | assert_in_delta scale.transform.(2), 23.333333, 0.000001 17 | assert_in_delta scale.transform.(3), 35, 0.0000001 18 | end 19 | 20 | test "returns a correct scale given custom range", %{min_max: min_max} do 21 | scale = 22 | [range: {2, 5}] 23 | |> Size.new() 24 | |> Size.train(min_max) 25 | 26 | assert_in_delta scale.transform.(0), 0, 0.0000001 27 | assert_in_delta scale.transform.(1), 7, 0.0000001 28 | assert_in_delta scale.transform.(2), 14, 0.0000001 29 | assert_in_delta scale.transform.(3), 21, 0.0000001 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_x_continuous_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleXContinuousTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.X 5 | 6 | describe "new/3" do 7 | test "creates a correct transformation function" do 8 | min_max = {1, 5} 9 | scale = X.Continuous.train(X.Continuous.new(), min_max) 10 | assert scale.transform.(1) == 0 11 | assert scale.transform.(3) == 100 12 | assert scale.transform.(5) == 200 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_x_date_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleXDateTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.X 5 | 6 | describe "new/3" do 7 | test "creates a correct transformation function" do 8 | min_max = {~D[2001-01-01], ~D[2001-12-31]} 9 | scale = X.Date.train(X.Date.new(), min_max) 10 | assert scale.transform.(~D[2001-01-01]) == 1 / 365 * 200 11 | assert scale.transform.(~D[2001-07-02]) < 100 + 1 / 365 * 200 12 | assert scale.transform.(~D[2001-07-03]) > 100 13 | 14 | assert_in_delta scale.transform.(~D[2001-07-03]) - scale.transform.(~D[2001-07-02]), 15 | 1 / 365 * 200, 16 | 0.000001 17 | 18 | assert scale.transform.(~D[2002-01-01]) == 200 + 1 / 365 * 200 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_x_datetime_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleXDateTimeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.X 5 | 6 | describe "train/2" do 7 | test "creates a correct transformation function for NaiveDateTime values" do 8 | values = [ 9 | ~N[2001-01-01 00:00:00], 10 | ~N[2001-01-03 00:00:00], 11 | ~N[2001-01-05 00:00:00] 12 | ] 13 | 14 | [date1, date2, date3] = values 15 | scale = X.DateTime.train(X.DateTime.new(), {date1, date3}) 16 | assert scale.transform.(date1) == 0 17 | assert scale.transform.(date2) == 100 18 | assert scale.transform.(date3) == 200 19 | end 20 | 21 | test "creates a correct transformation function for DateTime values" do 22 | values = [ 23 | ~U[2001-01-01 00:00:00Z], 24 | ~U[2001-01-03 00:00:00Z], 25 | ~U[2001-01-05 00:00:00Z] 26 | ] 27 | 28 | [date1, date2, date3] = values 29 | scale = X.DateTime.train(X.DateTime.new(), {date1, date3}) 30 | assert scale.transform.(date1) == 0 31 | assert scale.transform.(date2) == 100 32 | assert scale.transform.(date3) == 200 33 | end 34 | 35 | test "raises with non-date time values" do 36 | min_max = {1, 4} 37 | assert_raise FunctionClauseError, fn -> X.DateTime.train(X.DateTime.new(), min_max) end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/scale/ggity_scale_y_continuous_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GGityScaleYContinuousTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GGity.Scale.Y 5 | 6 | describe "new/3" do 7 | test "creates a correct transformation function" do 8 | min_max = {1, 5} 9 | scale = Y.Continuous.train(Y.Continuous.new(), min_max) 10 | assert scale.transform.(1) == 0 11 | assert scale.transform.(3) == 100 12 | assert scale.transform.(5) == 200 13 | end 14 | 15 | test "creates correct inverse and transform with one value" do 16 | value = {1, 1} 17 | scale = Y.Continuous.train(Y.Continuous.new(), value) 18 | assert scale.transform.(1) == scale.width / 2 19 | assert scale.inverse.(1) == scale.width / 2 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/visual/ggity_geom_bar_test.livemd: -------------------------------------------------------------------------------- 1 | # Bar Geom Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | 12 | manufacturer_subset = fn -> 13 | manufacturers = Explorer.Series.from_list(["chevrolet", "audi", "ford", "nissan", "subaru"]) 14 | 15 | Explorer.DataFrame.filter_with( 16 | Examples.mpg(), 17 | &Explorer.Series.in(&1["manufacturer"], manufacturers) 18 | ) 19 | end 20 | ``` 21 | 22 | ## Basic bar chart 23 | 24 | ```elixir 25 | manufacturer_subset.() 26 | |> Plot.new(%{x: "manufacturer"}) 27 | |> Plot.geom_bar( 28 | custom_attributes: fn plot, row -> 29 | [onclick: "alert('#{plot.labels.y}: #{row["count"]}')"] 30 | end 31 | ) 32 | |> Plot.scale_y_continuous(labels: &floor/1) 33 | |> Kino.render() 34 | ``` 35 | 36 | ## Stacked bar chart 37 | 38 | ```elixir 39 | manufacturer_subset.() 40 | |> Plot.new(%{x: "manufacturer"}) 41 | |> Plot.geom_bar( 42 | %{fill: "class"}, 43 | custom_attributes: fn plot, row -> 44 | [onclick: "alert('#{plot.labels.y}: #{row["count"]}')"] 45 | end 46 | ) 47 | |> Plot.scale_fill_viridis(option: :inferno) 48 | |> Kino.render() 49 | ``` 50 | 51 | ## Dodge adjustment 52 | 53 | ```elixir 54 | manufacturer_subset.() 55 | |> Plot.new(%{x: "manufacturer"}) 56 | |> Plot.geom_bar(%{fill: "class"}, position: :dodge) 57 | |> Kino.render() 58 | ``` 59 | 60 | ## geom_col example 61 | 62 | ```elixir 63 | [ 64 | %{"salesperson" => "Joe", "week" => "Week 1", "units" => 10}, 65 | %{"salesperson" => "Jane", "week" => "Week 1", "units" => 15}, 66 | %{"salesperson" => "Joe", "week" => "Week 2", "units" => 4}, 67 | %{"salesperson" => "Jane", "week" => "Week 2", "units" => 10}, 68 | %{"salesperson" => "Joe", "week" => "Week 3", "units" => 14}, 69 | %{"salesperson" => "Jane", "week" => "Week 3", "units" => 9} 70 | ] 71 | |> Explorer.DataFrame.new() 72 | |> Plot.new(%{x: "week", y: "units", fill: "salesperson"}) 73 | |> Plot.geom_col(position: :dodge, alpha: 0.7) 74 | |> Plot.scale_fill_viridis(option: :cividis) 75 | |> Kino.render() 76 | ``` 77 | -------------------------------------------------------------------------------- /test/visual/ggity_geom_boxplot_test.livemd: -------------------------------------------------------------------------------- 1 | # Boxplot Geom Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | ``` 12 | 13 | ## Basic boxplot example 14 | 15 | ```elixir 16 | Examples.mpg() 17 | |> Plot.new(%{x: "class", y: "hwy"}) 18 | |> Plot.geom_boxplot( 19 | custom_attributes: fn _plot, row -> [onclick: "alert('Median: #{row["middle"]}')"] end 20 | ) 21 | |> Plot.scale_y_continuous(labels: &floor/1) 22 | |> Kino.render() 23 | ``` 24 | 25 | ## Fixed color example 26 | 27 | ```elixir 28 | Examples.mpg() 29 | |> Plot.new(%{x: "class", y: "hwy"}) 30 | |> Plot.geom_boxplot(color: "blue") 31 | |> Plot.scale_y_continuous(labels: &floor/1) 32 | |> Kino.render() 33 | ``` 34 | 35 | ## Outlier color example 36 | 37 | ```elixir 38 | Examples.mpg() 39 | |> Plot.new(%{x: "class", y: "hwy"}) 40 | |> Plot.geom_boxplot(outlier_color: "red") 41 | |> Plot.scale_y_continuous(labels: &floor/1) 42 | |> Kino.render() 43 | ``` 44 | 45 | ## Outlier shape example 46 | 47 | ```elixir 48 | Examples.mpg() 49 | |> Plot.new(%{x: "class", y: "hwy"}) 50 | |> Plot.geom_boxplot(outlier_size: 6, outlier_color: "red", outlier_shape: 1) 51 | |> Plot.scale_y_continuous(labels: &floor/1) 52 | |> Kino.render() 53 | ``` 54 | 55 | ## Mapped color aesthetic example 56 | 57 | ```elixir 58 | Examples.mpg() 59 | |> Plot.new(%{x: "class", y: "hwy"}) 60 | |> Plot.geom_boxplot(%{color: "drv"}) 61 | |> Plot.scale_y_continuous(labels: &floor/1) 62 | |> Kino.render() 63 | ``` 64 | -------------------------------------------------------------------------------- /test/visual/ggity_geom_line_test.livemd: -------------------------------------------------------------------------------- 1 | # Line Geom Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | ``` 12 | 13 | ## Basic example 14 | 15 | ```elixir 16 | data = Examples.economics() 17 | 18 | data 19 | |> Explorer.DataFrame.filter_with(fn df -> 20 | Explorer.Series.less(df["date"], ~D[1970-12-31]) 21 | end) 22 | |> Plot.new(%{x: "date", y: "unemploy"}) 23 | |> Plot.geom_line(size: 1) 24 | |> Plot.labs(title: "Date data") 25 | |> Plot.scale_x_date(date_labels: "%Y") 26 | |> Kino.render() 27 | ``` 28 | 29 | ## Fixed line type example 30 | 31 | ```elixir 32 | Examples.mtcars() 33 | |> Plot.new(%{x: :wt, y: :mpg}) 34 | |> Plot.labs(title: "Fixed linetype: :twodash", x: "Weight") 35 | |> Plot.geom_line(linetype: :twodash, size: 1) 36 | |> Kino.render() 37 | ``` 38 | 39 | ## Fixed aesthetics example 40 | 41 | ```elixir 42 | Examples.economics() 43 | |> Plot.new(%{x: "date", y: "unemploy"}) 44 | |> Plot.geom_line(color: "red", size: 1) 45 | |> Plot.labs(title: "Fixed color: \"red\"") 46 | |> Plot.scale_x_date(breaks: 6, date_labels: "%m/%d/%Y") 47 | |> Plot.theme(axis_text_x: GGity.Element.Text.element_text(angle: 30)) 48 | |> Kino.render() 49 | ``` 50 | 51 | ## Date/time example 52 | 53 | ```elixir 54 | [ 55 | %{date_time: ~N[2001-01-01 00:00:00], price: 0.13}, 56 | %{date_time: ~N[2001-01-01 03:00:00], price: 0.5}, 57 | %{date_time: ~N[2001-01-01 06:00:00], price: 0.9}, 58 | %{date_time: ~N[2001-01-01 09:00:00], price: 0.63}, 59 | %{date_time: ~N[2001-01-01 12:00:00], price: 0.45}, 60 | %{date_time: ~N[2001-01-01 15:00:00], price: 0.25}, 61 | %{date_time: ~N[2001-01-01 18:00:00], price: 0.12}, 62 | %{date_time: ~N[2001-01-01 21:00:00], price: 0.13}, 63 | %{date_time: ~N[2001-01-02 00:00:00], price: 0.24}, 64 | %{date_time: ~N[2001-01-02 03:00:00], price: 0.74}, 65 | %{date_time: ~N[2001-01-02 06:00:00], price: 0.77}, 66 | %{date_time: ~N[2001-01-02 09:00:00], price: 0.63}, 67 | %{date_time: ~N[2001-01-02 12:00:00], price: 0.23}, 68 | %{date_time: ~N[2001-01-02 15:00:00], price: 0.53}, 69 | %{date_time: ~N[2001-01-02 21:00:00], price: 0.26}, 70 | %{date_time: ~N[2001-01-03 00:00:00], price: 0.27}, 71 | %{date_time: ~N[2001-01-03 03:00:00], price: 0.03}, 72 | %{date_time: ~N[2001-01-03 06:00:00], price: 0.79}, 73 | %{date_time: ~N[2001-01-03 09:00:00], price: 0.78}, 74 | %{date_time: ~N[2001-01-03 12:00:00], price: 0.08}, 75 | %{date_time: ~N[2001-01-03 18:00:00], price: 0.3}, 76 | %{date_time: ~N[2001-01-04 00:00:00], price: 0.7} 77 | ] 78 | |> Plot.new(%{x: :date_time, y: :price}) 79 | |> Plot.geom_line(size: 1) 80 | |> Plot.scale_x_datetime(date_labels: "%b %d H%H") 81 | |> Plot.labs(title: "DateTime data") 82 | |> Kino.render() 83 | ``` 84 | 85 | ## Group by color example 86 | 87 | ```elixir 88 | Examples.economics_long() 89 | |> Plot.new(%{x: "date", y: "value01"}) 90 | |> Plot.labs(title: "Mapped to color") 91 | |> Plot.geom_line(%{color: "variable"}, linetype: :dotted) 92 | |> Plot.scale_x_date(breaks: 6, date_labels: "%Y") 93 | |> Kino.render() 94 | ``` 95 | 96 | ## Group by linetype example 97 | 98 | ```elixir 99 | Examples.economics_long() 100 | |> Plot.new(%{x: "date", y: "value01"}) 101 | |> Plot.labs(title: "Mapped to linetype, custom glyph") 102 | |> Plot.geom_line(%{linetype: "variable"}, key_glyph: :path, color: "purple") 103 | |> Plot.scale_x_date(breaks: 6, date_labels: "%Y") 104 | |> Kino.render() 105 | ``` 106 | -------------------------------------------------------------------------------- /test/visual/ggity_geom_point_test.livemd: -------------------------------------------------------------------------------- 1 | # Geom point examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | ``` 12 | 13 | ## Basic 14 | 15 | ```elixir 16 | Examples.mtcars() 17 | |> Plot.new(%{x: :wt, y: :mpg}) 18 | |> Plot.labs(title: "Basic Plot") 19 | |> Plot.geom_point() 20 | |> Plot.xlab("Weight (lbs)") 21 | |> Plot.ylab("Miles Per Gallon") 22 | |> Kino.render() 23 | ``` 24 | 25 | ## Mapped color aesthetic 26 | 27 | ```elixir 28 | Examples.mtcars() 29 | |> Plot.new(%{x: :wt, y: :mpg}) 30 | |> Plot.labs(title: "Discrete Color", x: "Weight (lbs)", y: "Miles Per Gallon") 31 | |> Plot.geom_point(%{color: :cyl}) 32 | |> Plot.labs(color: "Cylinders") 33 | |> Kino.render() 34 | ``` 35 | 36 | ## Mapped shape aesthetic 37 | 38 | ```elixir 39 | Examples.mtcars() 40 | |> Plot.new(%{x: :wt, y: :mpg}) 41 | |> Plot.geom_point(%{shape: :cyl}, size: 5, color: "blue") 42 | |> Plot.labs(title: "Shape Aesthetic", shape: "Cylinders") 43 | |> Kino.render() 44 | ``` 45 | 46 | ## Manual shape aesthetic 47 | 48 | ```elixir 49 | Examples.mtcars() 50 | |> Plot.new(%{x: :wt, y: :mpg}) 51 | |> Plot.geom_point(%{shape: :cyl}, size: 7) 52 | |> Plot.scale_shape_manual(values: ["🐌", "🤷", "💪"]) 53 | |> Plot.labs(title: "Emoji Support", shape: "Cylinders") 54 | |> Kino.render() 55 | ``` 56 | 57 | ## Discrete alpha example 58 | 59 | ```elixir 60 | Examples.mtcars() 61 | |> Plot.new(%{x: :wt, y: :mpg}) 62 | |> Plot.geom_point(%{alpha: :cyl}, color: "blue") 63 | |> Plot.labs(title: "Discrete Alpha") 64 | |> Plot.scale_alpha_discrete() 65 | |> Kino.render() 66 | ``` 67 | 68 | ## Mapped size example 69 | 70 | ```elixir 71 | Examples.mtcars() 72 | |> Plot.new(%{x: :qsec, y: :mpg}) 73 | |> Plot.geom_point(%{size: :cyl}, alpha: 0.3, color: "blue", shape: :circle) 74 | |> Plot.geom_point(%{size: :wt}, color: "red", shape: :triangle) 75 | |> Plot.labs(title: "Size") 76 | |> Plot.scale_size(range: {1, 10}) 77 | |> Kino.render() 78 | ``` 79 | 80 | ## Fixed fill color example 81 | 82 | ```elixir 83 | Examples.mtcars() 84 | |> Plot.new(%{x: :wt, y: :mpg}) 85 | |> Plot.geom_point(color: "red", size: 6) 86 | |> Plot.labs(title: "Fixed, color: \"red\"") 87 | |> Kino.render() 88 | ``` 89 | 90 | ## Fixed alpha example 91 | 92 | ```elixir 93 | Examples.diamonds() 94 | |> Explorer.DataFrame.sample(10000, seed: 100) 95 | |> Plot.new(%{x: "carat", y: "price"}) 96 | |> Plot.geom_point(alpha: 1 / 20) 97 | |> Plot.labs(title: "Fixed, alpha: 1 / 20") 98 | |> Kino.render() 99 | ``` 100 | 101 | ## Two legends example 102 | 103 | ```elixir 104 | Examples.mtcars() 105 | |> Plot.new(%{x: :wt, y: :mpg}) 106 | |> Plot.geom_point(%{color: :cyl, shape: :vs}) 107 | |> Plot.labs(title: "Two Category Scales") 108 | |> Kino.render() 109 | ``` 110 | 111 | ## Discrete scale example 112 | 113 | ```elixir 114 | Examples.mpg() 115 | |> Plot.new(%{x: "manufacturer", y: "cty"}) 116 | |> Plot.geom_point() 117 | |> Plot.labs(title: "Discrete X") 118 | |> Kino.render() 119 | ``` 120 | -------------------------------------------------------------------------------- /test/visual/ggity_geom_ribbon_test.livemd: -------------------------------------------------------------------------------- 1 | # Ribbon geom examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | ``` 12 | 13 | ## Basic example 14 | 15 | ```elixir 16 | Examples.economics() 17 | |> Plot.new(%{x: "date", y_max: "unemploy"}) 18 | |> Plot.geom_ribbon() 19 | |> Plot.labs(title: "Basic Area Chart") 20 | |> Kino.render() 21 | ``` 22 | 23 | ## Area example 24 | 25 | ```elixir 26 | data = Examples.economics_long() 27 | mask1 = Explorer.Series.equal(data["variable"], "psavert") 28 | mask2 = Explorer.Series.equal(data["variable"], "uempmed") 29 | mask = Explorer.Series.or(mask1, mask2) 30 | 31 | data 32 | |> Explorer.DataFrame.mask(mask) 33 | |> Plot.new(%{x: "date", y_max: "value01"}) 34 | |> Plot.geom_area(%{fill: "variable"}) 35 | |> Plot.scale_fill_viridis(option: :cividis) 36 | |> Plot.scale_y_continuous(labels: fn value -> Float.round(value, 2) end) 37 | |> Plot.labs(title: "Stacked Area Chart") 38 | |> Kino.render() 39 | ``` 40 | 41 | ## Complicated ribbon with line example 42 | 43 | ```elixir 44 | supp_data = 45 | Explorer.DataFrame.filter_with( 46 | Examples.economics_long(), 47 | &Explorer.Series.equal(&1["variable"], "psavert") 48 | ) 49 | 50 | Examples.economics_long() 51 | |> Explorer.DataFrame.to_rows() 52 | |> Enum.filter(fn row -> row["variable"] in ["pop", "pce", "psavert"] end) 53 | |> Enum.with_index(fn row, index -> 54 | row 55 | |> Map.put("more", Map.get(row, "value01") + index * 0.0001) 56 | |> Map.put("less", Map.get(row, "value01") - index * 0.0001) 57 | end) 58 | |> Plot.new(%{x: "date", y_max: "more", y_min: "less", fill: "variable"}) 59 | |> Plot.geom_ribbon(alpha: 1) 60 | |> Plot.scale_y_continuous(labels: fn value -> Float.round(value, 2) end) 61 | |> Plot.scale_fill_viridis(option: :plasma) 62 | |> Plot.geom_line(%{x: "date", y: "value01"}, 63 | color: "grey", 64 | size: 0.5, 65 | data: supp_data 66 | ) 67 | |> Plot.labs(title: "Fancy Ribbons") 68 | |> Kino.render() 69 | ``` 70 | -------------------------------------------------------------------------------- /test/visual/ggity_geom_text_test.livemd: -------------------------------------------------------------------------------- 1 | # Text Geom Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | 12 | manufacturer_subset = fn -> 13 | manufacturers = Explorer.Series.from_list(["chevrolet", "audi", "ford", "nissan", "subaru"]) 14 | 15 | Explorer.DataFrame.filter_with( 16 | Examples.mpg(), 17 | &Explorer.Series.in(&1["manufacturer"], manufacturers) 18 | ) 19 | end 20 | 21 | simple_bar_data = 22 | Explorer.DataFrame.new([ 23 | %{"salesperson" => "Joe", "week" => "Week 1", "units" => 10}, 24 | %{"salesperson" => "Jane", "week" => "Week 1", "units" => 15}, 25 | %{"salesperson" => "Paul", "week" => "Week 1", "units" => 5}, 26 | %{"salesperson" => "Joe", "week" => "Week 2", "units" => 4}, 27 | %{"salesperson" => "Jane", "week" => "Week 2", "units" => 10}, 28 | %{"salesperson" => "Paul", "week" => "Week 2", "units" => 8}, 29 | %{"salesperson" => "Joe", "week" => "Week 3", "units" => 14}, 30 | %{"salesperson" => "Paul", "week" => "Week 3", "units" => 8}, 31 | %{"salesperson" => "Jane", "week" => "Week 3", "units" => 9}, 32 | %{"salesperson" => "Joe", "week" => "Week 4", "units" => 14}, 33 | %{"salesperson" => "Jane", "week" => "Week 4", "units" => 9} 34 | ]) 35 | ``` 36 | 37 | ## Basic example 38 | 39 | ```elixir 40 | Examples.mtcars() 41 | |> DataFrame.filter_with(&Series.contains(&1["model"], "Merc")) 42 | |> Plot.new(%{x: :wt, y: :mpg, label: :model}) 43 | |> Plot.geom_point() 44 | |> Plot.geom_text(%{alpha: :gear}, color: "blue", nudge_x: 5, hjust: :left, size: 8) 45 | |> Plot.scale_alpha_discrete(guide: :legend) 46 | |> Plot.xlab("Weight (tons)") 47 | |> Plot.ylab("Miles Per Gallon") 48 | |> Kino.render() 49 | ``` 50 | 51 | ## Labelled bars 52 | 53 | ```elixir 54 | manufacturer_subset.() 55 | |> Plot.new(%{x: "manufacturer"}) 56 | |> Plot.geom_bar() 57 | |> Plot.geom_text(%{label: :count}, 58 | position: :dodge, 59 | family: "Courier New", 60 | fontface: "bold", 61 | color: "cornflowerblue", 62 | stat: :count, 63 | size: 8, 64 | nudge_y: 5 65 | ) 66 | |> Kino.render() 67 | ``` 68 | 69 | ## Stacked bars 70 | 71 | ```elixir 72 | manufacturer_subset.() 73 | |> Plot.new(%{x: "manufacturer", group: "class"}) 74 | |> Plot.geom_bar(%{fill: "class"}, position: :stack) 75 | |> Plot.geom_text( 76 | %{label: "count"}, 77 | color: "grey", 78 | stat: :count, 79 | position: :stack, 80 | position_vjust: 0.5, 81 | fontface: "bold", 82 | size: 6 83 | ) 84 | |> Plot.scale_fill_viridis(option: :inferno) 85 | |> Kino.render() 86 | ``` 87 | 88 | ## Column stack 89 | 90 | ```elixir 91 | simple_bar_data 92 | |> Plot.new(%{x: "week", y: "units", label: "units", group: "salesperson"}) 93 | |> Plot.geom_col(%{fill: "salesperson"}, position: :stack) 94 | |> Plot.geom_text( 95 | color: "#BAAC6F", 96 | position: :stack, 97 | position_vjust: 0.5, 98 | fontface: "bold", 99 | size: 6 100 | ) 101 | |> Plot.scale_fill_viridis(option: :cividis) 102 | |> Kino.render() 103 | ``` 104 | 105 | ## Dodged bars 106 | 107 | ```elixir 108 | manufacturer_subset.() 109 | |> Plot.new(%{x: "manufacturer", group: "class"}) 110 | |> Plot.geom_bar(%{fill: "class"}, position: :dodge) 111 | |> Plot.geom_text(%{y: "count", label: "count"}, 112 | color: "grey", 113 | stat: :count, 114 | position: :dodge, 115 | position_vjust: 0.5, 116 | fontface: "bold", 117 | size: 6 118 | ) 119 | |> Plot.scale_fill_viridis(option: :inferno) 120 | |> Kino.render() 121 | ``` 122 | 123 | ## Dodged columns 124 | 125 | ```elixir 126 | simple_bar_data 127 | |> Plot.new(%{x: "week", y: "units", label: "units", group: "salesperson"}) 128 | |> Plot.geom_col(%{fill: "salesperson"}, position: :dodge) 129 | |> Plot.geom_text( 130 | color: "#BAAC6F", 131 | position: :dodge, 132 | fontface: "bold", 133 | position_vjust: 0.5, 134 | size: 6 135 | ) 136 | |> Plot.scale_fill_viridis(option: :cividis) 137 | |> Kino.render() 138 | ``` 139 | -------------------------------------------------------------------------------- /test/visual/ggity_labels_test.livemd: -------------------------------------------------------------------------------- 1 | # Annotate Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | 12 | mt_cars_plot = Plot.new(Examples.mtcars(), %{x: :wt, y: :mpg}) 13 | :ok 14 | ``` 15 | 16 | ## Text annotation 17 | 18 | ```elixir 19 | mt_cars_plot 20 | |> Plot.annotate(:text, 21 | x: 4, 22 | y: 25, 23 | label: "Some text", 24 | color: "red" 25 | ) 26 | |> Plot.geom_point() 27 | |> Kino.render() 28 | ``` 29 | 30 | ## Box annotation 31 | 32 | ```elixir 33 | mt_cars_plot 34 | |> Plot.annotate(:rect, 35 | xmin: 3, 36 | xmax: 4.2, 37 | ymin: 12, 38 | ymax: 21, 39 | alpha: 0.2 40 | ) 41 | |> Plot.geom_point() 42 | |> Kino.render() 43 | ``` 44 | 45 | ## Line annotation 46 | 47 | ```elixir 48 | mt_cars_plot 49 | |> Plot.annotate(:segment, 50 | x: 2.5, 51 | xend: 4, 52 | y: 15, 53 | yend: 26.25, 54 | color: "blue" 55 | ) 56 | |> Plot.geom_point() 57 | |> Kino.render() 58 | ``` 59 | -------------------------------------------------------------------------------- /test/visual/ggity_layers_test.livemd: -------------------------------------------------------------------------------- 1 | # Layer Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Kino, Plot} 11 | 12 | german_cars = fn -> 13 | germans = Series.from_list(["audi", "volkswagen"]) 14 | DataFrame.filter_with(Examples.mpg(), &Series.in(&1["manufacturer"], germans)) 15 | end 16 | 17 | japanese_cars = fn -> 18 | japanese = Series.from_list(["honda", "toyota"]) 19 | DataFrame.filter_with(Examples.mpg(), &Series.in(&1["manufacturer"], japanese)) 20 | end 21 | ``` 22 | 23 | ## Fixed line and mapped points 24 | 25 | ```elixir 26 | Examples.mtcars() 27 | |> Plot.new(%{x: :wt, y: :mpg}) 28 | |> Plot.labs(title: "Different Geoms") 29 | |> Plot.geom_line(linetype: :twodash, size: 1) 30 | |> Plot.geom_point(%{color: :cyl}) 31 | |> Plot.labs(color: "Cylinders") 32 | |> Kino.render() 33 | ``` 34 | 35 | ## Two mappings 36 | 37 | ```elixir 38 | Examples.mpg() 39 | |> Plot.new(%{x: "manufacturer", y: "cty"}) 40 | |> Plot.labs(title: "Different Mappings") 41 | |> Plot.geom_point(color: "blue") 42 | |> Plot.geom_point(%{y: "hwy"}, color: "green") 43 | |> Plot.theme(axis_text_x: GGity.Element.Text.element_text(angle: 90)) 44 | |> Plot.labs(y: "City(blue) vs. Highway(green)") 45 | |> Kino.render() 46 | ``` 47 | 48 | ## Two datasets 49 | 50 | ```elixir 51 | german_cars.() 52 | |> Plot.new(%{x: "manufacturer", y: "cty"}) 53 | |> Plot.labs(title: "Different Datasets") 54 | |> Plot.geom_point(color: "gold", shape: :triangle) 55 | |> Plot.geom_point(data: japanese_cars.(), color: "red", shape: :circle) 56 | |> Kino.render() 57 | ``` 58 | -------------------------------------------------------------------------------- /test/visual/ggity_scale_color_viridis_test.livemd: -------------------------------------------------------------------------------- 1 | # Color Viridis Scale Examples 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:ggity, path: ".", override: true}, 6 | {:kino_ggity, github: "srowley/kino_ggity"} 7 | ]) 8 | 9 | alias Explorer.{DataFrame, Series} 10 | alias GGity.{Examples, Labels, Kino, Plot} 11 | 12 | sales_for_city_subset_plot = fn -> 13 | cities = Explorer.Series.from_list(["Houston", "Fort Worth", "San Antonio", "Dallas", "Austin"]) 14 | 15 | Examples.tx_housing() 16 | |> Explorer.DataFrame.filter_with(&Explorer.Series.in(&1["city"], cities)) 17 | |> Plot.new(%{x: "sales", y: "median"}) 18 | end 19 | ``` 20 | 21 | ## Default 22 | 23 | ```elixir 24 | sales_for_city_subset_plot.() 25 | |> Plot.labs(title: "Default - Viridis") 26 | |> Plot.geom_point(%{color: "city"}) 27 | |> Kino.render() 28 | ``` 29 | 30 | ## Plasma 31 | 32 | ```elixir 33 | sales_for_city_subset_plot.() 34 | |> Plot.labs(title: "Plasma") 35 | |> Plot.geom_point(%{color: "city"}) 36 | |> Plot.scale_color_viridis(option: :plasma) 37 | |> Kino.render() 38 | ``` 39 | 40 | ## Inferno 41 | 42 | ```elixir 43 | sales_for_city_subset_plot.() 44 | |> Plot.labs(title: "Inferno") 45 | |> Plot.geom_point(%{color: "city"}) 46 | |> Plot.scale_color_viridis(option: :inferno) 47 | |> Kino.render() 48 | ``` 49 | 50 | ## Magma 51 | 52 | ```elixir 53 | sales_for_city_subset_plot.() 54 | |> Plot.labs(title: "Custom labels, fixed alpha") 55 | |> Plot.geom_point(%{color: "city"}, alpha: 0.4) 56 | |> Plot.scale_x_continuous(labels: :commas) 57 | |> Plot.scale_y_continuous(labels: fn value -> "$#{Labels.commas(round(value / 1000))}K" end) 58 | |> Plot.scale_color_viridis(option: :magma, labels: fn value -> "#{value}!!!" end) 59 | |> Kino.render() 60 | ``` 61 | 62 | ## Cividis 63 | 64 | ```elixir 65 | import GGity.Element.{Line, Rect} 66 | 67 | sales_for_city_subset_plot.() 68 | |> Plot.labs(title: "Cividis, size: 2") 69 | |> Plot.geom_point(%{color: "city"}, size: 2) 70 | |> Plot.scale_color_viridis(option: :cividis) 71 | |> Plot.theme( 72 | axis_ticks: nil, 73 | legend_key: element_rect(fill: "white", size: 1), 74 | panel_background: element_rect(fill: "white"), 75 | panel_border: element_line(color: "lightgray", size: 0.5), 76 | panel_grid: element_line(color: "lightgray"), 77 | panel_grid_major: element_line(size: 0.5) 78 | ) 79 | |> Kino.render() 80 | ``` 81 | --------------------------------------------------------------------------------