├── .envrc ├── .github └── workflows │ ├── deploy-pages.yml │ └── nix-flake-check.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── demo ├── Cargo.toml ├── Trunk.toml ├── assets │ ├── demo-normalise.css │ └── demo.css ├── build.rs ├── index.html └── src │ ├── app.rs │ ├── data │ ├── mod.rs │ └── sunspots.rs │ ├── examples │ ├── aspect_sunspots.rs │ ├── edge_layout.rs │ ├── edge_legend.rs │ ├── edge_rotated_label.rs │ ├── edge_tick_labels.rs │ ├── feature_colours.rs │ ├── feature_css.rs │ ├── feature_line_gradient.rs │ ├── feature_markers.rs │ ├── feature_markers_2.rs │ ├── feature_tooltip.rs │ ├── inner_axis_marker.rs │ ├── inner_grid_line.rs │ ├── inner_guide_line.rs │ ├── inner_layout.rs │ ├── inner_legend.rs │ ├── interpolation_mixed.rs │ ├── interpolation_stepped.rs │ ├── mod.rs │ ├── series_bar.rs │ ├── series_line.rs │ └── series_line_stack.rs │ ├── lib.rs │ ├── main.rs │ └── pages │ ├── demo.rs │ └── examples.rs ├── docs ├── developers.md └── screenshot.png ├── examples ├── README.md └── ssr │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── app.rs │ ├── lib.rs │ └── main.rs ├── flake.lock ├── flake.nix └── leptos-chartistry ├── Cargo.lock ├── Cargo.toml └── src ├── aspect_ratio.rs ├── bounds.rs ├── chart.rs ├── colours ├── colourmaps.rs ├── mod.rs └── scheme.rs ├── debug.rs ├── edge.rs ├── inner ├── axis_marker.rs ├── grid_line.rs ├── guide_line.rs ├── legend.rs └── mod.rs ├── layout ├── compose.rs ├── legend.rs ├── mod.rs ├── rotated_label.rs └── tick_labels.rs ├── lib.rs ├── overlay ├── mod.rs └── tooltip.rs ├── padding.rs ├── projection.rs ├── series ├── bar.rs ├── line │ ├── interpolation.rs │ ├── marker.rs │ └── mod.rs ├── mod.rs ├── stack.rs ├── use_data │ ├── data.rs │ ├── mod.rs │ └── range.rs └── use_y.rs ├── state.rs ├── ticks ├── gen │ ├── aligned_floats.rs │ ├── mod.rs │ ├── span.rs │ └── timestamps.rs └── mod.rs └── use_watched_node.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploys demo site to GH pages 2 | 3 | on: 4 | push: 5 | tags: 6 | - "pages*" 7 | workflow_call: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: nixbuild/nix-quick-install-action@v25 24 | - run: nix build .#demo --print-build-logs 25 | - name: Adjust permissions 26 | run: | 27 | chown -R $(id -u):$(id -g) ./result 28 | chmod -R a+rwx ./result 29 | - uses: actions/upload-pages-artifact@v3 30 | with: 31 | path: ./result 32 | 33 | deploy: 34 | runs-on: ubuntu-latest 35 | environment: 36 | name: github-pages 37 | url: ${{ steps.deployment.outputs.page_url }} 38 | needs: build 39 | steps: 40 | - uses: actions/deploy-pages@v4 41 | id: deployment 42 | -------------------------------------------------------------------------------- /.github/workflows/nix-flake-check.yml: -------------------------------------------------------------------------------- 1 | name: Runs Nix flake checks 2 | 3 | on: [push, workflow_call] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: nixbuild/nix-quick-install-action@v25 14 | - run: nix flake check . --print-build-logs 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /examples/*/target 3 | /dist 4 | /demo/dist 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ## [0.2.2] - 2025-02-27 18 | ### Fixed 19 | - Negative bar chart values [#44]. 20 | - Speed up compilation times [#48]. 21 | - Zero line position is miscalculated for diverging gradients. 22 | 23 | ## [0.2.1] - 2025-01-10 24 | ### Added 25 | - The examples use `leptos_chartistry::*` which rely on importing an `IntoInner` trait. This collides with `leptos::prelude::IntoInner` added in 0.7.1. The public API now declares `IntoInner as _` and `IntoEdge as _` fixing `leptos_chartistry::*` usage. 26 | ### Changed 27 | - Updated [leptos-use dependency](https://github.com/Synphonyte/leptos-use) to 0.15. 28 | 29 | ## [0.2.0] - 2024-12-13 30 | ### Changed 31 | - Most of the API now has `#[non_exhaustive]` on it. 32 | - Sealed [`Tick`](https://docs.rs/leptos-chartistry/latest/leptos_chartistry/trait.Tick.html) trait. [Open an issue](https://github.com/feral-dot-io/leptos-chartistry/issues) if you need a specific impl. 33 | - Most `` generic bounds in the API have been changed to ``. 34 | - Updated [Leptos](https://github.com/leptos-rs/leptos) to 0.7. 35 | - If compile times have gone out of the window, try the following: 36 | - On any page with a call to ``, add a call to [IntoAny::into_any](https://docs.rs/leptos/0.7.0/leptos/tachys/view/any_view/trait.IntoAny.html). For example: `view! { ... ... }.into_any()` 37 | - Drop debug info by adding to your `Cargo.toml`: `profile.dev.debug = false` 38 | - If your charts use a degree of dynamism fitting for Leptos then you might see charts being drawn outside of bounds. This is a [derived memo bug in Leptos](https://github.com/leptos-rs/leptos/issues/3339) 39 | 40 | ### Fixed 41 | - Stacked lines with an `f64::NAN` point are now correctly rendered. 42 | 43 | ## [0.1.7] - 2024-08-20 44 | ### Changed 45 | - Updated [leptos-use dependency](https://github.com/Synphonyte/leptos-use) to 0.12. 46 | - Skip generating empty line markers with `MarkerShape::None` for a small performance improvement. 47 | 48 | ## [0.1.6] - 2024-06-15 49 | ### Fixed 50 | - Panic if Tick::position returns f64::NaN. 51 | - Compile errors with Leptos nightly. 52 | 53 | ## [0.1.5] - 2024-02-23 54 | ### Added 55 | - [Bar charts](https://feral-dot-io.github.io/leptos-chartistry/examples.html#bar-chart) in [#15]. 56 | 57 | ## [0.1.4] - 2024-02-16 58 | ### Added 59 | - Line interpolation: [linear and monotone](https://feral-dot-io.github.io/leptos-chartistry/examples.html#linear-and-monotone) and [stepped](https://feral-dot-io.github.io/leptos-chartistry/examples.html#stepped) in [#12]. 60 | ### Changed 61 | - Default line interpolation is now [`Interpolation::monotone`](https://docs.rs/leptos-chartistry/latest/leptos_chartistry/enum.Interpolation.html#variant.Monotone). 62 | 63 | ## [0.1.3] - 2024-02-15 64 | ### Added 65 | - Application of [CSS styles](https://feral-dot-io.github.io/leptos-chartistry/examples.html#css-styles) in [#10]. 66 | 67 | ## [0.1.2] - 2024-02-11 68 | ### Added 69 | - [Interpolated line gradients](https://feral-dot-io.github.io/leptos-chartistry/examples.html#line-colour-scheme) in [#5]. 70 | - [Line point markers](https://feral-dot-io.github.io/leptos-chartistry/examples.html#point-markers) ([another example](https://feral-dot-io.github.io/leptos-chartistry/examples.html#point-markers-2)) in [#1]. 71 | 72 | ## [0.1.1] - 2024-02-11 73 | ### Fixed 74 | - Fix missing crates.io README. 75 | 76 | ## [0.1.0] - 2024-02-11 77 | Initial release! 78 | 79 | ### Added 80 | - Aspect ratio on inner, outer, or from the env chart. 81 | - Debug (draw bounding boxes, print to console). 82 | 83 | Edge layout options: 84 | - Legends. 85 | - Rotated labels. 86 | - Tick labels (aligned floats and periodic timestamps, custom formatting). 87 | 88 | Inner layout options: 89 | - Axis markers (on edges and zero). 90 | - Grid lines (aligned to ticks). 91 | - Guide lines (aligned to mouse or data). 92 | - Inset legends. 93 | 94 | Overlay options: 95 | - Tooltips (with sorting and formatting). 96 | 97 | Series options: 98 | - Line charts. 99 | - Stacked line charts. 100 | - X and Y ranges. 101 | - Colour scheme. 102 | 103 | 104 | [#1]: https://github.com/feral-dot-io/leptos-chartistry/pull/1 105 | [#5]: https://github.com/feral-dot-io/leptos-chartistry/pull/5 106 | [#10]: https://github.com/feral-dot-io/leptos-chartistry/pull/10 107 | [#12]: https://github.com/feral-dot-io/leptos-chartistry/pull/12 108 | [#15]: https://github.com/feral-dot-io/leptos-chartistry/pull/15 109 | [#44]: https://github.com/feral-dot-io/leptos-chartistry/pull/44 110 | [#48]: https://github.com/feral-dot-io/leptos-chartistry/pull/48 111 | [0.1.0]: https://github.com/feral-dot-io/leptos-chartistry/releases/tag/v0.1.0 112 | [0.1.1]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.0...v0.1.1 113 | [0.1.2]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.1...v0.1.2 114 | [0.1.3]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.2...v0.1.3 115 | [0.1.4]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.3...v0.1.4 116 | [0.1.5]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.4...v0.1.5 117 | [0.1.6]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.5...v0.1.6 118 | [0.1.7]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.6...v0.1.7 119 | [0.2.0]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.1.7...v0.2.0 120 | [0.2.1]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.2.0...v0.2.1 121 | [0.2.2]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.2.1...v0.2.2 122 | [unreleased]: https://github.com/feral-dot-io/leptos-chartistry/compare/v0.2.1...HEAD 123 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ "demo", "examples/ssr", "leptos-chartistry" ] 4 | 5 | [profile.release] 6 | codegen-units = 1 7 | lto = true 8 | opt-level = "z" 9 | panic = "abort" 10 | strip = true 11 | 12 | # Compile times have exploded and sometimes result in wasm linker errors with Leptos 0.7. This reduces the impact. 13 | [profile.dev] 14 | debug = false 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leptos Chartistry 2 | 3 |

Crates.io version   Docs.rs

4 | 5 | Chartistry is an extensible charting library for [Leptos](https://github.com/leptos-rs/leptos). It provides a simple and easy to use `` component. 6 | 7 | - [Interactive demo](https://feral-dot-io.github.io/leptos-chartistry/) 8 | - [Usage examples](https://feral-dot-io.github.io/leptos-chartistry/examples.html) -- start here 9 | - [API documentation](https://docs.rs/leptos-chartistry) 10 | 11 | Add Chartistry to your project with `cargo add leptos-chartistry` 12 | 13 | ![Chartistry in action!](docs/screenshot.png?raw=true "Chartistry in action!") 14 | 15 | ## Feedback 16 | 17 | I've found that engagement has helped drive this project forward. This has come in the form of opening issues, discussions on Leptos' Discord, and starring the repository. 18 | -------------------------------------------------------------------------------- /demo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "demo" 3 | version = "0.0.0" 4 | edition = "2021" 5 | description = "Chartistry demo site" 6 | authors = [ "Joshua McQuistan " ] 7 | license = "MPL-2.0" 8 | 9 | [build-dependencies] 10 | anyhow = "1.0" 11 | glob = "0.3" 12 | syntect = "5.0" 13 | 14 | [dependencies] 15 | chrono = "0.4" 16 | console_error_panic_hook = "0.1" 17 | console_log = "1.0" 18 | js-sys = "0.3" 19 | leptos = { version = "0.7", features = ["csr"] } 20 | leptos-chartistry = { path = "../leptos-chartistry" } 21 | leptos_meta.version = "0.7" 22 | leptos_router.version = "0.7" 23 | leptos-use = "0.15" 24 | log = "0.4" 25 | strum = { version = "0.26", features = ["derive"] } 26 | web-sys = "0.3" 27 | 28 | # Pinned. Must also be specified in flake.nix 29 | wasm-bindgen = "= 0.2.99" 30 | -------------------------------------------------------------------------------- /demo/Trunk.toml: -------------------------------------------------------------------------------- 1 | trunk-version = "*" 2 | 3 | [build] 4 | target = "index.html" 5 | dist = "../dist" 6 | public_url = "/leptos-chartistry/" 7 | minify = "on_release" 8 | 9 | [serve] 10 | addresses = ["127.0.0.1"] 11 | port = 8081 12 | -------------------------------------------------------------------------------- /demo/assets/demo-normalise.css: -------------------------------------------------------------------------------- 1 | /*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */ 2 | 3 | /* 4 | Document 5 | ======== 6 | */ 7 | 8 | /** 9 | Use a better box model (opinionated). 10 | */ 11 | 12 | *, 13 | ::before, 14 | ::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | html { 19 | /* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */ 20 | font-family: 21 | system-ui, 22 | 'Segoe UI', 23 | Roboto, 24 | Helvetica, 25 | Arial, 26 | sans-serif, 27 | 'Apple Color Emoji', 28 | 'Segoe UI Emoji'; 29 | line-height: 1.15; 30 | /* 1. Correct the line height in all browsers. */ 31 | -webkit-text-size-adjust: 100%; 32 | /* 2. Prevent adjustments of font size after orientation changes in iOS. */ 33 | -moz-tab-size: 4; 34 | /* 3. Use a more readable tab size (opinionated). */ 35 | tab-size: 4; 36 | /* 3 */ 37 | } 38 | 39 | /* 40 | Sections 41 | ======== 42 | */ 43 | 44 | body { 45 | margin: 0; 46 | /* Remove the margin in all browsers. */ 47 | } 48 | 49 | /* 50 | Grouping content 51 | ================ 52 | */ 53 | 54 | /** 55 | 1. Add the correct height in Firefox. 56 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 57 | */ 58 | 59 | hr { 60 | height: 0; 61 | /* 1 */ 62 | color: inherit; 63 | /* 2 */ 64 | } 65 | 66 | /* 67 | Text-level semantics 68 | ==================== 69 | */ 70 | 71 | /** 72 | Add the correct text decoration in Chrome, Edge, and Safari. 73 | */ 74 | 75 | abbr[title] { 76 | text-decoration: underline dotted; 77 | } 78 | 79 | /** 80 | Add the correct font weight in Edge and Safari. 81 | */ 82 | 83 | b, 84 | strong { 85 | font-weight: bolder; 86 | } 87 | 88 | /** 89 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 90 | 2. Correct the odd 'em' font sizing in all browsers. 91 | */ 92 | 93 | code, 94 | kbd, 95 | samp, 96 | pre { 97 | font-family: 98 | ui-monospace, 99 | SFMono-Regular, 100 | Consolas, 101 | 'Liberation Mono', 102 | Menlo, 103 | monospace; 104 | /* 1 */ 105 | font-size: 1em; 106 | /* 2 */ 107 | } 108 | 109 | /** 110 | Add the correct font size in all browsers. 111 | */ 112 | 113 | small { 114 | font-size: 80%; 115 | } 116 | 117 | /** 118 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 119 | */ 120 | 121 | sub, 122 | sup { 123 | font-size: 75%; 124 | line-height: 0; 125 | position: relative; 126 | vertical-align: baseline; 127 | } 128 | 129 | sub { 130 | bottom: -0.25em; 131 | } 132 | 133 | sup { 134 | top: -0.5em; 135 | } 136 | 137 | /* 138 | Tabular data 139 | ============ 140 | */ 141 | 142 | /** 143 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 144 | 2. Correct table border color inheritance in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 145 | */ 146 | 147 | table { 148 | text-indent: 0; 149 | /* 1 */ 150 | border-color: inherit; 151 | /* 2 */ 152 | } 153 | 154 | /* 155 | Forms 156 | ===== 157 | */ 158 | 159 | /** 160 | 1. Change the font styles in all browsers. 161 | 2. Remove the margin in Firefox and Safari. 162 | */ 163 | 164 | button, 165 | input, 166 | optgroup, 167 | select, 168 | textarea { 169 | font-family: inherit; 170 | /* 1 */ 171 | font-size: 100%; 172 | /* 1 */ 173 | line-height: 1.15; 174 | /* 1 */ 175 | margin: 0; 176 | /* 2 */ 177 | } 178 | 179 | /** 180 | Remove the inheritance of text transform in Edge and Firefox. 181 | */ 182 | 183 | button, 184 | select { 185 | text-transform: none; 186 | } 187 | 188 | /** 189 | Correct the inability to style clickable types in iOS and Safari. 190 | */ 191 | 192 | button, 193 | [type='button'], 194 | [type='reset'], 195 | [type='submit'] { 196 | -webkit-appearance: button; 197 | } 198 | 199 | /** 200 | Remove the inner border and padding in Firefox. 201 | */ 202 | 203 | ::-moz-focus-inner { 204 | border-style: none; 205 | padding: 0; 206 | } 207 | 208 | /** 209 | Restore the focus styles unset by the previous rule. 210 | */ 211 | 212 | :-moz-focusring { 213 | outline: 1px dotted ButtonText; 214 | } 215 | 216 | /** 217 | Remove the additional ':invalid' styles in Firefox. 218 | See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 219 | */ 220 | 221 | :-moz-ui-invalid { 222 | box-shadow: none; 223 | } 224 | 225 | /** 226 | Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. 227 | */ 228 | 229 | legend { 230 | padding: 0; 231 | } 232 | 233 | /** 234 | Add the correct vertical alignment in Chrome and Firefox. 235 | */ 236 | 237 | progress { 238 | vertical-align: baseline; 239 | } 240 | 241 | /** 242 | Correct the cursor style of increment and decrement buttons in Safari. 243 | */ 244 | 245 | ::-webkit-inner-spin-button, 246 | ::-webkit-outer-spin-button { 247 | height: auto; 248 | } 249 | 250 | /** 251 | 1. Correct the odd appearance in Chrome and Safari. 252 | 2. Correct the outline style in Safari. 253 | */ 254 | 255 | [type='search'] { 256 | -webkit-appearance: textfield; 257 | /* 1 */ 258 | outline-offset: -2px; 259 | /* 2 */ 260 | } 261 | 262 | /** 263 | Remove the inner padding in Chrome and Safari on macOS. 264 | */ 265 | 266 | ::-webkit-search-decoration { 267 | -webkit-appearance: none; 268 | } 269 | 270 | /** 271 | 1. Correct the inability to style clickable types in iOS and Safari. 272 | 2. Change font properties to 'inherit' in Safari. 273 | */ 274 | 275 | ::-webkit-file-upload-button { 276 | -webkit-appearance: button; 277 | /* 1 */ 278 | font: inherit; 279 | /* 2 */ 280 | } 281 | 282 | /* 283 | Interactive 284 | =========== 285 | */ 286 | 287 | /* 288 | Add the correct display in Chrome and Safari. 289 | */ 290 | 291 | summary { 292 | display: list-item; 293 | } -------------------------------------------------------------------------------- /demo/assets/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: repeating-radial-gradient(circle at center 2em, #eee, #12a5ed); 3 | min-height: 100vh; 4 | } 5 | 6 | p { 7 | max-width: 40em; 8 | } 9 | 10 | header { 11 | background-color: #fff; 12 | border-bottom: 1px solid #fc7089; 13 | font-size: 100%; 14 | padding: 0.5em 1em; 15 | display: flex; 16 | flex-flow: row wrap; 17 | gap: 0.5em; 18 | justify-content: space-between; 19 | align-items: end; 20 | } 21 | 22 | header>* { 23 | display: flex; 24 | flex-flow: row wrap; 25 | gap: 0.5em; 26 | justify-content: space-between; 27 | } 28 | 29 | header>h2 { 30 | margin: 0 3em 0 0; 31 | font-size: 100%; 32 | /* Remove styled heading */ 33 | background-color: inherit; 34 | border: none; 35 | padding: 0; 36 | } 37 | 38 | header p { 39 | margin: 0; 40 | } 41 | 42 | header>nav>p { 43 | margin: 0 1em; 44 | } 45 | 46 | header a { 47 | padding: 0.2em 0; 48 | } 49 | 50 | h1>a, 51 | h2>a, 52 | h3>a, 53 | h4>a, 54 | h5>a, 55 | h6>a, 56 | header a, 57 | .always-underline { 58 | text-decoration: none; 59 | color: #222; 60 | } 61 | 62 | h1>a:hover, 63 | h2>a:hover, 64 | h3>a:hover, 65 | h4>a:hover, 66 | h5>a:hover, 67 | h6>a:hover, 68 | header a:hover, 69 | .always-underline { 70 | text-decoration: underline; 71 | text-decoration-color: #fc7089; 72 | text-decoration-thickness: 0.3em; 73 | text-underline-position: under; 74 | } 75 | 76 | .always-underline { 77 | width: fit-content; 78 | } 79 | 80 | header>.badges a>img { 81 | display: block; 82 | } 83 | 84 | main { 85 | margin: 1em; 86 | } 87 | 88 | #demo>._chartistry { 89 | background-color: #fff; 90 | border: 1px solid #000; 91 | margin: 1em auto; 92 | width: fit-content; 93 | } 94 | 95 | #demo>.outer { 96 | margin: 1em auto; 97 | display: flex; 98 | gap: 1em 0.5em; 99 | flex-wrap: wrap; 100 | justify-content: center; 101 | align-items: flex-start; 102 | } 103 | 104 | fieldset { 105 | background-color: #fff; 106 | border: 1px solid #999; 107 | border-radius: 0.5em; 108 | padding: 0.5em 1em 1em 1em; 109 | display: grid; 110 | grid-template-columns: max-content 1fr repeat(3, min-content); 111 | align-items: baseline; 112 | } 113 | 114 | fieldset>h3 { 115 | grid-column: 2 / -1; 116 | font-size: 100%; 117 | font-weight: normal; 118 | margin: 0; 119 | align-self: end; 120 | padding: 0.2em 0.5em; 121 | } 122 | 123 | fieldset>p { 124 | display: contents; 125 | } 126 | 127 | fieldset>p> :nth-child(1) { 128 | grid-column: 1; 129 | text-align: right; 130 | } 131 | 132 | fieldset>p> :nth-child(2) { 133 | grid-column: 2; 134 | padding: 0.2em 0.5em; 135 | } 136 | 137 | fieldset>p> :nth-child(3) { 138 | grid-column: 3; 139 | } 140 | 141 | fieldset>p> :nth-child(4) { 142 | grid-column: 4; 143 | } 144 | 145 | fieldset>p> :nth-child(5) { 146 | grid-column: 5; 147 | } 148 | 149 | fieldset input[type=number] { 150 | width: 6ch; 151 | } 152 | 153 | fieldset input[type=color] { 154 | width: 6ch; 155 | height: 1.6em; 156 | } 157 | 158 | h1, 159 | h2, 160 | h3, 161 | fieldset>legend { 162 | background-color: #fff; 163 | margin: 0; 164 | font-size: 110%; 165 | font-weight: bold; 166 | } 167 | 168 | fieldset>legend { 169 | grid-column: span 2; 170 | float: left; 171 | text-align: center; 172 | margin-bottom: 0.5em; 173 | color: #222; 174 | } 175 | 176 | .cards-row>h2 { 177 | border-top: 0.2em solid #fc7089; 178 | border-radius: 0.5em; 179 | padding: 0.5em 2em; 180 | } 181 | 182 | ._chartistry h2, 183 | ._chartistry h3 { 184 | /* Undo our headings in charts */ 185 | background-color: inherit; 186 | border-bottom: 0; 187 | padding: 0; 188 | } 189 | 190 | .background-box { 191 | background-color: #fff; 192 | border: 1px solid #999; 193 | border-radius: 0.5em; 194 | padding: 1em; 195 | } 196 | 197 | .cards-row { 198 | display: flex; 199 | flex-flow: row; 200 | margin: 1em 0; 201 | gap: 2em 0.5em; 202 | } 203 | 204 | .cards-row>h2 { 205 | border: 1px solid #999; 206 | border-left: 0.2em solid #fc7089; 207 | padding: 1em; 208 | writing-mode: vertical-lr; 209 | } 210 | 211 | .cards { 212 | display: flex; 213 | flex-flow: row wrap; 214 | gap: 0.5em; 215 | } 216 | 217 | 218 | .cards h2>em { 219 | font-weight: normal; 220 | } 221 | 222 | .cards figure { 223 | margin: 0; 224 | width: calc(300px + 2.5em); 225 | } 226 | 227 | .cards figure.slim { 228 | width: min-content; 229 | } 230 | 231 | .cards figure.my-theme { 232 | border: 1px solid #fff; 233 | } 234 | 235 | .cards figure.my-theme figcaption h1, 236 | .cards figure.my-theme figcaption h3 { 237 | background-color: #2b303b; 238 | } 239 | 240 | .cards figure.my-theme figcaption h1>a, 241 | .cards figure.my-theme figcaption h3>a { 242 | color: #c0c5ce !important; 243 | } 244 | 245 | .cards figure.my-theme p a { 246 | color: #fc7089; 247 | } 248 | 249 | #examples dialog { 250 | background-color: #fff; 251 | border: 1px solid #000; 252 | border-radius: 0.5em; 253 | padding: 0; 254 | } 255 | 256 | #examples dialog>pre { 257 | margin: 0; 258 | padding: 5em; 259 | } 260 | 261 | ::backdrop { 262 | background-color: rgba(200, 200, 200, 0.75); 263 | } 264 | 265 | #examples section { 266 | margin: 1em 0; 267 | } 268 | 269 | #aspect-ratio>._chartistry:first-child { 270 | width: 40em; 271 | } 272 | 273 | .example .demo { 274 | width: fit-content; 275 | padding: 1em 2em; 276 | } 277 | 278 | .debug-box { 279 | width: fit-content; 280 | height: fit-content; 281 | } 282 | 283 | .example .code { 284 | /* From syntax theme in build.rs */ 285 | background-color: #2b303b; 286 | border: 1px solid #fff; 287 | margin: 1em auto; 288 | /* Keep vaguely left aligned */ 289 | margin-left: 0; 290 | width: fit-content; 291 | } 292 | 293 | .example .code pre { 294 | margin: 0; 295 | } -------------------------------------------------------------------------------- /demo/build.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use std::{ 3 | env, 4 | fs::{self, File}, 5 | io::Write, 6 | path::{Path, PathBuf}, 7 | }; 8 | use syntect::{highlighting::ThemeSet, html::highlighted_html_for_file, parsing::SyntaxSet}; 9 | 10 | fn main() -> Result<()> { 11 | // Find all of our examples 12 | println!("cargo:rerun-if-changed=src/examples"); 13 | let in_examples = glob::glob("src/examples/*.rs") 14 | .context("invalid glob pattern")? 15 | .collect::, _>>() 16 | .context("failed to collect example paths")?; 17 | 18 | // Output directory 19 | let out_dir = env::var_os("OUT_DIR").unwrap(); 20 | let out_dir = Path::new(&out_dir); 21 | 22 | // Syntax highlighting 23 | syntax_hl_examples(&in_examples, out_dir.join("examples-hl")) 24 | .context("syntax higlighting examples")?; 25 | 26 | Ok(()) 27 | } 28 | 29 | fn syntax_hl_examples(in_paths: &[PathBuf], out_dir: PathBuf) -> Result<()> { 30 | fs::create_dir_all(&out_dir).context("create out_dir")?; 31 | // Rust syntax highlighting settings 32 | let ss = SyntaxSet::load_defaults_newlines(); 33 | let ts = ThemeSet::load_defaults(); 34 | let theme = &ts.themes["base16-ocean.dark"]; 35 | 36 | for src_path in in_paths { 37 | // Run highlighter 38 | let html = highlighted_html_for_file(src_path, &ss, theme) 39 | .context("failed to highlight example")?; 40 | // Write to file 41 | let mut out_path = out_dir.clone(); 42 | out_path.push(src_path.file_name().unwrap()); 43 | let mut file = File::create(out_path).context("create highlight example file")?; 44 | file.write_all(html.as_bytes()) 45 | .context("writing highlight example")?; 46 | } 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::pages::{ 2 | demo::Demo, 3 | examples::{view_example, Example, Examples}, 4 | }; 5 | use leptos::prelude::*; 6 | use leptos_meta::provide_meta_context; 7 | use leptos_router::{ 8 | components::{Route, Router, Routes, A}, 9 | StaticSegment, 10 | }; 11 | 12 | #[derive(Clone, Debug, Default, PartialEq)] 13 | pub struct State { 14 | pub debug: RwSignal, 15 | } 16 | 17 | #[component] 18 | pub fn App() -> impl IntoView { 19 | provide_meta_context(); 20 | provide_app_context(); 21 | 22 | view! { 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | } 53 | } 54 | 55 | #[component] 56 | fn SiteHeader() -> impl IntoView { 57 | view! { 58 |
59 |

"leptos-chartistry"

60 | 64 |
65 |

66 | 67 | GitHub 68 | 69 |

70 |

71 | 72 | Crates.io version 73 | 74 |

75 |

76 | 77 | Docs.rs 78 | 79 |

80 |
81 |
82 | } 83 | } 84 | 85 | #[component] 86 | fn NotFound() -> impl IntoView { 87 | view! { 88 |
89 |
90 |

"Page not found"

91 |

"The page you are looking for does not exist."

92 |
93 |
94 | } 95 | .into_any() 96 | } 97 | 98 | pub fn provide_app_context() { 99 | provide_context(State::default()); 100 | } 101 | 102 | pub fn use_app_context() -> State { 103 | use_context::().unwrap() 104 | } 105 | -------------------------------------------------------------------------------- /demo/src/data/mod.rs: -------------------------------------------------------------------------------- 1 | mod sunspots; 2 | pub use sunspots::daily_sunspots; 3 | 4 | use chrono::prelude::*; 5 | 6 | #[derive(Clone, Debug, PartialEq)] 7 | pub struct Sunspots { 8 | pub year: DateTime, 9 | pub sunspots: f64, 10 | } 11 | 12 | impl Sunspots { 13 | fn new(year: i32, sunspots: f64) -> Self { 14 | Self { 15 | year: Utc.with_ymd_and_hms(year, 7, 1, 0, 0, 0).unwrap(), 16 | sunspots, 17 | } 18 | } 19 | 20 | fn from_vec(data: Vec<(i32, f64)>) -> Vec { 21 | data.into_iter() 22 | .map(|(year, sunspots)| Self::new(year, sunspots)) 23 | .collect() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo/src/examples/aspect_sunspots.rs: -------------------------------------------------------------------------------- 1 | use crate::data; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn AspectRatioSunspots(debug: Signal) -> impl IntoView { 7 | let x_ticks = TickLabels::from_generator(Timestamps::from_period(Period::Year)); 8 | let y_ticks = TickLabels::aligned_floats(); 9 | 10 | // Our sunspot data from https://www.sidc.be/silso/ 11 | let series = 12 | Series::new(|data: &data::Sunspots| data.year).line(|data: &data::Sunspots| data.sunspots); 13 | 14 | // Width slider 15 | let (width, set_width) = signal(0.8); 16 | let SLIDER_RANGE: f64 = 60.0; 17 | let frame_width = move || format!("{}%", (100.0 - SLIDER_RANGE) + SLIDER_RANGE * width.get()); 18 | let change_width = move |ev| { 19 | let value = event_target_value(&ev) 20 | .parse::() 21 | .unwrap_or_default() 22 | .clamp(0.0, 1.0); 23 | set_width.set(value); 24 | }; 25 | 26 | view! { 27 |

"The following chart is of sunspot activity from 1700 to 2020. Its " 28 | "width stretches the page and has a fixed height. Note the large ups " 29 | "and downs in the chart whose interpretation changes with page width."

30 |
31 | 50 |
51 | 52 |

"Try changing the chart width and see how the chart looks different " 53 | "rather than losing detail:"

54 |

57 | 58 |

"We can make more sense of the data—and see more useful patterns—by " 59 | "setting a fixed ratio for a chart. This can be done by supplying two " 60 | "variables from the formula: " "width / height = ratio".

61 |
62 | 75 |
76 | 77 |

"Source: ""Sunspot data from " 78 | "the World Data Center SILSO"", Royal Observatory of Belgium, " 79 | "Brussels"

80 | } 81 | } 82 | -------------------------------------------------------------------------------- /demo/src/examples/edge_layout.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line(Line::new(|data: &MyData| data.y1).with_name("sunshine")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("rain")); 11 | view! { 12 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/examples/edge_legend.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line(Line::new(|data: &MyData| data.y1).with_name("pears")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("apples")); 11 | view! { 12 | 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/src/examples/edge_rotated_label.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | let series = Series::new(|data: &MyData| data.x) 8 | .line(|data: &MyData| data.y1) 9 | .line(|data: &MyData| data.y2); 10 | view! { 11 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo/src/examples/edge_tick_labels.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | let series = Series::new(|data: &MyData| data.x) 8 | .line(|data: &MyData| data.y1) 9 | .line(|data: &MyData| data.y2); 10 | view! { 11 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/src/examples/feature_colours.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // A variety of ways to create new colours 8 | const BLACK: Colour = Colour::from_rgb(0, 0, 0); 9 | const THISTLE: Colour = Colour::from_rgb(216, 191, 216); 10 | let sea_green: Colour = "#20b2aa".parse().unwrap(); 11 | const RED: Colour = Colour::from_rgb(255, 0, 0); 12 | const BLUE_VIOLET: Colour = Colour::from_rgb(0, 0, 255); 13 | 14 | // We can also describe a colour scheme for our Series: 15 | // For non-stacked, colours are picked one after the other and then repeat 16 | // For stacked lines, colours are interpolated between the first and last 17 | let scheme = ColourScheme::new(RED, vec![BLUE_VIOLET]); 18 | 19 | // Add names to our lines for the legend to use 20 | let series = Series::new(|data: &MyData| data.x) 21 | .line( 22 | Line::new(|data: &MyData| data.y1) 23 | .with_name("roses") 24 | // Manually specify the colour of a line 25 | .with_colour(RED), 26 | ) 27 | .line( 28 | Line::new(|data: &MyData| data.y2) 29 | .with_name("violets") 30 | .with_colour(BLUE_VIOLET), 31 | ) 32 | // Or specify the colour scheme (this gives the same as above but more flexible) 33 | .with_colours(scheme); 34 | 35 | view! { 36 | 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /demo/src/examples/feature_css.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | use leptos_meta::Style; 5 | 6 | #[component] 7 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 8 | let series = Series::new(|data: &MyData| data.x).line( 9 | Line::new(|data: &MyData| data.y1) 10 | .with_name("sunlight") 11 | // The default colours assume a light background but this is easily 12 | // changed with the `invert` method. 13 | .with_gradient(my_scheme().invert()) 14 | .with_width(5.0), 15 | ); 16 | view! { 17 | // All elements drawn are given a class with the _chartistry_ prefix 18 | // which we can use to apply themes to our chart. 19 | 48 | 49 |
50 | ().unwrap()).into_inner(), 64 | AxisMarker::left_edge().into_inner(), 65 | AxisMarker::bottom_edge().into_inner(), 66 | YGuideLine::over_mouse().into_inner(), 67 | XGuideLine::over_data().into_inner(), 68 | ] 69 | tooltip=Tooltip::left_cursor().show_x_ticks(false) 70 | /> 71 |
72 | } 73 | } 74 | 75 | fn my_scheme() -> ColourScheme { 76 | STACK_COLOUR_SCHEME.into() 77 | } 78 | -------------------------------------------------------------------------------- /demo/src/examples/feature_line_gradient.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | // Move Y so that values cross zero to demonstrate a diverging gradient 6 | const Y_OFFSET: f64 = -6.0; 7 | 8 | #[component] 9 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 10 | let series = Series::new(|data: &MyData| data.x) 11 | .line( 12 | Line::new(|data: &MyData| data.y1 + Y_OFFSET) 13 | .with_width(5.0) 14 | // Add a linear gradient that changes the line colour based on 15 | // the Y value. 16 | .with_gradient(LINEAR_GRADIENT), 17 | ) 18 | .line( 19 | Line::new(|data: &MyData| data.y2 + Y_OFFSET) 20 | .with_width(5.0) 21 | // Add a diverging gradient that also changes the line colour 22 | // based on the Y value except it has a central value where the 23 | // gradient flips. In our case we show blue below zero and red 24 | // above. 25 | .with_gradient(DIVERGING_GRADIENT), 26 | ); 27 | view! { 28 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /demo/src/examples/feature_markers.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | const WHITE: Colour = Colour::from_rgb(255, 255, 255); 6 | 7 | #[component] 8 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 9 | let series = Series::new(|data: &MyData| data.x) 10 | .line( 11 | Line::new(|data: &MyData| data.y1) 12 | .with_name("laughter") 13 | // Add a marker to each point on the line 14 | .with_marker(MarkerShape::Circle), 15 | ) 16 | .line( 17 | Line::new(|data: &MyData| data.y2) 18 | .with_name("tears") 19 | .with_marker( 20 | // We can also decorate our markers. Here we put a border on 21 | // the marker that's the same as the line and then set the 22 | // marker colour to white. This gives a hollow marker. 23 | Marker::from_shape(MarkerShape::Circle) 24 | .with_colour(WHITE) 25 | // Note: default border colour is the line colour 26 | .with_border_width(1.0), 27 | ), 28 | ); 29 | 30 | view! { 31 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /demo/src/examples/feature_markers_2.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | const BACKGROUND: Colour = Colour::from_rgb(255, 255, 255); 6 | const BORDER_WIDTH: f64 = 2.0; 7 | 8 | #[component] 9 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 10 | let series = Series::new(|data: &MyData| data.x) 11 | .line( 12 | Line::new(|data: &MyData| data.y1) 13 | .with_name("lions") 14 | .with_marker( 15 | Marker::from_shape(MarkerShape::Cross) 16 | // Creating a border around the marker creates a small gap 17 | .with_border(BACKGROUND) 18 | .with_border_width(BORDER_WIDTH), 19 | ), 20 | ) 21 | .line( 22 | Line::new(|data: &MyData| data.y2) 23 | .with_name("tigers") 24 | .with_marker( 25 | Marker::from_shape(MarkerShape::Plus) 26 | .with_border(BACKGROUND) 27 | .with_border_width(BORDER_WIDTH), 28 | ), 29 | ); 30 | 31 | view! { 32 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /demo/src/examples/feature_tooltip.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line(Line::new(|data: &MyData| data.y1).with_name("pears")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("apples")); 11 | view! { 12 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/src/examples/inner_axis_marker.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | let series = Series::new(|data: &MyData| data.x) 8 | .line(|data: &MyData| data.y1) 9 | .line(|data: &MyData| data.y2) 10 | .with_x_range(0.0, 10.0) 11 | .with_y_range(-10.0, 10.0); 12 | view! { 13 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo/src/examples/inner_grid_line.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | let series = Series::new(|data: &MyData| data.x) 8 | .line(|data: &MyData| data.y1) 9 | .line(|data: &MyData| data.y2); 10 | let y_ticks = TickLabels::aligned_floats(); 11 | view! { 12 | 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/src/examples/inner_guide_line.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | let series = Series::new(|data: &MyData| data.x) 8 | .line(|data: &MyData| data.y1) 9 | .line(|data: &MyData| data.y2); 10 | view! { 11 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/examples/inner_layout.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line(Line::new(|data: &MyData| data.y1).with_name("tea")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("coffee")); 11 | let x_ticks = TickLabels::default(); 12 | let y_ticks = TickLabels::default(); 13 | view! { 14 | 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/src/examples/inner_legend.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line(Line::new(|data: &MyData| data.y1).with_name("cats")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("dogs")); 11 | view! { 12 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/examples/interpolation_mixed.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | // Draw our two usual lines and use `Interpolation::Monotone` (the default). 10 | .line(Line::new(|data: &MyData| data.y1).with_interpolation(Interpolation::Monotone)) 11 | .line(Line::new(|data: &MyData| data.y2).with_interpolation(Interpolation::Monotone)) 12 | // Draw two more lines with the same data but this time using 13 | // `Interpolation::Linear` to see the difference particularly around 14 | // curves. 15 | .line( 16 | Line::new(|data: &MyData| data.y1) 17 | .with_colour(GUIDE_LINE_COLOUR) 18 | .with_marker(Marker::from_shape(MarkerShape::Circle).with_colour(GUIDE_LINE_COLOUR)) 19 | .with_name("linear") 20 | .with_interpolation(Interpolation::Linear), 21 | ) 22 | .line( 23 | Line::new(|data: &MyData| data.y2) 24 | .with_colour(GUIDE_LINE_COLOUR) 25 | .with_marker(Marker::from_shape(MarkerShape::Circle).with_colour(GUIDE_LINE_COLOUR)) 26 | .with_interpolation(Interpolation::Linear), 27 | ); 28 | view! { 29 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /demo/src/examples/interpolation_stepped.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Add names to our lines for the legend to use 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line( 10 | Line::new(|data: &MyData| data.y1) 11 | .with_name("horizontal") 12 | .with_marker(MarkerShape::Square) 13 | // Horizontal steps move along the x-axis first, then the y-axis. 14 | // You can also use `Step::Vertical` to move along the y-axis first. 15 | .with_interpolation(Step::Horizontal), 16 | ) 17 | .line( 18 | Line::new(|data: &MyData| data.y2) 19 | .with_name("middle") 20 | .with_marker(MarkerShape::Diamond) 21 | // Alternatively you can move across half the x-axis first, then 22 | // all of the y-axis, then the second half of the x-axis. This 23 | // creates a step in the middle of each point. 24 | .with_interpolation(Step::HorizontalMiddle), 25 | ); 26 | view! { 27 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo/src/examples/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod aspect_sunspots; 2 | pub mod edge_layout; 3 | pub mod edge_legend; 4 | pub mod edge_rotated_label; 5 | pub mod edge_tick_labels; 6 | pub mod feature_colours; 7 | pub mod feature_css; 8 | pub mod feature_line_gradient; 9 | pub mod feature_markers; 10 | pub mod feature_markers_2; 11 | pub mod feature_tooltip; 12 | pub mod inner_axis_marker; 13 | pub mod inner_grid_line; 14 | pub mod inner_guide_line; 15 | pub mod inner_layout; 16 | pub mod inner_legend; 17 | pub mod interpolation_mixed; 18 | pub mod interpolation_stepped; 19 | pub mod series_bar; 20 | pub mod series_line; 21 | pub mod series_line_stack; 22 | 23 | use leptos::prelude::*; 24 | 25 | pub struct MyData { 26 | x: f64, 27 | y1: f64, 28 | y2: f64, 29 | } 30 | 31 | impl MyData { 32 | fn new(x: f64, y1: f64, y2: f64) -> Self { 33 | Self { x, y1, y2 } 34 | } 35 | } 36 | 37 | pub fn load_data() -> Signal> { 38 | Signal::derive(|| { 39 | vec![ 40 | MyData::new(0.0, 1.0, 0.0), 41 | MyData::new(1.0, 3.0, 1.0), 42 | MyData::new(2.0, 5.0, 2.5), 43 | MyData::new(3.0, 5.5, 3.0), 44 | MyData::new(4.0, 5.0, 3.0), 45 | MyData::new(5.0, 2.5, 4.0), 46 | MyData::new(6.0, 2.25, 9.0), 47 | MyData::new(7.0, 3.0, 5.0), 48 | MyData::new(8.0, 7.0, 3.5), 49 | MyData::new(9.0, 8.5, 3.2), 50 | MyData::new(10.0, 10.0, 3.0), 51 | ] 52 | }) 53 | } 54 | 55 | pub fn heart_rate() -> Signal> { 56 | Signal::derive(|| { 57 | vec![ 58 | MyData::new(0.0, 3.0, 0.0), 59 | MyData::new(1.5, 3.0, 0.0), 60 | MyData::new(1.7, 3.8, 0.0), 61 | MyData::new(2.0, 2.0, 0.0), 62 | MyData::new(2.4, 6.0, 0.0), 63 | MyData::new(2.8, 1.0, 0.0), 64 | MyData::new(3.3, 4.0, 0.0), 65 | MyData::new(3.6, 2.8, 0.0), 66 | MyData::new(3.8, 3.0, 0.0), 67 | MyData::new(5.3, 3.0, 0.0), 68 | ] 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /demo/src/examples/series_bar.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | let series = Series::new(|data: &MyData| data.x) 8 | .bar(|data: &MyData| data.y1) 9 | .bar(|data: &MyData| if data.x < 6.0 { data.y2 } else { -data.y2 }) 10 | .with_y_range(-10.0, 10.0); 11 | view! { 12 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/examples/series_line.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Lines are added to the series 8 | let series = Series::new(|data: &MyData| data.x) 9 | .line(Line::new(|data: &MyData| data.y1).with_name("butterflies")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("dragonflies")); 11 | view! { 12 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /demo/src/examples/series_line_stack.rs: -------------------------------------------------------------------------------- 1 | use super::MyData; 2 | use leptos::prelude::*; 3 | use leptos_chartistry::*; 4 | 5 | #[component] 6 | pub fn Example(debug: Signal, data: Signal>) -> impl IntoView { 7 | // Put our lines into a stack 8 | let stack = Stack::new() 9 | .line(Line::new(|data: &MyData| data.y1).with_name("fairies")) 10 | .line(Line::new(|data: &MyData| data.y2).with_name("pixies")); 11 | 12 | let series = Series::new(|data: &MyData| data.x) 13 | .stack(stack) 14 | // Start from zero 15 | .with_min_y(0.0); 16 | view! { 17 | 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | pub use app::*; 3 | 4 | pub mod data; 5 | pub mod examples; 6 | pub mod pages { 7 | pub mod demo; 8 | pub mod examples; 9 | } 10 | -------------------------------------------------------------------------------- /demo/src/main.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | 3 | fn main() { 4 | _ = console_log::init_with_level(log::Level::Debug); 5 | console_error_panic_hook::set_once(); 6 | mount_to_body(|| view! { }) 7 | } 8 | -------------------------------------------------------------------------------- /docs/developers.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | These are mostly thoughts and internal notes (for now). 4 | 5 | ## Design experiments, notes, and caveats: 6 | 7 | - Series is not a `MaybeSignal` because it requires each Line to specify a key when it's a signal for use in . To do this in a typesafe manner means bigger changes to the API however it doesn't seem to make downstream code better. It still mostly ended up being wrapped in a signal -- just limited to the Series instead. It also complicates the internal logic that would probably make it harder to iterate on future designs. The library's API will probably change. 8 | 9 | - Data is a `Vec` to simplify building of line Series. To drop the we could use an IntoIterator on the Series or each line. The has the drawback of complicating the chart internals as it still needs an efficient aggregate model. It's not clear that it could be efficient (avoiding extra iterations and copies of data) without impacting API ergonomics. For example, per line: `Iterator`, per series: `Iterator` and `[Y] -> Y` per line which implies a generic Item = (X, T) and T -> Y. There are usecases for a data transform step but this looks better suited as a step before the data is passed to the library. 10 | 11 | - Colours: need a general write up on difficulties. Assumes a light background. 12 | 13 | - Timestamps: Should be reworked to avoid overlapping labels. `iter_aligned_range` should be passed a Duration instead of using Period::increment. 14 | 15 | - Trying to generate timestamps over a long range with a small period (like ns) will result in huge lists being generated. Not fit for purpose. The current release navigates this by avoiding nanoseconds and not expecting a jump from "show me years" to "show me seconds" in the same chart. 16 | 17 | - aligned_floats sometimes generates weird values. The printed value doesn't always reflect the co-ordinate. The printed values look rounded and it may just be a visual formatting issue. This is only really noticed when you have a very specific range and handwritten labels would be noticeably better. 18 | 19 | - Specifying a Chart's font_width is a notable "why???". The current release relies on sensible defaults. 20 | 21 | ## TODO 22 | 23 | Features to add: 24 | - Stacked line ordering 25 | - Stacked bars 26 | - Loading status 27 | - Canvas 28 | - Calculate font 29 | - Multi-line labels 30 | - Can we get background colour? 31 | - Arrow head (or big red dot) on line chart + linecap 32 | - Tick: 33 | - f32 34 | - integers 35 | - Option 36 | - Legend: 37 | - lines with gradients don't render well 38 | - bar chart should render a block of colour 39 | - In interpolation_mixed.rs I'd like to show only the named lines 40 | - Render in pure SVG, not HTML 41 | 42 | - Site annoyances 43 | - Clicking show code doesn't show the chart 44 | - #aspect ratio section needs a link to docs.rs for context 45 | 46 | - Colours: 47 | - divergent gradient should be able to specify the centre point 48 | - missing alpha channel 49 | 50 | - Tooltip: 51 | - Expand left_cursor to a more general cursor. 52 | - Add "tend to centre" cursor 53 | 54 | - 0.2 bump: 55 | - Tooltip.class + possibly elsewhere 56 | - Move Tooltip.show_x_ticks to TickLabels 57 | - Consider removing anything that can be controlled exclusively by CSS 58 | - cursor_distance? 59 | - Marker.scale should be independent of line width with (use px) a default relative to the line width. 60 | 61 | ## Development cycle 62 | 63 | For the demo site: 64 | 65 | ``` 66 | trunk serve --config ./demo/Trunk.toml --open 67 | ``` 68 | 69 | ## Release checklist 70 | 71 | - `git checkout -b release-vX.Y.Z` 72 | - Update the version in `leptos-chartistry/Cargo.toml` 73 | - `cargo update` 74 | - Commit 75 | - `cargo semver-checks -p leptos-chartistry` 76 | - `nix flake check` -- success? All systems go! 77 | - `git push --set-upstream origin release-vX.Y.Z` 78 | - Wait for CI, review PR, squash and merge 79 | - `git checkout master; git pull; git tag -a vX.Y.Z; git push --tags` 80 | - `cargo publish -p leptos-chartistry` -- no turning back... 81 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feral-dot-io/leptos-chartistry/d3e8ad1081517ac656defbdcf0bddb542433f961/docs/screenshot.png -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | The best place for examples can be found on the [demo site](https://feral-dot-io.github.io/leptos-chartistry/examples.html). The source for these can be found under [`demo/src/examples`](../demo/src/examples). 4 | 5 | This folder contains complete examples that can be run locally. See the sub-folders for more information. 6 | -------------------------------------------------------------------------------- /examples/ssr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "my_example_ssr" 3 | edition = "2021" 4 | 5 | [lib] 6 | crate-type = ["cdylib", "rlib"] 7 | 8 | [dependencies] 9 | axum = { version = "0.7", optional = true } 10 | console_error_panic_hook = "0.1" 11 | console_log = "1.0" 12 | leptos = "0.7" 13 | leptos_axum = { version = "0.7", optional = true } 14 | # Use leptos_chartistry.version = "..." instead 15 | leptos-chartistry.path = "../../leptos-chartistry" 16 | leptos_meta = "0.7" 17 | leptos_router.version = "0.7" 18 | leptos-use = "0.15" 19 | log = "0.4" 20 | tokio = { version = "1.42", features = [ "full" ], optional = true } 21 | tower = { version = "0.5", optional = true } 22 | tower-http = { version = "0.6", features = ["fs"], optional = true } 23 | wasm-bindgen = "0.2.99" 24 | 25 | [features] 26 | hydrate = [ 27 | "leptos/hydrate", 28 | ] 29 | ssr = [ 30 | "dep:axum", 31 | "dep:leptos_axum", 32 | "dep:tower", 33 | "dep:tower-http", 34 | "dep:tokio", 35 | "leptos/ssr", 36 | "leptos_meta/ssr", 37 | "leptos-use/ssr", 38 | "leptos_router/ssr", 39 | ] 40 | 41 | [package.metadata.leptos] 42 | bin-features = ["ssr"] 43 | lib-features = ["hydrate"] 44 | -------------------------------------------------------------------------------- /examples/ssr/README.md: -------------------------------------------------------------------------------- 1 | # SSR example 2 | 3 | An example showcasing how to use Chartistry with Leptos and SSR. It borrows heavily from [Leptos' SSR mode axum example](https://github.com/leptos-rs/leptos/tree/main/examples/ssr_modes_axum). 4 | 5 | Run `cargo-leptos watch` (note the '-'). 6 | -------------------------------------------------------------------------------- /examples/ssr/src/app.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_chartistry::*; 3 | 4 | pub struct MyData { 5 | x: f64, 6 | y1: f64, 7 | y2: f64, 8 | } 9 | 10 | impl MyData { 11 | fn new(x: f64, y1: f64, y2: f64) -> Self { 12 | Self { x, y1, y2 } 13 | } 14 | } 15 | 16 | pub fn load_data() -> Signal> { 17 | Signal::derive(|| { 18 | vec![ 19 | MyData::new(0.0, 1.0, 0.0), 20 | MyData::new(1.0, 3.0, 1.0), 21 | MyData::new(2.0, 5.0, 2.5), 22 | MyData::new(3.0, 5.5, 3.0), 23 | MyData::new(4.0, 5.0, 3.0), 24 | MyData::new(5.0, 2.5, 4.0), 25 | MyData::new(6.0, 2.25, 9.0), 26 | MyData::new(7.0, 3.0, 5.0), 27 | MyData::new(8.0, 7.0, 3.5), 28 | MyData::new(9.0, 8.5, 3.2), 29 | MyData::new(10.0, 10.0, 3.0), 30 | ] 31 | }) 32 | } 33 | 34 | #[component] 35 | pub fn App() -> impl IntoView { 36 | let series = Series::new(|data: &MyData| data.x) 37 | .line(Line::new(|data: &MyData| data.y1).with_name("y1")) 38 | .line(Line::new(|data: &MyData| data.y2).with_name("y2")); 39 | view! { 40 |

"Hello, SSR!"

41 | 59 | } 60 | } 61 | 62 | pub fn shell(options: LeptosOptions) -> impl IntoView { 63 | use leptos_meta::MetaTags; 64 | view! { 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /examples/ssr/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | 3 | #[cfg(feature = "hydrate")] 4 | #[wasm_bindgen::prelude::wasm_bindgen] 5 | pub fn hydrate() { 6 | console_error_panic_hook::set_once(); 7 | _ = console_log::init_with_level(log::Level::Debug); 8 | leptos::mount::hydrate_body(app::App); 9 | } 10 | -------------------------------------------------------------------------------- /examples/ssr/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "ssr")] 2 | #[tokio::main] 3 | async fn main() { 4 | use axum::Router; 5 | use leptos::logging::log; 6 | use leptos::prelude::*; 7 | use leptos_axum::{generate_route_list, LeptosRoutes}; 8 | use my_example_ssr::app::*; 9 | 10 | let conf = get_configuration(None).unwrap(); 11 | let addr = conf.leptos_options.site_addr; 12 | let leptos_options = conf.leptos_options; 13 | // Generate the list of routes in your Leptos App 14 | let routes = generate_route_list(App); 15 | 16 | let app = Router::new() 17 | .leptos_routes(&leptos_options, routes, { 18 | let leptos_options = leptos_options.clone(); 19 | move || shell(leptos_options.clone()) 20 | }) 21 | .fallback(leptos_axum::file_and_error_handler(shell)) 22 | .with_state(leptos_options); 23 | 24 | // run our app with hyper 25 | // `axum::Server` is a re-export of `hyper::Server` 26 | log!("listening on http://{}", &addr); 27 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 28 | axum::serve(listener, app.into_make_service()) 29 | .await 30 | .unwrap(); 31 | } 32 | 33 | #[cfg(not(feature = "ssr"))] 34 | pub fn main() { 35 | // no client-side main function 36 | // unless we want this to work with e.g., Trunk for pure client-side testing 37 | // see lib.rs for hydration function instead 38 | } 39 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "advisory-db": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1735928634, 7 | "narHash": "sha256-Qg1vJOuEohAbdRmTTOLrbbGsyK9KRB54r3+aBuOMctM=", 8 | "owner": "rustsec", 9 | "repo": "advisory-db", 10 | "rev": "63a2f39924f66ca89cf5761f299a8a244fe02543", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "rustsec", 15 | "repo": "advisory-db", 16 | "type": "github" 17 | } 18 | }, 19 | "cargo-leptos-src": { 20 | "flake": false, 21 | "locked": { 22 | "lastModified": 1736363343, 23 | "narHash": "sha256-I2Ak7qSjlfTCENjJReeOIL97sHBO/BEIMSpn2d4c7UY=", 24 | "owner": "leptos-rs", 25 | "repo": "cargo-leptos", 26 | "rev": "cd379996ef4d9cd44d00b2958b877f03dad3d03d", 27 | "type": "github" 28 | }, 29 | "original": { 30 | "owner": "leptos-rs", 31 | "repo": "cargo-leptos", 32 | "type": "github" 33 | } 34 | }, 35 | "crane": { 36 | "locked": { 37 | "lastModified": 1736101677, 38 | "narHash": "sha256-iKOPq86AOWCohuzxwFy/MtC8PcSVGnrxBOvxpjpzrAY=", 39 | "owner": "ipetkov", 40 | "repo": "crane", 41 | "rev": "61ba163d85e5adeddc7b3a69bb174034965965b2", 42 | "type": "github" 43 | }, 44 | "original": { 45 | "owner": "ipetkov", 46 | "repo": "crane", 47 | "type": "github" 48 | } 49 | }, 50 | "nixpkgs": { 51 | "locked": { 52 | "lastModified": 1736061677, 53 | "narHash": "sha256-DjkQPnkAfd7eB522PwnkGhOMuT9QVCZspDpJJYyOj60=", 54 | "path": "/nix/store/g3jyakqb3ipnr6gz5rw10fb17ckr2z00-source", 55 | "rev": "cbd8ec4de4469333c82ff40d057350c30e9f7d36", 56 | "type": "path" 57 | }, 58 | "original": { 59 | "id": "nixpkgs", 60 | "type": "indirect" 61 | } 62 | }, 63 | "root": { 64 | "inputs": { 65 | "advisory-db": "advisory-db", 66 | "cargo-leptos-src": "cargo-leptos-src", 67 | "crane": "crane", 68 | "nixpkgs": "nixpkgs", 69 | "rust-overlay": "rust-overlay", 70 | "trunk-src": "trunk-src", 71 | "utils": "utils" 72 | } 73 | }, 74 | "rust-overlay": { 75 | "inputs": { 76 | "nixpkgs": [ 77 | "nixpkgs" 78 | ] 79 | }, 80 | "locked": { 81 | "lastModified": 1736438724, 82 | "narHash": "sha256-m0CFbWVKtXsAr5OBgWNwR8Uam/jkPIjWgJkdH9DY46M=", 83 | "owner": "oxalica", 84 | "repo": "rust-overlay", 85 | "rev": "15897cf5835bb95aa002b42c22dc61079c28f7c8", 86 | "type": "github" 87 | }, 88 | "original": { 89 | "owner": "oxalica", 90 | "repo": "rust-overlay", 91 | "type": "github" 92 | } 93 | }, 94 | "systems": { 95 | "locked": { 96 | "lastModified": 1681028828, 97 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 98 | "owner": "nix-systems", 99 | "repo": "default", 100 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 101 | "type": "github" 102 | }, 103 | "original": { 104 | "owner": "nix-systems", 105 | "repo": "default", 106 | "type": "github" 107 | } 108 | }, 109 | "trunk-src": { 110 | "flake": false, 111 | "locked": { 112 | "lastModified": 1734677507, 113 | "narHash": "sha256-3mX9NEXceWOeTy1/KWh9h/7f/9sMOhKWK2UwXTl1DJk=", 114 | "owner": "trunk-rs", 115 | "repo": "trunk", 116 | "rev": "d4d92c44d815ce44d1f36518077ed3dbfa524c09", 117 | "type": "github" 118 | }, 119 | "original": { 120 | "owner": "trunk-rs", 121 | "repo": "trunk", 122 | "type": "github" 123 | } 124 | }, 125 | "utils": { 126 | "inputs": { 127 | "systems": "systems" 128 | }, 129 | "locked": { 130 | "lastModified": 1731533236, 131 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 132 | "owner": "numtide", 133 | "repo": "flake-utils", 134 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 135 | "type": "github" 136 | }, 137 | "original": { 138 | "id": "flake-utils", 139 | "type": "indirect" 140 | } 141 | } 142 | }, 143 | "root": "root", 144 | "version": 7 145 | } 146 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Chartistry is a charting library for Leptos"; 3 | inputs = { 4 | nixpkgs.url = "nixpkgs"; 5 | utils.url = "flake-utils"; 6 | crane.url = "github:ipetkov/crane"; 7 | 8 | advisory-db = { 9 | url = "github:rustsec/advisory-db"; 10 | flake = false; 11 | }; 12 | cargo-leptos-src = { 13 | url = "github:leptos-rs/cargo-leptos?tag=v0.2.24"; 14 | flake = false; # Only provides a devShell 15 | }; 16 | rust-overlay = { 17 | url = "github:oxalica/rust-overlay"; 18 | inputs.nixpkgs.follows = "nixpkgs"; 19 | }; 20 | trunk-src = { 21 | url = "github:trunk-rs/trunk"; 22 | flake = false; # Avoid breakage if added 23 | }; 24 | }; 25 | 26 | outputs = 27 | { 28 | self, 29 | nixpkgs, 30 | utils, 31 | rust-overlay, 32 | crane, 33 | advisory-db, 34 | ... 35 | }@inputs: 36 | utils.lib.eachDefaultSystem ( 37 | system: 38 | let 39 | pkgs = nixpkgs.legacyPackages.${system}.appendOverlays [ 40 | rust-overlay.overlays.default 41 | #self.overlays.tools 42 | ]; 43 | rustToolchain = pkgs.rust-bin.stable.latest.default.override { 44 | targets = [ "wasm32-unknown-unknown" ]; 45 | }; 46 | craneLib = ((crane.mkLib pkgs).overrideToolchain rustToolchain).overrideScope ( 47 | final: prev: { 48 | trunk = trunk-local; 49 | wasm-bindgen-cli = wasm-bindgen-cli-local; 50 | } 51 | ); 52 | 53 | # Utilities 54 | cargo-leptos-local = craneLib.buildPackage { 55 | src = craneLib.cleanCargoSource inputs.cargo-leptos-src; 56 | strictDeps = true; 57 | buildInputs = with pkgs; [ 58 | openssl 59 | pkg-config 60 | wasm-bindgen-cli-local 61 | ]; 62 | cargoExtraArgs = "--no-default-features --features no_downloads"; 63 | doCheck = false; 64 | }; 65 | 66 | trunk-local = craneLib.buildPackage { 67 | src = inputs.trunk-src; # Don't clean source 68 | strictDeps = true; 69 | buildInputs = with pkgs; [ 70 | openssl 71 | pkg-config 72 | wasm-bindgen-cli-local 73 | ]; 74 | cargoExtraArgs = "--no-default-features --features rustls"; 75 | doCheck = false; 76 | }; 77 | 78 | wasm-bindgen-cli-local = pkgs.wasm-bindgen-cli.override { 79 | version = "0.2.99"; # Note: must be kept in sync with Cargo.lock 80 | hash = "sha256-1AN2E9t/lZhbXdVznhTcniy+7ZzlaEp/gwLEAucs6EA="; 81 | cargoHash = "sha256-DbwAh8RJtW38LJp+J9Ht8fAROK9OabaJ85D9C/Vkve4="; 82 | }; 83 | 84 | # Build demo 85 | src = 86 | with pkgs; 87 | lib.cleanSourceWith { 88 | src = ./.; 89 | filter = 90 | path: type: 91 | (lib.hasSuffix ".html" path) 92 | || (lib.hasInfix "/assets/" path) 93 | || (craneLib.filterCargoSources path type); 94 | }; 95 | 96 | commonArgs = { 97 | pname = "leptos-chartistry-workspace"; 98 | version = "0.0.1"; 99 | inherit src; 100 | strictDeps = true; 101 | CARGO_PROFILE = "release"; 102 | }; 103 | commonWasmArgs = commonArgs // { 104 | wasm-bindgen-cli = wasm-bindgen-cli-local; 105 | CARGO_BUILD_TARGET = "wasm32-unknown-unknown"; 106 | # Cannot run `cargo test` on wasm 107 | doCheck = false; 108 | }; 109 | 110 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 111 | wasmArtifacts = craneLib.buildDepsOnly commonWasmArgs; 112 | 113 | demo = craneLib.buildTrunkPackage ( 114 | commonWasmArgs 115 | // { 116 | pname = "chartistry-demo"; 117 | cargoArtifacts = wasmArtifacts; 118 | cargoExtraArgs = "--package=demo"; 119 | trunkExtraBuildArgs = "--config=./demo/Trunk.toml"; 120 | # Create symlinks for each of our pages. Enables a static site. 121 | postInstall = '' 122 | ln -s index.html $out/examples.html 123 | mkdir -p $out/examples 124 | for f in demo/src/examples/*.rs; do 125 | f=''${f##*/} # Remove dir prefix 126 | f=''${f%.rs} # Remove file suffix 127 | f=''${f//_/-} # Replace underscores with dashes 128 | ln -s ../index.html $out/examples/$f.html 129 | done 130 | ''; 131 | } 132 | ); 133 | 134 | # Build SSR example 135 | ssrExampleBin = craneLib.buildPackage ( 136 | commonArgs 137 | // { 138 | pname = "chartistry-ssr-example-bin"; 139 | inherit src cargoArtifacts; 140 | cargoExtraArgs = "-p my_example_ssr --bin=my_example_ssr --no-default-features --features=ssr"; 141 | } 142 | ); 143 | ssrExampleLib = craneLib.buildPackage ( 144 | commonWasmArgs 145 | // { 146 | pname = "chartistry-ssr-example-lib"; 147 | inherit src; 148 | cargoArtifacts = wasmArtifacts; 149 | cargoExtraArgs = "-p my_example_ssr --lib --no-default-features --features=hydrate"; 150 | } 151 | ); 152 | in 153 | { 154 | devShells.default = pkgs.mkShell { 155 | packages = [ 156 | cargo-leptos-local 157 | trunk-local 158 | wasm-bindgen-cli-local 159 | ]; 160 | }; 161 | 162 | checks = { 163 | # Ensure we can build all code 164 | inherit demo ssrExampleBin ssrExampleLib; 165 | 166 | clippy = craneLib.cargoClippy ( 167 | commonArgs 168 | // { 169 | inherit cargoArtifacts; 170 | cargoClippyExtraArgs = "--all-targets -- --deny warnings"; 171 | } 172 | ); 173 | 174 | doc = craneLib.cargoDoc ( 175 | commonArgs 176 | // { 177 | inherit cargoArtifacts; 178 | cargoDocExtraArgs = "--workspace"; 179 | } 180 | ); 181 | 182 | audit = craneLib.cargoAudit { inherit src advisory-db; }; 183 | fmt = craneLib.cargoFmt commonArgs; 184 | }; 185 | 186 | packages.demo = demo; 187 | } 188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /leptos-chartistry/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leptos-chartistry" 3 | version = "0.2.1" 4 | authors = [ "Joshua McQuistan " ] 5 | edition = "2021" 6 | description = "Chartistry is a charting library for Leptos" 7 | homepage = "https://feral-dot-io.github.io/leptos-chartistry/" 8 | documentation = "https://docs.rs/leptos-chartistry" 9 | repository = "https://github.com/feral-dot-io/leptos-chartistry" 10 | readme = "../README.md" 11 | license = "MPL-2.0" 12 | keywords = [ "leptos", "wasm", "charts" ] 13 | categories = [ "graphics", "gui", "wasm", "web-programming" ] 14 | 15 | [dependencies] 16 | chrono = "0.4" 17 | leptos = "0.7" 18 | leptos-use = "0.15" 19 | log = "0.4" 20 | web-sys = { version = "0.3", features = ["DomRectReadOnly"] } 21 | 22 | [features] 23 | ssr = ["leptos/ssr", "leptos-use/ssr"] 24 | -------------------------------------------------------------------------------- /leptos-chartistry/src/bounds.rs: -------------------------------------------------------------------------------- 1 | /// Describes a bounding area in 2D space. Asserts that the bounds are not negative, known as a "negative bounds". 2 | /// 3 | /// Warning: when calculating new dimentions try to use [`Bounds::shrink`] instead of [`Bounds::from_points`] as it better avoids a panic on negative bounds. 4 | #[derive(Copy, Clone, Debug, Default, PartialEq)] 5 | pub struct Bounds { 6 | top: f64, 7 | right: f64, 8 | bottom: f64, 9 | left: f64, 10 | } 11 | 12 | impl Bounds { 13 | pub fn new(width: f64, height: f64) -> Self { 14 | Self { 15 | left: 0.0, 16 | top: 0.0, 17 | right: width, 18 | bottom: height, 19 | } 20 | } 21 | 22 | /// Creates a new bounds from the given top-left (x1, y1) and bottom-right (x2, y2) points. Note that this differs from SVG which tends to use (x, y, width, height) and CSS which tends to use (top, right, bottom, left). The CSS pattern is preferred elsewhere. 23 | /// 24 | /// Panics on negative bounds (i.e., x1 > x2 or y1 > y2). Use [`Bounds::shrink`] where possible for creating layouts. 25 | pub fn from_points(x1: f64, y1: f64, x2: f64, y2: f64) -> Self { 26 | assert!(x1 <= x2); 27 | assert!(y1 <= y2); 28 | Self { 29 | left: x1, 30 | top: y1, 31 | right: x2, 32 | bottom: y2, 33 | } 34 | } 35 | 36 | /// Creates an inner bounds from an outer bounds. Shrinks each edge simultenously by the size given i.e., produces a rectangle inside another rectangular bounds. 37 | /// 38 | /// If size is too large (i.e., would extend beyond the opposite edge to create a negative bounds), then the size is clamped to the maximum to produce a zero area bounds. Preferences left and bottom edges e.g., if left + right > width, then left will be as large as possible. 39 | /// 40 | /// May produce a zero bounds. If size is negative, clamps to zero. 41 | pub fn shrink(self, top: f64, right: f64, bottom: f64, left: f64) -> Self { 42 | let top = top.max(0.0); 43 | let right = right.max(0.0); 44 | let bottom = bottom.max(0.0); 45 | let left = left.max(0.0); 46 | let left = f64::min(self.left + left, self.right); 47 | let bottom = f64::max(self.bottom - bottom, self.top); 48 | let right = f64::max(self.right - right, left); 49 | let top = f64::min(self.top + top, bottom); 50 | Self::from_points(left, top, right, bottom) 51 | } 52 | 53 | pub fn left_x(&self) -> f64 { 54 | self.left 55 | } 56 | 57 | pub fn right_x(&self) -> f64 { 58 | self.right 59 | } 60 | 61 | pub fn top_y(&self) -> f64 { 62 | self.top 63 | } 64 | 65 | pub fn bottom_y(&self) -> f64 { 66 | self.bottom 67 | } 68 | 69 | pub fn centre_x(&self) -> f64 { 70 | self.left_x() + (self.width() / 2.0) 71 | } 72 | 73 | pub fn centre_y(&self) -> f64 { 74 | self.top_y() + (self.height() / 2.0) 75 | } 76 | 77 | pub fn width(&self) -> f64 { 78 | self.right - self.left 79 | } 80 | 81 | pub fn height(&self) -> f64 { 82 | self.bottom - self.top 83 | } 84 | 85 | /// Tests if the given point is within the bounds. 86 | pub fn contains(&self, x: f64, y: f64) -> bool { 87 | x >= self.left && x <= self.right && y >= self.top && y <= self.bottom 88 | } 89 | } 90 | 91 | impl From for Bounds { 92 | fn from(r: web_sys::DomRectReadOnly) -> Self { 93 | Self::from_points(r.left(), r.top(), r.right(), r.bottom()) 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use super::*; 100 | 101 | #[test] 102 | fn test_bounds() { 103 | let b = Bounds::from_points(1.1, 2.2, 3.3, 4.4); 104 | assert_eq!(b.left_x(), 1.1); 105 | assert_eq!(b.right_x(), 3.3); 106 | assert_eq!(b.top_y(), 2.2); 107 | assert_eq!(b.bottom_y(), 4.4); 108 | assert_eq!(b.width(), (3.3 - 1.1)); 109 | assert_eq!(b.height(), (4.4 - 2.2)); 110 | assert_eq!(b.centre_x(), (3.3 - 1.1) / 2.0 + 1.1); 111 | assert_eq!(b.centre_y(), (4.4 - 2.2) / 2.0 + 2.2); 112 | assert!(b.contains(1.1, 2.2),); 113 | assert!(b.contains(3.3, 4.4),); 114 | assert!(!b.contains(5.5, 6.6),); 115 | } 116 | 117 | #[test] 118 | fn test_shrink() { 119 | let b = Bounds::new(100.0, 200.0); 120 | // Shrinks edges 121 | assert_eq!( 122 | b.shrink(10.0, 20.0, 30.0, 40.0), 123 | Bounds::from_points(40.0, 10.0, 80.0, 170.0) 124 | ); 125 | // Zero size does nothing 126 | assert_eq!(b.shrink(0.0, 0.0, 0.0, 0.0), b); 127 | // Clamp if top is too large 128 | assert_eq!( 129 | b.shrink(1000.0, 0.0, 0.0, 0.0), 130 | Bounds::from_points(0.0, 200.0, 100.0, 200.0) 131 | ); 132 | // Clamp if right is too large 133 | assert_eq!( 134 | b.shrink(0.0, 1000.0, 0.0, 0.0), 135 | Bounds::from_points(0.0, 0.0, 0.0, 200.0) 136 | ); 137 | // Clamp if bottom is too large 138 | assert_eq!( 139 | b.shrink(0.0, 0.0, 1000.0, 0.0), 140 | Bounds::from_points(0.0, 0.0, 100.0, 0.0) 141 | ); 142 | // Clamp if left is too large 143 | assert_eq!( 144 | b.shrink(0.0, 0.0, 0.0, 1000.0), 145 | Bounds::from_points(100.0, 0.0, 100.0, 200.0) 146 | ); 147 | // If clamping, preference left and bottom 148 | assert_eq!( 149 | b.shrink(1000.0, 1000.0, 1000.0, 1000.0), 150 | Bounds::from_points(100.0, 0.0, 100.0, 0.0) 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /leptos-chartistry/src/colours/colourmaps.rs: -------------------------------------------------------------------------------- 1 | // The Scientific colour maps are licensed under a MIT License 2 | // 3 | // Copyright (c) 2023, Fabio Crameri 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 | 23 | // The Scientific colour maps by Fabio Crameri (Crameri 2018) prevent visual distortion of the data and exclusion of readers with colour­-vision deficiencies (Crameri et al., 2020) and the overview graphic is available via the open-access s-ink.org repository. 24 | 25 | /* 26 | Notes: 27 | 28 | Colours are an important part of charts. Our aim is to avoid less readable and misleading colour schemes. So we rely on the scientific colour maps developed by Fabio Crameri. These are perceptually uniform, colour blind friendly, and monochrome friendly. 29 | 30 | Reading material: 31 | - Summary poster: https://www.fabiocrameri.ch/ws/media-library/a17d02961b3a4544961416de2d7900a4/posterscientificcolourmaps_crameri.pdf 32 | - Article "The misuse of colour in science communication" https://www.nature.com/articles/s41467-020-19160-7 33 | - Homepage https://www.fabiocrameri.ch/colourmaps/ 34 | - Flow chart on picking a scheme: https://s-ink.org/colour-map-guideline 35 | - Available colour schemes: https://s-ink.org/scientific-colour-maps 36 | */ 37 | 38 | use super::{scheme::DivergingGradient, Colour, SequentialGradient}; 39 | 40 | pub const BATLOW: [Colour; 10] = [ 41 | Colour::from_rgb(0x01, 0x19, 0x59), 42 | Colour::from_rgb(0x10, 0x3F, 0x60), 43 | Colour::from_rgb(0x1C, 0x5A, 0x62), 44 | Colour::from_rgb(0x3C, 0x6D, 0x56), 45 | Colour::from_rgb(0x68, 0x7B, 0x3E), 46 | Colour::from_rgb(0x9D, 0x89, 0x2B), 47 | Colour::from_rgb(0xD2, 0x93, 0x43), 48 | Colour::from_rgb(0xF8, 0xA1, 0x7B), 49 | Colour::from_rgb(0xFD, 0xB7, 0xBC), 50 | Colour::from_rgb(0xFA, 0xCC, 0xFA), 51 | ]; 52 | 53 | pub const LIPARI: SequentialGradient = ( 54 | Colour::from_rgb(0x03, 0x13, 0x26), 55 | &[ 56 | Colour::from_rgb(0x13, 0x38, 0x5A), 57 | Colour::from_rgb(0x47, 0x58, 0x7A), 58 | Colour::from_rgb(0x6B, 0x5F, 0x76), 59 | Colour::from_rgb(0x8E, 0x61, 0x6C), 60 | Colour::from_rgb(0xBC, 0x64, 0x61), 61 | Colour::from_rgb(0xE5, 0x7B, 0x62), 62 | Colour::from_rgb(0xE7, 0xA2, 0x79), 63 | Colour::from_rgb(0xE9, 0xC9, 0x9F), 64 | Colour::from_rgb(0xFD, 0xF5, 0xDA), 65 | ], 66 | ); 67 | 68 | pub const BERLIN: DivergingGradient = ( 69 | ( 70 | Colour::from_rgb(0x9E, 0xB0, 0xFF), 71 | &[ 72 | Colour::from_rgb(0x5B, 0xA4, 0xDB), 73 | Colour::from_rgb(0x2D, 0x75, 0x97), 74 | Colour::from_rgb(0x1A, 0x42, 0x56), 75 | Colour::from_rgb(0x11, 0x19, 0x1E), 76 | ], 77 | ), 78 | ( 79 | Colour::from_rgb(0x28, 0x0D, 0x01), 80 | &[ 81 | Colour::from_rgb(0x50, 0x18, 0x03), 82 | Colour::from_rgb(0x8A, 0x3F, 0x2A), 83 | Colour::from_rgb(0xC4, 0x75, 0x6A), 84 | Colour::from_rgb(0xFF, 0xAD, 0xAD), 85 | ], 86 | ), 87 | ); 88 | -------------------------------------------------------------------------------- /leptos-chartistry/src/colours/mod.rs: -------------------------------------------------------------------------------- 1 | mod colourmaps; 2 | mod scheme; 3 | 4 | pub use colourmaps::*; 5 | pub use scheme::{ColourScheme, DivergingGradient, LinearGradientSvg, SequentialGradient}; 6 | 7 | use leptos::prelude::*; 8 | use std::str::FromStr; 9 | 10 | /// A colour in RGB format. 11 | #[derive(Copy, Clone, Debug, PartialEq)] 12 | pub struct Colour { 13 | red: u8, 14 | green: u8, 15 | blue: u8, 16 | } 17 | 18 | impl Colour { 19 | /// Create a new colour with the given red, green, and blue values. 20 | #[deprecated(since = "0.1.1", note = "renamed to `from_rgb`")] 21 | pub const fn new(red: u8, green: u8, blue: u8) -> Self { 22 | Self::from_rgb(red, green, blue) 23 | } 24 | 25 | /// Create a new colour with the given red, green, and blue values. 26 | pub const fn from_rgb(red: u8, green: u8, blue: u8) -> Self { 27 | Self { red, green, blue } 28 | } 29 | 30 | fn interpolate(self, rhs: Self, ratio: f64) -> Self { 31 | let ratio = ratio.clamp(0.0, 1.0); 32 | let interpolate = |pre: u8, post: u8| { 33 | let pre = pre as f64; 34 | let post = post as f64; 35 | let diff = post - pre; 36 | (pre + (diff * ratio)).round() as u8 37 | }; 38 | Colour { 39 | red: interpolate(self.red, rhs.red), 40 | green: interpolate(self.green, rhs.green), 41 | blue: interpolate(self.blue, rhs.blue), 42 | } 43 | } 44 | } 45 | 46 | impl std::fmt::Display for Colour { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | write!(f, "#{:02X}{:02X}{:02X}", self.red, self.green, self.blue) 49 | } 50 | } 51 | 52 | impl FromStr for Colour { 53 | type Err = String; 54 | 55 | fn from_str(s: &str) -> Result { 56 | let s = s.trim_start_matches('#'); 57 | let len = s.len(); 58 | if len != 6 { 59 | return Err(format!("expected 6 characters, got {}", len)); 60 | } 61 | let red = u8::from_str_radix(&s[0..2], 16).map_err(|e| e.to_string())?; 62 | let green = u8::from_str_radix(&s[2..4], 16).map_err(|e| e.to_string())?; 63 | let blue = u8::from_str_radix(&s[4..6], 16).map_err(|e| e.to_string())?; 64 | Ok(Colour { red, green, blue }) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use super::*; 71 | 72 | #[test] 73 | fn test_colour_interpolation() { 74 | let black = Colour::from_rgb(0, 0, 0); 75 | let white = Colour::from_rgb(255, 255, 255); 76 | assert_eq!(black.interpolate(white, 1.0), white); 77 | assert_eq!(black.interpolate(white, 0.0), black); 78 | assert_eq!(white.interpolate(black, 1.0), black); 79 | assert_eq!(white.interpolate(black, 0.0), white); 80 | assert_eq!(black.interpolate(white, 0.2), Colour::from_rgb(51, 51, 51)); 81 | assert_eq!( 82 | white.interpolate(black, 0.2), 83 | Colour::from_rgb(204, 204, 204) 84 | ); 85 | let other = Colour::from_rgb(34, 202, 117); 86 | assert_eq!(black.interpolate(other, 0.4), Colour::from_rgb(14, 81, 47)); 87 | assert_eq!( 88 | white.interpolate(other, 0.2), 89 | Colour::from_rgb(211, 244, 227) 90 | ); 91 | assert_eq!( 92 | white.interpolate(other, 0.8), 93 | Colour::from_rgb(78, 213, 145) 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /leptos-chartistry/src/debug.rs: -------------------------------------------------------------------------------- 1 | use leptos::{either::Either, prelude::*}; 2 | 3 | use crate::bounds::Bounds; 4 | 5 | #[component] 6 | pub fn DebugRect( 7 | #[prop(into)] label: String, 8 | #[prop(into)] debug: Signal, 9 | #[prop(optional)] bounds: Vec>, 10 | ) -> impl IntoView { 11 | move || { 12 | if !debug.get() { 13 | return Either::Left(()); 14 | }; 15 | 16 | log::debug!("rendering {}", label); 17 | let rects = bounds 18 | .clone() 19 | .into_iter() 20 | .map(|bounds| { 21 | view! { 22 | 31 | } 32 | .into_any() 33 | }) 34 | .collect_view(); 35 | Either::Right(rects) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /leptos-chartistry/src/edge.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | /// Identifies a rectangle edge. 4 | #[derive(Copy, Clone, Debug, PartialEq)] 5 | pub enum Edge { 6 | /// The top edge. 7 | Top, 8 | /// The right edge. 9 | Right, 10 | /// The bottom edge. 11 | Bottom, 12 | /// The left edge. 13 | Left, 14 | } 15 | 16 | impl Edge { 17 | /// Returns true if the edge is horizontal (top and bottom). 18 | pub fn is_horizontal(&self) -> bool { 19 | match self { 20 | Self::Top | Self::Bottom => true, 21 | Self::Right | Self::Left => false, 22 | } 23 | } 24 | 25 | /// Returns true if the edge is vertical (left and right). 26 | pub fn is_vertical(&self) -> bool { 27 | !self.is_horizontal() 28 | } 29 | } 30 | 31 | impl std::fmt::Display for Edge { 32 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 | match self { 34 | Self::Top => write!(f, "top"), 35 | Self::Right => write!(f, "right"), 36 | Self::Bottom => write!(f, "bottom"), 37 | Self::Left => write!(f, "left"), 38 | } 39 | } 40 | } 41 | 42 | impl FromStr for Edge { 43 | type Err = String; 44 | 45 | fn from_str(s: &str) -> Result { 46 | match s.to_lowercase().as_str() { 47 | "top" => Ok(Self::Top), 48 | "right" => Ok(Self::Right), 49 | "bottom" => Ok(Self::Bottom), 50 | "left" => Ok(Self::Left), 51 | _ => Err(format!("unknown edge: `{}`", s)), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /leptos-chartistry/src/inner/axis_marker.rs: -------------------------------------------------------------------------------- 1 | use crate::{colours::Colour, debug::DebugRect, state::State, Tick}; 2 | use leptos::prelude::*; 3 | use std::str::FromStr; 4 | 5 | /// Default colour for axis markers. 6 | pub const AXIS_MARKER_COLOUR: Colour = Colour::from_rgb(0xD2, 0xD2, 0xD2); 7 | 8 | /// Builds an axis marker. This marks a boundary (e.g., zero or the chart edge) around the inner chart area. 9 | #[derive(Clone, Debug, PartialEq)] 10 | #[non_exhaustive] 11 | pub struct AxisMarker { 12 | /// Placement of the marker. 13 | pub placement: RwSignal, 14 | /// Colour of the marker. 15 | pub colour: RwSignal, 16 | /// Whether to show a small arrow at the end of the marker pointing outwards from zero. 17 | pub arrow: RwSignal, 18 | /// Width of the marker and arrow line. 19 | pub width: RwSignal, 20 | } 21 | 22 | /// Placement of an axis marker around the inner chart area. 23 | #[derive(Copy, Clone, Debug, PartialEq)] 24 | #[non_exhaustive] 25 | pub enum AxisPlacement { 26 | /// Top edge of the inner chart area. 27 | Top, 28 | /// Right edge of the inner chart area. 29 | Right, 30 | /// Bottom edge of the inner chart area. 31 | Bottom, 32 | /// Left edge of the inner chart area. 33 | Left, 34 | /// Horizontal zero line (if present). 35 | HorizontalZero, 36 | /// Vertical zero line (if present). 37 | VerticalZero, 38 | } 39 | 40 | impl AxisMarker { 41 | fn new(placement: AxisPlacement) -> Self { 42 | Self { 43 | placement: RwSignal::new(placement), 44 | colour: RwSignal::new(AXIS_MARKER_COLOUR), 45 | arrow: RwSignal::new(true), 46 | width: RwSignal::new(1.0), 47 | } 48 | } 49 | 50 | /// New axis marker on the top edge. 51 | pub fn top_edge() -> Self { 52 | Self::new(AxisPlacement::Top) 53 | } 54 | /// New axis marker on the right edge. 55 | pub fn right_edge() -> Self { 56 | Self::new(AxisPlacement::Right) 57 | } 58 | /// New axis marker on the bottom edge. 59 | pub fn bottom_edge() -> Self { 60 | Self::new(AxisPlacement::Bottom) 61 | } 62 | /// New axis marker on the left edge. 63 | pub fn left_edge() -> Self { 64 | Self::new(AxisPlacement::Left) 65 | } 66 | /// New axis marker on the horizontal zero line (if present). 67 | pub fn horizontal_zero() -> Self { 68 | Self::new(AxisPlacement::HorizontalZero) 69 | } 70 | /// New axis marker on the vertical zero line (if present). 71 | pub fn vertical_zero() -> Self { 72 | Self::new(AxisPlacement::VerticalZero) 73 | } 74 | 75 | /// Sets the arrow visibility. 76 | pub fn with_arrow(self, arrow: impl Into) -> Self { 77 | self.arrow.set(arrow.into()); 78 | self 79 | } 80 | 81 | /// Sets the marker colour. 82 | pub fn with_colour(self, colour: impl Into) -> Self { 83 | self.colour.set(colour.into()); 84 | self 85 | } 86 | } 87 | 88 | impl std::fmt::Display for AxisPlacement { 89 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 90 | use AxisPlacement as P; 91 | match self { 92 | P::Top => write!(f, "top"), 93 | P::Right => write!(f, "right"), 94 | P::Bottom => write!(f, "bottom"), 95 | P::Left => write!(f, "left"), 96 | P::HorizontalZero => write!(f, "horizontal zero"), 97 | P::VerticalZero => write!(f, "vertical zero"), 98 | } 99 | } 100 | } 101 | 102 | impl FromStr for AxisPlacement { 103 | type Err = String; 104 | 105 | fn from_str(s: &str) -> Result { 106 | use AxisPlacement::*; 107 | match s.to_lowercase().as_str() { 108 | "top" => Ok(Top), 109 | "right" => Ok(Right), 110 | "bottom" => Ok(Bottom), 111 | "left" => Ok(Left), 112 | "horizontal zero" => Ok(HorizontalZero), 113 | "vertical zero" => Ok(VerticalZero), 114 | _ => Err(format!("unknown axis placement: `{}`", s)), 115 | } 116 | } 117 | } 118 | 119 | #[component] 120 | pub(super) fn AxisMarker( 121 | marker: AxisMarker, 122 | state: State, 123 | ) -> impl IntoView { 124 | let debug = state.pre.debug; 125 | let zero = state.svg_zero; 126 | let inner = state.layout.inner; 127 | 128 | let pos = Memo::new(move |_| { 129 | let inner = inner.get(); 130 | let (top, right, bottom, left) = ( 131 | inner.top_y(), 132 | inner.right_x(), 133 | inner.bottom_y(), 134 | inner.left_x(), 135 | ); 136 | let (zero_x, zero_y) = zero.get(); 137 | let coords @ (x1, y1, x2, y2) = match marker.placement.get() { 138 | AxisPlacement::Top => (left, top, right, top), 139 | AxisPlacement::Bottom => (left, bottom, right, bottom), 140 | AxisPlacement::Left => (left, bottom, left, top), 141 | AxisPlacement::Right => (right, bottom, right, top), 142 | AxisPlacement::HorizontalZero => (left, zero_y, right, zero_y), 143 | AxisPlacement::VerticalZero => (zero_x, bottom, zero_x, top), 144 | }; 145 | let in_bounds = inner.contains(x1, y1) && inner.contains(x2, y2); 146 | (in_bounds, coords) 147 | }); 148 | // Check coords are within projection bounds 149 | let in_bounds = Memo::new(move |_| pos.get().0); 150 | let x1 = Memo::new(move |_| pos.get().1 .0); 151 | let y1 = Memo::new(move |_| pos.get().1 .1); 152 | let x2 = Memo::new(move |_| pos.get().1 .2); 153 | let y2 = Memo::new(move |_| pos.get().1 .3); 154 | 155 | let arrow = move || { 156 | if marker.arrow.get() { 157 | "url(#marker_axis_arrow)" 158 | } else { 159 | "" 160 | } 161 | }; 162 | 163 | let colour = marker.colour; 164 | let colour = move || colour.get().to_string(); 165 | view! { 166 | 170 | 171 | 172 | 179 | 180 | 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /leptos-chartistry/src/inner/grid_line.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | colours::Colour, debug::DebugRect, projection::Projection, state::State, ticks::GeneratedTicks, 3 | Tick, TickLabels, 4 | }; 5 | use leptos::prelude::*; 6 | 7 | /// Default colour for grid lines. 8 | pub const GRID_LINE_COLOUR: Colour = Colour::from_rgb(0xEF, 0xF2, 0xFA); 9 | 10 | macro_rules! impl_grid_line { 11 | ($name:ident) => { 12 | /// Builds a tick-aligned grid line across the inner chart area. 13 | #[derive(Clone, Debug, PartialEq)] 14 | #[non_exhaustive] 15 | pub struct $name { 16 | /// Width of the grid line. 17 | pub width: RwSignal, 18 | /// Colour of the grid line. 19 | pub colour: RwSignal, 20 | /// Ticks to align the grid line to. 21 | pub ticks: TickLabels, 22 | } 23 | 24 | impl $name { 25 | /// Creates a new grid line from a set of ticks. 26 | pub fn from_ticks(ticks: impl Into>) -> Self { 27 | Self { 28 | ticks: ticks.into(), 29 | ..Default::default() 30 | } 31 | } 32 | 33 | /// Sets the colour of the grid line. 34 | pub fn with_colour(self, colour: impl Into) -> Self { 35 | self.colour.set(colour.into()); 36 | self 37 | } 38 | } 39 | 40 | impl Default for $name { 41 | fn default() -> Self { 42 | Self { 43 | width: RwSignal::new(1.0), 44 | colour: RwSignal::new(GRID_LINE_COLOUR), 45 | ticks: TickLabels::default(), 46 | } 47 | } 48 | } 49 | }; 50 | } 51 | 52 | impl_grid_line!(XGridLine); 53 | impl_grid_line!(YGridLine); 54 | 55 | macro_rules! impl_use_grid_line { 56 | ($name:ident) => { 57 | pub struct $name { 58 | width: RwSignal, 59 | colour: RwSignal, 60 | ticks: Memo>, 61 | } 62 | 63 | impl Clone for $name { 64 | fn clone(&self) -> Self { 65 | Self { 66 | width: self.width, 67 | colour: self.colour, 68 | ticks: self.ticks, 69 | } 70 | } 71 | } 72 | }; 73 | } 74 | 75 | impl_use_grid_line!(UseXGridLine); 76 | impl_use_grid_line!(UseYGridLine); 77 | 78 | impl XGridLine { 79 | pub(crate) fn use_horizontal(self, state: &State) -> UseXGridLine { 80 | let inner = state.layout.inner; 81 | let avail_width = Signal::derive(move || inner.with(|inner| inner.width())); 82 | UseXGridLine { 83 | width: self.width, 84 | colour: self.colour, 85 | ticks: self.ticks.generate_x(&state.pre, avail_width), 86 | } 87 | } 88 | } 89 | 90 | impl YGridLine { 91 | pub(crate) fn use_vertical(self, state: &State) -> UseYGridLine { 92 | let inner = state.layout.inner; 93 | let avail_height = Signal::derive(move || inner.with(|inner| inner.height())); 94 | UseYGridLine { 95 | width: self.width, 96 | colour: self.colour, 97 | ticks: self.ticks.generate_y(&state.pre, avail_height), 98 | } 99 | } 100 | } 101 | 102 | #[component] 103 | pub(super) fn XGridLine( 104 | line: UseXGridLine, 105 | state: State, 106 | ) -> impl IntoView { 107 | let debug = state.pre.debug; 108 | let inner = state.layout.inner; 109 | let proj = state.projection; 110 | let colour = line.colour; 111 | 112 | let lines = move || { 113 | for_ticks(line.ticks, proj, true) 114 | .into_iter() 115 | .map(|(x, label)| { 116 | view! { 117 | 118 | 123 | } 124 | }) 125 | .collect_view() 126 | }; 127 | 128 | view! { 129 | 133 | 134 | {lines} 135 | 136 | } 137 | } 138 | 139 | #[component] 140 | pub(super) fn YGridLine( 141 | line: UseYGridLine, 142 | state: State, 143 | ) -> impl IntoView { 144 | let debug = state.pre.debug; 145 | let inner = state.layout.inner; 146 | let proj = state.projection; 147 | let colour = line.colour; 148 | 149 | let lines = move || { 150 | for_ticks(line.ticks, proj, false) 151 | .into_iter() 152 | .map(|(y, label)| { 153 | view! { 154 | 155 | 160 | } 161 | }) 162 | .collect_view() 163 | }; 164 | 165 | view! { 166 | 170 | 171 | {lines} 172 | 173 | } 174 | } 175 | 176 | fn for_ticks( 177 | ticks: Memo>, 178 | proj: Memo, 179 | is_x: bool, 180 | ) -> Vec<(f64, String)> { 181 | ticks.with(move |ticks| { 182 | let proj = proj.get(); 183 | ticks 184 | .ticks 185 | .iter() 186 | .map(|tick| { 187 | let label = ticks.state.format(tick); 188 | let tick = tick.position(); 189 | let tick = if is_x { 190 | proj.position_to_svg(tick, 0.0).0 191 | } else { 192 | proj.position_to_svg(0.0, tick).1 193 | }; 194 | (tick, label) 195 | }) 196 | .collect::>() 197 | }) 198 | } 199 | -------------------------------------------------------------------------------- /leptos-chartistry/src/inner/guide_line.rs: -------------------------------------------------------------------------------- 1 | use crate::{bounds::Bounds, colours::Colour, debug::DebugRect, state::State, Tick}; 2 | use leptos::prelude::*; 3 | use std::str::FromStr; 4 | 5 | /// Default colour for guide lines. 6 | pub const GUIDE_LINE_COLOUR: Colour = Colour::from_rgb(0x9A, 0x9A, 0x9A); 7 | 8 | macro_rules! impl_guide_line { 9 | ($name:ident) => { 10 | /// Builds a mouse guide line. Aligned over the mouse position or nearest data. 11 | #[derive(Clone, Debug, PartialEq)] 12 | #[non_exhaustive] 13 | pub struct $name { 14 | /// Alignment of the guide line. 15 | pub align: RwSignal, 16 | /// Width of the guide line. 17 | pub width: RwSignal, 18 | /// Colour of the guide line. 19 | pub colour: RwSignal, 20 | } 21 | 22 | impl $name { 23 | fn new(align: AlignOver) -> Self { 24 | Self { 25 | align: RwSignal::new(align.into()), 26 | width: RwSignal::new(1.0), 27 | colour: RwSignal::new(GUIDE_LINE_COLOUR), 28 | } 29 | } 30 | 31 | /// Creates a new guide line aligned over the mouse position. 32 | pub fn over_mouse() -> Self { 33 | Self::new(AlignOver::Mouse) 34 | } 35 | 36 | /// Creates a new guide line aligned over the nearest data. 37 | pub fn over_data() -> Self { 38 | Self::new(AlignOver::Data) 39 | } 40 | 41 | /// Sets the colour of the guide line. 42 | pub fn with_colour(self, colour: impl Into) -> Self { 43 | self.colour.set(colour.into()); 44 | self 45 | } 46 | } 47 | 48 | impl Default for $name { 49 | fn default() -> Self { 50 | Self::new(AlignOver::default()) 51 | } 52 | } 53 | }; 54 | } 55 | 56 | impl_guide_line!(XGuideLine); 57 | impl_guide_line!(YGuideLine); 58 | 59 | /// Align over mouse or nearest data. 60 | #[derive(Copy, Clone, Debug, Default, PartialEq)] 61 | #[non_exhaustive] 62 | pub enum AlignOver { 63 | /// Align over the mouse position. 64 | #[default] 65 | Mouse, 66 | /// Align over the nearest data. Creates a "snap to data" effect. 67 | Data, 68 | } 69 | 70 | #[derive(Clone)] 71 | pub struct UseXGuideLine(XGuideLine); 72 | 73 | #[derive(Clone)] 74 | pub struct UseYGuideLine(YGuideLine); 75 | 76 | impl XGuideLine { 77 | pub(crate) fn use_horizontal(self) -> UseXGuideLine { 78 | UseXGuideLine(self) 79 | } 80 | } 81 | 82 | impl YGuideLine { 83 | pub(crate) fn use_vertical(self) -> UseYGuideLine { 84 | UseYGuideLine(self) 85 | } 86 | } 87 | 88 | impl std::fmt::Display for AlignOver { 89 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 90 | match self { 91 | AlignOver::Mouse => write!(f, "mouse"), 92 | AlignOver::Data => write!(f, "data"), 93 | } 94 | } 95 | } 96 | 97 | impl FromStr for AlignOver { 98 | type Err = String; 99 | fn from_str(s: &str) -> Result { 100 | match s { 101 | "mouse" => Ok(AlignOver::Mouse), 102 | "data" => Ok(AlignOver::Data), 103 | _ => Err(format!("invalid align over: `{}`", s)), 104 | } 105 | } 106 | } 107 | 108 | #[component] 109 | pub(super) fn XGuideLine( 110 | line: UseXGuideLine, 111 | state: State, 112 | ) -> impl IntoView { 113 | let line = line.0; 114 | let inner = state.layout.inner; 115 | let mouse_chart = state.mouse_chart; 116 | 117 | // Data alignment 118 | let nearest_pos_x = state.pre.data.nearest_position_x(state.hover_position_x); 119 | let nearest_svg_x = Memo::new(move |_| { 120 | nearest_pos_x 121 | .get() 122 | .map(|pos_x| state.projection.get().position_to_svg(pos_x, 0.0).0) 123 | }); 124 | 125 | let pos = Signal::derive(move || { 126 | let (mouse_x, _) = mouse_chart.get(); 127 | let x = match line.align.get() { 128 | AlignOver::Data => nearest_svg_x.get().unwrap_or(mouse_x), 129 | AlignOver::Mouse => mouse_x, 130 | }; 131 | let inner = inner.get(); 132 | Bounds::from_points(x, inner.top_y(), x, inner.bottom_y()) 133 | }); 134 | 135 | view! { 136 | 137 | } 138 | } 139 | 140 | #[component] 141 | pub(super) fn YGuideLine( 142 | line: UseYGuideLine, 143 | state: State, 144 | ) -> impl IntoView { 145 | let line = line.0; 146 | let inner = state.layout.inner; 147 | let mouse_chart = state.mouse_chart; 148 | // TODO align over 149 | let pos = Signal::derive(move || { 150 | let (_, mouse_y) = mouse_chart.get(); 151 | let inner = inner.get(); 152 | Bounds::from_points(inner.left_x(), mouse_y, inner.right_x(), mouse_y) 153 | }); 154 | view! { 155 | 156 | } 157 | } 158 | 159 | #[component] 160 | fn GuideLine( 161 | id: &'static str, 162 | width: RwSignal, 163 | colour: RwSignal, 164 | state: State, 165 | pos: Signal, 166 | ) -> impl IntoView { 167 | let debug = state.pre.debug; 168 | let hover_inner = state.hover_inner; 169 | 170 | let x1 = Memo::new(move |_| pos.get().left_x()); 171 | let y1 = Memo::new(move |_| pos.get().top_y()); 172 | let x2 = Memo::new(move |_| pos.get().right_x()); 173 | let y2 = Memo::new(move |_| pos.get().bottom_y()); 174 | 175 | // Don't render if any of the coordinates are NaN i.e., no data 176 | let have_data = Signal::derive(move || { 177 | !(x1.get().is_nan() || y1.get().is_nan() || x2.get().is_nan() || y2.get().is_nan()) 178 | }); 179 | 180 | view! { 181 | 185 | 186 | 187 | 193 | 194 | 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /leptos-chartistry/src/inner/legend.rs: -------------------------------------------------------------------------------- 1 | use crate::{edge::Edge, state::State, Anchor, Legend, Tick}; 2 | use leptos::prelude::*; 3 | 4 | /// Builds an inset legend for the chart [series](crate::Series). Differs from [Legend](struct@Legend) by being placed inside the chart area. 5 | #[derive(Clone, Debug, PartialEq)] 6 | #[non_exhaustive] 7 | pub struct InsetLegend { 8 | /// Edge of the chart area to place the legend. 9 | pub edge: RwSignal, 10 | /// Legend to display. Relies on the internal `anchor` signal. See [Legend](struct@Legend) for details. 11 | pub legend: Legend, 12 | } 13 | 14 | impl InsetLegend { 15 | fn new(edge: Edge, anchor: Anchor) -> Self { 16 | Self { 17 | edge: RwSignal::new(edge), 18 | legend: Legend::new(anchor), 19 | } 20 | } 21 | 22 | /// Creates a new inset legend placed at the top-left corner of the chart area. 23 | pub fn top_left() -> Self { 24 | Self::new(Edge::Top, Anchor::Start) 25 | } 26 | /// Creates a new inset legend placed at the top-middle of the chart area. 27 | pub fn top() -> Self { 28 | Self::new(Edge::Top, Anchor::Middle) 29 | } 30 | /// Creates a new inset legend placed at the top-right corner of the chart area. 31 | pub fn top_right() -> Self { 32 | Self::new(Edge::Top, Anchor::End) 33 | } 34 | /// Creates a new inset legend placed at the bottom-left corner of the chart area. 35 | pub fn bottom_left() -> Self { 36 | Self::new(Edge::Bottom, Anchor::Start) 37 | } 38 | /// Creates a new inset legend placed at the bottom-middle of the chart area. 39 | pub fn bottom() -> Self { 40 | Self::new(Edge::Bottom, Anchor::Middle) 41 | } 42 | /// Creates a new inset legend placed at the bottom-right corner of the chart area. 43 | pub fn bottom_right() -> Self { 44 | Self::new(Edge::Bottom, Anchor::End) 45 | } 46 | /// Creates a new inset legend placed at the left-middle of the chart area. 47 | pub fn left() -> Self { 48 | Self::new(Edge::Left, Anchor::Middle) 49 | } 50 | /// Creates a new inset legend placed at the right-middle of the chart area. 51 | pub fn right() -> Self { 52 | Self::new(Edge::Right, Anchor::Middle) 53 | } 54 | } 55 | 56 | #[component] 57 | pub(super) fn InsetLegend( 58 | legend: InsetLegend, 59 | state: State, 60 | ) -> impl IntoView { 61 | let InsetLegend { edge, legend } = legend; 62 | let inner = state.layout.inner; 63 | let width = Legend::width(&state.pre); 64 | let height = legend.fixed_height(&state.pre); 65 | let bounds = Memo::new(move |_| { 66 | let inner = inner.get(); 67 | let height = height.get(); 68 | let width = width.get(); 69 | // Build legend bounds as an inset of the chart bounds 70 | let (top, right, bottom, left) = match edge.get() { 71 | Edge::Top => (0.0, 0.0, inner.height() - height, 0.0), 72 | Edge::Bottom => (inner.height() - height, 0.0, 0.0, 0.0), 73 | Edge::Left => (0.0, inner.width() - width, 0.0, 0.0), 74 | Edge::Right => (0.0, 0.0, 0.0, inner.width() - width), 75 | }; 76 | inner.shrink(top, right, bottom, left) 77 | }); 78 | 79 | view! { 80 | 81 | 82 | 83 | } 84 | .into_any() 85 | } 86 | -------------------------------------------------------------------------------- /leptos-chartistry/src/inner/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod axis_marker; 2 | pub mod grid_line; 3 | pub mod guide_line; 4 | pub mod legend; 5 | 6 | use crate::{state::State, Tick}; 7 | use axis_marker::AxisMarker; 8 | use grid_line::{XGridLine, YGridLine}; 9 | use guide_line::{XGuideLine, YGuideLine}; 10 | use legend::InsetLegend; 11 | use leptos::prelude::*; 12 | 13 | /// Inner layout options for a [Chart](crate::Chart). See [IntoInner](trait@IntoInner) for details. 14 | #[derive(Clone)] 15 | #[non_exhaustive] 16 | pub enum InnerLayout { 17 | /// Axis marker. See [AxisMarker](struct@axis_marker::AxisMarker) for details. 18 | AxisMarker(axis_marker::AxisMarker), 19 | /// X grid line. See [XGridLine](struct@grid_line::XGridLine) for details. 20 | XGridLine(grid_line::XGridLine), 21 | /// Y grid line. See [YGridLine](struct@grid_line::YGridLine) for details. 22 | YGridLine(grid_line::YGridLine), 23 | /// X guide line. See [XGuideLine](struct@guide_line::XGuideLine) for details. 24 | XGuideLine(guide_line::XGuideLine), 25 | /// Y guide line. See [YGuideLine](struct@guide_line::YGuideLine) for details. 26 | YGuideLine(guide_line::YGuideLine), 27 | /// Inset legend. See [InsetLegend](struct@legend::InsetLegend) for details. 28 | Legend(legend::InsetLegend), 29 | } 30 | 31 | /// Convert a type (e.g., a [guide line](struct@guide_line::XGuideLine)) into an inner layout for use in a [Chart](crate::Chart). 32 | pub trait IntoInner { 33 | /// Create an inner layout from the type. See [IntoInner](trait@IntoInner) for details. 34 | fn into_inner(self) -> InnerLayout; 35 | } 36 | 37 | pub enum UseInner { 38 | AxisMarker(axis_marker::AxisMarker), 39 | XGridLine(grid_line::UseXGridLine), 40 | YGridLine(grid_line::UseYGridLine), 41 | XGuideLine(guide_line::UseXGuideLine), 42 | YGuideLine(guide_line::UseYGuideLine), 43 | Legend(legend::InsetLegend), 44 | } 45 | 46 | impl InnerLayout { 47 | pub(super) fn into_use(self, state: &State) -> UseInner { 48 | match self { 49 | Self::AxisMarker(inner) => UseInner::AxisMarker(inner), 50 | Self::XGridLine(inner) => UseInner::XGridLine(inner.use_horizontal(state)), 51 | Self::YGridLine(inner) => UseInner::YGridLine(inner.use_vertical(state)), 52 | Self::XGuideLine(inner) => UseInner::XGuideLine(inner.use_horizontal()), 53 | Self::YGuideLine(inner) => UseInner::YGuideLine(inner.use_vertical()), 54 | Self::Legend(inner) => UseInner::Legend(inner), 55 | } 56 | } 57 | } 58 | 59 | impl UseInner { 60 | pub(super) fn render(self, state: State) -> impl IntoView { 61 | match self { 62 | Self::AxisMarker(inner) => view! {}.into_any(), 63 | Self::XGridLine(inner) => view! {}.into_any(), 64 | Self::YGridLine(inner) => view! {}.into_any(), 65 | Self::XGuideLine(inner) => view! {}.into_any(), 66 | Self::YGuideLine(inner) => view! {}.into_any(), 67 | Self::Legend(inner) => view! {}.into_any(), 68 | } 69 | } 70 | } 71 | 72 | macro_rules! impl_into_inner { 73 | ($ty:ty, $enum:ident) => { 74 | impl IntoInner for $ty { 75 | fn into_inner(self) -> InnerLayout { 76 | InnerLayout::$enum(self) 77 | } 78 | } 79 | 80 | impl From<$ty> for InnerLayout { 81 | fn from(inner: $ty) -> Self { 82 | inner.into_inner() 83 | } 84 | } 85 | 86 | impl From<$ty> for Vec> { 87 | fn from(inner: $ty) -> Self { 88 | vec![inner.into_inner()] 89 | } 90 | } 91 | }; 92 | } 93 | impl_into_inner!(axis_marker::AxisMarker, AxisMarker); 94 | impl_into_inner!(grid_line::XGridLine, XGridLine); 95 | impl_into_inner!(grid_line::YGridLine, YGridLine); 96 | impl_into_inner!(guide_line::XGuideLine, XGuideLine); 97 | impl_into_inner!(guide_line::YGuideLine, YGuideLine); 98 | impl_into_inner!(legend::InsetLegend, Legend); 99 | -------------------------------------------------------------------------------- /leptos-chartistry/src/layout/compose.rs: -------------------------------------------------------------------------------- 1 | use super::{EdgeLayout, UseLayout}; 2 | use crate::{ 3 | aspect_ratio::KnownAspectRatio, 4 | bounds::Bounds, 5 | edge::Edge, 6 | state::{PreState, State}, 7 | Tick, 8 | }; 9 | use leptos::prelude::*; 10 | 11 | #[derive(Clone, Debug)] 12 | #[non_exhaustive] 13 | pub struct Layout { 14 | pub outer: Memo, 15 | pub top: Vec>, 16 | pub right: Vec>, 17 | pub bottom: Vec>, 18 | pub left: Vec>, 19 | pub inner: Memo, 20 | pub x_width: Memo, 21 | } 22 | 23 | #[derive(Clone)] 24 | pub struct DeferredRender { 25 | edge: Edge, 26 | bounds: Memo, 27 | layout: UseLayout, 28 | } 29 | 30 | impl DeferredRender { 31 | pub fn render(self, state: State) -> impl IntoView { 32 | self.layout.render(self.edge, self.bounds, state) 33 | } 34 | } 35 | 36 | impl Layout { 37 | /// Composes a layout giving bounds to edges and invididual components. 38 | /// 39 | /// Note: 40 | /// Horizontal (top, bottom, x-axis) options have a fixed height (not dependent on the bounds of other elements) that constrains the layout. 41 | /// Vertical (left, right, y-axis) options have a variable width and are generated at layout time having been constrained by the horizontal options. 42 | /// 43 | /// This function is long but procedural. General process: 44 | /// - Constrain the layout using fixed height from top / bottom edges. 45 | /// - Calculate the inner height. 46 | /// - Process the left / right components using inner height. 47 | /// - Calculate the inner width. 48 | /// - Process top / bottom components using inner width. 49 | /// - Calculate the bounds: outer, inner, edges, edge components. Adhere to aspect ratio. 50 | /// - Return state (Layout) and a deferred renderer (ComposedLayout). 51 | /// 52 | pub fn compose( 53 | top: &[EdgeLayout], 54 | right: &[EdgeLayout], 55 | bottom: &[EdgeLayout], 56 | left: &[EdgeLayout], 57 | aspect_ratio: Memo, 58 | state: &PreState, 59 | ) -> (Layout, Vec) { 60 | // Horizontal options 61 | let top_heights = collect_heights(top, state); 62 | let top_height = sum_sizes(top_heights.clone()); 63 | let bottom_heights = collect_heights(bottom, state); 64 | let bottom_height = sum_sizes(bottom_heights.clone()); 65 | let inner_height = 66 | KnownAspectRatio::inner_height_signal(aspect_ratio, top_height, bottom_height); 67 | 68 | // Vertical options 69 | let (left_widths, left) = use_vertical(left, state, inner_height); 70 | let left_width = sum_sizes(left_widths.clone()); 71 | let (right_widths, right) = use_vertical(right, state, inner_height); 72 | let right_width = sum_sizes(right_widths.clone()); 73 | let avail_width = 74 | KnownAspectRatio::inner_width_signal(aspect_ratio, left_width, right_width); 75 | 76 | // Bounds 77 | let outer = Memo::new(move |_| { 78 | Bounds::new( 79 | left_width.get() + avail_width.get() + right_width.get(), 80 | top_height.get() + inner_height.get() + bottom_height.get(), 81 | ) 82 | }); 83 | let inner = Memo::new(move |_| { 84 | outer.get().shrink( 85 | top_height.get(), 86 | right_width.get(), 87 | bottom_height.get(), 88 | left_width.get(), 89 | ) 90 | }); 91 | 92 | // Edge bounds 93 | let top_bounds = Memo::new(move |_| { 94 | let i = inner.get(); 95 | Bounds::from_points(i.left_x(), outer.get().top_y(), i.right_x(), i.top_y()) 96 | }); 97 | let right_bounds = Memo::new(move |_| { 98 | let i = inner.get(); 99 | Bounds::from_points(i.right_x(), i.top_y(), outer.get().right_x(), i.bottom_y()) 100 | }); 101 | let bottom_bounds = Memo::new(move |_| { 102 | let i = inner.get(); 103 | let bottom_y = outer.get().bottom_y(); 104 | Bounds::from_points(i.left_x(), i.bottom_y(), i.right_x(), bottom_y) 105 | }); 106 | let left_bounds = Memo::new(move |_| { 107 | let i = inner.get(); 108 | Bounds::from_points(outer.get().left_x(), i.top_y(), i.left_x(), i.bottom_y()) 109 | }); 110 | 111 | // Find the width of each X 112 | let data_len = state.data.len; 113 | let x_width = Memo::new(move |_| inner.get().width() / data_len.get() as f64); 114 | 115 | // State signals 116 | let layout = Layout { 117 | outer, 118 | top: option_bounds(Edge::Top, top_bounds, top_heights), 119 | right: option_bounds(Edge::Right, right_bounds, right_widths), 120 | bottom: option_bounds(Edge::Bottom, bottom_bounds, bottom_heights), 121 | left: option_bounds(Edge::Left, left_bounds, left_widths), 122 | inner, 123 | x_width, 124 | }; 125 | 126 | let vertical = |edge, bounds: &[Memo], items: Vec<_>| { 127 | items 128 | .into_iter() 129 | .enumerate() 130 | .map(move |(index, opt)| (edge, bounds[index], opt)) 131 | .collect::>() 132 | }; 133 | let horizontal = |edge: Edge, bounds: &[Memo], items: &[EdgeLayout]| { 134 | items 135 | .iter() 136 | .enumerate() 137 | .map(|(index, opt)| { 138 | ( 139 | edge, 140 | bounds[index], 141 | opt.to_horizontal_use(state, avail_width), 142 | ) 143 | }) 144 | .collect::>() 145 | }; 146 | 147 | // Chain edges together for a deferred render 148 | let deferred = vertical(Edge::Left, &layout.left, left) 149 | .into_iter() 150 | .chain(vertical(Edge::Right, &layout.right, right)) 151 | .chain(horizontal(Edge::Top, &layout.top, top)) 152 | .chain(horizontal(Edge::Bottom, &layout.bottom, bottom)) 153 | .map(|(edge, bounds, layout)| DeferredRender { 154 | edge, 155 | bounds, 156 | layout, 157 | }) 158 | .collect::>(); 159 | 160 | (layout, deferred) 161 | } 162 | } 163 | 164 | fn collect_heights( 165 | items: &[EdgeLayout], 166 | state: &PreState, 167 | ) -> Vec> { 168 | items 169 | .iter() 170 | .map(|c| c.fixed_height(state)) 171 | .collect::>() 172 | } 173 | 174 | fn use_vertical( 175 | items: &[EdgeLayout], 176 | state: &PreState, 177 | avail_height: Memo, 178 | ) -> (Vec>, Vec) { 179 | items 180 | .iter() 181 | .map(|c| { 182 | let vert = c.to_vertical_use(state, avail_height); 183 | (vert.width, vert.layout) 184 | }) 185 | .unzip() 186 | } 187 | 188 | fn sum_sizes(sizes: Vec>) -> Memo { 189 | Memo::new(move |_| sizes.iter().map(|opt| opt.get()).sum::()) 190 | } 191 | 192 | fn option_bounds(edge: Edge, outer: Memo, sizes: Vec>) -> Vec> { 193 | let mut seen = Vec::>::with_capacity(sizes.len()); 194 | sizes 195 | .into_iter() 196 | .map(|size| { 197 | let prev = seen.clone(); 198 | seen.push(size); 199 | Memo::new(move |_| { 200 | // Proximal "nearest" and distal "furthest" are distances from the inner edge 201 | let proximal = prev.iter().map(|s| s.get()).sum::(); 202 | let distal = proximal + size.get(); 203 | let outer = outer.get(); 204 | let width = outer.width(); 205 | let height = outer.height(); 206 | match edge { 207 | Edge::Top => outer.shrink(height - distal, 0.0, proximal, 0.0), 208 | Edge::Bottom => outer.shrink(proximal, 0.0, height - distal, 0.0), 209 | Edge::Left => outer.shrink(0.0, proximal, 0.0, width - distal), 210 | Edge::Right => outer.shrink(0.0, width - distal, 0.0, proximal), 211 | } 212 | }) 213 | }) 214 | .collect::>() 215 | } 216 | -------------------------------------------------------------------------------- /leptos-chartistry/src/layout/legend.rs: -------------------------------------------------------------------------------- 1 | use super::{rotated_label::Anchor, UseLayout, UseVerticalLayout}; 2 | use crate::{ 3 | bounds::Bounds, 4 | debug::DebugRect, 5 | edge::Edge, 6 | series::{Snippet, UseY}, 7 | state::{PreState, State}, 8 | Padding, Tick, 9 | }; 10 | use leptos::prelude::*; 11 | 12 | /// Builds a legend for the chart [series](crate::Series). Orientated along the axis of its placed edge. Drawn in HTML. 13 | #[derive(Clone, Debug, PartialEq)] 14 | #[non_exhaustive] 15 | pub struct Legend { 16 | /// Anchor of the legend. 17 | pub anchor: RwSignal, 18 | } 19 | 20 | impl Legend { 21 | pub(crate) fn new(anchor: Anchor) -> Self { 22 | Self { 23 | anchor: RwSignal::new(anchor), 24 | } 25 | } 26 | 27 | /// Creates a new legend placed at the start of the line layout. 28 | pub fn start() -> Legend { 29 | Self::new(Anchor::Start) 30 | } 31 | /// Creates a new legend placed in the middle of the line layout. 32 | pub fn middle() -> Legend { 33 | Self::new(Anchor::Middle) 34 | } 35 | /// Creates a new legend placed at the end of the line layout. 36 | pub fn end() -> Legend { 37 | Self::new(Anchor::End) 38 | } 39 | 40 | pub(crate) fn width(state: &PreState) -> Signal { 41 | let font_height = state.font_height; 42 | let font_width = state.font_width; 43 | let padding = state.padding; 44 | let series = state.data.series; 45 | let snippet_bounds = UseY::snippet_width(font_height, font_width); 46 | Signal::derive(move || { 47 | let font_width = font_width.get(); 48 | let max_chars = series 49 | .get() 50 | .into_iter() 51 | .map(|line| line.name.get().len() as f64 * font_width) 52 | .reduce(f64::max) 53 | .unwrap_or_default(); 54 | snippet_bounds.get() + max_chars + padding.get().width() 55 | }) 56 | } 57 | 58 | pub(crate) fn fixed_height(&self, state: &PreState) -> Signal { 59 | let font_height = state.font_height; 60 | let padding = state.padding; 61 | Signal::derive(move || font_height.get() + padding.get().height()) 62 | } 63 | 64 | pub(super) fn to_horizontal_use(&self) -> UseLayout { 65 | UseLayout::Legend(self.clone()) 66 | } 67 | 68 | pub(super) fn to_vertical_use( 69 | &self, 70 | state: &PreState, 71 | ) -> UseVerticalLayout { 72 | UseVerticalLayout { 73 | width: Self::width(state), 74 | layout: UseLayout::Legend(self.clone()), 75 | } 76 | } 77 | } 78 | 79 | #[component] 80 | pub(crate) fn Legend( 81 | legend: Legend, 82 | #[prop(into)] edge: Signal, 83 | bounds: Memo, 84 | state: State, 85 | ) -> impl IntoView { 86 | let anchor = legend.anchor; 87 | let debug = state.pre.debug; 88 | let font_height = state.pre.font_height; 89 | let padding = state.pre.padding; 90 | let series = state.pre.data.series; 91 | 92 | // Don't apply padding on the edges of our axis i.e., maximise the space we extend over 93 | let padding = Memo::new(move |_| { 94 | let padding = padding.get(); 95 | if edge.get().is_horizontal() { 96 | Padding::sides(padding.top, 0.0, padding.bottom, 0.0) 97 | } else { 98 | Padding::sides(0.0, padding.right, 0.0, padding.left) 99 | } 100 | }); 101 | let inner = Signal::derive(move || padding.get().apply(bounds.get())); 102 | 103 | let html = move || { 104 | let edge = edge.get(); 105 | let body = if edge.is_horizontal() { 106 | view! {}.into_any() 107 | } else { 108 | view! {}.into_any() 109 | }; 110 | view! { 111 |
115 | 118 | 119 | {body} 120 | 121 |
122 |
123 | } 124 | .into_any() 125 | }; 126 | 127 | view! { 128 | 129 | 130 | 136 | {html} 137 | 138 | 139 | } 140 | } 141 | 142 | #[component] 143 | fn VerticalBody(series: Memo>, state: State) -> impl IntoView { 144 | let padding = move || { 145 | let p = state.pre.padding.get(); 146 | format!("0 {}px 0 {}px", p.right, p.left) 147 | }; 148 | view! { 149 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | } 160 | } 161 | 162 | #[component] 163 | fn HorizontalBody(series: Memo>, state: State) -> impl IntoView { 164 | let padding_left = move |i| { 165 | (i != 0) 166 | .then_some(state.pre.padding.get().left) 167 | .map(|p| format!("{}px", p)) 168 | .unwrap_or_default() 169 | }; 170 | view! { 171 | 172 | 176 | 177 | 178 | 179 | 180 | 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /leptos-chartistry/src/layout/mod.rs: -------------------------------------------------------------------------------- 1 | mod compose; 2 | pub mod legend; 3 | pub mod rotated_label; 4 | pub mod tick_labels; 5 | 6 | pub use compose::Layout; 7 | 8 | use crate::{ 9 | bounds::Bounds, 10 | edge::Edge, 11 | state::{PreState, State}, 12 | Tick, 13 | }; 14 | use leptos::prelude::*; 15 | 16 | /// All possible layout options for an edge of a [Chart](crate::Chart). See [IntoEdge](trait@IntoEdge) for details. 17 | #[derive(Clone)] 18 | #[non_exhaustive] 19 | pub enum EdgeLayout { 20 | /// Legend. See [legend](struct@legend::Legend) for details. 21 | Legend(legend::Legend), 22 | /// Rotated label. See [rotated_label](struct@rotated_label::RotatedLabel) for details. 23 | RotatedLabel(rotated_label::RotatedLabel), 24 | /// Tick labels. See [tick_labels](struct@tick_labels::TickLabels) for details. 25 | TickLabels(tick_labels::TickLabels), 26 | } 27 | 28 | struct UseVerticalLayout { 29 | width: Signal, 30 | layout: UseLayout, 31 | } 32 | 33 | #[derive(Clone)] 34 | enum UseLayout { 35 | Legend(legend::Legend), 36 | RotatedLabel(rotated_label::RotatedLabel), 37 | TickLabels(tick_labels::UseTickLabels), 38 | } 39 | 40 | impl UseLayout { 41 | fn render( 42 | self, 43 | edge: Edge, 44 | bounds: Memo, 45 | state: State, 46 | ) -> impl IntoView { 47 | match self { 48 | Self::Legend(inner) => view! {}.into_any(), 49 | Self::RotatedLabel(inner) => view! {}.into_any(), 50 | Self::TickLabels(inner) => view! {}.into_any(), 51 | } 52 | } 53 | } 54 | 55 | impl EdgeLayout { 56 | fn fixed_height(&self, state: &PreState) -> Signal { 57 | match self { 58 | Self::Legend(inner) => inner.fixed_height(state), 59 | Self::RotatedLabel(inner) => inner.fixed_height(state), 60 | Self::TickLabels(inner) => inner.fixed_height(state), 61 | } 62 | } 63 | 64 | fn to_horizontal_use( 65 | &self, 66 | state: &PreState, 67 | avail_width: Memo, 68 | ) -> UseLayout { 69 | match self { 70 | Self::Legend(inner) => inner.to_horizontal_use(), 71 | Self::RotatedLabel(inner) => inner.to_horizontal_use(), 72 | Self::TickLabels(inner) => inner.to_horizontal_use(state, avail_width), 73 | } 74 | } 75 | } 76 | 77 | impl EdgeLayout { 78 | fn to_vertical_use( 79 | &self, 80 | state: &PreState, 81 | avail_height: Memo, 82 | ) -> UseVerticalLayout { 83 | match self { 84 | Self::Legend(inner) => inner.to_vertical_use(state), 85 | Self::RotatedLabel(inner) => inner.to_vertical_use(state), 86 | Self::TickLabels(inner) => inner.to_vertical_use(state, avail_height), 87 | } 88 | } 89 | } 90 | 91 | /// Convert a type (e.g., a [rotated label](struct@rotated_label::RotatedLabel)) into an edge layout for use with [Chart](crate::Chart). 92 | pub trait IntoEdge { 93 | /// Create an edge layout from the type. See [IntoEdge](trait@IntoEdge) for details. 94 | fn into_edge(self) -> EdgeLayout; 95 | } 96 | 97 | macro_rules! impl_into_edge { 98 | ($ty:ty, $enum:ident) => { 99 | impl IntoEdge for $ty { 100 | fn into_edge(self) -> EdgeLayout { 101 | EdgeLayout::$enum(self) 102 | } 103 | } 104 | 105 | impl From<$ty> for EdgeLayout { 106 | fn from(inner: $ty) -> Self { 107 | inner.into_edge() 108 | } 109 | } 110 | 111 | impl From<$ty> for Vec> { 112 | fn from(inner: $ty) -> Self { 113 | vec![inner.into_edge()] 114 | } 115 | } 116 | }; 117 | } 118 | impl_into_edge!(legend::Legend, Legend); 119 | impl_into_edge!(rotated_label::RotatedLabel, RotatedLabel); 120 | impl_into_edge!(tick_labels::TickLabels, TickLabels); 121 | -------------------------------------------------------------------------------- /leptos-chartistry/src/layout/rotated_label.rs: -------------------------------------------------------------------------------- 1 | use super::{UseLayout, UseVerticalLayout}; 2 | use crate::{ 3 | bounds::Bounds, 4 | debug::DebugRect, 5 | edge::Edge, 6 | state::{PreState, State}, 7 | Tick, 8 | }; 9 | use leptos::prelude::*; 10 | use std::str::FromStr; 11 | 12 | /// Label placement on the main-axis of a component. An edge layout's main-axis runs parallel to its given edge. Similar to SVG's [text-anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) or CSS's [justify-content](https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content). 13 | #[derive(Copy, Clone, Debug, PartialEq)] 14 | #[non_exhaustive] 15 | pub enum Anchor { 16 | /// Start of the line (main-axis). 17 | Start, 18 | /// Middle of the line (main-axis). 19 | Middle, 20 | /// End of the line (main-axis). 21 | End, 22 | } 23 | 24 | /// Builds a rotated label to match the orientation of the axis it's placed on. 25 | /// 26 | /// Warning: does not wrap text. Extra text will not be clipped. 27 | #[derive(Clone, Debug, PartialEq)] 28 | #[non_exhaustive] 29 | pub struct RotatedLabel { 30 | /// Text to display. 31 | pub text: RwSignal, 32 | /// Anchor of the label. 33 | pub anchor: RwSignal, 34 | } 35 | 36 | impl RotatedLabel { 37 | fn new(anchor: Anchor, text: String) -> Self { 38 | Self { 39 | text: RwSignal::new(text), 40 | anchor: RwSignal::new(anchor), 41 | } 42 | } 43 | 44 | /// Creates a new rotated label anchored at the start of the line (main-axis). 45 | pub fn start(text: impl Into) -> Self { 46 | Self::new(Anchor::Start, text.into()) 47 | } 48 | /// Creates a new rotated label anchored at the middle of the line (main-axis). 49 | pub fn middle(text: impl Into) -> Self { 50 | Self::new(Anchor::Middle, text.into()) 51 | } 52 | /// Creates a new rotated label anchored at the end of the line (main-axis). 53 | pub fn end(text: impl Into) -> Self { 54 | Self::new(Anchor::End, text.into()) 55 | } 56 | 57 | fn size(&self, state: &PreState) -> Signal { 58 | let text = self.text; 59 | let font_height = state.font_height; 60 | let padding = state.padding; 61 | Signal::derive(move || { 62 | if text.with(|t| t.is_empty()) { 63 | 0.0 64 | } else { 65 | font_height.get() + padding.get().height() 66 | } 67 | }) 68 | } 69 | 70 | pub(super) fn fixed_height(&self, state: &PreState) -> Signal { 71 | self.size(state) 72 | } 73 | 74 | pub(super) fn to_horizontal_use(&self) -> UseLayout { 75 | UseLayout::RotatedLabel(self.clone()) 76 | } 77 | 78 | pub(super) fn to_vertical_use( 79 | &self, 80 | state: &PreState, 81 | ) -> UseVerticalLayout { 82 | // Note: width is height because it's rotated 83 | UseVerticalLayout { 84 | width: self.size(state), 85 | layout: UseLayout::RotatedLabel(self.clone()), 86 | } 87 | } 88 | } 89 | 90 | impl Anchor { 91 | fn to_svg_attr(self) -> String { 92 | self.to_string() 93 | } 94 | 95 | fn map_points(&self, left: f64, middle: f64, right: f64) -> f64 { 96 | match self { 97 | Anchor::Start => left, 98 | Anchor::Middle => middle, 99 | Anchor::End => right, 100 | } 101 | } 102 | 103 | pub(crate) fn css_justify_content(&self) -> &'static str { 104 | match self { 105 | Anchor::Start => "flex-start", 106 | Anchor::Middle => "center", 107 | Anchor::End => "flex-end", 108 | } 109 | } 110 | } 111 | 112 | impl FromStr for Anchor { 113 | type Err = String; 114 | 115 | fn from_str(s: &str) -> Result { 116 | match s.to_lowercase().as_str() { 117 | "start" => Ok(Anchor::Start), 118 | "middle" => Ok(Anchor::Middle), 119 | "end" => Ok(Anchor::End), 120 | _ => Err(format!("unknown anchor: `{}`", s)), 121 | } 122 | } 123 | } 124 | 125 | impl std::fmt::Display for Anchor { 126 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 127 | match self { 128 | Anchor::Start => write!(f, "start"), 129 | Anchor::Middle => write!(f, "middle"), 130 | Anchor::End => write!(f, "end"), 131 | } 132 | } 133 | } 134 | 135 | #[component] 136 | pub(super) fn RotatedLabel( 137 | label: RotatedLabel, 138 | edge: Edge, 139 | bounds: Memo, 140 | state: State, 141 | ) -> impl IntoView { 142 | let RotatedLabel { text, anchor } = label; 143 | let debug = state.pre.debug; 144 | let font_height = state.pre.font_height; 145 | let padding = state.pre.padding; 146 | 147 | let content = Signal::derive(move || padding.get().apply(bounds.get())); 148 | let position = Memo::new(move |_| { 149 | let c = content.get(); 150 | let (top, right, bottom, left) = (c.top_y(), c.right_x(), c.bottom_y(), c.left_x()); 151 | let (centre_x, centre_y) = (c.centre_x(), c.centre_y()); 152 | 153 | let anchor = anchor.get(); 154 | match edge { 155 | Edge::Top | Edge::Bottom => (0, anchor.map_points(left, centre_x, right), centre_y), 156 | Edge::Left => (270, centre_x, anchor.map_points(bottom, centre_y, top)), 157 | // Right rotates the opposite way to Left inverting the anchor points 158 | Edge::Right => (90, centre_x, anchor.map_points(top, centre_y, bottom)), 159 | } 160 | }); 161 | 162 | view! { 163 | 166 | 167 | 174 | {text} 175 | 176 | 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /leptos-chartistry/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | //! Welcome to Chartistry! This crate provides a flexible way to build charts in [Leptos](https://github.com/leptos-rs/leptos). 3 | //! 4 | //! All charts are built using the [Chart] fn. If you understand this function, you understand this library. 5 | //! 6 | //! ## Examples 7 | //! 8 | //! - See the [demo for Chartistry in action](https://feral-dot-io.github.io/leptos-chartistry/). 9 | //! - There is also an [large, assorted list of examples](https://feral-dot-io.github.io/leptos-chartistry/examples.html) available. 10 | //! 11 | //! Below is an example chart: 12 | //! 13 | //! ```rust 14 | //! use leptos::prelude::*; 15 | //! use leptos_chartistry::*; 16 | //! 17 | //! # use chrono::prelude::*; 18 | //! # struct MyData { x: DateTime, y1: f64, y2: f64 } 19 | //! # fn load_data() -> Signal> { Signal::default() } 20 | //! 21 | //! # #[component] 22 | //! # fn SimpleChartComponent() -> impl IntoView { 23 | //! let data: Signal> = load_data(/* pull data from a resource */); 24 | //! view! { 25 | //! 50 | //! } 51 | //! # } 52 | //! ``` 53 | 54 | mod aspect_ratio; 55 | mod bounds; 56 | mod chart; 57 | mod colours; 58 | mod debug; 59 | mod edge; 60 | mod inner; 61 | mod layout; 62 | mod overlay; 63 | mod padding; 64 | mod projection; 65 | mod series; 66 | mod state; 67 | mod ticks; 68 | mod use_watched_node; 69 | 70 | pub use aspect_ratio::AspectRatio; 71 | pub use chart::Chart; 72 | pub use colours::{Colour, ColourScheme, DivergingGradient, SequentialGradient}; 73 | pub use edge::Edge; 74 | pub use inner::{ 75 | axis_marker::{AxisMarker, AxisPlacement, AXIS_MARKER_COLOUR}, 76 | grid_line::{XGridLine, YGridLine, GRID_LINE_COLOUR}, 77 | guide_line::{AlignOver, XGuideLine, YGuideLine, GUIDE_LINE_COLOUR}, 78 | legend::InsetLegend, 79 | InnerLayout, IntoInner, IntoInner as _, 80 | }; 81 | pub use layout::{ 82 | legend::Legend, 83 | rotated_label::{Anchor, RotatedLabel}, 84 | tick_labels::TickLabels, 85 | EdgeLayout, IntoEdge, IntoEdge as _, 86 | }; 87 | pub use overlay::tooltip::{Tooltip, TooltipPlacement, TooltipSortBy, TOOLTIP_CURSOR_DISTANCE}; 88 | pub use padding::Padding; 89 | pub use series::{ 90 | Bar, BarPlacement, Interpolation, Line, Marker, MarkerShape, Series, Stack, Step, BAR_GAP, 91 | BAR_GAP_INNER, DIVERGING_GRADIENT, LINEAR_GRADIENT, SERIES_COLOUR_SCHEME, STACK_COLOUR_SCHEME, 92 | }; 93 | pub use ticks::{AlignedFloats, Period, Tick, TickFormat, Timestamps}; 94 | -------------------------------------------------------------------------------- /leptos-chartistry/src/overlay/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod tooltip; 2 | -------------------------------------------------------------------------------- /leptos-chartistry/src/padding.rs: -------------------------------------------------------------------------------- 1 | use super::bounds::Bounds; 2 | 3 | /// Represents padding around the edges of a component. 4 | #[derive(Copy, Clone, Debug, Default, PartialEq)] 5 | pub struct Padding { 6 | pub(crate) top: f64, 7 | pub(crate) right: f64, 8 | pub(crate) bottom: f64, 9 | pub(crate) left: f64, 10 | } 11 | 12 | /// Represents padding around a component. Note that the context matters on how it's applied. For example, padding applied to the whole chart will shrink the available space whereas padding applied to a label will increase the size used. 13 | impl Padding { 14 | /// Creates a new zero / empty / none padding. 15 | pub fn zero() -> Self { 16 | Self::sides(0.0, 0.0, 0.0, 0.0) 17 | } 18 | 19 | /// Creates a new padding with the given top, right, bottom, and left values. This is CSS style: clockwise from the top. 20 | pub fn sides(top: f64, right: f64, bottom: f64, left: f64) -> Self { 21 | Self { 22 | top, 23 | right, 24 | bottom, 25 | left, 26 | } 27 | } 28 | 29 | /// Creates a new padding with the given horizontal (top and bottom) and vertical (left and right) values. 30 | pub fn hv(h: f64, v: f64) -> Self { 31 | Self::sides(h, v, h, v) 32 | } 33 | 34 | /// Returns the total height of the padding. 35 | pub(crate) fn height(&self) -> f64 { 36 | self.top + self.bottom 37 | } 38 | 39 | /// Returns the total width of the padding. 40 | pub(crate) fn width(&self) -> f64 { 41 | self.left + self.right 42 | } 43 | 44 | /// Applies the padding to the given bounds. Shrinks the bounds by the padding. 45 | pub(crate) fn apply(self, outer: Bounds) -> Bounds { 46 | outer.shrink(self.top, self.right, self.bottom, self.left) 47 | } 48 | 49 | /// Converts the padding to a CSS style string. 50 | pub(crate) fn to_css_style(self) -> String { 51 | format!( 52 | "{}px {}px {}px {}px", 53 | self.top, self.right, self.bottom, self.left 54 | ) 55 | } 56 | } 57 | 58 | impl From for Padding { 59 | fn from(v: f64) -> Self { 60 | Padding::sides(v, v, v, v) 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | 68 | #[test] 69 | fn test_padding() { 70 | let p = Padding::sides(1.1, 2.2, 3.3, 4.4); 71 | assert_eq!(p.top, 1.1); 72 | assert_eq!(p.right, 2.2); 73 | assert_eq!(p.bottom, 3.3); 74 | assert_eq!(p.left, 4.4); 75 | assert_eq!(p.height(), 1.1 + 3.3); 76 | assert_eq!(p.width(), 2.2 + 4.4); 77 | assert_eq!( 78 | p.apply(Bounds::new(100.0, 200.0)), 79 | Bounds::from_points(4.4, 1.1, 97.8, 196.7) 80 | ); 81 | assert_eq!(p.to_css_style(), "1.1px 2.2px 3.3px 4.4px"); 82 | assert_eq!(Padding::zero().to_css_style(), "0px 0px 0px 0px"); 83 | assert_eq!( 84 | Padding::hv(1.1, 2.2).to_css_style(), 85 | "1.1px 2.2px 1.1px 2.2px" 86 | ); 87 | assert_eq!(Padding::from(1.1).to_css_style(), "1.1px 1.1px 1.1px 1.1px"); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /leptos-chartistry/src/projection.rs: -------------------------------------------------------------------------------- 1 | use crate::bounds::Bounds; 2 | 3 | /// A projection converts between data and SVG coordinates. SVG has zero in the top left corner. Data coordinates have zero in the bottom left. 4 | #[derive(Clone, Debug, PartialEq)] 5 | pub struct Projection { 6 | // SVG bounds 7 | bounds: Bounds, 8 | // Data offset 9 | left_x: f64, 10 | bottom_y: f64, 11 | 12 | x_mult: f64, 13 | y_mult: f64, 14 | } 15 | 16 | impl Projection { 17 | pub fn new(bounds: Bounds, range_x: Option<(f64, f64)>, range_y: Option<(f64, f64)>) -> Self { 18 | let (left_x, right_x) = range_x.unwrap_or_default(); 19 | let (bottom_y, top_y) = range_y.unwrap_or_default(); 20 | // If the range is zero, skip projection 21 | let width = right_x - left_x; 22 | let x_mult = bounds.width() / if width == 0.0 { 0.5 } else { width }; 23 | let height = top_y - bottom_y; 24 | let y_mult = bounds.height() / if height == 0.0 { 0.5 } else { height }; 25 | Projection { 26 | bounds, 27 | left_x, 28 | bottom_y, 29 | x_mult, 30 | y_mult, 31 | } 32 | } 33 | 34 | /// Converts a data point to SVG view coordinates. View coordinates are in SVG space with zero at top left. Data coordinates are in chart space with zero at bottom left. 35 | pub fn position_to_svg(&self, x: f64, y: f64) -> (f64, f64) { 36 | let x = self.bounds.left_x() + (x - self.left_x) * self.x_mult; 37 | let y = self.bounds.bottom_y() - (y - self.bottom_y) * self.y_mult; 38 | (x, y) 39 | } 40 | 41 | /// Converts an SVG point to data coordinates. View coordinates are in SVG space with zero at top left. Data coordinates are in chart space with zero at bottom left. 42 | pub fn svg_to_position(&self, x: f64, y: f64) -> (f64, f64) { 43 | let x = self.left_x + (x - self.bounds.left_x()) / self.x_mult; 44 | let y = self.bottom_y - (y - self.bounds.bottom_y()) / self.y_mult; 45 | (x, y) 46 | } 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use super::*; 52 | 53 | fn assert_coords(p: &Projection, pos: (f64, f64), svg: (f64, f64)) { 54 | assert_eq!(p.position_to_svg(pos.0, pos.1), (svg.0, svg.1), "to svg"); 55 | assert_eq!(p.svg_to_position(svg.0, svg.1), (pos.0, pos.1), "to pos"); 56 | } 57 | 58 | #[test] 59 | fn test_projection() { 60 | let bounds = Bounds::from_points(10.0, 10.0, 90.0, 90.0); 61 | let p = Projection::new(bounds, Some((0.0, 100.0)), Some((0.0, 100.0))); 62 | 63 | // Data range -> view bounds 64 | assert_coords(&p, (0.0, 0.0), (10.0, 90.0)); // Bottom left 65 | assert_coords(&p, (100.0, 0.0), (90.0, 90.0)); // Bottom right 66 | assert_coords(&p, (0.0, 100.0), (10.0, 10.0)); // Top left 67 | assert_coords(&p, (100.0, 100.0), (90.0, 10.0)); // Top right 68 | assert_coords(&p, (50.0, 50.0), (50.0, 50.0)); // Centre 69 | } 70 | 71 | #[test] 72 | fn test_incl_zero() { 73 | let bounds = Bounds::from_points(10.0, 10.0, 90.0, 90.0); 74 | let p = Projection::new(bounds, Some((0.0, 200.0)), Some((0.0, 200.0))); 75 | // Data range (0, 0) to (200, 200) -> view bounds 76 | assert_coords(&p, (0.0, 0.0), (10.0, 90.0)); // Bottom left 77 | assert_coords(&p, (200.0, 0.0), (90.0, 90.0)); // Bottom right 78 | assert_coords(&p, (0.0, 200.0), (10.0, 10.0)); // Top left 79 | assert_coords(&p, (200.0, 200.0), (90.0, 10.0)); // Top right 80 | assert_coords(&p, (100.0, 100.0), (50.0, 50.0)); // Centre 81 | } 82 | 83 | #[test] 84 | fn test_projection_zero_range() { 85 | let bounds = Bounds::from_points(10.0, 10.0, 90.0, 90.0); 86 | Projection::new(bounds, None, None); 87 | } 88 | 89 | #[test] 90 | fn test_partial_eq() { 91 | let bounds = Bounds::from_points(10.0, 10.0, 90.0, 90.0); 92 | let p = Projection::new( 93 | bounds, 94 | Some((bounds.left_x(), bounds.right_x())), 95 | Some((bounds.bottom_y(), bounds.top_y())), 96 | ); 97 | assert_eq!(p, p.clone()); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/bar.rs: -------------------------------------------------------------------------------- 1 | use super::{ApplyUseSeries, GetYValue, IntoUseBar, SeriesAcc, UseY}; 2 | use crate::{state::State, Colour, Tick}; 3 | use leptos::prelude::*; 4 | use std::sync::Arc; 5 | 6 | /// Default gap ratio between bars. 7 | pub const BAR_GAP: f64 = 0.1; 8 | /// Default gap ratio inside a group of bars. 9 | pub const BAR_GAP_INNER: f64 = 0.05; 10 | 11 | /// Draws a bar on the chart. 12 | /// 13 | /// # Example 14 | /// Creating a bar chart is simple. Add it to a series and pass it to a chart: 15 | /// ```rust 16 | /// # use leptos_chartistry::*; 17 | /// # struct MyData { x: f64, y1: f64, y2: f64 } 18 | /// let series = Series::new(|data: &MyData| data.x) 19 | /// .bar(|data: &MyData| data.y1) 20 | /// .bar(|data: &MyData| data.y2); 21 | /// ``` 22 | /// See this in action with a [full bar chart example](https://feral-dot-io.github.io/leptos-chartistry/examples.html#bar-chart). 23 | #[non_exhaustive] 24 | pub struct Bar { 25 | get_y: Arc>, 26 | /// Set the name of the bar as used in the legend and tooltip. 27 | pub name: RwSignal, 28 | /// Set the colour of the bar. If not set, the next colour in the series will be used. Default is `None`. 29 | pub colour: RwSignal>, 30 | /// Sets where the bar's bottom is placed. Defaults to the zero line. 31 | pub placement: RwSignal, 32 | /// Set the gap between group bars. Clamped to 0.0 and 1.0. Defaults to 0.1. 33 | /// 34 | /// The gap is the ratio of the available width for an X value. For example if the chart has a width of 200px and 5 items (`T`) that leaves 40px per item. So a gap of 0.1 (10%) would leave 4px between each item with 2px on either side. 35 | pub gap: RwSignal, 36 | /// Set the gap inside a group of bars. Clamped to 0.0 and 1.0. Defaults to 0.05. 37 | /// 38 | /// The group gap is the ratio of the available width for a single bar in a group of bars (for a single X value). Carrying on the example from [gap](Self::gap) a group gap of 0.05 (5%) and two bars would result in 1px (40 / 2 * 0.05). This group gap becomes the space inbetween each bar. 39 | pub group_gap: RwSignal, 40 | } 41 | 42 | /// The location of where the bar extends from. 43 | #[derive(Copy, Clone, Debug, Default, PartialEq)] 44 | #[non_exhaustive] 45 | pub enum BarPlacement { 46 | /// The bar extends from the zero line. 47 | #[default] 48 | Zero, 49 | /// The bar extends from the edge of the chart. 50 | Edge, 51 | } 52 | 53 | #[derive(Clone, Debug, PartialEq)] 54 | pub struct UseBar { 55 | group_id: usize, 56 | colour: Signal, 57 | placement: RwSignal, 58 | gap: RwSignal, 59 | group_gap: RwSignal, 60 | } 61 | 62 | impl Bar { 63 | /// Create a new bar. Use `get_y` to extract the Y value from your struct. 64 | /// 65 | /// See the module documentation for examples. 66 | pub fn new(get_y: impl Fn(&T) -> Y + Send + Sync + 'static) -> Self 67 | where 68 | Y: Tick, 69 | { 70 | Self { 71 | get_y: Arc::new(get_y), 72 | name: RwSignal::default(), 73 | colour: RwSignal::default(), 74 | placement: RwSignal::default(), 75 | gap: RwSignal::new(BAR_GAP), 76 | group_gap: RwSignal::new(BAR_GAP_INNER), 77 | } 78 | } 79 | 80 | /// Set the name of the bar. Used in the legend. 81 | pub fn with_name(self, name: impl Into) -> Self { 82 | self.name.set(name.into()); 83 | self 84 | } 85 | 86 | /// Set the colour of the bar. If not set, the next colour in the series will be used. 87 | pub fn with_colour(self, colour: impl Into>) -> Self { 88 | self.colour.set(colour.into()); 89 | self 90 | } 91 | 92 | /// Set the placement of the bar. 93 | pub fn with_placement(self, placement: impl Into) -> Self { 94 | self.placement.set(placement.into()); 95 | self 96 | } 97 | 98 | /// Set the gap between a group of bars. Clamped to 0.0 and 1.0. Defaults to 0.1. 99 | pub fn with_gap(self, gap: f64) -> Self { 100 | self.gap.set(gap); 101 | self 102 | } 103 | 104 | /// Set the gap inside a group of bars. Clamped to 0.0 and 1.0. Defaults to 0.05. 105 | pub fn with_group_gap(self, group_gap: f64) -> Self { 106 | self.group_gap.set(group_gap); 107 | self 108 | } 109 | } 110 | 111 | impl Clone for Bar { 112 | fn clone(&self) -> Self { 113 | Self { 114 | get_y: self.get_y.clone(), 115 | placement: self.placement, 116 | gap: self.gap, 117 | group_gap: self.group_gap, 118 | name: self.name, 119 | colour: self.colour, 120 | } 121 | } 122 | } 123 | 124 | impl Y + Send + Sync + 'static> From for Bar { 125 | fn from(f: F) -> Self { 126 | Self::new(f) 127 | } 128 | } 129 | 130 | impl ApplyUseSeries for Bar { 131 | fn apply_use_series(self: Arc, series: &mut SeriesAcc) { 132 | let colour = series.next_colour(); 133 | _ = series.push_bar(colour, (*self).clone()); 134 | } 135 | } 136 | 137 | impl IntoUseBar for Bar { 138 | fn into_use_bar( 139 | self, 140 | id: usize, 141 | group_id: usize, 142 | colour: Memo, 143 | ) -> (UseY, Arc>) { 144 | let override_colour = self.colour; 145 | let colour = Signal::derive(move || override_colour.get().unwrap_or(colour.get())); 146 | let bar = UseY::new_bar( 147 | id, 148 | self.name, 149 | UseBar { 150 | group_id, 151 | colour, 152 | placement: self.placement, 153 | gap: self.gap, 154 | group_gap: self.group_gap, 155 | }, 156 | ); 157 | (bar, self.get_y.clone()) 158 | } 159 | } 160 | 161 | #[component] 162 | pub fn RenderBar( 163 | bar: UseBar, 164 | state: State, 165 | positions: Signal>, 166 | ) -> impl IntoView { 167 | let bars = Memo::new(move |_| { 168 | state 169 | .pre 170 | .data 171 | .series 172 | .get() 173 | .iter() 174 | .filter_map(|series| series.bar().map(|bar| (series.clone(), bar.clone()))) 175 | .collect::>() 176 | .len() 177 | }); 178 | 179 | let rects = move || { 180 | positions.with(|positions| { 181 | // Find the bottom Y position of each bar 182 | // TODO Edge placement makes no sense with negative data 183 | let bottom_y = match bar.placement.get() { 184 | BarPlacement::Zero => state.svg_zero.get().1, 185 | BarPlacement::Edge => state.layout.inner.get().bottom_y(), 186 | }; 187 | 188 | // Find width of each X position 189 | // Note: this should possibly be on Layout 190 | let gap = bar.gap.get().clamp(0.0, 1.0); 191 | let width = state.layout.x_width.get() * (1.0 - gap); 192 | // Find width of each group in an X position 193 | let group_gap = bar.group_gap.get().clamp(0.0, 1.0); 194 | let group_width = width / bars.get() as f64; 195 | let group_width_inner = group_width * (1.0 - group_gap); 196 | let group_gap = group_width * group_gap; 197 | 198 | let offset = group_gap / 2.0 - width / 2.0; 199 | positions 200 | .iter() 201 | .map(|&(x, y)| { 202 | let height = bottom_y - y; 203 | let (y, height) = if height.is_sign_positive() { 204 | (y, height) 205 | } else { 206 | (bottom_y, -height) // Negative data point 207 | }; 208 | view! { 209 | 214 | } 215 | .into_any() 216 | }) 217 | .collect_view() 218 | .into_any() 219 | }) 220 | }; 221 | view! { 222 | 225 | {rects} 226 | 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/line/interpolation.rs: -------------------------------------------------------------------------------- 1 | /// Line interpolation. This is used to determine how to draw the line between points. 2 | #[derive(Copy, Clone, Debug, Default, PartialEq)] 3 | #[non_exhaustive] 4 | pub enum Interpolation { 5 | /// Linear interpolation draws a straight line between points. The simplest of methods. 6 | Linear, 7 | /// Step interpolation only uses horizontal and vertical lines to connect two points. 8 | Step(Step), 9 | /// Cubic monotone interpolation smooths the line between points. Avoids spurious oscillations.[^Steffen] 10 | /// 11 | /// [^Steffen]: Steffen, M., “A simple method for monotonic interpolation in one dimension.”, Astronomy and Astrophysics, vol. 239, pp. 443–450, 1990. 12 | #[default] 13 | Monotone, 14 | } 15 | 16 | /// Step interpolation only uses horizontal and vertical lines to connect two points. We have a choice of where to put the "corner" of the step. 17 | #[derive(Copy, Clone, Debug, Default, PartialEq)] 18 | #[non_exhaustive] 19 | pub enum Step { 20 | /// Moves across the horizontal plane first then the vertical. 21 | #[default] 22 | Horizontal, 23 | /// Moves midway across the horizontal plane first, then all of the vertical, then the rest of the horizontal. When chained with other steps, creates a single step but could make the data point less obvious. 24 | HorizontalMiddle, 25 | /// Moves across the vertical plane first then the horizontal. 26 | Vertical, 27 | /// Similar to [Step::HorizontalMiddle] but moves midway across the vertical plane first, then all of the horizontal, then the rest of the vertical. 28 | VerticalMiddle, 29 | } 30 | 31 | impl std::str::FromStr for Interpolation { 32 | type Err = String; 33 | 34 | fn from_str(s: &str) -> Result { 35 | match s { 36 | "linear" => Ok(Self::Linear), 37 | "step-horizontal" => Ok(Self::Step(Step::Horizontal)), 38 | "step-horizontal-middle" => Ok(Self::Step(Step::HorizontalMiddle)), 39 | "step-vertical" => Ok(Self::Step(Step::Vertical)), 40 | "step-vertical-middle" => Ok(Self::Step(Step::VerticalMiddle)), 41 | "monotone" => Ok(Self::Monotone), 42 | _ => Err(format!("unknown line interpolation: `{}`", s)), 43 | } 44 | } 45 | } 46 | 47 | impl std::fmt::Display for Interpolation { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | match self { 50 | Self::Linear => write!(f, "linear"), 51 | Self::Step(Step::Horizontal) => write!(f, "step-horizontal"), 52 | Self::Step(Step::HorizontalMiddle) => write!(f, "step-horizontal-middle"), 53 | Self::Step(Step::Vertical) => write!(f, "step-vertical"), 54 | Self::Step(Step::VerticalMiddle) => write!(f, "step-vertical-middle"), 55 | Self::Monotone => write!(f, "monotone"), 56 | } 57 | } 58 | } 59 | 60 | impl From for Interpolation { 61 | fn from(step: Step) -> Self { 62 | Self::Step(step) 63 | } 64 | } 65 | 66 | impl Interpolation { 67 | pub(super) fn path(self, points: &[(f64, f64)]) -> String { 68 | match self { 69 | Self::Linear => linear(points), 70 | Self::Step(step) => step.path(points), 71 | Self::Monotone => monotone(points), 72 | } 73 | } 74 | } 75 | 76 | fn linear(points: &[(f64, f64)]) -> String { 77 | let mut need_move = true; 78 | points 79 | .iter() 80 | .map(|(x, y)| { 81 | if x.is_nan() || y.is_nan() { 82 | need_move = true; 83 | "".to_string() 84 | } else if need_move { 85 | need_move = false; 86 | format!("M {} {} ", x, y) 87 | } else { 88 | format!("L {} {} ", x, y) 89 | } 90 | }) 91 | .collect::() 92 | } 93 | 94 | impl Step { 95 | fn path(self, points: &[(f64, f64)]) -> String { 96 | let mut prev: Option<(f64, f64)> = None; 97 | points 98 | .iter() 99 | .map(|&(x, y)| { 100 | if x.is_nan() || y.is_nan() { 101 | prev = None; 102 | "".to_string() 103 | } else if let Some((prev_x, prev_y)) = prev { 104 | prev = Some((x, y)); 105 | match self { 106 | Self::Horizontal => format!("H {} V {} ", x, y), 107 | Self::HorizontalMiddle => { 108 | format!("H {} V {} H {} ", (x + prev_x) / 2.0, y, x) 109 | } 110 | Self::Vertical => format!("V {} H {} ", y, x), 111 | Self::VerticalMiddle => { 112 | format!("V {} H {} V {} ", (y + prev_y) / 2.0, x, y) 113 | } 114 | } 115 | } else { 116 | prev = Some((x, y)); 117 | format!("M {} {} ", x, y) 118 | } 119 | }) 120 | .collect::() 121 | } 122 | } 123 | 124 | /* 125 | Implementation from "A simple method for monotonic interpolation in one dimension". [^Steffen] 126 | 127 | In Fortran: 128 | y1(i) = (sign(1.0, s[i-1]) + sign(1.0, s[i])) * min(abs(s[i-1]), 0.5 * abs(p[i])) 129 | Where: 130 | s[i] = (y[i+1] - y[i]) / (x[i+1] - x[i]) 131 | p[i] = (s[i-1]h[i] + s[i]h[i-1]) / (h[i-1] + h[i]) 132 | h[i] = x[i+1] - x[i] 133 | 134 | In Rust: 135 | y(i) = (s[i-1].signum() + s[i].signum()) * s[i-1].abs().min(0.5 * p[i].abs()) 136 | */ 137 | fn monotone(points: &[(f64, f64)]) -> String { 138 | let mut path = String::with_capacity(points.len()); 139 | for i in 0..points.len() { 140 | let (x_prev, y_prev) = get_or_nan(points, i.checked_sub(1)); 141 | let (x, y) = points[i]; 142 | let (x_next, y_next) = get_or_nan(points, i.checked_add(1)); 143 | // Path command 144 | let cmd = if x.is_nan() || y.is_nan() { 145 | // Inbetween segments 146 | "".to_string() 147 | } else if x_prev.is_nan() || y_prev.is_nan() { 148 | // Start of a new segment 149 | format!("M {x},{y} ") 150 | } else if x_next.is_nan() || y_next.is_nan() { 151 | // End of a segment 152 | format!("L {x},{y} ") 153 | } else { 154 | let tangent = tangent(x_prev, x, x_next, y_prev, y, y_next); 155 | let dx = (x - x_prev) / 3.0; 156 | let x_c = x - dx; 157 | let y_c = y - dx * tangent; 158 | format!("S {x_c},{y_c} {x},{y} ") 159 | }; 160 | path.push_str(&cmd); 161 | } 162 | path 163 | } 164 | 165 | fn get_or_nan(points: &[(f64, f64)], i: Option) -> (f64, f64) { 166 | i.and_then(|i| points.get(i).copied()) 167 | .unwrap_or((f64::NAN, f64::NAN)) 168 | } 169 | 170 | fn slope(x: f64, y: f64, x_next: f64, y_next: f64) -> f64 { 171 | (y_next - y) / (x_next - x) 172 | } 173 | 174 | fn tangent(x_prev: f64, x: f64, x_next: f64, y_prev: f64, y: f64, y_next: f64) -> f64 { 175 | let slope_prev = slope(x_prev, y_prev, x, y); 176 | let slope = slope(x, y, x_next, y_next); 177 | // Parabola 178 | let dist_prev = x - x_prev; 179 | let dist = x_next - x; 180 | let para = (slope_prev * dist + slope * dist_prev) / (dist_prev + dist); 181 | // Tangent 182 | (slope_prev.signum() + slope.signum()) * slope_prev.abs().min(0.5 * para.abs()) 183 | } 184 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/stack.rs: -------------------------------------------------------------------------------- 1 | use super::{ApplyUseSeries, GetYValue, IntoUseLine, SeriesAcc, UseY}; 2 | use crate::{ 3 | colours::{Colour, ColourScheme, BATLOW}, 4 | Line, 5 | }; 6 | use leptos::prelude::*; 7 | use std::sync::Arc; 8 | 9 | /// Default colour scheme for stack. Assumes a light background with dark values for high values. 10 | pub const STACK_COLOUR_SCHEME: [Colour; 10] = BATLOW; 11 | 12 | /// Draws a stack of lines on top of each other. 13 | /// 14 | /// # Example 15 | /// ```rust 16 | /// # use leptos_chartistry::*; 17 | /// # struct MyData { x: f64, y1: f64, y2: f64 } 18 | /// let stack = Stack::new() 19 | /// .line(Line::new(|data: &MyData| data.y1).with_name("fairies")) 20 | /// .line(Line::new(|data: &MyData| data.y2).with_name("pixies")); 21 | /// ``` 22 | /// See this in action with the [stacked line chart example](https://feral-dot-io.github.io/leptos-chartistry/examples.html#stacked-line-chart). 23 | #[derive(Clone)] 24 | #[non_exhaustive] 25 | pub struct Stack { 26 | lines: Vec>, 27 | /// Colour scheme for the stack. Interpolates colours across the whole scheme. 28 | pub colours: RwSignal, 29 | } 30 | 31 | impl Stack { 32 | /// Create a new empty stack. 33 | pub fn new() -> Self { 34 | Self::default() 35 | } 36 | 37 | /// Adds a line to the stack. 38 | pub fn line(mut self, line: impl Into>) -> Self { 39 | self.lines.push(line.into()); 40 | self 41 | } 42 | 43 | /// Gets the current number of lines in the stack. 44 | pub fn len(&self) -> usize { 45 | self.lines.len() 46 | } 47 | 48 | /// Returns true if there are no lines in the stack. 49 | pub fn is_empty(&self) -> bool { 50 | self.lines.is_empty() 51 | } 52 | 53 | /// Sets the colour scheme for the stack. 54 | pub fn with_colours(self, colours: impl Into) -> Self { 55 | self.colours.set(colours.into()); 56 | self 57 | } 58 | } 59 | 60 | impl Default for Stack { 61 | fn default() -> Self { 62 | Self { 63 | lines: Vec::new(), 64 | colours: RwSignal::new(ColourScheme::from(STACK_COLOUR_SCHEME).invert()), 65 | } 66 | } 67 | } 68 | 69 | impl>> From for Stack { 70 | fn from(lines: I) -> Self { 71 | let mut stack = Self::default(); 72 | for line in lines { 73 | stack = stack.line(line); 74 | } 75 | stack 76 | } 77 | } 78 | 79 | impl ApplyUseSeries for Stack { 80 | fn apply_use_series(self: Arc, series: &mut SeriesAcc) { 81 | let colours = self.colours; 82 | let total_lines = self.lines.len(); 83 | let mut previous = Vec::with_capacity(total_lines); 84 | for (id, line) in self.lines.clone().into_iter().enumerate() { 85 | let colour = Memo::new(move |_| colours.get().interpolate(id, total_lines)); 86 | let line = StackedLine { 87 | line, 88 | previous: previous.clone(), 89 | }; 90 | // Add line 91 | let get_y = series.push_line(colour, line); 92 | // Sum next line with this one 93 | previous.push(get_y); 94 | } 95 | } 96 | } 97 | 98 | #[derive(Clone)] 99 | struct StackedLine { 100 | line: Line, 101 | previous: Vec>>, 102 | } 103 | 104 | #[derive(Clone)] 105 | struct UseStackLine { 106 | line: Arc>, 107 | previous: Vec>>, 108 | } 109 | 110 | impl IntoUseLine for StackedLine { 111 | fn into_use_line(self, id: usize, colour: Memo) -> (UseY, Arc>) { 112 | let (line, get_y) = self.line.into_use_line(id, colour); 113 | let get_y = Arc::new(UseStackLine { 114 | line: get_y, 115 | previous: self.previous.clone(), 116 | }); 117 | (line, get_y) 118 | } 119 | } 120 | 121 | impl GetYValue for UseStackLine { 122 | fn value(&self, t: &T) -> f64 { 123 | self.line.value(t) 124 | } 125 | 126 | fn stacked_value(&self, t: &T) -> f64 { 127 | self.previous 128 | .iter() 129 | .chain(std::iter::once(&self.line)) 130 | .map(|get_y| get_y.value(t)) 131 | .filter(|v| v.is_normal()) 132 | .sum() 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/use_data/data.rs: -------------------------------------------------------------------------------- 1 | use super::Range; 2 | use crate::{ 3 | series::{GetX, GetY}, 4 | Tick, 5 | }; 6 | use std::collections::HashMap; 7 | 8 | #[derive(Clone, Debug, PartialEq)] 9 | #[non_exhaustive] 10 | pub struct Data { 11 | data_x: Vec, 12 | data_y: Vec>, 13 | 14 | // Data index: X position to data 15 | x_to_data: Vec, 16 | // Rendering data 17 | coords: HashMap>, 18 | 19 | range_x: Range, 20 | range_y: Range, 21 | } 22 | 23 | impl Data { 24 | pub fn new(get_x: GetX, get_ys: HashMap>, data: &[T]) -> Self { 25 | let cap = data.len(); 26 | let y_cap = get_ys.len(); 27 | 28 | // Empty positions 29 | let mut built = Self { 30 | data_x: Vec::with_capacity(cap), 31 | data_y: Vec::with_capacity(cap), 32 | x_to_data: Vec::with_capacity(cap * y_cap), 33 | coords: HashMap::with_capacity(cap), 34 | range_x: Range::default(), 35 | range_y: Range::default(), 36 | }; 37 | 38 | for datum in data { 39 | // X 40 | let x = (get_x)(datum); 41 | let x_position = x.position(); 42 | built.range_x.update(&x); 43 | built.x_to_data.push(x_position); 44 | 45 | // Y 46 | let mut y_data = HashMap::with_capacity(y_cap); 47 | for (&id, get_y) in &get_ys { 48 | let y = get_y.value(datum); 49 | // Note: cumulative can differ from Y when stacked 50 | let y_stacked = get_y.stacked_value(datum); 51 | built.range_y.update(&y_stacked); 52 | 53 | // Insert 54 | y_data.insert(id, y); 55 | built 56 | .coords 57 | .entry(id) 58 | .or_insert_with(|| Vec::with_capacity(cap)) 59 | .push((x_position, y_stacked.position())); 60 | } 61 | 62 | // Insert 63 | built.data_x.push(x); 64 | built.data_y.push(y_data); 65 | } 66 | 67 | built 68 | } 69 | 70 | pub fn len(&self) -> usize { 71 | self.data_x.len() 72 | } 73 | 74 | pub fn range_x(&self) -> Range { 75 | self.range_x.clone() 76 | } 77 | 78 | pub fn range_y(&self) -> Range { 79 | self.range_y.clone() 80 | } 81 | 82 | /// Finds the index of the _nearest_ position to the given X. Returns None if no data. 83 | fn nearest_index(&self, pos_x: f64) -> Option { 84 | // No values 85 | if self.x_to_data.is_empty() { 86 | return None; 87 | } 88 | // Find index after pos 89 | let index = self.x_to_data.partition_point(|&v| v < pos_x); 90 | // No value before 91 | if index == 0 { 92 | return Some(0); 93 | } 94 | // No value ahead 95 | if index == self.x_to_data.len() { 96 | return Some(index - 1); 97 | } 98 | // Find closest index 99 | let ahead = self.x_to_data[index] - pos_x; 100 | let before = pos_x - self.x_to_data[index - 1]; 101 | if ahead < before { 102 | Some(index) 103 | } else { 104 | Some(index - 1) 105 | } 106 | } 107 | 108 | pub fn nearest_data_x(&self, pos_x: f64) -> Option { 109 | self.nearest_index(pos_x) 110 | .map(|index| self.data_x[index].clone()) 111 | } 112 | 113 | pub fn nearest_data_y(&self, pos_x: f64) -> HashMap { 114 | self.nearest_index(pos_x) 115 | .map(|index| self.data_y[index].clone()) 116 | .unwrap_or_default() 117 | } 118 | 119 | /// Given an arbitrary (unaligned to data) X position, find the nearest X position aligned to data. Returns `f64::NAN` if no data. Note a position covers a range dependent on the chart width. 120 | pub fn nearest_position_x(&self, pos_x: f64) -> Option { 121 | self.nearest_index(pos_x).map(|index| self.x_to_data[index]) 122 | } 123 | 124 | pub fn series_positions(&self, id: usize) -> Vec<(f64, f64)> { 125 | self.coords.get(&id).cloned().unwrap_or_default() 126 | } 127 | } 128 | 129 | #[cfg(test)] 130 | mod tests { 131 | use super::*; 132 | use std::sync::Arc; 133 | 134 | #[derive(Clone, Debug, PartialEq)] 135 | struct MyData { 136 | x: f64, 137 | y1: f64, 138 | y2: f64, 139 | } 140 | 141 | impl MyData { 142 | const fn new(x: f64, y1: f64, y2: f64) -> Self { 143 | Self { x, y1, y2 } 144 | } 145 | } 146 | 147 | const DATA: &[MyData] = &[ 148 | MyData::new(1.0, 2.0, 3.0), 149 | MyData::new(4.0, 5.0, 6.0), 150 | MyData::new(7.0, 8.0, 9.0), 151 | ]; 152 | 153 | fn test_data(data: &[MyData]) -> Data { 154 | let mut get_ys = HashMap::>::new(); 155 | get_ys.insert(66, Arc::new(|d: &MyData| d.y1)); 156 | get_ys.insert(5, Arc::new(|d: &MyData| d.y2)); 157 | 158 | Data::new(Arc::new(|d: &MyData| d.x), get_ys, data) 159 | } 160 | 161 | #[test] 162 | fn test_data_new() { 163 | let data = test_data(DATA); 164 | // Data 165 | assert_eq!(data.data_x, vec![1.0, 4.0, 7.0]); 166 | assert_eq!( 167 | data.data_y, 168 | vec![ 169 | HashMap::from([(66, 2.0), (5, 3.0)]), 170 | HashMap::from([(66, 5.0), (5, 6.0)]), 171 | HashMap::from([(66, 8.0), (5, 9.0)]), 172 | ] 173 | ); 174 | // Positions 175 | assert_eq!(data.x_to_data, vec![1.0, 4.0, 7.0]); 176 | assert_eq!( 177 | data.coords, 178 | HashMap::from([ 179 | (66, vec![(1.0, 2.0), (4.0, 5.0), (7.0, 8.0)]), 180 | (5, vec![(1.0, 3.0), (4.0, 6.0), (7.0, 9.0)]), 181 | ]) 182 | ); 183 | // Ranges 184 | assert_eq!(data.range_x.range(), Some((&1.0, &7.0))); 185 | assert_eq!(data.range_x.positions(), Some((1.0, 7.0))); 186 | assert_eq!(data.range_y.range(), Some((&2.0, &9.0))); 187 | assert_eq!(data.range_y.positions(), Some((2.0, 9.0))); 188 | } 189 | 190 | #[test] 191 | fn test_nearest_index() { 192 | let data = test_data(DATA); 193 | // Before data 194 | assert_eq!(data.nearest_index(0.5), Some(0)); 195 | // After data 196 | assert_eq!(data.nearest_index(8.0), Some(2)); 197 | // Closest 198 | assert_eq!(data.nearest_index(3.0), Some(1)); 199 | assert_eq!(data.nearest_index(4.0), Some(1)); 200 | assert_eq!(data.nearest_index(5.0), Some(1)); 201 | assert_eq!(data.nearest_index(2.0), Some(0)); 202 | assert_eq!(data.nearest_index(6.5), Some(2)); 203 | } 204 | 205 | #[test] 206 | fn test_nearest_index_empty() { 207 | let data = test_data(&[]); 208 | assert_eq!(data.nearest_index(0.5), None); 209 | } 210 | 211 | #[test] 212 | fn test_nearest_data_x() { 213 | let data = test_data(DATA); 214 | assert_eq!(data.nearest_data_x(0.5), Some(1.0)); 215 | assert_eq!(data.nearest_data_x(8.0), Some(7.0)); 216 | assert_eq!(data.nearest_data_x(3.0), Some(4.0)); 217 | assert_eq!(data.nearest_data_x(4.0), Some(4.0)); 218 | } 219 | 220 | #[test] 221 | fn test_nearest_aligned_position_x() { 222 | let data = test_data(DATA); 223 | assert_eq!(data.nearest_position_x(0.5), Some(1.0)); 224 | assert_eq!(data.nearest_position_x(8.0), Some(7.0)); 225 | assert_eq!(data.nearest_position_x(3.0), Some(4.0)); 226 | assert_eq!(data.nearest_position_x(4.0), Some(4.0)); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/use_data/mod.rs: -------------------------------------------------------------------------------- 1 | mod data; 2 | mod range; 3 | 4 | pub use range::Range; 5 | 6 | use crate::{ 7 | series::{use_y::RenderUseY, UseY}, 8 | state::State, 9 | Series, Tick, 10 | }; 11 | use data::Data; 12 | use leptos::prelude::*; 13 | 14 | #[derive(Clone)] 15 | #[non_exhaustive] 16 | pub struct UseData { 17 | data: Memo>, 18 | pub len: Memo, 19 | pub series: Memo>, 20 | pub includes_bars: Memo, 21 | pub range_x: Memo>, 22 | pub range_y: Memo>, 23 | } 24 | 25 | impl UseData { 26 | pub fn new( 27 | series: Series, 28 | data: Signal>, 29 | ) -> UseData { 30 | let lines = series.to_use_lines(); 31 | 32 | // Data values 33 | let data = { 34 | let lines = lines.clone(); 35 | Memo::new(move |_| { 36 | let get_x = series.get_x.clone(); 37 | data.with(|data| { 38 | Data::new( 39 | get_x, 40 | lines 41 | .clone() 42 | .into_iter() 43 | .map(|(use_y, get_y)| (use_y.id, get_y)) 44 | .collect(), 45 | data, 46 | ) 47 | }) 48 | }) 49 | }; 50 | 51 | // Range signals 52 | let range_x: Memo> = Memo::new(move |_| { 53 | data.with(|data| data.range_x()) 54 | .maybe_update(vec![series.min_x.get(), series.max_x.get()]) 55 | }); 56 | let range_y: Memo> = Memo::new(move |_| { 57 | data.with(|data| data.range_y()) 58 | .maybe_update(vec![series.min_y.get(), series.max_y.get()]) 59 | }); 60 | 61 | // Sort series by name 62 | let series = { 63 | let (lines, _): (Vec<_>, Vec<_>) = lines.into_iter().unzip(); 64 | Memo::new(move |_| { 65 | let mut lines = lines.clone(); 66 | lines.sort_by_key(|line| line.name.get()); 67 | lines 68 | }) 69 | }; 70 | let includes_bars = 71 | Memo::new(move |_| series.get().iter().any(|use_y| use_y.bar().is_some())); 72 | 73 | UseData { 74 | data, 75 | len: Memo::new(move |_| data.with(|data| data.len())), 76 | series, 77 | includes_bars, 78 | range_x, 79 | range_y, 80 | } 81 | } 82 | } 83 | 84 | impl UseData { 85 | pub fn nearest_data_x(&self, pos_x: Memo) -> Memo> { 86 | let data = self.data; 87 | Memo::new(move |_| data.with(|data| data.nearest_data_x(pos_x.get()))) 88 | } 89 | 90 | pub fn nearest_position_x(&self, pos_x: Memo) -> Memo> { 91 | let data = self.data; 92 | Memo::new(move |_| data.with(|data| data.nearest_position_x(pos_x.get()))) 93 | } 94 | 95 | // TODO: this can never be None 96 | pub fn nearest_data_y(&self, pos_x: Memo) -> Memo)>> { 97 | let series = self.series; 98 | let data = self.data; 99 | Memo::new(move |_| { 100 | let y_values = data.with(|data| data.nearest_data_y(pos_x.get())); 101 | series 102 | .get() 103 | .into_iter() 104 | .map(|line| { 105 | let y_value = y_values.get(&line.id).cloned(); 106 | (line, y_value) 107 | }) 108 | .collect::>() 109 | }) 110 | } 111 | } 112 | 113 | #[component] 114 | pub fn RenderData(state: State) -> impl IntoView { 115 | let data = state.pre.data.clone(); 116 | let mk_svg_coords = move |id| { 117 | Signal::derive(move || { 118 | let proj = state.projection.get(); 119 | data.data.with(|data| { 120 | data.series_positions(id) 121 | .into_iter() 122 | .map(|(x, y)| proj.position_to_svg(x, y)) 123 | .collect::>() 124 | }) 125 | }) 126 | }; 127 | 128 | view! { 129 | 130 | 134 | 135 | 136 | 137 | }.into_any() 138 | } 139 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/use_data/range.rs: -------------------------------------------------------------------------------- 1 | use crate::Tick; 2 | 3 | #[derive(Clone, Debug, PartialEq)] 4 | pub struct Range(Option>); 5 | 6 | #[derive(Clone, Debug, PartialEq)] 7 | #[non_exhaustive] 8 | pub struct InnerRange { 9 | pub min: (T, f64), 10 | pub max: (T, f64), 11 | } 12 | 13 | impl Default for Range { 14 | fn default() -> Self { 15 | Self(None) 16 | } 17 | } 18 | 19 | impl Range { 20 | pub fn update(&mut self, t: &T) 21 | where 22 | T: Tick, 23 | { 24 | if let Some(range) = self.0.as_mut() { 25 | range.update(t); 26 | } else { 27 | *self = Range(InnerRange::new(t)); 28 | } 29 | } 30 | 31 | pub fn maybe_update(mut self, ts: Vec>) -> Self 32 | where 33 | T: Tick, 34 | { 35 | ts.into_iter().flatten().for_each(|t| { 36 | self.update(&t); 37 | }); 38 | self 39 | } 40 | 41 | // Returns the (min, max) of T if it exists 42 | pub fn range(&self) -> Option<(&T, &T)> { 43 | self.0.as_ref().map(|r| (&r.min.0, &r.max.0)) 44 | } 45 | 46 | // Returns the (min, max) of T's position if it exists 47 | pub fn positions(&self) -> Option<(f64, f64)> { 48 | self.0.as_ref().map(|r| (r.min.1, r.max.1)) 49 | } 50 | } 51 | 52 | impl InnerRange { 53 | pub fn new(t: &T) -> Option { 54 | Self::position(t).map(|pos| Self { 55 | min: (t.clone(), pos), 56 | max: (t.clone(), pos), 57 | }) 58 | } 59 | 60 | // Gets the position of T. Returns None on f64::NaN 61 | fn position(t: &T) -> Option { 62 | let pos = t.position(); 63 | // Ignore NaN. Similar behaviour to f64::min and f64::max 64 | if pos.is_nan() { 65 | return None; 66 | } 67 | Some(pos) 68 | } 69 | 70 | pub fn update(&mut self, t: &T) { 71 | if let Some(pos) = Self::position(t) { 72 | if *t < self.min.0 { 73 | self.min = (t.clone(), pos); 74 | } else if *t > self.max.0 { 75 | self.max = (t.clone(), pos); 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /leptos-chartistry/src/series/use_y.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | bar::{RenderBar, UseBar}, 3 | line::{RenderLine, UseLine}, 4 | }; 5 | use crate::{bounds::Bounds, debug::DebugRect, state::State, Tick}; 6 | use leptos::{either::Either, prelude::*}; 7 | 8 | #[derive(Clone, Debug, PartialEq)] 9 | #[non_exhaustive] 10 | pub struct UseY { 11 | pub id: usize, 12 | pub name: RwSignal, 13 | desc: UseYDesc, 14 | } 15 | 16 | #[derive(Clone, Debug, PartialEq)] 17 | enum UseYDesc { 18 | Line(UseLine), 19 | Bar(UseBar), 20 | } 21 | 22 | impl UseY { 23 | pub(super) fn new_line(id: usize, name: RwSignal, line: UseLine) -> Self { 24 | let desc = UseYDesc::Line(line); 25 | Self { id, name, desc } 26 | } 27 | 28 | pub(super) fn new_bar(id: usize, name: RwSignal, bar: UseBar) -> Self { 29 | let desc = UseYDesc::Bar(bar); 30 | Self { id, name, desc } 31 | } 32 | 33 | pub(crate) fn bar(&self) -> Option<&UseBar> { 34 | match &self.desc { 35 | UseYDesc::Bar(bar) => Some(bar), 36 | _ => None, 37 | } 38 | } 39 | 40 | fn taster_bounds(font_height: Memo, font_width: Memo) -> Memo { 41 | Memo::new(move |_| Bounds::new(font_width.get() * 2.5, font_height.get())) 42 | } 43 | 44 | pub fn snippet_width(font_height: Memo, font_width: Memo) -> Signal { 45 | let taster_bounds = Self::taster_bounds(font_height, font_width); 46 | Signal::derive(move || taster_bounds.get().width() + font_width.get()) 47 | } 48 | } 49 | 50 | #[component] 51 | pub(super) fn RenderUseY( 52 | use_y: UseY, 53 | state: State, 54 | positions: Signal>, 55 | ) -> impl IntoView { 56 | let desc = use_y.desc.clone(); 57 | match desc { 58 | UseYDesc::Line(line) => view! { 59 | 65 | } 66 | .into_any(), 67 | UseYDesc::Bar(bar) => { 68 | view! {}.into_any() 69 | } 70 | } 71 | } 72 | 73 | #[component] 74 | pub fn Snippet(series: UseY, state: State) -> impl IntoView { 75 | let debug = state.pre.debug; 76 | let name = series.name; 77 | view! { 78 |
79 | 80 | 81 | {name} 82 |
83 | } 84 | .into_any() 85 | } 86 | 87 | #[component] 88 | fn Taster(series: UseY, state: State) -> impl IntoView { 89 | const Y_OFFSET: f64 = 2.0; 90 | let debug = state.pre.debug; 91 | let font_width = state.pre.font_width; 92 | let right_padding = Signal::derive(move || font_width.get() / 2.0); 93 | let bounds = UseY::taster_bounds(state.pre.font_height, font_width); 94 | // Mock positions from left to right of our bounds 95 | let positions = Signal::derive(move || { 96 | let bounds = bounds.get(); 97 | let y = bounds.centre_y() + Y_OFFSET; 98 | vec![(bounds.left_x(), y), (bounds.right_x(), y)] 99 | }); 100 | 101 | let desc = match &series.desc { 102 | UseYDesc::Line(line) => { 103 | // One marker in the middle 104 | let markers = Signal::derive(move || { 105 | let bounds = bounds.get(); 106 | vec![(bounds.centre_x(), bounds.centre_y() + Y_OFFSET)] 107 | }); 108 | Either::Left(view! { 109 | 115 | }) 116 | } 117 | UseYDesc::Bar(bar) => Either::Right(view! { 118 | 119 | }), 120 | }; 121 | 122 | view! { 123 | 131 | 132 | {desc} 133 | 134 | } 135 | .into_any() 136 | } 137 | -------------------------------------------------------------------------------- /leptos-chartistry/src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | layout::Layout, projection::Projection, series::UseData, use_watched_node::UseWatchedNode, 3 | Padding, Tick, 4 | }; 5 | use leptos::prelude::*; 6 | 7 | #[derive(Clone)] 8 | #[non_exhaustive] 9 | pub struct PreState { 10 | pub debug: Signal, 11 | pub font_height: Memo, 12 | pub font_width: Memo, 13 | pub padding: Signal, 14 | pub data: UseData, 15 | } 16 | 17 | #[derive(Clone)] 18 | #[non_exhaustive] 19 | pub struct State { 20 | pub pre: PreState, 21 | pub layout: Layout, 22 | pub projection: Memo, 23 | 24 | pub svg_zero: Memo<(f64, f64)>, 25 | 26 | /// Mouse page position 27 | pub mouse_page: Signal<(f64, f64)>, 28 | /// Mouse page position relative to chart 29 | pub mouse_chart: Signal<(f64, f64)>, 30 | /// Mouse over inner chart? 31 | pub hover_inner: Signal, 32 | /// X mouse coord in data position space 33 | pub hover_position_x: Memo, 34 | } 35 | 36 | impl PreState { 37 | pub fn new( 38 | debug: Signal, 39 | font_height: Memo, 40 | font_width: Memo, 41 | padding: Signal, 42 | data: UseData, 43 | ) -> Self { 44 | Self { 45 | debug, 46 | font_height, 47 | font_width, 48 | padding, 49 | data, 50 | } 51 | } 52 | } 53 | 54 | impl State { 55 | pub fn new( 56 | pre: PreState, 57 | node: &UseWatchedNode, 58 | layout: Layout, 59 | proj: Memo, 60 | ) -> Self { 61 | // Mouse 62 | let mouse_chart = node.mouse_chart; 63 | let hover_inner = node.mouse_hover_inner(layout.inner); 64 | 65 | // Data 66 | let hover_position = Memo::new(move |_| { 67 | let (mouse_x, mouse_y) = mouse_chart.get(); 68 | proj.get().svg_to_position(mouse_x, mouse_y) 69 | }); 70 | let hover_position_x = Memo::new(move |_| hover_position.get().0); 71 | 72 | Self { 73 | pre, 74 | layout, 75 | projection: proj, 76 | svg_zero: Memo::new(move |_| proj.get().position_to_svg(0.0, 0.0)), 77 | 78 | mouse_page: node.mouse_page, 79 | mouse_chart, 80 | hover_inner, 81 | hover_position_x, 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /leptos-chartistry/src/ticks/gen/mod.rs: -------------------------------------------------------------------------------- 1 | mod aligned_floats; 2 | mod span; 3 | mod timestamps; 4 | 5 | pub use aligned_floats::AlignedFloats; 6 | pub use span::{HorizontalSpan, TickFormatFn, VerticalSpan}; 7 | pub use timestamps::{Period, Timestamps}; 8 | 9 | use std::sync::Arc; 10 | 11 | pub trait Generator: Send + Sync { 12 | type Tick; 13 | 14 | fn generate( 15 | &self, 16 | first: &Self::Tick, 17 | last: &Self::Tick, 18 | span: &dyn Span, 19 | ) -> GeneratedTicks; 20 | } 21 | 22 | pub trait Span { 23 | fn length(&self) -> f64; 24 | fn consumed(&self, state: &dyn Format, ticks: &[Tick]) -> f64; 25 | } 26 | 27 | /// Formats a tick value into a string. The precise format will be picked by the tick generator. For example if [Timestamps] is used and is only showing years then the format will be `YYYY`. 28 | pub trait Format { 29 | /// Our tick value. 30 | type Tick; 31 | 32 | /// Formats a tick into a string according to the tick generator used. 33 | fn format(&self, value: &Self::Tick) -> String; 34 | } 35 | 36 | #[derive(Clone)] 37 | #[non_exhaustive] 38 | pub struct GeneratedTicks { 39 | pub state: Arc + Send + Sync>, 40 | pub ticks: Vec, 41 | } 42 | 43 | impl GeneratedTicks { 44 | pub fn new(state: impl Format + Send + Sync + 'static, ticks: Vec) -> Self { 45 | GeneratedTicks { 46 | state: Arc::new(state), 47 | ticks, 48 | } 49 | } 50 | } 51 | 52 | impl GeneratedTicks { 53 | pub fn none() -> GeneratedTicks { 54 | Self::new(NilState(std::marker::PhantomData), vec![]) 55 | } 56 | } 57 | 58 | // Dummy TickState that should never be called. Used with no ticks. 59 | struct NilState(std::marker::PhantomData); 60 | 61 | impl Format for NilState { 62 | type Tick = Tick; 63 | 64 | fn format(&self, _: &Self::Tick) -> String { 65 | "-".to_string() 66 | } 67 | } 68 | 69 | /// Note: PartialEq only compares the `ticks`. Meaning TickGen implementations must result in the same TickState when Ticks are equal. 70 | impl PartialEq for GeneratedTicks { 71 | fn eq(&self, other: &Self) -> bool { 72 | self.ticks == other.ticks 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /leptos-chartistry/src/ticks/gen/span.rs: -------------------------------------------------------------------------------- 1 | use super::{Format, Span}; 2 | use crate::Tick; 3 | use std::sync::Arc; 4 | 5 | pub struct VerticalSpan { 6 | avail_height: f64, 7 | line_height: f64, 8 | } 9 | 10 | impl VerticalSpan { 11 | pub fn new(line_height: f64, avail_height: f64) -> Self { 12 | Self { 13 | avail_height, 14 | line_height, 15 | } 16 | } 17 | } 18 | 19 | impl Span for VerticalSpan { 20 | fn length(&self) -> f64 { 21 | self.avail_height 22 | } 23 | 24 | fn consumed(&self, _: &dyn Format, ticks: &[Tick]) -> f64 { 25 | self.line_height * ticks.len() as f64 26 | } 27 | } 28 | 29 | pub type TickFormatFn = dyn (Fn(&Tick, &dyn Format) -> String) + Send + Sync; 30 | 31 | pub struct HorizontalSpan { 32 | font_width: f64, 33 | min_chars: usize, 34 | padding_width: f64, 35 | avail_width: f64, 36 | format: Arc>, 37 | } 38 | 39 | impl HorizontalSpan { 40 | pub fn new( 41 | font_width: f64, 42 | min_chars: usize, 43 | padding_width: f64, 44 | avail_width: f64, 45 | format: Arc>, 46 | ) -> Self { 47 | Self { 48 | font_width, 49 | min_chars, 50 | padding_width, 51 | avail_width, 52 | format, 53 | } 54 | } 55 | 56 | pub fn identity_format() -> Arc> { 57 | Arc::new(|tick, state| state.format(tick)) 58 | } 59 | } 60 | 61 | impl Span for HorizontalSpan { 62 | fn length(&self) -> f64 { 63 | self.avail_width 64 | } 65 | 66 | fn consumed(&self, state: &dyn Format, ticks: &[XY]) -> f64 { 67 | let max_chars = ticks 68 | .iter() 69 | .map(|tick| (self.format)(tick, state).len().max(self.min_chars)) 70 | .max() 71 | .unwrap_or_default(); 72 | let max_label_width = max_chars as f64 * self.font_width + self.padding_width * 2.0; 73 | max_label_width * ticks.len() as f64 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /leptos-chartistry/src/ticks/mod.rs: -------------------------------------------------------------------------------- 1 | mod gen; 2 | 3 | pub use gen::{ 4 | AlignedFloats, Format as TickFormat, GeneratedTicks, Generator as TickGen, HorizontalSpan, 5 | Period, TickFormatFn, Timestamps, VerticalSpan, 6 | }; 7 | 8 | use chrono::prelude::*; 9 | 10 | mod private { 11 | pub trait Sealed {} 12 | } 13 | 14 | /// A type that can be used as a tick on an axis. Try to rely on provided implementations. 15 | pub trait Tick: Clone + PartialEq + PartialOrd + Send + Sync + 'static + private::Sealed { 16 | /// Default tick generator used in tick labels. 17 | fn tick_label_generator() -> impl TickGen; 18 | 19 | /// Default tick generator used in tooltips. 20 | fn tooltip_generator() -> impl TickGen { 21 | Self::tick_label_generator() 22 | } 23 | 24 | /// Maps the tick to a position on the axis. Must be uniform. May return `f64::NAN` for missing data. 25 | fn position(&self) -> f64; 26 | } 27 | 28 | impl private::Sealed for f64 {} 29 | impl private::Sealed for DateTime {} 30 | 31 | impl Tick for f64 { 32 | fn tick_label_generator() -> impl TickGen { 33 | AlignedFloats::default() 34 | } 35 | 36 | fn position(&self) -> f64 { 37 | *self 38 | } 39 | } 40 | 41 | impl Tick for DateTime 42 | where 43 | Tz: TimeZone + Send + Sync + 'static, 44 | Tz::Offset: std::fmt::Display + Send + Sync, 45 | { 46 | fn tick_label_generator() -> impl TickGen { 47 | Timestamps::default() 48 | } 49 | 50 | fn tooltip_generator() -> impl TickGen { 51 | Timestamps::default().with_long_format() 52 | } 53 | 54 | fn position(&self) -> f64 { 55 | self.timestamp() as f64 + (self.timestamp_subsec_nanos() as f64 / 1e9) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /leptos-chartistry/src/use_watched_node.rs: -------------------------------------------------------------------------------- 1 | use crate::bounds::Bounds; 2 | use leptos::{html::Div, prelude::*}; 3 | use leptos_use::{ 4 | use_element_hover, use_mouse_with_options, use_resize_observer_with_options, UseMouseCoordType, 5 | UseMouseOptions, UseMouseSourceType, UseResizeObserverOptions, 6 | }; 7 | use std::convert::Infallible; 8 | use web_sys::ResizeObserverBoxOptions; 9 | 10 | #[derive(Clone, Debug)] 11 | #[non_exhaustive] 12 | pub struct UseWatchedNode { 13 | pub bounds: Signal>, 14 | pub mouse_page: Signal<(f64, f64)>, 15 | pub mouse_chart: Signal<(f64, f64)>, 16 | pub mouse_chart_hover: Signal, 17 | } 18 | 19 | pub fn use_watched_node(node: NodeRef
) -> UseWatchedNode { 20 | // Outer chart bounds -- dimensions for our root element inside the document 21 | // Note has issues around observing size changes. So wrap in a
22 | // Note also that the box_ option doesn't seem to work for us so wrap in another
23 | let (bounds, set_bounds) = signal::>(None); 24 | use_resize_observer_with_options( 25 | node, 26 | move |entries, _| { 27 | let rect = &entries[0].target().get_bounding_client_rect(); 28 | let rect = Bounds::new(rect.width(), rect.height()); 29 | set_bounds.set(Some(rect)) 30 | }, 31 | UseResizeObserverOptions { 32 | box_: Some(ResizeObserverBoxOptions::BorderBox), 33 | }, 34 | ); 35 | let bounds: Signal> = bounds.into(); 36 | 37 | // Mouse position 38 | let mouse_page = use_mouse_with_options( 39 | UseMouseOptions::default() 40 | .target(node) 41 | .coord_type(UseMouseCoordType::::Page) 42 | .reset_on_touch_ends(true), 43 | ); 44 | 45 | // Mouse absolute coords on page 46 | let mouse_page_type = mouse_page.source_type; 47 | let mouse_page = Signal::derive(move || { 48 | let x = mouse_page.x.get(); 49 | let y = mouse_page.y.get(); 50 | (x, y) 51 | }); 52 | 53 | // Mouse relative to SVG 54 | let mouse_client = use_mouse_with_options( 55 | UseMouseOptions::default() 56 | .target(node) 57 | .coord_type(UseMouseCoordType::::Client) 58 | .reset_on_touch_ends(true), 59 | ); 60 | let mouse_chart: Signal<_> = Memo::new(move |_| { 61 | let (left, top) = node 62 | .get() 63 | .map(|target| { 64 | let rect = target.get_bounding_client_rect(); 65 | (rect.left(), rect.top()) 66 | }) 67 | .unwrap_or_default(); 68 | let x = mouse_client.x.get() - left; 69 | let y: f64 = mouse_client.y.get() - top; 70 | (x, y) 71 | }) 72 | .into(); 73 | 74 | // Mouse inside SVG? 75 | let el_hover = use_element_hover(node); 76 | let mouse_chart_hover = Memo::new(move |_| { 77 | let (x, y) = mouse_chart.get(); 78 | mouse_page_type.get() != UseMouseSourceType::Unset 79 | && el_hover.get() 80 | && bounds 81 | .get() 82 | .map(|bounds| bounds.contains(x, y)) 83 | .unwrap_or(false) 84 | }) 85 | .into(); 86 | 87 | UseWatchedNode { 88 | bounds, 89 | mouse_page, 90 | mouse_chart, 91 | mouse_chart_hover, 92 | } 93 | } 94 | 95 | impl UseWatchedNode { 96 | // Mouse inside inner chart? 97 | pub fn mouse_hover_inner(&self, inner: Memo) -> Signal { 98 | let (mouse_rel, hover) = (self.mouse_chart, self.mouse_chart_hover); 99 | Memo::new(move |_| { 100 | let (x, y) = mouse_rel.get(); 101 | hover.get() && inner.get().contains(x, y) 102 | }) 103 | .into() 104 | } 105 | } 106 | --------------------------------------------------------------------------------